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