From fba80b1a0dc0397972fdba80e5f1f5ce8ae632e0 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 7 Nov 2025 06:52:57 +0300 Subject: [PATCH] Integrate Platega balance top-ups --- app/config.py | 82 +++ app/database/crud/platega.py | 145 ++++++ app/database/models.py | 51 ++ app/handlers/admin/payments.py | 16 + app/handlers/balance/main.py | 39 ++ app/handlers/balance/platega.py | 313 +++++++++++ app/keyboards/inline.py | 8 + app/localization/locales/en.json | 13 + app/localization/locales/ru.json | 13 + app/services/payment/__init__.py | 2 + app/services/payment/platega.py | 488 ++++++++++++++++++ app/services/payment_service.py | 39 +- app/services/payment_verification_service.py | 61 +++ app/services/platega_service.py | 190 +++++++ app/states.py | 1 + app/webserver/payments.py | 49 ++ .../2b3c1d4e5f6a_add_platega_payments.py | 95 ++++ 17 files changed, 1604 insertions(+), 1 deletion(-) create mode 100644 app/database/crud/platega.py create mode 100644 app/handlers/balance/platega.py create mode 100644 app/services/payment/platega.py create mode 100644 app/services/platega_service.py create mode 100644 migrations/alembic/versions/2b3c1d4e5f6a_add_platega_payments.py diff --git a/app/config.py b/app/config.py index 8202b90a..96071196 100644 --- a/app/config.py +++ b/app/config.py @@ -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 diff --git a/app/database/crud/platega.py b/app/database/crud/platega.py new file mode 100644 index 00000000..f09f5898 --- /dev/null +++ b/app/database/crud/platega.py @@ -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 diff --git a/app/database/models.py b/app/database/models.py index e6080017..09695965 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -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 ( + "".format( + self.id, + self.platega_transaction_id, + self.amount_rubles, + self.status, + self.payment_method_code, + ) + ) + + class PromoGroup(Base): __tablename__ = "promo_groups" diff --git a/app/handlers/admin/payments.py b/app/handlers/admin/payments.py index da1c990a..5379a655 100644 --- a/app/handlers/admin/payments.py +++ b/app/handlers/admin/payments.py @@ -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: diff --git a/app/handlers/balance/main.py b/app/handlers/balance/main.py index 2f3428d0..04f2f5a9 100644 --- a/app/handlers/balance/main.py +++ b/app/handlers/balance/main.py @@ -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" diff --git a/app/handlers/balance/platega.py b/app/handlers/balance/platega.py new file mode 100644 index 00000000..887ef5a8 --- /dev/null +++ b/app/handlers/balance/platega.py @@ -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", + ( + "💳 Оплата через Platega ({method_name})\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", + ( + "💳 Оплата через Platega ({method})\n\n" + "💰 Сумма: {amount}\n" + "🆔 ID транзакции: {transaction}\n\n" + "📱 Инструкция:\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, + ) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index a522d3ca..64c89a08 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -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( diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 0a92e40a..4b0bc372 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -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": "💳 Payment via Platega ({method_name})\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": "💳 Payment via Platega ({method})\n\n💰 Amount: {amount}\n🆔 Transaction ID: {transaction}\n\n📱 Instructions:\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", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index da49f786..03fdf4b1 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -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": "💳 Оплата через Platega ({method_name})\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": "💳 Оплата через Platega ({method})\n\n💰 Сумма: {amount}\n🆔 ID транзакции: {transaction}\n\n📱 Инструкция:\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": "⌛ Отмена", diff --git a/app/services/payment/__init__.py b/app/services/payment/__init__.py index 93fe7d48..48030e05 100644 --- a/app/services/payment/__init__.py +++ b/app/services/payment/__init__.py @@ -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", ] diff --git a/app/services/payment/platega.py b/app/services/payment/platega.py new file mode 100644 index 00000000..379eadd0 --- /dev/null +++ b/app/services/payment/platega.py @@ -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, + ( + "✅ Пополнение успешно!\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 diff --git a/app/services/payment_service.py b/app/services/payment_service.py index b9f414ed..e8ca5b75 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -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), ) diff --git a/app/services/payment_verification_service.py b/app/services/payment_verification_service.py index c2b44a23..55b212a9 100644 --- a/app/services/payment_verification_service.py +++ b/app/services/payment_verification_service.py @@ -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 diff --git a/app/services/platega_service.py b/app/services/platega_service.py new file mode 100644 index 00000000..3f259adb --- /dev/null +++ b/app/services/platega_service.py @@ -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 diff --git a/app/states.py b/app/states.py index 9373c92f..39b3744d 100644 --- a/app/states.py +++ b/app/states.py @@ -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() diff --git a/app/webserver/payments.py b/app/webserver/payments.py index 61a19dcc..8c638d50 100644 --- a/app/webserver/payments.py +++ b/app/webserver/payments.py @@ -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(), } ) diff --git a/migrations/alembic/versions/2b3c1d4e5f6a_add_platega_payments.py b/migrations/alembic/versions/2b3c1d4e5f6a_add_platega_payments.py new file mode 100644 index 00000000..261d65a2 --- /dev/null +++ b/migrations/alembic/versions/2b3c1d4e5f6a_add_platega_payments.py @@ -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")