diff --git a/app/bot.py b/app/bot.py
index 19097554..826d8d6e 100644
--- a/app/bot.py
+++ b/app/bot.py
@@ -58,7 +58,6 @@ from app.handlers.admin import (
privacy_policy as admin_privacy_policy,
public_offer as admin_public_offer,
faq as admin_faq,
- payments as admin_payments,
)
from app.handlers.stars_payments import register_stars_handlers
@@ -173,7 +172,6 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_privacy_policy.register_handlers(dp)
admin_public_offer.register_handlers(dp)
admin_faq.register_handlers(dp)
- admin_payments.register_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
user_polls.register_handlers(dp)
diff --git a/app/config.py b/app/config.py
index aed79f7f..6a132550 100644
--- a/app/config.py
+++ b/app/config.py
@@ -18,9 +18,6 @@ DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS = [
]
-logger = logging.getLogger(__name__)
-
-
class Settings(BaseSettings):
BOT_TOKEN: str
@@ -185,8 +182,6 @@ class Settings(BaseSettings):
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
DISABLE_TOPUP_BUTTONS: bool = False
- PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
- PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10
# Настройки простой покупки
SIMPLE_SUBSCRIPTION_ENABLED: bool = False
@@ -844,24 +839,6 @@ class Settings(BaseSettings):
and self.WATA_TERMINAL_PUBLIC_ID is not None
)
- def is_payment_verification_auto_check_enabled(self) -> bool:
- return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
-
- def get_payment_verification_auto_check_interval(self) -> int:
- try:
- minutes = int(self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES)
- except (TypeError, ValueError): # pragma: no cover - защитная проверка конфигурации
- minutes = 10
-
- if minutes <= 0:
- logger.warning(
- "Некорректный интервал автопроверки платежей: %s. Используется значение по умолчанию 10 минут.",
- self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES,
- )
- return 10
-
- return minutes
-
def get_cryptobot_base_url(self) -> str:
if self.CRYPTOBOT_TESTNET:
return "https://testnet-pay.crypt.bot"
diff --git a/app/external/cryptobot.py b/app/external/cryptobot.py
index 7088dc21..5fce68c6 100644
--- a/app/external/cryptobot.py
+++ b/app/external/cryptobot.py
@@ -19,10 +19,10 @@ class CryptoBotService:
self.webhook_secret = settings.CRYPTOBOT_WEBHOOK_SECRET
async def _make_request(
- self,
- method: str,
- endpoint: str,
- data: Optional[Dict] = None,
+ self,
+ method: str,
+ endpoint: str,
+ data: Optional[Dict] = None
) -> Optional[Dict[str, Any]]:
if not self.api_token:
@@ -37,18 +37,11 @@ class CryptoBotService:
try:
async with aiohttp.ClientSession() as session:
- request_kwargs: Dict[str, Any] = {"headers": headers}
-
- if method.upper() == "GET":
- if data:
- request_kwargs["params"] = data
- elif data:
- request_kwargs["json"] = data
-
async with session.request(
- method,
- url,
- **request_kwargs,
+ method,
+ url,
+ headers=headers,
+ json=data if data else None
) as response:
response_data = await response.json()
@@ -102,34 +95,21 @@ class CryptoBotService:
asset: Optional[str] = None,
status: Optional[str] = None,
offset: int = 0,
- count: int = 100,
- invoice_ids: Optional[list] = None,
+ count: int = 100
) -> Optional[list]:
-
+
data = {
'offset': offset,
'count': count
}
-
+
if asset:
data['asset'] = asset
-
+
if status:
data['status'] = status
-
- if invoice_ids:
- data['invoice_ids'] = invoice_ids
-
- result = await self._make_request('GET', 'getInvoices', data)
-
- if isinstance(result, dict):
- items = result.get('items')
- return items if isinstance(items, list) else []
-
- if isinstance(result, list):
- return result
-
- return []
+
+ return await self._make_request('GET', 'getInvoices', data)
async def get_balance(self) -> Optional[list]:
return await self._make_request('GET', 'getBalance')
diff --git a/app/external/heleket.py b/app/external/heleket.py
index 02845e84..4b11299c 100644
--- a/app/external/heleket.py
+++ b/app/external/heleket.py
@@ -41,13 +41,7 @@ class HeleketService:
raw = f"{encoded}{api_key}"
return hashlib.md5(raw.encode("utf-8")).hexdigest()
- async def _request(
- self,
- endpoint: str,
- payload: Dict[str, Any],
- *,
- params: Optional[Dict[str, Any]] = None,
- ) -> Optional[Dict[str, Any]]:
+ async def _request(self, endpoint: str, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
if not self.is_configured:
logger.error("Heleket сервис не настроен: merchant или api_key отсутствуют")
return None
@@ -65,12 +59,7 @@ class HeleketService:
try:
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
- async with session.post(
- url,
- data=body.encode("utf-8"),
- headers=headers,
- params=params,
- ) as response:
+ async with session.post(url, data=body.encode("utf-8"), headers=headers) as response:
text = await response.text()
if response.content_type != "application/json":
logger.error("Ответ Heleket не JSON (%s): %s", response.content_type, text)
@@ -115,22 +104,6 @@ class HeleketService:
return await self._request("payment/info", payload)
- async def list_payments(
- self,
- *,
- date_from: Optional[str] = None,
- date_to: Optional[str] = None,
- cursor: Optional[str] = None,
- ) -> Optional[Dict[str, Any]]:
- payload: Dict[str, Any] = {}
- if date_from:
- payload["date_from"] = date_from
- if date_to:
- payload["date_to"] = date_to
-
- params = {"cursor": cursor} if cursor else None
- return await self._request("payment/list", payload, params=params)
-
def verify_webhook_signature(self, payload: Dict[str, Any]) -> bool:
if not self.is_configured:
logger.warning("Heleket сервис не настроен, подпись пропускается")
diff --git a/app/handlers/admin/payments.py b/app/handlers/admin/payments.py
deleted file mode 100644
index da1c990a..00000000
--- a/app/handlers/admin/payments.py
+++ /dev/null
@@ -1,596 +0,0 @@
-from __future__ import annotations
-
-import html
-import math
-from typing import Optional
-
-from aiogram import Dispatcher, F, types
-from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from app.config import settings
-from app.database.models import PaymentMethod, User
-from app.localization.texts import get_texts
-from app.services.payment_service import PaymentService
-from app.services.payment_verification_service import (
- PendingPayment,
- SUPPORTED_MANUAL_CHECK_METHODS,
- get_payment_record,
- list_recent_pending_payments,
- run_manual_check,
-)
-from app.utils.decorators import admin_required, error_handler
-from app.utils.formatters import format_datetime, format_time_ago, format_username
-
-
-PAGE_SIZE = 6
-
-
-def _method_display(method: PaymentMethod) -> str:
- if method == PaymentMethod.MULENPAY:
- return settings.get_mulenpay_display_name()
- if method == PaymentMethod.PAL24:
- return "PayPalych"
- if method == PaymentMethod.WATA:
- return "WATA"
- if method == PaymentMethod.HELEKET:
- return "Heleket"
- if method == PaymentMethod.YOOKASSA:
- return "YooKassa"
- if method == PaymentMethod.CRYPTOBOT:
- return "CryptoBot"
- if method == PaymentMethod.TELEGRAM_STARS:
- return "Telegram Stars"
- return method.value
-
-
-def _status_info(
- record: PendingPayment,
- *,
- texts,
-) -> tuple[str, str]:
- status = (record.status or "").lower()
-
- if record.is_paid:
- return "✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
-
- if record.method == PaymentMethod.PAL24:
- mapping = {
- "new": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
- "process": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
- "success": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
- "fail": ("❌", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
- "canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
- "cancel": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
- }
- return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
-
- if record.method == PaymentMethod.MULENPAY:
- mapping = {
- "created": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
- "processing": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
- "hold": ("🔒", texts.t("ADMIN_PAYMENT_STATUS_ON_HOLD", "🔒 Hold")),
- "success": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
- "canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
- "cancel": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
- "error": ("⚠️", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
- }
- return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
-
- if record.method == PaymentMethod.WATA:
- mapping = {
- "opened": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
- "pending": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
- "processing": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
- "paid": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
- "closed": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
- "declined": ("❌", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
- "canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
- "expired": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
- }
- return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
-
- if record.method == PaymentMethod.HELEKET:
- if status in {"pending", "created", "waiting", "check", "processing"}:
- return "⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")
- if status in {"paid", "paid_over"}:
- return "✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
- if status in {"cancel", "canceled", "fail", "failed", "expired"}:
- return "❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")
- return "❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")
-
- if record.method == PaymentMethod.YOOKASSA:
- mapping = {
- "pending": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
- "waiting_for_capture": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
- "succeeded": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
- "canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
- }
- return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
-
- if record.method == PaymentMethod.CRYPTOBOT:
- mapping = {
- "active": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
- "paid": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
- "expired": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
- }
- return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
-
- if record.method == PaymentMethod.TELEGRAM_STARS:
- if record.is_paid:
- return "✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
- return "⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")
-
- return "❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")
-
-
-def _is_checkable(record: PendingPayment) -> bool:
- if record.method not in SUPPORTED_MANUAL_CHECK_METHODS:
- return False
- if not record.is_recent():
- return False
- status = (record.status or "").lower()
- if record.method == PaymentMethod.PAL24:
- return status in {"new", "process"}
- if record.method == PaymentMethod.MULENPAY:
- return status in {"created", "processing", "hold"}
- if record.method == PaymentMethod.WATA:
- return status in {"opened", "pending", "processing", "inprogress", "in_progress"}
- if record.method == PaymentMethod.HELEKET:
- return status not in {"paid", "paid_over", "cancel", "canceled", "fail", "failed", "expired"}
- if record.method == PaymentMethod.YOOKASSA:
- return status in {"pending", "waiting_for_capture"}
- if record.method == PaymentMethod.CRYPTOBOT:
- return status in {"active"}
- return False
-
-
-def _record_display_number(record: PendingPayment) -> str:
- if record.identifier:
- return str(record.identifier)
- return str(record.local_id)
-
-
-def _build_list_keyboard(
- records: list[PendingPayment],
- *,
- page: int,
- total_pages: int,
- language: str,
-) -> InlineKeyboardMarkup:
- buttons: list[list[InlineKeyboardButton]] = []
- texts = get_texts(language)
-
- for record in records:
- number = _record_display_number(record)
- details_template = texts.t("ADMIN_PAYMENTS_ITEM_DETAILS", "📄 #{number}")
- try:
- button_text = details_template.format(number=number)
- except Exception: # pragma: no cover - fallback for broken localization
- button_text = f"📄 {number}"
- buttons.append(
- [
- InlineKeyboardButton(
- text=button_text,
- callback_data=f"admin_payment_{record.method.value}_{record.local_id}",
- )
- ]
- )
-
- if total_pages > 1:
- navigation_row: list[InlineKeyboardButton] = []
- if page > 1:
- navigation_row.append(
- InlineKeyboardButton(
- text="⬅️",
- callback_data=f"admin_payments_page_{page - 1}",
- )
- )
-
- navigation_row.append(
- InlineKeyboardButton(
- text=f"{page}/{total_pages}",
- callback_data="admin_payments_page_current",
- )
- )
-
- if page < total_pages:
- navigation_row.append(
- InlineKeyboardButton(
- text="➡️",
- callback_data=f"admin_payments_page_{page + 1}",
- )
- )
-
- buttons.append(navigation_row)
-
- buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")])
-
- return InlineKeyboardMarkup(inline_keyboard=buttons)
-
-
-def _build_detail_keyboard(
- record: PendingPayment,
- *,
- language: str,
-) -> InlineKeyboardMarkup:
- texts = get_texts(language)
- rows: list[list[InlineKeyboardButton]] = []
-
- payment = record.payment
- payment_url = getattr(payment, "payment_url", None)
- if record.method == PaymentMethod.PAL24:
- payment_url = payment.link_url or payment.link_page_url or payment_url
- elif record.method == PaymentMethod.WATA:
- payment_url = payment.url or payment_url
- elif record.method == PaymentMethod.YOOKASSA:
- payment_url = getattr(payment, "confirmation_url", None) or payment_url
- elif record.method == PaymentMethod.CRYPTOBOT:
- payment_url = (
- payment.bot_invoice_url
- or payment.mini_app_invoice_url
- or payment.web_app_invoice_url
- or payment_url
- )
-
- if payment_url:
- rows.append(
- [
- InlineKeyboardButton(
- text=texts.t("ADMIN_PAYMENT_OPEN_LINK", "🔗 Open link"),
- url=payment_url,
- )
- ]
- )
-
- if _is_checkable(record):
- rows.append(
- [
- InlineKeyboardButton(
- text=texts.t("ADMIN_PAYMENT_CHECK_BUTTON", "🔁 Check status"),
- callback_data=f"admin_payment_check_{record.method.value}_{record.local_id}",
- )
- ]
- )
-
- rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_payments")])
- return InlineKeyboardMarkup(inline_keyboard=rows)
-
-
-def _format_user_line(user: User) -> str:
- username = format_username(user.username, user.telegram_id, user.full_name)
- return f"👤 {html.escape(username)} ({user.telegram_id})"
-
-
-def _build_record_lines(
- record: PendingPayment,
- *,
- index: int,
- texts,
- language: str,
-) -> list[str]:
- amount = settings.format_price(record.amount_kopeks)
- if record.method == PaymentMethod.CRYPTOBOT:
- crypto_amount = getattr(record.payment, "amount", None)
- crypto_asset = getattr(record.payment, "asset", None)
- if crypto_amount and crypto_asset:
- amount = f"{crypto_amount} {crypto_asset}"
- method_name = _method_display(record.method)
- emoji, status_text = _status_info(record, texts=texts)
- created = format_datetime(record.created_at)
- age = format_time_ago(record.created_at, language)
- identifier = (
- html.escape(str(record.identifier)) if record.identifier else ""
- )
- display_number = html.escape(_record_display_number(record))
-
- lines = [
- f"{index}. {html.escape(method_name)} — {amount}",
- f" {emoji} {status_text}",
- f" 🕒 {created} ({age})",
- _format_user_line(record.user),
- ]
-
- if identifier:
- lines.append(f" 🆔 ID: {identifier}")
- else:
- lines.append(f" 🆔 ID: {display_number}")
-
- return lines
-
-
-def _build_payment_details_text(record: PendingPayment, *, texts, language: str) -> str:
- method_name = _method_display(record.method)
- emoji, status_text = _status_info(record, texts=texts)
- amount = settings.format_price(record.amount_kopeks)
- if record.method == PaymentMethod.CRYPTOBOT:
- crypto_amount = getattr(record.payment, "amount", None)
- crypto_asset = getattr(record.payment, "asset", None)
- if crypto_amount and crypto_asset:
- amount = f"{crypto_amount} {crypto_asset}"
- created = format_datetime(record.created_at)
- age = format_time_ago(record.created_at, language)
- raw_identifier = record.identifier if record.identifier else record.local_id
- identifier = html.escape(str(raw_identifier)) if raw_identifier is not None else "—"
- lines = [
- texts.t("ADMIN_PAYMENT_DETAILS_TITLE", "💳 Payment details"),
- "",
- f"{html.escape(method_name)}",
- f"{emoji} {status_text}",
- "",
- f"💰 {texts.t('ADMIN_PAYMENT_AMOUNT', 'Amount')}: {amount}",
- f"🕒 {texts.t('ADMIN_PAYMENT_CREATED', 'Created')}: {created} ({age})",
- f"🆔 ID: {identifier}",
- _format_user_line(record.user),
- ]
-
- if record.expires_at:
- expires_at = format_datetime(record.expires_at)
- lines.append(f"⏳ {texts.t('ADMIN_PAYMENT_EXPIRES', 'Expires')}: {expires_at}")
-
- payment = record.payment
-
- if record.method == PaymentMethod.PAL24:
- if getattr(payment, "payment_status", None):
- lines.append(
- f"💳 {texts.t('ADMIN_PAYMENT_GATEWAY_STATUS', 'Gateway status')}: "
- f"{html.escape(str(payment.payment_status))}"
- )
- if getattr(payment, "payment_method", None):
- lines.append(
- f"🏦 {texts.t('ADMIN_PAYMENT_GATEWAY_METHOD', 'Method')}: "
- f"{html.escape(str(payment.payment_method))}"
- )
- if getattr(payment, "balance_amount", None):
- lines.append(
- f"💱 {texts.t('ADMIN_PAYMENT_GATEWAY_AMOUNT', 'Gateway amount')}: "
- f"{html.escape(str(payment.balance_amount))}"
- )
- if getattr(payment, "payer_account", None):
- lines.append(
- f"👛 {texts.t('ADMIN_PAYMENT_GATEWAY_ACCOUNT', 'Payer account')}: "
- f"{html.escape(str(payment.payer_account))}"
- )
-
- if record.method == PaymentMethod.MULENPAY:
- if getattr(payment, "mulen_payment_id", None):
- lines.append(
- f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
- f"{html.escape(str(payment.mulen_payment_id))}"
- )
-
- if record.method == PaymentMethod.WATA:
- if getattr(payment, "order_id", None):
- lines.append(
- f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
- f"{html.escape(str(payment.order_id))}"
- )
- if getattr(payment, "terminal_public_id", None):
- lines.append(
- f"🏦 Terminal: {html.escape(str(payment.terminal_public_id))}"
- )
-
- if record.method == PaymentMethod.HELEKET:
- if getattr(payment, "order_id", None):
- lines.append(
- f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
- f"{html.escape(str(payment.order_id))}"
- )
- if getattr(payment, "payer_amount", None) and getattr(payment, "payer_currency", None):
- lines.append(
- f"🪙 {texts.t('ADMIN_PAYMENT_PAYER_AMOUNT', 'Paid amount')}: "
- f"{html.escape(str(payment.payer_amount))} {html.escape(str(payment.payer_currency))}"
- )
-
- if record.method == PaymentMethod.YOOKASSA:
- if getattr(payment, "payment_method_type", None):
- lines.append(
- f"💳 {texts.t('ADMIN_PAYMENT_GATEWAY_METHOD', 'Method')}: "
- f"{html.escape(str(payment.payment_method_type))}"
- )
- if getattr(payment, "confirmation_url", None):
- lines.append(texts.t("ADMIN_PAYMENT_HAS_LINK", "🔗 Payment link is available above."))
-
- if record.method == PaymentMethod.CRYPTOBOT:
- if getattr(payment, "amount", None) and getattr(payment, "asset", None):
- lines.append(
- f"🪙 {texts.t('ADMIN_PAYMENT_CRYPTO_AMOUNT', 'Crypto amount')}: "
- f"{html.escape(str(payment.amount))} {html.escape(str(payment.asset))}"
- )
- if getattr(payment, "bot_invoice_url", None) or getattr(payment, "mini_app_invoice_url", None):
- lines.append(
- texts.t("ADMIN_PAYMENT_HAS_LINK", "🔗 Payment link is available above.")
- )
- if getattr(payment, "status", None):
- lines.append(
- f"📊 {texts.t('ADMIN_PAYMENT_GATEWAY_STATUS', 'Gateway status')}: "
- f"{html.escape(str(payment.status))}"
- )
-
- if record.method == PaymentMethod.TELEGRAM_STARS:
- description = getattr(payment, "description", "") or ""
- if description:
- lines.append(f"📝 {html.escape(description)}")
- if getattr(payment, "external_id", None):
- lines.append(
- f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
- f"{html.escape(str(payment.external_id))}"
- )
-
- if _is_checkable(record):
- lines.append("")
- lines.append(texts.t("ADMIN_PAYMENT_CHECK_HINT", "ℹ️ You can trigger a manual status check."))
-
- return "\n".join(lines)
-
-
-def _parse_method_and_id(payload: str, *, prefix: str) -> Optional[tuple[PaymentMethod, int]]:
- suffix = payload[len(prefix) :]
- try:
- method_str, identifier = suffix.rsplit("_", 1)
- method = PaymentMethod(method_str)
- payment_id = int(identifier)
- return method, payment_id
- except (ValueError, KeyError):
- return None
-
-
-@admin_required
-@error_handler
-async def show_payments_overview(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession,
-) -> None:
- texts = get_texts(db_user.language)
-
- page = 1
- if callback.data.startswith("admin_payments_page_"):
- try:
- page = int(callback.data.split("_")[-1])
- except ValueError:
- page = 1
-
- records = await list_recent_pending_payments(db)
- total = len(records)
- total_pages = max(1, math.ceil(total / PAGE_SIZE))
- if page < 1:
- page = 1
- if page > total_pages:
- page = total_pages
-
- start_index = (page - 1) * PAGE_SIZE
- page_records = records[start_index : start_index + PAGE_SIZE]
-
- header = texts.t("ADMIN_PAYMENTS_TITLE", "💳 Top-up verification")
- description = texts.t(
- "ADMIN_PAYMENTS_DESCRIPTION",
- "Pending invoices created during the last 24 hours.",
- )
- notice = texts.t(
- "ADMIN_PAYMENTS_NOTICE",
- "Only invoices younger than 24 hours and waiting for payment can be checked.",
- )
-
- lines = [header, "", description]
-
- if page_records:
- for idx, record in enumerate(page_records, start=start_index + 1):
- lines.extend(_build_record_lines(record, index=idx, texts=texts, language=db_user.language))
- lines.append("")
- lines.append(notice)
- else:
- empty_text = texts.t("ADMIN_PAYMENTS_EMPTY", "No pending top-ups in the last 24 hours.")
- lines.append("")
- lines.append(empty_text)
-
- keyboard = _build_list_keyboard(
- page_records,
- page=page,
- total_pages=total_pages,
- language=db_user.language,
- )
-
- await callback.message.edit_text(
- "\n".join(line for line in lines if line is not None),
- parse_mode="HTML",
- reply_markup=keyboard,
- )
- await callback.answer()
-
-
-async def _render_payment_details(
- callback: types.CallbackQuery,
- db_user: User,
- record: PendingPayment,
-) -> None:
- texts = get_texts(db_user.language)
- text = _build_payment_details_text(record, texts=texts, language=db_user.language)
- keyboard = _build_detail_keyboard(record, language=db_user.language)
- await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
-
-
-@admin_required
-@error_handler
-async def show_payment_details(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession,
-) -> None:
- parsed = _parse_method_and_id(callback.data, prefix="admin_payment_")
- if not parsed:
- await callback.answer("❌ Invalid payment reference", show_alert=True)
- return
-
- method, payment_id = parsed
- record = await get_payment_record(db, method, payment_id)
- if not record:
- await callback.answer("❌ Платеж не найден", show_alert=True)
- return
-
- await _render_payment_details(callback, db_user, record)
- await callback.answer()
-
-
-@admin_required
-@error_handler
-async def manual_check_payment(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession,
-) -> None:
- parsed = _parse_method_and_id(callback.data, prefix="admin_payment_check_")
- if not parsed:
- await callback.answer("❌ Invalid payment reference", show_alert=True)
- return
-
- method, payment_id = parsed
- record = await get_payment_record(db, method, payment_id)
- texts = get_texts(db_user.language)
-
- if not record:
- await callback.answer(texts.t("ADMIN_PAYMENT_NOT_FOUND", "Payment not found."), show_alert=True)
- return
-
- if not _is_checkable(record):
- await callback.answer(
- texts.t("ADMIN_PAYMENT_CHECK_NOT_AVAILABLE", "Manual check is not available for this invoice."),
- show_alert=True,
- )
- return
-
- payment_service = PaymentService(callback.bot)
- updated = await run_manual_check(db, method, payment_id, payment_service)
-
- if not updated:
- await callback.answer(
- texts.t("ADMIN_PAYMENT_CHECK_FAILED", "Failed to refresh the payment status."),
- show_alert=True,
- )
- return
-
- await _render_payment_details(callback, db_user, updated)
-
- if updated.status != record.status or updated.is_paid != record.is_paid:
- emoji, status_text = _status_info(updated, texts=texts)
- message = texts.t(
- "ADMIN_PAYMENT_CHECK_SUCCESS",
- "Status updated: {status}",
- ).format(status=f"{emoji} {status_text}")
- else:
- message = texts.t(
- "ADMIN_PAYMENT_CHECK_NO_CHANGES",
- "Status is unchanged after the check.",
- )
-
- await callback.answer(message, show_alert=True)
-
-
-def register_handlers(dp: Dispatcher) -> None:
- dp.callback_query.register(manual_check_payment, F.data.startswith("admin_payment_check_"))
- dp.callback_query.register(
- show_payment_details,
- F.data.startswith("admin_payment_") & ~F.data.startswith("admin_payment_check_"),
- )
- dp.callback_query.register(show_payments_overview, F.data.startswith("admin_payments_page_"))
- dp.callback_query.register(show_payments_overview, F.data == "admin_payments")
diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py
index 5d993543..0fad927c 100644
--- a/app/keyboards/admin.py
+++ b/app/keyboards/admin.py
@@ -53,12 +53,6 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
callback_data="admin_submenu_system",
),
],
- [
- InlineKeyboardButton(
- text=_t(texts, "ADMIN_MAIN_PAYMENTS", "💳 Пополнения"),
- callback_data="admin_payments",
- )
- ],
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
])
diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json
index 4e9083e4..0103e8e6 100644
--- a/app/localization/locales/en.json
+++ b/app/localization/locales/en.json
@@ -130,7 +130,6 @@
"ADMIN_MAIN_SETTINGS": "⚙️ Settings",
"ADMIN_MAIN_SUPPORT": "🛟 Support",
"ADMIN_MAIN_SYSTEM": "🛠️ System",
- "ADMIN_MAIN_PAYMENTS": "💳 Top-ups",
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions",
"ADMIN_MESSAGES": "📨 Broadcasts",
"ADMIN_MESSAGES_ALL_USERS": "📨 All users",
@@ -165,39 +164,6 @@
"ADMIN_MONITORING_STOP": "⏸️ Stop",
"ADMIN_MONITORING_STOP_HARD": "⏹️ Stop",
"ADMIN_MONITORING_TEST_NOTIFICATIONS": "🧪 Test notifications",
- "ADMIN_PAYMENTS_TITLE": "💳 Top-up verification",
- "ADMIN_PAYMENTS_DESCRIPTION": "Pending top-up invoices created during the last 24 hours.",
- "ADMIN_PAYMENTS_NOTICE": "Only invoices younger than 24 hours and waiting for payment can be checked.",
- "ADMIN_PAYMENTS_EMPTY": "No pending top-up invoices found in the last 24 hours.",
- "ADMIN_PAYMENTS_ITEM_DETAILS": "📄 #{number}",
- "ADMIN_PAYMENT_STATUS_PENDING": "Pending",
- "ADMIN_PAYMENT_STATUS_PROCESSING": "Processing",
- "ADMIN_PAYMENT_STATUS_PAID": "Paid",
- "ADMIN_PAYMENT_STATUS_FAILED": "Failed",
- "ADMIN_PAYMENT_STATUS_CANCELED": "Cancelled",
- "ADMIN_PAYMENT_STATUS_UNKNOWN": "Unknown status",
- "ADMIN_PAYMENT_STATUS_ON_HOLD": "On hold",
- "ADMIN_PAYMENT_STATUS_EXPIRED": "Expired",
- "ADMIN_PAYMENT_DETAILS_TITLE": "💳 Payment details",
- "ADMIN_PAYMENT_AMOUNT": "Amount",
- "ADMIN_PAYMENT_CREATED": "Created",
- "ADMIN_PAYMENT_EXPIRES": "Expires",
- "ADMIN_PAYMENT_GATEWAY_STATUS": "Gateway status",
- "ADMIN_PAYMENT_GATEWAY_METHOD": "Method",
- "ADMIN_PAYMENT_GATEWAY_AMOUNT": "Gateway amount",
- "ADMIN_PAYMENT_GATEWAY_ACCOUNT": "Payer account",
- "ADMIN_PAYMENT_GATEWAY_ID": "Gateway ID",
- "ADMIN_PAYMENT_PAYER_AMOUNT": "Paid amount",
- "ADMIN_PAYMENT_CRYPTO_AMOUNT": "Crypto amount",
- "ADMIN_PAYMENT_HAS_LINK": "🔗 A payment link is available via the button above.",
- "ADMIN_PAYMENT_OPEN_LINK": "🔗 Open link",
- "ADMIN_PAYMENT_CHECK_BUTTON": "🔁 Check status",
- "ADMIN_PAYMENT_CHECK_HINT": "ℹ️ You can trigger a manual status check.",
- "ADMIN_PAYMENT_CHECK_NOT_AVAILABLE": "Manual status check is not available for this invoice.",
- "ADMIN_PAYMENT_CHECK_FAILED": "Failed to refresh the payment status.",
- "ADMIN_PAYMENT_CHECK_SUCCESS": "Status updated: {status}",
- "ADMIN_PAYMENT_CHECK_NO_CHANGES": "Status did not change after the check.",
- "ADMIN_PAYMENT_NOT_FOUND": "Payment not found.",
"ADMIN_NODE_DISABLE": "⏸️ Disable",
"ADMIN_NODE_ENABLE": "▶️ Enable",
"ADMIN_NODE_RESTART": "🔄 Restart",
diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json
index 81d732a4..20f6ed7e 100644
--- a/app/localization/locales/ru.json
+++ b/app/localization/locales/ru.json
@@ -130,7 +130,6 @@
"ADMIN_MAIN_SETTINGS": "⚙️ Настройки",
"ADMIN_MAIN_SUPPORT": "🛟 Поддержка",
"ADMIN_MAIN_SYSTEM": "🛠️ Система",
- "ADMIN_MAIN_PAYMENTS": "💳 Пополнения",
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки",
"ADMIN_MESSAGES": "📨 Рассылки",
"ADMIN_MESSAGES_ALL_USERS": "📨 Всем пользователям",
@@ -165,39 +164,6 @@
"ADMIN_MONITORING_STOP": "⏸️ Остановить",
"ADMIN_MONITORING_STOP_HARD": "⏹️ Остановить",
"ADMIN_MONITORING_TEST_NOTIFICATIONS": "🧪 Тест уведомлений",
- "ADMIN_PAYMENTS_TITLE": "💳 Проверка пополнений",
- "ADMIN_PAYMENTS_DESCRIPTION": "Список счетов на пополнение, созданных за последние 24 часа и ожидающих оплаты.",
- "ADMIN_PAYMENTS_NOTICE": "Проверять можно только счета моложе 24 часов и со статусом ожидания.",
- "ADMIN_PAYMENTS_EMPTY": "За последние 24 часа не найдено счетов на пополнение в ожидании.",
- "ADMIN_PAYMENTS_ITEM_DETAILS": "📄 №{number}",
- "ADMIN_PAYMENT_STATUS_PENDING": "Ожидает оплаты",
- "ADMIN_PAYMENT_STATUS_PROCESSING": "Обрабатывается",
- "ADMIN_PAYMENT_STATUS_PAID": "Оплачен",
- "ADMIN_PAYMENT_STATUS_FAILED": "Ошибка",
- "ADMIN_PAYMENT_STATUS_CANCELED": "Отменён",
- "ADMIN_PAYMENT_STATUS_UNKNOWN": "Статус неизвестен",
- "ADMIN_PAYMENT_STATUS_ON_HOLD": "На удержании",
- "ADMIN_PAYMENT_STATUS_EXPIRED": "Просрочен",
- "ADMIN_PAYMENT_DETAILS_TITLE": "💳 Детали платежа",
- "ADMIN_PAYMENT_AMOUNT": "Сумма",
- "ADMIN_PAYMENT_CREATED": "Создан",
- "ADMIN_PAYMENT_EXPIRES": "Истекает",
- "ADMIN_PAYMENT_GATEWAY_STATUS": "Статус в платёжке",
- "ADMIN_PAYMENT_GATEWAY_METHOD": "Метод оплаты",
- "ADMIN_PAYMENT_GATEWAY_AMOUNT": "Сумма в платёжке",
- "ADMIN_PAYMENT_GATEWAY_ACCOUNT": "Счёт плательщика",
- "ADMIN_PAYMENT_GATEWAY_ID": "ID в платёжке",
- "ADMIN_PAYMENT_PAYER_AMOUNT": "Оплачено",
- "ADMIN_PAYMENT_CRYPTO_AMOUNT": "Сумма в криптовалюте",
- "ADMIN_PAYMENT_HAS_LINK": "🔗 Ссылка на оплату доступна в кнопке выше.",
- "ADMIN_PAYMENT_OPEN_LINK": "🔗 Открыть ссылку",
- "ADMIN_PAYMENT_CHECK_BUTTON": "🔁 Проверить статус",
- "ADMIN_PAYMENT_CHECK_HINT": "ℹ️ Можно запустить ручную проверку статуса.",
- "ADMIN_PAYMENT_CHECK_NOT_AVAILABLE": "Для этого счёта ручная проверка недоступна.",
- "ADMIN_PAYMENT_CHECK_FAILED": "Не удалось обновить статус платежа.",
- "ADMIN_PAYMENT_CHECK_SUCCESS": "Статус обновлён: {status}",
- "ADMIN_PAYMENT_CHECK_NO_CHANGES": "Статус не изменился после проверки.",
- "ADMIN_PAYMENT_NOT_FOUND": "Платёж не найден.",
"ADMIN_NODE_DISABLE": "⏸️ Отключить",
"ADMIN_NODE_ENABLE": "▶️ Включить",
"ADMIN_NODE_RESTART": "🔄 Перезагрузить",
diff --git a/app/services/mulenpay_service.py b/app/services/mulenpay_service.py
index bbd3901c..6cfbebf1 100644
--- a/app/services/mulenpay_service.py
+++ b/app/services/mulenpay_service.py
@@ -214,21 +214,3 @@ class MulenPayService:
async def get_payment(self, payment_id: int) -> Optional[Dict[str, Any]]:
return await self._request("GET", f"/v2/payments/{payment_id}")
-
- async def list_payments(
- self,
- *,
- offset: int = 0,
- limit: int = 100,
- uuid: Optional[str] = None,
- status: Optional[int] = None,
- ) -> Optional[Dict[str, Any]]:
- params = {
- "offset": max(0, offset),
- "limit": max(1, min(limit, 1000)),
- }
- if uuid:
- params["uuid"] = uuid
- if status is not None:
- params["status"] = status
- return await self._request("GET", "/v2/payments", params=params)
diff --git a/app/services/payment/cryptobot.py b/app/services/payment/cryptobot.py
index aab5adb5..ebfbe672 100644
--- a/app/services/payment/cryptobot.py
+++ b/app/services/payment/cryptobot.py
@@ -337,83 +337,3 @@ class CryptoBotPaymentMixin:
"Ошибка обработки CryptoBot webhook: %s", error, exc_info=True
)
return False
-
- async def get_cryptobot_payment_status(
- self,
- db: AsyncSession,
- local_payment_id: int,
- ) -> Optional[Dict[str, Any]]:
- """Запрашивает актуальный статус CryptoBot invoice и синхронизирует его."""
-
- cryptobot_crud = import_module("app.database.crud.cryptobot")
- payment = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id)
- if not payment:
- logger.warning("CryptoBot платеж %s не найден", local_payment_id)
- return None
-
- if not self.cryptobot_service:
- logger.warning("CryptoBot сервис не инициализирован для ручной проверки")
- return {"payment": payment}
-
- invoice_id = payment.invoice_id
- try:
- invoices = await self.cryptobot_service.get_invoices(
- invoice_ids=[invoice_id]
- )
- except Exception as error: # pragma: no cover - network errors
- logger.error(
- "Ошибка запроса статуса CryptoBot invoice %s: %s",
- invoice_id,
- error,
- )
- return {"payment": payment}
-
- remote_invoice: Optional[Dict[str, Any]] = None
- if invoices:
- for item in invoices:
- if str(item.get("invoice_id")) == str(invoice_id):
- remote_invoice = item
- break
-
- if not remote_invoice:
- logger.info(
- "CryptoBot invoice %s не найден через API при ручной проверке",
- invoice_id,
- )
- refreshed = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id)
- return {"payment": refreshed or payment}
-
- status = (remote_invoice.get("status") or "").lower()
- paid_at_str = remote_invoice.get("paid_at")
- paid_at = None
- if paid_at_str:
- try:
- paid_at = datetime.fromisoformat(paid_at_str.replace("Z", "+00:00")).replace(
- tzinfo=None
- )
- except Exception: # pragma: no cover - defensive parsing
- paid_at = None
-
- if status == "paid":
- webhook_payload = {
- "update_type": "invoice_paid",
- "payload": {
- "invoice_id": remote_invoice.get("invoice_id") or invoice_id,
- "amount": remote_invoice.get("amount") or payment.amount,
- "asset": remote_invoice.get("asset") or payment.asset,
- "paid_at": paid_at_str,
- "payload": remote_invoice.get("payload") or payment.payload,
- },
- }
- await self.process_cryptobot_webhook(db, webhook_payload)
- else:
- if status and status != (payment.status or "").lower():
- await cryptobot_crud.update_cryptobot_payment_status(
- db,
- invoice_id,
- status,
- paid_at,
- )
-
- refreshed = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id)
- return {"payment": refreshed or payment}
diff --git a/app/services/payment/heleket.py b/app/services/payment/heleket.py
index 48b3dba3..cffcc201 100644
--- a/app/services/payment/heleket.py
+++ b/app/services/payment/heleket.py
@@ -5,7 +5,7 @@ from __future__ import annotations
import logging
import secrets
import time
-from datetime import datetime, timezone, timedelta
+from datetime import datetime, timezone
from importlib import import_module
from typing import Any, Dict, Optional
@@ -417,40 +417,27 @@ class HeleketPaymentMixin:
logger.error("Heleket платеж с id=%s не найден", local_payment_id)
return None
- payload: Optional[Dict[str, Any]] = None
try:
response = await self.heleket_service.get_payment_info( # type: ignore[union-attr]
uuid=payment.uuid,
order_id=payment.order_id,
)
except Exception as error: # pragma: no cover - defensive
- logger.exception(
- "Ошибка получения статуса Heleket платежа %s: %s",
- payment.uuid,
- error,
+ logger.exception("Ошибка получения статуса Heleket платежа %s: %s", payment.uuid, error)
+ return payment
+
+ if not response:
+ logger.warning(
+ "Heleket API вернул пустой ответ при проверке платежа %s", payment.uuid
)
- else:
- if response:
- result = response.get("result") if isinstance(response, dict) else None
- if isinstance(result, dict):
- payload = dict(result)
- else:
- logger.error(
- "Некорректный ответ Heleket API при проверке платежа %s: %s",
- payment.uuid,
- response,
- )
+ return payment
- if payload is None:
- fallback = await self._lookup_heleket_payment_history(payment)
- if not fallback:
- logger.warning(
- "Heleket API не вернул информацию по платежу %s",
- payment.uuid,
- )
- return payment
- payload = dict(fallback)
+ result = response.get("result") if isinstance(response, dict) else None
+ if not isinstance(result, dict):
+ logger.error("Некорректный ответ Heleket API при проверке платежа %s: %s", payment.uuid, response)
+ return payment
+ payload: Dict[str, Any] = dict(result)
payload.setdefault("uuid", payment.uuid)
payload.setdefault("order_id", payment.order_id)
@@ -461,58 +448,3 @@ class HeleketPaymentMixin:
)
return updated_payment or payment
-
- async def _lookup_heleket_payment_history(
- self,
- payment: "HeleketPayment",
- ) -> Optional[Dict[str, Any]]:
- service = getattr(self, "heleket_service", None)
- if not service:
- return None
-
- created_at = getattr(payment, "created_at", None)
- date_from_str: Optional[str] = None
- date_to_str: Optional[str] = None
- if isinstance(created_at, datetime):
- start = created_at - timedelta(days=2)
- end = created_at + timedelta(days=2)
- date_from_str = start.strftime("%Y-%m-%d %H:%M:%S")
- date_to_str = end.strftime("%Y-%m-%d %H:%M:%S")
-
- cursor: Optional[str] = None
- for _ in range(10):
- response = await service.list_payments(
- date_from=date_from_str,
- date_to=date_to_str,
- cursor=cursor,
- )
- if not response or not isinstance(response, dict):
- return None
-
- result = response.get("result")
- if not isinstance(result, dict):
- return None
-
- items = result.get("items")
- if isinstance(items, list):
- for item in items:
- if not isinstance(item, dict):
- continue
- uuid = str(item.get("uuid") or "").strip()
- order_id = str(item.get("order_id") or "").strip()
- if uuid and uuid == str(payment.uuid):
- return item
- if order_id and order_id == str(payment.order_id):
- return item
-
- paginate = result.get("paginate")
- cursor = None
- if isinstance(paginate, dict):
- next_cursor = paginate.get("nextCursor")
- if isinstance(next_cursor, str) and next_cursor:
- cursor = next_cursor
-
- if not cursor:
- break
-
- return None
diff --git a/app/services/payment/mulenpay.py b/app/services/payment/mulenpay.py
index 71a9dd8b..dc0546ec 100644
--- a/app/services/payment/mulenpay.py
+++ b/app/services/payment/mulenpay.py
@@ -440,59 +440,35 @@ class MulenPayPaymentMixin:
response = await self.mulenpay_service.get_payment(
payment.mulen_payment_id
)
- if response:
- if isinstance(response, dict) and response.get("success"):
- remote_data = response.get("payment")
- elif isinstance(response, dict) and "status" in response and "id" in response:
- remote_data = response
- if not remote_data and getattr(self, "mulenpay_service", None):
- list_response = await self.mulenpay_service.list_payments(
- limit=100,
- uuid=payment.uuid,
- )
- items = []
- if isinstance(list_response, dict):
- items = list_response.get("items") or []
- if items:
- for candidate in items:
- if not isinstance(candidate, dict):
- continue
- candidate_id = candidate.get("id")
- candidate_uuid = candidate.get("uuid")
- if (
- (candidate_id is not None and candidate_id == payment.mulen_payment_id)
- or (candidate_uuid and candidate_uuid == payment.uuid)
- ):
- remote_data = candidate
- break
+ if response and response.get("success"):
+ remote_data = response.get("payment")
+ if isinstance(remote_data, dict):
+ remote_status_code = remote_data.get("status")
+ mapped_status = self._map_mulenpay_status(remote_status_code)
- if isinstance(remote_data, dict):
- remote_status_code = remote_data.get("status")
- mapped_status = self._map_mulenpay_status(remote_status_code)
-
- if mapped_status == "success" and not payment.is_paid:
- await self.process_mulenpay_callback(
- db,
- {
- "uuid": payment.uuid,
- "payment_status": "success",
- "id": remote_data.get("id"),
- "amount": remote_data.get("amount"),
- },
- )
- payment = await payment_module.get_mulenpay_payment_by_local_id(
- db, local_payment_id
- )
- elif mapped_status and mapped_status != payment.status:
- await payment_module.update_mulenpay_payment_status(
- db,
- payment=payment,
- status=mapped_status,
- mulen_payment_id=remote_data.get("id"),
- )
- payment = await payment_module.get_mulenpay_payment_by_local_id(
- db, local_payment_id
- )
+ if mapped_status == "success" and not payment.is_paid:
+ await self.process_mulenpay_callback(
+ db,
+ {
+ "uuid": payment.uuid,
+ "payment_status": "success",
+ "id": remote_data.get("id"),
+ "amount": remote_data.get("amount"),
+ },
+ )
+ payment = await payment_module.get_mulenpay_payment_by_local_id(
+ db, local_payment_id
+ )
+ elif mapped_status and mapped_status != payment.status:
+ await payment_module.update_mulenpay_payment_status(
+ db,
+ payment=payment,
+ status=mapped_status,
+ mulen_payment_id=remote_data.get("id"),
+ )
+ payment = await payment_module.get_mulenpay_payment_by_local_id(
+ db, local_payment_id
+ )
return {
"payment": payment,
diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py
index 7cae4325..011789b1 100644
--- a/app/services/payment/pal24.py
+++ b/app/services/payment/pal24.py
@@ -6,7 +6,7 @@ import logging
from datetime import datetime
from importlib import import_module
import uuid
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, Optional
from sqlalchemy.ext.asyncio import AsyncSession
@@ -507,135 +507,53 @@ class Pal24PaymentMixin:
return None
remote_status: Optional[str] = None
- remote_payloads: Dict[str, Any] = {}
- payment_info_candidates: List[Dict[str, Optional[str]]] = []
+ remote_data: Optional[Dict[str, Any]] = None
service = getattr(self, "pal24_service", None)
if service and payment.bill_id:
- bill_id_str = str(payment.bill_id)
try:
- response = await service.get_bill_status(bill_id_str)
+ response = await service.get_bill_status(payment.bill_id)
+ remote_data = response
+ remote_status = response.get("status") or response.get("bill", {}).get("status")
+
+ payment_info = self._extract_remote_payment_info(response)
+
+ if remote_status:
+ normalized_remote = str(remote_status).upper()
+ update_kwargs: Dict[str, Any] = {
+ "status": normalized_remote,
+ "payment_status": payment_info.get("status") or remote_status,
+ }
+
+ if payment_info.get("id"):
+ update_kwargs["payment_id"] = payment_info["id"]
+ if payment_info.get("method"):
+ update_kwargs["payment_method"] = payment_info["method"]
+ if payment_info.get("balance_amount"):
+ update_kwargs["balance_amount"] = payment_info["balance_amount"]
+ if payment_info.get("balance_currency"):
+ update_kwargs["balance_currency"] = payment_info["balance_currency"]
+ if payment_info.get("account"):
+ update_kwargs["payer_account"] = payment_info["account"]
+
+ if normalized_remote in getattr(service, "BILL_SUCCESS_STATES", {"SUCCESS"}):
+ update_kwargs["is_paid"] = True
+ if not payment.paid_at:
+ update_kwargs["paid_at"] = datetime.utcnow()
+ elif normalized_remote in getattr(service, "BILL_FAILED_STATES", {"FAIL"}):
+ update_kwargs["is_paid"] = False
+ elif normalized_remote in getattr(service, "BILL_PENDING_STATES", {"NEW", "PROCESS"}):
+ update_kwargs.setdefault("is_paid", False)
+
+ payment = await payment_module.update_pal24_payment_status(
+ db,
+ payment,
+ **update_kwargs,
+ )
except Pal24APIError as error:
- logger.error("Ошибка Pal24 API при получении статуса счёта: %s", error)
- else:
- if response:
- remote_payloads["bill_status"] = response
- status_value = response.get("status") or (response.get("bill") or {}).get("status")
- if status_value:
- remote_status = str(status_value).upper()
- extracted = self._extract_remote_payment_info(response)
- if extracted:
- payment_info_candidates.append(extracted)
-
- if payment.payment_id:
- payment_id_str = str(payment.payment_id)
- try:
- payment_response = await service.get_payment_status(payment_id_str)
- except Pal24APIError as error:
- logger.error("Ошибка Pal24 API при получении статуса платежа: %s", error)
- else:
- if payment_response:
- remote_payloads["payment_status"] = payment_response
- extracted = self._extract_remote_payment_info(payment_response)
- if extracted:
- payment_info_candidates.append(extracted)
-
- try:
- payments_response = await service.get_bill_payments(bill_id_str)
- except Pal24APIError as error:
- logger.error("Ошибка Pal24 API при получении списка платежей: %s", error)
- else:
- if payments_response:
- remote_payloads["bill_payments"] = payments_response
- for candidate in self._collect_payment_candidates(payments_response):
- extracted = self._extract_remote_payment_info(candidate)
- if extracted:
- payment_info_candidates.append(extracted)
-
- payment_info = self._select_best_payment_info(payment, payment_info_candidates)
- if payment_info:
- remote_payloads.setdefault("selected_payment", payment_info)
-
- bill_success = getattr(service, "BILL_SUCCESS_STATES", {"SUCCESS"}) if service else {"SUCCESS"}
- bill_failed = getattr(service, "BILL_FAILED_STATES", {"FAIL"}) if service else {"FAIL"}
- bill_pending = getattr(service, "BILL_PENDING_STATES", {"NEW", "PROCESS"}) if service else {"NEW", "PROCESS"}
-
- update_status = payment.status or "NEW"
- update_kwargs: Dict[str, Any] = {}
- is_paid_update: Optional[bool] = None
-
- if remote_status:
- update_status = remote_status
- if remote_status in bill_success:
- is_paid_update = True
- elif remote_status in bill_failed:
- is_paid_update = False
- elif remote_status in bill_pending and is_paid_update is None:
- is_paid_update = False
-
- payment_status_code: Optional[str] = None
- if payment_info:
- payment_status_code = (payment_info.get("status") or "").upper() or None
- if payment_status_code:
- existing_status = (getattr(payment, "payment_status", "") or "").upper()
- if payment_status_code != existing_status:
- update_kwargs["payment_status"] = payment_status_code
-
- payment_id_value = payment_info.get("id")
- if payment_id_value and payment_id_value != (payment.payment_id or ""):
- update_kwargs["payment_id"] = payment_id_value
-
- method_value = payment_info.get("method")
- if method_value:
- normalized_method = self._normalize_payment_method(method_value)
- if normalized_method != (payment.payment_method or ""):
- update_kwargs["payment_method"] = normalized_method
-
- balance_amount = payment_info.get("balance_amount")
- if balance_amount and balance_amount != (payment.balance_amount or ""):
- update_kwargs["balance_amount"] = balance_amount
-
- balance_currency = payment_info.get("balance_currency")
- if balance_currency and balance_currency != (payment.balance_currency or ""):
- update_kwargs["balance_currency"] = balance_currency
-
- payer_account = payment_info.get("account")
- if payer_account and payer_account != (payment.payer_account or ""):
- update_kwargs["payer_account"] = payer_account
-
- if payment_status_code:
- success_states = {"SUCCESS", "OVERPAID"}
- failed_states = {"FAIL"}
- pending_states = {"NEW", "PROCESS", "UNDERPAID"}
- if payment_status_code in success_states:
- is_paid_update = True
- elif payment_status_code in failed_states and is_paid_update is not True:
- is_paid_update = False
- elif payment_status_code in pending_states and is_paid_update is None:
- is_paid_update = False
-
- if not remote_status and payment_status_code:
- update_status = payment_status_code
-
- if is_paid_update is not None and is_paid_update != bool(payment.is_paid):
- update_kwargs["is_paid"] = is_paid_update
- if is_paid_update and not payment.paid_at:
- update_kwargs.setdefault("paid_at", datetime.utcnow())
-
- current_status = payment.status or ""
- effective_status = update_status or current_status or "NEW"
- needs_update = bool(update_kwargs) or effective_status != current_status
-
- if needs_update:
- payment = await payment_module.update_pal24_payment_status(
- db,
- payment,
- status=effective_status,
- **update_kwargs,
- )
-
- remote_status_for_return = remote_status or payment_status_code
- remote_data = remote_payloads or None
+ logger.error(
+ "Ошибка Pal24 API при получении статуса: %s", error
+ )
if payment.is_paid and not payment.transaction_id:
try:
@@ -658,7 +576,7 @@ class Pal24PaymentMixin:
"payment": payment,
"status": payment.status,
"is_paid": payment.is_paid,
- "remote_status": remote_status_for_return,
+ "remote_status": remote_status,
"remote_data": remote_data,
}
@@ -703,26 +621,11 @@ class Pal24PaymentMixin:
or candidate.get("payer_account")
or candidate.get("AccountNumber")
),
- "bill_id": _stringify(
- candidate.get("bill_id")
- or candidate.get("BillId")
- or candidate.get("billId")
- ),
}
if not isinstance(remote_data, dict):
return {}
- lower_keys = {str(key).lower() for key in remote_data.keys()}
- has_status = any(key in lower_keys for key in ("status", "payment_status"))
- has_identifier = any(
- key in lower_keys
- for key in ("payment_id", "from_card", "account_amount", "id")
- ) or "bill_id" in lower_keys
-
- if has_status and has_identifier and "bill" not in lower_keys:
- return _normalize(remote_data)
-
search_spaces = [remote_data]
bill_section = remote_data.get("bill") or remote_data.get("Bill")
if isinstance(bill_section, dict):
@@ -738,59 +641,8 @@ class Pal24PaymentMixin:
if candidate:
return _normalize(candidate)
- data_section = remote_data.get("data") or remote_data.get("Data")
- candidate = _pick_candidate(data_section)
- if candidate:
- return _normalize(candidate)
-
return {}
- @staticmethod
- def _collect_payment_candidates(remote_data: Any) -> List[Dict[str, Any]]:
- candidates: List[Dict[str, Any]] = []
-
- def _visit(value: Any) -> None:
- if isinstance(value, dict):
- lower_keys = {str(key).lower() for key in value.keys()}
- has_status = any(key in lower_keys for key in ("status", "payment_status"))
- has_identifier = any(
- key in lower_keys
- for key in ("id", "payment_id", "bill_id", "from_card", "account_amount")
- )
- if has_status and has_identifier and value not in candidates:
- candidates.append(value)
- for nested in value.values():
- _visit(nested)
- elif isinstance(value, list):
- for item in value:
- _visit(item)
-
- _visit(remote_data)
- return candidates
-
- @staticmethod
- def _select_best_payment_info(
- payment: Any,
- candidates: List[Dict[str, Optional[str]]],
- ) -> Dict[str, Optional[str]]:
- if not candidates:
- return {}
-
- payment_id = str(getattr(payment, "payment_id", "") or "")
- bill_id = str(getattr(payment, "bill_id", "") or "")
-
- for candidate in candidates:
- candidate_id = str(candidate.get("id") or "")
- if payment_id and candidate_id == payment_id:
- return candidate
-
- for candidate in candidates:
- candidate_bill = str(candidate.get("bill_id") or "")
- if bill_id and candidate_bill == bill_id:
- return candidate
-
- return candidates[0]
-
@staticmethod
def _normalize_payment_method(payment_method: Optional[str]) -> str:
mapping = {
diff --git a/app/services/payment/wata.py b/app/services/payment/wata.py
index 134c1ca0..213c731e 100644
--- a/app/services/payment/wata.py
+++ b/app/services/payment/wata.py
@@ -18,52 +18,6 @@ from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
-def _extract_transaction_id(payment: Any, remote_link: Optional[Dict[str, Any]] = None) -> Optional[str]:
- """Try to find the remote WATA transaction identifier from stored payloads."""
-
- def _from_mapping(mapping: Any) -> Optional[str]:
- if isinstance(mapping, str):
- try:
- import json
-
- mapping = json.loads(mapping)
- except Exception: # pragma: no cover - defensive parsing
- return None
- if not isinstance(mapping, dict):
- return None
- for key in ("id", "transaction_id", "transactionId"):
- value = mapping.get(key)
- if not value:
- continue
- value_str = str(value)
- if "-" in value_str:
- return value_str
- return None
-
- candidate = None
-
- if hasattr(payment, "callback_payload"):
- candidate = _from_mapping(getattr(payment, "callback_payload"))
- if candidate:
- return candidate
-
- metadata = getattr(payment, "metadata_json", None)
- if isinstance(metadata, dict):
- if "transaction" in metadata:
- candidate = _from_mapping(metadata.get("transaction"))
- if candidate:
- return candidate
- candidate = _from_mapping(metadata)
- if candidate:
- return candidate
-
- candidate = _from_mapping(remote_link)
- if candidate:
- return candidate
-
- return None
-
-
class WataPaymentMixin:
"""Encapsulates creation and status handling for WATA payment links."""
@@ -272,7 +226,6 @@ class WataPaymentMixin:
remote_link: Optional[Dict[str, Any]] = None
transaction_payload: Optional[Dict[str, Any]] = None
- transaction_id: Optional[str] = None
if getattr(self, "wata_service", None) and payment.payment_link_id:
try:
@@ -300,84 +253,29 @@ class WataPaymentMixin:
remote_status_normalized = (remote_status or "").lower()
if remote_status_normalized in {"closed", "paid"} and not payment.is_paid:
- transaction_id = _extract_transaction_id(payment, remote_link)
- if transaction_id:
- try:
- transaction_payload = await self.wata_service.get_transaction( # type: ignore[union-attr]
- transaction_id
- )
- except WataAPIError as error:
- logger.error(
- "Ошибка получения WATA транзакции %s: %s",
- transaction_id,
- error,
- )
- except Exception as error: # pragma: no cover - safety net
- logger.exception(
- "Непредвиденная ошибка при запросе WATA транзакции %s: %s",
- transaction_id,
- error,
- )
- if not transaction_payload:
- try:
- tx_response = await self.wata_service.search_transactions( # type: ignore[union-attr]
- order_id=payment.order_id,
- payment_link_id=payment.payment_link_id,
- status="Paid",
- limit=5,
- )
- items = tx_response.get("items") or []
- for item in items:
- if (item or {}).get("status") == "Paid":
- transaction_payload = item
- break
- except WataAPIError as error:
- logger.error(
- "Ошибка поиска WATA транзакций для %s: %s",
- payment.payment_link_id,
- error,
- )
- except Exception as error: # pragma: no cover - safety net
- logger.exception("Непредвиденная ошибка при поиске WATA транзакции: %s", error)
-
- if (
- not transaction_payload
- and not payment.is_paid
- and getattr(self, "wata_service", None)
- ):
- fallback_transaction_id = transaction_id or _extract_transaction_id(payment)
- if fallback_transaction_id:
try:
- transaction_payload = await self.wata_service.get_transaction( # type: ignore[union-attr]
- fallback_transaction_id
+ tx_response = await self.wata_service.search_transactions( # type: ignore[union-attr]
+ order_id=payment.order_id,
+ payment_link_id=payment.payment_link_id,
+ status="Paid",
+ limit=5,
)
+ items = tx_response.get("items") or []
+ for item in items:
+ if (item or {}).get("status") == "Paid":
+ transaction_payload = item
+ break
except WataAPIError as error:
logger.error(
- "Ошибка повторного запроса WATA транзакции %s: %s",
- fallback_transaction_id,
+ "Ошибка поиска WATA транзакций для %s: %s",
+ payment.payment_link_id,
error,
)
except Exception as error: # pragma: no cover - safety net
- logger.exception(
- "Непредвиденная ошибка при повторном запросе WATA транзакции %s: %s",
- fallback_transaction_id,
- error,
- )
+ logger.exception("Непредвиденная ошибка при поиске WATA транзакции: %s", error)
if transaction_payload and not payment.is_paid:
- normalized_status = None
- if isinstance(transaction_payload, dict):
- raw_status = transaction_payload.get("status") or transaction_payload.get("statusName")
- if raw_status:
- normalized_status = str(raw_status).lower()
- if normalized_status == "paid":
- payment = await self._finalize_wata_payment(db, payment, transaction_payload)
- else:
- logger.debug(
- "WATA транзакция %s в статусе %s, повторная обработка не требуется",
- transaction_id or getattr(payment, "payment_link_id", ""),
- normalized_status or "unknown",
- )
+ payment = await self._finalize_wata_payment(db, payment, transaction_payload)
return {
"payment": payment,
@@ -395,22 +293,7 @@ class WataPaymentMixin:
) -> Any:
payment_module = import_module("app.services.payment_service")
- if isinstance(transaction_payload, dict):
- paid_status = transaction_payload.get("status") or transaction_payload.get("statusName")
- else:
- paid_status = None
- if paid_status and str(paid_status).lower() not in {"paid", "declined", "pending"}:
- logger.debug(
- "Неизвестный статус WATA транзакции %s: %s",
- getattr(payment, "payment_link_id", ""),
- paid_status,
- )
-
- paid_at = None
- if isinstance(transaction_payload, dict):
- paid_at = WataService._parse_datetime(transaction_payload.get("paymentTime"))
- if not paid_at and getattr(payment, "paid_at", None):
- paid_at = payment.paid_at
+ paid_at = WataService._parse_datetime(transaction_payload.get("paymentTime"))
existing_metadata = dict(getattr(payment, "metadata_json", {}) or {})
existing_metadata["transaction"] = transaction_payload
diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py
index b4210899..e9489bc3 100644
--- a/app/services/payment/yookassa.py
+++ b/app/services/payment/yookassa.py
@@ -207,108 +207,6 @@ class YooKassaPaymentMixin:
logger.error("Ошибка создания платежа YooKassa СБП: %s", error)
return None
- async def get_yookassa_payment_status(
- self,
- db: AsyncSession,
- local_payment_id: int,
- ) -> Optional[Dict[str, Any]]:
- """Запрашивает статус платежа в YooKassa и синхронизирует локальные данные."""
-
- payment_module = import_module("app.services.payment_service")
-
- payment = await payment_module.get_yookassa_payment_by_local_id(db, local_payment_id)
- if not payment:
- return None
-
- remote_data: Optional[Dict[str, Any]] = None
-
- if getattr(self, "yookassa_service", None):
- try:
- remote_data = await self.yookassa_service.get_payment_info( # type: ignore[union-attr]
- payment.yookassa_payment_id
- )
- except Exception as error: # pragma: no cover - defensive logging
- logger.error(
- "Ошибка получения статуса YooKassa %s: %s",
- payment.yookassa_payment_id,
- error,
- )
-
- if remote_data:
- status = remote_data.get("status") or payment.status
- paid = bool(remote_data.get("paid", getattr(payment, "is_paid", False)))
- captured_raw = remote_data.get("captured_at")
- captured_at = None
- if captured_raw:
- try:
- captured_at = datetime.fromisoformat(
- str(captured_raw).replace("Z", "+00:00")
- ).replace(tzinfo=None)
- except Exception as parse_error: # pragma: no cover - diagnostic log
- logger.debug(
- "Не удалось распарсить captured_at %s: %s",
- captured_raw,
- parse_error,
- )
- captured_at = None
-
- payment_method_type = remote_data.get("payment_method_type")
-
- updated_payment = await payment_module.update_yookassa_payment_status(
- db,
- payment.yookassa_payment_id,
- status=status,
- is_paid=paid,
- is_captured=paid and status == "succeeded",
- captured_at=captured_at,
- payment_method_type=payment_method_type,
- )
-
- if updated_payment:
- payment = updated_payment
-
- transaction_id = getattr(payment, "transaction_id", None)
-
- if (
- payment.status == "succeeded"
- and getattr(payment, "is_paid", False)
- ):
- if not transaction_id:
- try:
- await db.refresh(payment)
- transaction_id = getattr(payment, "transaction_id", None)
- except Exception as refresh_error: # pragma: no cover - defensive logging
- logger.warning(
- "Не удалось обновить состояние платежа YooKassa %s перед повторной обработкой: %s",
- payment.yookassa_payment_id,
- refresh_error,
- exc_info=True,
- )
-
- if transaction_id:
- logger.info(
- "Пропускаем повторную обработку платежа YooKassa %s: уже связан с транзакцией %s",
- payment.yookassa_payment_id,
- transaction_id,
- )
- else:
- try:
- await self._process_successful_yookassa_payment(db, payment)
- except Exception as process_error: # pragma: no cover - defensive logging
- logger.error(
- "Ошибка обработки успешного платежа YooKassa %s: %s",
- payment.yookassa_payment_id,
- process_error,
- exc_info=True,
- )
-
- return {
- "payment": payment,
- "status": payment.status,
- "is_paid": getattr(payment, "is_paid", False),
- "remote_data": remote_data,
- }
-
async def _process_successful_yookassa_payment(
self,
db: AsyncSession,
@@ -358,17 +256,12 @@ class YooKassaPaymentMixin:
is_completed=True,
)
- linked_payment = await payment_module.link_yookassa_payment_to_transaction(
+ await payment_module.link_yookassa_payment_to_transaction(
db,
payment.yookassa_payment_id,
transaction.id,
)
- if linked_payment:
- payment.transaction_id = getattr(linked_payment, "transaction_id", transaction.id)
- if hasattr(linked_payment, "transaction"):
- payment.transaction = linked_payment.transaction
-
user = await payment_module.get_user_by_id(db, payment.user_id)
if user:
if is_simple_subscription:
diff --git a/app/services/payment_service.py b/app/services/payment_service.py
index b9f414ed..6acb83dd 100644
--- a/app/services/payment_service.py
+++ b/app/services/payment_service.py
@@ -55,11 +55,6 @@ async def get_yookassa_payment_by_id(*args, **kwargs):
return await yk_crud.get_yookassa_payment_by_id(*args, **kwargs)
-async def get_yookassa_payment_by_local_id(*args, **kwargs):
- yk_crud = import_module("app.database.crud.yookassa")
- return await yk_crud.get_yookassa_payment_by_local_id(*args, **kwargs)
-
-
async def create_transaction(*args, **kwargs):
transaction_crud = import_module("app.database.crud.transaction")
return await transaction_crud.create_transaction(*args, **kwargs)
diff --git a/app/services/payment_verification_service.py b/app/services/payment_verification_service.py
deleted file mode 100644
index c2b44a23..00000000
--- a/app/services/payment_verification_service.py
+++ /dev/null
@@ -1,767 +0,0 @@
-"""Helpers for inspecting and manually checking pending top-up payments."""
-
-from __future__ import annotations
-
-import asyncio
-import logging
-import re
-from collections import Counter
-from dataclasses import dataclass
-from datetime import datetime, timedelta
-from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional
-
-from sqlalchemy import desc, select
-from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy.orm import selectinload
-
-from app.config import settings
-from app.database.database import AsyncSessionLocal
-from app.database.models import (
- CryptoBotPayment,
- HeleketPayment,
- MulenPayPayment,
- Pal24Payment,
- PaymentMethod,
- Transaction,
- TransactionType,
- User,
- WataPayment,
- YooKassaPayment,
-)
-
-logger = logging.getLogger(__name__)
-
-
-PENDING_MAX_AGE = timedelta(hours=24)
-
-
-@dataclass(slots=True)
-class PendingPayment:
- """Normalized representation of a provider specific payment entry."""
-
- method: PaymentMethod
- local_id: int
- identifier: str
- amount_kopeks: int
- status: str
- is_paid: bool
- created_at: datetime
- user: User
- payment: Any
- expires_at: Optional[datetime] = None
-
- def is_recent(self, max_age: timedelta = PENDING_MAX_AGE) -> bool:
- return (datetime.utcnow() - self.created_at) <= max_age
-
-
-SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
- {
- PaymentMethod.YOOKASSA,
- PaymentMethod.MULENPAY,
- PaymentMethod.PAL24,
- PaymentMethod.WATA,
- PaymentMethod.HELEKET,
- PaymentMethod.CRYPTOBOT,
- }
-)
-
-
-SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
- {
- PaymentMethod.YOOKASSA,
- PaymentMethod.MULENPAY,
- PaymentMethod.PAL24,
- PaymentMethod.WATA,
- PaymentMethod.CRYPTOBOT,
- }
-)
-
-
-def method_display_name(method: PaymentMethod) -> str:
- if method == PaymentMethod.MULENPAY:
- return settings.get_mulenpay_display_name()
- if method == PaymentMethod.PAL24:
- return "PayPalych"
- if method == PaymentMethod.YOOKASSA:
- return "YooKassa"
- if method == PaymentMethod.WATA:
- return "WATA"
- if method == PaymentMethod.CRYPTOBOT:
- return "CryptoBot"
- if method == PaymentMethod.HELEKET:
- return "Heleket"
- if method == PaymentMethod.TELEGRAM_STARS:
- return "Telegram Stars"
- return method.value
-
-
-def _method_is_enabled(method: PaymentMethod) -> bool:
- if method == PaymentMethod.YOOKASSA:
- return settings.is_yookassa_enabled()
- if method == PaymentMethod.MULENPAY:
- return settings.is_mulenpay_enabled()
- if method == PaymentMethod.PAL24:
- return settings.is_pal24_enabled()
- if method == PaymentMethod.WATA:
- return settings.is_wata_enabled()
- if method == PaymentMethod.CRYPTOBOT:
- return settings.is_cryptobot_enabled()
- if method == PaymentMethod.HELEKET:
- return settings.is_heleket_enabled()
- return False
-
-
-def get_enabled_auto_methods() -> List[PaymentMethod]:
- return [
- method
- for method in SUPPORTED_AUTO_CHECK_METHODS
- if _method_is_enabled(method)
- ]
-
-
-class AutoPaymentVerificationService:
- """Background checker that periodically refreshes pending payments."""
-
- def __init__(self) -> None:
- self._task: Optional[asyncio.Task[None]] = None
- self._payment_service: Optional["PaymentService"] = None
-
- def set_payment_service(self, payment_service: "PaymentService") -> None:
- self._payment_service = payment_service
-
- def is_running(self) -> bool:
- return self._task is not None and not self._task.done()
-
- async def start(self) -> None:
- await self.stop()
-
- if not settings.is_payment_verification_auto_check_enabled():
- logger.info("Автопроверка пополнений отключена настройками")
- return
-
- if not self._payment_service:
- logger.warning(
- "Автопроверка пополнений не запущена: PaymentService не инициализирован"
- )
- return
-
- methods = get_enabled_auto_methods()
- if not methods:
- logger.info(
- "Автопроверка пополнений не запущена: нет активных провайдеров"
- )
- return
-
- display_names = ", ".join(
- sorted(method_display_name(method) for method in methods)
- )
- interval_minutes = settings.get_payment_verification_auto_check_interval()
-
- self._task = asyncio.create_task(self._auto_check_loop())
- logger.info(
- "🔄 Автопроверка пополнений запущена (каждые %s мин) для: %s",
- interval_minutes,
- display_names,
- )
-
- async def stop(self) -> None:
- if self._task and not self._task.done():
- self._task.cancel()
- try:
- await self._task
- except asyncio.CancelledError:
- pass
- self._task = None
-
- async def _auto_check_loop(self) -> None:
- try:
- while True:
- interval_minutes = settings.get_payment_verification_auto_check_interval()
- try:
- if (
- settings.is_payment_verification_auto_check_enabled()
- and self._payment_service
- ):
- methods = get_enabled_auto_methods()
- if methods:
- await self._run_checks(methods)
- else:
- logger.debug(
- "Автопроверка пополнений: активных провайдеров нет"
- )
- else:
- logger.debug(
- "Автопроверка пополнений: отключена настройками или сервис не готов"
- )
- except asyncio.CancelledError:
- raise
- except Exception as error: # noqa: BLE001 - логируем непредвиденные ошибки
- logger.error(
- "Ошибка автопроверки пополнений: %s",
- error,
- exc_info=True,
- )
-
- await asyncio.sleep(max(1, interval_minutes) * 60)
- except asyncio.CancelledError:
- logger.info("Автопроверка пополнений остановлена")
- raise
-
- async def _run_checks(self, methods: List[PaymentMethod]) -> None:
- if not self._payment_service:
- return
-
- async with AsyncSessionLocal() as session:
- try:
- pending = await list_recent_pending_payments(session)
- candidates = [
- record
- for record in pending
- if record.method in methods and not record.is_paid
- ]
-
- if not candidates:
- logger.debug(
- "Автопроверка пополнений: подходящих ожидающих платежей нет"
- )
- return
-
- counts = Counter(record.method for record in candidates)
- summary = ", ".join(
- f"{method_display_name(method)}: {count}"
- for method, count in sorted(
- counts.items(), key=lambda item: method_display_name(item[0])
- )
- )
- logger.info(
- "🔄 Автопроверка пополнений: найдено %s инвойсов (%s)",
- len(candidates),
- summary,
- )
-
- for record in candidates:
- refreshed = await run_manual_check(
- session,
- record.method,
- record.local_id,
- self._payment_service,
- )
-
- if not refreshed:
- logger.debug(
- "Автопроверка пополнений: не удалось обновить %s %s",
- method_display_name(record.method),
- record.identifier,
- )
- continue
-
- if refreshed.is_paid and not record.is_paid:
- logger.info(
- "✅ %s %s отмечен как оплаченный после автопроверки",
- method_display_name(refreshed.method),
- refreshed.identifier,
- )
- elif refreshed.status != record.status:
- logger.info(
- "ℹ️ %s %s обновлён: %s → %s",
- method_display_name(refreshed.method),
- refreshed.identifier,
- record.status or "—",
- refreshed.status or "—",
- )
- else:
- logger.debug(
- "Автопроверка пополнений: %s %s без изменений (%s)",
- method_display_name(refreshed.method),
- refreshed.identifier,
- refreshed.status or "—",
- )
-
- if session.in_transaction():
- await session.commit()
- except Exception:
- if session.in_transaction():
- await session.rollback()
- raise
-
-
-auto_payment_verification_service = AutoPaymentVerificationService()
-
-def _is_pal24_pending(payment: Pal24Payment) -> bool:
- if payment.is_paid:
- return False
- status = (payment.status or "").upper()
- return status in {"NEW", "PROCESS"}
-
-
-def _is_mulenpay_pending(payment: MulenPayPayment) -> bool:
- if payment.is_paid:
- return False
- status = (payment.status or "").lower()
- return status in {"created", "processing", "hold"}
-
-
-def _is_wata_pending(payment: WataPayment) -> bool:
- if payment.is_paid:
- return False
- status = (payment.status or "").lower()
- return status not in {
- "paid",
- "closed",
- "declined",
- "canceled",
- "cancelled",
- "expired",
- }
-
-
-def _is_heleket_pending(payment: HeleketPayment) -> bool:
- if payment.is_paid:
- return False
- status = (payment.status or "").lower()
- return status not in {"paid", "paid_over", "cancel", "canceled", "failed", "fail", "expired"}
-
-
-def _is_yookassa_pending(payment: YooKassaPayment) -> bool:
- if getattr(payment, "is_paid", False) and payment.status == "succeeded":
- return False
- status = (payment.status or "").lower()
- return status in {"pending", "waiting_for_capture"}
-
-
-def _is_cryptobot_pending(payment: CryptoBotPayment) -> bool:
- status = (payment.status or "").lower()
- return status == "active"
-
-
-def _parse_cryptobot_amount_kopeks(payment: CryptoBotPayment) -> int:
- payload = payment.payload or ""
- match = re.search(r"_(\d+)$", payload)
- if match:
- try:
- return int(match.group(1))
- except ValueError:
- return 0
- return 0
-
-
-def _metadata_is_balance(payment: YooKassaPayment) -> bool:
- metadata = getattr(payment, "metadata_json", {}) or {}
- payment_type = str(metadata.get("type") or metadata.get("payment_type") or "").lower()
- return payment_type.startswith("balance_topup")
-
-
-def _build_record(method: PaymentMethod, payment: Any, *, identifier: str, amount_kopeks: int,
- status: str, is_paid: bool, expires_at: Optional[datetime] = None) -> Optional[PendingPayment]:
- user = getattr(payment, "user", None)
- if user is None:
- logger.debug("Skipping %s payment %s without linked user", method.value, identifier)
- return None
-
- created_at = getattr(payment, "created_at", None)
- if not isinstance(created_at, datetime):
- logger.debug("Skipping %s payment %s without valid created_at", method.value, identifier)
- return None
-
- local_id = getattr(payment, "id", None)
- if local_id is None:
- logger.debug("Skipping %s payment without local id", method.value)
- return None
-
- return PendingPayment(
- method=method,
- local_id=int(local_id),
- identifier=identifier,
- amount_kopeks=amount_kopeks,
- status=status,
- is_paid=is_paid,
- created_at=created_at,
- user=user,
- payment=payment,
- expires_at=expires_at,
- )
-
-
-async def _fetch_pal24_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
- stmt = (
- select(Pal24Payment)
- .options(selectinload(Pal24Payment.user))
- .where(Pal24Payment.created_at >= cutoff)
- .order_by(desc(Pal24Payment.created_at))
- )
- result = await db.execute(stmt)
- records: List[PendingPayment] = []
- for payment in result.scalars().all():
- if not _is_pal24_pending(payment):
- continue
- record = _build_record(
- PaymentMethod.PAL24,
- payment,
- identifier=payment.bill_id,
- amount_kopeks=payment.amount_kopeks,
- status=payment.status or "",
- is_paid=bool(payment.is_paid),
- expires_at=getattr(payment, "expires_at", None),
- )
- if record:
- records.append(record)
- return records
-
-
-async def _fetch_mulenpay_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
- stmt = (
- select(MulenPayPayment)
- .options(selectinload(MulenPayPayment.user))
- .where(MulenPayPayment.created_at >= cutoff)
- .order_by(desc(MulenPayPayment.created_at))
- )
- result = await db.execute(stmt)
- records: List[PendingPayment] = []
- for payment in result.scalars().all():
- if not _is_mulenpay_pending(payment):
- continue
- record = _build_record(
- PaymentMethod.MULENPAY,
- payment,
- identifier=payment.uuid,
- amount_kopeks=payment.amount_kopeks,
- status=payment.status or "",
- is_paid=bool(payment.is_paid),
- )
- if record:
- records.append(record)
- return records
-
-
-async def _fetch_wata_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
- stmt = (
- select(WataPayment)
- .options(selectinload(WataPayment.user))
- .where(WataPayment.created_at >= cutoff)
- .order_by(desc(WataPayment.created_at))
- )
- result = await db.execute(stmt)
- records: List[PendingPayment] = []
- for payment in result.scalars().all():
- if not _is_wata_pending(payment):
- continue
- record = _build_record(
- PaymentMethod.WATA,
- payment,
- identifier=payment.payment_link_id,
- amount_kopeks=payment.amount_kopeks,
- status=payment.status or "",
- is_paid=bool(payment.is_paid),
- expires_at=getattr(payment, "expires_at", None),
- )
- if record:
- records.append(record)
- return records
-
-
-async def _fetch_heleket_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
- stmt = (
- select(HeleketPayment)
- .options(selectinload(HeleketPayment.user))
- .where(HeleketPayment.created_at >= cutoff)
- .order_by(desc(HeleketPayment.created_at))
- )
- result = await db.execute(stmt)
- records: List[PendingPayment] = []
- for payment in result.scalars().all():
- if not _is_heleket_pending(payment):
- continue
- record = _build_record(
- PaymentMethod.HELEKET,
- payment,
- identifier=payment.uuid,
- amount_kopeks=payment.amount_kopeks,
- status=payment.status or "",
- is_paid=bool(payment.is_paid),
- expires_at=getattr(payment, "expires_at", None),
- )
- if record:
- records.append(record)
- return records
-
-
-async def _fetch_yookassa_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
- stmt = (
- select(YooKassaPayment)
- .options(selectinload(YooKassaPayment.user))
- .where(YooKassaPayment.created_at >= cutoff)
- .order_by(desc(YooKassaPayment.created_at))
- )
- result = await db.execute(stmt)
- records: List[PendingPayment] = []
- for payment in result.scalars().all():
- if payment.transaction_id:
- continue
- if not _metadata_is_balance(payment):
- continue
- if not _is_yookassa_pending(payment):
- continue
- record = _build_record(
- PaymentMethod.YOOKASSA,
- payment,
- identifier=payment.yookassa_payment_id,
- amount_kopeks=payment.amount_kopeks,
- status=payment.status or "",
- is_paid=bool(getattr(payment, "is_paid", False)),
- )
- if record:
- records.append(record)
- return records
-
-
-async def _fetch_cryptobot_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
- stmt = (
- select(CryptoBotPayment)
- .options(selectinload(CryptoBotPayment.user))
- .where(CryptoBotPayment.created_at >= cutoff)
- .order_by(desc(CryptoBotPayment.created_at))
- )
- result = await db.execute(stmt)
- records: List[PendingPayment] = []
- for payment in result.scalars().all():
- status = (payment.status or "").lower()
- if not _is_cryptobot_pending(payment) and status != "paid":
- continue
- amount_kopeks = _parse_cryptobot_amount_kopeks(payment)
- record = _build_record(
- PaymentMethod.CRYPTOBOT,
- payment,
- identifier=payment.invoice_id,
- amount_kopeks=amount_kopeks,
- status=payment.status or "",
- is_paid=bool(payment.is_paid),
- )
- if record:
- records.append(record)
- return records
-
-
-async def _fetch_stars_transactions(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
- stmt = (
- select(Transaction)
- .options(selectinload(Transaction.user))
- .where(
- Transaction.created_at >= cutoff,
- Transaction.type == TransactionType.DEPOSIT.value,
- Transaction.payment_method == PaymentMethod.TELEGRAM_STARS.value,
- )
- .order_by(desc(Transaction.created_at))
- )
- result = await db.execute(stmt)
- records: List[PendingPayment] = []
- for transaction in result.scalars().all():
- record = _build_record(
- PaymentMethod.TELEGRAM_STARS,
- transaction,
- identifier=transaction.external_id or str(transaction.id),
- amount_kopeks=transaction.amount_kopeks,
- status="paid" if transaction.is_completed else "pending",
- is_paid=bool(transaction.is_completed),
- )
- if record:
- records.append(record)
- return records
-
-
-async def list_recent_pending_payments(
- db: AsyncSession,
- *,
- max_age: timedelta = PENDING_MAX_AGE,
-) -> List[PendingPayment]:
- """Return pending payments (top-ups) from supported providers within the age window."""
-
- cutoff = datetime.utcnow() - max_age
-
- tasks: Iterable[List[PendingPayment]] = (
- await _fetch_yookassa_payments(db, cutoff),
- await _fetch_pal24_payments(db, cutoff),
- await _fetch_mulenpay_payments(db, cutoff),
- await _fetch_wata_payments(db, cutoff),
- await _fetch_heleket_payments(db, cutoff),
- await _fetch_cryptobot_payments(db, cutoff),
- await _fetch_stars_transactions(db, cutoff),
- )
-
- records: List[PendingPayment] = []
- for batch in tasks:
- records.extend(batch)
-
- records.sort(key=lambda item: item.created_at, reverse=True)
- return records
-
-
-async def get_payment_record(
- db: AsyncSession,
- method: PaymentMethod,
- local_payment_id: int,
-) -> Optional[PendingPayment]:
- """Load single payment record and normalize it to :class:`PendingPayment`."""
-
- cutoff = datetime.utcnow() - PENDING_MAX_AGE
-
- if method == PaymentMethod.PAL24:
- payment = await db.get(Pal24Payment, local_payment_id)
- if not payment:
- return None
- await db.refresh(payment, attribute_names=["user"])
- return _build_record(
- method,
- payment,
- identifier=payment.bill_id,
- amount_kopeks=payment.amount_kopeks,
- status=payment.status or "",
- is_paid=bool(payment.is_paid),
- expires_at=getattr(payment, "expires_at", None),
- )
-
- if method == PaymentMethod.MULENPAY:
- payment = await db.get(MulenPayPayment, local_payment_id)
- if not payment:
- return None
- await db.refresh(payment, attribute_names=["user"])
- return _build_record(
- method,
- payment,
- identifier=payment.uuid,
- amount_kopeks=payment.amount_kopeks,
- status=payment.status or "",
- is_paid=bool(payment.is_paid),
- )
-
- if method == PaymentMethod.WATA:
- payment = await db.get(WataPayment, local_payment_id)
- if not payment:
- return None
- await db.refresh(payment, attribute_names=["user"])
- return _build_record(
- method,
- payment,
- identifier=payment.payment_link_id,
- amount_kopeks=payment.amount_kopeks,
- status=payment.status or "",
- is_paid=bool(payment.is_paid),
- expires_at=getattr(payment, "expires_at", None),
- )
-
- if method == PaymentMethod.HELEKET:
- payment = await db.get(HeleketPayment, local_payment_id)
- if not payment:
- return None
- await db.refresh(payment, attribute_names=["user"])
- return _build_record(
- method,
- payment,
- identifier=payment.uuid,
- amount_kopeks=payment.amount_kopeks,
- status=payment.status or "",
- is_paid=bool(payment.is_paid),
- expires_at=getattr(payment, "expires_at", None),
- )
-
- if method == PaymentMethod.YOOKASSA:
- payment = await db.get(YooKassaPayment, local_payment_id)
- if not payment:
- return None
- await db.refresh(payment, attribute_names=["user"])
- if payment.created_at < cutoff:
- logger.debug("YooKassa payment %s is older than cutoff", payment.id)
- return _build_record(
- method,
- payment,
- identifier=payment.yookassa_payment_id,
- amount_kopeks=payment.amount_kopeks,
- status=payment.status or "",
- is_paid=bool(getattr(payment, "is_paid", False)),
- )
-
- if method == PaymentMethod.CRYPTOBOT:
- payment = await db.get(CryptoBotPayment, local_payment_id)
- if not payment:
- return None
- await db.refresh(payment, attribute_names=["user"])
- amount_kopeks = _parse_cryptobot_amount_kopeks(payment)
- return _build_record(
- method,
- payment,
- identifier=payment.invoice_id,
- amount_kopeks=amount_kopeks,
- status=payment.status or "",
- is_paid=bool(payment.is_paid),
- )
-
- if method == PaymentMethod.TELEGRAM_STARS:
- transaction = await db.get(Transaction, local_payment_id)
- if not transaction:
- return None
- await db.refresh(transaction, attribute_names=["user"])
- if transaction.payment_method != PaymentMethod.TELEGRAM_STARS.value:
- return None
- return _build_record(
- method,
- transaction,
- identifier=transaction.external_id or str(transaction.id),
- amount_kopeks=transaction.amount_kopeks,
- status="paid" if transaction.is_completed else "pending",
- is_paid=bool(transaction.is_completed),
- )
-
- logger.debug("Unsupported payment method requested: %s", method)
- return None
-
-
-async def run_manual_check(
- db: AsyncSession,
- method: PaymentMethod,
- local_payment_id: int,
- payment_service: "PaymentService",
-) -> Optional[PendingPayment]:
- """Trigger provider specific status refresh and return the updated record."""
-
- try:
- if method == PaymentMethod.PAL24:
- result = await payment_service.get_pal24_payment_status(db, local_payment_id)
- payment = result.get("payment") if result else None
- elif method == PaymentMethod.MULENPAY:
- result = await payment_service.get_mulenpay_payment_status(db, local_payment_id)
- payment = result.get("payment") if result else None
- elif method == PaymentMethod.WATA:
- result = await payment_service.get_wata_payment_status(db, local_payment_id)
- payment = result.get("payment") if result else None
- elif method == PaymentMethod.HELEKET:
- payment = await payment_service.sync_heleket_payment_status(
- db, local_payment_id=local_payment_id
- )
- elif method == PaymentMethod.YOOKASSA:
- result = await payment_service.get_yookassa_payment_status(db, local_payment_id)
- payment = result.get("payment") if result else None
- elif method == PaymentMethod.CRYPTOBOT:
- result = await payment_service.get_cryptobot_payment_status(db, local_payment_id)
- payment = result.get("payment") if result else None
- else:
- logger.warning("Manual check requested for unsupported method %s", method)
- return None
-
- if not payment:
- return None
-
- return await get_payment_record(db, method, local_payment_id)
-
- except Exception as error: # pragma: no cover - defensive logging
- logger.error(
- "Manual status check failed for %s payment %s: %s",
- method.value,
- local_payment_id,
- error,
- exc_info=True,
- )
- return None
-
-
-if TYPE_CHECKING: # pragma: no cover
- from app.services.payment_service import PaymentService
-
diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py
index 673a8cc0..0041e4bc 100644
--- a/app/services/system_settings_service.py
+++ b/app/services/system_settings_service.py
@@ -72,7 +72,6 @@ class BotConfigurationService:
"LOCALIZATION": "🌍 Языки интерфейса",
"CHANNEL": "📣 Обязательная подписка",
"PAYMENT": "💳 Общие платежные настройки",
- "PAYMENT_VERIFICATION": "🕵️ Проверка платежей",
"TELEGRAM": "⭐ Telegram Stars",
"CRYPTOBOT": "🪙 CryptoBot",
"HELEKET": "🪙 Heleket",
@@ -125,7 +124,6 @@ class BotConfigurationService:
"LOCALIZATION": "Доступные языки, локализация интерфейса и выбор языка.",
"CHANNEL": "Настройки обязательной подписки на канал или группу.",
"PAYMENT": "Общие тексты платежей, описания чеков и шаблоны.",
- "PAYMENT_VERIFICATION": "Автоматическая проверка пополнений и интервал выполнения.",
"YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.",
"CRYPTOBOT": "CryptoBot и криптоплатежи через Telegram.",
"HELEKET": "Heleket: криптоплатежи, ключи мерчанта и вебхуки.",
@@ -294,7 +292,6 @@ class BotConfigurationService:
"MULENPAY_": "MULENPAY",
"PAL24_": "PAL24",
"PAYMENT_": "PAYMENT",
- "PAYMENT_VERIFICATION_": "PAYMENT_VERIFICATION",
"WATA_": "WATA",
"EXTERNAL_ADMIN_": "EXTERNAL_ADMIN",
"SIMPLE_SUBSCRIPTION_": "SIMPLE_SUBSCRIPTION",
@@ -456,24 +453,6 @@ class BotConfigurationService:
"warning": "Пустой токен или неверный вебхук приведут к отказам платежей.",
"dependencies": "CRYPTOBOT_API_TOKEN, CRYPTOBOT_WEBHOOK_SECRET",
},
- "PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED": {
- "description": (
- "Запускает фоновую проверку ожидающих пополнений и повторно обращается "
- "к платёжным провайдерам без участия администратора."
- ),
- "format": "Булево значение.",
- "example": "Включено, чтобы автоматически перепроверять зависшие платежи.",
- "warning": "Требует активных интеграций YooKassa, {mulenpay_name}, PayPalych, WATA или CryptoBot.",
- },
- "PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES": {
- "description": (
- "Интервал между автоматическими проверками ожидающих пополнений в минутах."
- ),
- "format": "Целое число не меньше 1.",
- "example": "10",
- "warning": "Слишком малый интервал может привести к частым обращениям к платёжным API.",
- "dependencies": "PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED",
- },
"SUPPORT_TICKET_SLA_MINUTES": {
"description": "Лимит времени для ответа модераторов на тикет в минутах.",
"format": "Целое число от 1 до 1440.",
diff --git a/main.py b/main.py
index f8a68603..30508b0e 100644
--- a/main.py
+++ b/main.py
@@ -13,14 +13,6 @@ from app.database.database import init_db
from app.services.monitoring_service import monitoring_service
from app.services.maintenance_service import maintenance_service
from app.services.payment_service import PaymentService
-from app.services.payment_verification_service import (
- PENDING_MAX_AGE,
- SUPPORTED_MANUAL_CHECK_METHODS,
- auto_payment_verification_service,
- get_enabled_auto_methods,
- method_display_name,
-)
-from app.database.models import PaymentMethod
from app.services.version_service import version_service
from app.external.webhook_server import WebhookServer
from app.external.heleket_webhook import start_heleket_webhook_server
@@ -222,67 +214,6 @@ async def main():
logger.error(f"❌ Ошибка запуска автосинхронизации RemnaWave: {e}")
payment_service = PaymentService(bot)
- auto_payment_verification_service.set_payment_service(payment_service)
-
- verification_providers: list[str] = []
- auto_verification_active = False
- async with timeline.stage(
- "Сервис проверки пополнений",
- "💳",
- success_message="Ручная проверка активна",
- ) as stage:
- for method in SUPPORTED_MANUAL_CHECK_METHODS:
- if method == PaymentMethod.YOOKASSA and settings.is_yookassa_enabled():
- verification_providers.append("YooKassa")
- elif method == PaymentMethod.MULENPAY and settings.is_mulenpay_enabled():
- verification_providers.append(settings.get_mulenpay_display_name())
- elif method == PaymentMethod.PAL24 and settings.is_pal24_enabled():
- verification_providers.append("PayPalych")
- elif method == PaymentMethod.WATA and settings.is_wata_enabled():
- verification_providers.append("WATA")
- elif method == PaymentMethod.HELEKET and settings.is_heleket_enabled():
- verification_providers.append("Heleket")
- elif method == PaymentMethod.CRYPTOBOT and settings.is_cryptobot_enabled():
- verification_providers.append("CryptoBot")
-
- if verification_providers:
- hours = int(PENDING_MAX_AGE.total_seconds() // 3600)
- stage.log(
- "Ожидающие пополнения автоматически отбираются не старше "
- f"{hours}ч"
- )
- stage.log(
- "Доступна ручная проверка для: "
- + ", ".join(sorted(verification_providers))
- )
- stage.success(
- f"Активно провайдеров: {len(verification_providers)}"
- )
- else:
- stage.skip("Нет активных провайдеров для ручной проверки")
-
- if settings.is_payment_verification_auto_check_enabled():
- auto_methods = get_enabled_auto_methods()
- if auto_methods:
- interval_minutes = settings.get_payment_verification_auto_check_interval()
- auto_labels = ", ".join(
- sorted(method_display_name(method) for method in auto_methods)
- )
- stage.log(
- "Автопроверка каждые "
- f"{interval_minutes} мин: {auto_labels}"
- )
- else:
- stage.log(
- "Автопроверка включена, но нет активных провайдеров"
- )
- else:
- stage.log("Автопроверка отключена настройками")
-
- await auto_payment_verification_service.start()
- auto_verification_active = auto_payment_verification_service.is_running()
- if auto_verification_active:
- stage.log("Фоновая автопроверка запущена")
async with timeline.stage(
"Внешняя админка",
@@ -492,18 +423,6 @@ async def main():
f"Проверка версий: {'Включен' if version_check_task else 'Отключен'}",
f"Отчеты: {'Включен' if reporting_service.is_running() else 'Отключен'}",
]
- services_lines.append(
- "Проверка пополнений: "
- + ("Включена" if verification_providers else "Отключена")
- )
- services_lines.append(
- "Автопроверка пополнений: "
- + (
- "Включена"
- if auto_payment_verification_service.is_running()
- else "Отключена"
- )
- )
timeline.log_section("Активные фоновые сервисы", services_lines, icon="📄")
timeline.log_summary()
@@ -565,14 +484,7 @@ async def main():
if settings.is_version_check_enabled():
logger.info("🔄 Перезапуск сервиса проверки версий...")
version_check_task = asyncio.create_task(version_service.start_periodic_check())
-
- if auto_verification_active and not auto_payment_verification_service.is_running():
- logger.warning(
- "Сервис автопроверки пополнений остановился, пробуем перезапустить..."
- )
- await auto_payment_verification_service.start()
- auto_verification_active = auto_payment_verification_service.is_running()
-
+
if polling_task.done():
exception = polling_task.exception()
if exception:
@@ -591,15 +503,7 @@ async def main():
timeline.log_summary()
summary_logged = True
logger.info("🛑 Начинается корректное завершение работы...")
-
- logger.info("ℹ️ Остановка сервиса автопроверки пополнений...")
- try:
- await auto_payment_verification_service.stop()
- except Exception as error:
- logger.error(
- f"Ошибка остановки сервиса автопроверки пополнений: {error}"
- )
-
+
if yookassa_server_task and not yookassa_server_task.done():
logger.info("ℹ️ Остановка YooKassa webhook сервера...")
yookassa_server_task.cancel()
diff --git a/tests/services/test_payment_service_heleket.py b/tests/services/test_payment_service_heleket.py
index 80f1f1fd..26c58100 100644
--- a/tests/services/test_payment_service_heleket.py
+++ b/tests/services/test_payment_service_heleket.py
@@ -50,8 +50,6 @@ class StubHeleketService:
self.info_response = info_response
self.calls: list[Dict[str, Any]] = []
self.info_calls: list[Dict[str, Optional[str]]] = []
- self.list_response: Optional[Dict[str, Any]] = None
- self.list_calls: list[Dict[str, Optional[str]]] = []
async def create_payment(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
self.calls.append(payload)
@@ -66,18 +64,6 @@ class StubHeleketService:
self.info_calls.append({"uuid": uuid, "order_id": order_id})
return self.info_response
- async def list_payments(
- self,
- *,
- date_from: Optional[str] = None,
- date_to: Optional[str] = None,
- cursor: Optional[str] = None,
- ) -> Optional[Dict[str, Any]]:
- self.list_calls.append(
- {"date_from": date_from, "date_to": date_to, "cursor": cursor}
- )
- return self.list_response
-
def _make_service(stub: Optional[StubHeleketService]) -> PaymentService:
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
@@ -263,53 +249,3 @@ async def test_sync_heleket_payment_status_without_response(monkeypatch: pytest.
assert result is payment
assert stub.info_calls == [{"uuid": payment.uuid, "order_id": payment.order_id}]
- assert stub.list_calls # fallback to history should be attempted
-
-
-@pytest.mark.anyio("asyncio")
-async def test_sync_heleket_payment_status_history_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
- stub = StubHeleketService(response=None, info_response=None)
- stub.list_response = {
- "state": 0,
- "result": {
- "items": [
- {
- "uuid": "heleket-uuid",
- "order_id": "order-123",
- "status": "paid",
- "payment_amount": "150.00",
- }
- ],
- "paginate": {"nextCursor": None},
- },
- }
- service = _make_service(stub)
- db = DummySession()
-
- payment = SimpleNamespace(
- id=77,
- uuid="heleket-uuid",
- order_id="order-123",
- status="check",
- user_id=8,
- )
-
- async def fake_get_by_id(db, payment_id):
- assert payment_id == payment.id
- return payment
-
- captured: Dict[str, Any] = {}
-
- async def fake_process(self, db, payload, *, metadata_key):
- captured["payload"] = payload
- captured["metadata_key"] = metadata_key
- return SimpleNamespace(**payload)
-
- monkeypatch.setattr(heleket_crud, "get_heleket_payment_by_id", fake_get_by_id, raising=False)
- monkeypatch.setattr(PaymentService, "_process_heleket_payload", fake_process, raising=False)
-
- result = await service.sync_heleket_payment_status(db, local_payment_id=payment.id)
-
- assert result is not None
- assert captured["payload"]["status"] == "paid"
- assert stub.list_calls
diff --git a/tests/services/test_payment_service_pal24.py b/tests/services/test_payment_service_pal24.py
index 124553b9..02d4f91f 100644
--- a/tests/services/test_payment_service_pal24.py
+++ b/tests/services/test_payment_service_pal24.py
@@ -4,7 +4,6 @@ from pathlib import Path
from typing import Any, Dict, Optional
import sys
from datetime import datetime
-from types import SimpleNamespace
import pytest
@@ -35,12 +34,7 @@ class DummyLocalPayment:
class StubPal24Service:
- def __init__(
- self,
- *,
- configured: bool = True,
- response: Optional[Dict[str, Any]] = None,
- ) -> None:
+ def __init__(self, *, configured: bool = True, response: Optional[Dict[str, Any]] = None) -> None:
self.is_configured = configured
self.response = response or {
"success": True,
@@ -51,12 +45,6 @@ class StubPal24Service:
}
self.calls: list[Dict[str, Any]] = []
self.raise_error: Optional[Exception] = None
- self.status_response: Optional[Dict[str, Any]] = {"status": "NEW"}
- self.payment_status_response: Optional[Dict[str, Any]] = None
- self.bill_payments_response: Optional[Dict[str, Any]] = None
- self.status_calls: list[str] = []
- self.payment_status_calls: list[str] = []
- self.bill_payments_calls: list[str] = []
async def create_bill(self, **kwargs: Any) -> Dict[str, Any]:
self.calls.append(kwargs)
@@ -64,18 +52,6 @@ class StubPal24Service:
raise self.raise_error
return self.response
- async def get_bill_status(self, bill_id: str) -> Optional[Dict[str, Any]]:
- self.status_calls.append(bill_id)
- return self.status_response
-
- async def get_payment_status(self, payment_id: str) -> Optional[Dict[str, Any]]:
- self.payment_status_calls.append(payment_id)
- return self.payment_status_response
-
- async def get_bill_payments(self, bill_id: str) -> Optional[Dict[str, Any]]:
- self.bill_payments_calls.append(bill_id)
- return self.bill_payments_response
-
def _make_service(stub: Optional[StubPal24Service]) -> PaymentService:
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
@@ -222,110 +198,3 @@ async def test_create_pal24_payment_handles_api_errors(monkeypatch: pytest.Monke
language="ru",
)
assert result is None
-
-
-@pytest.mark.anyio("asyncio")
-async def test_get_pal24_payment_status_updates_from_remote(monkeypatch: pytest.MonkeyPatch) -> None:
- stub = StubPal24Service()
- stub.status_response = {"status": "SUCCESS"}
- stub.payment_status_response = {
- "success": True,
- "id": "PAY-1",
- "bill_id": "BILL-1",
- "status": "SUCCESS",
- "payment_method": "SBP",
- "account_amount": "700.00",
- "from_card": "676754******1234",
- }
- stub.bill_payments_response = {
- "data": [
- {
- "id": "PAY-1",
- "bill_id": "BILL-1",
- "status": "SUCCESS",
- "from_card": "676754******1234",
- "payment_method": "SBP",
- }
- ]
- }
-
- service = _make_service(stub)
- db = DummySession()
-
- payment = SimpleNamespace(
- id=99,
- bill_id="BILL-1",
- payment_id=None,
- payment_status="NEW",
- payment_method=None,
- balance_amount=None,
- balance_currency=None,
- payer_account=None,
- status="NEW",
- is_paid=False,
- paid_at=None,
- transaction_id=None,
- user_id=1,
- )
-
- async def fake_get_by_id(db: DummySession, payment_id: int) -> SimpleNamespace:
- assert payment_id == payment.id
- return payment
-
- async def fake_update_status(
- db: DummySession,
- payment_obj: SimpleNamespace,
- *,
- status: str,
- **kwargs: Any,
- ) -> SimpleNamespace:
- payment_obj.status = status
- payment_obj.last_status = status
- for key, value in kwargs.items():
- setattr(payment_obj, key, value)
- if "is_paid" in kwargs:
- payment_obj.is_paid = kwargs["is_paid"]
- await db.commit()
- return payment_obj
-
- async def fake_finalize(
- self: PaymentService,
- db: DummySession,
- payment_obj: Any,
- *,
- payment_id: Optional[str] = None,
- trigger: str,
- ) -> bool:
- return False
-
- monkeypatch.setattr(
- payment_service_module,
- "get_pal24_payment_by_id",
- fake_get_by_id,
- raising=False,
- )
- monkeypatch.setattr(
- payment_service_module,
- "update_pal24_payment_status",
- fake_update_status,
- raising=False,
- )
- monkeypatch.setattr(
- PaymentService,
- "_finalize_pal24_payment",
- fake_finalize,
- raising=False,
- )
-
- result = await service.get_pal24_payment_status(db, local_payment_id=payment.id)
-
- assert result is not None
- assert payment.status == "SUCCESS"
- assert payment.payment_id == "PAY-1"
- assert payment.payment_status == "SUCCESS"
- assert payment.payment_method == "sbp"
- assert payment.is_paid is True
- assert stub.status_calls == ["BILL-1"]
- assert stub.payment_status_calls in ([], ["PAY-1"])
- assert result["remote_status"] == "SUCCESS"
- assert result["remote_data"] and "bill_status" in result["remote_data"]
diff --git a/tests/services/test_payment_service_webhooks.py b/tests/services/test_payment_service_webhooks.py
index f003d765..8542fff3 100644
--- a/tests/services/test_payment_service_webhooks.py
+++ b/tests/services/test_payment_service_webhooks.py
@@ -6,7 +6,7 @@ from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace, ModuleType
-from typing import Any, Dict, Optional
+from typing import Any, Dict
import sys
import pytest
@@ -829,21 +829,6 @@ async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.Monkey
},
}
- async def get_payment_status(self, payment_id: str) -> Optional[Dict[str, Any]]:
- return None
-
- async def get_bill_payments(self, bill_id: str) -> Optional[Dict[str, Any]]:
- return {
- "data": [
- {
- "id": "trs-auto-1",
- "bill_id": bill_id,
- "status": "SUCCESS",
- "payment_method": "SBP",
- }
- ]
- }
-
service.pal24_service = DummyPal24Service()
fake_session = FakeSession()