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()