From 051121a366843650affbb9836a130bd38422d999 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 26 Oct 2025 09:45:56 +0300 Subject: [PATCH 1/2] =?UTF-8?q?Revert=20"=D0=A1=D0=B5=D1=80=D0=B2=D0=B8?= =?UTF-8?q?=D1=81=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BB=D0=B0=D1=82=D0=B5=D0=B6=D0=B5=D0=B9"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 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, 8 insertions(+), 1765 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 c2b44a23..00000000 --- a/app/services/payment_verification_service.py +++ /dev/null @@ -1,767 +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 "—", - ) - - 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 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 86df2a14089adbf78a11c805533497927d0b362b Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 26 Oct 2025 10:44:32 +0300 Subject: [PATCH 2/2] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d3e0e1e0..aca837a4 100644 --- a/README.md +++ b/README.md @@ -748,7 +748,7 @@ LOG_FILE=logs/bot.log - 💸 **Автоматические скидки** при оплате и автопродлении - 👥 Реферальная программа с комиссиями и бонусами - 📊 Аналитика доходов и конверсии рефералов -- 🔗 Генерация реферальных ссылок и QR-кодов +- 🔗 Генерация реферальных ссылок и QR кодов 💎 **Промо-группы и скидки** - 🏷️ **Система промогрупп** с индивидуальными скидками