From 82af176018411d055dfe34da76a71df6c0e619ce Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 26 Oct 2025 07:16:35 +0300 Subject: [PATCH 01/21] Add admin top-up monitoring and manual payment checks --- app/bot.py | 2 + app/handlers/admin/transactions.py | 374 +++++++++++++++ app/keyboards/admin.py | 6 + app/keyboards/inline.py | 87 ++++ app/localization/locales/en.json | 31 ++ app/localization/locales/ru.json | 31 ++ app/services/admin_transaction_service.py | 530 ++++++++++++++++++++++ app/services/payment/yookassa.py | 72 +++ 8 files changed, 1133 insertions(+) create mode 100644 app/handlers/admin/transactions.py create mode 100644 app/services/admin_transaction_service.py diff --git a/app/bot.py b/app/bot.py index 826d8d6e..b5d2be41 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, + transactions as admin_transactions, ) 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_transactions.register_handlers(dp) common.register_handlers(dp) register_stars_handlers(dp) user_polls.register_handlers(dp) diff --git a/app/handlers/admin/transactions.py b/app/handlers/admin/transactions.py new file mode 100644 index 00000000..f215741f --- /dev/null +++ b/app/handlers/admin/transactions.py @@ -0,0 +1,374 @@ +import html +import json +from datetime import datetime +from math import ceil +from typing import Dict, Optional + +from aiogram import Dispatcher, types, F +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import User +from app.keyboards.inline import ( + get_admin_transaction_view_keyboard, + get_admin_transactions_keyboard, +) +from app.localization.texts import get_texts +from app.services.admin_transaction_service import AdminTransactionService +from app.services.payment_service import PaymentService +from app.utils.decorators import admin_required, error_handler +from app.utils.formatters import format_datetime + + +PER_PAGE = 10 + + +def _parse_method_and_id(data: str, prefix: str) -> tuple[Optional[str], Optional[int], int]: + parts = data.split("_") + method: Optional[str] = None + local_id: Optional[int] = None + page = 1 + + if len(parts) >= 5: + method = parts[3] + try: + local_id = int(parts[4]) + except (TypeError, ValueError): + local_id = None + if len(parts) >= 6 and parts[5].startswith("p"): + try: + page = int(parts[5][1:]) + except (TypeError, ValueError): + page = 1 + return method, local_id, page + + +def _method_display(texts, record, default: str = "") -> str: + method = record.method + payment_type = (record.payment_method_type or "").lower() + + if method == "yookassa": + if payment_type == "sbp": + return texts.t("ADMIN_TRANSACTIONS_METHOD_YOOKASSA_SBP", "🏦 YooKassa (СБП)") + return texts.t("ADMIN_TRANSACTIONS_METHOD_YOOKASSA", "💳 YooKassa") + if method == "mulenpay": + name = settings.get_mulenpay_display_name() + return texts.t( + "ADMIN_TRANSACTIONS_METHOD_MULENPAY", + f"💳 {name}", + ).format(name=name) + if method == "pal24": + return texts.t("ADMIN_TRANSACTIONS_METHOD_PAL24", "🏦 PayPalych") + if method == "wata": + return texts.t("ADMIN_TRANSACTIONS_METHOD_WATA", "🏧 WATA") + if method == "heleket": + return texts.t("ADMIN_TRANSACTIONS_METHOD_HELEKET", "🪙 Heleket") + if method == "cryptobot": + return texts.t("ADMIN_TRANSACTIONS_METHOD_CRYPTOBOT", "🪙 CryptoBot") + if method == "telegram_stars": + return texts.t("ADMIN_TRANSACTIONS_METHOD_TELEGRAM_STARS", "⭐ Telegram Stars") + if method == "tribute": + return texts.t("ADMIN_TRANSACTIONS_METHOD_TRIBUTE", "💎 Tribute") + if method == "manual": + return texts.t("ADMIN_TRANSACTIONS_METHOD_MANUAL", "🛠️ Вручную") + return default or method + + +def _status_display(texts, status: str) -> str: + mapping: Dict[str, str] = { + "pending": texts.t("ADMIN_TRANSACTIONS_STATUS_PENDING", "Ожидание"), + "paid": texts.t("ADMIN_TRANSACTIONS_STATUS_PAID", "Оплачен"), + "failed": texts.t("ADMIN_TRANSACTIONS_STATUS_FAILED", "Неудача"), + "expired": texts.t("ADMIN_TRANSACTIONS_STATUS_EXPIRED", "Истёк"), + "unknown": texts.t("ADMIN_TRANSACTIONS_STATUS_UNKNOWN", "Неизвестно"), + } + return mapping.get(status, mapping["unknown"]) + + +def _format_dt(dt: Optional[datetime]) -> str: + if not dt: + return "—" + return format_datetime(dt, "%d.%m.%Y %H:%M") + + +def _manual_check_reason(texts, reason: Optional[str]) -> str: + if reason == "too_old": + return texts.t("ADMIN_TRANSACTION_CHECK_TOO_OLD", "Платёж старше 24 часов.") + if reason == "not_pending": + return texts.t("ADMIN_TRANSACTION_CHECK_NOT_PENDING", "Платёж уже не в ожидании.") + if reason == "unsupported": + return texts.t( + "ADMIN_TRANSACTION_CHECK_NOT_SUPPORTED", + "Ручная проверка не поддерживается для этого метода.", + ) + if reason == "not_found": + return texts.t("ADMIN_TRANSACTION_CHECK_NOT_FOUND", "Платёж не найден или уже удалён.") + if reason == "service_disabled": + return texts.t( + "ADMIN_TRANSACTION_CHECK_SERVICE_DISABLED", + "Провайдер отключён, ручная проверка недоступна.", + ) + return texts.t( + "ADMIN_TRANSACTION_CHECK_NOT_SUPPORTED", + "Ручная проверка не поддерживается для этого метода.", + ) + + +def _render_detail_text(texts, record, page: int, language: str) -> tuple[str, types.InlineKeyboardMarkup]: + method_label = _method_display(texts, record) + status_label = _status_display(texts, record.status) + lines = [texts.t("ADMIN_TRANSACTION_DETAILS_TITLE", "💳 Детали пополнения")] + + user = record.user + if user: + full_name = html.escape(user.full_name or "—") + username = f"@{html.escape(user.username)}" if getattr(user, "username", None) else "—" + telegram_id = getattr(user, "telegram_id", "—") + lines.append(f"👤 {full_name}") + lines.append(f"🆔 Telegram ID: {telegram_id}") + lines.append(f"📱 {username}") + + lines.append(f"💳 Метод: {method_label}") + lines.append(f"📊 Статус: {record.status_emoji()} {status_label}") + if record.status_raw and record.status_raw.lower() != record.status: + lines.append(f" ({html.escape(record.status_raw)})") + lines.append(f"💰 Сумма: {html.escape(record.amount_display)}") + if record.amount_secondary and record.amount_secondary != record.amount_display: + lines.append(f" (~ {html.escape(record.amount_secondary)})") + + lines.append(f"📅 Создан: {_format_dt(record.created_at)}") + if record.updated_at: + lines.append(f"🔄 Обновлён: {_format_dt(record.updated_at)}") + if record.paid_at: + lines.append(f"✅ Оплачен: {_format_dt(record.paid_at)}") + if record.expires_at: + lines.append(f"⌛ Истекает: {_format_dt(record.expires_at)}") + + lines.append(f"🆔 Запись: {record.local_id}") + if record.external_id: + lines.append(f"🌐 Внешний ID: {html.escape(str(record.external_id))}") + if record.transaction_id: + lines.append(f"🧾 Транзакция: {record.transaction_id}") + if record.description: + lines.append(f"📝 {html.escape(record.description)}") + if record.url: + safe_url = html.escape(record.url) + lines.append(f"🔗 Открыть ссылку оплаты") + + if record.metadata: + try: + metadata_str = json.dumps(record.metadata, ensure_ascii=False) + except Exception: + metadata_str = str(record.metadata) + if len(metadata_str) > 600: + metadata_str = metadata_str[:597] + "..." + lines.append(f"🧾 Данные: {html.escape(metadata_str)}") + + if record.callback_payload: + try: + payload_str = json.dumps(record.callback_payload, ensure_ascii=False) + except Exception: + payload_str = str(record.callback_payload) + if len(payload_str) > 600: + payload_str = payload_str[:597] + "..." + lines.append(f"📦 Callback: {html.escape(payload_str)}") + + if record.can_manual_check: + lines.append( + texts.t( + "ADMIN_TRANSACTION_DETAILS_READY_CHECK", + "🔄 Можно выполнить ручную проверку.", + ) + ) + else: + lines.append( + texts.t( + "ADMIN_TRANSACTION_DETAILS_CANNOT_CHECK", + "⚠️ Ручная проверка недоступна: {reason}", + ).format(reason=_manual_check_reason(texts, record.manual_check_reason)) + ) + + keyboard = get_admin_transaction_view_keyboard( + language=language, + method=record.method, + local_id=record.local_id, + page=page, + can_manual_check=record.can_manual_check, + user_id=getattr(record.user, "id", None), + ) + + return "\n".join(lines), keyboard + + +@admin_required +@error_handler +async def show_admin_transactions( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + data = callback.data or "admin_transactions" + page = 1 + if data.startswith("admin_transactions_page_"): + try: + page = int(data.rsplit("_", maxsplit=1)[-1]) + except (TypeError, ValueError): + page = 1 + + if page < 1: + page = 1 + + texts = get_texts(db_user.language) + service = AdminTransactionService() + result = await service.list_payments(db, page=page, per_page=PER_PAGE) + + items = result["items"] + total = result["total"] + pending = result["pending"] + total_pages = max(ceil(total / PER_PAGE), 1) if total else 1 + if page > total_pages: + page = total_pages + result = await service.list_payments(db, page=page, per_page=PER_PAGE) + items = result["items"] + total = result["total"] + pending = result["pending"] + + header_lines = [texts.t("ADMIN_TRANSACTIONS_TITLE", "💳 Пополнения пользователей")] + header_lines.append( + texts.t( + "ADMIN_TRANSACTIONS_SUMMARY", + "📊 Всего: {total} · ⏳ В ожидании: {pending}\nСтраница {page}/{pages}", + ).format(total=total, pending=pending, page=page, pages=total_pages) + ) + + rows: list[str] = [] + keyboard_items = [] + + if not items: + rows.append(texts.t("ADMIN_TRANSACTIONS_EMPTY", "📭 Пополнений пока нет.")) + else: + for index, record in enumerate(items, start=(page - 1) * PER_PAGE + 1): + method_label = _method_display(texts, record) + status_label = _status_display(texts, record.status) + user = record.user + user_display = "—" + if user: + full_name = html.escape(user.full_name or "—") + user_display = full_name + if getattr(user, "telegram_id", None): + user_display += f" (ID: {user.telegram_id})" + created_at = _format_dt(record.created_at) + amount = record.amount_display + if record.amount_secondary and record.amount_secondary != amount: + amount = f"{amount} ({record.amount_secondary})" + + status_line = ( + f"{index}. {record.status_emoji()} {method_label} • {amount}\n" + f" {status_label} — {created_at}\n" + f" {user_display}" + ) + if record.external_id: + status_line += f"\n ID: {html.escape(str(record.external_id))}" + rows.append(status_line) + + keyboard_items.append( + { + "text": f"{record.status_emoji()} {method_label} • {amount}", + "callback": f"admin_tx_view_{record.method}_{record.local_id}_p{page}", + } + ) + + text = "\n\n".join(["\n".join(header_lines), "\n\n".join(rows)]) + + keyboard = get_admin_transactions_keyboard( + keyboard_items, + current_page=page, + total_pages=total_pages, + language=db_user.language, + ) + + await callback.message.edit_text(text, reply_markup=keyboard) + await callback.answer() + + +@admin_required +@error_handler +async def view_admin_transaction( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + method, local_id, page = _parse_method_and_id(callback.data or "", "admin_tx_view") + if not method or local_id is None: + await callback.answer("❌ Некорректный идентификатор", show_alert=True) + return + + texts = get_texts(db_user.language) + service = AdminTransactionService() + record = await service.get_payment_details(db, method, local_id) + + if not record: + await callback.answer("❌ Платёж не найден", show_alert=True) + return + + text, keyboard = _render_detail_text(texts, record, page, db_user.language) + await callback.message.edit_text(text, reply_markup=keyboard, disable_web_page_preview=True) + await callback.answer() + + +@admin_required +@error_handler +async def run_manual_check( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + method, local_id, page = _parse_method_and_id(callback.data or "", "admin_tx_check") + if not method or local_id is None: + await callback.answer("❌ Некорректный идентификатор", show_alert=True) + return + + texts = get_texts(db_user.language) + service = AdminTransactionService(payment_service=PaymentService(callback.bot)) + result = await service.run_manual_check(db, method, local_id) + + if not result.get("ok"): + reason = result.get("error", "unsupported") + await callback.answer(_manual_check_reason(texts, reason), show_alert=True) + return + + updated = await service.get_payment_details(db, method, local_id) + if not updated: + await callback.answer("❌ Платёж не найден", show_alert=True) + return + + text, keyboard = _render_detail_text(texts, updated, page, db_user.language) + await callback.message.edit_text(text, reply_markup=keyboard, disable_web_page_preview=True) + + status_label = _status_display(texts, updated.status) + await callback.answer( + texts.t( + "ADMIN_TRANSACTION_CHECK_SUCCESS", + "Статус обновлён: {status}", + ).format(status=status_label), + show_alert=True, + ) + + +def register_handlers(dp: Dispatcher) -> None: + dp.callback_query.register( + show_admin_transactions, + F.data == "admin_transactions", + ) + dp.callback_query.register( + show_admin_transactions, + F.data.startswith("admin_transactions_page_"), + ) + dp.callback_query.register( + view_admin_transaction, + F.data.startswith("admin_tx_view_"), + ) + dp.callback_query.register( + run_manual_check, + F.data.startswith("admin_tx_check_"), + ) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 0fad927c..d340524d 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -33,6 +33,12 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup: callback_data="admin_submenu_promo", ), ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_MAIN_TRANSACTIONS", "💳 Пополнения"), + callback_data="admin_transactions", + ), + ], [ InlineKeyboardButton( text=_t(texts, "ADMIN_MAIN_SUPPORT", "🛟 Поддержка"), diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 46a64f05..9d5c4302 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -2426,3 +2426,90 @@ def get_admin_ticket_reply_cancel_keyboard(language: str = DEFAULT_LANGUAGE) -> ) ] ]) + + +def get_admin_transactions_keyboard( + items: list[dict], + *, + current_page: int, + total_pages: int, + language: str = DEFAULT_LANGUAGE, +) -> InlineKeyboardMarkup: + texts = get_texts(language) + rows: list[list[InlineKeyboardButton]] = [] + + for item in items: + rows.append([ + InlineKeyboardButton( + text=item.get("text", "—"), + callback_data=item.get("callback", "admin_transactions"), + ) + ]) + + if total_pages > 1: + pagination_row: list[InlineKeyboardButton] = [] + if current_page > 1: + pagination_row.append( + InlineKeyboardButton( + text="⬅️", + callback_data=f"admin_transactions_page_{current_page - 1}", + ) + ) + pagination_row.append( + InlineKeyboardButton( + text=f"{current_page}/{total_pages}", + callback_data=f"admin_transactions_page_{current_page}", + ) + ) + if current_page < total_pages: + pagination_row.append( + InlineKeyboardButton( + text="➡️", + callback_data=f"admin_transactions_page_{current_page + 1}", + ) + ) + rows.append(pagination_row) + + rows.append([ + InlineKeyboardButton(text=texts.ADMIN_BACK_TO_ADMIN, callback_data="admin_panel") + ]) + + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def get_admin_transaction_view_keyboard( + *, + language: str = DEFAULT_LANGUAGE, + method: str, + local_id: int, + page: int, + can_manual_check: bool, + user_id: Optional[int], +) -> InlineKeyboardMarkup: + texts = get_texts(language) + rows: list[list[InlineKeyboardButton]] = [] + + if can_manual_check: + rows.append([ + InlineKeyboardButton( + text=texts.t("ADMIN_TRANSACTION_MANUAL_CHECK_BUTTON", "🔄 Проверить"), + callback_data=f"admin_tx_check_{method}_{local_id}_p{page}", + ) + ]) + + if user_id: + rows.append([ + InlineKeyboardButton( + text=texts.t("ADMIN_TRANSACTION_GO_TO_USER", "👤 К пользователю"), + callback_data=f"admin_user_manage_{user_id}", + ) + ]) + + rows.append([ + InlineKeyboardButton( + text=texts.t("ADMIN_TRANSACTION_BACK_BUTTON", "⬅️ К списку"), + callback_data=f"admin_transactions_page_{page}", + ) + ]) + + return InlineKeyboardMarkup(inline_keyboard=rows) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 0103e8e6..36429f4d 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_TRANSACTIONS": "💳 Top-ups", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions", "ADMIN_MESSAGES": "📨 Broadcasts", "ADMIN_MESSAGES_ALL_USERS": "📨 All users", @@ -1422,3 +1423,33 @@ "POLL_COMPLETED": "🙏 Thanks for completing the poll!", "POLL_REWARD_GRANTED": "Reward {amount} has been credited to your balance." } + "ADMIN_TRANSACTIONS_TITLE": "💳 User top-ups", + "ADMIN_TRANSACTIONS_SUMMARY": "📊 Total: {total} · ⏳ Pending: {pending}\nPage {page}/{pages}", + "ADMIN_TRANSACTIONS_EMPTY": "📭 No top-ups yet.", + "ADMIN_TRANSACTIONS_METHOD_YOOKASSA": "💳 YooKassa", + "ADMIN_TRANSACTIONS_METHOD_YOOKASSA_SBP": "🏦 YooKassa (SBP)", + "ADMIN_TRANSACTIONS_METHOD_MULENPAY": "💳 {name}", + "ADMIN_TRANSACTIONS_METHOD_PAL24": "🏦 PayPalych", + "ADMIN_TRANSACTIONS_METHOD_WATA": "🏧 WATA", + "ADMIN_TRANSACTIONS_METHOD_HELEKET": "🪙 Heleket", + "ADMIN_TRANSACTIONS_METHOD_CRYPTOBOT": "🪙 CryptoBot", + "ADMIN_TRANSACTIONS_METHOD_TELEGRAM_STARS": "⭐ Telegram Stars", + "ADMIN_TRANSACTIONS_METHOD_TRIBUTE": "💎 Tribute", + "ADMIN_TRANSACTIONS_METHOD_MANUAL": "🛠️ Manual", + "ADMIN_TRANSACTIONS_STATUS_PENDING": "Pending", + "ADMIN_TRANSACTIONS_STATUS_PAID": "Paid", + "ADMIN_TRANSACTIONS_STATUS_FAILED": "Failed", + "ADMIN_TRANSACTIONS_STATUS_EXPIRED": "Expired", + "ADMIN_TRANSACTIONS_STATUS_UNKNOWN": "Unknown", + "ADMIN_TRANSACTION_DETAILS_TITLE": "💳 Top-up details", + "ADMIN_TRANSACTION_DETAILS_READY_CHECK": "🔄 Manual recheck is available.", + "ADMIN_TRANSACTION_DETAILS_CANNOT_CHECK": "⚠️ Manual check unavailable: {reason}", + "ADMIN_TRANSACTION_MANUAL_CHECK_BUTTON": "🔄 Recheck", + "ADMIN_TRANSACTION_GO_TO_USER": "👤 Open user", + "ADMIN_TRANSACTION_BACK_BUTTON": "⬅️ Back to list", + "ADMIN_TRANSACTION_CHECK_TOO_OLD": "Invoice is older than 24 hours.", + "ADMIN_TRANSACTION_CHECK_NOT_PENDING": "Invoice is no longer pending.", + "ADMIN_TRANSACTION_CHECK_NOT_SUPPORTED": "Manual check is not supported for this provider.", + "ADMIN_TRANSACTION_CHECK_NOT_FOUND": "Payment not found or already removed.", + "ADMIN_TRANSACTION_CHECK_SERVICE_DISABLED": "Provider is disabled, manual check unavailable.", + "ADMIN_TRANSACTION_CHECK_SUCCESS": "Status updated: {status}", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 20f6ed7e..d6045008 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_TRANSACTIONS": "💳 Пополнения", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки", "ADMIN_MESSAGES": "📨 Рассылки", "ADMIN_MESSAGES_ALL_USERS": "📨 Всем пользователям", @@ -1422,3 +1423,33 @@ "POLL_COMPLETED": "🙏 Спасибо за участие в опросе!", "POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс." } + "ADMIN_TRANSACTIONS_TITLE": "💳 Пополнения пользователей", + "ADMIN_TRANSACTIONS_SUMMARY": "📊 Всего: {total} · ⏳ В ожидании: {pending}\nСтраница {page}/{pages}", + "ADMIN_TRANSACTIONS_EMPTY": "📭 Пополнений пока нет.", + "ADMIN_TRANSACTIONS_METHOD_YOOKASSA": "💳 YooKassa", + "ADMIN_TRANSACTIONS_METHOD_YOOKASSA_SBP": "🏦 YooKassa (СБП)", + "ADMIN_TRANSACTIONS_METHOD_MULENPAY": "💳 {name}", + "ADMIN_TRANSACTIONS_METHOD_PAL24": "🏦 PayPalych", + "ADMIN_TRANSACTIONS_METHOD_WATA": "🏧 WATA", + "ADMIN_TRANSACTIONS_METHOD_HELEKET": "🪙 Heleket", + "ADMIN_TRANSACTIONS_METHOD_CRYPTOBOT": "🪙 CryptoBot", + "ADMIN_TRANSACTIONS_METHOD_TELEGRAM_STARS": "⭐ Telegram Stars", + "ADMIN_TRANSACTIONS_METHOD_TRIBUTE": "💎 Tribute", + "ADMIN_TRANSACTIONS_METHOD_MANUAL": "🛠️ Вручную", + "ADMIN_TRANSACTIONS_STATUS_PENDING": "Ожидание", + "ADMIN_TRANSACTIONS_STATUS_PAID": "Оплачен", + "ADMIN_TRANSACTIONS_STATUS_FAILED": "Неудача", + "ADMIN_TRANSACTIONS_STATUS_EXPIRED": "Истёк", + "ADMIN_TRANSACTIONS_STATUS_UNKNOWN": "Неизвестно", + "ADMIN_TRANSACTION_DETAILS_TITLE": "💳 Детали пополнения", + "ADMIN_TRANSACTION_DETAILS_READY_CHECK": "🔄 Можно выполнить ручную проверку.", + "ADMIN_TRANSACTION_DETAILS_CANNOT_CHECK": "⚠️ Ручная проверка недоступна: {reason}", + "ADMIN_TRANSACTION_MANUAL_CHECK_BUTTON": "🔄 Проверить", + "ADMIN_TRANSACTION_GO_TO_USER": "👤 К пользователю", + "ADMIN_TRANSACTION_BACK_BUTTON": "⬅️ К списку", + "ADMIN_TRANSACTION_CHECK_TOO_OLD": "Платёж старше 24 часов.", + "ADMIN_TRANSACTION_CHECK_NOT_PENDING": "Платёж уже не в ожидании.", + "ADMIN_TRANSACTION_CHECK_NOT_SUPPORTED": "Ручная проверка не поддерживается для этого метода.", + "ADMIN_TRANSACTION_CHECK_NOT_FOUND": "Платёж не найден или уже удалён.", + "ADMIN_TRANSACTION_CHECK_SERVICE_DISABLED": "Провайдер отключён, ручная проверка недоступна.", + "ADMIN_TRANSACTION_CHECK_SUCCESS": "Статус обновлён: {status}", diff --git a/app/services/admin_transaction_service.py b/app/services/admin_transaction_service.py new file mode 100644 index 00000000..b7d518ed --- /dev/null +++ b/app/services/admin_transaction_service.py @@ -0,0 +1,530 @@ +"""Utilities for aggregating and inspecting top-up payments in the admin panel.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Any, Dict, Iterable, List, Optional, Tuple + +from sqlalchemy import Select, select +from sqlalchemy.orm import selectinload +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import ( + CryptoBotPayment, + HeleketPayment, + MulenPayPayment, + Pal24Payment, + WataPayment, + YooKassaPayment, +) +from app.services.payment_service import PaymentService + + +MANUAL_CHECK_SUPPORTED_METHODS = {"yookassa", "mulenpay", "pal24", "wata", "heleket"} + +STATUS_EMOJI = { + "pending": "⏳", + "paid": "✅", + "failed": "❌", + "expired": "⌛", + "unknown": "❔", +} + + +@dataclass(slots=True) +class AdminPaymentRecord: + """Normalized representation of a payment across different providers.""" + + method: str + local_id: int + external_id: Optional[str] + status: str + status_raw: Optional[str] + amount_kopeks: Optional[int] + amount_display: str + amount_secondary: Optional[str] + currency: Optional[str] + description: Optional[str] + payment_method_type: Optional[str] + transaction_id: Optional[int] + created_at: Optional[datetime] + updated_at: Optional[datetime] + paid_at: Optional[datetime] + expires_at: Optional[datetime] + metadata: Optional[Any] + callback_payload: Optional[Any] + url: Optional[str] + user: Any + supports_manual_check: bool + can_manual_check: bool + manual_check_reason: Optional[str] + raw_payment: Any + + def status_emoji(self) -> str: + return STATUS_EMOJI.get(self.status, STATUS_EMOJI["unknown"]) + + +class AdminTransactionService: + """Collects payment information for the admin transactions section.""" + + PER_PAGE_DEFAULT = 10 + MAX_PER_PROVIDER = 200 + MANUAL_CHECK_TIMEOUT = timedelta(hours=24) + + def __init__(self, payment_service: Optional[PaymentService] = None) -> None: + self.payment_service = payment_service + + async def list_payments( + self, + db: AsyncSession, + *, + page: int = 1, + per_page: int = PER_PAGE_DEFAULT, + ) -> Dict[str, Any]: + """Return paginated payments aggregated from all enabled providers.""" + + limit = min(self.MAX_PER_PROVIDER, max(per_page * max(page, 1) * 3, per_page)) + payments: List[AdminPaymentRecord] = [] + for fetcher in ( + self._fetch_yookassa_payments, + self._fetch_mulenpay_payments, + self._fetch_pal24_payments, + self._fetch_wata_payments, + self._fetch_heleket_payments, + self._fetch_cryptobot_payments, + ): + payments.extend(await fetcher(db, limit)) + + payments.sort(key=lambda item: item.created_at or datetime.min, reverse=True) + + total = len(payments) + pending_count = sum(1 for item in payments if item.status == "pending") + + start_index = max(page - 1, 0) * per_page + end_index = start_index + per_page + page_items = payments[start_index:end_index] + + return { + "items": page_items, + "total": total, + "pending": pending_count, + "page": page, + "per_page": per_page, + } + + async def get_payment_details( + self, + db: AsyncSession, + method: str, + local_id: int, + ) -> Optional[AdminPaymentRecord]: + """Fetch a single payment and normalize it for rendering.""" + + method_normalized = method.lower() + if method_normalized == "yookassa": + payment = await self._fetch_single(db, YooKassaPayment, local_id) + elif method_normalized == "mulenpay": + payment = await self._fetch_single(db, MulenPayPayment, local_id) + elif method_normalized == "pal24": + payment = await self._fetch_single(db, Pal24Payment, local_id) + elif method_normalized == "wata": + payment = await self._fetch_single(db, WataPayment, local_id) + elif method_normalized == "heleket": + payment = await self._fetch_single(db, HeleketPayment, local_id) + elif method_normalized == "cryptobot": + payment = await self._fetch_single(db, CryptoBotPayment, local_id) + else: + payment = None + + if not payment: + return None + + return self._normalize_payment(payment, method_normalized) + + async def run_manual_check( + self, + db: AsyncSession, + method: str, + local_id: int, + ) -> Dict[str, Any]: + """Execute provider-specific manual status refresh.""" + + if not self.payment_service: + self.payment_service = PaymentService() + + method_normalized = method.lower() + + current_state = await self.get_payment_details(db, method_normalized, local_id) + if not current_state: + return {"ok": False, "error": "not_found"} + if not current_state.can_manual_check: + reason = current_state.manual_check_reason or "not_pending" + return {"ok": False, "error": reason} + + if method_normalized == "yookassa": + result = await self.payment_service.sync_yookassa_payment_status( + db, local_payment_id=local_id + ) + elif method_normalized == "mulenpay": + result = await self.payment_service.get_mulenpay_payment_status( + db, local_id + ) + elif method_normalized == "pal24": + result = await self.payment_service.get_pal24_payment_status( + db, local_id + ) + elif method_normalized == "wata": + result = await self.payment_service.get_wata_payment_status( + db, local_id + ) + elif method_normalized == "heleket": + result = await self.payment_service.sync_heleket_payment_status( + db, local_payment_id=local_id + ) + else: + return {"ok": False, "error": "unsupported"} + + if not result: + return {"ok": False, "error": "not_found"} + + if isinstance(result, dict) and result.get("error"): + return {"ok": False, "error": result.get("error")} + + return {"ok": True, "result": result} + + async def _fetch_single( + self, + db: AsyncSession, + model: Any, + local_id: int, + ) -> Any: + stmt: Select[Any] = ( + select(model) + .options(selectinload(model.user)) + .where(model.id == local_id) + ) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + async def _fetch_yookassa_payments( + self, + db: AsyncSession, + limit: int, + ) -> Iterable[AdminPaymentRecord]: + if not settings.is_yookassa_enabled(): + return [] + stmt = ( + select(YooKassaPayment) + .options(selectinload(YooKassaPayment.user)) + .order_by(YooKassaPayment.created_at.desc()) + .limit(limit) + ) + result = await db.execute(stmt) + payments = result.scalars().all() + return [self._normalize_payment(payment, "yookassa") for payment in payments] + + async def _fetch_mulenpay_payments( + self, + db: AsyncSession, + limit: int, + ) -> Iterable[AdminPaymentRecord]: + if not settings.is_mulenpay_enabled(): + return [] + stmt = ( + select(MulenPayPayment) + .options(selectinload(MulenPayPayment.user)) + .order_by(MulenPayPayment.created_at.desc()) + .limit(limit) + ) + result = await db.execute(stmt) + payments = result.scalars().all() + return [self._normalize_payment(payment, "mulenpay") for payment in payments] + + async def _fetch_pal24_payments( + self, + db: AsyncSession, + limit: int, + ) -> Iterable[AdminPaymentRecord]: + if not settings.is_pal24_enabled(): + return [] + stmt = ( + select(Pal24Payment) + .options(selectinload(Pal24Payment.user)) + .order_by(Pal24Payment.created_at.desc()) + .limit(limit) + ) + result = await db.execute(stmt) + payments = result.scalars().all() + return [self._normalize_payment(payment, "pal24") for payment in payments] + + async def _fetch_wata_payments( + self, + db: AsyncSession, + limit: int, + ) -> Iterable[AdminPaymentRecord]: + if not settings.is_wata_enabled(): + return [] + stmt = ( + select(WataPayment) + .options(selectinload(WataPayment.user)) + .order_by(WataPayment.created_at.desc()) + .limit(limit) + ) + result = await db.execute(stmt) + payments = result.scalars().all() + return [self._normalize_payment(payment, "wata") for payment in payments] + + async def _fetch_heleket_payments( + self, + db: AsyncSession, + limit: int, + ) -> Iterable[AdminPaymentRecord]: + if not settings.is_heleket_enabled(): + return [] + stmt = ( + select(HeleketPayment) + .options(selectinload(HeleketPayment.user)) + .order_by(HeleketPayment.created_at.desc()) + .limit(limit) + ) + result = await db.execute(stmt) + payments = result.scalars().all() + return [self._normalize_payment(payment, "heleket") for payment in payments] + + async def _fetch_cryptobot_payments( + self, + db: AsyncSession, + limit: int, + ) -> Iterable[AdminPaymentRecord]: + if not settings.is_cryptobot_enabled(): + return [] + stmt = ( + select(CryptoBotPayment) + .options(selectinload(CryptoBotPayment.user)) + .order_by(CryptoBotPayment.created_at.desc()) + .limit(limit) + ) + result = await db.execute(stmt) + payments = result.scalars().all() + return [self._normalize_payment(payment, "cryptobot") for payment in payments] + + def _normalize_payment(self, payment: Any, method: str) -> AdminPaymentRecord: + amount_kopeks = self._extract_amount_kopeks(payment) + amount_display, amount_secondary = self._build_amount_display(payment, amount_kopeks, method) + + status = self._classify_status(method, payment) + status_raw = getattr(payment, "status", None) + + created_at = getattr(payment, "created_at", None) + if not created_at and hasattr(payment, "yookassa_created_at"): + created_at = getattr(payment, "yookassa_created_at") + + supports_manual = method in MANUAL_CHECK_SUPPORTED_METHODS + can_manual, reason = self._can_manual_check( + method, + status, + created_at, + supports_manual, + ) + + return AdminPaymentRecord( + method=method, + local_id=getattr(payment, "id"), + external_id=self._extract_external_id(method, payment), + status=status, + status_raw=status_raw, + amount_kopeks=amount_kopeks, + amount_display=amount_display, + amount_secondary=amount_secondary, + currency=getattr(payment, "currency", getattr(payment, "asset", None)), + description=getattr(payment, "description", None), + payment_method_type=getattr(payment, "payment_method_type", None), + transaction_id=getattr(payment, "transaction_id", None), + created_at=created_at, + updated_at=getattr(payment, "updated_at", None), + paid_at=getattr(payment, "paid_at", None) or getattr(payment, "captured_at", None), + expires_at=getattr(payment, "expires_at", None), + metadata=self._safe_json(getattr(payment, "metadata_json", None)), + callback_payload=self._safe_json(getattr(payment, "callback_payload", None)), + url=self._extract_url(method, payment), + user=getattr(payment, "user", None), + supports_manual_check=supports_manual, + can_manual_check=can_manual, + manual_check_reason=reason, + raw_payment=payment, + ) + + def _extract_amount_kopeks(self, payment: Any) -> Optional[int]: + if hasattr(payment, "amount_kopeks") and payment.amount_kopeks is not None: + try: + return int(payment.amount_kopeks) + except (TypeError, ValueError): + return None + if hasattr(payment, "amount_float"): + try: + return int(round(payment.amount_float * 100)) + except (TypeError, ValueError): + return None + if hasattr(payment, "amount"): + try: + return int(round(float(payment.amount) * 100)) + except (TypeError, ValueError): + return None + return None + + def _build_amount_display( + self, + payment: Any, + amount_kopeks: Optional[int], + method: str, + ) -> Tuple[str, Optional[str]]: + if amount_kopeks is not None: + primary = settings.format_price(amount_kopeks) + else: + primary = str(getattr(payment, "amount", "—")) + + secondary: Optional[str] = None + if method == "cryptobot": + amount = getattr(payment, "amount", None) + asset = getattr(payment, "asset", None) + if amount and asset: + secondary = f"{amount} {asset}" + elif method == "heleket": + payer_amount = getattr(payment, "payer_amount", None) + payer_currency = getattr(payment, "payer_currency", None) + if payer_amount: + secondary = f"{payer_amount} {payer_currency or ''}".strip() + + return primary, secondary + + def _extract_external_id(self, method: str, payment: Any) -> Optional[str]: + if method == "yookassa": + return getattr(payment, "yookassa_payment_id", None) + if method == "mulenpay": + return str(getattr(payment, "mulen_payment_id", None) or getattr(payment, "uuid", None) or "") or None + if method == "pal24": + return getattr(payment, "bill_id", None) + if method == "wata": + return getattr(payment, "payment_link_id", None) + if method == "heleket": + return getattr(payment, "uuid", None) + if method == "cryptobot": + return getattr(payment, "invoice_id", None) + return None + + def _extract_url(self, method: str, payment: Any) -> Optional[str]: + if method == "yookassa": + return getattr(payment, "confirmation_url", None) + if method == "mulenpay": + return getattr(payment, "payment_url", None) + if method == "pal24": + return getattr(payment, "link_url", None) or getattr(payment, "link_page_url", None) + if method == "wata": + return getattr(payment, "url", None) + if method == "heleket": + return getattr(payment, "payment_url", None) + if method == "cryptobot": + return ( + getattr(payment, "web_app_invoice_url", None) + or getattr(payment, "mini_app_invoice_url", None) + or getattr(payment, "bot_invoice_url", None) + ) + return None + + def _classify_status(self, method: str, payment: Any) -> str: + status_raw = (getattr(payment, "status", None) or "").lower() + + if method == "yookassa": + if status_raw in {"pending", "waiting_for_capture"} and not getattr(payment, "is_paid", False): + return "pending" + if status_raw == "succeeded" or getattr(payment, "is_paid", False): + return "paid" + if status_raw in {"canceled", "cancelled", "refunded", "failed"}: + return "failed" + return "unknown" + + if method == "mulenpay": + if status_raw in {"created", "processing", "hold"} and not getattr(payment, "is_paid", False): + return "pending" + if status_raw == "success" or getattr(payment, "is_paid", False): + return "paid" + if status_raw in {"canceled", "cancelled", "error"}: + return "failed" + return "unknown" + + if method == "pal24": + status_upper = status_raw.upper() + if status_upper in {"NEW", "PROCESS"} and not getattr(payment, "is_paid", False): + return "pending" + if status_upper in {"SUCCESS"} or getattr(payment, "is_paid", False): + return "paid" + if status_upper in {"EXPIRED"}: + return "expired" + if status_upper in {"FAIL", "FAILED", "CANCELED", "CANCELLED"}: + return "failed" + return "unknown" + + if method == "wata": + status_lower = status_raw.lower() + if status_lower in {"opened", "pending", "processing", "new"} and not getattr(payment, "is_paid", False): + return "pending" + if status_lower in {"paid", "closed"} or getattr(payment, "is_paid", False): + return "paid" + if status_lower in {"expired"}: + return "expired" + if status_lower in {"canceled", "cancelled", "declined", "failed"}: + return "failed" + return "unknown" + + if method == "heleket": + status_lower = status_raw.lower() + if status_lower in {"paid", "paid_over", "success"} or getattr(payment, "is_paid", False): + return "paid" + if status_lower in {"cancelled", "canceled", "failed", "error"}: + return "failed" + if status_lower in {"expired"}: + return "expired" + return "pending" + + if method == "cryptobot": + if status_raw == "paid": + return "paid" + if status_raw == "expired": + return "expired" + if status_raw == "active": + return "pending" + return "unknown" + + return "unknown" + + def _can_manual_check( + self, + method: str, + status: str, + created_at: Optional[datetime], + supported: bool, + ) -> Tuple[bool, Optional[str]]: + if not supported: + return False, "unsupported" + if status != "pending": + return False, "not_pending" + if created_at is None: + return True, None + + if datetime.utcnow() - created_at > self.MANUAL_CHECK_TIMEOUT: + return False, "too_old" + + return True, None + + def _safe_json(self, payload: Any) -> Any: + if payload is None: + return None + if isinstance(payload, (dict, list)): + return payload + try: + return json.loads(payload) + except Exception: + return payload diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index e9489bc3..6faaaa46 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -696,6 +696,78 @@ class YooKassaPaymentMixin: return await payment_module.get_yookassa_payment_by_id(db, yookassa_payment_id) + async def sync_yookassa_payment_status( + self, + db: AsyncSession, + *, + local_payment_id: Optional[int] = None, + yookassa_payment_id: Optional[str] = None, + ) -> Optional[Dict[str, Any]]: + """Принудительно обновляет состояние платежа в YooKassa и синхронизирует локальную запись.""" + + payment_module = import_module("app.services.payment_service") + + payment = None + if local_payment_id is not None: + payment = await payment_module.get_yookassa_payment_by_local_id(db, local_payment_id) + if not payment and yookassa_payment_id: + payment = await payment_module.get_yookassa_payment_by_id(db, yookassa_payment_id) + + if not payment: + return None + + if not getattr(self, "yookassa_service", None): + return { + "payment": payment, + "status": payment.status, + "is_paid": payment.is_paid, + "error": "service_disabled", + } + + remote = await self.yookassa_service.get_payment_info(payment.yookassa_payment_id) # type: ignore[union-attr] + if not remote: + return { + "payment": payment, + "status": payment.status, + "is_paid": payment.is_paid, + "error": "not_found", + } + + status = remote.get("status") or payment.status + is_paid = bool(remote.get("paid") or status == "succeeded") + payment_method_type = remote.get("payment_method_type") + + captured_at_raw = remote.get("captured_at") + captured_at = None + if captured_at_raw: + try: + captured_at = datetime.fromisoformat(captured_at_raw.replace("Z", "+00:00")).replace(tzinfo=None) + except Exception: + captured_at = None + + updated_payment = await payment_module.update_yookassa_payment_status( + db, + payment.yookassa_payment_id, + status=status, + is_paid=is_paid, + is_captured=is_paid, + captured_at=captured_at, + payment_method_type=payment_method_type, + ) + + payment = updated_payment or payment + + if payment.status == "succeeded" and payment.is_paid and not payment.transaction_id: + await self._process_successful_yookassa_payment(db, payment) + payment = await payment_module.get_yookassa_payment_by_id(db, payment.yookassa_payment_id) + + return { + "payment": payment, + "status": payment.status, + "is_paid": payment.is_paid, + "remote_data": remote, + } + @staticmethod def _normalise_yookassa_metadata(metadata: Any) -> Dict[str, Any]: if isinstance(metadata, dict): From 651dba2648ff5e0e20dd1beac28e047096196741 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 26 Oct 2025 07:19:41 +0300 Subject: [PATCH 02/21] Revert "Add admin top-up monitoring and manual payment checks" --- app/bot.py | 2 - app/handlers/admin/transactions.py | 374 --------------- app/keyboards/admin.py | 6 - app/keyboards/inline.py | 87 ---- app/localization/locales/en.json | 31 -- app/localization/locales/ru.json | 31 -- app/services/admin_transaction_service.py | 530 ---------------------- app/services/payment/yookassa.py | 72 --- 8 files changed, 1133 deletions(-) delete mode 100644 app/handlers/admin/transactions.py delete mode 100644 app/services/admin_transaction_service.py diff --git a/app/bot.py b/app/bot.py index b5d2be41..826d8d6e 100644 --- a/app/bot.py +++ b/app/bot.py @@ -58,7 +58,6 @@ from app.handlers.admin import ( privacy_policy as admin_privacy_policy, public_offer as admin_public_offer, faq as admin_faq, - transactions as admin_transactions, ) from app.handlers.stars_payments import register_stars_handlers @@ -173,7 +172,6 @@ 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_transactions.register_handlers(dp) common.register_handlers(dp) register_stars_handlers(dp) user_polls.register_handlers(dp) diff --git a/app/handlers/admin/transactions.py b/app/handlers/admin/transactions.py deleted file mode 100644 index f215741f..00000000 --- a/app/handlers/admin/transactions.py +++ /dev/null @@ -1,374 +0,0 @@ -import html -import json -from datetime import datetime -from math import ceil -from typing import Dict, Optional - -from aiogram import Dispatcher, types, F -from sqlalchemy.ext.asyncio import AsyncSession - -from app.config import settings -from app.database.models import User -from app.keyboards.inline import ( - get_admin_transaction_view_keyboard, - get_admin_transactions_keyboard, -) -from app.localization.texts import get_texts -from app.services.admin_transaction_service import AdminTransactionService -from app.services.payment_service import PaymentService -from app.utils.decorators import admin_required, error_handler -from app.utils.formatters import format_datetime - - -PER_PAGE = 10 - - -def _parse_method_and_id(data: str, prefix: str) -> tuple[Optional[str], Optional[int], int]: - parts = data.split("_") - method: Optional[str] = None - local_id: Optional[int] = None - page = 1 - - if len(parts) >= 5: - method = parts[3] - try: - local_id = int(parts[4]) - except (TypeError, ValueError): - local_id = None - if len(parts) >= 6 and parts[5].startswith("p"): - try: - page = int(parts[5][1:]) - except (TypeError, ValueError): - page = 1 - return method, local_id, page - - -def _method_display(texts, record, default: str = "") -> str: - method = record.method - payment_type = (record.payment_method_type or "").lower() - - if method == "yookassa": - if payment_type == "sbp": - return texts.t("ADMIN_TRANSACTIONS_METHOD_YOOKASSA_SBP", "🏦 YooKassa (СБП)") - return texts.t("ADMIN_TRANSACTIONS_METHOD_YOOKASSA", "💳 YooKassa") - if method == "mulenpay": - name = settings.get_mulenpay_display_name() - return texts.t( - "ADMIN_TRANSACTIONS_METHOD_MULENPAY", - f"💳 {name}", - ).format(name=name) - if method == "pal24": - return texts.t("ADMIN_TRANSACTIONS_METHOD_PAL24", "🏦 PayPalych") - if method == "wata": - return texts.t("ADMIN_TRANSACTIONS_METHOD_WATA", "🏧 WATA") - if method == "heleket": - return texts.t("ADMIN_TRANSACTIONS_METHOD_HELEKET", "🪙 Heleket") - if method == "cryptobot": - return texts.t("ADMIN_TRANSACTIONS_METHOD_CRYPTOBOT", "🪙 CryptoBot") - if method == "telegram_stars": - return texts.t("ADMIN_TRANSACTIONS_METHOD_TELEGRAM_STARS", "⭐ Telegram Stars") - if method == "tribute": - return texts.t("ADMIN_TRANSACTIONS_METHOD_TRIBUTE", "💎 Tribute") - if method == "manual": - return texts.t("ADMIN_TRANSACTIONS_METHOD_MANUAL", "🛠️ Вручную") - return default or method - - -def _status_display(texts, status: str) -> str: - mapping: Dict[str, str] = { - "pending": texts.t("ADMIN_TRANSACTIONS_STATUS_PENDING", "Ожидание"), - "paid": texts.t("ADMIN_TRANSACTIONS_STATUS_PAID", "Оплачен"), - "failed": texts.t("ADMIN_TRANSACTIONS_STATUS_FAILED", "Неудача"), - "expired": texts.t("ADMIN_TRANSACTIONS_STATUS_EXPIRED", "Истёк"), - "unknown": texts.t("ADMIN_TRANSACTIONS_STATUS_UNKNOWN", "Неизвестно"), - } - return mapping.get(status, mapping["unknown"]) - - -def _format_dt(dt: Optional[datetime]) -> str: - if not dt: - return "—" - return format_datetime(dt, "%d.%m.%Y %H:%M") - - -def _manual_check_reason(texts, reason: Optional[str]) -> str: - if reason == "too_old": - return texts.t("ADMIN_TRANSACTION_CHECK_TOO_OLD", "Платёж старше 24 часов.") - if reason == "not_pending": - return texts.t("ADMIN_TRANSACTION_CHECK_NOT_PENDING", "Платёж уже не в ожидании.") - if reason == "unsupported": - return texts.t( - "ADMIN_TRANSACTION_CHECK_NOT_SUPPORTED", - "Ручная проверка не поддерживается для этого метода.", - ) - if reason == "not_found": - return texts.t("ADMIN_TRANSACTION_CHECK_NOT_FOUND", "Платёж не найден или уже удалён.") - if reason == "service_disabled": - return texts.t( - "ADMIN_TRANSACTION_CHECK_SERVICE_DISABLED", - "Провайдер отключён, ручная проверка недоступна.", - ) - return texts.t( - "ADMIN_TRANSACTION_CHECK_NOT_SUPPORTED", - "Ручная проверка не поддерживается для этого метода.", - ) - - -def _render_detail_text(texts, record, page: int, language: str) -> tuple[str, types.InlineKeyboardMarkup]: - method_label = _method_display(texts, record) - status_label = _status_display(texts, record.status) - lines = [texts.t("ADMIN_TRANSACTION_DETAILS_TITLE", "💳 Детали пополнения")] - - user = record.user - if user: - full_name = html.escape(user.full_name or "—") - username = f"@{html.escape(user.username)}" if getattr(user, "username", None) else "—" - telegram_id = getattr(user, "telegram_id", "—") - lines.append(f"👤 {full_name}") - lines.append(f"🆔 Telegram ID: {telegram_id}") - lines.append(f"📱 {username}") - - lines.append(f"💳 Метод: {method_label}") - lines.append(f"📊 Статус: {record.status_emoji()} {status_label}") - if record.status_raw and record.status_raw.lower() != record.status: - lines.append(f" ({html.escape(record.status_raw)})") - lines.append(f"💰 Сумма: {html.escape(record.amount_display)}") - if record.amount_secondary and record.amount_secondary != record.amount_display: - lines.append(f" (~ {html.escape(record.amount_secondary)})") - - lines.append(f"📅 Создан: {_format_dt(record.created_at)}") - if record.updated_at: - lines.append(f"🔄 Обновлён: {_format_dt(record.updated_at)}") - if record.paid_at: - lines.append(f"✅ Оплачен: {_format_dt(record.paid_at)}") - if record.expires_at: - lines.append(f"⌛ Истекает: {_format_dt(record.expires_at)}") - - lines.append(f"🆔 Запись: {record.local_id}") - if record.external_id: - lines.append(f"🌐 Внешний ID: {html.escape(str(record.external_id))}") - if record.transaction_id: - lines.append(f"🧾 Транзакция: {record.transaction_id}") - if record.description: - lines.append(f"📝 {html.escape(record.description)}") - if record.url: - safe_url = html.escape(record.url) - lines.append(f"🔗 Открыть ссылку оплаты") - - if record.metadata: - try: - metadata_str = json.dumps(record.metadata, ensure_ascii=False) - except Exception: - metadata_str = str(record.metadata) - if len(metadata_str) > 600: - metadata_str = metadata_str[:597] + "..." - lines.append(f"🧾 Данные: {html.escape(metadata_str)}") - - if record.callback_payload: - try: - payload_str = json.dumps(record.callback_payload, ensure_ascii=False) - except Exception: - payload_str = str(record.callback_payload) - if len(payload_str) > 600: - payload_str = payload_str[:597] + "..." - lines.append(f"📦 Callback: {html.escape(payload_str)}") - - if record.can_manual_check: - lines.append( - texts.t( - "ADMIN_TRANSACTION_DETAILS_READY_CHECK", - "🔄 Можно выполнить ручную проверку.", - ) - ) - else: - lines.append( - texts.t( - "ADMIN_TRANSACTION_DETAILS_CANNOT_CHECK", - "⚠️ Ручная проверка недоступна: {reason}", - ).format(reason=_manual_check_reason(texts, record.manual_check_reason)) - ) - - keyboard = get_admin_transaction_view_keyboard( - language=language, - method=record.method, - local_id=record.local_id, - page=page, - can_manual_check=record.can_manual_check, - user_id=getattr(record.user, "id", None), - ) - - return "\n".join(lines), keyboard - - -@admin_required -@error_handler -async def show_admin_transactions( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - data = callback.data or "admin_transactions" - page = 1 - if data.startswith("admin_transactions_page_"): - try: - page = int(data.rsplit("_", maxsplit=1)[-1]) - except (TypeError, ValueError): - page = 1 - - if page < 1: - page = 1 - - texts = get_texts(db_user.language) - service = AdminTransactionService() - result = await service.list_payments(db, page=page, per_page=PER_PAGE) - - items = result["items"] - total = result["total"] - pending = result["pending"] - total_pages = max(ceil(total / PER_PAGE), 1) if total else 1 - if page > total_pages: - page = total_pages - result = await service.list_payments(db, page=page, per_page=PER_PAGE) - items = result["items"] - total = result["total"] - pending = result["pending"] - - header_lines = [texts.t("ADMIN_TRANSACTIONS_TITLE", "💳 Пополнения пользователей")] - header_lines.append( - texts.t( - "ADMIN_TRANSACTIONS_SUMMARY", - "📊 Всего: {total} · ⏳ В ожидании: {pending}\nСтраница {page}/{pages}", - ).format(total=total, pending=pending, page=page, pages=total_pages) - ) - - rows: list[str] = [] - keyboard_items = [] - - if not items: - rows.append(texts.t("ADMIN_TRANSACTIONS_EMPTY", "📭 Пополнений пока нет.")) - else: - for index, record in enumerate(items, start=(page - 1) * PER_PAGE + 1): - method_label = _method_display(texts, record) - status_label = _status_display(texts, record.status) - user = record.user - user_display = "—" - if user: - full_name = html.escape(user.full_name or "—") - user_display = full_name - if getattr(user, "telegram_id", None): - user_display += f" (ID: {user.telegram_id})" - created_at = _format_dt(record.created_at) - amount = record.amount_display - if record.amount_secondary and record.amount_secondary != amount: - amount = f"{amount} ({record.amount_secondary})" - - status_line = ( - f"{index}. {record.status_emoji()} {method_label} • {amount}\n" - f" {status_label} — {created_at}\n" - f" {user_display}" - ) - if record.external_id: - status_line += f"\n ID: {html.escape(str(record.external_id))}" - rows.append(status_line) - - keyboard_items.append( - { - "text": f"{record.status_emoji()} {method_label} • {amount}", - "callback": f"admin_tx_view_{record.method}_{record.local_id}_p{page}", - } - ) - - text = "\n\n".join(["\n".join(header_lines), "\n\n".join(rows)]) - - keyboard = get_admin_transactions_keyboard( - keyboard_items, - current_page=page, - total_pages=total_pages, - language=db_user.language, - ) - - await callback.message.edit_text(text, reply_markup=keyboard) - await callback.answer() - - -@admin_required -@error_handler -async def view_admin_transaction( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - method, local_id, page = _parse_method_and_id(callback.data or "", "admin_tx_view") - if not method or local_id is None: - await callback.answer("❌ Некорректный идентификатор", show_alert=True) - return - - texts = get_texts(db_user.language) - service = AdminTransactionService() - record = await service.get_payment_details(db, method, local_id) - - if not record: - await callback.answer("❌ Платёж не найден", show_alert=True) - return - - text, keyboard = _render_detail_text(texts, record, page, db_user.language) - await callback.message.edit_text(text, reply_markup=keyboard, disable_web_page_preview=True) - await callback.answer() - - -@admin_required -@error_handler -async def run_manual_check( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - method, local_id, page = _parse_method_and_id(callback.data or "", "admin_tx_check") - if not method or local_id is None: - await callback.answer("❌ Некорректный идентификатор", show_alert=True) - return - - texts = get_texts(db_user.language) - service = AdminTransactionService(payment_service=PaymentService(callback.bot)) - result = await service.run_manual_check(db, method, local_id) - - if not result.get("ok"): - reason = result.get("error", "unsupported") - await callback.answer(_manual_check_reason(texts, reason), show_alert=True) - return - - updated = await service.get_payment_details(db, method, local_id) - if not updated: - await callback.answer("❌ Платёж не найден", show_alert=True) - return - - text, keyboard = _render_detail_text(texts, updated, page, db_user.language) - await callback.message.edit_text(text, reply_markup=keyboard, disable_web_page_preview=True) - - status_label = _status_display(texts, updated.status) - await callback.answer( - texts.t( - "ADMIN_TRANSACTION_CHECK_SUCCESS", - "Статус обновлён: {status}", - ).format(status=status_label), - show_alert=True, - ) - - -def register_handlers(dp: Dispatcher) -> None: - dp.callback_query.register( - show_admin_transactions, - F.data == "admin_transactions", - ) - dp.callback_query.register( - show_admin_transactions, - F.data.startswith("admin_transactions_page_"), - ) - dp.callback_query.register( - view_admin_transaction, - F.data.startswith("admin_tx_view_"), - ) - dp.callback_query.register( - run_manual_check, - F.data.startswith("admin_tx_check_"), - ) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index d340524d..0fad927c 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -33,12 +33,6 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup: callback_data="admin_submenu_promo", ), ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MAIN_TRANSACTIONS", "💳 Пополнения"), - callback_data="admin_transactions", - ), - ], [ InlineKeyboardButton( text=_t(texts, "ADMIN_MAIN_SUPPORT", "🛟 Поддержка"), diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 9d5c4302..46a64f05 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -2426,90 +2426,3 @@ def get_admin_ticket_reply_cancel_keyboard(language: str = DEFAULT_LANGUAGE) -> ) ] ]) - - -def get_admin_transactions_keyboard( - items: list[dict], - *, - current_page: int, - total_pages: int, - language: str = DEFAULT_LANGUAGE, -) -> InlineKeyboardMarkup: - texts = get_texts(language) - rows: list[list[InlineKeyboardButton]] = [] - - for item in items: - rows.append([ - InlineKeyboardButton( - text=item.get("text", "—"), - callback_data=item.get("callback", "admin_transactions"), - ) - ]) - - if total_pages > 1: - pagination_row: list[InlineKeyboardButton] = [] - if current_page > 1: - pagination_row.append( - InlineKeyboardButton( - text="⬅️", - callback_data=f"admin_transactions_page_{current_page - 1}", - ) - ) - pagination_row.append( - InlineKeyboardButton( - text=f"{current_page}/{total_pages}", - callback_data=f"admin_transactions_page_{current_page}", - ) - ) - if current_page < total_pages: - pagination_row.append( - InlineKeyboardButton( - text="➡️", - callback_data=f"admin_transactions_page_{current_page + 1}", - ) - ) - rows.append(pagination_row) - - rows.append([ - InlineKeyboardButton(text=texts.ADMIN_BACK_TO_ADMIN, callback_data="admin_panel") - ]) - - return InlineKeyboardMarkup(inline_keyboard=rows) - - -def get_admin_transaction_view_keyboard( - *, - language: str = DEFAULT_LANGUAGE, - method: str, - local_id: int, - page: int, - can_manual_check: bool, - user_id: Optional[int], -) -> InlineKeyboardMarkup: - texts = get_texts(language) - rows: list[list[InlineKeyboardButton]] = [] - - if can_manual_check: - rows.append([ - InlineKeyboardButton( - text=texts.t("ADMIN_TRANSACTION_MANUAL_CHECK_BUTTON", "🔄 Проверить"), - callback_data=f"admin_tx_check_{method}_{local_id}_p{page}", - ) - ]) - - if user_id: - rows.append([ - InlineKeyboardButton( - text=texts.t("ADMIN_TRANSACTION_GO_TO_USER", "👤 К пользователю"), - callback_data=f"admin_user_manage_{user_id}", - ) - ]) - - rows.append([ - InlineKeyboardButton( - text=texts.t("ADMIN_TRANSACTION_BACK_BUTTON", "⬅️ К списку"), - callback_data=f"admin_transactions_page_{page}", - ) - ]) - - return InlineKeyboardMarkup(inline_keyboard=rows) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 36429f4d..0103e8e6 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -130,7 +130,6 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Settings", "ADMIN_MAIN_SUPPORT": "🛟 Support", "ADMIN_MAIN_SYSTEM": "🛠️ System", - "ADMIN_MAIN_TRANSACTIONS": "💳 Top-ups", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions", "ADMIN_MESSAGES": "📨 Broadcasts", "ADMIN_MESSAGES_ALL_USERS": "📨 All users", @@ -1423,33 +1422,3 @@ "POLL_COMPLETED": "🙏 Thanks for completing the poll!", "POLL_REWARD_GRANTED": "Reward {amount} has been credited to your balance." } - "ADMIN_TRANSACTIONS_TITLE": "💳 User top-ups", - "ADMIN_TRANSACTIONS_SUMMARY": "📊 Total: {total} · ⏳ Pending: {pending}\nPage {page}/{pages}", - "ADMIN_TRANSACTIONS_EMPTY": "📭 No top-ups yet.", - "ADMIN_TRANSACTIONS_METHOD_YOOKASSA": "💳 YooKassa", - "ADMIN_TRANSACTIONS_METHOD_YOOKASSA_SBP": "🏦 YooKassa (SBP)", - "ADMIN_TRANSACTIONS_METHOD_MULENPAY": "💳 {name}", - "ADMIN_TRANSACTIONS_METHOD_PAL24": "🏦 PayPalych", - "ADMIN_TRANSACTIONS_METHOD_WATA": "🏧 WATA", - "ADMIN_TRANSACTIONS_METHOD_HELEKET": "🪙 Heleket", - "ADMIN_TRANSACTIONS_METHOD_CRYPTOBOT": "🪙 CryptoBot", - "ADMIN_TRANSACTIONS_METHOD_TELEGRAM_STARS": "⭐ Telegram Stars", - "ADMIN_TRANSACTIONS_METHOD_TRIBUTE": "💎 Tribute", - "ADMIN_TRANSACTIONS_METHOD_MANUAL": "🛠️ Manual", - "ADMIN_TRANSACTIONS_STATUS_PENDING": "Pending", - "ADMIN_TRANSACTIONS_STATUS_PAID": "Paid", - "ADMIN_TRANSACTIONS_STATUS_FAILED": "Failed", - "ADMIN_TRANSACTIONS_STATUS_EXPIRED": "Expired", - "ADMIN_TRANSACTIONS_STATUS_UNKNOWN": "Unknown", - "ADMIN_TRANSACTION_DETAILS_TITLE": "💳 Top-up details", - "ADMIN_TRANSACTION_DETAILS_READY_CHECK": "🔄 Manual recheck is available.", - "ADMIN_TRANSACTION_DETAILS_CANNOT_CHECK": "⚠️ Manual check unavailable: {reason}", - "ADMIN_TRANSACTION_MANUAL_CHECK_BUTTON": "🔄 Recheck", - "ADMIN_TRANSACTION_GO_TO_USER": "👤 Open user", - "ADMIN_TRANSACTION_BACK_BUTTON": "⬅️ Back to list", - "ADMIN_TRANSACTION_CHECK_TOO_OLD": "Invoice is older than 24 hours.", - "ADMIN_TRANSACTION_CHECK_NOT_PENDING": "Invoice is no longer pending.", - "ADMIN_TRANSACTION_CHECK_NOT_SUPPORTED": "Manual check is not supported for this provider.", - "ADMIN_TRANSACTION_CHECK_NOT_FOUND": "Payment not found or already removed.", - "ADMIN_TRANSACTION_CHECK_SERVICE_DISABLED": "Provider is disabled, manual check unavailable.", - "ADMIN_TRANSACTION_CHECK_SUCCESS": "Status updated: {status}", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index d6045008..20f6ed7e 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -130,7 +130,6 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Настройки", "ADMIN_MAIN_SUPPORT": "🛟 Поддержка", "ADMIN_MAIN_SYSTEM": "🛠️ Система", - "ADMIN_MAIN_TRANSACTIONS": "💳 Пополнения", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки", "ADMIN_MESSAGES": "📨 Рассылки", "ADMIN_MESSAGES_ALL_USERS": "📨 Всем пользователям", @@ -1423,33 +1422,3 @@ "POLL_COMPLETED": "🙏 Спасибо за участие в опросе!", "POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс." } - "ADMIN_TRANSACTIONS_TITLE": "💳 Пополнения пользователей", - "ADMIN_TRANSACTIONS_SUMMARY": "📊 Всего: {total} · ⏳ В ожидании: {pending}\nСтраница {page}/{pages}", - "ADMIN_TRANSACTIONS_EMPTY": "📭 Пополнений пока нет.", - "ADMIN_TRANSACTIONS_METHOD_YOOKASSA": "💳 YooKassa", - "ADMIN_TRANSACTIONS_METHOD_YOOKASSA_SBP": "🏦 YooKassa (СБП)", - "ADMIN_TRANSACTIONS_METHOD_MULENPAY": "💳 {name}", - "ADMIN_TRANSACTIONS_METHOD_PAL24": "🏦 PayPalych", - "ADMIN_TRANSACTIONS_METHOD_WATA": "🏧 WATA", - "ADMIN_TRANSACTIONS_METHOD_HELEKET": "🪙 Heleket", - "ADMIN_TRANSACTIONS_METHOD_CRYPTOBOT": "🪙 CryptoBot", - "ADMIN_TRANSACTIONS_METHOD_TELEGRAM_STARS": "⭐ Telegram Stars", - "ADMIN_TRANSACTIONS_METHOD_TRIBUTE": "💎 Tribute", - "ADMIN_TRANSACTIONS_METHOD_MANUAL": "🛠️ Вручную", - "ADMIN_TRANSACTIONS_STATUS_PENDING": "Ожидание", - "ADMIN_TRANSACTIONS_STATUS_PAID": "Оплачен", - "ADMIN_TRANSACTIONS_STATUS_FAILED": "Неудача", - "ADMIN_TRANSACTIONS_STATUS_EXPIRED": "Истёк", - "ADMIN_TRANSACTIONS_STATUS_UNKNOWN": "Неизвестно", - "ADMIN_TRANSACTION_DETAILS_TITLE": "💳 Детали пополнения", - "ADMIN_TRANSACTION_DETAILS_READY_CHECK": "🔄 Можно выполнить ручную проверку.", - "ADMIN_TRANSACTION_DETAILS_CANNOT_CHECK": "⚠️ Ручная проверка недоступна: {reason}", - "ADMIN_TRANSACTION_MANUAL_CHECK_BUTTON": "🔄 Проверить", - "ADMIN_TRANSACTION_GO_TO_USER": "👤 К пользователю", - "ADMIN_TRANSACTION_BACK_BUTTON": "⬅️ К списку", - "ADMIN_TRANSACTION_CHECK_TOO_OLD": "Платёж старше 24 часов.", - "ADMIN_TRANSACTION_CHECK_NOT_PENDING": "Платёж уже не в ожидании.", - "ADMIN_TRANSACTION_CHECK_NOT_SUPPORTED": "Ручная проверка не поддерживается для этого метода.", - "ADMIN_TRANSACTION_CHECK_NOT_FOUND": "Платёж не найден или уже удалён.", - "ADMIN_TRANSACTION_CHECK_SERVICE_DISABLED": "Провайдер отключён, ручная проверка недоступна.", - "ADMIN_TRANSACTION_CHECK_SUCCESS": "Статус обновлён: {status}", diff --git a/app/services/admin_transaction_service.py b/app/services/admin_transaction_service.py deleted file mode 100644 index b7d518ed..00000000 --- a/app/services/admin_transaction_service.py +++ /dev/null @@ -1,530 +0,0 @@ -"""Utilities for aggregating and inspecting top-up payments in the admin panel.""" - -from __future__ import annotations - -import json -from dataclasses import dataclass -from datetime import datetime, timedelta -from typing import Any, Dict, Iterable, List, Optional, Tuple - -from sqlalchemy import Select, select -from sqlalchemy.orm import selectinload -from sqlalchemy.ext.asyncio import AsyncSession - -from app.config import settings -from app.database.models import ( - CryptoBotPayment, - HeleketPayment, - MulenPayPayment, - Pal24Payment, - WataPayment, - YooKassaPayment, -) -from app.services.payment_service import PaymentService - - -MANUAL_CHECK_SUPPORTED_METHODS = {"yookassa", "mulenpay", "pal24", "wata", "heleket"} - -STATUS_EMOJI = { - "pending": "⏳", - "paid": "✅", - "failed": "❌", - "expired": "⌛", - "unknown": "❔", -} - - -@dataclass(slots=True) -class AdminPaymentRecord: - """Normalized representation of a payment across different providers.""" - - method: str - local_id: int - external_id: Optional[str] - status: str - status_raw: Optional[str] - amount_kopeks: Optional[int] - amount_display: str - amount_secondary: Optional[str] - currency: Optional[str] - description: Optional[str] - payment_method_type: Optional[str] - transaction_id: Optional[int] - created_at: Optional[datetime] - updated_at: Optional[datetime] - paid_at: Optional[datetime] - expires_at: Optional[datetime] - metadata: Optional[Any] - callback_payload: Optional[Any] - url: Optional[str] - user: Any - supports_manual_check: bool - can_manual_check: bool - manual_check_reason: Optional[str] - raw_payment: Any - - def status_emoji(self) -> str: - return STATUS_EMOJI.get(self.status, STATUS_EMOJI["unknown"]) - - -class AdminTransactionService: - """Collects payment information for the admin transactions section.""" - - PER_PAGE_DEFAULT = 10 - MAX_PER_PROVIDER = 200 - MANUAL_CHECK_TIMEOUT = timedelta(hours=24) - - def __init__(self, payment_service: Optional[PaymentService] = None) -> None: - self.payment_service = payment_service - - async def list_payments( - self, - db: AsyncSession, - *, - page: int = 1, - per_page: int = PER_PAGE_DEFAULT, - ) -> Dict[str, Any]: - """Return paginated payments aggregated from all enabled providers.""" - - limit = min(self.MAX_PER_PROVIDER, max(per_page * max(page, 1) * 3, per_page)) - payments: List[AdminPaymentRecord] = [] - for fetcher in ( - self._fetch_yookassa_payments, - self._fetch_mulenpay_payments, - self._fetch_pal24_payments, - self._fetch_wata_payments, - self._fetch_heleket_payments, - self._fetch_cryptobot_payments, - ): - payments.extend(await fetcher(db, limit)) - - payments.sort(key=lambda item: item.created_at or datetime.min, reverse=True) - - total = len(payments) - pending_count = sum(1 for item in payments if item.status == "pending") - - start_index = max(page - 1, 0) * per_page - end_index = start_index + per_page - page_items = payments[start_index:end_index] - - return { - "items": page_items, - "total": total, - "pending": pending_count, - "page": page, - "per_page": per_page, - } - - async def get_payment_details( - self, - db: AsyncSession, - method: str, - local_id: int, - ) -> Optional[AdminPaymentRecord]: - """Fetch a single payment and normalize it for rendering.""" - - method_normalized = method.lower() - if method_normalized == "yookassa": - payment = await self._fetch_single(db, YooKassaPayment, local_id) - elif method_normalized == "mulenpay": - payment = await self._fetch_single(db, MulenPayPayment, local_id) - elif method_normalized == "pal24": - payment = await self._fetch_single(db, Pal24Payment, local_id) - elif method_normalized == "wata": - payment = await self._fetch_single(db, WataPayment, local_id) - elif method_normalized == "heleket": - payment = await self._fetch_single(db, HeleketPayment, local_id) - elif method_normalized == "cryptobot": - payment = await self._fetch_single(db, CryptoBotPayment, local_id) - else: - payment = None - - if not payment: - return None - - return self._normalize_payment(payment, method_normalized) - - async def run_manual_check( - self, - db: AsyncSession, - method: str, - local_id: int, - ) -> Dict[str, Any]: - """Execute provider-specific manual status refresh.""" - - if not self.payment_service: - self.payment_service = PaymentService() - - method_normalized = method.lower() - - current_state = await self.get_payment_details(db, method_normalized, local_id) - if not current_state: - return {"ok": False, "error": "not_found"} - if not current_state.can_manual_check: - reason = current_state.manual_check_reason or "not_pending" - return {"ok": False, "error": reason} - - if method_normalized == "yookassa": - result = await self.payment_service.sync_yookassa_payment_status( - db, local_payment_id=local_id - ) - elif method_normalized == "mulenpay": - result = await self.payment_service.get_mulenpay_payment_status( - db, local_id - ) - elif method_normalized == "pal24": - result = await self.payment_service.get_pal24_payment_status( - db, local_id - ) - elif method_normalized == "wata": - result = await self.payment_service.get_wata_payment_status( - db, local_id - ) - elif method_normalized == "heleket": - result = await self.payment_service.sync_heleket_payment_status( - db, local_payment_id=local_id - ) - else: - return {"ok": False, "error": "unsupported"} - - if not result: - return {"ok": False, "error": "not_found"} - - if isinstance(result, dict) and result.get("error"): - return {"ok": False, "error": result.get("error")} - - return {"ok": True, "result": result} - - async def _fetch_single( - self, - db: AsyncSession, - model: Any, - local_id: int, - ) -> Any: - stmt: Select[Any] = ( - select(model) - .options(selectinload(model.user)) - .where(model.id == local_id) - ) - result = await db.execute(stmt) - return result.scalar_one_or_none() - - async def _fetch_yookassa_payments( - self, - db: AsyncSession, - limit: int, - ) -> Iterable[AdminPaymentRecord]: - if not settings.is_yookassa_enabled(): - return [] - stmt = ( - select(YooKassaPayment) - .options(selectinload(YooKassaPayment.user)) - .order_by(YooKassaPayment.created_at.desc()) - .limit(limit) - ) - result = await db.execute(stmt) - payments = result.scalars().all() - return [self._normalize_payment(payment, "yookassa") for payment in payments] - - async def _fetch_mulenpay_payments( - self, - db: AsyncSession, - limit: int, - ) -> Iterable[AdminPaymentRecord]: - if not settings.is_mulenpay_enabled(): - return [] - stmt = ( - select(MulenPayPayment) - .options(selectinload(MulenPayPayment.user)) - .order_by(MulenPayPayment.created_at.desc()) - .limit(limit) - ) - result = await db.execute(stmt) - payments = result.scalars().all() - return [self._normalize_payment(payment, "mulenpay") for payment in payments] - - async def _fetch_pal24_payments( - self, - db: AsyncSession, - limit: int, - ) -> Iterable[AdminPaymentRecord]: - if not settings.is_pal24_enabled(): - return [] - stmt = ( - select(Pal24Payment) - .options(selectinload(Pal24Payment.user)) - .order_by(Pal24Payment.created_at.desc()) - .limit(limit) - ) - result = await db.execute(stmt) - payments = result.scalars().all() - return [self._normalize_payment(payment, "pal24") for payment in payments] - - async def _fetch_wata_payments( - self, - db: AsyncSession, - limit: int, - ) -> Iterable[AdminPaymentRecord]: - if not settings.is_wata_enabled(): - return [] - stmt = ( - select(WataPayment) - .options(selectinload(WataPayment.user)) - .order_by(WataPayment.created_at.desc()) - .limit(limit) - ) - result = await db.execute(stmt) - payments = result.scalars().all() - return [self._normalize_payment(payment, "wata") for payment in payments] - - async def _fetch_heleket_payments( - self, - db: AsyncSession, - limit: int, - ) -> Iterable[AdminPaymentRecord]: - if not settings.is_heleket_enabled(): - return [] - stmt = ( - select(HeleketPayment) - .options(selectinload(HeleketPayment.user)) - .order_by(HeleketPayment.created_at.desc()) - .limit(limit) - ) - result = await db.execute(stmt) - payments = result.scalars().all() - return [self._normalize_payment(payment, "heleket") for payment in payments] - - async def _fetch_cryptobot_payments( - self, - db: AsyncSession, - limit: int, - ) -> Iterable[AdminPaymentRecord]: - if not settings.is_cryptobot_enabled(): - return [] - stmt = ( - select(CryptoBotPayment) - .options(selectinload(CryptoBotPayment.user)) - .order_by(CryptoBotPayment.created_at.desc()) - .limit(limit) - ) - result = await db.execute(stmt) - payments = result.scalars().all() - return [self._normalize_payment(payment, "cryptobot") for payment in payments] - - def _normalize_payment(self, payment: Any, method: str) -> AdminPaymentRecord: - amount_kopeks = self._extract_amount_kopeks(payment) - amount_display, amount_secondary = self._build_amount_display(payment, amount_kopeks, method) - - status = self._classify_status(method, payment) - status_raw = getattr(payment, "status", None) - - created_at = getattr(payment, "created_at", None) - if not created_at and hasattr(payment, "yookassa_created_at"): - created_at = getattr(payment, "yookassa_created_at") - - supports_manual = method in MANUAL_CHECK_SUPPORTED_METHODS - can_manual, reason = self._can_manual_check( - method, - status, - created_at, - supports_manual, - ) - - return AdminPaymentRecord( - method=method, - local_id=getattr(payment, "id"), - external_id=self._extract_external_id(method, payment), - status=status, - status_raw=status_raw, - amount_kopeks=amount_kopeks, - amount_display=amount_display, - amount_secondary=amount_secondary, - currency=getattr(payment, "currency", getattr(payment, "asset", None)), - description=getattr(payment, "description", None), - payment_method_type=getattr(payment, "payment_method_type", None), - transaction_id=getattr(payment, "transaction_id", None), - created_at=created_at, - updated_at=getattr(payment, "updated_at", None), - paid_at=getattr(payment, "paid_at", None) or getattr(payment, "captured_at", None), - expires_at=getattr(payment, "expires_at", None), - metadata=self._safe_json(getattr(payment, "metadata_json", None)), - callback_payload=self._safe_json(getattr(payment, "callback_payload", None)), - url=self._extract_url(method, payment), - user=getattr(payment, "user", None), - supports_manual_check=supports_manual, - can_manual_check=can_manual, - manual_check_reason=reason, - raw_payment=payment, - ) - - def _extract_amount_kopeks(self, payment: Any) -> Optional[int]: - if hasattr(payment, "amount_kopeks") and payment.amount_kopeks is not None: - try: - return int(payment.amount_kopeks) - except (TypeError, ValueError): - return None - if hasattr(payment, "amount_float"): - try: - return int(round(payment.amount_float * 100)) - except (TypeError, ValueError): - return None - if hasattr(payment, "amount"): - try: - return int(round(float(payment.amount) * 100)) - except (TypeError, ValueError): - return None - return None - - def _build_amount_display( - self, - payment: Any, - amount_kopeks: Optional[int], - method: str, - ) -> Tuple[str, Optional[str]]: - if amount_kopeks is not None: - primary = settings.format_price(amount_kopeks) - else: - primary = str(getattr(payment, "amount", "—")) - - secondary: Optional[str] = None - if method == "cryptobot": - amount = getattr(payment, "amount", None) - asset = getattr(payment, "asset", None) - if amount and asset: - secondary = f"{amount} {asset}" - elif method == "heleket": - payer_amount = getattr(payment, "payer_amount", None) - payer_currency = getattr(payment, "payer_currency", None) - if payer_amount: - secondary = f"{payer_amount} {payer_currency or ''}".strip() - - return primary, secondary - - def _extract_external_id(self, method: str, payment: Any) -> Optional[str]: - if method == "yookassa": - return getattr(payment, "yookassa_payment_id", None) - if method == "mulenpay": - return str(getattr(payment, "mulen_payment_id", None) or getattr(payment, "uuid", None) or "") or None - if method == "pal24": - return getattr(payment, "bill_id", None) - if method == "wata": - return getattr(payment, "payment_link_id", None) - if method == "heleket": - return getattr(payment, "uuid", None) - if method == "cryptobot": - return getattr(payment, "invoice_id", None) - return None - - def _extract_url(self, method: str, payment: Any) -> Optional[str]: - if method == "yookassa": - return getattr(payment, "confirmation_url", None) - if method == "mulenpay": - return getattr(payment, "payment_url", None) - if method == "pal24": - return getattr(payment, "link_url", None) or getattr(payment, "link_page_url", None) - if method == "wata": - return getattr(payment, "url", None) - if method == "heleket": - return getattr(payment, "payment_url", None) - if method == "cryptobot": - return ( - getattr(payment, "web_app_invoice_url", None) - or getattr(payment, "mini_app_invoice_url", None) - or getattr(payment, "bot_invoice_url", None) - ) - return None - - def _classify_status(self, method: str, payment: Any) -> str: - status_raw = (getattr(payment, "status", None) or "").lower() - - if method == "yookassa": - if status_raw in {"pending", "waiting_for_capture"} and not getattr(payment, "is_paid", False): - return "pending" - if status_raw == "succeeded" or getattr(payment, "is_paid", False): - return "paid" - if status_raw in {"canceled", "cancelled", "refunded", "failed"}: - return "failed" - return "unknown" - - if method == "mulenpay": - if status_raw in {"created", "processing", "hold"} and not getattr(payment, "is_paid", False): - return "pending" - if status_raw == "success" or getattr(payment, "is_paid", False): - return "paid" - if status_raw in {"canceled", "cancelled", "error"}: - return "failed" - return "unknown" - - if method == "pal24": - status_upper = status_raw.upper() - if status_upper in {"NEW", "PROCESS"} and not getattr(payment, "is_paid", False): - return "pending" - if status_upper in {"SUCCESS"} or getattr(payment, "is_paid", False): - return "paid" - if status_upper in {"EXPIRED"}: - return "expired" - if status_upper in {"FAIL", "FAILED", "CANCELED", "CANCELLED"}: - return "failed" - return "unknown" - - if method == "wata": - status_lower = status_raw.lower() - if status_lower in {"opened", "pending", "processing", "new"} and not getattr(payment, "is_paid", False): - return "pending" - if status_lower in {"paid", "closed"} or getattr(payment, "is_paid", False): - return "paid" - if status_lower in {"expired"}: - return "expired" - if status_lower in {"canceled", "cancelled", "declined", "failed"}: - return "failed" - return "unknown" - - if method == "heleket": - status_lower = status_raw.lower() - if status_lower in {"paid", "paid_over", "success"} or getattr(payment, "is_paid", False): - return "paid" - if status_lower in {"cancelled", "canceled", "failed", "error"}: - return "failed" - if status_lower in {"expired"}: - return "expired" - return "pending" - - if method == "cryptobot": - if status_raw == "paid": - return "paid" - if status_raw == "expired": - return "expired" - if status_raw == "active": - return "pending" - return "unknown" - - return "unknown" - - def _can_manual_check( - self, - method: str, - status: str, - created_at: Optional[datetime], - supported: bool, - ) -> Tuple[bool, Optional[str]]: - if not supported: - return False, "unsupported" - if status != "pending": - return False, "not_pending" - if created_at is None: - return True, None - - if datetime.utcnow() - created_at > self.MANUAL_CHECK_TIMEOUT: - return False, "too_old" - - return True, None - - def _safe_json(self, payload: Any) -> Any: - if payload is None: - return None - if isinstance(payload, (dict, list)): - return payload - try: - return json.loads(payload) - except Exception: - return payload diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index 6faaaa46..e9489bc3 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -696,78 +696,6 @@ class YooKassaPaymentMixin: return await payment_module.get_yookassa_payment_by_id(db, yookassa_payment_id) - async def sync_yookassa_payment_status( - self, - db: AsyncSession, - *, - local_payment_id: Optional[int] = None, - yookassa_payment_id: Optional[str] = None, - ) -> Optional[Dict[str, Any]]: - """Принудительно обновляет состояние платежа в YooKassa и синхронизирует локальную запись.""" - - payment_module = import_module("app.services.payment_service") - - payment = None - if local_payment_id is not None: - payment = await payment_module.get_yookassa_payment_by_local_id(db, local_payment_id) - if not payment and yookassa_payment_id: - payment = await payment_module.get_yookassa_payment_by_id(db, yookassa_payment_id) - - if not payment: - return None - - if not getattr(self, "yookassa_service", None): - return { - "payment": payment, - "status": payment.status, - "is_paid": payment.is_paid, - "error": "service_disabled", - } - - remote = await self.yookassa_service.get_payment_info(payment.yookassa_payment_id) # type: ignore[union-attr] - if not remote: - return { - "payment": payment, - "status": payment.status, - "is_paid": payment.is_paid, - "error": "not_found", - } - - status = remote.get("status") or payment.status - is_paid = bool(remote.get("paid") or status == "succeeded") - payment_method_type = remote.get("payment_method_type") - - captured_at_raw = remote.get("captured_at") - captured_at = None - if captured_at_raw: - try: - captured_at = datetime.fromisoformat(captured_at_raw.replace("Z", "+00:00")).replace(tzinfo=None) - except Exception: - captured_at = None - - updated_payment = await payment_module.update_yookassa_payment_status( - db, - payment.yookassa_payment_id, - status=status, - is_paid=is_paid, - is_captured=is_paid, - captured_at=captured_at, - payment_method_type=payment_method_type, - ) - - payment = updated_payment or payment - - if payment.status == "succeeded" and payment.is_paid and not payment.transaction_id: - await self._process_successful_yookassa_payment(db, payment) - payment = await payment_module.get_yookassa_payment_by_id(db, payment.yookassa_payment_id) - - return { - "payment": payment, - "status": payment.status, - "is_paid": payment.is_paid, - "remote_data": remote, - } - @staticmethod def _normalise_yookassa_metadata(metadata: Any) -> Dict[str, Any]: if isinstance(metadata, dict): From 63efb31785e47eb6760a5c5db82c94130a58372a Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 26 Oct 2025 07:20:23 +0300 Subject: [PATCH 03/21] Add admin APIs for pending payment checks --- app/database/crud/cryptobot.py | 14 +- app/database/crud/heleket.py | 23 +- app/database/crud/mulenpay.py | 37 +- app/database/crud/pal24.py | 36 +- app/database/crud/wata.py | 36 +- app/database/crud/yookassa.py | 22 +- app/services/payment/yookassa.py | 71 ++++ app/services/pending_payment_service.py | 510 ++++++++++++++++++++++++ app/webapi/routes/transactions.py | 185 ++++++++- app/webapi/schemas/transactions.py | 54 ++- 10 files changed, 951 insertions(+), 37 deletions(-) create mode 100644 app/services/pending_payment_service.py diff --git a/app/database/crud/cryptobot.py b/app/database/crud/cryptobot.py index 0d5eecef..d8cd228a 100644 --- a/app/database/crud/cryptobot.py +++ b/app/database/crud/cryptobot.py @@ -136,21 +136,21 @@ async def get_user_cryptobot_payments( async def get_pending_cryptobot_payments( db: AsyncSession, - older_than_hours: int = 24 + *, + max_age_hours: int = 24, ) -> List[CryptoBotPayment]: - - from datetime import timedelta - cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours) - + + cutoff_time = datetime.utcnow() - timedelta(hours=max_age_hours) + result = await db.execute( select(CryptoBotPayment) .options(selectinload(CryptoBotPayment.user)) .where( and_( CryptoBotPayment.status == "active", - CryptoBotPayment.created_at < cutoff_time + CryptoBotPayment.created_at >= cutoff_time, ) ) - .order_by(CryptoBotPayment.created_at) + .order_by(CryptoBotPayment.created_at.desc()) ) return result.scalars().all() diff --git a/app/database/crud/heleket.py b/app/database/crud/heleket.py index d02d5e83..46023ada 100644 --- a/app/database/crud/heleket.py +++ b/app/database/crud/heleket.py @@ -1,6 +1,6 @@ import logging -from datetime import datetime -from typing import Any, Dict, Optional +from datetime import datetime, timedelta +from typing import Any, Dict, Optional, List from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -96,6 +96,25 @@ async def get_heleket_payment_by_id( return result.scalar_one_or_none() +async def get_pending_heleket_payments( + db: AsyncSession, + *, + max_age_hours: int = 24, +) -> List[HeleketPayment]: + cutoff = datetime.utcnow() - timedelta(hours=max_age_hours) + + result = await db.execute( + select(HeleketPayment) + .options(selectinload(HeleketPayment.user)) + .where( + HeleketPayment.transaction_id.is_(None), + HeleketPayment.created_at >= cutoff, + ) + .order_by(HeleketPayment.created_at.desc()) + ) + return result.scalars().all() + + async def update_heleket_payment( db: AsyncSession, uuid: str, diff --git a/app/database/crud/mulenpay.py b/app/database/crud/mulenpay.py index 17648f4a..3ca4dc1b 100644 --- a/app/database/crud/mulenpay.py +++ b/app/database/crud/mulenpay.py @@ -1,9 +1,9 @@ import logging -from datetime import datetime +from datetime import datetime, timedelta from typing import Optional - from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.config import settings from app.database.models import MulenPayPayment @@ -56,7 +56,9 @@ async def get_mulenpay_payment_by_local_id( db: AsyncSession, payment_id: int ) -> Optional[MulenPayPayment]: result = await db.execute( - select(MulenPayPayment).where(MulenPayPayment.id == payment_id) + select(MulenPayPayment) + .options(selectinload(MulenPayPayment.user)) + .where(MulenPayPayment.id == payment_id) ) return result.scalar_one_or_none() @@ -65,7 +67,9 @@ async def get_mulenpay_payment_by_uuid( db: AsyncSession, uuid: str ) -> Optional[MulenPayPayment]: result = await db.execute( - select(MulenPayPayment).where(MulenPayPayment.uuid == uuid) + select(MulenPayPayment) + .options(selectinload(MulenPayPayment.user)) + .where(MulenPayPayment.uuid == uuid) ) return result.scalar_one_or_none() @@ -74,13 +78,32 @@ async def get_mulenpay_payment_by_mulen_id( db: AsyncSession, mulen_payment_id: int ) -> Optional[MulenPayPayment]: result = await db.execute( - select(MulenPayPayment).where( - MulenPayPayment.mulen_payment_id == mulen_payment_id - ) + select(MulenPayPayment) + .options(selectinload(MulenPayPayment.user)) + .where(MulenPayPayment.mulen_payment_id == mulen_payment_id) ) return result.scalar_one_or_none() +async def get_pending_mulenpay_payments( + db: AsyncSession, + *, + max_age_hours: int = 24, +) -> list[MulenPayPayment]: + cutoff = datetime.utcnow() - timedelta(hours=max_age_hours) + + result = await db.execute( + select(MulenPayPayment) + .options(selectinload(MulenPayPayment.user)) + .where( + MulenPayPayment.is_paid.is_(False), + MulenPayPayment.created_at >= cutoff, + ) + .order_by(MulenPayPayment.created_at.desc()) + ) + return result.scalars().all() + + async def update_mulenpay_payment_status( db: AsyncSession, *, diff --git a/app/database/crud/pal24.py b/app/database/crud/pal24.py index a88c46b4..cd8c25bc 100644 --- a/app/database/crud/pal24.py +++ b/app/database/crud/pal24.py @@ -3,11 +3,12 @@ from __future__ import annotations import logging -from datetime import datetime -from typing import Any, Dict, Optional +from datetime import datetime, timedelta +from typing import Any, Dict, Optional, List from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.database.models import Pal24Payment @@ -62,25 +63,50 @@ async def create_pal24_payment( async def get_pal24_payment_by_id(db: AsyncSession, payment_id: int) -> Optional[Pal24Payment]: result = await db.execute( - select(Pal24Payment).where(Pal24Payment.id == payment_id) + select(Pal24Payment) + .options(selectinload(Pal24Payment.user)) + .where(Pal24Payment.id == payment_id) ) return result.scalar_one_or_none() async def get_pal24_payment_by_bill_id(db: AsyncSession, bill_id: str) -> Optional[Pal24Payment]: result = await db.execute( - select(Pal24Payment).where(Pal24Payment.bill_id == bill_id) + select(Pal24Payment) + .options(selectinload(Pal24Payment.user)) + .where(Pal24Payment.bill_id == bill_id) ) return result.scalar_one_or_none() async def get_pal24_payment_by_order_id(db: AsyncSession, order_id: str) -> Optional[Pal24Payment]: result = await db.execute( - select(Pal24Payment).where(Pal24Payment.order_id == order_id) + select(Pal24Payment) + .options(selectinload(Pal24Payment.user)) + .where(Pal24Payment.order_id == order_id) ) return result.scalar_one_or_none() +async def get_pending_pal24_payments( + db: AsyncSession, + *, + max_age_hours: int = 24, +) -> List[Pal24Payment]: + cutoff = datetime.utcnow() - timedelta(hours=max_age_hours) + + result = await db.execute( + select(Pal24Payment) + .options(selectinload(Pal24Payment.user)) + .where( + Pal24Payment.is_paid.is_(False), + Pal24Payment.created_at >= cutoff, + ) + .order_by(Pal24Payment.created_at.desc()) + ) + return result.scalars().all() + + async def update_pal24_payment_status( db: AsyncSession, payment: Pal24Payment, diff --git a/app/database/crud/wata.py b/app/database/crud/wata.py index ac047d32..3fb1069d 100644 --- a/app/database/crud/wata.py +++ b/app/database/crud/wata.py @@ -1,11 +1,12 @@ """CRUD helpers for WATA payment records.""" import logging -from datetime import datetime -from typing import Any, Dict, Optional +from datetime import datetime, timedelta +from typing import Any, Dict, Optional, List from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.database.models import WataPayment @@ -67,7 +68,9 @@ async def get_wata_payment_by_id( payment_id: int, ) -> Optional[WataPayment]: result = await db.execute( - select(WataPayment).where(WataPayment.id == payment_id) + select(WataPayment) + .options(selectinload(WataPayment.user)) + .where(WataPayment.id == payment_id) ) return result.scalar_one_or_none() @@ -77,7 +80,9 @@ async def get_wata_payment_by_link_id( payment_link_id: str, ) -> Optional[WataPayment]: result = await db.execute( - select(WataPayment).where(WataPayment.payment_link_id == payment_link_id) + select(WataPayment) + .options(selectinload(WataPayment.user)) + .where(WataPayment.payment_link_id == payment_link_id) ) return result.scalar_one_or_none() @@ -87,11 +92,32 @@ async def get_wata_payment_by_order_id( order_id: str, ) -> Optional[WataPayment]: result = await db.execute( - select(WataPayment).where(WataPayment.order_id == order_id) + select(WataPayment) + .options(selectinload(WataPayment.user)) + .where(WataPayment.order_id == order_id) ) return result.scalar_one_or_none() +async def get_pending_wata_payments( + db: AsyncSession, + *, + max_age_hours: int = 24, +) -> List[WataPayment]: + cutoff = datetime.utcnow() - timedelta(hours=max_age_hours) + + result = await db.execute( + select(WataPayment) + .options(selectinload(WataPayment.user)) + .where( + WataPayment.is_paid.is_(False), + WataPayment.created_at >= cutoff, + ) + .order_by(WataPayment.created_at.desc()) + ) + return result.scalars().all() + + async def update_wata_payment_status( db: AsyncSession, payment: WataPayment, diff --git a/app/database/crud/yookassa.py b/app/database/crud/yookassa.py index 09c52777..ebdea984 100644 --- a/app/database/crud/yookassa.py +++ b/app/database/crud/yookassa.py @@ -1,8 +1,9 @@ import logging +from datetime import datetime, timedelta from typing import Optional, List -from datetime import datetime -from sqlalchemy.ext.asyncio import AsyncSession + from sqlalchemy import select, update, and_ +from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.database.models import YooKassaPayment, User, Transaction @@ -163,15 +164,22 @@ async def get_user_yookassa_payments( async def get_pending_yookassa_payments( db: AsyncSession, user_id: Optional[int] = None, - limit: int = 100 + limit: int = 100, + max_age_hours: int = 24, ) -> List[YooKassaPayment]: - + query = select(YooKassaPayment).options(selectinload(YooKassaPayment.user)) - - conditions = [YooKassaPayment.status.in_(["pending", "waiting_for_capture"])] + + cutoff = datetime.utcnow() - timedelta(hours=max_age_hours) + + conditions = [ + YooKassaPayment.status.in_(["pending", "waiting_for_capture"]), + YooKassaPayment.is_paid.is_(False), + YooKassaPayment.created_at >= cutoff, + ] if user_id: conditions.append(YooKassaPayment.user_id == user_id) - + result = await db.execute( query.where(and_(*conditions)) .order_by(YooKassaPayment.created_at.desc()) diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index e9489bc3..e84e75ff 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -207,6 +207,77 @@ 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]]: + """Fetches the latest YooKassa status and synchronises the local record.""" + + 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_info: Optional[Dict[str, Any]] = None + + if getattr(self, "yookassa_service", None) and payment.yookassa_payment_id: + try: + remote_info = await self.yookassa_service.get_payment_info( # type: ignore[union-attr] + payment.yookassa_payment_id + ) + except Exception as error: # pragma: no cover - failsafe + logger.error( + "Ошибка получения статуса платежа YooKassa %s: %s", + payment.yookassa_payment_id, + error, + exc_info=True, + ) + remote_info = None + + if remote_info: + captured_at_raw = remote_info.get("captured_at") + captured_at = None + if captured_at_raw: + try: + captured_at = datetime.fromisoformat( + str(captured_at_raw).replace("Z", "+00:00") + ).replace(tzinfo=None) + except Exception: + captured_at = None + + updated_payment = await payment_module.update_yookassa_payment_status( + db, + payment.yookassa_payment_id, + status=remote_info.get("status", payment.status), + is_paid=bool(remote_info.get("paid", payment.is_paid)), + is_captured=remote_info.get("status") == "succeeded" + and bool(remote_info.get("paid", payment.is_paid)), + captured_at=captured_at, + payment_method_type=remote_info.get("payment_method_type"), + ) + + if updated_payment: + payment = updated_payment + + if payment.status == "succeeded" and payment.is_paid and not payment.transaction_id: + try: + await self._process_successful_yookassa_payment(db, payment) + payment = await payment_module.get_yookassa_payment_by_local_id( + db, + local_payment_id, + ) or payment + except Exception as error: # pragma: no cover - defensive + logger.error( + "Ошибка обработки успешного платежа YooKassa %s при ручной проверке: %s", + payment.yookassa_payment_id, + error, + exc_info=True, + ) + + return {"payment": payment, "remote": remote_info} + async def _process_successful_yookassa_payment( self, db: AsyncSession, diff --git a/app/services/pending_payment_service.py b/app/services/pending_payment_service.py new file mode 100644 index 00000000..554cf8e2 --- /dev/null +++ b/app/services/pending_payment_service.py @@ -0,0 +1,510 @@ +"""Utilities for aggregating and manually checking pending top-up payments.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Any, Dict, Iterable, List, Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import PaymentMethod +from app.database.crud import ( + cryptobot as cryptobot_crud, + heleket as heleket_crud, + mulenpay as mulenpay_crud, + pal24 as pal24_crud, + wata as wata_crud, + yookassa as yookassa_crud, +) +from app.services.payment_service import PaymentService + +logger = logging.getLogger(__name__) + + +class PendingPaymentError(Exception): + """Base error for pending payment operations.""" + + +class PendingPaymentNotFoundError(PendingPaymentError): + """Raised when a pending payment cannot be located.""" + + +class PendingPaymentTooOldError(PendingPaymentError): + """Raised when a payment is older than the allowed interval.""" + + +class PendingPaymentNotPendingError(PendingPaymentError): + """Raised when a payment is no longer in a pending state.""" + + +class PendingPaymentUnsupportedError(PendingPaymentError): + """Raised when manual verification is not implemented for a provider.""" + + +@dataclass +class _PendingPaymentEntry: + provider: PaymentMethod + payment: Any + + +class PendingPaymentService: + """Aggregates pending payments and provides manual verification helpers.""" + + SUPPORTED_METHODS: tuple[PaymentMethod, ...] = ( + PaymentMethod.YOOKASSA, + PaymentMethod.MULENPAY, + PaymentMethod.PAL24, + PaymentMethod.WATA, + PaymentMethod.HELEKET, + ) + + def __init__(self, bot: Any | None = None) -> None: + self._payment_service = PaymentService(bot) + + async def list_pending_payments( + self, + db: AsyncSession, + *, + max_age_hours: int = 24, + provider: PaymentMethod | None = None, + ) -> list[dict[str, Any]]: + entries = await self._collect_pending_entries( + db, + max_age_hours=max_age_hours, + provider=provider, + ) + + serialised = [ + self._serialise(entry.provider, entry.payment) + for entry in entries + ] + + serialised.sort( + key=lambda item: item.get("created_at") or datetime.min, + reverse=True, + ) + + return serialised + + async def get_payment( + self, + db: AsyncSession, + provider: PaymentMethod, + payment_id: int, + ) -> dict[str, Any]: + payment = await self._fetch_payment(db, provider, payment_id) + + if payment is None: + raise PendingPaymentNotFoundError + + return self._serialise(provider, payment, include_details=True) + + async def run_manual_check( + self, + db: AsyncSession, + provider: PaymentMethod, + payment_id: int, + *, + max_age_hours: int = 24, + ) -> dict[str, Any]: + payment = await self._fetch_payment(db, provider, payment_id) + + if payment is None: + raise PendingPaymentNotFoundError + + if not self._is_pending(provider, payment): + raise PendingPaymentNotPendingError + + if not self._is_within_age(payment, max_age_hours): + raise PendingPaymentTooOldError + + status_before = getattr(payment, "status", None) + performed = await self._perform_manual_check(db, provider, payment_id) + + refreshed = await self._fetch_payment(db, provider, payment_id) + if refreshed is None: + raise PendingPaymentNotFoundError + + payload = self._serialise(provider, refreshed, include_details=True) + + payload.update( + { + "check_performed": performed, + "status_before": status_before, + "status_after": payload.get("status"), + "completed": bool(payload.get("is_paid")) + or bool(payload.get("transaction_id")), + } + ) + + return payload + + async def run_bulk_check( + self, + db: AsyncSession, + *, + max_age_hours: int = 24, + provider: PaymentMethod | None = None, + ) -> dict[str, Any]: + entries = await self._collect_pending_entries( + db, + max_age_hours=max_age_hours, + provider=provider, + ) + + results: list[dict[str, Any]] = [] + checked = 0 + completed = 0 + skipped = 0 + + for entry in entries: + try: + result = await self.run_manual_check( + db, + entry.provider, + entry.payment.id, + max_age_hours=max_age_hours, + ) + except PendingPaymentUnsupportedError as error: + skipped += 1 + logger.warning( + "Manual check is not supported for %s: %s", + entry.provider.value, + error, + ) + continue + except PendingPaymentError as error: + skipped += 1 + logger.warning( + "Skipping payment %s/%s due to %s", + entry.provider.value, + entry.payment.id, + error, + ) + continue + + checked += 1 + if result.get("completed"): + completed += 1 + + results.append(result) + + return { + "total": len(entries), + "checked": checked, + "completed": completed, + "skipped": skipped, + "results": results, + } + + async def _collect_pending_entries( + self, + db: AsyncSession, + *, + max_age_hours: int, + provider: PaymentMethod | None, + ) -> list[_PendingPaymentEntry]: + providers: Iterable[PaymentMethod] + if provider is None: + providers = self.SUPPORTED_METHODS + else: + providers = (provider,) + + entries: list[_PendingPaymentEntry] = [] + + for method in providers: + payments = await self._fetch_pending_for_provider( + db, + method, + max_age_hours=max_age_hours, + ) + for payment in payments: + if not self._is_pending(method, payment): + continue + if not self._is_within_age(payment, max_age_hours): + continue + entries.append(_PendingPaymentEntry(method, payment)) + + return entries + + async def _fetch_pending_for_provider( + self, + db: AsyncSession, + method: PaymentMethod, + *, + max_age_hours: int, + ) -> list[Any]: + if method == PaymentMethod.YOOKASSA: + return await yookassa_crud.get_pending_yookassa_payments( + db, + max_age_hours=max_age_hours, + ) + if method == PaymentMethod.MULENPAY: + return await mulenpay_crud.get_pending_mulenpay_payments( + db, + max_age_hours=max_age_hours, + ) + if method == PaymentMethod.PAL24: + return await pal24_crud.get_pending_pal24_payments( + db, + max_age_hours=max_age_hours, + ) + if method == PaymentMethod.WATA: + return await wata_crud.get_pending_wata_payments( + db, + max_age_hours=max_age_hours, + ) + if method == PaymentMethod.HELEKET: + return await heleket_crud.get_pending_heleket_payments( + db, + max_age_hours=max_age_hours, + ) + + return [] + + async def _fetch_payment( + self, + db: AsyncSession, + provider: PaymentMethod, + payment_id: int, + ) -> Any | None: + if provider == PaymentMethod.YOOKASSA: + return await yookassa_crud.get_yookassa_payment_by_local_id(db, payment_id) + if provider == PaymentMethod.MULENPAY: + return await mulenpay_crud.get_mulenpay_payment_by_local_id(db, payment_id) + if provider == PaymentMethod.PAL24: + return await pal24_crud.get_pal24_payment_by_id(db, payment_id) + if provider == PaymentMethod.WATA: + return await wata_crud.get_wata_payment_by_id(db, payment_id) + if provider == PaymentMethod.HELEKET: + return await heleket_crud.get_heleket_payment_by_id(db, payment_id) + + if provider == PaymentMethod.CRYPTOBOT: + return await cryptobot_crud.get_cryptobot_payment_by_id(db, payment_id) + + return None + + async def _perform_manual_check( + self, + db: AsyncSession, + provider: PaymentMethod, + payment_id: int, + ) -> bool: + if provider == PaymentMethod.YOOKASSA: + result = await self._payment_service.get_yookassa_payment_status( + db, + payment_id, + ) + return bool(result) + if provider == PaymentMethod.MULENPAY: + result = await self._payment_service.get_mulenpay_payment_status( + db, + payment_id, + ) + return bool(result) + if provider == PaymentMethod.PAL24: + result = await self._payment_service.get_pal24_payment_status( + db, + payment_id, + ) + return bool(result) + if provider == PaymentMethod.WATA: + result = await self._payment_service.get_wata_payment_status( + db, + payment_id, + ) + return bool(result) + if provider == PaymentMethod.HELEKET: + result = await self._payment_service.sync_heleket_payment_status( + db, + local_payment_id=payment_id, + ) + return bool(result) + + raise PendingPaymentUnsupportedError( + f"Manual status check is not implemented for {provider.value}" + ) + + def _serialise( + self, + provider: PaymentMethod, + payment: Any, + *, + include_details: bool = False, + ) -> dict[str, Any]: + amount_kopeks = int(getattr(payment, "amount_kopeks", 0) or 0) + currency = getattr(payment, "currency", None) or "RUB" + + data: dict[str, Any] = { + "id": getattr(payment, "id", None), + "provider": provider.value, + "user": self._extract_user(payment), + "amount_kopeks": amount_kopeks, + "amount_rubles": round(amount_kopeks / 100, 2), + "currency": currency, + "status": getattr(payment, "status", None), + "is_paid": bool(getattr(payment, "is_paid", False)), + "description": getattr(payment, "description", None), + "payment_url": self._get_payment_url(provider, payment), + "external_id": self._get_external_id(provider, payment), + "transaction_id": getattr(payment, "transaction_id", None), + "created_at": getattr(payment, "created_at", None), + "updated_at": getattr(payment, "updated_at", None), + "expires_at": getattr(payment, "expires_at", None), + "is_pending": self._is_pending(provider, payment), + } + + if include_details: + data["metadata"] = self._collect_metadata(provider, payment) + + return data + + def _collect_metadata(self, provider: PaymentMethod, payment: Any) -> dict[str, Any]: + metadata: dict[str, Any] = {} + + base_metadata = getattr(payment, "metadata_json", None) + if base_metadata: + metadata["metadata_json"] = base_metadata + + callback_payload = getattr(payment, "callback_payload", None) + if callback_payload: + metadata["callback_payload"] = callback_payload + + if provider == PaymentMethod.YOOKASSA: + metadata.update( + { + "confirmation_url": getattr(payment, "confirmation_url", None), + "payment_method_type": getattr(payment, "payment_method_type", None), + "refundable": getattr(payment, "refundable", None), + "test_mode": getattr(payment, "test_mode", None), + } + ) + elif provider == PaymentMethod.MULENPAY: + metadata.update( + { + "mulen_payment_id": getattr(payment, "mulen_payment_id", None), + } + ) + elif provider == PaymentMethod.PAL24: + metadata.update( + { + "payment_id": getattr(payment, "payment_id", None), + "payment_status": getattr(payment, "payment_status", None), + "payment_method": getattr(payment, "payment_method", None), + "balance_amount": getattr(payment, "balance_amount", None), + "balance_currency": getattr(payment, "balance_currency", None), + "payer_account": getattr(payment, "payer_account", None), + "last_status": getattr(payment, "last_status", None), + } + ) + elif provider == PaymentMethod.WATA: + metadata.update( + { + "terminal_public_id": getattr(payment, "terminal_public_id", None), + "last_status": getattr(payment, "last_status", None), + "success_redirect_url": getattr(payment, "success_redirect_url", None), + "fail_redirect_url": getattr(payment, "fail_redirect_url", None), + } + ) + elif provider == PaymentMethod.HELEKET: + metadata.update( + { + "order_id": getattr(payment, "order_id", None), + "payer_amount": getattr(payment, "payer_amount", None), + "payer_currency": getattr(payment, "payer_currency", None), + "exchange_rate": getattr(payment, "exchange_rate", None), + "discount_percent": getattr(payment, "discount_percent", None), + } + ) + + # Remove empty values for cleanliness + cleaned = { + key: value + for key, value in metadata.items() + if value is not None and value != {} + } + + return cleaned + + def _get_payment_url(self, provider: PaymentMethod, payment: Any) -> Optional[str]: + if provider == PaymentMethod.YOOKASSA: + return getattr(payment, "confirmation_url", None) + if provider == PaymentMethod.MULENPAY: + return getattr(payment, "payment_url", None) + if provider == PaymentMethod.PAL24: + return ( + getattr(payment, "link_url", None) + or getattr(payment, "link_page_url", None) + ) + if provider == PaymentMethod.WATA: + return getattr(payment, "url", None) + if provider == PaymentMethod.HELEKET: + return getattr(payment, "payment_url", None) + + return None + + def _get_external_id(self, provider: PaymentMethod, payment: Any) -> Optional[str]: + if provider == PaymentMethod.YOOKASSA: + return getattr(payment, "yookassa_payment_id", None) + if provider == PaymentMethod.MULENPAY: + return getattr(payment, "uuid", None) + if provider == PaymentMethod.PAL24: + return getattr(payment, "bill_id", None) + if provider == PaymentMethod.WATA: + return getattr(payment, "payment_link_id", None) + if provider == PaymentMethod.HELEKET: + return getattr(payment, "uuid", None) + + return None + + def _extract_user(self, payment: Any) -> dict[str, Any]: + user = getattr(payment, "user", None) + if not user: + return {} + + return { + "id": getattr(user, "id", None), + "telegram_id": getattr(user, "telegram_id", None), + "username": getattr(user, "username", None), + "first_name": getattr(user, "first_name", None), + "last_name": getattr(user, "last_name", None), + } + + def _is_pending(self, provider: PaymentMethod, payment: Any) -> bool: + status_raw = getattr(payment, "status", "") or "" + status_lower = str(status_raw).lower() + + if provider == PaymentMethod.YOOKASSA: + return status_lower in {"pending", "waiting_for_capture"} and not getattr( + payment, "is_paid", False + ) + if provider == PaymentMethod.MULENPAY: + return status_lower in {"created", "processing", "hold"} and not getattr( + payment, "is_paid", False + ) + if provider == PaymentMethod.PAL24: + return status_lower in {"new", "process", "underpaid"} and not getattr( + payment, "is_paid", False + ) + if provider == PaymentMethod.WATA: + return status_lower not in {"closed", "paid"} and not getattr( + payment, "is_paid", False + ) + if provider == PaymentMethod.HELEKET: + return status_lower not in {"paid", "paid_over"} and getattr( + payment, "transaction_id", None + ) is None + if provider == PaymentMethod.CRYPTOBOT: + return status_lower == "active" + + return False + + def _is_within_age(self, payment: Any, max_age_hours: int) -> bool: + created_at = getattr(payment, "created_at", None) + if created_at is None: + return True + + cutoff = datetime.utcnow() - timedelta(hours=max_age_hours) + return created_at >= cutoff diff --git a/app/webapi/routes/transactions.py b/app/webapi/routes/transactions.py index c00ebca1..6fd67e95 100644 --- a/app/webapi/routes/transactions.py +++ b/app/webapi/routes/transactions.py @@ -3,14 +3,24 @@ from __future__ import annotations from datetime import datetime from typing import Any, Optional -from fastapi import APIRouter, Depends, Query, Security +from fastapi import APIRouter, Depends, HTTPException, Query, Security, status from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import Transaction +from app.database.models import PaymentMethod, Transaction +from app.services.pending_payment_service import PendingPaymentService, PendingPaymentError, PendingPaymentNotFoundError, PendingPaymentNotPendingError, PendingPaymentTooOldError, PendingPaymentUnsupportedError from ..dependencies import get_db_session, require_api_token -from ..schemas.transactions import TransactionListResponse, TransactionResponse +from ..schemas.transactions import ( + PendingPaymentBulkCheckResponse, + PendingPaymentCheckResponse, + PendingPaymentDetailResponse, + PendingPaymentListResponse, + PendingPaymentResponse, + PendingPaymentUserResponse, + TransactionListResponse, + TransactionResponse, +) router = APIRouter() @@ -77,3 +87,172 @@ async def list_transactions( limit=limit, offset=offset, ) + + +def _parse_provider(service: PendingPaymentService, provider: str) -> PaymentMethod: + try: + method = PaymentMethod(provider) + except ValueError as error: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown payment provider") from error + + if method not in service.SUPPORTED_METHODS: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unsupported payment provider") + + return method + + +def _build_pending_summary(data: dict) -> PendingPaymentResponse: + user_data = data.get("user") or {} + return PendingPaymentResponse( + id=data.get("id"), + provider=data.get("provider"), + user=PendingPaymentUserResponse(**user_data), + amount_kopeks=data.get("amount_kopeks", 0), + amount_rubles=data.get("amount_rubles", 0.0), + currency=data.get("currency", "RUB"), + status=data.get("status"), + is_paid=data.get("is_paid", False), + description=data.get("description"), + payment_url=data.get("payment_url"), + external_id=data.get("external_id"), + transaction_id=data.get("transaction_id"), + created_at=data.get("created_at"), + updated_at=data.get("updated_at"), + expires_at=data.get("expires_at"), + is_pending=data.get("is_pending", False), + ) + + +def _build_pending_detail(data: dict) -> PendingPaymentDetailResponse: + summary = _build_pending_summary(data) + return PendingPaymentDetailResponse( + **summary.dict(), + metadata=data.get("metadata"), + ) + + +@router.get("/pending-payments", response_model=PendingPaymentListResponse) +async def list_pending_payments( + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), + provider: Optional[str] = Query(default=None), + max_age_hours: int = Query(default=24, ge=1, le=72), +) -> PendingPaymentListResponse: + service = PendingPaymentService() + payment_method = _parse_provider(service, provider) if provider else None + + raw_items = await service.list_pending_payments( + db, + max_age_hours=max_age_hours, + provider=payment_method, + ) + + items = [_build_pending_summary(item) for item in raw_items] + + return PendingPaymentListResponse( + items=items, + total=len(items), + ) + + +@router.get( + "/pending-payments/{provider}/{payment_id}", + response_model=PendingPaymentDetailResponse, +) +async def get_pending_payment( + provider: str, + payment_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> PendingPaymentDetailResponse: + service = PendingPaymentService() + payment_method = _parse_provider(service, provider) + + try: + payment = await service.get_payment(db, payment_method, payment_id) + except PendingPaymentNotFoundError as error: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Payment not found") from error + + return _build_pending_detail(payment) + + +@router.post( + "/pending-payments/{provider}/{payment_id}/check", + response_model=PendingPaymentCheckResponse, +) +async def check_pending_payment( + provider: str, + payment_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), + max_age_hours: int = Query(default=24, ge=1, le=72), +) -> PendingPaymentCheckResponse: + service = PendingPaymentService() + payment_method = _parse_provider(service, provider) + + try: + result = await service.run_manual_check( + db, + payment_method, + payment_id, + max_age_hours=max_age_hours, + ) + except PendingPaymentNotFoundError as error: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Payment not found") from error + except PendingPaymentNotPendingError as error: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Payment is not pending") from error + except PendingPaymentTooOldError as error: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Payment is older than the allowed interval") from error + except PendingPaymentUnsupportedError as error: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error + except PendingPaymentError as error: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to check payment") from error + + detail = _build_pending_detail(result) + + return PendingPaymentCheckResponse( + payment=detail, + check_performed=bool(result.get("check_performed")), + status_before=result.get("status_before"), + status_after=result.get("status_after"), + completed=bool(result.get("completed")), + ) + + +@router.post( + "/pending-payments/check", + response_model=PendingPaymentBulkCheckResponse, +) +async def check_all_pending_payments( + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), + provider: Optional[str] = Query(default=None), + max_age_hours: int = Query(default=24, ge=1, le=72), +) -> PendingPaymentBulkCheckResponse: + service = PendingPaymentService() + payment_method = _parse_provider(service, provider) if provider else None + + summary = await service.run_bulk_check( + db, + max_age_hours=max_age_hours, + provider=payment_method, + ) + + results = [ + PendingPaymentCheckResponse( + payment=_build_pending_detail(item), + check_performed=bool(item.get("check_performed")), + status_before=item.get("status_before"), + status_after=item.get("status_after"), + completed=bool(item.get("completed")), + ) + for item in summary.get("results", []) + ] + + return PendingPaymentBulkCheckResponse( + total=summary.get("total", 0), + checked=summary.get("checked", 0), + completed=summary.get("completed", 0), + skipped=summary.get("skipped", 0), + results=results, + ) diff --git a/app/webapi/schemas/transactions.py b/app/webapi/schemas/transactions.py index 9408f6c9..aef0534a 100644 --- a/app/webapi/schemas/transactions.py +++ b/app/webapi/schemas/transactions.py @@ -1,7 +1,7 @@ from __future__ import annotations from datetime import datetime -from typing import Optional +from typing import Any, Optional from pydantic import BaseModel @@ -25,3 +25,55 @@ class TransactionListResponse(BaseModel): total: int limit: int offset: int + + +class PendingPaymentUserResponse(BaseModel): + id: Optional[int] = None + telegram_id: Optional[int] = None + username: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + + +class PendingPaymentResponse(BaseModel): + id: int + provider: str + user: PendingPaymentUserResponse + amount_kopeks: int + amount_rubles: float + currency: str + status: Optional[str] = None + is_paid: bool + description: Optional[str] = None + payment_url: Optional[str] = None + external_id: Optional[str] = None + transaction_id: Optional[int] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + expires_at: Optional[datetime] = None + is_pending: bool + + +class PendingPaymentDetailResponse(PendingPaymentResponse): + metadata: Optional[dict[str, Any]] = None + + +class PendingPaymentListResponse(BaseModel): + items: list[PendingPaymentResponse] + total: int + + +class PendingPaymentCheckResponse(BaseModel): + payment: PendingPaymentDetailResponse + check_performed: bool + status_before: Optional[str] = None + status_after: Optional[str] = None + completed: bool + + +class PendingPaymentBulkCheckResponse(BaseModel): + total: int + checked: int + completed: int + skipped: int + results: list[PendingPaymentCheckResponse] From 051c0db10659d6906a6d51ffb191cd8c905e4731 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 26 Oct 2025 07:23:24 +0300 Subject: [PATCH 04/21] Revert "Add admin pending payment verification endpoints" --- app/database/crud/cryptobot.py | 14 +- app/database/crud/heleket.py | 23 +- app/database/crud/mulenpay.py | 37 +- app/database/crud/pal24.py | 36 +- app/database/crud/wata.py | 36 +- app/database/crud/yookassa.py | 22 +- app/services/payment/yookassa.py | 71 ---- app/services/pending_payment_service.py | 510 ------------------------ app/webapi/routes/transactions.py | 185 +-------- app/webapi/schemas/transactions.py | 54 +-- 10 files changed, 37 insertions(+), 951 deletions(-) delete mode 100644 app/services/pending_payment_service.py diff --git a/app/database/crud/cryptobot.py b/app/database/crud/cryptobot.py index d8cd228a..0d5eecef 100644 --- a/app/database/crud/cryptobot.py +++ b/app/database/crud/cryptobot.py @@ -136,21 +136,21 @@ async def get_user_cryptobot_payments( async def get_pending_cryptobot_payments( db: AsyncSession, - *, - max_age_hours: int = 24, + older_than_hours: int = 24 ) -> List[CryptoBotPayment]: - - cutoff_time = datetime.utcnow() - timedelta(hours=max_age_hours) - + + from datetime import timedelta + cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours) + result = await db.execute( select(CryptoBotPayment) .options(selectinload(CryptoBotPayment.user)) .where( and_( CryptoBotPayment.status == "active", - CryptoBotPayment.created_at >= cutoff_time, + CryptoBotPayment.created_at < cutoff_time ) ) - .order_by(CryptoBotPayment.created_at.desc()) + .order_by(CryptoBotPayment.created_at) ) return result.scalars().all() diff --git a/app/database/crud/heleket.py b/app/database/crud/heleket.py index 46023ada..d02d5e83 100644 --- a/app/database/crud/heleket.py +++ b/app/database/crud/heleket.py @@ -1,6 +1,6 @@ import logging -from datetime import datetime, timedelta -from typing import Any, Dict, Optional, List +from datetime import datetime +from typing import Any, Dict, Optional from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -96,25 +96,6 @@ async def get_heleket_payment_by_id( return result.scalar_one_or_none() -async def get_pending_heleket_payments( - db: AsyncSession, - *, - max_age_hours: int = 24, -) -> List[HeleketPayment]: - cutoff = datetime.utcnow() - timedelta(hours=max_age_hours) - - result = await db.execute( - select(HeleketPayment) - .options(selectinload(HeleketPayment.user)) - .where( - HeleketPayment.transaction_id.is_(None), - HeleketPayment.created_at >= cutoff, - ) - .order_by(HeleketPayment.created_at.desc()) - ) - return result.scalars().all() - - async def update_heleket_payment( db: AsyncSession, uuid: str, diff --git a/app/database/crud/mulenpay.py b/app/database/crud/mulenpay.py index 3ca4dc1b..17648f4a 100644 --- a/app/database/crud/mulenpay.py +++ b/app/database/crud/mulenpay.py @@ -1,9 +1,9 @@ import logging -from datetime import datetime, timedelta +from datetime import datetime from typing import Optional + from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload from app.config import settings from app.database.models import MulenPayPayment @@ -56,9 +56,7 @@ async def get_mulenpay_payment_by_local_id( db: AsyncSession, payment_id: int ) -> Optional[MulenPayPayment]: result = await db.execute( - select(MulenPayPayment) - .options(selectinload(MulenPayPayment.user)) - .where(MulenPayPayment.id == payment_id) + select(MulenPayPayment).where(MulenPayPayment.id == payment_id) ) return result.scalar_one_or_none() @@ -67,9 +65,7 @@ async def get_mulenpay_payment_by_uuid( db: AsyncSession, uuid: str ) -> Optional[MulenPayPayment]: result = await db.execute( - select(MulenPayPayment) - .options(selectinload(MulenPayPayment.user)) - .where(MulenPayPayment.uuid == uuid) + select(MulenPayPayment).where(MulenPayPayment.uuid == uuid) ) return result.scalar_one_or_none() @@ -78,32 +74,13 @@ async def get_mulenpay_payment_by_mulen_id( db: AsyncSession, mulen_payment_id: int ) -> Optional[MulenPayPayment]: result = await db.execute( - select(MulenPayPayment) - .options(selectinload(MulenPayPayment.user)) - .where(MulenPayPayment.mulen_payment_id == mulen_payment_id) + select(MulenPayPayment).where( + MulenPayPayment.mulen_payment_id == mulen_payment_id + ) ) return result.scalar_one_or_none() -async def get_pending_mulenpay_payments( - db: AsyncSession, - *, - max_age_hours: int = 24, -) -> list[MulenPayPayment]: - cutoff = datetime.utcnow() - timedelta(hours=max_age_hours) - - result = await db.execute( - select(MulenPayPayment) - .options(selectinload(MulenPayPayment.user)) - .where( - MulenPayPayment.is_paid.is_(False), - MulenPayPayment.created_at >= cutoff, - ) - .order_by(MulenPayPayment.created_at.desc()) - ) - return result.scalars().all() - - async def update_mulenpay_payment_status( db: AsyncSession, *, diff --git a/app/database/crud/pal24.py b/app/database/crud/pal24.py index cd8c25bc..a88c46b4 100644 --- a/app/database/crud/pal24.py +++ b/app/database/crud/pal24.py @@ -3,12 +3,11 @@ from __future__ import annotations import logging -from datetime import datetime, timedelta -from typing import Any, Dict, Optional, List +from datetime import datetime +from typing import Any, Dict, Optional from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload from app.database.models import Pal24Payment @@ -63,50 +62,25 @@ async def create_pal24_payment( async def get_pal24_payment_by_id(db: AsyncSession, payment_id: int) -> Optional[Pal24Payment]: result = await db.execute( - select(Pal24Payment) - .options(selectinload(Pal24Payment.user)) - .where(Pal24Payment.id == payment_id) + select(Pal24Payment).where(Pal24Payment.id == payment_id) ) return result.scalar_one_or_none() async def get_pal24_payment_by_bill_id(db: AsyncSession, bill_id: str) -> Optional[Pal24Payment]: result = await db.execute( - select(Pal24Payment) - .options(selectinload(Pal24Payment.user)) - .where(Pal24Payment.bill_id == bill_id) + select(Pal24Payment).where(Pal24Payment.bill_id == bill_id) ) return result.scalar_one_or_none() async def get_pal24_payment_by_order_id(db: AsyncSession, order_id: str) -> Optional[Pal24Payment]: result = await db.execute( - select(Pal24Payment) - .options(selectinload(Pal24Payment.user)) - .where(Pal24Payment.order_id == order_id) + select(Pal24Payment).where(Pal24Payment.order_id == order_id) ) return result.scalar_one_or_none() -async def get_pending_pal24_payments( - db: AsyncSession, - *, - max_age_hours: int = 24, -) -> List[Pal24Payment]: - cutoff = datetime.utcnow() - timedelta(hours=max_age_hours) - - result = await db.execute( - select(Pal24Payment) - .options(selectinload(Pal24Payment.user)) - .where( - Pal24Payment.is_paid.is_(False), - Pal24Payment.created_at >= cutoff, - ) - .order_by(Pal24Payment.created_at.desc()) - ) - return result.scalars().all() - - async def update_pal24_payment_status( db: AsyncSession, payment: Pal24Payment, diff --git a/app/database/crud/wata.py b/app/database/crud/wata.py index 3fb1069d..ac047d32 100644 --- a/app/database/crud/wata.py +++ b/app/database/crud/wata.py @@ -1,12 +1,11 @@ """CRUD helpers for WATA payment records.""" import logging -from datetime import datetime, timedelta -from typing import Any, Dict, Optional, List +from datetime import datetime +from typing import Any, Dict, Optional from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload from app.database.models import WataPayment @@ -68,9 +67,7 @@ async def get_wata_payment_by_id( payment_id: int, ) -> Optional[WataPayment]: result = await db.execute( - select(WataPayment) - .options(selectinload(WataPayment.user)) - .where(WataPayment.id == payment_id) + select(WataPayment).where(WataPayment.id == payment_id) ) return result.scalar_one_or_none() @@ -80,9 +77,7 @@ async def get_wata_payment_by_link_id( payment_link_id: str, ) -> Optional[WataPayment]: result = await db.execute( - select(WataPayment) - .options(selectinload(WataPayment.user)) - .where(WataPayment.payment_link_id == payment_link_id) + select(WataPayment).where(WataPayment.payment_link_id == payment_link_id) ) return result.scalar_one_or_none() @@ -92,32 +87,11 @@ async def get_wata_payment_by_order_id( order_id: str, ) -> Optional[WataPayment]: result = await db.execute( - select(WataPayment) - .options(selectinload(WataPayment.user)) - .where(WataPayment.order_id == order_id) + select(WataPayment).where(WataPayment.order_id == order_id) ) return result.scalar_one_or_none() -async def get_pending_wata_payments( - db: AsyncSession, - *, - max_age_hours: int = 24, -) -> List[WataPayment]: - cutoff = datetime.utcnow() - timedelta(hours=max_age_hours) - - result = await db.execute( - select(WataPayment) - .options(selectinload(WataPayment.user)) - .where( - WataPayment.is_paid.is_(False), - WataPayment.created_at >= cutoff, - ) - .order_by(WataPayment.created_at.desc()) - ) - return result.scalars().all() - - async def update_wata_payment_status( db: AsyncSession, payment: WataPayment, diff --git a/app/database/crud/yookassa.py b/app/database/crud/yookassa.py index ebdea984..09c52777 100644 --- a/app/database/crud/yookassa.py +++ b/app/database/crud/yookassa.py @@ -1,9 +1,8 @@ import logging -from datetime import datetime, timedelta from typing import Optional, List - -from sqlalchemy import select, update, and_ +from datetime import datetime from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update, and_ from sqlalchemy.orm import selectinload from app.database.models import YooKassaPayment, User, Transaction @@ -164,22 +163,15 @@ async def get_user_yookassa_payments( async def get_pending_yookassa_payments( db: AsyncSession, user_id: Optional[int] = None, - limit: int = 100, - max_age_hours: int = 24, + limit: int = 100 ) -> List[YooKassaPayment]: - + query = select(YooKassaPayment).options(selectinload(YooKassaPayment.user)) - - cutoff = datetime.utcnow() - timedelta(hours=max_age_hours) - - conditions = [ - YooKassaPayment.status.in_(["pending", "waiting_for_capture"]), - YooKassaPayment.is_paid.is_(False), - YooKassaPayment.created_at >= cutoff, - ] + + conditions = [YooKassaPayment.status.in_(["pending", "waiting_for_capture"])] if user_id: conditions.append(YooKassaPayment.user_id == user_id) - + result = await db.execute( query.where(and_(*conditions)) .order_by(YooKassaPayment.created_at.desc()) diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index e84e75ff..e9489bc3 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -207,77 +207,6 @@ 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]]: - """Fetches the latest YooKassa status and synchronises the local record.""" - - 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_info: Optional[Dict[str, Any]] = None - - if getattr(self, "yookassa_service", None) and payment.yookassa_payment_id: - try: - remote_info = await self.yookassa_service.get_payment_info( # type: ignore[union-attr] - payment.yookassa_payment_id - ) - except Exception as error: # pragma: no cover - failsafe - logger.error( - "Ошибка получения статуса платежа YooKassa %s: %s", - payment.yookassa_payment_id, - error, - exc_info=True, - ) - remote_info = None - - if remote_info: - captured_at_raw = remote_info.get("captured_at") - captured_at = None - if captured_at_raw: - try: - captured_at = datetime.fromisoformat( - str(captured_at_raw).replace("Z", "+00:00") - ).replace(tzinfo=None) - except Exception: - captured_at = None - - updated_payment = await payment_module.update_yookassa_payment_status( - db, - payment.yookassa_payment_id, - status=remote_info.get("status", payment.status), - is_paid=bool(remote_info.get("paid", payment.is_paid)), - is_captured=remote_info.get("status") == "succeeded" - and bool(remote_info.get("paid", payment.is_paid)), - captured_at=captured_at, - payment_method_type=remote_info.get("payment_method_type"), - ) - - if updated_payment: - payment = updated_payment - - if payment.status == "succeeded" and payment.is_paid and not payment.transaction_id: - try: - await self._process_successful_yookassa_payment(db, payment) - payment = await payment_module.get_yookassa_payment_by_local_id( - db, - local_payment_id, - ) or payment - except Exception as error: # pragma: no cover - defensive - logger.error( - "Ошибка обработки успешного платежа YooKassa %s при ручной проверке: %s", - payment.yookassa_payment_id, - error, - exc_info=True, - ) - - return {"payment": payment, "remote": remote_info} - async def _process_successful_yookassa_payment( self, db: AsyncSession, diff --git a/app/services/pending_payment_service.py b/app/services/pending_payment_service.py deleted file mode 100644 index 554cf8e2..00000000 --- a/app/services/pending_payment_service.py +++ /dev/null @@ -1,510 +0,0 @@ -"""Utilities for aggregating and manually checking pending top-up payments.""" - -from __future__ import annotations - -import logging -from dataclasses import dataclass -from datetime import datetime, timedelta -from typing import Any, Dict, Iterable, List, Optional - -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database.models import PaymentMethod -from app.database.crud import ( - cryptobot as cryptobot_crud, - heleket as heleket_crud, - mulenpay as mulenpay_crud, - pal24 as pal24_crud, - wata as wata_crud, - yookassa as yookassa_crud, -) -from app.services.payment_service import PaymentService - -logger = logging.getLogger(__name__) - - -class PendingPaymentError(Exception): - """Base error for pending payment operations.""" - - -class PendingPaymentNotFoundError(PendingPaymentError): - """Raised when a pending payment cannot be located.""" - - -class PendingPaymentTooOldError(PendingPaymentError): - """Raised when a payment is older than the allowed interval.""" - - -class PendingPaymentNotPendingError(PendingPaymentError): - """Raised when a payment is no longer in a pending state.""" - - -class PendingPaymentUnsupportedError(PendingPaymentError): - """Raised when manual verification is not implemented for a provider.""" - - -@dataclass -class _PendingPaymentEntry: - provider: PaymentMethod - payment: Any - - -class PendingPaymentService: - """Aggregates pending payments and provides manual verification helpers.""" - - SUPPORTED_METHODS: tuple[PaymentMethod, ...] = ( - PaymentMethod.YOOKASSA, - PaymentMethod.MULENPAY, - PaymentMethod.PAL24, - PaymentMethod.WATA, - PaymentMethod.HELEKET, - ) - - def __init__(self, bot: Any | None = None) -> None: - self._payment_service = PaymentService(bot) - - async def list_pending_payments( - self, - db: AsyncSession, - *, - max_age_hours: int = 24, - provider: PaymentMethod | None = None, - ) -> list[dict[str, Any]]: - entries = await self._collect_pending_entries( - db, - max_age_hours=max_age_hours, - provider=provider, - ) - - serialised = [ - self._serialise(entry.provider, entry.payment) - for entry in entries - ] - - serialised.sort( - key=lambda item: item.get("created_at") or datetime.min, - reverse=True, - ) - - return serialised - - async def get_payment( - self, - db: AsyncSession, - provider: PaymentMethod, - payment_id: int, - ) -> dict[str, Any]: - payment = await self._fetch_payment(db, provider, payment_id) - - if payment is None: - raise PendingPaymentNotFoundError - - return self._serialise(provider, payment, include_details=True) - - async def run_manual_check( - self, - db: AsyncSession, - provider: PaymentMethod, - payment_id: int, - *, - max_age_hours: int = 24, - ) -> dict[str, Any]: - payment = await self._fetch_payment(db, provider, payment_id) - - if payment is None: - raise PendingPaymentNotFoundError - - if not self._is_pending(provider, payment): - raise PendingPaymentNotPendingError - - if not self._is_within_age(payment, max_age_hours): - raise PendingPaymentTooOldError - - status_before = getattr(payment, "status", None) - performed = await self._perform_manual_check(db, provider, payment_id) - - refreshed = await self._fetch_payment(db, provider, payment_id) - if refreshed is None: - raise PendingPaymentNotFoundError - - payload = self._serialise(provider, refreshed, include_details=True) - - payload.update( - { - "check_performed": performed, - "status_before": status_before, - "status_after": payload.get("status"), - "completed": bool(payload.get("is_paid")) - or bool(payload.get("transaction_id")), - } - ) - - return payload - - async def run_bulk_check( - self, - db: AsyncSession, - *, - max_age_hours: int = 24, - provider: PaymentMethod | None = None, - ) -> dict[str, Any]: - entries = await self._collect_pending_entries( - db, - max_age_hours=max_age_hours, - provider=provider, - ) - - results: list[dict[str, Any]] = [] - checked = 0 - completed = 0 - skipped = 0 - - for entry in entries: - try: - result = await self.run_manual_check( - db, - entry.provider, - entry.payment.id, - max_age_hours=max_age_hours, - ) - except PendingPaymentUnsupportedError as error: - skipped += 1 - logger.warning( - "Manual check is not supported for %s: %s", - entry.provider.value, - error, - ) - continue - except PendingPaymentError as error: - skipped += 1 - logger.warning( - "Skipping payment %s/%s due to %s", - entry.provider.value, - entry.payment.id, - error, - ) - continue - - checked += 1 - if result.get("completed"): - completed += 1 - - results.append(result) - - return { - "total": len(entries), - "checked": checked, - "completed": completed, - "skipped": skipped, - "results": results, - } - - async def _collect_pending_entries( - self, - db: AsyncSession, - *, - max_age_hours: int, - provider: PaymentMethod | None, - ) -> list[_PendingPaymentEntry]: - providers: Iterable[PaymentMethod] - if provider is None: - providers = self.SUPPORTED_METHODS - else: - providers = (provider,) - - entries: list[_PendingPaymentEntry] = [] - - for method in providers: - payments = await self._fetch_pending_for_provider( - db, - method, - max_age_hours=max_age_hours, - ) - for payment in payments: - if not self._is_pending(method, payment): - continue - if not self._is_within_age(payment, max_age_hours): - continue - entries.append(_PendingPaymentEntry(method, payment)) - - return entries - - async def _fetch_pending_for_provider( - self, - db: AsyncSession, - method: PaymentMethod, - *, - max_age_hours: int, - ) -> list[Any]: - if method == PaymentMethod.YOOKASSA: - return await yookassa_crud.get_pending_yookassa_payments( - db, - max_age_hours=max_age_hours, - ) - if method == PaymentMethod.MULENPAY: - return await mulenpay_crud.get_pending_mulenpay_payments( - db, - max_age_hours=max_age_hours, - ) - if method == PaymentMethod.PAL24: - return await pal24_crud.get_pending_pal24_payments( - db, - max_age_hours=max_age_hours, - ) - if method == PaymentMethod.WATA: - return await wata_crud.get_pending_wata_payments( - db, - max_age_hours=max_age_hours, - ) - if method == PaymentMethod.HELEKET: - return await heleket_crud.get_pending_heleket_payments( - db, - max_age_hours=max_age_hours, - ) - - return [] - - async def _fetch_payment( - self, - db: AsyncSession, - provider: PaymentMethod, - payment_id: int, - ) -> Any | None: - if provider == PaymentMethod.YOOKASSA: - return await yookassa_crud.get_yookassa_payment_by_local_id(db, payment_id) - if provider == PaymentMethod.MULENPAY: - return await mulenpay_crud.get_mulenpay_payment_by_local_id(db, payment_id) - if provider == PaymentMethod.PAL24: - return await pal24_crud.get_pal24_payment_by_id(db, payment_id) - if provider == PaymentMethod.WATA: - return await wata_crud.get_wata_payment_by_id(db, payment_id) - if provider == PaymentMethod.HELEKET: - return await heleket_crud.get_heleket_payment_by_id(db, payment_id) - - if provider == PaymentMethod.CRYPTOBOT: - return await cryptobot_crud.get_cryptobot_payment_by_id(db, payment_id) - - return None - - async def _perform_manual_check( - self, - db: AsyncSession, - provider: PaymentMethod, - payment_id: int, - ) -> bool: - if provider == PaymentMethod.YOOKASSA: - result = await self._payment_service.get_yookassa_payment_status( - db, - payment_id, - ) - return bool(result) - if provider == PaymentMethod.MULENPAY: - result = await self._payment_service.get_mulenpay_payment_status( - db, - payment_id, - ) - return bool(result) - if provider == PaymentMethod.PAL24: - result = await self._payment_service.get_pal24_payment_status( - db, - payment_id, - ) - return bool(result) - if provider == PaymentMethod.WATA: - result = await self._payment_service.get_wata_payment_status( - db, - payment_id, - ) - return bool(result) - if provider == PaymentMethod.HELEKET: - result = await self._payment_service.sync_heleket_payment_status( - db, - local_payment_id=payment_id, - ) - return bool(result) - - raise PendingPaymentUnsupportedError( - f"Manual status check is not implemented for {provider.value}" - ) - - def _serialise( - self, - provider: PaymentMethod, - payment: Any, - *, - include_details: bool = False, - ) -> dict[str, Any]: - amount_kopeks = int(getattr(payment, "amount_kopeks", 0) or 0) - currency = getattr(payment, "currency", None) or "RUB" - - data: dict[str, Any] = { - "id": getattr(payment, "id", None), - "provider": provider.value, - "user": self._extract_user(payment), - "amount_kopeks": amount_kopeks, - "amount_rubles": round(amount_kopeks / 100, 2), - "currency": currency, - "status": getattr(payment, "status", None), - "is_paid": bool(getattr(payment, "is_paid", False)), - "description": getattr(payment, "description", None), - "payment_url": self._get_payment_url(provider, payment), - "external_id": self._get_external_id(provider, payment), - "transaction_id": getattr(payment, "transaction_id", None), - "created_at": getattr(payment, "created_at", None), - "updated_at": getattr(payment, "updated_at", None), - "expires_at": getattr(payment, "expires_at", None), - "is_pending": self._is_pending(provider, payment), - } - - if include_details: - data["metadata"] = self._collect_metadata(provider, payment) - - return data - - def _collect_metadata(self, provider: PaymentMethod, payment: Any) -> dict[str, Any]: - metadata: dict[str, Any] = {} - - base_metadata = getattr(payment, "metadata_json", None) - if base_metadata: - metadata["metadata_json"] = base_metadata - - callback_payload = getattr(payment, "callback_payload", None) - if callback_payload: - metadata["callback_payload"] = callback_payload - - if provider == PaymentMethod.YOOKASSA: - metadata.update( - { - "confirmation_url": getattr(payment, "confirmation_url", None), - "payment_method_type": getattr(payment, "payment_method_type", None), - "refundable": getattr(payment, "refundable", None), - "test_mode": getattr(payment, "test_mode", None), - } - ) - elif provider == PaymentMethod.MULENPAY: - metadata.update( - { - "mulen_payment_id": getattr(payment, "mulen_payment_id", None), - } - ) - elif provider == PaymentMethod.PAL24: - metadata.update( - { - "payment_id": getattr(payment, "payment_id", None), - "payment_status": getattr(payment, "payment_status", None), - "payment_method": getattr(payment, "payment_method", None), - "balance_amount": getattr(payment, "balance_amount", None), - "balance_currency": getattr(payment, "balance_currency", None), - "payer_account": getattr(payment, "payer_account", None), - "last_status": getattr(payment, "last_status", None), - } - ) - elif provider == PaymentMethod.WATA: - metadata.update( - { - "terminal_public_id": getattr(payment, "terminal_public_id", None), - "last_status": getattr(payment, "last_status", None), - "success_redirect_url": getattr(payment, "success_redirect_url", None), - "fail_redirect_url": getattr(payment, "fail_redirect_url", None), - } - ) - elif provider == PaymentMethod.HELEKET: - metadata.update( - { - "order_id": getattr(payment, "order_id", None), - "payer_amount": getattr(payment, "payer_amount", None), - "payer_currency": getattr(payment, "payer_currency", None), - "exchange_rate": getattr(payment, "exchange_rate", None), - "discount_percent": getattr(payment, "discount_percent", None), - } - ) - - # Remove empty values for cleanliness - cleaned = { - key: value - for key, value in metadata.items() - if value is not None and value != {} - } - - return cleaned - - def _get_payment_url(self, provider: PaymentMethod, payment: Any) -> Optional[str]: - if provider == PaymentMethod.YOOKASSA: - return getattr(payment, "confirmation_url", None) - if provider == PaymentMethod.MULENPAY: - return getattr(payment, "payment_url", None) - if provider == PaymentMethod.PAL24: - return ( - getattr(payment, "link_url", None) - or getattr(payment, "link_page_url", None) - ) - if provider == PaymentMethod.WATA: - return getattr(payment, "url", None) - if provider == PaymentMethod.HELEKET: - return getattr(payment, "payment_url", None) - - return None - - def _get_external_id(self, provider: PaymentMethod, payment: Any) -> Optional[str]: - if provider == PaymentMethod.YOOKASSA: - return getattr(payment, "yookassa_payment_id", None) - if provider == PaymentMethod.MULENPAY: - return getattr(payment, "uuid", None) - if provider == PaymentMethod.PAL24: - return getattr(payment, "bill_id", None) - if provider == PaymentMethod.WATA: - return getattr(payment, "payment_link_id", None) - if provider == PaymentMethod.HELEKET: - return getattr(payment, "uuid", None) - - return None - - def _extract_user(self, payment: Any) -> dict[str, Any]: - user = getattr(payment, "user", None) - if not user: - return {} - - return { - "id": getattr(user, "id", None), - "telegram_id": getattr(user, "telegram_id", None), - "username": getattr(user, "username", None), - "first_name": getattr(user, "first_name", None), - "last_name": getattr(user, "last_name", None), - } - - def _is_pending(self, provider: PaymentMethod, payment: Any) -> bool: - status_raw = getattr(payment, "status", "") or "" - status_lower = str(status_raw).lower() - - if provider == PaymentMethod.YOOKASSA: - return status_lower in {"pending", "waiting_for_capture"} and not getattr( - payment, "is_paid", False - ) - if provider == PaymentMethod.MULENPAY: - return status_lower in {"created", "processing", "hold"} and not getattr( - payment, "is_paid", False - ) - if provider == PaymentMethod.PAL24: - return status_lower in {"new", "process", "underpaid"} and not getattr( - payment, "is_paid", False - ) - if provider == PaymentMethod.WATA: - return status_lower not in {"closed", "paid"} and not getattr( - payment, "is_paid", False - ) - if provider == PaymentMethod.HELEKET: - return status_lower not in {"paid", "paid_over"} and getattr( - payment, "transaction_id", None - ) is None - if provider == PaymentMethod.CRYPTOBOT: - return status_lower == "active" - - return False - - def _is_within_age(self, payment: Any, max_age_hours: int) -> bool: - created_at = getattr(payment, "created_at", None) - if created_at is None: - return True - - cutoff = datetime.utcnow() - timedelta(hours=max_age_hours) - return created_at >= cutoff diff --git a/app/webapi/routes/transactions.py b/app/webapi/routes/transactions.py index 6fd67e95..c00ebca1 100644 --- a/app/webapi/routes/transactions.py +++ b/app/webapi/routes/transactions.py @@ -3,24 +3,14 @@ from __future__ import annotations from datetime import datetime from typing import Any, Optional -from fastapi import APIRouter, Depends, HTTPException, Query, Security, status +from fastapi import APIRouter, Depends, Query, Security from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import PaymentMethod, Transaction -from app.services.pending_payment_service import PendingPaymentService, PendingPaymentError, PendingPaymentNotFoundError, PendingPaymentNotPendingError, PendingPaymentTooOldError, PendingPaymentUnsupportedError +from app.database.models import Transaction from ..dependencies import get_db_session, require_api_token -from ..schemas.transactions import ( - PendingPaymentBulkCheckResponse, - PendingPaymentCheckResponse, - PendingPaymentDetailResponse, - PendingPaymentListResponse, - PendingPaymentResponse, - PendingPaymentUserResponse, - TransactionListResponse, - TransactionResponse, -) +from ..schemas.transactions import TransactionListResponse, TransactionResponse router = APIRouter() @@ -87,172 +77,3 @@ async def list_transactions( limit=limit, offset=offset, ) - - -def _parse_provider(service: PendingPaymentService, provider: str) -> PaymentMethod: - try: - method = PaymentMethod(provider) - except ValueError as error: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown payment provider") from error - - if method not in service.SUPPORTED_METHODS: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unsupported payment provider") - - return method - - -def _build_pending_summary(data: dict) -> PendingPaymentResponse: - user_data = data.get("user") or {} - return PendingPaymentResponse( - id=data.get("id"), - provider=data.get("provider"), - user=PendingPaymentUserResponse(**user_data), - amount_kopeks=data.get("amount_kopeks", 0), - amount_rubles=data.get("amount_rubles", 0.0), - currency=data.get("currency", "RUB"), - status=data.get("status"), - is_paid=data.get("is_paid", False), - description=data.get("description"), - payment_url=data.get("payment_url"), - external_id=data.get("external_id"), - transaction_id=data.get("transaction_id"), - created_at=data.get("created_at"), - updated_at=data.get("updated_at"), - expires_at=data.get("expires_at"), - is_pending=data.get("is_pending", False), - ) - - -def _build_pending_detail(data: dict) -> PendingPaymentDetailResponse: - summary = _build_pending_summary(data) - return PendingPaymentDetailResponse( - **summary.dict(), - metadata=data.get("metadata"), - ) - - -@router.get("/pending-payments", response_model=PendingPaymentListResponse) -async def list_pending_payments( - _: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), - provider: Optional[str] = Query(default=None), - max_age_hours: int = Query(default=24, ge=1, le=72), -) -> PendingPaymentListResponse: - service = PendingPaymentService() - payment_method = _parse_provider(service, provider) if provider else None - - raw_items = await service.list_pending_payments( - db, - max_age_hours=max_age_hours, - provider=payment_method, - ) - - items = [_build_pending_summary(item) for item in raw_items] - - return PendingPaymentListResponse( - items=items, - total=len(items), - ) - - -@router.get( - "/pending-payments/{provider}/{payment_id}", - response_model=PendingPaymentDetailResponse, -) -async def get_pending_payment( - provider: str, - payment_id: int, - _: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), -) -> PendingPaymentDetailResponse: - service = PendingPaymentService() - payment_method = _parse_provider(service, provider) - - try: - payment = await service.get_payment(db, payment_method, payment_id) - except PendingPaymentNotFoundError as error: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Payment not found") from error - - return _build_pending_detail(payment) - - -@router.post( - "/pending-payments/{provider}/{payment_id}/check", - response_model=PendingPaymentCheckResponse, -) -async def check_pending_payment( - provider: str, - payment_id: int, - _: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), - max_age_hours: int = Query(default=24, ge=1, le=72), -) -> PendingPaymentCheckResponse: - service = PendingPaymentService() - payment_method = _parse_provider(service, provider) - - try: - result = await service.run_manual_check( - db, - payment_method, - payment_id, - max_age_hours=max_age_hours, - ) - except PendingPaymentNotFoundError as error: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Payment not found") from error - except PendingPaymentNotPendingError as error: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Payment is not pending") from error - except PendingPaymentTooOldError as error: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Payment is older than the allowed interval") from error - except PendingPaymentUnsupportedError as error: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error - except PendingPaymentError as error: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to check payment") from error - - detail = _build_pending_detail(result) - - return PendingPaymentCheckResponse( - payment=detail, - check_performed=bool(result.get("check_performed")), - status_before=result.get("status_before"), - status_after=result.get("status_after"), - completed=bool(result.get("completed")), - ) - - -@router.post( - "/pending-payments/check", - response_model=PendingPaymentBulkCheckResponse, -) -async def check_all_pending_payments( - _: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), - provider: Optional[str] = Query(default=None), - max_age_hours: int = Query(default=24, ge=1, le=72), -) -> PendingPaymentBulkCheckResponse: - service = PendingPaymentService() - payment_method = _parse_provider(service, provider) if provider else None - - summary = await service.run_bulk_check( - db, - max_age_hours=max_age_hours, - provider=payment_method, - ) - - results = [ - PendingPaymentCheckResponse( - payment=_build_pending_detail(item), - check_performed=bool(item.get("check_performed")), - status_before=item.get("status_before"), - status_after=item.get("status_after"), - completed=bool(item.get("completed")), - ) - for item in summary.get("results", []) - ] - - return PendingPaymentBulkCheckResponse( - total=summary.get("total", 0), - checked=summary.get("checked", 0), - completed=summary.get("completed", 0), - skipped=summary.get("skipped", 0), - results=results, - ) diff --git a/app/webapi/schemas/transactions.py b/app/webapi/schemas/transactions.py index aef0534a..9408f6c9 100644 --- a/app/webapi/schemas/transactions.py +++ b/app/webapi/schemas/transactions.py @@ -1,7 +1,7 @@ from __future__ import annotations from datetime import datetime -from typing import Any, Optional +from typing import Optional from pydantic import BaseModel @@ -25,55 +25,3 @@ class TransactionListResponse(BaseModel): total: int limit: int offset: int - - -class PendingPaymentUserResponse(BaseModel): - id: Optional[int] = None - telegram_id: Optional[int] = None - username: Optional[str] = None - first_name: Optional[str] = None - last_name: Optional[str] = None - - -class PendingPaymentResponse(BaseModel): - id: int - provider: str - user: PendingPaymentUserResponse - amount_kopeks: int - amount_rubles: float - currency: str - status: Optional[str] = None - is_paid: bool - description: Optional[str] = None - payment_url: Optional[str] = None - external_id: Optional[str] = None - transaction_id: Optional[int] = None - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None - expires_at: Optional[datetime] = None - is_pending: bool - - -class PendingPaymentDetailResponse(PendingPaymentResponse): - metadata: Optional[dict[str, Any]] = None - - -class PendingPaymentListResponse(BaseModel): - items: list[PendingPaymentResponse] - total: int - - -class PendingPaymentCheckResponse(BaseModel): - payment: PendingPaymentDetailResponse - check_performed: bool - status_before: Optional[str] = None - status_after: Optional[str] = None - completed: bool - - -class PendingPaymentBulkCheckResponse(BaseModel): - total: int - checked: int - completed: int - skipped: int - results: list[PendingPaymentCheckResponse] From 969cd6ed9008492b8b493e6b8423eb776ba1b8b2 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 26 Oct 2025 07:24:03 +0300 Subject: [PATCH 05/21] Add admin top-up verification tools --- app/bot.py | 2 + app/handlers/admin/payments.py | 513 +++++++++++++++++++ app/keyboards/admin.py | 6 + app/localization/locales/en.json | 33 ++ app/localization/locales/ru.json | 33 ++ app/services/payment/yookassa.py | 78 +++ app/services/payment_verification_service.py | 431 ++++++++++++++++ 7 files changed, 1096 insertions(+) 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/handlers/admin/payments.py b/app/handlers/admin/payments.py new file mode 100644 index 00000000..e628aa40 --- /dev/null +++ b/app/handlers/admin/payments.py @@ -0,0 +1,513 @@ +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" + 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"))) + + 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"} + 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 + + 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) + 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) + 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 _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_")) + 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..28c8a277 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,38 @@ "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_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..1c36f87d 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,38 @@ "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_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/yookassa.py b/app/services/payment/yookassa.py index e9489bc3..fdcd517a 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -207,6 +207,84 @@ 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 + + if payment.status == "succeeded" and getattr(payment, "is_paid", False): + 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, diff --git a/app/services/payment_verification_service.py b/app/services/payment_verification_service.py new file mode 100644 index 00000000..e96cd961 --- /dev/null +++ b/app/services/payment_verification_service.py @@ -0,0 +1,431 @@ +"""Helpers for inspecting and manually checking pending top-up payments.""" + +from __future__ import annotations + +import logging +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.database.models import ( + HeleketPayment, + MulenPayPayment, + Pal24Payment, + PaymentMethod, + 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, + } +) + + +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 _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 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), + ) + + 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)), + ) + + 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 + 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 + From ed404a0e54d00991cb11f241ce8dd34ea68728ed Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 26 Oct 2025 07:37:15 +0300 Subject: [PATCH 06/21] Revert "Add admin top-up verification tools" --- app/bot.py | 2 - app/handlers/admin/payments.py | 513 ------------------- app/keyboards/admin.py | 6 - app/localization/locales/en.json | 33 -- app/localization/locales/ru.json | 33 -- app/services/payment/yookassa.py | 78 --- app/services/payment_verification_service.py | 431 ---------------- 7 files changed, 1096 deletions(-) delete mode 100644 app/handlers/admin/payments.py delete mode 100644 app/services/payment_verification_service.py diff --git a/app/bot.py b/app/bot.py index 19097554..826d8d6e 100644 --- a/app/bot.py +++ b/app/bot.py @@ -58,7 +58,6 @@ 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 @@ -173,7 +172,6 @@ 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/handlers/admin/payments.py b/app/handlers/admin/payments.py deleted file mode 100644 index e628aa40..00000000 --- a/app/handlers/admin/payments.py +++ /dev/null @@ -1,513 +0,0 @@ -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" - 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"))) - - 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"} - 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 - - 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) - 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) - 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 _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_")) - 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 5d993543..0fad927c 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -53,12 +53,6 @@ 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 28c8a277..0103e8e6 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -130,7 +130,6 @@ "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", @@ -165,38 +164,6 @@ "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_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 1c36f87d..20f6ed7e 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -130,7 +130,6 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Настройки", "ADMIN_MAIN_SUPPORT": "🛟 Поддержка", "ADMIN_MAIN_SYSTEM": "🛠️ Система", - "ADMIN_MAIN_PAYMENTS": "💳 Пополнения", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки", "ADMIN_MESSAGES": "📨 Рассылки", "ADMIN_MESSAGES_ALL_USERS": "📨 Всем пользователям", @@ -165,38 +164,6 @@ "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_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/yookassa.py b/app/services/payment/yookassa.py index fdcd517a..e9489bc3 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -207,84 +207,6 @@ 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 - - if payment.status == "succeeded" and getattr(payment, "is_paid", False): - 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, diff --git a/app/services/payment_verification_service.py b/app/services/payment_verification_service.py deleted file mode 100644 index e96cd961..00000000 --- a/app/services/payment_verification_service.py +++ /dev/null @@ -1,431 +0,0 @@ -"""Helpers for inspecting and manually checking pending top-up payments.""" - -from __future__ import annotations - -import logging -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.database.models import ( - HeleketPayment, - MulenPayPayment, - Pal24Payment, - PaymentMethod, - 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, - } -) - - -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 _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 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), - ) - - 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)), - ) - - 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 - 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 - From 6a0b2aabaf12f07dafdcad7958fd7b67e4011cf2 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 26 Oct 2025 07:38:17 +0300 Subject: [PATCH 07/21] Add payment service wrapper for YooKassa local lookup --- app/bot.py | 2 + app/handlers/admin/payments.py | 513 +++++++++++++++++++ app/keyboards/admin.py | 6 + app/localization/locales/en.json | 33 ++ app/localization/locales/ru.json | 33 ++ app/services/payment/yookassa.py | 78 +++ app/services/payment_service.py | 5 + app/services/payment_verification_service.py | 431 ++++++++++++++++ 8 files changed, 1101 insertions(+) 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/handlers/admin/payments.py b/app/handlers/admin/payments.py new file mode 100644 index 00000000..e628aa40 --- /dev/null +++ b/app/handlers/admin/payments.py @@ -0,0 +1,513 @@ +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" + 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"))) + + 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"} + 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 + + 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) + 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) + 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 _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_")) + 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..28c8a277 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,38 @@ "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_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..1c36f87d 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,38 @@ "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_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/yookassa.py b/app/services/payment/yookassa.py index e9489bc3..fdcd517a 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -207,6 +207,84 @@ 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 + + if payment.status == "succeeded" and getattr(payment, "is_paid", False): + 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, 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..e96cd961 --- /dev/null +++ b/app/services/payment_verification_service.py @@ -0,0 +1,431 @@ +"""Helpers for inspecting and manually checking pending top-up payments.""" + +from __future__ import annotations + +import logging +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.database.models import ( + HeleketPayment, + MulenPayPayment, + Pal24Payment, + PaymentMethod, + 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, + } +) + + +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 _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 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), + ) + + 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)), + ) + + 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 + 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 + From 8e21c34c8c2902cf4f6f4151692c7b5424ba9244 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 26 Oct 2025 08:18:07 +0300 Subject: [PATCH 08/21] Revert "Fix admin YooKassa manual check lookup" --- app/bot.py | 2 - app/handlers/admin/payments.py | 513 ------------------- app/keyboards/admin.py | 6 - app/localization/locales/en.json | 33 -- app/localization/locales/ru.json | 33 -- app/services/payment/yookassa.py | 78 --- app/services/payment_service.py | 5 - app/services/payment_verification_service.py | 431 ---------------- 8 files changed, 1101 deletions(-) delete mode 100644 app/handlers/admin/payments.py delete mode 100644 app/services/payment_verification_service.py diff --git a/app/bot.py b/app/bot.py index 19097554..826d8d6e 100644 --- a/app/bot.py +++ b/app/bot.py @@ -58,7 +58,6 @@ 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 @@ -173,7 +172,6 @@ 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/handlers/admin/payments.py b/app/handlers/admin/payments.py deleted file mode 100644 index e628aa40..00000000 --- a/app/handlers/admin/payments.py +++ /dev/null @@ -1,513 +0,0 @@ -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" - 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"))) - - 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"} - 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 - - 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) - 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) - 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 _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_")) - 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 5d993543..0fad927c 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -53,12 +53,6 @@ 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 28c8a277..0103e8e6 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -130,7 +130,6 @@ "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", @@ -165,38 +164,6 @@ "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_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 1c36f87d..20f6ed7e 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -130,7 +130,6 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Настройки", "ADMIN_MAIN_SUPPORT": "🛟 Поддержка", "ADMIN_MAIN_SYSTEM": "🛠️ Система", - "ADMIN_MAIN_PAYMENTS": "💳 Пополнения", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки", "ADMIN_MESSAGES": "📨 Рассылки", "ADMIN_MESSAGES_ALL_USERS": "📨 Всем пользователям", @@ -165,38 +164,6 @@ "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_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/yookassa.py b/app/services/payment/yookassa.py index fdcd517a..e9489bc3 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -207,84 +207,6 @@ 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 - - if payment.status == "succeeded" and getattr(payment, "is_paid", False): - 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, diff --git a/app/services/payment_service.py b/app/services/payment_service.py index b9f414ed..6acb83dd 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -55,11 +55,6 @@ 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 deleted file mode 100644 index e96cd961..00000000 --- a/app/services/payment_verification_service.py +++ /dev/null @@ -1,431 +0,0 @@ -"""Helpers for inspecting and manually checking pending top-up payments.""" - -from __future__ import annotations - -import logging -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.database.models import ( - HeleketPayment, - MulenPayPayment, - Pal24Payment, - PaymentMethod, - 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, - } -) - - -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 _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 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), - ) - - 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)), - ) - - 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 - 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 - From f3a1de633521bc8135045f782d46a1b4825f8ae3 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 26 Oct 2025 08:18:30 +0300 Subject: [PATCH 09/21] Include CryptoBot and Stars in admin top-up checks --- app/bot.py | 2 + app/external/cryptobot.py | 14 +- app/handlers/admin/payments.py | 575 +++++++++++++++++++ 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 | 78 +++ app/services/payment_service.py | 5 + app/services/payment_verification_service.py | 542 +++++++++++++++++ 10 files changed, 1365 insertions(+), 5 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/external/cryptobot.py b/app/external/cryptobot.py index 5fce68c6..c9e5bcc7 100644 --- a/app/external/cryptobot.py +++ b/app/external/cryptobot.py @@ -95,20 +95,24 @@ 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 - + + if invoice_ids: + data['invoice_ids'] = invoice_ids + return await self._make_request('GET', 'getInvoices', data) async def get_balance(self) -> Optional[list]: diff --git a/app/handlers/admin/payments.py b/app/handlers/admin/payments.py new file mode 100644 index 00000000..8436a3f1 --- /dev/null +++ b/app/handlers/admin/payments.py @@ -0,0 +1,575 @@ +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_")) + 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..fdcd517a 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -207,6 +207,84 @@ 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 + + if payment.status == "succeeded" and getattr(payment, "is_paid", False): + 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, 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..89e8a0ff --- /dev/null +++ b/app/services/payment_verification_service.py @@ -0,0 +1,542 @@ +"""Helpers for inspecting and manually checking pending top-up payments.""" + +from __future__ import annotations + +import logging +import re +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.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, + } +) + + +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 + From 0dacd795982ecf279637d777c35d372dfbcf406a Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 26 Oct 2025 08:25:03 +0300 Subject: [PATCH 10/21] Revert "Include CryptoBot and Stars in admin payment verification" --- app/bot.py | 2 - app/external/cryptobot.py | 14 +- app/handlers/admin/payments.py | 575 ------------------- 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 | 78 --- app/services/payment_service.py | 5 - app/services/payment_verification_service.py | 542 ----------------- 10 files changed, 5 insertions(+), 1365 deletions(-) delete mode 100644 app/handlers/admin/payments.py delete mode 100644 app/services/payment_verification_service.py diff --git a/app/bot.py b/app/bot.py index 19097554..826d8d6e 100644 --- a/app/bot.py +++ b/app/bot.py @@ -58,7 +58,6 @@ 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 @@ -173,7 +172,6 @@ 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/external/cryptobot.py b/app/external/cryptobot.py index c9e5bcc7..5fce68c6 100644 --- a/app/external/cryptobot.py +++ b/app/external/cryptobot.py @@ -95,24 +95,20 @@ class CryptoBotService: asset: Optional[str] = None, status: Optional[str] = None, offset: int = 0, - count: int = 100, - invoice_ids: Optional[list] = None, + count: int = 100 ) -> Optional[list]: - + data = { 'offset': offset, 'count': count } - + if asset: data['asset'] = asset - + if status: data['status'] = status - - if invoice_ids: - data['invoice_ids'] = invoice_ids - + return await self._make_request('GET', 'getInvoices', data) async def get_balance(self) -> Optional[list]: diff --git a/app/handlers/admin/payments.py b/app/handlers/admin/payments.py deleted file mode 100644 index 8436a3f1..00000000 --- a/app/handlers/admin/payments.py +++ /dev/null @@ -1,575 +0,0 @@ -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_")) - 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 5d993543..0fad927c 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -53,12 +53,6 @@ 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 a562f690..0103e8e6 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -130,7 +130,6 @@ "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", @@ -165,39 +164,6 @@ "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 0b4f5100..20f6ed7e 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -130,7 +130,6 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Настройки", "ADMIN_MAIN_SUPPORT": "🛟 Поддержка", "ADMIN_MAIN_SYSTEM": "🛠️ Система", - "ADMIN_MAIN_PAYMENTS": "💳 Пополнения", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки", "ADMIN_MESSAGES": "📨 Рассылки", "ADMIN_MESSAGES_ALL_USERS": "📨 Всем пользователям", @@ -165,39 +164,6 @@ "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 aab5adb5..ebfbe672 100644 --- a/app/services/payment/cryptobot.py +++ b/app/services/payment/cryptobot.py @@ -337,83 +337,3 @@ 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 fdcd517a..e9489bc3 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -207,84 +207,6 @@ 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 - - if payment.status == "succeeded" and getattr(payment, "is_paid", False): - 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, diff --git a/app/services/payment_service.py b/app/services/payment_service.py index b9f414ed..6acb83dd 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -55,11 +55,6 @@ 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 deleted file mode 100644 index 89e8a0ff..00000000 --- a/app/services/payment_verification_service.py +++ /dev/null @@ -1,542 +0,0 @@ -"""Helpers for inspecting and manually checking pending top-up payments.""" - -from __future__ import annotations - -import logging -import re -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.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, - } -) - - -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 - From 49c3c1c0c96df7c24ab811a7331d2c8f457313d4 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 26 Oct 2025 08:25:43 +0300 Subject: [PATCH 11/21] Include CryptoBot and Stars invoices in admin checks --- app/bot.py | 2 + app/handlers/admin/payments.py | 569 +++++++++++++++++++ app/keyboards/admin.py | 6 + app/localization/locales/en.json | 36 ++ app/localization/locales/ru.json | 36 ++ app/services/payment/yookassa.py | 78 +++ app/services/payment_service.py | 5 + app/services/payment_verification_service.py | 568 ++++++++++++++++++ 8 files changed, 1300 insertions(+) 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/handlers/admin/payments.py b/app/handlers/admin/payments.py new file mode 100644 index 00000000..3ff5e2a1 --- /dev/null +++ b/app/handlers/admin/payments.py @@ -0,0 +1,569 @@ +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")), + "pending": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")), + "paid": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")), + "expired": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")), + "canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")), + "cancelled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")), + } + 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"} + 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 = ( + getattr(payment, "bot_invoice_url", None) + or getattr(payment, "mini_app_invoice_url", None) + or getattr(payment, "web_app_invoice_url", None) + 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) + 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}") + + 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: + lines.append( + f" 💱 {html.escape(str(crypto_amount))} {html.escape(str(crypto_asset))}" + ) + + 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) + 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, "paid_at", None): + paid_at = format_datetime(payment.paid_at) + lines.append( + f"💸 {texts.t('ADMIN_PAYMENT_PAID_AT', 'Paid at')}: {paid_at}" + ) + 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 record.method == PaymentMethod.TELEGRAM_STARS: + if getattr(payment, "completed_at", None): + lines.append( + f"✅ {texts.t('ADMIN_PAYMENT_COMPLETED_AT', 'Completed at')}: " + f"{format_datetime(payment.completed_at)}" + ) + + 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_")) + 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..b03ed7f5 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,41 @@ "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_PAID_AT": "Paid at", + "ADMIN_PAYMENT_COMPLETED_AT": "Completed at", + "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..d81f3b98 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,41 @@ "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_PAID_AT": "Время оплаты", + "ADMIN_PAYMENT_COMPLETED_AT": "Завершён", + "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/yookassa.py b/app/services/payment/yookassa.py index e9489bc3..fdcd517a 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -207,6 +207,84 @@ 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 + + if payment.status == "succeeded" and getattr(payment, "is_paid", False): + 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, 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..fa62a3ca --- /dev/null +++ b/app/services/payment_verification_service.py @@ -0,0 +1,568 @@ +"""Helpers for inspecting and manually checking pending top-up payments.""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any, Iterable, List, Optional + +from sqlalchemy import desc, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import ( + CryptoBotPayment, + HeleketPayment, + MulenPayPayment, + Pal24Payment, + PaymentMethod, + Transaction, + 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, + } +) + + +def _extract_amount_from_payload(payload: Optional[str]) -> Optional[int]: + if not payload: + return None + + try: + suffix = payload.rsplit("_", 1)[-1] + amount = int(suffix) + return amount if amount > 0 else None + except (ValueError, TypeError): + return None + + +def _guess_cryptobot_amount(payment: CryptoBotPayment) -> int: + amount_from_payload = _extract_amount_from_payload(getattr(payment, "payload", None)) + if amount_from_payload: + return amount_from_payload + + description = getattr(payment, "description", "") or "" + if description: + match = re.search(r"(\d+(?:[.,]\d+)?)\s*₽", description) + if match: + try: + rubles = float(match.group(1).replace(",", ".")) + if rubles > 0: + return int(round(rubles * 100)) + except ValueError: + pass + + try: + usd_amount = float(getattr(payment, "amount", "0") or 0) + if usd_amount <= 0: + return 0 + except (TypeError, ValueError): + return 0 + + # As a last resort store the crypto amount converted to kopeks without applying FX rate. + return int(round(usd_amount * 100)) + + +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 _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 status and status not in {"active", "pending"}: + continue + amount_kopeks = _guess_cryptobot_amount(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), + expires_at=getattr(payment, "paid_at", None), + ) + 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.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_cryptobot_payments(db, cutoff), + await _fetch_stars_transactions(db, cutoff), + 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), + ) + + 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"]) + return _build_record( + method, + payment, + identifier=payment.invoice_id, + amount_kopeks=_guess_cryptobot_amount(payment), + status=payment.status or "", + is_paid=bool(payment.is_paid), + expires_at=getattr(payment, "paid_at", None), + ) + + 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.created_at < cutoff: + logger.debug("Stars transaction %s is older than cutoff", transaction.id) + 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 + 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 + From f5eea6798afe3656560f952201b9c5a1bbc94a12 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 26 Oct 2025 08:27:04 +0300 Subject: [PATCH 12/21] Revert "Include CryptoBot and Stars invoices in admin checks" --- app/bot.py | 2 - app/handlers/admin/payments.py | 569 ------------------- app/keyboards/admin.py | 6 - app/localization/locales/en.json | 36 -- app/localization/locales/ru.json | 36 -- app/services/payment/yookassa.py | 78 --- app/services/payment_service.py | 5 - app/services/payment_verification_service.py | 568 ------------------ 8 files changed, 1300 deletions(-) delete mode 100644 app/handlers/admin/payments.py delete mode 100644 app/services/payment_verification_service.py diff --git a/app/bot.py b/app/bot.py index 19097554..826d8d6e 100644 --- a/app/bot.py +++ b/app/bot.py @@ -58,7 +58,6 @@ 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 @@ -173,7 +172,6 @@ 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/handlers/admin/payments.py b/app/handlers/admin/payments.py deleted file mode 100644 index 3ff5e2a1..00000000 --- a/app/handlers/admin/payments.py +++ /dev/null @@ -1,569 +0,0 @@ -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")), - "pending": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")), - "paid": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")), - "expired": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")), - "canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")), - "cancelled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")), - } - 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"} - 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 = ( - getattr(payment, "bot_invoice_url", None) - or getattr(payment, "mini_app_invoice_url", None) - or getattr(payment, "web_app_invoice_url", None) - 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) - 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}") - - 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: - lines.append( - f" 💱 {html.escape(str(crypto_amount))} {html.escape(str(crypto_asset))}" - ) - - 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) - 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, "paid_at", None): - paid_at = format_datetime(payment.paid_at) - lines.append( - f"💸 {texts.t('ADMIN_PAYMENT_PAID_AT', 'Paid at')}: {paid_at}" - ) - 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 record.method == PaymentMethod.TELEGRAM_STARS: - if getattr(payment, "completed_at", None): - lines.append( - f"✅ {texts.t('ADMIN_PAYMENT_COMPLETED_AT', 'Completed at')}: " - f"{format_datetime(payment.completed_at)}" - ) - - 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_")) - 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 5d993543..0fad927c 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -53,12 +53,6 @@ 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 b03ed7f5..0103e8e6 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -130,7 +130,6 @@ "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", @@ -165,41 +164,6 @@ "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_PAID_AT": "Paid at", - "ADMIN_PAYMENT_COMPLETED_AT": "Completed at", - "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 d81f3b98..20f6ed7e 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -130,7 +130,6 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Настройки", "ADMIN_MAIN_SUPPORT": "🛟 Поддержка", "ADMIN_MAIN_SYSTEM": "🛠️ Система", - "ADMIN_MAIN_PAYMENTS": "💳 Пополнения", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки", "ADMIN_MESSAGES": "📨 Рассылки", "ADMIN_MESSAGES_ALL_USERS": "📨 Всем пользователям", @@ -165,41 +164,6 @@ "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_PAID_AT": "Время оплаты", - "ADMIN_PAYMENT_COMPLETED_AT": "Завершён", - "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/yookassa.py b/app/services/payment/yookassa.py index fdcd517a..e9489bc3 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -207,84 +207,6 @@ 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 - - if payment.status == "succeeded" and getattr(payment, "is_paid", False): - 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, diff --git a/app/services/payment_service.py b/app/services/payment_service.py index b9f414ed..6acb83dd 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -55,11 +55,6 @@ 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 deleted file mode 100644 index fa62a3ca..00000000 --- a/app/services/payment_verification_service.py +++ /dev/null @@ -1,568 +0,0 @@ -"""Helpers for inspecting and manually checking pending top-up payments.""" - -from __future__ import annotations - -import logging -import re -from dataclasses import dataclass -from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Any, Iterable, List, Optional - -from sqlalchemy import desc, select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload - -from app.database.models import ( - CryptoBotPayment, - HeleketPayment, - MulenPayPayment, - Pal24Payment, - PaymentMethod, - Transaction, - 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, - } -) - - -def _extract_amount_from_payload(payload: Optional[str]) -> Optional[int]: - if not payload: - return None - - try: - suffix = payload.rsplit("_", 1)[-1] - amount = int(suffix) - return amount if amount > 0 else None - except (ValueError, TypeError): - return None - - -def _guess_cryptobot_amount(payment: CryptoBotPayment) -> int: - amount_from_payload = _extract_amount_from_payload(getattr(payment, "payload", None)) - if amount_from_payload: - return amount_from_payload - - description = getattr(payment, "description", "") or "" - if description: - match = re.search(r"(\d+(?:[.,]\d+)?)\s*₽", description) - if match: - try: - rubles = float(match.group(1).replace(",", ".")) - if rubles > 0: - return int(round(rubles * 100)) - except ValueError: - pass - - try: - usd_amount = float(getattr(payment, "amount", "0") or 0) - if usd_amount <= 0: - return 0 - except (TypeError, ValueError): - return 0 - - # As a last resort store the crypto amount converted to kopeks without applying FX rate. - return int(round(usd_amount * 100)) - - -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 _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 status and status not in {"active", "pending"}: - continue - amount_kopeks = _guess_cryptobot_amount(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), - expires_at=getattr(payment, "paid_at", None), - ) - 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.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_cryptobot_payments(db, cutoff), - await _fetch_stars_transactions(db, cutoff), - 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), - ) - - 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"]) - return _build_record( - method, - payment, - identifier=payment.invoice_id, - amount_kopeks=_guess_cryptobot_amount(payment), - status=payment.status or "", - is_paid=bool(payment.is_paid), - expires_at=getattr(payment, "paid_at", None), - ) - - 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.created_at < cutoff: - logger.debug("Stars transaction %s is older than cutoff", transaction.id) - 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 - 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 - From bd036fb4a9de64edd7a3ba8fefc39c73b873229b Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 26 Oct 2025 08:27:22 +0300 Subject: [PATCH 13/21] Show CryptoBot and Stars invoices in admin payments --- app/bot.py | 2 + app/handlers/admin/payments.py | 570 +++++++++++++++++++ app/keyboards/admin.py | 6 + app/localization/locales/en.json | 38 ++ app/localization/locales/ru.json | 38 ++ app/services/payment/yookassa.py | 78 +++ app/services/payment_service.py | 5 + app/services/payment_verification_service.py | 546 ++++++++++++++++++ 8 files changed, 1283 insertions(+) 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/handlers/admin/payments.py b/app/handlers/admin/payments.py new file mode 100644 index 00000000..0298a3b2 --- /dev/null +++ b/app/handlers/admin/payments.py @@ -0,0 +1,570 @@ +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 False + if record.method == PaymentMethod.TELEGRAM_STARS: + return False + 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 = ( + getattr(payment, "web_app_invoice_url", None) + or getattr(payment, "mini_app_invoice_url", None) + or getattr(payment, "bot_invoice_url", None) + 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 = record.amount_display or settings.format_price(record.amount_kopeks) + 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 = record.amount_display or settings.format_price(record.amount_kopeks) + 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, "asset", None): + lines.append( + f"🪙 {texts.t('ADMIN_PAYMENT_CRYPTOBOT_ASSET', 'Asset')}: " + f"{html.escape(str(payment.asset))}" + ) + if getattr(payment, "amount", None): + lines.append( + f"💱 {texts.t('ADMIN_PAYMENT_CRYPTOBOT_AMOUNT', 'Invoice amount')}: " + f"{html.escape(str(payment.amount))}" + ) + if getattr(payment, "paid_at", None): + paid_at = format_datetime(payment.paid_at) + lines.append( + f"✅ {texts.t('ADMIN_PAYMENT_CRYPTOBOT_PAID_AT', 'Paid at')}: {paid_at}" + ) + + if record.method == PaymentMethod.TELEGRAM_STARS: + if getattr(payment, "completed_at", None): + completed_at = format_datetime(payment.completed_at) + lines.append( + f"✅ {texts.t('ADMIN_PAYMENT_STARS_COMPLETED_AT', 'Completed at')}: {completed_at}" + ) + if record.amount_kopeks: + stars_amount = settings.rubles_to_stars(record.amount_kopeks / 100) + lines.append( + f"⭐ {texts.t('ADMIN_PAYMENT_STARS_AMOUNT', 'Stars used')}: {stars_amount}" + ) + + 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_")) + 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..7c014796 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,43 @@ "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_CRYPTOBOT_ASSET": "Asset", + "ADMIN_PAYMENT_CRYPTOBOT_AMOUNT": "Invoice amount", + "ADMIN_PAYMENT_CRYPTOBOT_PAID_AT": "Paid at", + "ADMIN_PAYMENT_STARS_COMPLETED_AT": "Completed at", + "ADMIN_PAYMENT_STARS_AMOUNT": "Stars used", + "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..b6ebfbca 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,43 @@ "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_CRYPTOBOT_ASSET": "Актив", + "ADMIN_PAYMENT_CRYPTOBOT_AMOUNT": "Сумма счёта", + "ADMIN_PAYMENT_CRYPTOBOT_PAID_AT": "Оплачен", + "ADMIN_PAYMENT_STARS_COMPLETED_AT": "Завершён", + "ADMIN_PAYMENT_STARS_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/yookassa.py b/app/services/payment/yookassa.py index e9489bc3..fdcd517a 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -207,6 +207,84 @@ 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 + + if payment.status == "succeeded" and getattr(payment, "is_paid", False): + 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, 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..404f9ab2 --- /dev/null +++ b/app/services/payment_verification_service.py @@ -0,0 +1,546 @@ +"""Helpers for inspecting and manually checking pending top-up payments.""" + +from __future__ import annotations + +import logging +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.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 + amount_display: Optional[str] = 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, + } +) + + +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 _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, + amount_display: Optional[str] = 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, + amount_display=amount_display, + ) + + +def _is_cryptobot_pending(payment: CryptoBotPayment) -> bool: + if payment.transaction_id: + return False + status = (payment.status or "").lower() + return status == "active" + + +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(): + if not _is_cryptobot_pending(payment): + continue + amount_display = None + if getattr(payment, "amount", None) and getattr(payment, "asset", None): + amount_display = f"{payment.amount} {payment.asset}" + record = _build_record( + PaymentMethod.CRYPTOBOT, + payment, + identifier=payment.invoice_id, + amount_kopeks=0, + status=payment.status or "", + is_paid=bool(payment.is_paid), + amount_display=amount_display, + ) + 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(): + identifier = transaction.external_id or str(transaction.id) + record = _build_record( + PaymentMethod.TELEGRAM_STARS, + transaction, + identifier=identifier, + 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_display = None + if getattr(payment, "amount", None) and getattr(payment, "asset", None): + amount_display = f"{payment.amount} {payment.asset}" + return _build_record( + method, + payment, + identifier=payment.invoice_id, + amount_kopeks=0, + status=payment.status or "", + is_paid=bool(payment.is_paid), + amount_display=amount_display, + ) + + if method == PaymentMethod.TELEGRAM_STARS: + payment = await db.get(Transaction, local_payment_id) + if not payment: + return None + await db.refresh(payment, attribute_names=["user"]) + if payment.created_at < cutoff: + logger.debug("Stars transaction %s is older than cutoff", payment.id) + identifier = payment.external_id or str(payment.id) + return _build_record( + method, + payment, + identifier=identifier, + amount_kopeks=payment.amount_kopeks, + status="paid" if payment.is_completed else "pending", + is_paid=bool(payment.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 + 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 + From 9e09e97ed80379587732f5f534d37a57eb263cf7 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 26 Oct 2025 08:32:30 +0300 Subject: [PATCH 14/21] Revert "Show CryptoBot and Stars invoices in admin payments" --- app/bot.py | 2 - app/handlers/admin/payments.py | 570 ------------------- app/keyboards/admin.py | 6 - app/localization/locales/en.json | 38 -- app/localization/locales/ru.json | 38 -- app/services/payment/yookassa.py | 78 --- app/services/payment_service.py | 5 - app/services/payment_verification_service.py | 546 ------------------ 8 files changed, 1283 deletions(-) delete mode 100644 app/handlers/admin/payments.py delete mode 100644 app/services/payment_verification_service.py diff --git a/app/bot.py b/app/bot.py index 19097554..826d8d6e 100644 --- a/app/bot.py +++ b/app/bot.py @@ -58,7 +58,6 @@ 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 @@ -173,7 +172,6 @@ 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/handlers/admin/payments.py b/app/handlers/admin/payments.py deleted file mode 100644 index 0298a3b2..00000000 --- a/app/handlers/admin/payments.py +++ /dev/null @@ -1,570 +0,0 @@ -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 False - if record.method == PaymentMethod.TELEGRAM_STARS: - return False - 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 = ( - getattr(payment, "web_app_invoice_url", None) - or getattr(payment, "mini_app_invoice_url", None) - or getattr(payment, "bot_invoice_url", None) - 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 = record.amount_display or settings.format_price(record.amount_kopeks) - 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 = record.amount_display or settings.format_price(record.amount_kopeks) - 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, "asset", None): - lines.append( - f"🪙 {texts.t('ADMIN_PAYMENT_CRYPTOBOT_ASSET', 'Asset')}: " - f"{html.escape(str(payment.asset))}" - ) - if getattr(payment, "amount", None): - lines.append( - f"💱 {texts.t('ADMIN_PAYMENT_CRYPTOBOT_AMOUNT', 'Invoice amount')}: " - f"{html.escape(str(payment.amount))}" - ) - if getattr(payment, "paid_at", None): - paid_at = format_datetime(payment.paid_at) - lines.append( - f"✅ {texts.t('ADMIN_PAYMENT_CRYPTOBOT_PAID_AT', 'Paid at')}: {paid_at}" - ) - - if record.method == PaymentMethod.TELEGRAM_STARS: - if getattr(payment, "completed_at", None): - completed_at = format_datetime(payment.completed_at) - lines.append( - f"✅ {texts.t('ADMIN_PAYMENT_STARS_COMPLETED_AT', 'Completed at')}: {completed_at}" - ) - if record.amount_kopeks: - stars_amount = settings.rubles_to_stars(record.amount_kopeks / 100) - lines.append( - f"⭐ {texts.t('ADMIN_PAYMENT_STARS_AMOUNT', 'Stars used')}: {stars_amount}" - ) - - 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_")) - 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 5d993543..0fad927c 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -53,12 +53,6 @@ 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 7c014796..0103e8e6 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -130,7 +130,6 @@ "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", @@ -165,43 +164,6 @@ "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_CRYPTOBOT_ASSET": "Asset", - "ADMIN_PAYMENT_CRYPTOBOT_AMOUNT": "Invoice amount", - "ADMIN_PAYMENT_CRYPTOBOT_PAID_AT": "Paid at", - "ADMIN_PAYMENT_STARS_COMPLETED_AT": "Completed at", - "ADMIN_PAYMENT_STARS_AMOUNT": "Stars used", - "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 b6ebfbca..20f6ed7e 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -130,7 +130,6 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Настройки", "ADMIN_MAIN_SUPPORT": "🛟 Поддержка", "ADMIN_MAIN_SYSTEM": "🛠️ Система", - "ADMIN_MAIN_PAYMENTS": "💳 Пополнения", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки", "ADMIN_MESSAGES": "📨 Рассылки", "ADMIN_MESSAGES_ALL_USERS": "📨 Всем пользователям", @@ -165,43 +164,6 @@ "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_CRYPTOBOT_ASSET": "Актив", - "ADMIN_PAYMENT_CRYPTOBOT_AMOUNT": "Сумма счёта", - "ADMIN_PAYMENT_CRYPTOBOT_PAID_AT": "Оплачен", - "ADMIN_PAYMENT_STARS_COMPLETED_AT": "Завершён", - "ADMIN_PAYMENT_STARS_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/yookassa.py b/app/services/payment/yookassa.py index fdcd517a..e9489bc3 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -207,84 +207,6 @@ 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 - - if payment.status == "succeeded" and getattr(payment, "is_paid", False): - 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, diff --git a/app/services/payment_service.py b/app/services/payment_service.py index b9f414ed..6acb83dd 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -55,11 +55,6 @@ 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 deleted file mode 100644 index 404f9ab2..00000000 --- a/app/services/payment_verification_service.py +++ /dev/null @@ -1,546 +0,0 @@ -"""Helpers for inspecting and manually checking pending top-up payments.""" - -from __future__ import annotations - -import logging -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.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 - amount_display: Optional[str] = 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, - } -) - - -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 _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, - amount_display: Optional[str] = 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, - amount_display=amount_display, - ) - - -def _is_cryptobot_pending(payment: CryptoBotPayment) -> bool: - if payment.transaction_id: - return False - status = (payment.status or "").lower() - return status == "active" - - -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(): - if not _is_cryptobot_pending(payment): - continue - amount_display = None - if getattr(payment, "amount", None) and getattr(payment, "asset", None): - amount_display = f"{payment.amount} {payment.asset}" - record = _build_record( - PaymentMethod.CRYPTOBOT, - payment, - identifier=payment.invoice_id, - amount_kopeks=0, - status=payment.status or "", - is_paid=bool(payment.is_paid), - amount_display=amount_display, - ) - 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(): - identifier = transaction.external_id or str(transaction.id) - record = _build_record( - PaymentMethod.TELEGRAM_STARS, - transaction, - identifier=identifier, - 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_display = None - if getattr(payment, "amount", None) and getattr(payment, "asset", None): - amount_display = f"{payment.amount} {payment.asset}" - return _build_record( - method, - payment, - identifier=payment.invoice_id, - amount_kopeks=0, - status=payment.status or "", - is_paid=bool(payment.is_paid), - amount_display=amount_display, - ) - - if method == PaymentMethod.TELEGRAM_STARS: - payment = await db.get(Transaction, local_payment_id) - if not payment: - return None - await db.refresh(payment, attribute_names=["user"]) - if payment.created_at < cutoff: - logger.debug("Stars transaction %s is older than cutoff", payment.id) - identifier = payment.external_id or str(payment.id) - return _build_record( - method, - payment, - identifier=identifier, - amount_kopeks=payment.amount_kopeks, - status="paid" if payment.is_completed else "pending", - is_paid=bool(payment.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 - 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 - From 538b2b52d388ba8c1a4ca9c21446be9ffd4d9a25 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 26 Oct 2025 08:36:27 +0300 Subject: [PATCH 15/21] Avoid admin payment detail handler catching manual checks --- app/bot.py | 2 + app/external/cryptobot.py | 14 +- 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 | 78 +++ app/services/payment_service.py | 5 + app/services/payment_verification_service.py | 542 +++++++++++++++++ 10 files changed, 1368 insertions(+), 5 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/external/cryptobot.py b/app/external/cryptobot.py index 5fce68c6..c9e5bcc7 100644 --- a/app/external/cryptobot.py +++ b/app/external/cryptobot.py @@ -95,20 +95,24 @@ 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 - + + if invoice_ids: + data['invoice_ids'] = invoice_ids + return await self._make_request('GET', 'getInvoices', data) async def get_balance(self) -> Optional[list]: 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..fdcd517a 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -207,6 +207,84 @@ 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 + + if payment.status == "succeeded" and getattr(payment, "is_paid", False): + 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, 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..89e8a0ff --- /dev/null +++ b/app/services/payment_verification_service.py @@ -0,0 +1,542 @@ +"""Helpers for inspecting and manually checking pending top-up payments.""" + +from __future__ import annotations + +import logging +import re +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.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, + } +) + + +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 + From 901f5fd90d82ef00a74377177178cddf1bc53719 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 26 Oct 2025 08:52:46 +0300 Subject: [PATCH 16/21] Revert "Prevent admin payment detail handler from handling manual check callbacks" --- app/bot.py | 2 - app/external/cryptobot.py | 14 +- 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 | 78 --- app/services/payment_service.py | 5 - app/services/payment_verification_service.py | 542 ----------------- 10 files changed, 5 insertions(+), 1368 deletions(-) delete mode 100644 app/handlers/admin/payments.py delete mode 100644 app/services/payment_verification_service.py diff --git a/app/bot.py b/app/bot.py index 19097554..826d8d6e 100644 --- a/app/bot.py +++ b/app/bot.py @@ -58,7 +58,6 @@ 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 @@ -173,7 +172,6 @@ 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/external/cryptobot.py b/app/external/cryptobot.py index c9e5bcc7..5fce68c6 100644 --- a/app/external/cryptobot.py +++ b/app/external/cryptobot.py @@ -95,24 +95,20 @@ class CryptoBotService: asset: Optional[str] = None, status: Optional[str] = None, offset: int = 0, - count: int = 100, - invoice_ids: Optional[list] = None, + count: int = 100 ) -> Optional[list]: - + data = { 'offset': offset, 'count': count } - + if asset: data['asset'] = asset - + if status: data['status'] = status - - if invoice_ids: - data['invoice_ids'] = invoice_ids - + return await self._make_request('GET', 'getInvoices', data) async def get_balance(self) -> Optional[list]: diff --git a/app/handlers/admin/payments.py b/app/handlers/admin/payments.py deleted file mode 100644 index 52119921..00000000 --- a/app/handlers/admin/payments.py +++ /dev/null @@ -1,578 +0,0 @@ -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 5d993543..0fad927c 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -53,12 +53,6 @@ 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 a562f690..0103e8e6 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -130,7 +130,6 @@ "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", @@ -165,39 +164,6 @@ "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 0b4f5100..20f6ed7e 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -130,7 +130,6 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Настройки", "ADMIN_MAIN_SUPPORT": "🛟 Поддержка", "ADMIN_MAIN_SYSTEM": "🛠️ Система", - "ADMIN_MAIN_PAYMENTS": "💳 Пополнения", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки", "ADMIN_MESSAGES": "📨 Рассылки", "ADMIN_MESSAGES_ALL_USERS": "📨 Всем пользователям", @@ -165,39 +164,6 @@ "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 aab5adb5..ebfbe672 100644 --- a/app/services/payment/cryptobot.py +++ b/app/services/payment/cryptobot.py @@ -337,83 +337,3 @@ 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 fdcd517a..e9489bc3 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -207,84 +207,6 @@ 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 - - if payment.status == "succeeded" and getattr(payment, "is_paid", False): - 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, diff --git a/app/services/payment_service.py b/app/services/payment_service.py index b9f414ed..6acb83dd 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -55,11 +55,6 @@ 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 deleted file mode 100644 index 89e8a0ff..00000000 --- a/app/services/payment_verification_service.py +++ /dev/null @@ -1,542 +0,0 @@ -"""Helpers for inspecting and manually checking pending top-up payments.""" - -from __future__ import annotations - -import logging -import re -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.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, - } -) - - -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 - From 8d7f73edecd1a84d038c4e11524a9592a7d5f8dd Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 26 Oct 2025 08:54:05 +0300 Subject: [PATCH 17/21] Log payment verification service activation --- app/bot.py | 2 + app/external/cryptobot.py | 14 +- 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 | 78 +++ app/services/payment_service.py | 5 + app/services/payment_verification_service.py | 542 +++++++++++++++++ main.py | 45 ++ 11 files changed, 1413 insertions(+), 5 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/external/cryptobot.py b/app/external/cryptobot.py index 5fce68c6..c9e5bcc7 100644 --- a/app/external/cryptobot.py +++ b/app/external/cryptobot.py @@ -95,20 +95,24 @@ 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 - + + if invoice_ids: + data['invoice_ids'] = invoice_ids + return await self._make_request('GET', 'getInvoices', data) async def get_balance(self) -> Optional[list]: 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..fdcd517a 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -207,6 +207,84 @@ 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 + + if payment.status == "succeeded" and getattr(payment, "is_paid", False): + 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, 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..89e8a0ff --- /dev/null +++ b/app/services/payment_verification_service.py @@ -0,0 +1,542 @@ +"""Helpers for inspecting and manually checking pending top-up payments.""" + +from __future__ import annotations + +import logging +import re +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.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, + } +) + + +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/main.py b/main.py index 30508b0e..6b316ede 100644 --- a/main.py +++ b/main.py @@ -13,6 +13,11 @@ 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, +) +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 @@ -215,6 +220,42 @@ async def main(): payment_service = PaymentService(bot) + verification_providers: list[str] = [] + 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("Нет активных провайдеров для ручной проверки") + async with timeline.stage( "Внешняя админка", "🛡️", @@ -423,6 +464,10 @@ async def main(): f"Проверка версий: {'Включен' if version_check_task else 'Отключен'}", f"Отчеты: {'Включен' if reporting_service.is_running() else 'Отключен'}", ] + services_lines.append( + "Проверка пополнений: " + + ("Включена" if verification_providers else "Отключена") + ) timeline.log_section("Активные фоновые сервисы", services_lines, icon="📄") timeline.log_summary() From 8985a89a9f07ec9608e0f4bfda82666a4257aa4a Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 26 Oct 2025 09:09:46 +0300 Subject: [PATCH 18/21] Revert "Log payment verification service activation during startup" --- app/bot.py | 2 - app/external/cryptobot.py | 14 +- 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 | 78 --- app/services/payment_service.py | 5 - app/services/payment_verification_service.py | 542 ----------------- main.py | 45 -- 11 files changed, 5 insertions(+), 1413 deletions(-) delete mode 100644 app/handlers/admin/payments.py delete mode 100644 app/services/payment_verification_service.py diff --git a/app/bot.py b/app/bot.py index 19097554..826d8d6e 100644 --- a/app/bot.py +++ b/app/bot.py @@ -58,7 +58,6 @@ 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 @@ -173,7 +172,6 @@ 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/external/cryptobot.py b/app/external/cryptobot.py index c9e5bcc7..5fce68c6 100644 --- a/app/external/cryptobot.py +++ b/app/external/cryptobot.py @@ -95,24 +95,20 @@ class CryptoBotService: asset: Optional[str] = None, status: Optional[str] = None, offset: int = 0, - count: int = 100, - invoice_ids: Optional[list] = None, + count: int = 100 ) -> Optional[list]: - + data = { 'offset': offset, 'count': count } - + if asset: data['asset'] = asset - + if status: data['status'] = status - - if invoice_ids: - data['invoice_ids'] = invoice_ids - + return await self._make_request('GET', 'getInvoices', data) async def get_balance(self) -> Optional[list]: diff --git a/app/handlers/admin/payments.py b/app/handlers/admin/payments.py deleted file mode 100644 index 52119921..00000000 --- a/app/handlers/admin/payments.py +++ /dev/null @@ -1,578 +0,0 @@ -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 5d993543..0fad927c 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -53,12 +53,6 @@ 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 a562f690..0103e8e6 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -130,7 +130,6 @@ "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", @@ -165,39 +164,6 @@ "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 0b4f5100..20f6ed7e 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -130,7 +130,6 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Настройки", "ADMIN_MAIN_SUPPORT": "🛟 Поддержка", "ADMIN_MAIN_SYSTEM": "🛠️ Система", - "ADMIN_MAIN_PAYMENTS": "💳 Пополнения", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки", "ADMIN_MESSAGES": "📨 Рассылки", "ADMIN_MESSAGES_ALL_USERS": "📨 Всем пользователям", @@ -165,39 +164,6 @@ "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 aab5adb5..ebfbe672 100644 --- a/app/services/payment/cryptobot.py +++ b/app/services/payment/cryptobot.py @@ -337,83 +337,3 @@ 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 fdcd517a..e9489bc3 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -207,84 +207,6 @@ 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 - - if payment.status == "succeeded" and getattr(payment, "is_paid", False): - 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, diff --git a/app/services/payment_service.py b/app/services/payment_service.py index b9f414ed..6acb83dd 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -55,11 +55,6 @@ 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 deleted file mode 100644 index 89e8a0ff..00000000 --- a/app/services/payment_verification_service.py +++ /dev/null @@ -1,542 +0,0 @@ -"""Helpers for inspecting and manually checking pending top-up payments.""" - -from __future__ import annotations - -import logging -import re -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.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, - } -) - - -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/main.py b/main.py index 6b316ede..30508b0e 100644 --- a/main.py +++ b/main.py @@ -13,11 +13,6 @@ 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, -) -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 @@ -220,42 +215,6 @@ async def main(): payment_service = PaymentService(bot) - verification_providers: list[str] = [] - 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("Нет активных провайдеров для ручной проверки") - async with timeline.stage( "Внешняя админка", "🛡️", @@ -464,10 +423,6 @@ async def main(): f"Проверка версий: {'Включен' if version_check_task else 'Отключен'}", f"Отчеты: {'Включен' if reporting_service.is_running() else 'Отключен'}", ] - services_lines.append( - "Проверка пополнений: " - + ("Включена" if verification_providers else "Отключена") - ) timeline.log_section("Активные фоновые сервисы", services_lines, icon="📄") timeline.log_summary() From 3938b568028a0485ec30437935ed23ba0992a575 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 26 Oct 2025 09:15:35 +0300 Subject: [PATCH 19/21] Guard YooKassa manual checks from duplicate processing --- app/bot.py | 2 + app/config.py | 23 + app/external/cryptobot.py | 14 +- 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 | 765 +++++++++++++++++++ app/services/system_settings_service.py | 21 + main.py | 100 ++- 13 files changed, 1763 insertions(+), 8 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..c9e5bcc7 100644 --- a/app/external/cryptobot.py +++ b/app/external/cryptobot.py @@ -95,20 +95,24 @@ 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 - + + if invoice_ids: + data['invoice_ids'] = invoice_ids + return await self._make_request('GET', 'getInvoices', data) async def get_balance(self) -> Optional[list]: 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..78bf53f9 --- /dev/null +++ b/app/services/payment_verification_service.py @@ -0,0 +1,765 @@ +"""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 "—", + ) + + await session.commit() + except Exception: + 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() From 2375bf51b38e583d5db61bd0214ec1b01e5998a3 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 26 Oct 2025 09:24:49 +0300 Subject: [PATCH 20/21] Revert "Guard YooKassa manual checks from duplicate processing" --- app/bot.py | 2 - app/config.py | 23 - app/external/cryptobot.py | 14 +- 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 | 765 ------------------- app/services/system_settings_service.py | 21 - main.py | 100 +-- 13 files changed, 8 insertions(+), 1763 deletions(-) delete mode 100644 app/handlers/admin/payments.py delete mode 100644 app/services/payment_verification_service.py diff --git a/app/bot.py b/app/bot.py index 19097554..826d8d6e 100644 --- a/app/bot.py +++ b/app/bot.py @@ -58,7 +58,6 @@ 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 @@ -173,7 +172,6 @@ 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 aed79f7f..6a132550 100644 --- a/app/config.py +++ b/app/config.py @@ -18,9 +18,6 @@ DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS = [ ] -logger = logging.getLogger(__name__) - - class Settings(BaseSettings): BOT_TOKEN: str @@ -185,8 +182,6 @@ 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 @@ -844,24 +839,6 @@ 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 c9e5bcc7..5fce68c6 100644 --- a/app/external/cryptobot.py +++ b/app/external/cryptobot.py @@ -95,24 +95,20 @@ class CryptoBotService: asset: Optional[str] = None, status: Optional[str] = None, offset: int = 0, - count: int = 100, - invoice_ids: Optional[list] = None, + count: int = 100 ) -> Optional[list]: - + data = { 'offset': offset, 'count': count } - + if asset: data['asset'] = asset - + if status: data['status'] = status - - if invoice_ids: - data['invoice_ids'] = invoice_ids - + return await self._make_request('GET', 'getInvoices', data) async def get_balance(self) -> Optional[list]: diff --git a/app/handlers/admin/payments.py b/app/handlers/admin/payments.py deleted file mode 100644 index 52119921..00000000 --- a/app/handlers/admin/payments.py +++ /dev/null @@ -1,578 +0,0 @@ -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 5d993543..0fad927c 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -53,12 +53,6 @@ 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 a562f690..0103e8e6 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -130,7 +130,6 @@ "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", @@ -165,39 +164,6 @@ "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 0b4f5100..20f6ed7e 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -130,7 +130,6 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Настройки", "ADMIN_MAIN_SUPPORT": "🛟 Поддержка", "ADMIN_MAIN_SYSTEM": "🛠️ Система", - "ADMIN_MAIN_PAYMENTS": "💳 Пополнения", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки", "ADMIN_MESSAGES": "📨 Рассылки", "ADMIN_MESSAGES_ALL_USERS": "📨 Всем пользователям", @@ -165,39 +164,6 @@ "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 aab5adb5..ebfbe672 100644 --- a/app/services/payment/cryptobot.py +++ b/app/services/payment/cryptobot.py @@ -337,83 +337,3 @@ 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 b4210899..e9489bc3 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -207,108 +207,6 @@ 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, @@ -358,17 +256,12 @@ class YooKassaPaymentMixin: is_completed=True, ) - linked_payment = await payment_module.link_yookassa_payment_to_transaction( + 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 b9f414ed..6acb83dd 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -55,11 +55,6 @@ 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 deleted file mode 100644 index 78bf53f9..00000000 --- a/app/services/payment_verification_service.py +++ /dev/null @@ -1,765 +0,0 @@ -"""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 "—", - ) - - await session.commit() - except Exception: - 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 673a8cc0..0041e4bc 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -72,7 +72,6 @@ class BotConfigurationService: "LOCALIZATION": "🌍 Языки интерфейса", "CHANNEL": "📣 Обязательная подписка", "PAYMENT": "💳 Общие платежные настройки", - "PAYMENT_VERIFICATION": "🕵️ Проверка платежей", "TELEGRAM": "⭐ Telegram Stars", "CRYPTOBOT": "🪙 CryptoBot", "HELEKET": "🪙 Heleket", @@ -125,7 +124,6 @@ class BotConfigurationService: "LOCALIZATION": "Доступные языки, локализация интерфейса и выбор языка.", "CHANNEL": "Настройки обязательной подписки на канал или группу.", "PAYMENT": "Общие тексты платежей, описания чеков и шаблоны.", - "PAYMENT_VERIFICATION": "Автоматическая проверка пополнений и интервал выполнения.", "YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.", "CRYPTOBOT": "CryptoBot и криптоплатежи через Telegram.", "HELEKET": "Heleket: криптоплатежи, ключи мерчанта и вебхуки.", @@ -294,7 +292,6 @@ class BotConfigurationService: "MULENPAY_": "MULENPAY", "PAL24_": "PAL24", "PAYMENT_": "PAYMENT", - "PAYMENT_VERIFICATION_": "PAYMENT_VERIFICATION", "WATA_": "WATA", "EXTERNAL_ADMIN_": "EXTERNAL_ADMIN", "SIMPLE_SUBSCRIPTION_": "SIMPLE_SUBSCRIPTION", @@ -456,24 +453,6 @@ 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 f8a68603..30508b0e 100644 --- a/main.py +++ b/main.py @@ -13,14 +13,6 @@ 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 @@ -222,67 +214,6 @@ 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( "Внешняя админка", @@ -492,18 +423,6 @@ 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() @@ -565,14 +484,7 @@ 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: @@ -591,15 +503,7 @@ 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() From c8dcde0919675b49a647b2d5bcb40c74e576987c Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 26 Oct 2025 09:25:14 +0300 Subject: [PATCH 21/21] Guard auto verification commits --- app/bot.py | 2 + app/config.py | 23 + app/external/cryptobot.py | 14 +- 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, 1765 insertions(+), 8 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..c9e5bcc7 100644 --- a/app/external/cryptobot.py +++ b/app/external/cryptobot.py @@ -95,20 +95,24 @@ 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 - + + if invoice_ids: + data['invoice_ids'] = invoice_ids + return await self._make_request('GET', 'getInvoices', data) async def get_balance(self) -> Optional[list]: 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()