From 93c733aac05bd5d82059ab880a765a453ebc2ae1 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 26 Oct 2025 10:46:38 +0300 Subject: [PATCH] Revert "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, 2524 insertions(+), 121 deletions(-) create mode 100644 app/handlers/admin/payments.py create mode 100644 app/services/payment_verification_service.py diff --git a/app/bot.py b/app/bot.py index 826d8d6e..19097554 100644 --- a/app/bot.py +++ b/app/bot.py @@ -58,6 +58,7 @@ from app.handlers.admin import ( privacy_policy as admin_privacy_policy, public_offer as admin_public_offer, faq as admin_faq, + payments as admin_payments, ) from app.handlers.stars_payments import register_stars_handlers @@ -172,6 +173,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_privacy_policy.register_handlers(dp) admin_public_offer.register_handlers(dp) admin_faq.register_handlers(dp) + admin_payments.register_handlers(dp) common.register_handlers(dp) register_stars_handlers(dp) user_polls.register_handlers(dp) diff --git a/app/config.py b/app/config.py index 6a132550..aed79f7f 100644 --- a/app/config.py +++ b/app/config.py @@ -18,6 +18,9 @@ DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS = [ ] +logger = logging.getLogger(__name__) + + class Settings(BaseSettings): BOT_TOKEN: str @@ -182,6 +185,8 @@ class Settings(BaseSettings): YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000 YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False DISABLE_TOPUP_BUTTONS: bool = False + PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False + PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10 # Настройки простой покупки SIMPLE_SUBSCRIPTION_ENABLED: bool = False @@ -839,6 +844,24 @@ class Settings(BaseSettings): and self.WATA_TERMINAL_PUBLIC_ID is not None ) + def is_payment_verification_auto_check_enabled(self) -> bool: + return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED + + def get_payment_verification_auto_check_interval(self) -> int: + try: + minutes = int(self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES) + except (TypeError, ValueError): # pragma: no cover - защитная проверка конфигурации + minutes = 10 + + if minutes <= 0: + logger.warning( + "Некорректный интервал автопроверки платежей: %s. Используется значение по умолчанию 10 минут.", + self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES, + ) + return 10 + + return minutes + def get_cryptobot_base_url(self) -> str: if self.CRYPTOBOT_TESTNET: return "https://testnet-pay.crypt.bot" diff --git a/app/external/cryptobot.py b/app/external/cryptobot.py index 5fce68c6..7088dc21 100644 --- a/app/external/cryptobot.py +++ b/app/external/cryptobot.py @@ -19,10 +19,10 @@ class CryptoBotService: self.webhook_secret = settings.CRYPTOBOT_WEBHOOK_SECRET async def _make_request( - self, - method: str, - endpoint: str, - data: Optional[Dict] = None + self, + method: str, + endpoint: str, + data: Optional[Dict] = None, ) -> Optional[Dict[str, Any]]: if not self.api_token: @@ -37,11 +37,18 @@ class CryptoBotService: try: async with aiohttp.ClientSession() as session: + request_kwargs: Dict[str, Any] = {"headers": headers} + + if method.upper() == "GET": + if data: + request_kwargs["params"] = data + elif data: + request_kwargs["json"] = data + async with session.request( - method, - url, - headers=headers, - json=data if data else None + method, + url, + **request_kwargs, ) as response: response_data = await response.json() @@ -95,21 +102,34 @@ class CryptoBotService: asset: Optional[str] = None, status: Optional[str] = None, offset: int = 0, - count: int = 100 + count: int = 100, + invoice_ids: Optional[list] = None, ) -> Optional[list]: - + data = { 'offset': offset, 'count': count } - + if asset: data['asset'] = asset - + if status: data['status'] = status - - return await self._make_request('GET', 'getInvoices', data) + + if invoice_ids: + data['invoice_ids'] = invoice_ids + + result = await self._make_request('GET', 'getInvoices', data) + + if isinstance(result, dict): + items = result.get('items') + return items if isinstance(items, list) else [] + + if isinstance(result, list): + return result + + return [] async def get_balance(self) -> Optional[list]: return await self._make_request('GET', 'getBalance') diff --git a/app/external/heleket.py b/app/external/heleket.py index 4b11299c..02845e84 100644 --- a/app/external/heleket.py +++ b/app/external/heleket.py @@ -41,7 +41,13 @@ 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]) -> Optional[Dict[str, Any]]: + async def _request( + self, + endpoint: str, + payload: Dict[str, Any], + *, + params: Optional[Dict[str, Any]] = None, + ) -> Optional[Dict[str, Any]]: if not self.is_configured: logger.error("Heleket сервис не настроен: merchant или api_key отсутствуют") return None @@ -59,7 +65,12 @@ 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) as response: + async with session.post( + url, + data=body.encode("utf-8"), + headers=headers, + params=params, + ) as response: text = await response.text() if response.content_type != "application/json": logger.error("Ответ Heleket не JSON (%s): %s", response.content_type, text) @@ -104,6 +115,22 @@ 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 new file mode 100644 index 00000000..da1c990a --- /dev/null +++ b/app/handlers/admin/payments.py @@ -0,0 +1,596 @@ +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 0fad927c..5d993543 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -53,6 +53,12 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup: callback_data="admin_submenu_system", ), ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_MAIN_PAYMENTS", "💳 Пополнения"), + callback_data="admin_payments", + ) + ], [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] ]) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 0103e8e6..4e9083e4 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -130,6 +130,7 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Settings", "ADMIN_MAIN_SUPPORT": "🛟 Support", "ADMIN_MAIN_SYSTEM": "🛠️ System", + "ADMIN_MAIN_PAYMENTS": "💳 Top-ups", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions", "ADMIN_MESSAGES": "📨 Broadcasts", "ADMIN_MESSAGES_ALL_USERS": "📨 All users", @@ -164,6 +165,39 @@ "ADMIN_MONITORING_STOP": "⏸️ Stop", "ADMIN_MONITORING_STOP_HARD": "⏹️ Stop", "ADMIN_MONITORING_TEST_NOTIFICATIONS": "🧪 Test notifications", + "ADMIN_PAYMENTS_TITLE": "💳 Top-up verification", + "ADMIN_PAYMENTS_DESCRIPTION": "Pending top-up invoices created during the last 24 hours.", + "ADMIN_PAYMENTS_NOTICE": "Only invoices younger than 24 hours and waiting for payment can be checked.", + "ADMIN_PAYMENTS_EMPTY": "No pending top-up invoices found in the last 24 hours.", + "ADMIN_PAYMENTS_ITEM_DETAILS": "📄 #{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 20f6ed7e..81d732a4 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -130,6 +130,7 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Настройки", "ADMIN_MAIN_SUPPORT": "🛟 Поддержка", "ADMIN_MAIN_SYSTEM": "🛠️ Система", + "ADMIN_MAIN_PAYMENTS": "💳 Пополнения", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки", "ADMIN_MESSAGES": "📨 Рассылки", "ADMIN_MESSAGES_ALL_USERS": "📨 Всем пользователям", @@ -164,6 +165,39 @@ "ADMIN_MONITORING_STOP": "⏸️ Остановить", "ADMIN_MONITORING_STOP_HARD": "⏹️ Остановить", "ADMIN_MONITORING_TEST_NOTIFICATIONS": "🧪 Тест уведомлений", + "ADMIN_PAYMENTS_TITLE": "💳 Проверка пополнений", + "ADMIN_PAYMENTS_DESCRIPTION": "Список счетов на пополнение, созданных за последние 24 часа и ожидающих оплаты.", + "ADMIN_PAYMENTS_NOTICE": "Проверять можно только счета моложе 24 часов и со статусом ожидания.", + "ADMIN_PAYMENTS_EMPTY": "За последние 24 часа не найдено счетов на пополнение в ожидании.", + "ADMIN_PAYMENTS_ITEM_DETAILS": "📄 №{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 6cfbebf1..bbd3901c 100644 --- a/app/services/mulenpay_service.py +++ b/app/services/mulenpay_service.py @@ -214,3 +214,21 @@ 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 ebfbe672..aab5adb5 100644 --- a/app/services/payment/cryptobot.py +++ b/app/services/payment/cryptobot.py @@ -337,3 +337,83 @@ class CryptoBotPaymentMixin: "Ошибка обработки CryptoBot webhook: %s", error, exc_info=True ) return False + + async def get_cryptobot_payment_status( + self, + db: AsyncSession, + local_payment_id: int, + ) -> Optional[Dict[str, Any]]: + """Запрашивает актуальный статус CryptoBot invoice и синхронизирует его.""" + + cryptobot_crud = import_module("app.database.crud.cryptobot") + payment = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id) + if not payment: + logger.warning("CryptoBot платеж %s не найден", local_payment_id) + return None + + if not self.cryptobot_service: + logger.warning("CryptoBot сервис не инициализирован для ручной проверки") + return {"payment": payment} + + invoice_id = payment.invoice_id + try: + invoices = await self.cryptobot_service.get_invoices( + invoice_ids=[invoice_id] + ) + except Exception as error: # pragma: no cover - network errors + logger.error( + "Ошибка запроса статуса CryptoBot invoice %s: %s", + invoice_id, + error, + ) + return {"payment": payment} + + remote_invoice: Optional[Dict[str, Any]] = None + if invoices: + for item in invoices: + if str(item.get("invoice_id")) == str(invoice_id): + remote_invoice = item + break + + if not remote_invoice: + logger.info( + "CryptoBot invoice %s не найден через API при ручной проверке", + invoice_id, + ) + refreshed = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id) + return {"payment": refreshed or payment} + + status = (remote_invoice.get("status") or "").lower() + paid_at_str = remote_invoice.get("paid_at") + paid_at = None + if paid_at_str: + try: + paid_at = datetime.fromisoformat(paid_at_str.replace("Z", "+00:00")).replace( + tzinfo=None + ) + except Exception: # pragma: no cover - defensive parsing + paid_at = None + + if status == "paid": + webhook_payload = { + "update_type": "invoice_paid", + "payload": { + "invoice_id": remote_invoice.get("invoice_id") or invoice_id, + "amount": remote_invoice.get("amount") or payment.amount, + "asset": remote_invoice.get("asset") or payment.asset, + "paid_at": paid_at_str, + "payload": remote_invoice.get("payload") or payment.payload, + }, + } + await self.process_cryptobot_webhook(db, webhook_payload) + else: + if status and status != (payment.status or "").lower(): + await cryptobot_crud.update_cryptobot_payment_status( + db, + invoice_id, + status, + paid_at, + ) + + refreshed = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id) + return {"payment": refreshed or payment} diff --git a/app/services/payment/heleket.py b/app/services/payment/heleket.py index cffcc201..48b3dba3 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 +from datetime import datetime, timezone, timedelta from importlib import import_module from typing import Any, Dict, Optional @@ -417,27 +417,40 @@ 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) - return payment - - if not response: - logger.warning( - "Heleket API вернул пустой ответ при проверке платежа %s", payment.uuid + logger.exception( + "Ошибка получения статуса Heleket платежа %s: %s", + payment.uuid, + error, ) - return payment + 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, + ) - 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 + 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) - payload: Dict[str, Any] = dict(result) payload.setdefault("uuid", payment.uuid) payload.setdefault("order_id", payment.order_id) @@ -448,3 +461,58 @@ 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 dc0546ec..71a9dd8b 100644 --- a/app/services/payment/mulenpay.py +++ b/app/services/payment/mulenpay.py @@ -440,35 +440,59 @@ class MulenPayPaymentMixin: response = await self.mulenpay_service.get_payment( payment.mulen_payment_id ) - 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 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 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 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 + ) return { "payment": payment, diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py index 011789b1..7cae4325 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, Optional +from typing import Any, Dict, List, Optional from sqlalchemy.ext.asyncio import AsyncSession @@ -507,53 +507,135 @@ class Pal24PaymentMixin: return None remote_status: Optional[str] = None - remote_data: Optional[Dict[str, Any]] = None + remote_payloads: Dict[str, Any] = {} + payment_info_candidates: List[Dict[str, Optional[str]]] = [] 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(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, - ) + response = await service.get_bill_status(bill_id_str) except Pal24APIError as error: - logger.error( - "Ошибка Pal24 API при получении статуса: %s", 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 if payment.is_paid and not payment.transaction_id: try: @@ -576,7 +658,7 @@ class Pal24PaymentMixin: "payment": payment, "status": payment.status, "is_paid": payment.is_paid, - "remote_status": remote_status, + "remote_status": remote_status_for_return, "remote_data": remote_data, } @@ -621,11 +703,26 @@ 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): @@ -641,8 +738,59 @@ 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 213c731e..134c1ca0 100644 --- a/app/services/payment/wata.py +++ b/app/services/payment/wata.py @@ -18,6 +18,52 @@ 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.""" @@ -226,6 +272,7 @@ 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: @@ -253,29 +300,84 @@ 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: - 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, + transaction_payload = await self.wata_service.get_transaction( # type: ignore[union-attr] + fallback_transaction_id ) - 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, + "Ошибка повторного запроса WATA транзакции %s: %s", + fallback_transaction_id, error, ) except Exception as error: # pragma: no cover - safety net - logger.exception("Непредвиденная ошибка при поиске WATA транзакции: %s", error) + logger.exception( + "Непредвиденная ошибка при повторном запросе WATA транзакции %s: %s", + fallback_transaction_id, + error, + ) if transaction_payload and not payment.is_paid: - payment = await self._finalize_wata_payment(db, payment, transaction_payload) + 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", + ) return { "payment": payment, @@ -293,7 +395,22 @@ class WataPaymentMixin: ) -> Any: payment_module = import_module("app.services.payment_service") - paid_at = WataService._parse_datetime(transaction_payload.get("paymentTime")) + 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 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 e9489bc3..b4210899 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -207,6 +207,108 @@ class YooKassaPaymentMixin: logger.error("Ошибка создания платежа YooKassa СБП: %s", error) return None + async def get_yookassa_payment_status( + self, + db: AsyncSession, + local_payment_id: int, + ) -> Optional[Dict[str, Any]]: + """Запрашивает статус платежа в YooKassa и синхронизирует локальные данные.""" + + payment_module = import_module("app.services.payment_service") + + payment = await payment_module.get_yookassa_payment_by_local_id(db, local_payment_id) + if not payment: + return None + + remote_data: Optional[Dict[str, Any]] = None + + if getattr(self, "yookassa_service", None): + try: + remote_data = await self.yookassa_service.get_payment_info( # type: ignore[union-attr] + payment.yookassa_payment_id + ) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "Ошибка получения статуса YooKassa %s: %s", + payment.yookassa_payment_id, + error, + ) + + if remote_data: + status = remote_data.get("status") or payment.status + paid = bool(remote_data.get("paid", getattr(payment, "is_paid", False))) + captured_raw = remote_data.get("captured_at") + captured_at = None + if captured_raw: + try: + captured_at = datetime.fromisoformat( + str(captured_raw).replace("Z", "+00:00") + ).replace(tzinfo=None) + except Exception as parse_error: # pragma: no cover - diagnostic log + logger.debug( + "Не удалось распарсить captured_at %s: %s", + captured_raw, + parse_error, + ) + captured_at = None + + payment_method_type = remote_data.get("payment_method_type") + + updated_payment = await payment_module.update_yookassa_payment_status( + db, + payment.yookassa_payment_id, + status=status, + is_paid=paid, + is_captured=paid and status == "succeeded", + captured_at=captured_at, + payment_method_type=payment_method_type, + ) + + if updated_payment: + payment = updated_payment + + transaction_id = getattr(payment, "transaction_id", None) + + if ( + payment.status == "succeeded" + and getattr(payment, "is_paid", False) + ): + if not transaction_id: + try: + await db.refresh(payment) + transaction_id = getattr(payment, "transaction_id", None) + except Exception as refresh_error: # pragma: no cover - defensive logging + logger.warning( + "Не удалось обновить состояние платежа YooKassa %s перед повторной обработкой: %s", + payment.yookassa_payment_id, + refresh_error, + exc_info=True, + ) + + if transaction_id: + logger.info( + "Пропускаем повторную обработку платежа YooKassa %s: уже связан с транзакцией %s", + payment.yookassa_payment_id, + transaction_id, + ) + else: + try: + await self._process_successful_yookassa_payment(db, payment) + except Exception as process_error: # pragma: no cover - defensive logging + logger.error( + "Ошибка обработки успешного платежа YooKassa %s: %s", + payment.yookassa_payment_id, + process_error, + exc_info=True, + ) + + return { + "payment": payment, + "status": payment.status, + "is_paid": getattr(payment, "is_paid", False), + "remote_data": remote_data, + } + async def _process_successful_yookassa_payment( self, db: AsyncSession, @@ -256,12 +358,17 @@ class YooKassaPaymentMixin: is_completed=True, ) - await payment_module.link_yookassa_payment_to_transaction( + linked_payment = await payment_module.link_yookassa_payment_to_transaction( db, payment.yookassa_payment_id, transaction.id, ) + if linked_payment: + payment.transaction_id = getattr(linked_payment, "transaction_id", transaction.id) + if hasattr(linked_payment, "transaction"): + payment.transaction = linked_payment.transaction + user = await payment_module.get_user_by_id(db, payment.user_id) if user: if is_simple_subscription: diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 6acb83dd..b9f414ed 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -55,6 +55,11 @@ async def get_yookassa_payment_by_id(*args, **kwargs): return await yk_crud.get_yookassa_payment_by_id(*args, **kwargs) +async def get_yookassa_payment_by_local_id(*args, **kwargs): + yk_crud = import_module("app.database.crud.yookassa") + return await yk_crud.get_yookassa_payment_by_local_id(*args, **kwargs) + + async def create_transaction(*args, **kwargs): transaction_crud = import_module("app.database.crud.transaction") return await transaction_crud.create_transaction(*args, **kwargs) diff --git a/app/services/payment_verification_service.py b/app/services/payment_verification_service.py new file mode 100644 index 00000000..c2b44a23 --- /dev/null +++ b/app/services/payment_verification_service.py @@ -0,0 +1,767 @@ +"""Helpers for inspecting and manually checking pending top-up payments.""" + +from __future__ import annotations + +import asyncio +import logging +import re +from collections import Counter +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional + +from sqlalchemy import desc, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.config import settings +from app.database.database import AsyncSessionLocal +from app.database.models import ( + CryptoBotPayment, + HeleketPayment, + MulenPayPayment, + Pal24Payment, + PaymentMethod, + Transaction, + TransactionType, + User, + WataPayment, + YooKassaPayment, +) + +logger = logging.getLogger(__name__) + + +PENDING_MAX_AGE = timedelta(hours=24) + + +@dataclass(slots=True) +class PendingPayment: + """Normalized representation of a provider specific payment entry.""" + + method: PaymentMethod + local_id: int + identifier: str + amount_kopeks: int + status: str + is_paid: bool + created_at: datetime + user: User + payment: Any + expires_at: Optional[datetime] = None + + def is_recent(self, max_age: timedelta = PENDING_MAX_AGE) -> bool: + return (datetime.utcnow() - self.created_at) <= max_age + + +SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset( + { + PaymentMethod.YOOKASSA, + PaymentMethod.MULENPAY, + PaymentMethod.PAL24, + PaymentMethod.WATA, + PaymentMethod.HELEKET, + PaymentMethod.CRYPTOBOT, + } +) + + +SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset( + { + PaymentMethod.YOOKASSA, + PaymentMethod.MULENPAY, + PaymentMethod.PAL24, + PaymentMethod.WATA, + PaymentMethod.CRYPTOBOT, + } +) + + +def method_display_name(method: PaymentMethod) -> str: + if method == PaymentMethod.MULENPAY: + return settings.get_mulenpay_display_name() + if method == PaymentMethod.PAL24: + return "PayPalych" + if method == PaymentMethod.YOOKASSA: + return "YooKassa" + if method == PaymentMethod.WATA: + return "WATA" + if method == PaymentMethod.CRYPTOBOT: + return "CryptoBot" + if method == PaymentMethod.HELEKET: + return "Heleket" + if method == PaymentMethod.TELEGRAM_STARS: + return "Telegram Stars" + return method.value + + +def _method_is_enabled(method: PaymentMethod) -> bool: + if method == PaymentMethod.YOOKASSA: + return settings.is_yookassa_enabled() + if method == PaymentMethod.MULENPAY: + return settings.is_mulenpay_enabled() + if method == PaymentMethod.PAL24: + return settings.is_pal24_enabled() + if method == PaymentMethod.WATA: + return settings.is_wata_enabled() + if method == PaymentMethod.CRYPTOBOT: + return settings.is_cryptobot_enabled() + if method == PaymentMethod.HELEKET: + return settings.is_heleket_enabled() + return False + + +def get_enabled_auto_methods() -> List[PaymentMethod]: + return [ + method + for method in SUPPORTED_AUTO_CHECK_METHODS + if _method_is_enabled(method) + ] + + +class AutoPaymentVerificationService: + """Background checker that periodically refreshes pending payments.""" + + def __init__(self) -> None: + self._task: Optional[asyncio.Task[None]] = None + self._payment_service: Optional["PaymentService"] = None + + def set_payment_service(self, payment_service: "PaymentService") -> None: + self._payment_service = payment_service + + def is_running(self) -> bool: + return self._task is not None and not self._task.done() + + async def start(self) -> None: + await self.stop() + + if not settings.is_payment_verification_auto_check_enabled(): + logger.info("Автопроверка пополнений отключена настройками") + return + + if not self._payment_service: + logger.warning( + "Автопроверка пополнений не запущена: PaymentService не инициализирован" + ) + return + + methods = get_enabled_auto_methods() + if not methods: + logger.info( + "Автопроверка пополнений не запущена: нет активных провайдеров" + ) + return + + display_names = ", ".join( + sorted(method_display_name(method) for method in methods) + ) + interval_minutes = settings.get_payment_verification_auto_check_interval() + + self._task = asyncio.create_task(self._auto_check_loop()) + logger.info( + "🔄 Автопроверка пополнений запущена (каждые %s мин) для: %s", + interval_minutes, + display_names, + ) + + async def stop(self) -> None: + if self._task and not self._task.done(): + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + async def _auto_check_loop(self) -> None: + try: + while True: + interval_minutes = settings.get_payment_verification_auto_check_interval() + try: + if ( + settings.is_payment_verification_auto_check_enabled() + and self._payment_service + ): + methods = get_enabled_auto_methods() + if methods: + await self._run_checks(methods) + else: + logger.debug( + "Автопроверка пополнений: активных провайдеров нет" + ) + else: + logger.debug( + "Автопроверка пополнений: отключена настройками или сервис не готов" + ) + except asyncio.CancelledError: + raise + except Exception as error: # noqa: BLE001 - логируем непредвиденные ошибки + logger.error( + "Ошибка автопроверки пополнений: %s", + error, + exc_info=True, + ) + + await asyncio.sleep(max(1, interval_minutes) * 60) + except asyncio.CancelledError: + logger.info("Автопроверка пополнений остановлена") + raise + + async def _run_checks(self, methods: List[PaymentMethod]) -> None: + if not self._payment_service: + return + + async with AsyncSessionLocal() as session: + try: + pending = await list_recent_pending_payments(session) + candidates = [ + record + for record in pending + if record.method in methods and not record.is_paid + ] + + if not candidates: + logger.debug( + "Автопроверка пополнений: подходящих ожидающих платежей нет" + ) + return + + counts = Counter(record.method for record in candidates) + summary = ", ".join( + f"{method_display_name(method)}: {count}" + for method, count in sorted( + counts.items(), key=lambda item: method_display_name(item[0]) + ) + ) + logger.info( + "🔄 Автопроверка пополнений: найдено %s инвойсов (%s)", + len(candidates), + summary, + ) + + for record in candidates: + refreshed = await run_manual_check( + session, + record.method, + record.local_id, + self._payment_service, + ) + + if not refreshed: + logger.debug( + "Автопроверка пополнений: не удалось обновить %s %s", + method_display_name(record.method), + record.identifier, + ) + continue + + if refreshed.is_paid and not record.is_paid: + logger.info( + "✅ %s %s отмечен как оплаченный после автопроверки", + method_display_name(refreshed.method), + refreshed.identifier, + ) + elif refreshed.status != record.status: + logger.info( + "ℹ️ %s %s обновлён: %s → %s", + method_display_name(refreshed.method), + refreshed.identifier, + record.status or "—", + refreshed.status or "—", + ) + else: + logger.debug( + "Автопроверка пополнений: %s %s без изменений (%s)", + method_display_name(refreshed.method), + refreshed.identifier, + refreshed.status or "—", + ) + + if session.in_transaction(): + await session.commit() + except Exception: + if session.in_transaction(): + await session.rollback() + raise + + +auto_payment_verification_service = AutoPaymentVerificationService() + +def _is_pal24_pending(payment: Pal24Payment) -> bool: + if payment.is_paid: + return False + status = (payment.status or "").upper() + return status in {"NEW", "PROCESS"} + + +def _is_mulenpay_pending(payment: MulenPayPayment) -> bool: + if payment.is_paid: + return False + status = (payment.status or "").lower() + return status in {"created", "processing", "hold"} + + +def _is_wata_pending(payment: WataPayment) -> bool: + if payment.is_paid: + return False + status = (payment.status or "").lower() + return status not in { + "paid", + "closed", + "declined", + "canceled", + "cancelled", + "expired", + } + + +def _is_heleket_pending(payment: HeleketPayment) -> bool: + if payment.is_paid: + return False + status = (payment.status or "").lower() + return status not in {"paid", "paid_over", "cancel", "canceled", "failed", "fail", "expired"} + + +def _is_yookassa_pending(payment: YooKassaPayment) -> bool: + if getattr(payment, "is_paid", False) and payment.status == "succeeded": + return False + status = (payment.status or "").lower() + return status in {"pending", "waiting_for_capture"} + + +def _is_cryptobot_pending(payment: CryptoBotPayment) -> bool: + status = (payment.status or "").lower() + return status == "active" + + +def _parse_cryptobot_amount_kopeks(payment: CryptoBotPayment) -> int: + payload = payment.payload or "" + match = re.search(r"_(\d+)$", payload) + if match: + try: + return int(match.group(1)) + except ValueError: + return 0 + return 0 + + +def _metadata_is_balance(payment: YooKassaPayment) -> bool: + metadata = getattr(payment, "metadata_json", {}) or {} + payment_type = str(metadata.get("type") or metadata.get("payment_type") or "").lower() + return payment_type.startswith("balance_topup") + + +def _build_record(method: PaymentMethod, payment: Any, *, identifier: str, amount_kopeks: int, + status: str, is_paid: bool, expires_at: Optional[datetime] = None) -> Optional[PendingPayment]: + user = getattr(payment, "user", None) + if user is None: + logger.debug("Skipping %s payment %s without linked user", method.value, identifier) + return None + + created_at = getattr(payment, "created_at", None) + if not isinstance(created_at, datetime): + logger.debug("Skipping %s payment %s without valid created_at", method.value, identifier) + return None + + local_id = getattr(payment, "id", None) + if local_id is None: + logger.debug("Skipping %s payment without local id", method.value) + return None + + return PendingPayment( + method=method, + local_id=int(local_id), + identifier=identifier, + amount_kopeks=amount_kopeks, + status=status, + is_paid=is_paid, + created_at=created_at, + user=user, + payment=payment, + expires_at=expires_at, + ) + + +async def _fetch_pal24_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]: + stmt = ( + select(Pal24Payment) + .options(selectinload(Pal24Payment.user)) + .where(Pal24Payment.created_at >= cutoff) + .order_by(desc(Pal24Payment.created_at)) + ) + result = await db.execute(stmt) + records: List[PendingPayment] = [] + for payment in result.scalars().all(): + if not _is_pal24_pending(payment): + continue + record = _build_record( + PaymentMethod.PAL24, + payment, + identifier=payment.bill_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or "", + is_paid=bool(payment.is_paid), + expires_at=getattr(payment, "expires_at", None), + ) + if record: + records.append(record) + return records + + +async def _fetch_mulenpay_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]: + stmt = ( + select(MulenPayPayment) + .options(selectinload(MulenPayPayment.user)) + .where(MulenPayPayment.created_at >= cutoff) + .order_by(desc(MulenPayPayment.created_at)) + ) + result = await db.execute(stmt) + records: List[PendingPayment] = [] + for payment in result.scalars().all(): + if not _is_mulenpay_pending(payment): + continue + record = _build_record( + PaymentMethod.MULENPAY, + payment, + identifier=payment.uuid, + amount_kopeks=payment.amount_kopeks, + status=payment.status or "", + is_paid=bool(payment.is_paid), + ) + if record: + records.append(record) + return records + + +async def _fetch_wata_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]: + stmt = ( + select(WataPayment) + .options(selectinload(WataPayment.user)) + .where(WataPayment.created_at >= cutoff) + .order_by(desc(WataPayment.created_at)) + ) + result = await db.execute(stmt) + records: List[PendingPayment] = [] + for payment in result.scalars().all(): + if not _is_wata_pending(payment): + continue + record = _build_record( + PaymentMethod.WATA, + payment, + identifier=payment.payment_link_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or "", + is_paid=bool(payment.is_paid), + expires_at=getattr(payment, "expires_at", None), + ) + if record: + records.append(record) + return records + + +async def _fetch_heleket_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]: + stmt = ( + select(HeleketPayment) + .options(selectinload(HeleketPayment.user)) + .where(HeleketPayment.created_at >= cutoff) + .order_by(desc(HeleketPayment.created_at)) + ) + result = await db.execute(stmt) + records: List[PendingPayment] = [] + for payment in result.scalars().all(): + if not _is_heleket_pending(payment): + continue + record = _build_record( + PaymentMethod.HELEKET, + payment, + identifier=payment.uuid, + amount_kopeks=payment.amount_kopeks, + status=payment.status or "", + is_paid=bool(payment.is_paid), + expires_at=getattr(payment, "expires_at", None), + ) + if record: + records.append(record) + return records + + +async def _fetch_yookassa_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]: + stmt = ( + select(YooKassaPayment) + .options(selectinload(YooKassaPayment.user)) + .where(YooKassaPayment.created_at >= cutoff) + .order_by(desc(YooKassaPayment.created_at)) + ) + result = await db.execute(stmt) + records: List[PendingPayment] = [] + for payment in result.scalars().all(): + if payment.transaction_id: + continue + if not _metadata_is_balance(payment): + continue + if not _is_yookassa_pending(payment): + continue + record = _build_record( + PaymentMethod.YOOKASSA, + payment, + identifier=payment.yookassa_payment_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or "", + is_paid=bool(getattr(payment, "is_paid", False)), + ) + if record: + records.append(record) + return records + + +async def _fetch_cryptobot_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]: + stmt = ( + select(CryptoBotPayment) + .options(selectinload(CryptoBotPayment.user)) + .where(CryptoBotPayment.created_at >= cutoff) + .order_by(desc(CryptoBotPayment.created_at)) + ) + result = await db.execute(stmt) + records: List[PendingPayment] = [] + for payment in result.scalars().all(): + status = (payment.status or "").lower() + if not _is_cryptobot_pending(payment) and status != "paid": + continue + amount_kopeks = _parse_cryptobot_amount_kopeks(payment) + record = _build_record( + PaymentMethod.CRYPTOBOT, + payment, + identifier=payment.invoice_id, + amount_kopeks=amount_kopeks, + status=payment.status or "", + is_paid=bool(payment.is_paid), + ) + if record: + records.append(record) + return records + + +async def _fetch_stars_transactions(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]: + stmt = ( + select(Transaction) + .options(selectinload(Transaction.user)) + .where( + Transaction.created_at >= cutoff, + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.payment_method == PaymentMethod.TELEGRAM_STARS.value, + ) + .order_by(desc(Transaction.created_at)) + ) + result = await db.execute(stmt) + records: List[PendingPayment] = [] + for transaction in result.scalars().all(): + record = _build_record( + PaymentMethod.TELEGRAM_STARS, + transaction, + identifier=transaction.external_id or str(transaction.id), + amount_kopeks=transaction.amount_kopeks, + status="paid" if transaction.is_completed else "pending", + is_paid=bool(transaction.is_completed), + ) + if record: + records.append(record) + return records + + +async def list_recent_pending_payments( + db: AsyncSession, + *, + max_age: timedelta = PENDING_MAX_AGE, +) -> List[PendingPayment]: + """Return pending payments (top-ups) from supported providers within the age window.""" + + cutoff = datetime.utcnow() - max_age + + tasks: Iterable[List[PendingPayment]] = ( + await _fetch_yookassa_payments(db, cutoff), + await _fetch_pal24_payments(db, cutoff), + await _fetch_mulenpay_payments(db, cutoff), + await _fetch_wata_payments(db, cutoff), + await _fetch_heleket_payments(db, cutoff), + await _fetch_cryptobot_payments(db, cutoff), + await _fetch_stars_transactions(db, cutoff), + ) + + records: List[PendingPayment] = [] + for batch in tasks: + records.extend(batch) + + records.sort(key=lambda item: item.created_at, reverse=True) + return records + + +async def get_payment_record( + db: AsyncSession, + method: PaymentMethod, + local_payment_id: int, +) -> Optional[PendingPayment]: + """Load single payment record and normalize it to :class:`PendingPayment`.""" + + cutoff = datetime.utcnow() - PENDING_MAX_AGE + + if method == PaymentMethod.PAL24: + payment = await db.get(Pal24Payment, local_payment_id) + if not payment: + return None + await db.refresh(payment, attribute_names=["user"]) + return _build_record( + method, + payment, + identifier=payment.bill_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or "", + is_paid=bool(payment.is_paid), + expires_at=getattr(payment, "expires_at", None), + ) + + if method == PaymentMethod.MULENPAY: + payment = await db.get(MulenPayPayment, local_payment_id) + if not payment: + return None + await db.refresh(payment, attribute_names=["user"]) + return _build_record( + method, + payment, + identifier=payment.uuid, + amount_kopeks=payment.amount_kopeks, + status=payment.status or "", + is_paid=bool(payment.is_paid), + ) + + if method == PaymentMethod.WATA: + payment = await db.get(WataPayment, local_payment_id) + if not payment: + return None + await db.refresh(payment, attribute_names=["user"]) + return _build_record( + method, + payment, + identifier=payment.payment_link_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or "", + is_paid=bool(payment.is_paid), + expires_at=getattr(payment, "expires_at", None), + ) + + if method == PaymentMethod.HELEKET: + payment = await db.get(HeleketPayment, local_payment_id) + if not payment: + return None + await db.refresh(payment, attribute_names=["user"]) + return _build_record( + method, + payment, + identifier=payment.uuid, + amount_kopeks=payment.amount_kopeks, + status=payment.status or "", + is_paid=bool(payment.is_paid), + expires_at=getattr(payment, "expires_at", None), + ) + + if method == PaymentMethod.YOOKASSA: + payment = await db.get(YooKassaPayment, local_payment_id) + if not payment: + return None + await db.refresh(payment, attribute_names=["user"]) + if payment.created_at < cutoff: + logger.debug("YooKassa payment %s is older than cutoff", payment.id) + return _build_record( + method, + payment, + identifier=payment.yookassa_payment_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or "", + is_paid=bool(getattr(payment, "is_paid", False)), + ) + + if method == PaymentMethod.CRYPTOBOT: + payment = await db.get(CryptoBotPayment, local_payment_id) + if not payment: + return None + await db.refresh(payment, attribute_names=["user"]) + amount_kopeks = _parse_cryptobot_amount_kopeks(payment) + return _build_record( + method, + payment, + identifier=payment.invoice_id, + amount_kopeks=amount_kopeks, + status=payment.status or "", + is_paid=bool(payment.is_paid), + ) + + if method == PaymentMethod.TELEGRAM_STARS: + transaction = await db.get(Transaction, local_payment_id) + if not transaction: + return None + await db.refresh(transaction, attribute_names=["user"]) + if transaction.payment_method != PaymentMethod.TELEGRAM_STARS.value: + return None + return _build_record( + method, + transaction, + identifier=transaction.external_id or str(transaction.id), + amount_kopeks=transaction.amount_kopeks, + status="paid" if transaction.is_completed else "pending", + is_paid=bool(transaction.is_completed), + ) + + logger.debug("Unsupported payment method requested: %s", method) + return None + + +async def run_manual_check( + db: AsyncSession, + method: PaymentMethod, + local_payment_id: int, + payment_service: "PaymentService", +) -> Optional[PendingPayment]: + """Trigger provider specific status refresh and return the updated record.""" + + try: + if method == PaymentMethod.PAL24: + result = await payment_service.get_pal24_payment_status(db, local_payment_id) + payment = result.get("payment") if result else None + elif method == PaymentMethod.MULENPAY: + result = await payment_service.get_mulenpay_payment_status(db, local_payment_id) + payment = result.get("payment") if result else None + elif method == PaymentMethod.WATA: + result = await payment_service.get_wata_payment_status(db, local_payment_id) + payment = result.get("payment") if result else None + elif method == PaymentMethod.HELEKET: + payment = await payment_service.sync_heleket_payment_status( + db, local_payment_id=local_payment_id + ) + elif method == PaymentMethod.YOOKASSA: + result = await payment_service.get_yookassa_payment_status(db, local_payment_id) + payment = result.get("payment") if result else None + elif method == PaymentMethod.CRYPTOBOT: + result = await payment_service.get_cryptobot_payment_status(db, local_payment_id) + payment = result.get("payment") if result else None + else: + logger.warning("Manual check requested for unsupported method %s", method) + return None + + if not payment: + return None + + return await get_payment_record(db, method, local_payment_id) + + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "Manual status check failed for %s payment %s: %s", + method.value, + local_payment_id, + error, + exc_info=True, + ) + return None + + +if TYPE_CHECKING: # pragma: no cover + from app.services.payment_service import PaymentService + diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 0041e4bc..673a8cc0 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -72,6 +72,7 @@ class BotConfigurationService: "LOCALIZATION": "🌍 Языки интерфейса", "CHANNEL": "📣 Обязательная подписка", "PAYMENT": "💳 Общие платежные настройки", + "PAYMENT_VERIFICATION": "🕵️ Проверка платежей", "TELEGRAM": "⭐ Telegram Stars", "CRYPTOBOT": "🪙 CryptoBot", "HELEKET": "🪙 Heleket", @@ -124,6 +125,7 @@ class BotConfigurationService: "LOCALIZATION": "Доступные языки, локализация интерфейса и выбор языка.", "CHANNEL": "Настройки обязательной подписки на канал или группу.", "PAYMENT": "Общие тексты платежей, описания чеков и шаблоны.", + "PAYMENT_VERIFICATION": "Автоматическая проверка пополнений и интервал выполнения.", "YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.", "CRYPTOBOT": "CryptoBot и криптоплатежи через Telegram.", "HELEKET": "Heleket: криптоплатежи, ключи мерчанта и вебхуки.", @@ -292,6 +294,7 @@ class BotConfigurationService: "MULENPAY_": "MULENPAY", "PAL24_": "PAL24", "PAYMENT_": "PAYMENT", + "PAYMENT_VERIFICATION_": "PAYMENT_VERIFICATION", "WATA_": "WATA", "EXTERNAL_ADMIN_": "EXTERNAL_ADMIN", "SIMPLE_SUBSCRIPTION_": "SIMPLE_SUBSCRIPTION", @@ -453,6 +456,24 @@ class BotConfigurationService: "warning": "Пустой токен или неверный вебхук приведут к отказам платежей.", "dependencies": "CRYPTOBOT_API_TOKEN, CRYPTOBOT_WEBHOOK_SECRET", }, + "PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED": { + "description": ( + "Запускает фоновую проверку ожидающих пополнений и повторно обращается " + "к платёжным провайдерам без участия администратора." + ), + "format": "Булево значение.", + "example": "Включено, чтобы автоматически перепроверять зависшие платежи.", + "warning": "Требует активных интеграций YooKassa, {mulenpay_name}, PayPalych, WATA или CryptoBot.", + }, + "PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES": { + "description": ( + "Интервал между автоматическими проверками ожидающих пополнений в минутах." + ), + "format": "Целое число не меньше 1.", + "example": "10", + "warning": "Слишком малый интервал может привести к частым обращениям к платёжным API.", + "dependencies": "PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED", + }, "SUPPORT_TICKET_SLA_MINUTES": { "description": "Лимит времени для ответа модераторов на тикет в минутах.", "format": "Целое число от 1 до 1440.", diff --git a/main.py b/main.py index 30508b0e..f8a68603 100644 --- a/main.py +++ b/main.py @@ -13,6 +13,14 @@ from app.database.database import init_db from app.services.monitoring_service import monitoring_service from app.services.maintenance_service import maintenance_service from app.services.payment_service import PaymentService +from app.services.payment_verification_service import ( + PENDING_MAX_AGE, + SUPPORTED_MANUAL_CHECK_METHODS, + auto_payment_verification_service, + get_enabled_auto_methods, + method_display_name, +) +from app.database.models import PaymentMethod from app.services.version_service import version_service from app.external.webhook_server import WebhookServer from app.external.heleket_webhook import start_heleket_webhook_server @@ -214,6 +222,67 @@ async def main(): logger.error(f"❌ Ошибка запуска автосинхронизации RemnaWave: {e}") payment_service = PaymentService(bot) + auto_payment_verification_service.set_payment_service(payment_service) + + verification_providers: list[str] = [] + auto_verification_active = False + async with timeline.stage( + "Сервис проверки пополнений", + "💳", + success_message="Ручная проверка активна", + ) as stage: + for method in SUPPORTED_MANUAL_CHECK_METHODS: + if method == PaymentMethod.YOOKASSA and settings.is_yookassa_enabled(): + verification_providers.append("YooKassa") + elif method == PaymentMethod.MULENPAY and settings.is_mulenpay_enabled(): + verification_providers.append(settings.get_mulenpay_display_name()) + elif method == PaymentMethod.PAL24 and settings.is_pal24_enabled(): + verification_providers.append("PayPalych") + elif method == PaymentMethod.WATA and settings.is_wata_enabled(): + verification_providers.append("WATA") + elif method == PaymentMethod.HELEKET and settings.is_heleket_enabled(): + verification_providers.append("Heleket") + elif method == PaymentMethod.CRYPTOBOT and settings.is_cryptobot_enabled(): + verification_providers.append("CryptoBot") + + if verification_providers: + hours = int(PENDING_MAX_AGE.total_seconds() // 3600) + stage.log( + "Ожидающие пополнения автоматически отбираются не старше " + f"{hours}ч" + ) + stage.log( + "Доступна ручная проверка для: " + + ", ".join(sorted(verification_providers)) + ) + stage.success( + f"Активно провайдеров: {len(verification_providers)}" + ) + else: + stage.skip("Нет активных провайдеров для ручной проверки") + + if settings.is_payment_verification_auto_check_enabled(): + auto_methods = get_enabled_auto_methods() + if auto_methods: + interval_minutes = settings.get_payment_verification_auto_check_interval() + auto_labels = ", ".join( + sorted(method_display_name(method) for method in auto_methods) + ) + stage.log( + "Автопроверка каждые " + f"{interval_minutes} мин: {auto_labels}" + ) + else: + stage.log( + "Автопроверка включена, но нет активных провайдеров" + ) + else: + stage.log("Автопроверка отключена настройками") + + await auto_payment_verification_service.start() + auto_verification_active = auto_payment_verification_service.is_running() + if auto_verification_active: + stage.log("Фоновая автопроверка запущена") async with timeline.stage( "Внешняя админка", @@ -423,6 +492,18 @@ async def main(): f"Проверка версий: {'Включен' if version_check_task else 'Отключен'}", f"Отчеты: {'Включен' if reporting_service.is_running() else 'Отключен'}", ] + services_lines.append( + "Проверка пополнений: " + + ("Включена" if verification_providers else "Отключена") + ) + services_lines.append( + "Автопроверка пополнений: " + + ( + "Включена" + if auto_payment_verification_service.is_running() + else "Отключена" + ) + ) timeline.log_section("Активные фоновые сервисы", services_lines, icon="📄") timeline.log_summary() @@ -484,7 +565,14 @@ async def main(): if settings.is_version_check_enabled(): logger.info("🔄 Перезапуск сервиса проверки версий...") version_check_task = asyncio.create_task(version_service.start_periodic_check()) - + + if auto_verification_active and not auto_payment_verification_service.is_running(): + logger.warning( + "Сервис автопроверки пополнений остановился, пробуем перезапустить..." + ) + await auto_payment_verification_service.start() + auto_verification_active = auto_payment_verification_service.is_running() + if polling_task.done(): exception = polling_task.exception() if exception: @@ -503,7 +591,15 @@ async def main(): timeline.log_summary() summary_logged = True logger.info("🛑 Начинается корректное завершение работы...") - + + logger.info("ℹ️ Остановка сервиса автопроверки пополнений...") + try: + await auto_payment_verification_service.stop() + except Exception as error: + logger.error( + f"Ошибка остановки сервиса автопроверки пополнений: {error}" + ) + if yookassa_server_task and not yookassa_server_task.done(): logger.info("ℹ️ Остановка YooKassa webhook сервера...") yookassa_server_task.cancel() diff --git a/tests/services/test_payment_service_heleket.py b/tests/services/test_payment_service_heleket.py index 26c58100..80f1f1fd 100644 --- a/tests/services/test_payment_service_heleket.py +++ b/tests/services/test_payment_service_heleket.py @@ -50,6 +50,8 @@ 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) @@ -64,6 +66,18 @@ 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] @@ -249,3 +263,53 @@ 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 02d4f91f..124553b9 100644 --- a/tests/services/test_payment_service_pal24.py +++ b/tests/services/test_payment_service_pal24.py @@ -4,6 +4,7 @@ from pathlib import Path from typing import Any, Dict, Optional import sys from datetime import datetime +from types import SimpleNamespace import pytest @@ -34,7 +35,12 @@ 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, @@ -45,6 +51,12 @@ 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) @@ -52,6 +64,18 @@ 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] @@ -198,3 +222,110 @@ 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 8542fff3..f003d765 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 +from typing import Any, Dict, Optional import sys import pytest @@ -829,6 +829,21 @@ 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()