From 2d3d1ba5d3efbf6425501d50852cd859431fc3d1 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 26 Oct 2025 09:46:46 +0300 Subject: [PATCH] Fix CryptoBot admin status checks --- app/bot.py | 2 + app/config.py | 23 + app/external/cryptobot.py | 48 +- app/handlers/admin/payments.py | 578 ++++++++++++++ app/keyboards/admin.py | 6 + app/localization/locales/en.json | 34 + app/localization/locales/ru.json | 34 + app/services/payment/cryptobot.py | 80 ++ app/services/payment/yookassa.py | 109 ++- app/services/payment_service.py | 5 + app/services/payment_verification_service.py | 767 +++++++++++++++++++ app/services/system_settings_service.py | 21 + main.py | 100 ++- 13 files changed, 1790 insertions(+), 17 deletions(-) create mode 100644 app/handlers/admin/payments.py create mode 100644 app/services/payment_verification_service.py diff --git a/app/bot.py b/app/bot.py index 826d8d6e..19097554 100644 --- a/app/bot.py +++ b/app/bot.py @@ -58,6 +58,7 @@ from app.handlers.admin import ( privacy_policy as admin_privacy_policy, public_offer as admin_public_offer, faq as admin_faq, + payments as admin_payments, ) from app.handlers.stars_payments import register_stars_handlers @@ -172,6 +173,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_privacy_policy.register_handlers(dp) admin_public_offer.register_handlers(dp) admin_faq.register_handlers(dp) + admin_payments.register_handlers(dp) common.register_handlers(dp) register_stars_handlers(dp) user_polls.register_handlers(dp) diff --git a/app/config.py b/app/config.py index 6a132550..aed79f7f 100644 --- a/app/config.py +++ b/app/config.py @@ -18,6 +18,9 @@ DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS = [ ] +logger = logging.getLogger(__name__) + + class Settings(BaseSettings): BOT_TOKEN: str @@ -182,6 +185,8 @@ class Settings(BaseSettings): YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000 YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False DISABLE_TOPUP_BUTTONS: bool = False + PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False + PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10 # Настройки простой покупки SIMPLE_SUBSCRIPTION_ENABLED: bool = False @@ -839,6 +844,24 @@ class Settings(BaseSettings): and self.WATA_TERMINAL_PUBLIC_ID is not None ) + def is_payment_verification_auto_check_enabled(self) -> bool: + return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED + + def get_payment_verification_auto_check_interval(self) -> int: + try: + minutes = int(self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES) + except (TypeError, ValueError): # pragma: no cover - защитная проверка конфигурации + minutes = 10 + + if minutes <= 0: + logger.warning( + "Некорректный интервал автопроверки платежей: %s. Используется значение по умолчанию 10 минут.", + self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES, + ) + return 10 + + return minutes + def get_cryptobot_base_url(self) -> str: if self.CRYPTOBOT_TESTNET: return "https://testnet-pay.crypt.bot" diff --git a/app/external/cryptobot.py b/app/external/cryptobot.py index 5fce68c6..7088dc21 100644 --- a/app/external/cryptobot.py +++ b/app/external/cryptobot.py @@ -19,10 +19,10 @@ class CryptoBotService: self.webhook_secret = settings.CRYPTOBOT_WEBHOOK_SECRET async def _make_request( - self, - method: str, - endpoint: str, - data: Optional[Dict] = None + self, + method: str, + endpoint: str, + data: Optional[Dict] = None, ) -> Optional[Dict[str, Any]]: if not self.api_token: @@ -37,11 +37,18 @@ class CryptoBotService: try: async with aiohttp.ClientSession() as session: + request_kwargs: Dict[str, Any] = {"headers": headers} + + if method.upper() == "GET": + if data: + request_kwargs["params"] = data + elif data: + request_kwargs["json"] = data + async with session.request( - method, - url, - headers=headers, - json=data if data else None + method, + url, + **request_kwargs, ) as response: response_data = await response.json() @@ -95,21 +102,34 @@ class CryptoBotService: asset: Optional[str] = None, status: Optional[str] = None, offset: int = 0, - count: int = 100 + count: int = 100, + invoice_ids: Optional[list] = None, ) -> Optional[list]: - + data = { 'offset': offset, 'count': count } - + if asset: data['asset'] = asset - + if status: data['status'] = status - - return await self._make_request('GET', 'getInvoices', data) + + if invoice_ids: + data['invoice_ids'] = invoice_ids + + result = await self._make_request('GET', 'getInvoices', data) + + if isinstance(result, dict): + items = result.get('items') + return items if isinstance(items, list) else [] + + if isinstance(result, list): + return result + + return [] async def get_balance(self) -> Optional[list]: return await self._make_request('GET', 'getBalance') diff --git a/app/handlers/admin/payments.py b/app/handlers/admin/payments.py new file mode 100644 index 00000000..52119921 --- /dev/null +++ b/app/handlers/admin/payments.py @@ -0,0 +1,578 @@ +from __future__ import annotations + +import html +import math +from typing import Optional + +from aiogram import Dispatcher, F, types +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import PaymentMethod, User +from app.localization.texts import get_texts +from app.services.payment_service import PaymentService +from app.services.payment_verification_service import ( + PendingPayment, + SUPPORTED_MANUAL_CHECK_METHODS, + get_payment_record, + list_recent_pending_payments, + run_manual_check, +) +from app.utils.decorators import admin_required, error_handler +from app.utils.formatters import format_datetime, format_time_ago, format_username + + +PAGE_SIZE = 6 + + +def _method_display(method: PaymentMethod) -> str: + if method == PaymentMethod.MULENPAY: + return settings.get_mulenpay_display_name() + if method == PaymentMethod.PAL24: + return "PayPalych" + if method == PaymentMethod.WATA: + return "WATA" + if method == PaymentMethod.HELEKET: + return "Heleket" + if method == PaymentMethod.YOOKASSA: + return "YooKassa" + if method == PaymentMethod.CRYPTOBOT: + return "CryptoBot" + if method == PaymentMethod.TELEGRAM_STARS: + return "Telegram Stars" + return method.value + + +def _status_info( + record: PendingPayment, + *, + texts, +) -> tuple[str, str]: + status = (record.status or "").lower() + + if record.is_paid: + return "✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid") + + if record.method == PaymentMethod.PAL24: + mapping = { + "new": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")), + "process": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")), + "success": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")), + "fail": ("❌", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")), + "canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")), + "cancel": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")), + } + return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown"))) + + if record.method == PaymentMethod.MULENPAY: + mapping = { + "created": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")), + "processing": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")), + "hold": ("🔒", texts.t("ADMIN_PAYMENT_STATUS_ON_HOLD", "🔒 Hold")), + "success": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")), + "canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")), + "cancel": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")), + "error": ("⚠️", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")), + } + return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown"))) + + if record.method == PaymentMethod.WATA: + mapping = { + "opened": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")), + "pending": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")), + "processing": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")), + "paid": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")), + "closed": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")), + "declined": ("❌", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")), + "canceled": ("❌", 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") + if status in {"paid", "paid_over"}: + return "✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid") + if status in {"cancel", "canceled", "fail", "failed", "expired"}: + return "❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled") + return "❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown") + + if record.method == PaymentMethod.YOOKASSA: + mapping = { + "pending": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")), + "waiting_for_capture": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")), + "succeeded": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")), + "canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")), + } + return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown"))) + + if record.method == PaymentMethod.CRYPTOBOT: + mapping = { + "active": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")), + "paid": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")), + "expired": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")), + } + return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown"))) + + if record.method == PaymentMethod.TELEGRAM_STARS: + if record.is_paid: + return "✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid") + return "⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending") + + return "❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown") + + +def _is_checkable(record: PendingPayment) -> bool: + if record.method not in SUPPORTED_MANUAL_CHECK_METHODS: + return False + if not record.is_recent(): + return False + status = (record.status or "").lower() + if record.method == PaymentMethod.PAL24: + return status in {"new", "process"} + if record.method == PaymentMethod.MULENPAY: + return status in {"created", "processing", "hold"} + if record.method == PaymentMethod.WATA: + return status in {"opened", "pending", "processing", "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: + return status in {"pending", "waiting_for_capture"} + if record.method == PaymentMethod.CRYPTOBOT: + return status in {"active"} + return False + + +def _build_list_keyboard( + records: list[PendingPayment], + *, + page: int, + total_pages: int, + language: str, +) -> InlineKeyboardMarkup: + buttons: list[list[InlineKeyboardButton]] = [] + texts = get_texts(language) + + for record in records: + buttons.append( + [ + InlineKeyboardButton( + text=texts.t("ADMIN_PAYMENTS_ITEM_DETAILS", "📄 Details"), + callback_data=f"admin_payment_{record.method.value}_{record.local_id}", + ) + ] + ) + + if total_pages > 1: + navigation_row: list[InlineKeyboardButton] = [] + if page > 1: + navigation_row.append( + InlineKeyboardButton( + text="⬅️", + callback_data=f"admin_payments_page_{page - 1}", + ) + ) + + navigation_row.append( + InlineKeyboardButton( + text=f"{page}/{total_pages}", + callback_data="admin_payments_page_current", + ) + ) + + if page < total_pages: + navigation_row.append( + InlineKeyboardButton( + text="➡️", + callback_data=f"admin_payments_page_{page + 1}", + ) + ) + + buttons.append(navigation_row) + + buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")]) + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +def _build_detail_keyboard( + record: PendingPayment, + *, + language: str, +) -> InlineKeyboardMarkup: + texts = get_texts(language) + rows: list[list[InlineKeyboardButton]] = [] + + payment = record.payment + payment_url = getattr(payment, "payment_url", None) + if record.method == PaymentMethod.PAL24: + payment_url = payment.link_url or payment.link_page_url or payment_url + elif record.method == PaymentMethod.WATA: + payment_url = payment.url or payment_url + elif record.method == PaymentMethod.YOOKASSA: + payment_url = getattr(payment, "confirmation_url", None) or payment_url + elif record.method == PaymentMethod.CRYPTOBOT: + payment_url = ( + payment.bot_invoice_url + or payment.mini_app_invoice_url + or payment.web_app_invoice_url + or payment_url + ) + + if payment_url: + rows.append( + [ + InlineKeyboardButton( + text=texts.t("ADMIN_PAYMENT_OPEN_LINK", "🔗 Open link"), + url=payment_url, + ) + ] + ) + + if _is_checkable(record): + rows.append( + [ + InlineKeyboardButton( + text=texts.t("ADMIN_PAYMENT_CHECK_BUTTON", "🔁 Check status"), + callback_data=f"admin_payment_check_{record.method.value}_{record.local_id}", + ) + ] + ) + + rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_payments")]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def _format_user_line(user: User) -> str: + username = format_username(user.username, user.telegram_id, user.full_name) + return f"👤 {html.escape(username)} ({user.telegram_id})" + + +def _build_record_lines( + record: PendingPayment, + *, + index: int, + texts, + language: str, +) -> list[str]: + amount = settings.format_price(record.amount_kopeks) + if record.method == PaymentMethod.CRYPTOBOT: + crypto_amount = getattr(record.payment, "amount", None) + crypto_asset = getattr(record.payment, "asset", None) + if crypto_amount and crypto_asset: + amount = f"{crypto_amount} {crypto_asset}" + method_name = _method_display(record.method) + emoji, status_text = _status_info(record, texts=texts) + created = format_datetime(record.created_at) + age = format_time_ago(record.created_at, language) + identifier = html.escape(str(record.identifier)) if record.identifier else "" + + lines = [ + f"{index}. {html.escape(method_name)} — {amount}", + f" {emoji} {status_text}", + f" 🕒 {created} ({age})", + _format_user_line(record.user), + ] + + if identifier: + lines.append(f" 🆔 {identifier}") + + return lines + + +def _build_payment_details_text(record: PendingPayment, *, texts, language: str) -> str: + method_name = _method_display(record.method) + emoji, status_text = _status_info(record, texts=texts) + amount = settings.format_price(record.amount_kopeks) + if record.method == PaymentMethod.CRYPTOBOT: + crypto_amount = getattr(record.payment, "amount", None) + crypto_asset = getattr(record.payment, "asset", None) + if crypto_amount and crypto_asset: + amount = f"{crypto_amount} {crypto_asset}" + created = format_datetime(record.created_at) + age = format_time_ago(record.created_at, language) + identifier = html.escape(str(record.identifier)) if record.identifier else "—" + lines = [ + texts.t("ADMIN_PAYMENT_DETAILS_TITLE", "💳 Payment details"), + "", + f"{html.escape(method_name)}", + f"{emoji} {status_text}", + "", + f"💰 {texts.t('ADMIN_PAYMENT_AMOUNT', 'Amount')}: {amount}", + f"🕒 {texts.t('ADMIN_PAYMENT_CREATED', 'Created')}: {created} ({age})", + f"🆔 ID: {identifier}", + _format_user_line(record.user), + ] + + if record.expires_at: + expires_at = format_datetime(record.expires_at) + lines.append(f"⏳ {texts.t('ADMIN_PAYMENT_EXPIRES', 'Expires')}: {expires_at}") + + payment = record.payment + + if record.method == PaymentMethod.PAL24: + if getattr(payment, "payment_status", None): + lines.append( + f"💳 {texts.t('ADMIN_PAYMENT_GATEWAY_STATUS', 'Gateway status')}: " + f"{html.escape(str(payment.payment_status))}" + ) + if getattr(payment, "payment_method", None): + lines.append( + f"🏦 {texts.t('ADMIN_PAYMENT_GATEWAY_METHOD', 'Method')}: " + f"{html.escape(str(payment.payment_method))}" + ) + if getattr(payment, "balance_amount", None): + lines.append( + f"💱 {texts.t('ADMIN_PAYMENT_GATEWAY_AMOUNT', 'Gateway amount')}: " + f"{html.escape(str(payment.balance_amount))}" + ) + if getattr(payment, "payer_account", None): + lines.append( + f"👛 {texts.t('ADMIN_PAYMENT_GATEWAY_ACCOUNT', 'Payer account')}: " + f"{html.escape(str(payment.payer_account))}" + ) + + if record.method == PaymentMethod.MULENPAY: + if getattr(payment, "mulen_payment_id", None): + lines.append( + f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: " + f"{html.escape(str(payment.mulen_payment_id))}" + ) + + if record.method == PaymentMethod.WATA: + if getattr(payment, "order_id", None): + lines.append( + f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: " + f"{html.escape(str(payment.order_id))}" + ) + if getattr(payment, "terminal_public_id", None): + lines.append( + f"🏦 Terminal: {html.escape(str(payment.terminal_public_id))}" + ) + + if record.method == PaymentMethod.HELEKET: + if getattr(payment, "order_id", None): + lines.append( + f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: " + f"{html.escape(str(payment.order_id))}" + ) + if getattr(payment, "payer_amount", None) and getattr(payment, "payer_currency", None): + lines.append( + f"🪙 {texts.t('ADMIN_PAYMENT_PAYER_AMOUNT', 'Paid amount')}: " + f"{html.escape(str(payment.payer_amount))} {html.escape(str(payment.payer_currency))}" + ) + + if record.method == PaymentMethod.YOOKASSA: + if getattr(payment, "payment_method_type", None): + lines.append( + f"💳 {texts.t('ADMIN_PAYMENT_GATEWAY_METHOD', 'Method')}: " + f"{html.escape(str(payment.payment_method_type))}" + ) + if getattr(payment, "confirmation_url", None): + lines.append(texts.t("ADMIN_PAYMENT_HAS_LINK", "🔗 Payment link is available above.")) + + if record.method == PaymentMethod.CRYPTOBOT: + if getattr(payment, "amount", None) and getattr(payment, "asset", None): + lines.append( + f"🪙 {texts.t('ADMIN_PAYMENT_CRYPTO_AMOUNT', 'Crypto amount')}: " + f"{html.escape(str(payment.amount))} {html.escape(str(payment.asset))}" + ) + if getattr(payment, "bot_invoice_url", None) or getattr(payment, "mini_app_invoice_url", None): + lines.append( + texts.t("ADMIN_PAYMENT_HAS_LINK", "🔗 Payment link is available above.") + ) + if getattr(payment, "status", None): + lines.append( + f"📊 {texts.t('ADMIN_PAYMENT_GATEWAY_STATUS', 'Gateway status')}: " + f"{html.escape(str(payment.status))}" + ) + + if record.method == PaymentMethod.TELEGRAM_STARS: + description = getattr(payment, "description", "") or "" + if description: + lines.append(f"📝 {html.escape(description)}") + if getattr(payment, "external_id", None): + lines.append( + f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: " + f"{html.escape(str(payment.external_id))}" + ) + + if _is_checkable(record): + lines.append("") + lines.append(texts.t("ADMIN_PAYMENT_CHECK_HINT", "ℹ️ You can trigger a manual status check.")) + + return "\n".join(lines) + + +def _parse_method_and_id(payload: str, *, prefix: str) -> Optional[tuple[PaymentMethod, int]]: + suffix = payload[len(prefix) :] + try: + method_str, identifier = suffix.rsplit("_", 1) + method = PaymentMethod(method_str) + payment_id = int(identifier) + return method, payment_id + except (ValueError, KeyError): + return None + + +@admin_required +@error_handler +async def show_payments_overview( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +) -> None: + texts = get_texts(db_user.language) + + page = 1 + if callback.data.startswith("admin_payments_page_"): + try: + page = int(callback.data.split("_")[-1]) + except ValueError: + page = 1 + + records = await list_recent_pending_payments(db) + total = len(records) + total_pages = max(1, math.ceil(total / PAGE_SIZE)) + if page < 1: + page = 1 + if page > total_pages: + page = total_pages + + start_index = (page - 1) * PAGE_SIZE + page_records = records[start_index : start_index + PAGE_SIZE] + + header = texts.t("ADMIN_PAYMENTS_TITLE", "💳 Top-up verification") + description = texts.t( + "ADMIN_PAYMENTS_DESCRIPTION", + "Pending invoices created during the last 24 hours.", + ) + notice = texts.t( + "ADMIN_PAYMENTS_NOTICE", + "Only invoices younger than 24 hours and waiting for payment can be checked.", + ) + + lines = [header, "", description] + + if page_records: + for idx, record in enumerate(page_records, start=start_index + 1): + lines.extend(_build_record_lines(record, index=idx, texts=texts, language=db_user.language)) + lines.append("") + lines.append(notice) + else: + empty_text = texts.t("ADMIN_PAYMENTS_EMPTY", "No pending top-ups in the last 24 hours.") + lines.append("") + lines.append(empty_text) + + keyboard = _build_list_keyboard( + page_records, + page=page, + total_pages=total_pages, + language=db_user.language, + ) + + await callback.message.edit_text( + "\n".join(line for line in lines if line is not None), + parse_mode="HTML", + reply_markup=keyboard, + ) + await callback.answer() + + +async def _render_payment_details( + callback: types.CallbackQuery, + db_user: User, + record: PendingPayment, +) -> None: + texts = get_texts(db_user.language) + text = _build_payment_details_text(record, texts=texts, language=db_user.language) + keyboard = _build_detail_keyboard(record, language=db_user.language) + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + + +@admin_required +@error_handler +async def show_payment_details( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +) -> None: + parsed = _parse_method_and_id(callback.data, prefix="admin_payment_") + if not parsed: + await callback.answer("❌ Invalid payment reference", show_alert=True) + return + + method, payment_id = parsed + record = await get_payment_record(db, method, payment_id) + if not record: + await callback.answer("❌ Платеж не найден", show_alert=True) + return + + await _render_payment_details(callback, db_user, record) + await callback.answer() + + +@admin_required +@error_handler +async def manual_check_payment( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +) -> None: + parsed = _parse_method_and_id(callback.data, prefix="admin_payment_check_") + if not parsed: + await callback.answer("❌ Invalid payment reference", show_alert=True) + return + + method, payment_id = parsed + record = await get_payment_record(db, method, payment_id) + texts = get_texts(db_user.language) + + if not record: + await callback.answer(texts.t("ADMIN_PAYMENT_NOT_FOUND", "Payment not found."), show_alert=True) + return + + if not _is_checkable(record): + await callback.answer( + texts.t("ADMIN_PAYMENT_CHECK_NOT_AVAILABLE", "Manual check is not available for this invoice."), + show_alert=True, + ) + return + + payment_service = PaymentService(callback.bot) + updated = await run_manual_check(db, method, payment_id, payment_service) + + if not updated: + await callback.answer( + texts.t("ADMIN_PAYMENT_CHECK_FAILED", "Failed to refresh the payment status."), + show_alert=True, + ) + return + + await _render_payment_details(callback, db_user, updated) + + if updated.status != record.status or updated.is_paid != record.is_paid: + emoji, status_text = _status_info(updated, texts=texts) + message = texts.t( + "ADMIN_PAYMENT_CHECK_SUCCESS", + "Status updated: {status}", + ).format(status=f"{emoji} {status_text}") + else: + message = texts.t( + "ADMIN_PAYMENT_CHECK_NO_CHANGES", + "Status is unchanged after the check.", + ) + + await callback.answer(message, show_alert=True) + + +def register_handlers(dp: Dispatcher) -> None: + dp.callback_query.register(manual_check_payment, F.data.startswith("admin_payment_check_")) + dp.callback_query.register( + show_payment_details, + F.data.startswith("admin_payment_") & ~F.data.startswith("admin_payment_check_"), + ) + dp.callback_query.register(show_payments_overview, F.data.startswith("admin_payments_page_")) + dp.callback_query.register(show_payments_overview, F.data == "admin_payments") diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 0fad927c..5d993543 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -53,6 +53,12 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup: callback_data="admin_submenu_system", ), ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_MAIN_PAYMENTS", "💳 Пополнения"), + callback_data="admin_payments", + ) + ], [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] ]) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 0103e8e6..a562f690 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -130,6 +130,7 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Settings", "ADMIN_MAIN_SUPPORT": "🛟 Support", "ADMIN_MAIN_SYSTEM": "🛠️ System", + "ADMIN_MAIN_PAYMENTS": "💳 Top-ups", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions", "ADMIN_MESSAGES": "📨 Broadcasts", "ADMIN_MESSAGES_ALL_USERS": "📨 All users", @@ -164,6 +165,39 @@ "ADMIN_MONITORING_STOP": "⏸️ Stop", "ADMIN_MONITORING_STOP_HARD": "⏹️ Stop", "ADMIN_MONITORING_TEST_NOTIFICATIONS": "🧪 Test notifications", + "ADMIN_PAYMENTS_TITLE": "💳 Top-up verification", + "ADMIN_PAYMENTS_DESCRIPTION": "Pending top-up invoices created during the last 24 hours.", + "ADMIN_PAYMENTS_NOTICE": "Only invoices younger than 24 hours and waiting for payment can be checked.", + "ADMIN_PAYMENTS_EMPTY": "No pending top-up invoices found in the last 24 hours.", + "ADMIN_PAYMENTS_ITEM_DETAILS": "📄 Details", + "ADMIN_PAYMENT_STATUS_PENDING": "Pending", + "ADMIN_PAYMENT_STATUS_PROCESSING": "Processing", + "ADMIN_PAYMENT_STATUS_PAID": "Paid", + "ADMIN_PAYMENT_STATUS_FAILED": "Failed", + "ADMIN_PAYMENT_STATUS_CANCELED": "Cancelled", + "ADMIN_PAYMENT_STATUS_UNKNOWN": "Unknown status", + "ADMIN_PAYMENT_STATUS_ON_HOLD": "On hold", + "ADMIN_PAYMENT_STATUS_EXPIRED": "Expired", + "ADMIN_PAYMENT_DETAILS_TITLE": "💳 Payment details", + "ADMIN_PAYMENT_AMOUNT": "Amount", + "ADMIN_PAYMENT_CREATED": "Created", + "ADMIN_PAYMENT_EXPIRES": "Expires", + "ADMIN_PAYMENT_GATEWAY_STATUS": "Gateway status", + "ADMIN_PAYMENT_GATEWAY_METHOD": "Method", + "ADMIN_PAYMENT_GATEWAY_AMOUNT": "Gateway amount", + "ADMIN_PAYMENT_GATEWAY_ACCOUNT": "Payer account", + "ADMIN_PAYMENT_GATEWAY_ID": "Gateway ID", + "ADMIN_PAYMENT_PAYER_AMOUNT": "Paid amount", + "ADMIN_PAYMENT_CRYPTO_AMOUNT": "Crypto amount", + "ADMIN_PAYMENT_HAS_LINK": "🔗 A payment link is available via the button above.", + "ADMIN_PAYMENT_OPEN_LINK": "🔗 Open link", + "ADMIN_PAYMENT_CHECK_BUTTON": "🔁 Check status", + "ADMIN_PAYMENT_CHECK_HINT": "ℹ️ You can trigger a manual status check.", + "ADMIN_PAYMENT_CHECK_NOT_AVAILABLE": "Manual status check is not available for this invoice.", + "ADMIN_PAYMENT_CHECK_FAILED": "Failed to refresh the payment status.", + "ADMIN_PAYMENT_CHECK_SUCCESS": "Status updated: {status}", + "ADMIN_PAYMENT_CHECK_NO_CHANGES": "Status did not change after the check.", + "ADMIN_PAYMENT_NOT_FOUND": "Payment not found.", "ADMIN_NODE_DISABLE": "⏸️ Disable", "ADMIN_NODE_ENABLE": "▶️ Enable", "ADMIN_NODE_RESTART": "🔄 Restart", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 20f6ed7e..0b4f5100 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -130,6 +130,7 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Настройки", "ADMIN_MAIN_SUPPORT": "🛟 Поддержка", "ADMIN_MAIN_SYSTEM": "🛠️ Система", + "ADMIN_MAIN_PAYMENTS": "💳 Пополнения", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки", "ADMIN_MESSAGES": "📨 Рассылки", "ADMIN_MESSAGES_ALL_USERS": "📨 Всем пользователям", @@ -164,6 +165,39 @@ "ADMIN_MONITORING_STOP": "⏸️ Остановить", "ADMIN_MONITORING_STOP_HARD": "⏹️ Остановить", "ADMIN_MONITORING_TEST_NOTIFICATIONS": "🧪 Тест уведомлений", + "ADMIN_PAYMENTS_TITLE": "💳 Проверка пополнений", + "ADMIN_PAYMENTS_DESCRIPTION": "Список счетов на пополнение, созданных за последние 24 часа и ожидающих оплаты.", + "ADMIN_PAYMENTS_NOTICE": "Проверять можно только счета моложе 24 часов и со статусом ожидания.", + "ADMIN_PAYMENTS_EMPTY": "За последние 24 часа не найдено счетов на пополнение в ожидании.", + "ADMIN_PAYMENTS_ITEM_DETAILS": "📄 Подробнее", + "ADMIN_PAYMENT_STATUS_PENDING": "Ожидает оплаты", + "ADMIN_PAYMENT_STATUS_PROCESSING": "Обрабатывается", + "ADMIN_PAYMENT_STATUS_PAID": "Оплачен", + "ADMIN_PAYMENT_STATUS_FAILED": "Ошибка", + "ADMIN_PAYMENT_STATUS_CANCELED": "Отменён", + "ADMIN_PAYMENT_STATUS_UNKNOWN": "Статус неизвестен", + "ADMIN_PAYMENT_STATUS_ON_HOLD": "На удержании", + "ADMIN_PAYMENT_STATUS_EXPIRED": "Просрочен", + "ADMIN_PAYMENT_DETAILS_TITLE": "💳 Детали платежа", + "ADMIN_PAYMENT_AMOUNT": "Сумма", + "ADMIN_PAYMENT_CREATED": "Создан", + "ADMIN_PAYMENT_EXPIRES": "Истекает", + "ADMIN_PAYMENT_GATEWAY_STATUS": "Статус в платёжке", + "ADMIN_PAYMENT_GATEWAY_METHOD": "Метод оплаты", + "ADMIN_PAYMENT_GATEWAY_AMOUNT": "Сумма в платёжке", + "ADMIN_PAYMENT_GATEWAY_ACCOUNT": "Счёт плательщика", + "ADMIN_PAYMENT_GATEWAY_ID": "ID в платёжке", + "ADMIN_PAYMENT_PAYER_AMOUNT": "Оплачено", + "ADMIN_PAYMENT_CRYPTO_AMOUNT": "Сумма в криптовалюте", + "ADMIN_PAYMENT_HAS_LINK": "🔗 Ссылка на оплату доступна в кнопке выше.", + "ADMIN_PAYMENT_OPEN_LINK": "🔗 Открыть ссылку", + "ADMIN_PAYMENT_CHECK_BUTTON": "🔁 Проверить статус", + "ADMIN_PAYMENT_CHECK_HINT": "ℹ️ Можно запустить ручную проверку статуса.", + "ADMIN_PAYMENT_CHECK_NOT_AVAILABLE": "Для этого счёта ручная проверка недоступна.", + "ADMIN_PAYMENT_CHECK_FAILED": "Не удалось обновить статус платежа.", + "ADMIN_PAYMENT_CHECK_SUCCESS": "Статус обновлён: {status}", + "ADMIN_PAYMENT_CHECK_NO_CHANGES": "Статус не изменился после проверки.", + "ADMIN_PAYMENT_NOT_FOUND": "Платёж не найден.", "ADMIN_NODE_DISABLE": "⏸️ Отключить", "ADMIN_NODE_ENABLE": "▶️ Включить", "ADMIN_NODE_RESTART": "🔄 Перезагрузить", diff --git a/app/services/payment/cryptobot.py b/app/services/payment/cryptobot.py index ebfbe672..aab5adb5 100644 --- a/app/services/payment/cryptobot.py +++ b/app/services/payment/cryptobot.py @@ -337,3 +337,83 @@ class CryptoBotPaymentMixin: "Ошибка обработки CryptoBot webhook: %s", error, exc_info=True ) return False + + async def get_cryptobot_payment_status( + self, + db: AsyncSession, + local_payment_id: int, + ) -> Optional[Dict[str, Any]]: + """Запрашивает актуальный статус CryptoBot invoice и синхронизирует его.""" + + cryptobot_crud = import_module("app.database.crud.cryptobot") + payment = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id) + if not payment: + logger.warning("CryptoBot платеж %s не найден", local_payment_id) + return None + + if not self.cryptobot_service: + logger.warning("CryptoBot сервис не инициализирован для ручной проверки") + return {"payment": payment} + + invoice_id = payment.invoice_id + try: + invoices = await self.cryptobot_service.get_invoices( + invoice_ids=[invoice_id] + ) + except Exception as error: # pragma: no cover - network errors + logger.error( + "Ошибка запроса статуса CryptoBot invoice %s: %s", + invoice_id, + error, + ) + return {"payment": payment} + + remote_invoice: Optional[Dict[str, Any]] = None + if invoices: + for item in invoices: + if str(item.get("invoice_id")) == str(invoice_id): + remote_invoice = item + break + + if not remote_invoice: + logger.info( + "CryptoBot invoice %s не найден через API при ручной проверке", + invoice_id, + ) + refreshed = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id) + return {"payment": refreshed or payment} + + status = (remote_invoice.get("status") or "").lower() + paid_at_str = remote_invoice.get("paid_at") + paid_at = None + if paid_at_str: + try: + paid_at = datetime.fromisoformat(paid_at_str.replace("Z", "+00:00")).replace( + tzinfo=None + ) + except Exception: # pragma: no cover - defensive parsing + paid_at = None + + if status == "paid": + webhook_payload = { + "update_type": "invoice_paid", + "payload": { + "invoice_id": remote_invoice.get("invoice_id") or invoice_id, + "amount": remote_invoice.get("amount") or payment.amount, + "asset": remote_invoice.get("asset") or payment.asset, + "paid_at": paid_at_str, + "payload": remote_invoice.get("payload") or payment.payload, + }, + } + await self.process_cryptobot_webhook(db, webhook_payload) + else: + if status and status != (payment.status or "").lower(): + await cryptobot_crud.update_cryptobot_payment_status( + db, + invoice_id, + status, + paid_at, + ) + + refreshed = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id) + return {"payment": refreshed or payment} diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index e9489bc3..b4210899 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -207,6 +207,108 @@ class YooKassaPaymentMixin: logger.error("Ошибка создания платежа YooKassa СБП: %s", error) return None + async def get_yookassa_payment_status( + self, + db: AsyncSession, + local_payment_id: int, + ) -> Optional[Dict[str, Any]]: + """Запрашивает статус платежа в YooKassa и синхронизирует локальные данные.""" + + payment_module = import_module("app.services.payment_service") + + payment = await payment_module.get_yookassa_payment_by_local_id(db, local_payment_id) + if not payment: + return None + + remote_data: Optional[Dict[str, Any]] = None + + if getattr(self, "yookassa_service", None): + try: + remote_data = await self.yookassa_service.get_payment_info( # type: ignore[union-attr] + payment.yookassa_payment_id + ) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "Ошибка получения статуса YooKassa %s: %s", + payment.yookassa_payment_id, + error, + ) + + if remote_data: + status = remote_data.get("status") or payment.status + paid = bool(remote_data.get("paid", getattr(payment, "is_paid", False))) + captured_raw = remote_data.get("captured_at") + captured_at = None + if captured_raw: + try: + captured_at = datetime.fromisoformat( + str(captured_raw).replace("Z", "+00:00") + ).replace(tzinfo=None) + except Exception as parse_error: # pragma: no cover - diagnostic log + logger.debug( + "Не удалось распарсить captured_at %s: %s", + captured_raw, + parse_error, + ) + captured_at = None + + payment_method_type = remote_data.get("payment_method_type") + + updated_payment = await payment_module.update_yookassa_payment_status( + db, + payment.yookassa_payment_id, + status=status, + is_paid=paid, + is_captured=paid and status == "succeeded", + captured_at=captured_at, + payment_method_type=payment_method_type, + ) + + if updated_payment: + payment = updated_payment + + transaction_id = getattr(payment, "transaction_id", None) + + if ( + payment.status == "succeeded" + and getattr(payment, "is_paid", False) + ): + if not transaction_id: + try: + await db.refresh(payment) + transaction_id = getattr(payment, "transaction_id", None) + except Exception as refresh_error: # pragma: no cover - defensive logging + logger.warning( + "Не удалось обновить состояние платежа YooKassa %s перед повторной обработкой: %s", + payment.yookassa_payment_id, + refresh_error, + exc_info=True, + ) + + if transaction_id: + logger.info( + "Пропускаем повторную обработку платежа YooKassa %s: уже связан с транзакцией %s", + payment.yookassa_payment_id, + transaction_id, + ) + else: + try: + await self._process_successful_yookassa_payment(db, payment) + except Exception as process_error: # pragma: no cover - defensive logging + logger.error( + "Ошибка обработки успешного платежа YooKassa %s: %s", + payment.yookassa_payment_id, + process_error, + exc_info=True, + ) + + return { + "payment": payment, + "status": payment.status, + "is_paid": getattr(payment, "is_paid", False), + "remote_data": remote_data, + } + async def _process_successful_yookassa_payment( self, db: AsyncSession, @@ -256,12 +358,17 @@ class YooKassaPaymentMixin: is_completed=True, ) - await payment_module.link_yookassa_payment_to_transaction( + linked_payment = await payment_module.link_yookassa_payment_to_transaction( db, payment.yookassa_payment_id, transaction.id, ) + if linked_payment: + payment.transaction_id = getattr(linked_payment, "transaction_id", transaction.id) + if hasattr(linked_payment, "transaction"): + payment.transaction = linked_payment.transaction + user = await payment_module.get_user_by_id(db, payment.user_id) if user: if is_simple_subscription: diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 6acb83dd..b9f414ed 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -55,6 +55,11 @@ async def get_yookassa_payment_by_id(*args, **kwargs): return await yk_crud.get_yookassa_payment_by_id(*args, **kwargs) +async def get_yookassa_payment_by_local_id(*args, **kwargs): + yk_crud = import_module("app.database.crud.yookassa") + return await yk_crud.get_yookassa_payment_by_local_id(*args, **kwargs) + + async def create_transaction(*args, **kwargs): transaction_crud = import_module("app.database.crud.transaction") return await transaction_crud.create_transaction(*args, **kwargs) diff --git a/app/services/payment_verification_service.py b/app/services/payment_verification_service.py new file mode 100644 index 00000000..c2b44a23 --- /dev/null +++ b/app/services/payment_verification_service.py @@ -0,0 +1,767 @@ +"""Helpers for inspecting and manually checking pending top-up payments.""" + +from __future__ import annotations + +import asyncio +import logging +import re +from collections import Counter +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional + +from sqlalchemy import desc, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.config import settings +from app.database.database import AsyncSessionLocal +from app.database.models import ( + CryptoBotPayment, + HeleketPayment, + MulenPayPayment, + Pal24Payment, + PaymentMethod, + Transaction, + TransactionType, + User, + WataPayment, + YooKassaPayment, +) + +logger = logging.getLogger(__name__) + + +PENDING_MAX_AGE = timedelta(hours=24) + + +@dataclass(slots=True) +class PendingPayment: + """Normalized representation of a provider specific payment entry.""" + + method: PaymentMethod + local_id: int + identifier: str + amount_kopeks: int + status: str + is_paid: bool + created_at: datetime + user: User + payment: Any + expires_at: Optional[datetime] = None + + def is_recent(self, max_age: timedelta = PENDING_MAX_AGE) -> bool: + return (datetime.utcnow() - self.created_at) <= max_age + + +SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset( + { + PaymentMethod.YOOKASSA, + PaymentMethod.MULENPAY, + PaymentMethod.PAL24, + PaymentMethod.WATA, + PaymentMethod.HELEKET, + PaymentMethod.CRYPTOBOT, + } +) + + +SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset( + { + PaymentMethod.YOOKASSA, + PaymentMethod.MULENPAY, + PaymentMethod.PAL24, + PaymentMethod.WATA, + PaymentMethod.CRYPTOBOT, + } +) + + +def method_display_name(method: PaymentMethod) -> str: + if method == PaymentMethod.MULENPAY: + return settings.get_mulenpay_display_name() + if method == PaymentMethod.PAL24: + return "PayPalych" + if method == PaymentMethod.YOOKASSA: + return "YooKassa" + if method == PaymentMethod.WATA: + return "WATA" + if method == PaymentMethod.CRYPTOBOT: + return "CryptoBot" + if method == PaymentMethod.HELEKET: + return "Heleket" + if method == PaymentMethod.TELEGRAM_STARS: + return "Telegram Stars" + return method.value + + +def _method_is_enabled(method: PaymentMethod) -> bool: + if method == PaymentMethod.YOOKASSA: + return settings.is_yookassa_enabled() + if method == PaymentMethod.MULENPAY: + return settings.is_mulenpay_enabled() + if method == PaymentMethod.PAL24: + return settings.is_pal24_enabled() + if method == PaymentMethod.WATA: + return settings.is_wata_enabled() + if method == PaymentMethod.CRYPTOBOT: + return settings.is_cryptobot_enabled() + if method == PaymentMethod.HELEKET: + return settings.is_heleket_enabled() + return False + + +def get_enabled_auto_methods() -> List[PaymentMethod]: + return [ + method + for method in SUPPORTED_AUTO_CHECK_METHODS + if _method_is_enabled(method) + ] + + +class AutoPaymentVerificationService: + """Background checker that periodically refreshes pending payments.""" + + def __init__(self) -> None: + self._task: Optional[asyncio.Task[None]] = None + self._payment_service: Optional["PaymentService"] = None + + def set_payment_service(self, payment_service: "PaymentService") -> None: + self._payment_service = payment_service + + def is_running(self) -> bool: + return self._task is not None and not self._task.done() + + async def start(self) -> None: + await self.stop() + + if not settings.is_payment_verification_auto_check_enabled(): + logger.info("Автопроверка пополнений отключена настройками") + return + + if not self._payment_service: + logger.warning( + "Автопроверка пополнений не запущена: PaymentService не инициализирован" + ) + return + + methods = get_enabled_auto_methods() + if not methods: + logger.info( + "Автопроверка пополнений не запущена: нет активных провайдеров" + ) + return + + display_names = ", ".join( + sorted(method_display_name(method) for method in methods) + ) + interval_minutes = settings.get_payment_verification_auto_check_interval() + + self._task = asyncio.create_task(self._auto_check_loop()) + logger.info( + "🔄 Автопроверка пополнений запущена (каждые %s мин) для: %s", + interval_minutes, + display_names, + ) + + async def stop(self) -> None: + if self._task and not self._task.done(): + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + async def _auto_check_loop(self) -> None: + try: + while True: + interval_minutes = settings.get_payment_verification_auto_check_interval() + try: + if ( + settings.is_payment_verification_auto_check_enabled() + and self._payment_service + ): + methods = get_enabled_auto_methods() + if methods: + await self._run_checks(methods) + else: + logger.debug( + "Автопроверка пополнений: активных провайдеров нет" + ) + else: + logger.debug( + "Автопроверка пополнений: отключена настройками или сервис не готов" + ) + except asyncio.CancelledError: + raise + except Exception as error: # noqa: BLE001 - логируем непредвиденные ошибки + logger.error( + "Ошибка автопроверки пополнений: %s", + error, + exc_info=True, + ) + + await asyncio.sleep(max(1, interval_minutes) * 60) + except asyncio.CancelledError: + logger.info("Автопроверка пополнений остановлена") + raise + + async def _run_checks(self, methods: List[PaymentMethod]) -> None: + if not self._payment_service: + return + + async with AsyncSessionLocal() as session: + try: + pending = await list_recent_pending_payments(session) + candidates = [ + record + for record in pending + if record.method in methods and not record.is_paid + ] + + if not candidates: + logger.debug( + "Автопроверка пополнений: подходящих ожидающих платежей нет" + ) + return + + counts = Counter(record.method for record in candidates) + summary = ", ".join( + f"{method_display_name(method)}: {count}" + for method, count in sorted( + counts.items(), key=lambda item: method_display_name(item[0]) + ) + ) + logger.info( + "🔄 Автопроверка пополнений: найдено %s инвойсов (%s)", + len(candidates), + summary, + ) + + for record in candidates: + refreshed = await run_manual_check( + session, + record.method, + record.local_id, + self._payment_service, + ) + + if not refreshed: + logger.debug( + "Автопроверка пополнений: не удалось обновить %s %s", + method_display_name(record.method), + record.identifier, + ) + continue + + if refreshed.is_paid and not record.is_paid: + logger.info( + "✅ %s %s отмечен как оплаченный после автопроверки", + method_display_name(refreshed.method), + refreshed.identifier, + ) + elif refreshed.status != record.status: + logger.info( + "ℹ️ %s %s обновлён: %s → %s", + method_display_name(refreshed.method), + refreshed.identifier, + record.status or "—", + refreshed.status or "—", + ) + else: + logger.debug( + "Автопроверка пополнений: %s %s без изменений (%s)", + method_display_name(refreshed.method), + refreshed.identifier, + refreshed.status or "—", + ) + + if session.in_transaction(): + await session.commit() + except Exception: + if session.in_transaction(): + await session.rollback() + raise + + +auto_payment_verification_service = AutoPaymentVerificationService() + +def _is_pal24_pending(payment: Pal24Payment) -> bool: + if payment.is_paid: + return False + status = (payment.status or "").upper() + return status in {"NEW", "PROCESS"} + + +def _is_mulenpay_pending(payment: MulenPayPayment) -> bool: + if payment.is_paid: + return False + status = (payment.status or "").lower() + return status in {"created", "processing", "hold"} + + +def _is_wata_pending(payment: WataPayment) -> bool: + if payment.is_paid: + return False + status = (payment.status or "").lower() + return status not in { + "paid", + "closed", + "declined", + "canceled", + "cancelled", + "expired", + } + + +def _is_heleket_pending(payment: HeleketPayment) -> bool: + if payment.is_paid: + return False + status = (payment.status or "").lower() + return status not in {"paid", "paid_over", "cancel", "canceled", "failed", "fail", "expired"} + + +def _is_yookassa_pending(payment: YooKassaPayment) -> bool: + if getattr(payment, "is_paid", False) and payment.status == "succeeded": + return False + status = (payment.status or "").lower() + return status in {"pending", "waiting_for_capture"} + + +def _is_cryptobot_pending(payment: CryptoBotPayment) -> bool: + status = (payment.status or "").lower() + return status == "active" + + +def _parse_cryptobot_amount_kopeks(payment: CryptoBotPayment) -> int: + payload = payment.payload or "" + match = re.search(r"_(\d+)$", payload) + if match: + try: + return int(match.group(1)) + except ValueError: + return 0 + return 0 + + +def _metadata_is_balance(payment: YooKassaPayment) -> bool: + metadata = getattr(payment, "metadata_json", {}) or {} + payment_type = str(metadata.get("type") or metadata.get("payment_type") or "").lower() + return payment_type.startswith("balance_topup") + + +def _build_record(method: PaymentMethod, payment: Any, *, identifier: str, amount_kopeks: int, + status: str, is_paid: bool, expires_at: Optional[datetime] = None) -> Optional[PendingPayment]: + user = getattr(payment, "user", None) + if user is None: + logger.debug("Skipping %s payment %s without linked user", method.value, identifier) + return None + + created_at = getattr(payment, "created_at", None) + if not isinstance(created_at, datetime): + logger.debug("Skipping %s payment %s without valid created_at", method.value, identifier) + return None + + local_id = getattr(payment, "id", None) + if local_id is None: + logger.debug("Skipping %s payment without local id", method.value) + return None + + return PendingPayment( + method=method, + local_id=int(local_id), + identifier=identifier, + amount_kopeks=amount_kopeks, + status=status, + is_paid=is_paid, + created_at=created_at, + user=user, + payment=payment, + expires_at=expires_at, + ) + + +async def _fetch_pal24_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]: + stmt = ( + select(Pal24Payment) + .options(selectinload(Pal24Payment.user)) + .where(Pal24Payment.created_at >= cutoff) + .order_by(desc(Pal24Payment.created_at)) + ) + result = await db.execute(stmt) + records: List[PendingPayment] = [] + for payment in result.scalars().all(): + if not _is_pal24_pending(payment): + continue + record = _build_record( + PaymentMethod.PAL24, + payment, + identifier=payment.bill_id, + 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_mulenpay_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]: + stmt = ( + select(MulenPayPayment) + .options(selectinload(MulenPayPayment.user)) + .where(MulenPayPayment.created_at >= cutoff) + .order_by(desc(MulenPayPayment.created_at)) + ) + result = await db.execute(stmt) + records: List[PendingPayment] = [] + for payment in result.scalars().all(): + if not _is_mulenpay_pending(payment): + continue + record = _build_record( + PaymentMethod.MULENPAY, + payment, + identifier=payment.uuid, + amount_kopeks=payment.amount_kopeks, + status=payment.status or "", + is_paid=bool(payment.is_paid), + ) + if record: + records.append(record) + return records + + +async def _fetch_wata_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]: + stmt = ( + select(WataPayment) + .options(selectinload(WataPayment.user)) + .where(WataPayment.created_at >= cutoff) + .order_by(desc(WataPayment.created_at)) + ) + result = await db.execute(stmt) + records: List[PendingPayment] = [] + for payment in result.scalars().all(): + if not _is_wata_pending(payment): + continue + record = _build_record( + PaymentMethod.WATA, + payment, + identifier=payment.payment_link_id, + 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) + .options(selectinload(HeleketPayment.user)) + .where(HeleketPayment.created_at >= cutoff) + .order_by(desc(HeleketPayment.created_at)) + ) + result = await db.execute(stmt) + records: List[PendingPayment] = [] + for payment in result.scalars().all(): + if not _is_heleket_pending(payment): + continue + record = _build_record( + PaymentMethod.HELEKET, + payment, + identifier=payment.uuid, + 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_yookassa_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]: + stmt = ( + select(YooKassaPayment) + .options(selectinload(YooKassaPayment.user)) + .where(YooKassaPayment.created_at >= cutoff) + .order_by(desc(YooKassaPayment.created_at)) + ) + result = await db.execute(stmt) + records: List[PendingPayment] = [] + for payment in result.scalars().all(): + if payment.transaction_id: + continue + if not _metadata_is_balance(payment): + continue + if not _is_yookassa_pending(payment): + continue + record = _build_record( + PaymentMethod.YOOKASSA, + payment, + identifier=payment.yookassa_payment_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or "", + is_paid=bool(getattr(payment, "is_paid", False)), + ) + if record: + records.append(record) + return records + + +async def _fetch_cryptobot_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]: + stmt = ( + select(CryptoBotPayment) + .options(selectinload(CryptoBotPayment.user)) + .where(CryptoBotPayment.created_at >= cutoff) + .order_by(desc(CryptoBotPayment.created_at)) + ) + result = await db.execute(stmt) + records: List[PendingPayment] = [] + for payment in result.scalars().all(): + status = (payment.status or "").lower() + if not _is_cryptobot_pending(payment) and status != "paid": + continue + amount_kopeks = _parse_cryptobot_amount_kopeks(payment) + record = _build_record( + PaymentMethod.CRYPTOBOT, + payment, + identifier=payment.invoice_id, + amount_kopeks=amount_kopeks, + status=payment.status or "", + is_paid=bool(payment.is_paid), + ) + if record: + records.append(record) + return records + + +async def _fetch_stars_transactions(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]: + stmt = ( + select(Transaction) + .options(selectinload(Transaction.user)) + .where( + Transaction.created_at >= cutoff, + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.payment_method == PaymentMethod.TELEGRAM_STARS.value, + ) + .order_by(desc(Transaction.created_at)) + ) + result = await db.execute(stmt) + records: List[PendingPayment] = [] + for transaction in result.scalars().all(): + record = _build_record( + PaymentMethod.TELEGRAM_STARS, + transaction, + identifier=transaction.external_id or str(transaction.id), + amount_kopeks=transaction.amount_kopeks, + status="paid" if transaction.is_completed else "pending", + is_paid=bool(transaction.is_completed), + ) + if record: + records.append(record) + return records + + +async def list_recent_pending_payments( + db: AsyncSession, + *, + max_age: timedelta = PENDING_MAX_AGE, +) -> List[PendingPayment]: + """Return pending payments (top-ups) from supported providers within the age window.""" + + cutoff = datetime.utcnow() - max_age + + tasks: Iterable[List[PendingPayment]] = ( + await _fetch_yookassa_payments(db, cutoff), + await _fetch_pal24_payments(db, cutoff), + await _fetch_mulenpay_payments(db, cutoff), + await _fetch_wata_payments(db, cutoff), + await _fetch_heleket_payments(db, cutoff), + await _fetch_cryptobot_payments(db, cutoff), + await _fetch_stars_transactions(db, cutoff), + ) + + records: List[PendingPayment] = [] + for batch in tasks: + records.extend(batch) + + records.sort(key=lambda item: item.created_at, reverse=True) + return records + + +async def get_payment_record( + db: AsyncSession, + method: PaymentMethod, + local_payment_id: int, +) -> Optional[PendingPayment]: + """Load single payment record and normalize it to :class:`PendingPayment`.""" + + cutoff = datetime.utcnow() - PENDING_MAX_AGE + + if method == PaymentMethod.PAL24: + payment = await db.get(Pal24Payment, local_payment_id) + if not payment: + return None + await db.refresh(payment, attribute_names=["user"]) + return _build_record( + method, + payment, + identifier=payment.bill_id, + 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.MULENPAY: + payment = await db.get(MulenPayPayment, local_payment_id) + if not payment: + return None + await db.refresh(payment, attribute_names=["user"]) + return _build_record( + method, + payment, + identifier=payment.uuid, + amount_kopeks=payment.amount_kopeks, + status=payment.status or "", + is_paid=bool(payment.is_paid), + ) + + if method == PaymentMethod.WATA: + payment = await db.get(WataPayment, local_payment_id) + if not payment: + return None + await db.refresh(payment, attribute_names=["user"]) + return _build_record( + method, + payment, + identifier=payment.payment_link_id, + 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: + return None + await db.refresh(payment, attribute_names=["user"]) + return _build_record( + method, + payment, + identifier=payment.uuid, + 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.YOOKASSA: + payment = await db.get(YooKassaPayment, local_payment_id) + if not payment: + return None + await db.refresh(payment, attribute_names=["user"]) + if payment.created_at < cutoff: + logger.debug("YooKassa payment %s is older than cutoff", payment.id) + return _build_record( + method, + payment, + identifier=payment.yookassa_payment_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or "", + is_paid=bool(getattr(payment, "is_paid", False)), + ) + + if method == PaymentMethod.CRYPTOBOT: + payment = await db.get(CryptoBotPayment, local_payment_id) + if not payment: + return None + await db.refresh(payment, attribute_names=["user"]) + amount_kopeks = _parse_cryptobot_amount_kopeks(payment) + return _build_record( + method, + payment, + identifier=payment.invoice_id, + amount_kopeks=amount_kopeks, + status=payment.status or "", + is_paid=bool(payment.is_paid), + ) + + if method == PaymentMethod.TELEGRAM_STARS: + transaction = await db.get(Transaction, local_payment_id) + if not transaction: + return None + await db.refresh(transaction, attribute_names=["user"]) + if transaction.payment_method != PaymentMethod.TELEGRAM_STARS.value: + return None + return _build_record( + method, + transaction, + identifier=transaction.external_id or str(transaction.id), + amount_kopeks=transaction.amount_kopeks, + status="paid" if transaction.is_completed else "pending", + is_paid=bool(transaction.is_completed), + ) + + logger.debug("Unsupported payment method requested: %s", method) + return None + + +async def run_manual_check( + db: AsyncSession, + method: PaymentMethod, + local_payment_id: int, + payment_service: "PaymentService", +) -> Optional[PendingPayment]: + """Trigger provider specific status refresh and return the updated record.""" + + try: + if method == PaymentMethod.PAL24: + result = await payment_service.get_pal24_payment_status(db, local_payment_id) + payment = result.get("payment") if result else None + elif method == PaymentMethod.MULENPAY: + result = await payment_service.get_mulenpay_payment_status(db, local_payment_id) + payment = result.get("payment") if result else None + 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.HELEKET: + payment = await payment_service.sync_heleket_payment_status( + db, local_payment_id=local_payment_id + ) + elif method == PaymentMethod.YOOKASSA: + result = await payment_service.get_yookassa_payment_status(db, local_payment_id) + payment = result.get("payment") if result else None + elif method == PaymentMethod.CRYPTOBOT: + result = await payment_service.get_cryptobot_payment_status(db, local_payment_id) + payment = result.get("payment") if result else None + else: + logger.warning("Manual check requested for unsupported method %s", method) + return None + + if not payment: + return None + + return await get_payment_record(db, method, local_payment_id) + + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "Manual status check failed for %s payment %s: %s", + method.value, + local_payment_id, + error, + exc_info=True, + ) + return None + + +if TYPE_CHECKING: # pragma: no cover + from app.services.payment_service import PaymentService + diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 0041e4bc..673a8cc0 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -72,6 +72,7 @@ class BotConfigurationService: "LOCALIZATION": "🌍 Языки интерфейса", "CHANNEL": "📣 Обязательная подписка", "PAYMENT": "💳 Общие платежные настройки", + "PAYMENT_VERIFICATION": "🕵️ Проверка платежей", "TELEGRAM": "⭐ Telegram Stars", "CRYPTOBOT": "🪙 CryptoBot", "HELEKET": "🪙 Heleket", @@ -124,6 +125,7 @@ class BotConfigurationService: "LOCALIZATION": "Доступные языки, локализация интерфейса и выбор языка.", "CHANNEL": "Настройки обязательной подписки на канал или группу.", "PAYMENT": "Общие тексты платежей, описания чеков и шаблоны.", + "PAYMENT_VERIFICATION": "Автоматическая проверка пополнений и интервал выполнения.", "YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.", "CRYPTOBOT": "CryptoBot и криптоплатежи через Telegram.", "HELEKET": "Heleket: криптоплатежи, ключи мерчанта и вебхуки.", @@ -292,6 +294,7 @@ class BotConfigurationService: "MULENPAY_": "MULENPAY", "PAL24_": "PAL24", "PAYMENT_": "PAYMENT", + "PAYMENT_VERIFICATION_": "PAYMENT_VERIFICATION", "WATA_": "WATA", "EXTERNAL_ADMIN_": "EXTERNAL_ADMIN", "SIMPLE_SUBSCRIPTION_": "SIMPLE_SUBSCRIPTION", @@ -453,6 +456,24 @@ class BotConfigurationService: "warning": "Пустой токен или неверный вебхук приведут к отказам платежей.", "dependencies": "CRYPTOBOT_API_TOKEN, CRYPTOBOT_WEBHOOK_SECRET", }, + "PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED": { + "description": ( + "Запускает фоновую проверку ожидающих пополнений и повторно обращается " + "к платёжным провайдерам без участия администратора." + ), + "format": "Булево значение.", + "example": "Включено, чтобы автоматически перепроверять зависшие платежи.", + "warning": "Требует активных интеграций YooKassa, {mulenpay_name}, PayPalych, WATA или CryptoBot.", + }, + "PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES": { + "description": ( + "Интервал между автоматическими проверками ожидающих пополнений в минутах." + ), + "format": "Целое число не меньше 1.", + "example": "10", + "warning": "Слишком малый интервал может привести к частым обращениям к платёжным API.", + "dependencies": "PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED", + }, "SUPPORT_TICKET_SLA_MINUTES": { "description": "Лимит времени для ответа модераторов на тикет в минутах.", "format": "Целое число от 1 до 1440.", diff --git a/main.py b/main.py index 30508b0e..f8a68603 100644 --- a/main.py +++ b/main.py @@ -13,6 +13,14 @@ from app.database.database import init_db from app.services.monitoring_service import monitoring_service from app.services.maintenance_service import maintenance_service from app.services.payment_service import PaymentService +from app.services.payment_verification_service import ( + PENDING_MAX_AGE, + SUPPORTED_MANUAL_CHECK_METHODS, + auto_payment_verification_service, + get_enabled_auto_methods, + method_display_name, +) +from app.database.models import PaymentMethod from app.services.version_service import version_service from app.external.webhook_server import WebhookServer from app.external.heleket_webhook import start_heleket_webhook_server @@ -214,6 +222,67 @@ async def main(): logger.error(f"❌ Ошибка запуска автосинхронизации RemnaWave: {e}") payment_service = PaymentService(bot) + auto_payment_verification_service.set_payment_service(payment_service) + + verification_providers: list[str] = [] + auto_verification_active = False + async with timeline.stage( + "Сервис проверки пополнений", + "💳", + success_message="Ручная проверка активна", + ) as stage: + for method in SUPPORTED_MANUAL_CHECK_METHODS: + if method == PaymentMethod.YOOKASSA and settings.is_yookassa_enabled(): + verification_providers.append("YooKassa") + elif method == PaymentMethod.MULENPAY and settings.is_mulenpay_enabled(): + verification_providers.append(settings.get_mulenpay_display_name()) + elif method == PaymentMethod.PAL24 and settings.is_pal24_enabled(): + verification_providers.append("PayPalych") + elif method == PaymentMethod.WATA and settings.is_wata_enabled(): + verification_providers.append("WATA") + elif method == PaymentMethod.HELEKET and settings.is_heleket_enabled(): + verification_providers.append("Heleket") + elif method == PaymentMethod.CRYPTOBOT and settings.is_cryptobot_enabled(): + verification_providers.append("CryptoBot") + + if verification_providers: + hours = int(PENDING_MAX_AGE.total_seconds() // 3600) + stage.log( + "Ожидающие пополнения автоматически отбираются не старше " + f"{hours}ч" + ) + stage.log( + "Доступна ручная проверка для: " + + ", ".join(sorted(verification_providers)) + ) + stage.success( + f"Активно провайдеров: {len(verification_providers)}" + ) + else: + stage.skip("Нет активных провайдеров для ручной проверки") + + if settings.is_payment_verification_auto_check_enabled(): + auto_methods = get_enabled_auto_methods() + if auto_methods: + interval_minutes = settings.get_payment_verification_auto_check_interval() + auto_labels = ", ".join( + sorted(method_display_name(method) for method in auto_methods) + ) + stage.log( + "Автопроверка каждые " + f"{interval_minutes} мин: {auto_labels}" + ) + else: + stage.log( + "Автопроверка включена, но нет активных провайдеров" + ) + else: + stage.log("Автопроверка отключена настройками") + + await auto_payment_verification_service.start() + auto_verification_active = auto_payment_verification_service.is_running() + if auto_verification_active: + stage.log("Фоновая автопроверка запущена") async with timeline.stage( "Внешняя админка", @@ -423,6 +492,18 @@ async def main(): f"Проверка версий: {'Включен' if version_check_task else 'Отключен'}", f"Отчеты: {'Включен' if reporting_service.is_running() else 'Отключен'}", ] + services_lines.append( + "Проверка пополнений: " + + ("Включена" if verification_providers else "Отключена") + ) + services_lines.append( + "Автопроверка пополнений: " + + ( + "Включена" + if auto_payment_verification_service.is_running() + else "Отключена" + ) + ) timeline.log_section("Активные фоновые сервисы", services_lines, icon="📄") timeline.log_summary() @@ -484,7 +565,14 @@ async def main(): if settings.is_version_check_enabled(): logger.info("🔄 Перезапуск сервиса проверки версий...") version_check_task = asyncio.create_task(version_service.start_periodic_check()) - + + if auto_verification_active and not auto_payment_verification_service.is_running(): + logger.warning( + "Сервис автопроверки пополнений остановился, пробуем перезапустить..." + ) + await auto_payment_verification_service.start() + auto_verification_active = auto_payment_verification_service.is_running() + if polling_task.done(): exception = polling_task.exception() if exception: @@ -503,7 +591,15 @@ async def main(): timeline.log_summary() summary_logged = True logger.info("🛑 Начинается корректное завершение работы...") - + + logger.info("ℹ️ Остановка сервиса автопроверки пополнений...") + try: + await auto_payment_verification_service.stop() + except Exception as error: + logger.error( + f"Ошибка остановки сервиса автопроверки пополнений: {error}" + ) + if yookassa_server_task and not yookassa_server_task.done(): logger.info("ℹ️ Остановка YooKassa webhook сервера...") yookassa_server_task.cancel()