From aacc07835eccccc351a34ebddeb7ef4acf3f44a3 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 26 Oct 2025 10:45:35 +0300 Subject: [PATCH] Revert "Improve Pal24 and Heleket verification coverage" --- app/bot.py | 2 - app/config.py | 23 - app/external/cryptobot.py | 48 +- app/external/heleket.py | 31 +- app/handlers/admin/payments.py | 596 -------------- app/keyboards/admin.py | 6 - app/localization/locales/en.json | 34 - app/localization/locales/ru.json | 34 - app/services/mulenpay_service.py | 18 - app/services/payment/cryptobot.py | 80 -- app/services/payment/heleket.py | 94 +-- app/services/payment/mulenpay.py | 80 +- app/services/payment/pal24.py | 236 +----- app/services/payment/wata.py | 147 +--- 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 +-- .../services/test_payment_service_heleket.py | 64 -- tests/services/test_payment_service_pal24.py | 133 +-- .../services/test_payment_service_webhooks.py | 17 +- 22 files changed, 121 insertions(+), 2524 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 7088dc21..5fce68c6 100644 --- a/app/external/cryptobot.py +++ b/app/external/cryptobot.py @@ -19,10 +19,10 @@ class CryptoBotService: self.webhook_secret = settings.CRYPTOBOT_WEBHOOK_SECRET async def _make_request( - self, - method: str, - endpoint: str, - data: Optional[Dict] = None, + self, + method: str, + endpoint: str, + data: Optional[Dict] = None ) -> Optional[Dict[str, Any]]: if not self.api_token: @@ -37,18 +37,11 @@ class CryptoBotService: try: async with aiohttp.ClientSession() as session: - request_kwargs: Dict[str, Any] = {"headers": headers} - - if method.upper() == "GET": - if data: - request_kwargs["params"] = data - elif data: - request_kwargs["json"] = data - async with session.request( - method, - url, - **request_kwargs, + method, + url, + headers=headers, + json=data if data else None ) as response: response_data = await response.json() @@ -102,34 +95,21 @@ 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 - - result = await self._make_request('GET', 'getInvoices', data) - - if isinstance(result, dict): - items = result.get('items') - return items if isinstance(items, list) else [] - - if isinstance(result, list): - return result - - return [] + + return await self._make_request('GET', 'getInvoices', data) async def get_balance(self) -> Optional[list]: return await self._make_request('GET', 'getBalance') diff --git a/app/external/heleket.py b/app/external/heleket.py index 02845e84..4b11299c 100644 --- a/app/external/heleket.py +++ b/app/external/heleket.py @@ -41,13 +41,7 @@ class HeleketService: raw = f"{encoded}{api_key}" return hashlib.md5(raw.encode("utf-8")).hexdigest() - async def _request( - self, - endpoint: str, - payload: Dict[str, Any], - *, - params: Optional[Dict[str, Any]] = None, - ) -> Optional[Dict[str, Any]]: + async def _request(self, endpoint: str, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: if not self.is_configured: logger.error("Heleket сервис не настроен: merchant или api_key отсутствуют") return None @@ -65,12 +59,7 @@ class HeleketService: try: timeout = aiohttp.ClientTimeout(total=30) async with aiohttp.ClientSession(timeout=timeout) as session: - async with session.post( - url, - data=body.encode("utf-8"), - headers=headers, - params=params, - ) as response: + async with session.post(url, data=body.encode("utf-8"), headers=headers) as response: text = await response.text() if response.content_type != "application/json": logger.error("Ответ Heleket не JSON (%s): %s", response.content_type, text) @@ -115,22 +104,6 @@ class HeleketService: return await self._request("payment/info", payload) - async def list_payments( - self, - *, - date_from: Optional[str] = None, - date_to: Optional[str] = None, - cursor: Optional[str] = None, - ) -> Optional[Dict[str, Any]]: - payload: Dict[str, Any] = {} - if date_from: - payload["date_from"] = date_from - if date_to: - payload["date_to"] = date_to - - params = {"cursor": cursor} if cursor else None - return await self._request("payment/list", payload, params=params) - def verify_webhook_signature(self, payload: Dict[str, Any]) -> bool: if not self.is_configured: logger.warning("Heleket сервис не настроен, подпись пропускается") diff --git a/app/handlers/admin/payments.py b/app/handlers/admin/payments.py deleted file mode 100644 index da1c990a..00000000 --- a/app/handlers/admin/payments.py +++ /dev/null @@ -1,596 +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 _record_display_number(record: PendingPayment) -> str: - if record.identifier: - return str(record.identifier) - return str(record.local_id) - - -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: - number = _record_display_number(record) - details_template = texts.t("ADMIN_PAYMENTS_ITEM_DETAILS", "📄 #{number}") - try: - button_text = details_template.format(number=number) - except Exception: # pragma: no cover - fallback for broken localization - button_text = f"📄 {number}" - buttons.append( - [ - InlineKeyboardButton( - text=button_text, - 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 "" - ) - display_number = html.escape(_record_display_number(record)) - - 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" 🆔 ID: {identifier}") - else: - lines.append(f" 🆔 ID: {display_number}") - - 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) - raw_identifier = record.identifier if record.identifier else record.local_id - identifier = html.escape(str(raw_identifier)) if raw_identifier is not None 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 4e9083e4..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": "📄 #{number}", - "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 81d732a4..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": "📄 №{number}", - "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/mulenpay_service.py b/app/services/mulenpay_service.py index bbd3901c..6cfbebf1 100644 --- a/app/services/mulenpay_service.py +++ b/app/services/mulenpay_service.py @@ -214,21 +214,3 @@ class MulenPayService: async def get_payment(self, payment_id: int) -> Optional[Dict[str, Any]]: return await self._request("GET", f"/v2/payments/{payment_id}") - - async def list_payments( - self, - *, - offset: int = 0, - limit: int = 100, - uuid: Optional[str] = None, - status: Optional[int] = None, - ) -> Optional[Dict[str, Any]]: - params = { - "offset": max(0, offset), - "limit": max(1, min(limit, 1000)), - } - if uuid: - params["uuid"] = uuid - if status is not None: - params["status"] = status - return await self._request("GET", "/v2/payments", params=params) 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/heleket.py b/app/services/payment/heleket.py index 48b3dba3..cffcc201 100644 --- a/app/services/payment/heleket.py +++ b/app/services/payment/heleket.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging import secrets import time -from datetime import datetime, timezone, timedelta +from datetime import datetime, timezone from importlib import import_module from typing import Any, Dict, Optional @@ -417,40 +417,27 @@ class HeleketPaymentMixin: logger.error("Heleket платеж с id=%s не найден", local_payment_id) return None - payload: Optional[Dict[str, Any]] = None try: response = await self.heleket_service.get_payment_info( # type: ignore[union-attr] uuid=payment.uuid, order_id=payment.order_id, ) except Exception as error: # pragma: no cover - defensive - logger.exception( - "Ошибка получения статуса Heleket платежа %s: %s", - payment.uuid, - error, + logger.exception("Ошибка получения статуса Heleket платежа %s: %s", payment.uuid, error) + return payment + + if not response: + logger.warning( + "Heleket API вернул пустой ответ при проверке платежа %s", payment.uuid ) - else: - if response: - result = response.get("result") if isinstance(response, dict) else None - if isinstance(result, dict): - payload = dict(result) - else: - logger.error( - "Некорректный ответ Heleket API при проверке платежа %s: %s", - payment.uuid, - response, - ) + return payment - if payload is None: - fallback = await self._lookup_heleket_payment_history(payment) - if not fallback: - logger.warning( - "Heleket API не вернул информацию по платежу %s", - payment.uuid, - ) - return payment - payload = dict(fallback) + result = response.get("result") if isinstance(response, dict) else None + if not isinstance(result, dict): + logger.error("Некорректный ответ Heleket API при проверке платежа %s: %s", payment.uuid, response) + return payment + payload: Dict[str, Any] = dict(result) payload.setdefault("uuid", payment.uuid) payload.setdefault("order_id", payment.order_id) @@ -461,58 +448,3 @@ class HeleketPaymentMixin: ) return updated_payment or payment - - async def _lookup_heleket_payment_history( - self, - payment: "HeleketPayment", - ) -> Optional[Dict[str, Any]]: - service = getattr(self, "heleket_service", None) - if not service: - return None - - created_at = getattr(payment, "created_at", None) - date_from_str: Optional[str] = None - date_to_str: Optional[str] = None - if isinstance(created_at, datetime): - start = created_at - timedelta(days=2) - end = created_at + timedelta(days=2) - date_from_str = start.strftime("%Y-%m-%d %H:%M:%S") - date_to_str = end.strftime("%Y-%m-%d %H:%M:%S") - - cursor: Optional[str] = None - for _ in range(10): - response = await service.list_payments( - date_from=date_from_str, - date_to=date_to_str, - cursor=cursor, - ) - if not response or not isinstance(response, dict): - return None - - result = response.get("result") - if not isinstance(result, dict): - return None - - items = result.get("items") - if isinstance(items, list): - for item in items: - if not isinstance(item, dict): - continue - uuid = str(item.get("uuid") or "").strip() - order_id = str(item.get("order_id") or "").strip() - if uuid and uuid == str(payment.uuid): - return item - if order_id and order_id == str(payment.order_id): - return item - - paginate = result.get("paginate") - cursor = None - if isinstance(paginate, dict): - next_cursor = paginate.get("nextCursor") - if isinstance(next_cursor, str) and next_cursor: - cursor = next_cursor - - if not cursor: - break - - return None diff --git a/app/services/payment/mulenpay.py b/app/services/payment/mulenpay.py index 71a9dd8b..dc0546ec 100644 --- a/app/services/payment/mulenpay.py +++ b/app/services/payment/mulenpay.py @@ -440,59 +440,35 @@ class MulenPayPaymentMixin: response = await self.mulenpay_service.get_payment( payment.mulen_payment_id ) - if response: - if isinstance(response, dict) and response.get("success"): - remote_data = response.get("payment") - elif isinstance(response, dict) and "status" in response and "id" in response: - remote_data = response - if not remote_data and getattr(self, "mulenpay_service", None): - list_response = await self.mulenpay_service.list_payments( - limit=100, - uuid=payment.uuid, - ) - items = [] - if isinstance(list_response, dict): - items = list_response.get("items") or [] - if items: - for candidate in items: - if not isinstance(candidate, dict): - continue - candidate_id = candidate.get("id") - candidate_uuid = candidate.get("uuid") - if ( - (candidate_id is not None and candidate_id == payment.mulen_payment_id) - or (candidate_uuid and candidate_uuid == payment.uuid) - ): - remote_data = candidate - break + if response and response.get("success"): + remote_data = response.get("payment") + if isinstance(remote_data, dict): + remote_status_code = remote_data.get("status") + mapped_status = self._map_mulenpay_status(remote_status_code) - if isinstance(remote_data, dict): - remote_status_code = remote_data.get("status") - mapped_status = self._map_mulenpay_status(remote_status_code) - - if mapped_status == "success" and not payment.is_paid: - await self.process_mulenpay_callback( - db, - { - "uuid": payment.uuid, - "payment_status": "success", - "id": remote_data.get("id"), - "amount": remote_data.get("amount"), - }, - ) - payment = await payment_module.get_mulenpay_payment_by_local_id( - db, local_payment_id - ) - elif mapped_status and mapped_status != payment.status: - await payment_module.update_mulenpay_payment_status( - db, - payment=payment, - status=mapped_status, - mulen_payment_id=remote_data.get("id"), - ) - payment = await payment_module.get_mulenpay_payment_by_local_id( - db, local_payment_id - ) + if mapped_status == "success" and not payment.is_paid: + await self.process_mulenpay_callback( + db, + { + "uuid": payment.uuid, + "payment_status": "success", + "id": remote_data.get("id"), + "amount": remote_data.get("amount"), + }, + ) + payment = await payment_module.get_mulenpay_payment_by_local_id( + db, local_payment_id + ) + elif mapped_status and mapped_status != payment.status: + await payment_module.update_mulenpay_payment_status( + db, + payment=payment, + status=mapped_status, + mulen_payment_id=remote_data.get("id"), + ) + payment = await payment_module.get_mulenpay_payment_by_local_id( + db, local_payment_id + ) return { "payment": payment, diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py index 7cae4325..011789b1 100644 --- a/app/services/payment/pal24.py +++ b/app/services/payment/pal24.py @@ -6,7 +6,7 @@ import logging from datetime import datetime from importlib import import_module import uuid -from typing import Any, Dict, List, Optional +from typing import Any, Dict, Optional from sqlalchemy.ext.asyncio import AsyncSession @@ -507,135 +507,53 @@ class Pal24PaymentMixin: return None remote_status: Optional[str] = None - remote_payloads: Dict[str, Any] = {} - payment_info_candidates: List[Dict[str, Optional[str]]] = [] + remote_data: Optional[Dict[str, Any]] = None service = getattr(self, "pal24_service", None) if service and payment.bill_id: - bill_id_str = str(payment.bill_id) try: - response = await service.get_bill_status(bill_id_str) + response = await service.get_bill_status(payment.bill_id) + remote_data = response + remote_status = response.get("status") or response.get("bill", {}).get("status") + + payment_info = self._extract_remote_payment_info(response) + + if remote_status: + normalized_remote = str(remote_status).upper() + update_kwargs: Dict[str, Any] = { + "status": normalized_remote, + "payment_status": payment_info.get("status") or remote_status, + } + + if payment_info.get("id"): + update_kwargs["payment_id"] = payment_info["id"] + if payment_info.get("method"): + update_kwargs["payment_method"] = payment_info["method"] + if payment_info.get("balance_amount"): + update_kwargs["balance_amount"] = payment_info["balance_amount"] + if payment_info.get("balance_currency"): + update_kwargs["balance_currency"] = payment_info["balance_currency"] + if payment_info.get("account"): + update_kwargs["payer_account"] = payment_info["account"] + + if normalized_remote in getattr(service, "BILL_SUCCESS_STATES", {"SUCCESS"}): + update_kwargs["is_paid"] = True + if not payment.paid_at: + update_kwargs["paid_at"] = datetime.utcnow() + elif normalized_remote in getattr(service, "BILL_FAILED_STATES", {"FAIL"}): + update_kwargs["is_paid"] = False + elif normalized_remote in getattr(service, "BILL_PENDING_STATES", {"NEW", "PROCESS"}): + update_kwargs.setdefault("is_paid", False) + + payment = await payment_module.update_pal24_payment_status( + db, + payment, + **update_kwargs, + ) except Pal24APIError as error: - logger.error("Ошибка Pal24 API при получении статуса счёта: %s", error) - else: - if response: - remote_payloads["bill_status"] = response - status_value = response.get("status") or (response.get("bill") or {}).get("status") - if status_value: - remote_status = str(status_value).upper() - extracted = self._extract_remote_payment_info(response) - if extracted: - payment_info_candidates.append(extracted) - - if payment.payment_id: - payment_id_str = str(payment.payment_id) - try: - payment_response = await service.get_payment_status(payment_id_str) - except Pal24APIError as error: - logger.error("Ошибка Pal24 API при получении статуса платежа: %s", error) - else: - if payment_response: - remote_payloads["payment_status"] = payment_response - extracted = self._extract_remote_payment_info(payment_response) - if extracted: - payment_info_candidates.append(extracted) - - try: - payments_response = await service.get_bill_payments(bill_id_str) - except Pal24APIError as error: - logger.error("Ошибка Pal24 API при получении списка платежей: %s", error) - else: - if payments_response: - remote_payloads["bill_payments"] = payments_response - for candidate in self._collect_payment_candidates(payments_response): - extracted = self._extract_remote_payment_info(candidate) - if extracted: - payment_info_candidates.append(extracted) - - payment_info = self._select_best_payment_info(payment, payment_info_candidates) - if payment_info: - remote_payloads.setdefault("selected_payment", payment_info) - - bill_success = getattr(service, "BILL_SUCCESS_STATES", {"SUCCESS"}) if service else {"SUCCESS"} - bill_failed = getattr(service, "BILL_FAILED_STATES", {"FAIL"}) if service else {"FAIL"} - bill_pending = getattr(service, "BILL_PENDING_STATES", {"NEW", "PROCESS"}) if service else {"NEW", "PROCESS"} - - update_status = payment.status or "NEW" - update_kwargs: Dict[str, Any] = {} - is_paid_update: Optional[bool] = None - - if remote_status: - update_status = remote_status - if remote_status in bill_success: - is_paid_update = True - elif remote_status in bill_failed: - is_paid_update = False - elif remote_status in bill_pending and is_paid_update is None: - is_paid_update = False - - payment_status_code: Optional[str] = None - if payment_info: - payment_status_code = (payment_info.get("status") or "").upper() or None - if payment_status_code: - existing_status = (getattr(payment, "payment_status", "") or "").upper() - if payment_status_code != existing_status: - update_kwargs["payment_status"] = payment_status_code - - payment_id_value = payment_info.get("id") - if payment_id_value and payment_id_value != (payment.payment_id or ""): - update_kwargs["payment_id"] = payment_id_value - - method_value = payment_info.get("method") - if method_value: - normalized_method = self._normalize_payment_method(method_value) - if normalized_method != (payment.payment_method or ""): - update_kwargs["payment_method"] = normalized_method - - balance_amount = payment_info.get("balance_amount") - if balance_amount and balance_amount != (payment.balance_amount or ""): - update_kwargs["balance_amount"] = balance_amount - - balance_currency = payment_info.get("balance_currency") - if balance_currency and balance_currency != (payment.balance_currency or ""): - update_kwargs["balance_currency"] = balance_currency - - payer_account = payment_info.get("account") - if payer_account and payer_account != (payment.payer_account or ""): - update_kwargs["payer_account"] = payer_account - - if payment_status_code: - success_states = {"SUCCESS", "OVERPAID"} - failed_states = {"FAIL"} - pending_states = {"NEW", "PROCESS", "UNDERPAID"} - if payment_status_code in success_states: - is_paid_update = True - elif payment_status_code in failed_states and is_paid_update is not True: - is_paid_update = False - elif payment_status_code in pending_states and is_paid_update is None: - is_paid_update = False - - if not remote_status and payment_status_code: - update_status = payment_status_code - - if is_paid_update is not None and is_paid_update != bool(payment.is_paid): - update_kwargs["is_paid"] = is_paid_update - if is_paid_update and not payment.paid_at: - update_kwargs.setdefault("paid_at", datetime.utcnow()) - - current_status = payment.status or "" - effective_status = update_status or current_status or "NEW" - needs_update = bool(update_kwargs) or effective_status != current_status - - if needs_update: - payment = await payment_module.update_pal24_payment_status( - db, - payment, - status=effective_status, - **update_kwargs, - ) - - remote_status_for_return = remote_status or payment_status_code - remote_data = remote_payloads or None + logger.error( + "Ошибка Pal24 API при получении статуса: %s", error + ) if payment.is_paid and not payment.transaction_id: try: @@ -658,7 +576,7 @@ class Pal24PaymentMixin: "payment": payment, "status": payment.status, "is_paid": payment.is_paid, - "remote_status": remote_status_for_return, + "remote_status": remote_status, "remote_data": remote_data, } @@ -703,26 +621,11 @@ class Pal24PaymentMixin: or candidate.get("payer_account") or candidate.get("AccountNumber") ), - "bill_id": _stringify( - candidate.get("bill_id") - or candidate.get("BillId") - or candidate.get("billId") - ), } if not isinstance(remote_data, dict): return {} - lower_keys = {str(key).lower() for key in remote_data.keys()} - has_status = any(key in lower_keys for key in ("status", "payment_status")) - has_identifier = any( - key in lower_keys - for key in ("payment_id", "from_card", "account_amount", "id") - ) or "bill_id" in lower_keys - - if has_status and has_identifier and "bill" not in lower_keys: - return _normalize(remote_data) - search_spaces = [remote_data] bill_section = remote_data.get("bill") or remote_data.get("Bill") if isinstance(bill_section, dict): @@ -738,59 +641,8 @@ class Pal24PaymentMixin: if candidate: return _normalize(candidate) - data_section = remote_data.get("data") or remote_data.get("Data") - candidate = _pick_candidate(data_section) - if candidate: - return _normalize(candidate) - return {} - @staticmethod - def _collect_payment_candidates(remote_data: Any) -> List[Dict[str, Any]]: - candidates: List[Dict[str, Any]] = [] - - def _visit(value: Any) -> None: - if isinstance(value, dict): - lower_keys = {str(key).lower() for key in value.keys()} - has_status = any(key in lower_keys for key in ("status", "payment_status")) - has_identifier = any( - key in lower_keys - for key in ("id", "payment_id", "bill_id", "from_card", "account_amount") - ) - if has_status and has_identifier and value not in candidates: - candidates.append(value) - for nested in value.values(): - _visit(nested) - elif isinstance(value, list): - for item in value: - _visit(item) - - _visit(remote_data) - return candidates - - @staticmethod - def _select_best_payment_info( - payment: Any, - candidates: List[Dict[str, Optional[str]]], - ) -> Dict[str, Optional[str]]: - if not candidates: - return {} - - payment_id = str(getattr(payment, "payment_id", "") or "") - bill_id = str(getattr(payment, "bill_id", "") or "") - - for candidate in candidates: - candidate_id = str(candidate.get("id") or "") - if payment_id and candidate_id == payment_id: - return candidate - - for candidate in candidates: - candidate_bill = str(candidate.get("bill_id") or "") - if bill_id and candidate_bill == bill_id: - return candidate - - return candidates[0] - @staticmethod def _normalize_payment_method(payment_method: Optional[str]) -> str: mapping = { diff --git a/app/services/payment/wata.py b/app/services/payment/wata.py index 134c1ca0..213c731e 100644 --- a/app/services/payment/wata.py +++ b/app/services/payment/wata.py @@ -18,52 +18,6 @@ from app.utils.user_utils import format_referrer_info logger = logging.getLogger(__name__) -def _extract_transaction_id(payment: Any, remote_link: Optional[Dict[str, Any]] = None) -> Optional[str]: - """Try to find the remote WATA transaction identifier from stored payloads.""" - - def _from_mapping(mapping: Any) -> Optional[str]: - if isinstance(mapping, str): - try: - import json - - mapping = json.loads(mapping) - except Exception: # pragma: no cover - defensive parsing - return None - if not isinstance(mapping, dict): - return None - for key in ("id", "transaction_id", "transactionId"): - value = mapping.get(key) - if not value: - continue - value_str = str(value) - if "-" in value_str: - return value_str - return None - - candidate = None - - if hasattr(payment, "callback_payload"): - candidate = _from_mapping(getattr(payment, "callback_payload")) - if candidate: - return candidate - - metadata = getattr(payment, "metadata_json", None) - if isinstance(metadata, dict): - if "transaction" in metadata: - candidate = _from_mapping(metadata.get("transaction")) - if candidate: - return candidate - candidate = _from_mapping(metadata) - if candidate: - return candidate - - candidate = _from_mapping(remote_link) - if candidate: - return candidate - - return None - - class WataPaymentMixin: """Encapsulates creation and status handling for WATA payment links.""" @@ -272,7 +226,6 @@ class WataPaymentMixin: remote_link: Optional[Dict[str, Any]] = None transaction_payload: Optional[Dict[str, Any]] = None - transaction_id: Optional[str] = None if getattr(self, "wata_service", None) and payment.payment_link_id: try: @@ -300,84 +253,29 @@ class WataPaymentMixin: remote_status_normalized = (remote_status or "").lower() if remote_status_normalized in {"closed", "paid"} and not payment.is_paid: - transaction_id = _extract_transaction_id(payment, remote_link) - if transaction_id: - try: - transaction_payload = await self.wata_service.get_transaction( # type: ignore[union-attr] - transaction_id - ) - except WataAPIError as error: - logger.error( - "Ошибка получения WATA транзакции %s: %s", - transaction_id, - error, - ) - except Exception as error: # pragma: no cover - safety net - logger.exception( - "Непредвиденная ошибка при запросе WATA транзакции %s: %s", - transaction_id, - error, - ) - if not transaction_payload: - try: - tx_response = await self.wata_service.search_transactions( # type: ignore[union-attr] - order_id=payment.order_id, - payment_link_id=payment.payment_link_id, - status="Paid", - limit=5, - ) - items = tx_response.get("items") or [] - for item in items: - if (item or {}).get("status") == "Paid": - transaction_payload = item - break - except WataAPIError as error: - logger.error( - "Ошибка поиска WATA транзакций для %s: %s", - payment.payment_link_id, - error, - ) - except Exception as error: # pragma: no cover - safety net - logger.exception("Непредвиденная ошибка при поиске WATA транзакции: %s", error) - - if ( - not transaction_payload - and not payment.is_paid - and getattr(self, "wata_service", None) - ): - fallback_transaction_id = transaction_id or _extract_transaction_id(payment) - if fallback_transaction_id: try: - transaction_payload = await self.wata_service.get_transaction( # type: ignore[union-attr] - fallback_transaction_id + tx_response = await self.wata_service.search_transactions( # type: ignore[union-attr] + order_id=payment.order_id, + payment_link_id=payment.payment_link_id, + status="Paid", + limit=5, ) + items = tx_response.get("items") or [] + for item in items: + if (item or {}).get("status") == "Paid": + transaction_payload = item + break except WataAPIError as error: logger.error( - "Ошибка повторного запроса WATA транзакции %s: %s", - fallback_transaction_id, + "Ошибка поиска WATA транзакций для %s: %s", + payment.payment_link_id, error, ) except Exception as error: # pragma: no cover - safety net - logger.exception( - "Непредвиденная ошибка при повторном запросе WATA транзакции %s: %s", - fallback_transaction_id, - error, - ) + logger.exception("Непредвиденная ошибка при поиске WATA транзакции: %s", error) if transaction_payload and not payment.is_paid: - normalized_status = None - if isinstance(transaction_payload, dict): - raw_status = transaction_payload.get("status") or transaction_payload.get("statusName") - if raw_status: - normalized_status = str(raw_status).lower() - if normalized_status == "paid": - payment = await self._finalize_wata_payment(db, payment, transaction_payload) - else: - logger.debug( - "WATA транзакция %s в статусе %s, повторная обработка не требуется", - transaction_id or getattr(payment, "payment_link_id", ""), - normalized_status or "unknown", - ) + payment = await self._finalize_wata_payment(db, payment, transaction_payload) return { "payment": payment, @@ -395,22 +293,7 @@ class WataPaymentMixin: ) -> Any: payment_module = import_module("app.services.payment_service") - if isinstance(transaction_payload, dict): - paid_status = transaction_payload.get("status") or transaction_payload.get("statusName") - else: - paid_status = None - if paid_status and str(paid_status).lower() not in {"paid", "declined", "pending"}: - logger.debug( - "Неизвестный статус WATA транзакции %s: %s", - getattr(payment, "payment_link_id", ""), - paid_status, - ) - - paid_at = None - if isinstance(transaction_payload, dict): - paid_at = WataService._parse_datetime(transaction_payload.get("paymentTime")) - if not paid_at and getattr(payment, "paid_at", None): - paid_at = payment.paid_at + paid_at = WataService._parse_datetime(transaction_payload.get("paymentTime")) existing_metadata = dict(getattr(payment, "metadata_json", {}) or {}) existing_metadata["transaction"] = transaction_payload 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() diff --git a/tests/services/test_payment_service_heleket.py b/tests/services/test_payment_service_heleket.py index 80f1f1fd..26c58100 100644 --- a/tests/services/test_payment_service_heleket.py +++ b/tests/services/test_payment_service_heleket.py @@ -50,8 +50,6 @@ class StubHeleketService: self.info_response = info_response self.calls: list[Dict[str, Any]] = [] self.info_calls: list[Dict[str, Optional[str]]] = [] - self.list_response: Optional[Dict[str, Any]] = None - self.list_calls: list[Dict[str, Optional[str]]] = [] async def create_payment(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: self.calls.append(payload) @@ -66,18 +64,6 @@ class StubHeleketService: self.info_calls.append({"uuid": uuid, "order_id": order_id}) return self.info_response - async def list_payments( - self, - *, - date_from: Optional[str] = None, - date_to: Optional[str] = None, - cursor: Optional[str] = None, - ) -> Optional[Dict[str, Any]]: - self.list_calls.append( - {"date_from": date_from, "date_to": date_to, "cursor": cursor} - ) - return self.list_response - def _make_service(stub: Optional[StubHeleketService]) -> PaymentService: service = PaymentService.__new__(PaymentService) # type: ignore[call-arg] @@ -263,53 +249,3 @@ async def test_sync_heleket_payment_status_without_response(monkeypatch: pytest. assert result is payment assert stub.info_calls == [{"uuid": payment.uuid, "order_id": payment.order_id}] - assert stub.list_calls # fallback to history should be attempted - - -@pytest.mark.anyio("asyncio") -async def test_sync_heleket_payment_status_history_fallback(monkeypatch: pytest.MonkeyPatch) -> None: - stub = StubHeleketService(response=None, info_response=None) - stub.list_response = { - "state": 0, - "result": { - "items": [ - { - "uuid": "heleket-uuid", - "order_id": "order-123", - "status": "paid", - "payment_amount": "150.00", - } - ], - "paginate": {"nextCursor": None}, - }, - } - service = _make_service(stub) - db = DummySession() - - payment = SimpleNamespace( - id=77, - uuid="heleket-uuid", - order_id="order-123", - status="check", - user_id=8, - ) - - async def fake_get_by_id(db, payment_id): - assert payment_id == payment.id - return payment - - captured: Dict[str, Any] = {} - - async def fake_process(self, db, payload, *, metadata_key): - captured["payload"] = payload - captured["metadata_key"] = metadata_key - return SimpleNamespace(**payload) - - monkeypatch.setattr(heleket_crud, "get_heleket_payment_by_id", fake_get_by_id, raising=False) - monkeypatch.setattr(PaymentService, "_process_heleket_payload", fake_process, raising=False) - - result = await service.sync_heleket_payment_status(db, local_payment_id=payment.id) - - assert result is not None - assert captured["payload"]["status"] == "paid" - assert stub.list_calls diff --git a/tests/services/test_payment_service_pal24.py b/tests/services/test_payment_service_pal24.py index 124553b9..02d4f91f 100644 --- a/tests/services/test_payment_service_pal24.py +++ b/tests/services/test_payment_service_pal24.py @@ -4,7 +4,6 @@ from pathlib import Path from typing import Any, Dict, Optional import sys from datetime import datetime -from types import SimpleNamespace import pytest @@ -35,12 +34,7 @@ class DummyLocalPayment: class StubPal24Service: - def __init__( - self, - *, - configured: bool = True, - response: Optional[Dict[str, Any]] = None, - ) -> None: + def __init__(self, *, configured: bool = True, response: Optional[Dict[str, Any]] = None) -> None: self.is_configured = configured self.response = response or { "success": True, @@ -51,12 +45,6 @@ class StubPal24Service: } self.calls: list[Dict[str, Any]] = [] self.raise_error: Optional[Exception] = None - self.status_response: Optional[Dict[str, Any]] = {"status": "NEW"} - self.payment_status_response: Optional[Dict[str, Any]] = None - self.bill_payments_response: Optional[Dict[str, Any]] = None - self.status_calls: list[str] = [] - self.payment_status_calls: list[str] = [] - self.bill_payments_calls: list[str] = [] async def create_bill(self, **kwargs: Any) -> Dict[str, Any]: self.calls.append(kwargs) @@ -64,18 +52,6 @@ class StubPal24Service: raise self.raise_error return self.response - async def get_bill_status(self, bill_id: str) -> Optional[Dict[str, Any]]: - self.status_calls.append(bill_id) - return self.status_response - - async def get_payment_status(self, payment_id: str) -> Optional[Dict[str, Any]]: - self.payment_status_calls.append(payment_id) - return self.payment_status_response - - async def get_bill_payments(self, bill_id: str) -> Optional[Dict[str, Any]]: - self.bill_payments_calls.append(bill_id) - return self.bill_payments_response - def _make_service(stub: Optional[StubPal24Service]) -> PaymentService: service = PaymentService.__new__(PaymentService) # type: ignore[call-arg] @@ -222,110 +198,3 @@ async def test_create_pal24_payment_handles_api_errors(monkeypatch: pytest.Monke language="ru", ) assert result is None - - -@pytest.mark.anyio("asyncio") -async def test_get_pal24_payment_status_updates_from_remote(monkeypatch: pytest.MonkeyPatch) -> None: - stub = StubPal24Service() - stub.status_response = {"status": "SUCCESS"} - stub.payment_status_response = { - "success": True, - "id": "PAY-1", - "bill_id": "BILL-1", - "status": "SUCCESS", - "payment_method": "SBP", - "account_amount": "700.00", - "from_card": "676754******1234", - } - stub.bill_payments_response = { - "data": [ - { - "id": "PAY-1", - "bill_id": "BILL-1", - "status": "SUCCESS", - "from_card": "676754******1234", - "payment_method": "SBP", - } - ] - } - - service = _make_service(stub) - db = DummySession() - - payment = SimpleNamespace( - id=99, - bill_id="BILL-1", - payment_id=None, - payment_status="NEW", - payment_method=None, - balance_amount=None, - balance_currency=None, - payer_account=None, - status="NEW", - is_paid=False, - paid_at=None, - transaction_id=None, - user_id=1, - ) - - async def fake_get_by_id(db: DummySession, payment_id: int) -> SimpleNamespace: - assert payment_id == payment.id - return payment - - async def fake_update_status( - db: DummySession, - payment_obj: SimpleNamespace, - *, - status: str, - **kwargs: Any, - ) -> SimpleNamespace: - payment_obj.status = status - payment_obj.last_status = status - for key, value in kwargs.items(): - setattr(payment_obj, key, value) - if "is_paid" in kwargs: - payment_obj.is_paid = kwargs["is_paid"] - await db.commit() - return payment_obj - - async def fake_finalize( - self: PaymentService, - db: DummySession, - payment_obj: Any, - *, - payment_id: Optional[str] = None, - trigger: str, - ) -> bool: - return False - - monkeypatch.setattr( - payment_service_module, - "get_pal24_payment_by_id", - fake_get_by_id, - raising=False, - ) - monkeypatch.setattr( - payment_service_module, - "update_pal24_payment_status", - fake_update_status, - raising=False, - ) - monkeypatch.setattr( - PaymentService, - "_finalize_pal24_payment", - fake_finalize, - raising=False, - ) - - result = await service.get_pal24_payment_status(db, local_payment_id=payment.id) - - assert result is not None - assert payment.status == "SUCCESS" - assert payment.payment_id == "PAY-1" - assert payment.payment_status == "SUCCESS" - assert payment.payment_method == "sbp" - assert payment.is_paid is True - assert stub.status_calls == ["BILL-1"] - assert stub.payment_status_calls in ([], ["PAY-1"]) - assert result["remote_status"] == "SUCCESS" - assert result["remote_data"] and "bill_status" in result["remote_data"] diff --git a/tests/services/test_payment_service_webhooks.py b/tests/services/test_payment_service_webhooks.py index f003d765..8542fff3 100644 --- a/tests/services/test_payment_service_webhooks.py +++ b/tests/services/test_payment_service_webhooks.py @@ -6,7 +6,7 @@ from __future__ import annotations from pathlib import Path from types import SimpleNamespace, ModuleType -from typing import Any, Dict, Optional +from typing import Any, Dict import sys import pytest @@ -829,21 +829,6 @@ async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.Monkey }, } - async def get_payment_status(self, payment_id: str) -> Optional[Dict[str, Any]]: - return None - - async def get_bill_payments(self, bill_id: str) -> Optional[Dict[str, Any]]: - return { - "data": [ - { - "id": "trs-auto-1", - "bill_id": bill_id, - "status": "SUCCESS", - "payment_method": "SBP", - } - ] - } - service.pal24_service = DummyPal24Service() fake_session = FakeSession()