Integrate Platega balance top-ups

This commit is contained in:
Egor
2025-11-07 06:52:57 +03:00
parent 37c97de5d6
commit fba80b1a0d
17 changed files with 1604 additions and 1 deletions

View File

@@ -270,6 +270,20 @@ class Settings(BaseSettings):
PAL24_SBP_BUTTON_VISIBLE: bool = True
PAL24_CARD_BUTTON_VISIBLE: bool = True
PLATEGA_ENABLED: bool = False
PLATEGA_MERCHANT_ID: Optional[str] = None
PLATEGA_SECRET: Optional[str] = None
PLATEGA_BASE_URL: str = "https://app.platega.io"
PLATEGA_RETURN_URL: Optional[str] = None
PLATEGA_FAILED_URL: Optional[str] = None
PLATEGA_CURRENCY: str = "RUB"
PLATEGA_ACTIVE_METHODS: str = "2,10,11,12,13"
PLATEGA_MIN_AMOUNT_KOPEKS: int = 10000
PLATEGA_MAX_AMOUNT_KOPEKS: int = 100000000
PLATEGA_WEBHOOK_PATH: str = "/platega-webhook"
PLATEGA_WEBHOOK_HOST: str = "0.0.0.0"
PLATEGA_WEBHOOK_PORT: int = 8086
WATA_ENABLED: bool = False
WATA_BASE_URL: str = "https://api.wata.pro/api/h2h"
WATA_ACCESS_TOKEN: Optional[str] = None
@@ -917,6 +931,74 @@ class Settings(BaseSettings):
and self.PAL24_SHOP_ID is not None
)
def is_platega_enabled(self) -> bool:
return (
self.PLATEGA_ENABLED
and self.PLATEGA_MERCHANT_ID is not None
and self.PLATEGA_SECRET is not None
)
def get_platega_return_url(self) -> Optional[str]:
if self.PLATEGA_RETURN_URL:
return self.PLATEGA_RETURN_URL
if self.WEBHOOK_URL:
return f"{self.WEBHOOK_URL}/payment-success"
return None
def get_platega_failed_url(self) -> Optional[str]:
if self.PLATEGA_FAILED_URL:
return self.PLATEGA_FAILED_URL
if self.WEBHOOK_URL:
return f"{self.WEBHOOK_URL}/payment-failed"
return None
def get_platega_active_methods(self) -> List[int]:
raw_value = str(self.PLATEGA_ACTIVE_METHODS or "")
normalized = raw_value.replace(";", ",")
methods: list[int] = []
seen: set[int] = set()
for part in normalized.split(","):
part = part.strip()
if not part:
continue
try:
method_code = int(part)
except ValueError:
logger.warning("Некорректный код метода Platega: %s", part)
continue
if method_code in {2, 10, 11, 12, 13} and method_code not in seen:
methods.append(method_code)
seen.add(method_code)
if not methods:
return [2]
return methods
@staticmethod
def get_platega_method_definitions() -> Dict[int, Dict[str, str]]:
return {
2: {"name": "СБП (QR)", "title": "🏦 СБП (QR)"},
10: {"name": "Банковские карты (RUB)", "title": "💳 Карты (RUB)"},
11: {"name": "Банковские карты", "title": "💳 Банковские карты"},
12: {"name": "Международные карты", "title": "🌍 Международные карты"},
13: {"name": "Криптовалюта", "title": "🪙 Криптовалюта"},
}
def get_platega_method_display_name(self, method_code: int) -> str:
definitions = self.get_platega_method_definitions()
info = definitions.get(method_code)
if info and info.get("name"):
return info["name"]
return f"Метод {method_code}"
def get_platega_method_display_title(self, method_code: int) -> str:
definitions = self.get_platega_method_definitions()
info = definitions.get(method_code)
if not info:
return f"Platega {method_code}"
return info.get("title") or info.get("name") or f"Platega {method_code}"
def is_wata_enabled(self) -> bool:
return (
self.WATA_ENABLED

View File

@@ -0,0 +1,145 @@
"""CRUD-операции для платежей Platega."""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PlategaPayment
logger = logging.getLogger(__name__)
async def create_platega_payment(
db: AsyncSession,
*,
user_id: int,
amount_kopeks: int,
currency: str,
description: Optional[str],
status: str,
payment_method_code: int,
correlation_id: str,
platega_transaction_id: Optional[str],
redirect_url: Optional[str],
return_url: Optional[str],
failed_url: Optional[str],
payload: Optional[str],
metadata: Optional[dict[str, Any]] = None,
expires_at: Optional[datetime] = None,
) -> PlategaPayment:
payment = PlategaPayment(
user_id=user_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
status=status,
payment_method_code=payment_method_code,
correlation_id=correlation_id,
platega_transaction_id=platega_transaction_id,
redirect_url=redirect_url,
return_url=return_url,
failed_url=failed_url,
payload=payload,
metadata_json=metadata or {},
expires_at=expires_at,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(
"Создан Platega платеж #%s (tx=%s) на сумму %s копеек для пользователя %s",
payment.id,
platega_transaction_id,
amount_kopeks,
user_id,
)
return payment
async def get_platega_payment_by_id(
db: AsyncSession, payment_id: int
) -> Optional[PlategaPayment]:
result = await db.execute(
select(PlategaPayment).where(PlategaPayment.id == payment_id)
)
return result.scalar_one_or_none()
async def get_platega_payment_by_transaction_id(
db: AsyncSession, transaction_id: str
) -> Optional[PlategaPayment]:
result = await db.execute(
select(PlategaPayment).where(
PlategaPayment.platega_transaction_id == transaction_id
)
)
return result.scalar_one_or_none()
async def get_platega_payment_by_correlation_id(
db: AsyncSession, correlation_id: str
) -> Optional[PlategaPayment]:
result = await db.execute(
select(PlategaPayment).where(
PlategaPayment.correlation_id == correlation_id
)
)
return result.scalar_one_or_none()
async def update_platega_payment(
db: AsyncSession,
*,
payment: PlategaPayment,
status: Optional[str] = None,
is_paid: Optional[bool] = None,
paid_at: Optional[datetime] = None,
platega_transaction_id: Optional[str] = None,
redirect_url: Optional[str] = None,
callback_payload: Optional[dict[str, Any]] = None,
metadata: Optional[dict[str, Any]] = None,
expires_at: Optional[datetime] = None,
) -> PlategaPayment:
if status is not None:
payment.status = status
if is_paid is not None:
payment.is_paid = is_paid
if paid_at is not None:
payment.paid_at = paid_at
if platega_transaction_id and not payment.platega_transaction_id:
payment.platega_transaction_id = platega_transaction_id
if redirect_url is not None:
payment.redirect_url = redirect_url
if callback_payload is not None:
payment.callback_payload = callback_payload
if metadata is not None:
payment.metadata_json = metadata
if expires_at is not None:
payment.expires_at = expires_at
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
return payment
async def link_platega_payment_to_transaction(
db: AsyncSession,
*,
payment: PlategaPayment,
transaction_id: int,
) -> PlategaPayment:
payment.transaction_id = transaction_id
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
return payment

View File

@@ -82,6 +82,7 @@ class PaymentMethod(Enum):
MULENPAY = "mulenpay"
PAL24 = "pal24"
WATA = "wata"
PLATEGA = "platega"
MANUAL = "manual"
@@ -414,6 +415,56 @@ class WataPayment(Base):
)
class PlategaPayment(Base):
__tablename__ = "platega_payments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
platega_transaction_id = Column(String(255), unique=True, nullable=True, index=True)
correlation_id = Column(String(64), unique=True, nullable=False, index=True)
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default="RUB")
description = Column(Text, nullable=True)
payment_method_code = Column(Integer, nullable=False)
status = Column(String(50), nullable=False, default="PENDING")
is_paid = Column(Boolean, default=False)
paid_at = Column(DateTime, nullable=True)
redirect_url = Column(Text, nullable=True)
return_url = Column(Text, nullable=True)
failed_url = Column(Text, nullable=True)
payload = Column(String(255), nullable=True)
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
expires_at = Column(DateTime, nullable=True)
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
user = relationship("User", backref="platega_payments")
transaction = relationship("Transaction", backref="platega_payment")
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
def __repr__(self) -> str: # pragma: no cover - debug helper
return (
"<PlategaPayment(id={0}, transaction_id={1}, amount={2}₽, status={3}, method={4})>".format(
self.id,
self.platega_transaction_id,
self.amount_rubles,
self.status,
self.payment_method_code,
)
)
class PromoGroup(Base):
__tablename__ = "promo_groups"

View File

@@ -37,6 +37,8 @@ def _method_display(method: PaymentMethod) -> str:
return "Heleket"
if method == PaymentMethod.YOOKASSA:
return "YooKassa"
if method == PaymentMethod.PLATEGA:
return "Platega"
if method == PaymentMethod.CRYPTOBOT:
return "CryptoBot"
if method == PaymentMethod.TELEGRAM_STARS:
@@ -90,6 +92,18 @@ def _status_info(
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.PLATEGA:
mapping = {
"pending": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"inprogress": ("", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
"confirmed": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"failed": ("", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
"canceled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"cancelled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"expired": ("", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.HELEKET:
if status in {"pending", "created", "waiting", "check", "processing"}:
return "", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")
@@ -136,6 +150,8 @@ def _is_checkable(record: PendingPayment) -> bool:
return status in {"created", "processing", "hold"}
if record.method == PaymentMethod.WATA:
return status in {"opened", "pending", "processing", "inprogress", "in_progress"}
if record.method == PaymentMethod.PLATEGA:
return status in {"pending", "inprogress", "in_progress"}
if record.method == PaymentMethod.HELEKET:
return status not in {"paid", "paid_over", "cancel", "canceled", "fail", "failed", "expired"}
if record.method == PaymentMethod.YOOKASSA:

View File

@@ -519,6 +519,14 @@ async def process_topup_amount(
from .mulenpay import process_mulenpay_payment_amount
async with AsyncSessionLocal() as db:
await process_mulenpay_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "platega":
from app.database.database import AsyncSessionLocal
from .platega import process_platega_payment_amount
async with AsyncSessionLocal() as db:
await process_platega_payment_amount(
message, db_user, db, amount_kopeks, state
)
elif payment_method == "wata":
from app.database.database import AsyncSessionLocal
from .wata import process_wata_payment_amount
@@ -630,6 +638,14 @@ async def handle_quick_amount_selection(
await process_mulenpay_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "platega":
from app.database.database import AsyncSessionLocal
from .platega import process_platega_payment_amount
async with AsyncSessionLocal() as db:
await process_platega_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "wata":
from app.database.database import AsyncSessionLocal
from .wata import process_wata_payment_amount
@@ -717,6 +733,13 @@ async def handle_topup_amount_callback(
await process_mulenpay_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "platega":
from app.database.database import AsyncSessionLocal
from .platega import process_platega_payment_amount
async with AsyncSessionLocal() as db:
await process_platega_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "pal24":
from app.database.database import AsyncSessionLocal
from .pal24 import process_pal24_payment_amount
@@ -821,6 +844,16 @@ def register_balance_handlers(dp: Dispatcher):
F.data.startswith("pal24_method_"),
)
from .platega import start_platega_payment, handle_platega_method_selection
dp.callback_query.register(
start_platega_payment,
F.data == "topup_platega"
)
dp.callback_query.register(
handle_platega_method_selection,
F.data.startswith("platega_method_"),
)
from .yookassa import check_yookassa_payment_status
dp.callback_query.register(
check_yookassa_payment_status,
@@ -889,6 +922,12 @@ def register_balance_handlers(dp: Dispatcher):
F.data.startswith("check_pal24_")
)
from .platega import check_platega_payment_status
dp.callback_query.register(
check_platega_payment_status,
F.data.startswith("check_platega_")
)
dp.callback_query.register(
handle_payment_methods_unavailable,
F.data == "payment_methods_unavailable"

View File

@@ -0,0 +1,313 @@
import logging
from typing import List
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
def _get_active_methods() -> List[int]:
methods = settings.get_platega_active_methods()
return [code for code in methods if code in {2, 10, 11, 12, 13}]
async def _prompt_amount(
message: types.Message,
db_user: User,
state: FSMContext,
method_code: int,
) -> None:
texts = get_texts(db_user.language)
method_name = settings.get_platega_method_display_title(method_code)
prompt_template = texts.t(
"PLATEGA_TOPUP_PROMPT",
(
"💳 <b>Оплата через Platega ({method_name})</b>\n\n"
"Введите сумму для пополнения от 100 до 1 000 000 ₽.\n"
"Оплата происходит через Platega."
),
)
keyboard = get_back_keyboard(db_user.language)
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await message.edit_text(
prompt_template.format(method_name=method_name),
reply_markup=keyboard,
parse_mode="HTML",
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="platega", platega_method=method_code)
@error_handler
async def start_platega_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_platega_enabled():
await callback.answer(
texts.t(
"PLATEGA_TEMPORARILY_UNAVAILABLE",
"❌ Оплата через Platega временно недоступна",
),
show_alert=True,
)
return
active_methods = _get_active_methods()
if not active_methods:
await callback.answer(
texts.t(
"PLATEGA_METHODS_NOT_CONFIGURED",
"⚠️ На стороне Platega нет доступных методов оплаты",
),
show_alert=True,
)
return
await state.update_data(payment_method="platega")
if len(active_methods) == 1:
await _prompt_amount(callback.message, db_user, state, active_methods[0])
await callback.answer()
return
method_buttons: list[list[types.InlineKeyboardButton]] = []
for method_code in active_methods:
label = settings.get_platega_method_display_title(method_code)
method_buttons.append(
[
types.InlineKeyboardButton(
text=label,
callback_data=f"platega_method_{method_code}",
)
]
)
method_buttons.append(
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
)
await callback.message.edit_text(
texts.t(
"PLATEGA_SELECT_PAYMENT_METHOD",
"Выберите способ оплаты Platega:",
),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=method_buttons),
)
await state.set_state(BalanceStates.waiting_for_platega_method)
await callback.answer()
@error_handler
async def handle_platega_method_selection(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
try:
method_code = int(callback.data.rsplit("_", 1)[-1])
except ValueError:
await callback.answer("❌ Некорректный способ оплаты", show_alert=True)
return
if method_code not in _get_active_methods():
await callback.answer("⚠️ Этот способ сейчас недоступен", show_alert=True)
return
await _prompt_amount(callback.message, db_user, state, method_code)
await callback.answer()
@error_handler
async def process_platega_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_platega_enabled():
await message.answer(
texts.t(
"PLATEGA_TEMPORARILY_UNAVAILABLE",
"❌ Оплата через Platega временно недоступна",
)
)
return
data = await state.get_data()
method_code = int(data.get("platega_method", 0))
if method_code not in _get_active_methods():
await message.answer(
texts.t(
"PLATEGA_METHOD_SELECTION_REQUIRED",
"⚠️ Выберите способ оплаты Platega перед вводом суммы",
)
)
await state.set_state(BalanceStates.waiting_for_platega_method)
return
if amount_kopeks < settings.PLATEGA_MIN_AMOUNT_KOPEKS:
await message.answer(
texts.t(
"PLATEGA_AMOUNT_TOO_LOW",
"Минимальная сумма для оплаты через Platega: {amount}",
).format(amount=settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS))
)
return
if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS:
await message.answer(
texts.t(
"PLATEGA_AMOUNT_TOO_HIGH",
"Максимальная сумма для оплаты через Platega: {amount}",
).format(amount=settings.format_price(settings.PLATEGA_MAX_AMOUNT_KOPEKS))
)
return
try:
payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_platega_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
language=db_user.language,
payment_method_code=method_code,
)
except Exception as error:
logger.exception("Ошибка создания платежа Platega: %s", error)
payment_result = None
if not payment_result or not payment_result.get("redirect_url"):
await message.answer(
texts.t(
"PLATEGA_PAYMENT_ERROR",
"❌ Ошибка создания платежа Platega. Попробуйте позже или обратитесь в поддержку.",
)
)
await state.clear()
return
redirect_url = payment_result.get("redirect_url")
local_payment_id = payment_result.get("local_payment_id")
transaction_id = payment_result.get("transaction_id")
method_title = settings.get_platega_method_display_title(method_code)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"PLATEGA_PAY_BUTTON",
"💳 Оплатить через {method}",
).format(method=method_title),
url=redirect_url,
)
],
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
callback_data=f"check_platega_{local_payment_id}",
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
]
)
instructions_template = texts.t(
"PLATEGA_PAYMENT_INSTRUCTIONS",
(
"💳 <b>Оплата через Platega ({method})</b>\n\n"
"💰 Сумма: {amount}\n"
"🆔 ID транзакции: {transaction}\n\n"
"📱 <b>Инструкция:</b>\n"
"1. Нажмите кнопку «Оплатить»\n"
"2. Следуйте подсказкам платёжной системы\n"
"3. Подтвердите перевод\n"
"4. Средства зачислятся автоматически\n\n"
"❓ Если возникнут проблемы, обратитесь в {support}"
),
)
await message.answer(
instructions_template.format(
method=method_title,
amount=settings.format_price(amount_kopeks),
transaction=transaction_id or local_payment_id,
support=settings.get_support_contact_display_html(),
),
reply_markup=keyboard,
parse_mode="HTML",
)
await state.clear()
@error_handler
async def check_platega_payment_status(
callback: types.CallbackQuery,
db: AsyncSession,
):
try:
local_payment_id = int(callback.data.split("_")[-1])
except ValueError:
await callback.answer("❌ Некорректный идентификатор платежа", show_alert=True)
return
payment_service = PaymentService(callback.bot)
try:
status_info = await payment_service.get_platega_payment_status(db, local_payment_id)
except Exception as error:
logger.exception("Ошибка проверки статуса Platega: %s", error)
await callback.answer("⚠️ Ошибка проверки статуса", show_alert=True)
return
if not status_info:
await callback.answer("⚠️ Платёж не найден", show_alert=True)
return
payment = status_info.get("payment")
status = status_info.get("status")
is_paid = status_info.get("is_paid")
language = "ru"
user = getattr(payment, "user", None)
if user and getattr(user, "language", None):
language = user.language
texts = get_texts(language)
if is_paid:
await callback.answer(texts.t("PLATEGA_PAYMENT_ALREADY_CONFIRMED", "✅ Платёж уже зачислен"), show_alert=True)
else:
await callback.answer(
texts.t("PLATEGA_PAYMENT_STATUS", "Текущий статус платежа: {status}").format(status=status),
show_alert=True,
)

View File

@@ -1159,6 +1159,14 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
])
if settings.is_platega_enabled() and settings.get_platega_active_methods():
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_PLATEGA", "💳 Platega"),
callback_data=_build_callback("platega"),
)
])
if settings.is_cryptobot_enabled():
keyboard.append([
InlineKeyboardButton(

View File

@@ -1027,6 +1027,7 @@
"PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)",
"PAYMENT_CARD_WATA": "💳 Bank card (WATA)",
"PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)",
"PAYMENT_PLATEGA": "💳 Platega",
"PAYMENT_CHARGE_ERROR": "⚠️ Failed to charge the payment",
"PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)",
"PAYMENT_HELEKET": "🪙 Cryptocurrency (Heleket)",
@@ -1078,6 +1079,18 @@
"PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)",
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
"PAYMENT_VIA_SUPPORT": "🛠️ Via support",
"PLATEGA_TOPUP_PROMPT": "💳 <b>Payment via Platega ({method_name})</b>\n\nEnter the amount from 100 to 1,000,000 ₽.\nPayment is processed by Platega.",
"PLATEGA_SELECT_PAYMENT_METHOD": "Choose a Platega payment method:",
"PLATEGA_TEMPORARILY_UNAVAILABLE": "❌ Platega payments are temporarily unavailable",
"PLATEGA_METHODS_NOT_CONFIGURED": "⚠️ No active Platega methods configured",
"PLATEGA_METHOD_SELECTION_REQUIRED": "⚠️ Select a Platega payment method before entering the amount",
"PLATEGA_AMOUNT_TOO_LOW": "Minimum amount for Platega: {amount}",
"PLATEGA_AMOUNT_TOO_HIGH": "Maximum amount for Platega: {amount}",
"PLATEGA_PAYMENT_ERROR": "❌ Failed to create Platega payment. Please try again later or contact support.",
"PLATEGA_PAYMENT_INSTRUCTIONS": "💳 <b>Payment via Platega ({method})</b>\n\n💰 Amount: {amount}\n🆔 Transaction ID: {transaction}\n\n📱 <b>Instructions:</b>\n1. Tap the Pay button\n2. Follow the payment provider instructions\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ If you have issues, contact {support}",
"PLATEGA_PAY_BUTTON": "💳 Pay via {method}",
"PLATEGA_PAYMENT_ALREADY_CONFIRMED": "✅ Payment already credited",
"PLATEGA_PAYMENT_STATUS": "Current payment status: {status}",
"PAY_NOW_BUTTON": "💳 Pay",
"PAY_WITH_COINS_BUTTON": "🪙 Pay",
"PENDING_CANCEL_BUTTON": "⌛ Cancel",

View File

@@ -1047,6 +1047,7 @@
"PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)",
"PAYMENT_CARD_WATA": "💳 Банковская карта (WATA)",
"PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)",
"PAYMENT_PLATEGA": "💳 Platega",
"PAYMENT_CHARGE_ERROR": "⚠️ Ошибка списания средств",
"PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)",
"PAYMENT_HELEKET": "🪙 Криптовалюта (Heleket)",
@@ -1098,6 +1099,18 @@
"PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)",
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
"PAYMENT_VIA_SUPPORT": "🛠️ Через поддержку",
"PLATEGA_TOPUP_PROMPT": "💳 <b>Оплата через Platega ({method_name})</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата происходит через Platega.",
"PLATEGA_SELECT_PAYMENT_METHOD": "Выберите способ оплаты Platega:",
"PLATEGA_TEMPORARILY_UNAVAILABLE": "❌ Оплата через Platega временно недоступна",
"PLATEGA_METHODS_NOT_CONFIGURED": "⚠️ На стороне Platega нет доступных методов оплаты",
"PLATEGA_METHOD_SELECTION_REQUIRED": "⚠️ Выберите способ оплаты Platega перед вводом суммы",
"PLATEGA_AMOUNT_TOO_LOW": "Минимальная сумма для оплаты через Platega: {amount}",
"PLATEGA_AMOUNT_TOO_HIGH": "Максимальная сумма для оплаты через Platega: {amount}",
"PLATEGA_PAYMENT_ERROR": "❌ Ошибка создания платежа Platega. Попробуйте позже или обратитесь в поддержку.",
"PLATEGA_PAYMENT_INSTRUCTIONS": "💳 <b>Оплата через Platega ({method})</b>\n\n💰 Сумма: {amount}\n🆔 ID транзакции: {transaction}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку «Оплатить»\n2. Следуйте подсказкам платёжной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
"PLATEGA_PAY_BUTTON": "💳 Оплатить через {method}",
"PLATEGA_PAYMENT_ALREADY_CONFIRMED": "✅ Платёж уже зачислен",
"PLATEGA_PAYMENT_STATUS": "Текущий статус платежа: {status}",
"PAY_NOW_BUTTON": "💳 Оплатить",
"PAY_WITH_COINS_BUTTON": "🪙 Оплатить",
"PENDING_CANCEL_BUTTON": "⌛ Отмена",

View File

@@ -12,6 +12,7 @@ from .cryptobot import CryptoBotPaymentMixin
from .heleket import HeleketPaymentMixin
from .mulenpay import MulenPayPaymentMixin
from .pal24 import Pal24PaymentMixin
from .platega import PlategaPaymentMixin
from .wata import WataPaymentMixin
__all__ = [
@@ -23,5 +24,6 @@ __all__ = [
"HeleketPaymentMixin",
"MulenPayPaymentMixin",
"Pal24PaymentMixin",
"PlategaPaymentMixin",
"WataPaymentMixin",
]

View File

@@ -0,0 +1,488 @@
"""Mixin для интеграции платежей Platega."""
from __future__ import annotations
import logging
import uuid
from datetime import datetime
from importlib import import_module
from typing import Any, Dict, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.platega_service import PlategaService
from app.services.subscription_auto_purchase_service import (
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
class PlategaPaymentMixin:
"""Логика создания и обработки платежей Platega."""
_SUCCESS_STATUSES = {"CONFIRMED"}
_FAILED_STATUSES = {"FAILED", "CANCELED", "EXPIRED"}
_PENDING_STATUSES = {"PENDING", "INPROGRESS"}
async def create_platega_payment(
self,
db: AsyncSession,
*,
user_id: int,
amount_kopeks: int,
description: str,
language: str,
payment_method_code: int,
) -> Optional[Dict[str, Any]]:
service: Optional[PlategaService] = getattr(self, "platega_service", None)
if not service or not service.is_configured:
logger.error("Platega сервис не инициализирован")
return None
if amount_kopeks < settings.PLATEGA_MIN_AMOUNT_KOPEKS:
logger.warning(
"Сумма Platega меньше минимальной: %s < %s",
amount_kopeks,
settings.PLATEGA_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS:
logger.warning(
"Сумма Platega больше максимальной: %s > %s",
amount_kopeks,
settings.PLATEGA_MAX_AMOUNT_KOPEKS,
)
return None
correlation_id = uuid.uuid4().hex
payload_token = f"platega:{correlation_id}"
amount_value = amount_kopeks / 100
try:
response = await service.create_payment(
payment_method=payment_method_code,
amount=amount_value,
currency=settings.PLATEGA_CURRENCY,
description=description,
return_url=settings.get_platega_return_url(),
failed_url=settings.get_platega_failed_url(),
payload=payload_token,
)
except Exception as error: # pragma: no cover - network errors
logger.exception("Ошибка Platega при создании платежа: %s", error)
return None
if not response:
logger.error("Platega вернул пустой ответ при создании платежа")
return None
transaction_id = response.get("transactionId") or response.get("id")
redirect_url = response.get("redirect")
status = str(response.get("status") or "PENDING").upper()
expires_at = PlategaService.parse_expires_at(response.get("expiresIn"))
metadata = {
"raw_response": response,
"language": language,
"selected_method": payment_method_code,
}
payment_module = import_module("app.services.payment_service")
payment = await payment_module.create_platega_payment(
db,
user_id=user_id,
amount_kopeks=amount_kopeks,
currency=settings.PLATEGA_CURRENCY,
description=description,
status=status,
payment_method_code=payment_method_code,
correlation_id=correlation_id,
platega_transaction_id=transaction_id,
redirect_url=redirect_url,
return_url=settings.get_platega_return_url(),
failed_url=settings.get_platega_failed_url(),
payload=payload_token,
metadata=metadata,
expires_at=expires_at,
)
logger.info(
"Создан Platega платеж %s для пользователя %s (метод %s, сумма %s₽)",
transaction_id or payment.id,
user_id,
payment_method_code,
amount_value,
)
return {
"local_payment_id": payment.id,
"transaction_id": transaction_id,
"redirect_url": redirect_url,
"status": status,
"expires_at": expires_at,
"correlation_id": correlation_id,
}
async def process_platega_webhook(
self,
db: AsyncSession,
payload: Dict[str, Any],
) -> bool:
payment_module = import_module("app.services.payment_service")
transaction_id = str(payload.get("id") or "").strip()
payload_token = payload.get("payload")
payment = None
if transaction_id:
payment = await payment_module.get_platega_payment_by_transaction_id(
db, transaction_id
)
if not payment and payload_token:
payment = await payment_module.get_platega_payment_by_correlation_id(
db, str(payload_token).replace("platega:", "")
)
if not payment:
logger.warning("Platega webhook: платеж не найден (id=%s)", transaction_id)
return False
status_raw = str(payload.get("status") or "").upper()
if not status_raw:
logger.warning("Platega webhook без статуса для платежа %s", payment.id)
return False
update_kwargs = {
"status": status_raw,
"callback_payload": payload,
}
if transaction_id:
update_kwargs["platega_transaction_id"] = transaction_id
if status_raw in self._SUCCESS_STATUSES:
if payment.is_paid:
logger.info(
"Platega платеж %s уже помечен как оплачен", payment.correlation_id
)
await payment_module.update_platega_payment(
db,
payment=payment,
**update_kwargs,
is_paid=True,
)
return True
payment = await payment_module.update_platega_payment(
db,
payment=payment,
**update_kwargs,
)
await self._finalize_platega_payment(db, payment, payload)
return True
if status_raw in self._FAILED_STATUSES:
await payment_module.update_platega_payment(
db,
payment=payment,
**update_kwargs,
is_paid=False,
)
logger.info(
"Platega платеж %s перешёл в статус %s", payment.correlation_id, status_raw
)
return True
await payment_module.update_platega_payment(
db,
payment=payment,
**update_kwargs,
)
return True
async def get_platega_payment_status(
self,
db: AsyncSession,
local_payment_id: int,
) -> Optional[Dict[str, Any]]:
payment_module = import_module("app.services.payment_service")
payment = await payment_module.get_platega_payment_by_id(db, local_payment_id)
if not payment:
return None
service: Optional[PlategaService] = getattr(self, "platega_service", None)
remote_status: Optional[str] = None
remote_payload: Optional[Dict[str, Any]] = None
if service and payment.platega_transaction_id:
try:
remote_payload = await service.get_transaction(
payment.platega_transaction_id
)
except Exception as error: # pragma: no cover - network errors
logger.error(
"Ошибка Platega при получении транзакции %s: %s",
payment.platega_transaction_id,
error,
)
if remote_payload:
remote_status = str(remote_payload.get("status") or "").upper()
if remote_status and remote_status != payment.status:
await payment_module.update_platega_payment(
db,
payment=payment,
status=remote_status,
metadata={
**(getattr(payment, "metadata_json", {}) or {}),
"remote_status": remote_payload,
},
)
payment = await payment_module.get_platega_payment_by_id(db, local_payment_id)
if (
remote_status in self._SUCCESS_STATUSES
and not payment.is_paid
):
payment = await payment_module.update_platega_payment(
db,
payment=payment,
status=remote_status,
callback_payload=remote_payload,
)
await self._finalize_platega_payment(db, payment, remote_payload)
return {
"payment": payment,
"status": payment.status,
"is_paid": payment.is_paid,
"remote": remote_payload,
}
async def _finalize_platega_payment(
self,
db: AsyncSession,
payment: Any,
payload: Optional[Dict[str, Any]],
) -> Any:
payment_module = import_module("app.services.payment_service")
metadata = dict(getattr(payment, "metadata_json", {}) or {})
if payload is not None:
metadata["webhook"] = payload
paid_at = None
if isinstance(payload, dict):
paid_at_raw = payload.get("paidAt") or payload.get("confirmedAt")
if paid_at_raw:
try:
paid_at = datetime.fromisoformat(str(paid_at_raw))
except ValueError:
paid_at = None
payment = await payment_module.update_platega_payment(
db,
payment=payment,
status="CONFIRMED",
is_paid=True,
paid_at=paid_at,
metadata=metadata,
callback_payload=payload,
)
if payment.transaction_id:
logger.info(
"Platega платеж %s уже связан с транзакцией %s",
payment.correlation_id,
payment.transaction_id,
)
return payment
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error("Пользователь %s не найден для Platega", payment.user_id)
return payment
transaction_external_id = (
str(payload.get("id"))
if isinstance(payload, dict) and payload.get("id")
else payment.platega_transaction_id
)
method_display = settings.get_platega_method_display_name(payment.payment_method_code)
description = (
f"Пополнение через Platega ({method_display})"
if method_display
else "Пополнение через Platega"
)
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=description,
payment_method=PaymentMethod.PLATEGA,
external_id=transaction_external_id or payment.correlation_id,
is_completed=True,
)
await payment_module.link_platega_payment_to_transaction(
db, payment=payment, transaction_id=transaction.id
)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
promo_group = user.get_primary_promo_group()
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
payment.amount_kopeks,
getattr(self, "bot", None),
)
except Exception as error:
logger.error("Ошибка обработки реферального пополнения Platega: %s", error)
if was_first_topup and not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error("Ошибка отправки админ уведомления Platega: %s", error)
method_title = settings.get_platega_method_display_title(payment.payment_method_code)
if getattr(self, "bot", None):
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
f"🦊 Способ: {method_title}\n"
f"🆔 Транзакция: {transaction.id}\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
reply_markup=keyboard,
)
except Exception as error:
logger.error("Ошибка отправки уведомления пользователю Platega: %s", error)
try:
from app.services.user_cart_service import user_cart_service
from aiogram import types
has_saved_cart = await user_cart_service.has_user_cart(user.id)
auto_purchase_success = False
if has_saved_cart:
try:
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
db,
user,
bot=getattr(self, "bot", None),
)
except Exception as auto_error:
logger.error(
"Ошибка автоматической покупки подписки для пользователя %s: %s",
user.id,
auto_error,
exc_info=True,
)
if auto_purchase_success:
has_saved_cart = False
if has_saved_cart and getattr(self, "bot", None):
from app.localization.texts import get_texts
texts = get_texts(user.language)
cart_message = texts.t(
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
"🛒 У вас есть неоформленный заказ.\n\n"
"Вы можете продолжить оформление с теми же параметрами.",
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="return_to_saved_cart",
)
],
[
types.InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance",
)
],
[
types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu",
)
],
]
)
await self.bot.send_message(
chat_id=user.telegram_id,
text=(
f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
f"{cart_message}"
),
reply_markup=keyboard,
)
except Exception as error:
logger.error(
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
payment.user_id,
error,
exc_info=True,
)
logger.info(
"✅ Обработан Platega платеж %s для пользователя %s",
payment.correlation_id,
payment.user_id,
)
return payment

View File

@@ -15,11 +15,13 @@ from app.external.heleket import HeleketService
from app.external.telegram_stars import TelegramStarsService
from app.services.mulenpay_service import MulenPayService
from app.services.pal24_service import Pal24Service
from app.services.platega_service import PlategaService
from app.services.payment import (
CryptoBotPaymentMixin,
HeleketPaymentMixin,
MulenPayPaymentMixin,
Pal24PaymentMixin,
PlategaPaymentMixin,
PaymentCommonMixin,
TelegramStarsMixin,
TributePaymentMixin,
@@ -170,6 +172,36 @@ async def link_wata_payment_to_transaction(*args, **kwargs):
return await wata_crud.link_wata_payment_to_transaction(*args, **kwargs)
async def create_platega_payment(*args, **kwargs):
platega_crud = import_module("app.database.crud.platega")
return await platega_crud.create_platega_payment(*args, **kwargs)
async def get_platega_payment_by_id(*args, **kwargs):
platega_crud = import_module("app.database.crud.platega")
return await platega_crud.get_platega_payment_by_id(*args, **kwargs)
async def get_platega_payment_by_transaction_id(*args, **kwargs):
platega_crud = import_module("app.database.crud.platega")
return await platega_crud.get_platega_payment_by_transaction_id(*args, **kwargs)
async def get_platega_payment_by_correlation_id(*args, **kwargs):
platega_crud = import_module("app.database.crud.platega")
return await platega_crud.get_platega_payment_by_correlation_id(*args, **kwargs)
async def update_platega_payment(*args, **kwargs):
platega_crud = import_module("app.database.crud.platega")
return await platega_crud.update_platega_payment(*args, **kwargs)
async def link_platega_payment_to_transaction(*args, **kwargs):
platega_crud = import_module("app.database.crud.platega")
return await platega_crud.link_platega_payment_to_transaction(*args, **kwargs)
async def create_cryptobot_payment(*args, **kwargs):
crypto_crud = import_module("app.database.crud.cryptobot")
return await crypto_crud.create_cryptobot_payment(*args, **kwargs)
@@ -224,6 +256,7 @@ class PaymentService(
HeleketPaymentMixin,
MulenPayPaymentMixin,
Pal24PaymentMixin,
PlategaPaymentMixin,
WataPaymentMixin,
):
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
@@ -248,11 +281,14 @@ class PaymentService(
self.pal24_service = (
Pal24Service() if settings.is_pal24_enabled() else None
)
self.platega_service = (
PlategaService() if settings.is_platega_enabled() else None
)
self.wata_service = WataService() if settings.is_wata_enabled() else None
mulenpay_name = settings.get_mulenpay_display_name()
logger.debug(
"PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, Heleket=%s, %s=%s, Pal24=%s, Wata=%s)",
"PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, Heleket=%s, %s=%s, Pal24=%s, Platega=%s, Wata=%s)",
bool(self.yookassa_service),
bool(self.stars_service),
bool(self.cryptobot_service),
@@ -260,5 +296,6 @@ class PaymentService(
mulenpay_name,
bool(self.mulenpay_service),
bool(self.pal24_service),
bool(self.platega_service),
bool(self.wata_service),
)

View File

@@ -21,6 +21,7 @@ from app.database.models import (
HeleketPayment,
MulenPayPayment,
Pal24Payment,
PlategaPayment,
PaymentMethod,
Transaction,
TransactionType,
@@ -62,6 +63,7 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
PaymentMethod.WATA,
PaymentMethod.HELEKET,
PaymentMethod.CRYPTOBOT,
PaymentMethod.PLATEGA,
}
)
@@ -73,6 +75,7 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
PaymentMethod.PAL24,
PaymentMethod.WATA,
PaymentMethod.CRYPTOBOT,
PaymentMethod.PLATEGA,
}
)
@@ -86,6 +89,8 @@ def method_display_name(method: PaymentMethod) -> str:
return "YooKassa"
if method == PaymentMethod.WATA:
return "WATA"
if method == PaymentMethod.PLATEGA:
return "Platega"
if method == PaymentMethod.CRYPTOBOT:
return "CryptoBot"
if method == PaymentMethod.HELEKET:
@@ -104,6 +109,8 @@ def _method_is_enabled(method: PaymentMethod) -> bool:
return settings.is_pal24_enabled()
if method == PaymentMethod.WATA:
return settings.is_wata_enabled()
if method == PaymentMethod.PLATEGA:
return settings.is_platega_enabled()
if method == PaymentMethod.CRYPTOBOT:
return settings.is_cryptobot_enabled()
if method == PaymentMethod.HELEKET:
@@ -315,6 +322,13 @@ def _is_wata_pending(payment: WataPayment) -> bool:
}
def _is_platega_pending(payment: PlategaPayment) -> bool:
if payment.is_paid:
return False
status = (payment.status or "").lower()
return status in {"pending", "inprogress", "in_progress"}
def _is_heleket_pending(payment: HeleketPayment) -> bool:
if payment.is_paid:
return False
@@ -459,6 +473,33 @@ async def _fetch_wata_payments(db: AsyncSession, cutoff: datetime) -> List[Pendi
return records
async def _fetch_platega_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
stmt = (
select(PlategaPayment)
.options(selectinload(PlategaPayment.user))
.where(PlategaPayment.created_at >= cutoff)
.order_by(desc(PlategaPayment.created_at))
)
result = await db.execute(stmt)
records: List[PendingPayment] = []
for payment in result.scalars().all():
if not _is_platega_pending(payment):
continue
identifier = payment.platega_transaction_id or payment.correlation_id or str(payment.id)
record = _build_record(
PaymentMethod.PLATEGA,
payment,
identifier=identifier,
amount_kopeks=payment.amount_kopeks,
status=payment.status or "",
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, "expires_at", None),
)
if record:
records.append(record)
return records
async def _fetch_heleket_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
stmt = (
select(HeleketPayment)
@@ -582,6 +623,7 @@ async def list_recent_pending_payments(
await _fetch_pal24_payments(db, cutoff),
await _fetch_mulenpay_payments(db, cutoff),
await _fetch_wata_payments(db, cutoff),
await _fetch_platega_payments(db, cutoff),
await _fetch_heleket_payments(db, cutoff),
await _fetch_cryptobot_payments(db, cutoff),
await _fetch_stars_transactions(db, cutoff),
@@ -648,6 +690,22 @@ async def get_payment_record(
expires_at=getattr(payment, "expires_at", None),
)
if method == PaymentMethod.PLATEGA:
payment = await db.get(PlategaPayment, local_payment_id)
if not payment:
return None
await db.refresh(payment, attribute_names=["user"])
identifier = payment.platega_transaction_id or payment.correlation_id or str(payment.id)
return _build_record(
method,
payment,
identifier=identifier,
amount_kopeks=payment.amount_kopeks,
status=payment.status or "",
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, "expires_at", None),
)
if method == PaymentMethod.HELEKET:
payment = await db.get(HeleketPayment, local_payment_id)
if not payment:
@@ -732,6 +790,9 @@ async def run_manual_check(
elif method == PaymentMethod.WATA:
result = await payment_service.get_wata_payment_status(db, local_payment_id)
payment = result.get("payment") if result else None
elif method == PaymentMethod.PLATEGA:
result = await payment_service.get_platega_payment_status(db, local_payment_id)
payment = result.get("payment") if result else None
elif method == PaymentMethod.HELEKET:
payment = await payment_service.sync_heleket_payment_status(
db, local_payment_id=local_payment_id

View File

@@ -0,0 +1,190 @@
"""HTTP-интеграция с Platega API."""
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime, timedelta
from typing import Any, Dict, Optional
import aiohttp
from app.config import settings
logger = logging.getLogger(__name__)
class PlategaService:
"""Обертка над Platega API с базовой повторной отправкой запросов."""
def __init__(self) -> None:
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._timeout = aiohttp.ClientTimeout(total=30, connect=10, sock_read=25)
self._max_retries = 3
self._retry_delay = 0.5
self._retryable_statuses = {500, 502, 503, 504}
@property
def is_configured(self) -> bool:
return settings.is_platega_enabled()
async def create_payment(
self,
*,
payment_method: int,
amount: float,
currency: str,
description: Optional[str] = None,
return_url: Optional[str] = None,
failed_url: Optional[str] = None,
payload: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
body: Dict[str, Any] = {
"paymentMethod": payment_method,
"paymentDetails": {
"amount": round(amount, 2),
"currency": currency,
},
}
if description:
body["description"] = description
if return_url:
body["return"] = return_url
if failed_url:
body["failedUrl"] = failed_url
if payload:
body["payload"] = payload
return await self._request("POST", "/transaction/process", json_data=body)
async def get_transaction(self, transaction_id: str) -> Optional[Dict[str, Any]]:
endpoint = f"/transaction/{transaction_id}"
return await self._request("GET", endpoint)
async def _request(
self,
method: str,
endpoint: str,
*,
json_data: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
if not self.is_configured:
logger.error("Platega service is not configured")
return None
url = f"{self.base_url}{endpoint}"
headers = {
"X-MerchantId": self.merchant_id or "",
"X-Secret": self.secret or "",
"Content-Type": "application/json",
}
last_error: Optional[BaseException] = None
for attempt in range(1, self._max_retries + 1):
try:
async with aiohttp.ClientSession(timeout=self._timeout) as session:
async with session.request(
method,
url,
json=json_data,
params=params,
headers=headers,
) as response:
data, raw_text = await self._deserialize_response(response)
if response.status >= 400:
logger.error(
"Platega API error %s %s: %s",
response.status,
endpoint,
raw_text,
)
if (
response.status in self._retryable_statuses
and attempt < self._max_retries
):
await asyncio.sleep(self._retry_delay * attempt)
continue
return None
return data
except asyncio.CancelledError:
logger.debug("Platega request cancelled: %s %s", method, endpoint)
raise
except asyncio.TimeoutError as error:
last_error = error
logger.warning(
"Platega request timeout (%s %s) attempt %s/%s",
method,
endpoint,
attempt,
self._max_retries,
)
except aiohttp.ClientError as error:
last_error = error
logger.warning(
"Platega client error (%s %s) attempt %s/%s: %s",
method,
endpoint,
attempt,
self._max_retries,
error,
)
except Exception as error: # pragma: no cover - safety
logger.exception("Unexpected Platega error: %s", error)
return None
if attempt < self._max_retries:
await asyncio.sleep(self._retry_delay * attempt)
if last_error is not None:
logger.error(
"Platega request failed after %s attempts (%s %s): %s",
self._max_retries,
method,
endpoint,
last_error,
)
return None
@staticmethod
async def _deserialize_response(
response: aiohttp.ClientResponse,
) -> tuple[Optional[Dict[str, Any]], str]:
raw_text = await response.text()
if not raw_text:
return None, ""
content_type = response.headers.get("Content-Type", "")
if "json" in content_type.lower() or not content_type:
try:
return json.loads(raw_text), raw_text
except json.JSONDecodeError as error:
logger.error(
"Failed to decode Platega JSON response %s: %s",
response.url,
error,
)
return None, raw_text
return None, raw_text
@staticmethod
def parse_expires_at(expires_in: Optional[str]) -> Optional[datetime]:
if not expires_in:
return None
try:
hours, minutes, seconds = [int(part) for part in expires_in.split(":", 2)]
delta = timedelta(hours=hours, minutes=minutes, seconds=seconds)
return datetime.utcnow() + delta
except Exception:
logger.warning("Failed to parse Platega expiresIn value: %s", expires_in)
return None

View File

@@ -25,6 +25,7 @@ class SubscriptionStates(StatesGroup):
class BalanceStates(StatesGroup):
waiting_for_amount = State()
waiting_for_pal24_method = State()
waiting_for_platega_method = State()
waiting_for_stars_payment = State()
waiting_for_support_request = State()

View File

@@ -577,6 +577,54 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
routes_registered = True
if settings.is_platega_enabled():
@router.get(settings.PLATEGA_WEBHOOK_PATH)
async def platega_health() -> JSONResponse:
return JSONResponse(
{
"status": "ok",
"service": "platega_webhook",
"enabled": settings.is_platega_enabled(),
}
)
@router.post(settings.PLATEGA_WEBHOOK_PATH)
async def platega_webhook(request: Request) -> JSONResponse:
merchant_id = request.headers.get("X-MerchantId", "")
secret = request.headers.get("X-Secret", "")
if (
merchant_id != (settings.PLATEGA_MERCHANT_ID or "")
or secret != (settings.PLATEGA_SECRET or "")
):
return JSONResponse(
{"status": "error", "reason": "unauthorized"},
status_code=status.HTTP_401_UNAUTHORIZED,
)
try:
payload = await request.json()
except json.JSONDecodeError:
return JSONResponse(
{"status": "error", "reason": "invalid_json"},
status_code=status.HTTP_400_BAD_REQUEST,
)
success = await _process_payment_service_callback(
payment_service,
payload,
"process_platega_webhook",
)
if success:
return JSONResponse({"status": "ok"})
return JSONResponse(
{"status": "error", "reason": "not_processed"},
status_code=status.HTTP_400_BAD_REQUEST,
)
routes_registered = True
if routes_registered:
@router.get("/health/payment-webhooks")
async def payment_webhooks_health() -> JSONResponse:
@@ -590,6 +638,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
"wata_enabled": settings.is_wata_enabled(),
"heleket_enabled": settings.is_heleket_enabled(),
"pal24_enabled": settings.is_pal24_enabled(),
"platega_enabled": settings.is_platega_enabled(),
}
)

View File

@@ -0,0 +1,95 @@
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "2b3c1d4e5f6a"
down_revision: Union[str, None] = "9f0f2d5a1c7b"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"platega_payments",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("platega_transaction_id", sa.String(length=255), nullable=True, unique=True),
sa.Column("correlation_id", sa.String(length=64), nullable=False, unique=True),
sa.Column("amount_kopeks", sa.Integer(), nullable=False),
sa.Column(
"currency",
sa.String(length=10),
nullable=False,
server_default="RUB",
),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("payment_method_code", sa.Integer(), nullable=False),
sa.Column(
"status",
sa.String(length=50),
nullable=False,
server_default="PENDING",
),
sa.Column(
"is_paid",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
sa.Column("paid_at", sa.DateTime(), nullable=True),
sa.Column("redirect_url", sa.Text(), nullable=True),
sa.Column("return_url", sa.Text(), nullable=True),
sa.Column("failed_url", sa.Text(), nullable=True),
sa.Column("payload", sa.String(length=255), nullable=True),
sa.Column("metadata_json", sa.JSON(), nullable=True),
sa.Column("callback_payload", sa.JSON(), nullable=True),
sa.Column("expires_at", sa.DateTime(), nullable=True),
sa.Column("transaction_id", sa.Integer(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["transaction_id"], ["transactions.id"], ondelete="SET NULL"),
)
op.create_index("ix_platega_payments_id", "platega_payments", ["id"])
op.create_index("ix_platega_payments_user_id", "platega_payments", ["user_id"])
op.create_index(
"ix_platega_payments_platega_transaction_id",
"platega_payments",
["platega_transaction_id"],
)
op.create_index(
"ix_platega_payments_correlation_id",
"platega_payments",
["correlation_id"],
unique=True,
)
op.create_index(
"ix_platega_payments_transaction_id",
"platega_payments",
["transaction_id"],
)
def downgrade() -> None:
op.drop_index("ix_platega_payments_transaction_id", table_name="platega_payments")
op.drop_index("ix_platega_payments_correlation_id", table_name="platega_payments")
op.drop_index(
"ix_platega_payments_platega_transaction_id",
table_name="platega_payments",
)
op.drop_index("ix_platega_payments_user_id", table_name="platega_payments")
op.drop_index("ix_platega_payments_id", table_name="platega_payments")
op.drop_table("platega_payments")