Display transaction numbers in admin payment list

This commit is contained in:
Egor
2025-10-26 10:28:32 +03:00
parent 9727e07464
commit 5b59ca3c4f
22 changed files with 2524 additions and 121 deletions

View File

@@ -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)

View File

@@ -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"

View File

@@ -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')

View File

@@ -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 сервис не настроен, подпись пропускается")

View 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")

View File

@@ -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")]
])

View File

@@ -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",

View File

@@ -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": "🔄 Перезагрузить",

View File

@@ -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)

View File

@@ -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}

View File

@@ -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

View File

@@ -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,

View File

@@ -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 = {

View File

@@ -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

View File

@@ -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:

View File

@@ -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)

View 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

View File

@@ -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
View File

@@ -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()

View File

@@ -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

View File

@@ -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"]

View File

@@ -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()