mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 03:40:26 +00:00
Display transaction numbers in admin payment list
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
48
app/external/cryptobot.py
vendored
48
app/external/cryptobot.py
vendored
@@ -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')
|
||||
|
||||
31
app/external/heleket.py
vendored
31
app/external/heleket.py
vendored
@@ -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 сервис не настроен, подпись пропускается")
|
||||
|
||||
596
app/handlers/admin/payments.py
Normal file
596
app/handlers/admin/payments.py
Normal file
@@ -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)} (<code>{user.telegram_id}</code>)"
|
||||
|
||||
|
||||
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}. <b>{html.escape(method_name)}</b> — {amount}",
|
||||
f" {emoji} {status_text}",
|
||||
f" 🕒 {created} ({age})",
|
||||
_format_user_line(record.user),
|
||||
]
|
||||
|
||||
if identifier:
|
||||
lines.append(f" 🆔 ID: <code>{identifier}</code>")
|
||||
else:
|
||||
lines.append(f" 🆔 ID: <code>{display_number}</code>")
|
||||
|
||||
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", "💳 <b>Payment details</b>"),
|
||||
"",
|
||||
f"<b>{html.escape(method_name)}</b>",
|
||||
f"{emoji} {status_text}",
|
||||
"",
|
||||
f"💰 {texts.t('ADMIN_PAYMENT_AMOUNT', 'Amount')}: {amount}",
|
||||
f"🕒 {texts.t('ADMIN_PAYMENT_CREATED', 'Created')}: {created} ({age})",
|
||||
f"🆔 ID: <code>{identifier}</code>",
|
||||
_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", "💳 <b>Top-up verification</b>")
|
||||
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")
|
||||
@@ -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")]
|
||||
])
|
||||
|
||||
|
||||
@@ -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": "💳 <b>Top-up verification</b>",
|
||||
"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": "💳 <b>Payment details</b>",
|
||||
"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",
|
||||
|
||||
@@ -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": "💳 <b>Проверка пополнений</b>",
|
||||
"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": "💳 <b>Детали платежа</b>",
|
||||
"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": "🔄 Перезагрузить",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
767
app/services/payment_verification_service.py
Normal file
767
app/services/payment_verification_service.py
Normal file
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
100
main.py
100
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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user