Merge pull request #1507 from Fr1ngg/revert-1506-6evcye-bedolaga/add-auto-check-service-for-user-deposits

Revert "Add admin pending payment verification endpoints"
This commit is contained in:
Egor
2025-10-26 07:23:33 +03:00
committed by GitHub
10 changed files with 37 additions and 951 deletions
+7 -7
View File
@@ -136,21 +136,21 @@ async def get_user_cryptobot_payments(
async def get_pending_cryptobot_payments(
db: AsyncSession,
*,
max_age_hours: int = 24,
older_than_hours: int = 24
) -> List[CryptoBotPayment]:
cutoff_time = datetime.utcnow() - timedelta(hours=max_age_hours)
from datetime import timedelta
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
result = await db.execute(
select(CryptoBotPayment)
.options(selectinload(CryptoBotPayment.user))
.where(
and_(
CryptoBotPayment.status == "active",
CryptoBotPayment.created_at >= cutoff_time,
CryptoBotPayment.created_at < cutoff_time
)
)
.order_by(CryptoBotPayment.created_at.desc())
.order_by(CryptoBotPayment.created_at)
)
return result.scalars().all()
+2 -21
View File
@@ -1,6 +1,6 @@
import logging
from datetime import datetime, timedelta
from typing import Any, Dict, Optional, List
from datetime import datetime
from typing import Any, Dict, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -96,25 +96,6 @@ async def get_heleket_payment_by_id(
return result.scalar_one_or_none()
async def get_pending_heleket_payments(
db: AsyncSession,
*,
max_age_hours: int = 24,
) -> List[HeleketPayment]:
cutoff = datetime.utcnow() - timedelta(hours=max_age_hours)
result = await db.execute(
select(HeleketPayment)
.options(selectinload(HeleketPayment.user))
.where(
HeleketPayment.transaction_id.is_(None),
HeleketPayment.created_at >= cutoff,
)
.order_by(HeleketPayment.created_at.desc())
)
return result.scalars().all()
async def update_heleket_payment(
db: AsyncSession,
uuid: str,
+7 -30
View File
@@ -1,9 +1,9 @@
import logging
from datetime import datetime, timedelta
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.models import MulenPayPayment
@@ -56,9 +56,7 @@ async def get_mulenpay_payment_by_local_id(
db: AsyncSession, payment_id: int
) -> Optional[MulenPayPayment]:
result = await db.execute(
select(MulenPayPayment)
.options(selectinload(MulenPayPayment.user))
.where(MulenPayPayment.id == payment_id)
select(MulenPayPayment).where(MulenPayPayment.id == payment_id)
)
return result.scalar_one_or_none()
@@ -67,9 +65,7 @@ async def get_mulenpay_payment_by_uuid(
db: AsyncSession, uuid: str
) -> Optional[MulenPayPayment]:
result = await db.execute(
select(MulenPayPayment)
.options(selectinload(MulenPayPayment.user))
.where(MulenPayPayment.uuid == uuid)
select(MulenPayPayment).where(MulenPayPayment.uuid == uuid)
)
return result.scalar_one_or_none()
@@ -78,32 +74,13 @@ async def get_mulenpay_payment_by_mulen_id(
db: AsyncSession, mulen_payment_id: int
) -> Optional[MulenPayPayment]:
result = await db.execute(
select(MulenPayPayment)
.options(selectinload(MulenPayPayment.user))
.where(MulenPayPayment.mulen_payment_id == mulen_payment_id)
select(MulenPayPayment).where(
MulenPayPayment.mulen_payment_id == mulen_payment_id
)
)
return result.scalar_one_or_none()
async def get_pending_mulenpay_payments(
db: AsyncSession,
*,
max_age_hours: int = 24,
) -> list[MulenPayPayment]:
cutoff = datetime.utcnow() - timedelta(hours=max_age_hours)
result = await db.execute(
select(MulenPayPayment)
.options(selectinload(MulenPayPayment.user))
.where(
MulenPayPayment.is_paid.is_(False),
MulenPayPayment.created_at >= cutoff,
)
.order_by(MulenPayPayment.created_at.desc())
)
return result.scalars().all()
async def update_mulenpay_payment_status(
db: AsyncSession,
*,
+5 -31
View File
@@ -3,12 +3,11 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import Any, Dict, Optional, List
from datetime import datetime
from typing import Any, Dict, Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import Pal24Payment
@@ -63,50 +62,25 @@ async def create_pal24_payment(
async def get_pal24_payment_by_id(db: AsyncSession, payment_id: int) -> Optional[Pal24Payment]:
result = await db.execute(
select(Pal24Payment)
.options(selectinload(Pal24Payment.user))
.where(Pal24Payment.id == payment_id)
select(Pal24Payment).where(Pal24Payment.id == payment_id)
)
return result.scalar_one_or_none()
async def get_pal24_payment_by_bill_id(db: AsyncSession, bill_id: str) -> Optional[Pal24Payment]:
result = await db.execute(
select(Pal24Payment)
.options(selectinload(Pal24Payment.user))
.where(Pal24Payment.bill_id == bill_id)
select(Pal24Payment).where(Pal24Payment.bill_id == bill_id)
)
return result.scalar_one_or_none()
async def get_pal24_payment_by_order_id(db: AsyncSession, order_id: str) -> Optional[Pal24Payment]:
result = await db.execute(
select(Pal24Payment)
.options(selectinload(Pal24Payment.user))
.where(Pal24Payment.order_id == order_id)
select(Pal24Payment).where(Pal24Payment.order_id == order_id)
)
return result.scalar_one_or_none()
async def get_pending_pal24_payments(
db: AsyncSession,
*,
max_age_hours: int = 24,
) -> List[Pal24Payment]:
cutoff = datetime.utcnow() - timedelta(hours=max_age_hours)
result = await db.execute(
select(Pal24Payment)
.options(selectinload(Pal24Payment.user))
.where(
Pal24Payment.is_paid.is_(False),
Pal24Payment.created_at >= cutoff,
)
.order_by(Pal24Payment.created_at.desc())
)
return result.scalars().all()
async def update_pal24_payment_status(
db: AsyncSession,
payment: Pal24Payment,
+5 -31
View File
@@ -1,12 +1,11 @@
"""CRUD helpers for WATA payment records."""
import logging
from datetime import datetime, timedelta
from typing import Any, Dict, Optional, List
from datetime import datetime
from typing import Any, Dict, Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import WataPayment
@@ -68,9 +67,7 @@ async def get_wata_payment_by_id(
payment_id: int,
) -> Optional[WataPayment]:
result = await db.execute(
select(WataPayment)
.options(selectinload(WataPayment.user))
.where(WataPayment.id == payment_id)
select(WataPayment).where(WataPayment.id == payment_id)
)
return result.scalar_one_or_none()
@@ -80,9 +77,7 @@ async def get_wata_payment_by_link_id(
payment_link_id: str,
) -> Optional[WataPayment]:
result = await db.execute(
select(WataPayment)
.options(selectinload(WataPayment.user))
.where(WataPayment.payment_link_id == payment_link_id)
select(WataPayment).where(WataPayment.payment_link_id == payment_link_id)
)
return result.scalar_one_or_none()
@@ -92,32 +87,11 @@ async def get_wata_payment_by_order_id(
order_id: str,
) -> Optional[WataPayment]:
result = await db.execute(
select(WataPayment)
.options(selectinload(WataPayment.user))
.where(WataPayment.order_id == order_id)
select(WataPayment).where(WataPayment.order_id == order_id)
)
return result.scalar_one_or_none()
async def get_pending_wata_payments(
db: AsyncSession,
*,
max_age_hours: int = 24,
) -> List[WataPayment]:
cutoff = datetime.utcnow() - timedelta(hours=max_age_hours)
result = await db.execute(
select(WataPayment)
.options(selectinload(WataPayment.user))
.where(
WataPayment.is_paid.is_(False),
WataPayment.created_at >= cutoff,
)
.order_by(WataPayment.created_at.desc())
)
return result.scalars().all()
async def update_wata_payment_status(
db: AsyncSession,
payment: WataPayment,
+7 -15
View File
@@ -1,9 +1,8 @@
import logging
from datetime import datetime, timedelta
from typing import Optional, List
from sqlalchemy import select, update, and_
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, and_
from sqlalchemy.orm import selectinload
from app.database.models import YooKassaPayment, User, Transaction
@@ -164,22 +163,15 @@ async def get_user_yookassa_payments(
async def get_pending_yookassa_payments(
db: AsyncSession,
user_id: Optional[int] = None,
limit: int = 100,
max_age_hours: int = 24,
limit: int = 100
) -> List[YooKassaPayment]:
query = select(YooKassaPayment).options(selectinload(YooKassaPayment.user))
cutoff = datetime.utcnow() - timedelta(hours=max_age_hours)
conditions = [
YooKassaPayment.status.in_(["pending", "waiting_for_capture"]),
YooKassaPayment.is_paid.is_(False),
YooKassaPayment.created_at >= cutoff,
]
conditions = [YooKassaPayment.status.in_(["pending", "waiting_for_capture"])]
if user_id:
conditions.append(YooKassaPayment.user_id == user_id)
result = await db.execute(
query.where(and_(*conditions))
.order_by(YooKassaPayment.created_at.desc())
-71
View File
@@ -207,77 +207,6 @@ class YooKassaPaymentMixin:
logger.error("Ошибка создания платежа YooKassa СБП: %s", error)
return None
async def get_yookassa_payment_status(
self,
db: AsyncSession,
local_payment_id: int,
) -> Optional[Dict[str, Any]]:
"""Fetches the latest YooKassa status and synchronises the local record."""
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_info: Optional[Dict[str, Any]] = None
if getattr(self, "yookassa_service", None) and payment.yookassa_payment_id:
try:
remote_info = await self.yookassa_service.get_payment_info( # type: ignore[union-attr]
payment.yookassa_payment_id
)
except Exception as error: # pragma: no cover - failsafe
logger.error(
"Ошибка получения статуса платежа YooKassa %s: %s",
payment.yookassa_payment_id,
error,
exc_info=True,
)
remote_info = None
if remote_info:
captured_at_raw = remote_info.get("captured_at")
captured_at = None
if captured_at_raw:
try:
captured_at = datetime.fromisoformat(
str(captured_at_raw).replace("Z", "+00:00")
).replace(tzinfo=None)
except Exception:
captured_at = None
updated_payment = await payment_module.update_yookassa_payment_status(
db,
payment.yookassa_payment_id,
status=remote_info.get("status", payment.status),
is_paid=bool(remote_info.get("paid", payment.is_paid)),
is_captured=remote_info.get("status") == "succeeded"
and bool(remote_info.get("paid", payment.is_paid)),
captured_at=captured_at,
payment_method_type=remote_info.get("payment_method_type"),
)
if updated_payment:
payment = updated_payment
if payment.status == "succeeded" and payment.is_paid and not payment.transaction_id:
try:
await self._process_successful_yookassa_payment(db, payment)
payment = await payment_module.get_yookassa_payment_by_local_id(
db,
local_payment_id,
) or payment
except Exception as error: # pragma: no cover - defensive
logger.error(
"Ошибка обработки успешного платежа YooKassa %s при ручной проверке: %s",
payment.yookassa_payment_id,
error,
exc_info=True,
)
return {"payment": payment, "remote": remote_info}
async def _process_successful_yookassa_payment(
self,
db: AsyncSession,
-510
View File
@@ -1,510 +0,0 @@
"""Utilities for aggregating and manually checking pending top-up payments."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, Dict, Iterable, List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PaymentMethod
from app.database.crud import (
cryptobot as cryptobot_crud,
heleket as heleket_crud,
mulenpay as mulenpay_crud,
pal24 as pal24_crud,
wata as wata_crud,
yookassa as yookassa_crud,
)
from app.services.payment_service import PaymentService
logger = logging.getLogger(__name__)
class PendingPaymentError(Exception):
"""Base error for pending payment operations."""
class PendingPaymentNotFoundError(PendingPaymentError):
"""Raised when a pending payment cannot be located."""
class PendingPaymentTooOldError(PendingPaymentError):
"""Raised when a payment is older than the allowed interval."""
class PendingPaymentNotPendingError(PendingPaymentError):
"""Raised when a payment is no longer in a pending state."""
class PendingPaymentUnsupportedError(PendingPaymentError):
"""Raised when manual verification is not implemented for a provider."""
@dataclass
class _PendingPaymentEntry:
provider: PaymentMethod
payment: Any
class PendingPaymentService:
"""Aggregates pending payments and provides manual verification helpers."""
SUPPORTED_METHODS: tuple[PaymentMethod, ...] = (
PaymentMethod.YOOKASSA,
PaymentMethod.MULENPAY,
PaymentMethod.PAL24,
PaymentMethod.WATA,
PaymentMethod.HELEKET,
)
def __init__(self, bot: Any | None = None) -> None:
self._payment_service = PaymentService(bot)
async def list_pending_payments(
self,
db: AsyncSession,
*,
max_age_hours: int = 24,
provider: PaymentMethod | None = None,
) -> list[dict[str, Any]]:
entries = await self._collect_pending_entries(
db,
max_age_hours=max_age_hours,
provider=provider,
)
serialised = [
self._serialise(entry.provider, entry.payment)
for entry in entries
]
serialised.sort(
key=lambda item: item.get("created_at") or datetime.min,
reverse=True,
)
return serialised
async def get_payment(
self,
db: AsyncSession,
provider: PaymentMethod,
payment_id: int,
) -> dict[str, Any]:
payment = await self._fetch_payment(db, provider, payment_id)
if payment is None:
raise PendingPaymentNotFoundError
return self._serialise(provider, payment, include_details=True)
async def run_manual_check(
self,
db: AsyncSession,
provider: PaymentMethod,
payment_id: int,
*,
max_age_hours: int = 24,
) -> dict[str, Any]:
payment = await self._fetch_payment(db, provider, payment_id)
if payment is None:
raise PendingPaymentNotFoundError
if not self._is_pending(provider, payment):
raise PendingPaymentNotPendingError
if not self._is_within_age(payment, max_age_hours):
raise PendingPaymentTooOldError
status_before = getattr(payment, "status", None)
performed = await self._perform_manual_check(db, provider, payment_id)
refreshed = await self._fetch_payment(db, provider, payment_id)
if refreshed is None:
raise PendingPaymentNotFoundError
payload = self._serialise(provider, refreshed, include_details=True)
payload.update(
{
"check_performed": performed,
"status_before": status_before,
"status_after": payload.get("status"),
"completed": bool(payload.get("is_paid"))
or bool(payload.get("transaction_id")),
}
)
return payload
async def run_bulk_check(
self,
db: AsyncSession,
*,
max_age_hours: int = 24,
provider: PaymentMethod | None = None,
) -> dict[str, Any]:
entries = await self._collect_pending_entries(
db,
max_age_hours=max_age_hours,
provider=provider,
)
results: list[dict[str, Any]] = []
checked = 0
completed = 0
skipped = 0
for entry in entries:
try:
result = await self.run_manual_check(
db,
entry.provider,
entry.payment.id,
max_age_hours=max_age_hours,
)
except PendingPaymentUnsupportedError as error:
skipped += 1
logger.warning(
"Manual check is not supported for %s: %s",
entry.provider.value,
error,
)
continue
except PendingPaymentError as error:
skipped += 1
logger.warning(
"Skipping payment %s/%s due to %s",
entry.provider.value,
entry.payment.id,
error,
)
continue
checked += 1
if result.get("completed"):
completed += 1
results.append(result)
return {
"total": len(entries),
"checked": checked,
"completed": completed,
"skipped": skipped,
"results": results,
}
async def _collect_pending_entries(
self,
db: AsyncSession,
*,
max_age_hours: int,
provider: PaymentMethod | None,
) -> list[_PendingPaymentEntry]:
providers: Iterable[PaymentMethod]
if provider is None:
providers = self.SUPPORTED_METHODS
else:
providers = (provider,)
entries: list[_PendingPaymentEntry] = []
for method in providers:
payments = await self._fetch_pending_for_provider(
db,
method,
max_age_hours=max_age_hours,
)
for payment in payments:
if not self._is_pending(method, payment):
continue
if not self._is_within_age(payment, max_age_hours):
continue
entries.append(_PendingPaymentEntry(method, payment))
return entries
async def _fetch_pending_for_provider(
self,
db: AsyncSession,
method: PaymentMethod,
*,
max_age_hours: int,
) -> list[Any]:
if method == PaymentMethod.YOOKASSA:
return await yookassa_crud.get_pending_yookassa_payments(
db,
max_age_hours=max_age_hours,
)
if method == PaymentMethod.MULENPAY:
return await mulenpay_crud.get_pending_mulenpay_payments(
db,
max_age_hours=max_age_hours,
)
if method == PaymentMethod.PAL24:
return await pal24_crud.get_pending_pal24_payments(
db,
max_age_hours=max_age_hours,
)
if method == PaymentMethod.WATA:
return await wata_crud.get_pending_wata_payments(
db,
max_age_hours=max_age_hours,
)
if method == PaymentMethod.HELEKET:
return await heleket_crud.get_pending_heleket_payments(
db,
max_age_hours=max_age_hours,
)
return []
async def _fetch_payment(
self,
db: AsyncSession,
provider: PaymentMethod,
payment_id: int,
) -> Any | None:
if provider == PaymentMethod.YOOKASSA:
return await yookassa_crud.get_yookassa_payment_by_local_id(db, payment_id)
if provider == PaymentMethod.MULENPAY:
return await mulenpay_crud.get_mulenpay_payment_by_local_id(db, payment_id)
if provider == PaymentMethod.PAL24:
return await pal24_crud.get_pal24_payment_by_id(db, payment_id)
if provider == PaymentMethod.WATA:
return await wata_crud.get_wata_payment_by_id(db, payment_id)
if provider == PaymentMethod.HELEKET:
return await heleket_crud.get_heleket_payment_by_id(db, payment_id)
if provider == PaymentMethod.CRYPTOBOT:
return await cryptobot_crud.get_cryptobot_payment_by_id(db, payment_id)
return None
async def _perform_manual_check(
self,
db: AsyncSession,
provider: PaymentMethod,
payment_id: int,
) -> bool:
if provider == PaymentMethod.YOOKASSA:
result = await self._payment_service.get_yookassa_payment_status(
db,
payment_id,
)
return bool(result)
if provider == PaymentMethod.MULENPAY:
result = await self._payment_service.get_mulenpay_payment_status(
db,
payment_id,
)
return bool(result)
if provider == PaymentMethod.PAL24:
result = await self._payment_service.get_pal24_payment_status(
db,
payment_id,
)
return bool(result)
if provider == PaymentMethod.WATA:
result = await self._payment_service.get_wata_payment_status(
db,
payment_id,
)
return bool(result)
if provider == PaymentMethod.HELEKET:
result = await self._payment_service.sync_heleket_payment_status(
db,
local_payment_id=payment_id,
)
return bool(result)
raise PendingPaymentUnsupportedError(
f"Manual status check is not implemented for {provider.value}"
)
def _serialise(
self,
provider: PaymentMethod,
payment: Any,
*,
include_details: bool = False,
) -> dict[str, Any]:
amount_kopeks = int(getattr(payment, "amount_kopeks", 0) or 0)
currency = getattr(payment, "currency", None) or "RUB"
data: dict[str, Any] = {
"id": getattr(payment, "id", None),
"provider": provider.value,
"user": self._extract_user(payment),
"amount_kopeks": amount_kopeks,
"amount_rubles": round(amount_kopeks / 100, 2),
"currency": currency,
"status": getattr(payment, "status", None),
"is_paid": bool(getattr(payment, "is_paid", False)),
"description": getattr(payment, "description", None),
"payment_url": self._get_payment_url(provider, payment),
"external_id": self._get_external_id(provider, payment),
"transaction_id": getattr(payment, "transaction_id", None),
"created_at": getattr(payment, "created_at", None),
"updated_at": getattr(payment, "updated_at", None),
"expires_at": getattr(payment, "expires_at", None),
"is_pending": self._is_pending(provider, payment),
}
if include_details:
data["metadata"] = self._collect_metadata(provider, payment)
return data
def _collect_metadata(self, provider: PaymentMethod, payment: Any) -> dict[str, Any]:
metadata: dict[str, Any] = {}
base_metadata = getattr(payment, "metadata_json", None)
if base_metadata:
metadata["metadata_json"] = base_metadata
callback_payload = getattr(payment, "callback_payload", None)
if callback_payload:
metadata["callback_payload"] = callback_payload
if provider == PaymentMethod.YOOKASSA:
metadata.update(
{
"confirmation_url": getattr(payment, "confirmation_url", None),
"payment_method_type": getattr(payment, "payment_method_type", None),
"refundable": getattr(payment, "refundable", None),
"test_mode": getattr(payment, "test_mode", None),
}
)
elif provider == PaymentMethod.MULENPAY:
metadata.update(
{
"mulen_payment_id": getattr(payment, "mulen_payment_id", None),
}
)
elif provider == PaymentMethod.PAL24:
metadata.update(
{
"payment_id": getattr(payment, "payment_id", None),
"payment_status": getattr(payment, "payment_status", None),
"payment_method": getattr(payment, "payment_method", None),
"balance_amount": getattr(payment, "balance_amount", None),
"balance_currency": getattr(payment, "balance_currency", None),
"payer_account": getattr(payment, "payer_account", None),
"last_status": getattr(payment, "last_status", None),
}
)
elif provider == PaymentMethod.WATA:
metadata.update(
{
"terminal_public_id": getattr(payment, "terminal_public_id", None),
"last_status": getattr(payment, "last_status", None),
"success_redirect_url": getattr(payment, "success_redirect_url", None),
"fail_redirect_url": getattr(payment, "fail_redirect_url", None),
}
)
elif provider == PaymentMethod.HELEKET:
metadata.update(
{
"order_id": getattr(payment, "order_id", None),
"payer_amount": getattr(payment, "payer_amount", None),
"payer_currency": getattr(payment, "payer_currency", None),
"exchange_rate": getattr(payment, "exchange_rate", None),
"discount_percent": getattr(payment, "discount_percent", None),
}
)
# Remove empty values for cleanliness
cleaned = {
key: value
for key, value in metadata.items()
if value is not None and value != {}
}
return cleaned
def _get_payment_url(self, provider: PaymentMethod, payment: Any) -> Optional[str]:
if provider == PaymentMethod.YOOKASSA:
return getattr(payment, "confirmation_url", None)
if provider == PaymentMethod.MULENPAY:
return getattr(payment, "payment_url", None)
if provider == PaymentMethod.PAL24:
return (
getattr(payment, "link_url", None)
or getattr(payment, "link_page_url", None)
)
if provider == PaymentMethod.WATA:
return getattr(payment, "url", None)
if provider == PaymentMethod.HELEKET:
return getattr(payment, "payment_url", None)
return None
def _get_external_id(self, provider: PaymentMethod, payment: Any) -> Optional[str]:
if provider == PaymentMethod.YOOKASSA:
return getattr(payment, "yookassa_payment_id", None)
if provider == PaymentMethod.MULENPAY:
return getattr(payment, "uuid", None)
if provider == PaymentMethod.PAL24:
return getattr(payment, "bill_id", None)
if provider == PaymentMethod.WATA:
return getattr(payment, "payment_link_id", None)
if provider == PaymentMethod.HELEKET:
return getattr(payment, "uuid", None)
return None
def _extract_user(self, payment: Any) -> dict[str, Any]:
user = getattr(payment, "user", None)
if not user:
return {}
return {
"id": getattr(user, "id", None),
"telegram_id": getattr(user, "telegram_id", None),
"username": getattr(user, "username", None),
"first_name": getattr(user, "first_name", None),
"last_name": getattr(user, "last_name", None),
}
def _is_pending(self, provider: PaymentMethod, payment: Any) -> bool:
status_raw = getattr(payment, "status", "") or ""
status_lower = str(status_raw).lower()
if provider == PaymentMethod.YOOKASSA:
return status_lower in {"pending", "waiting_for_capture"} and not getattr(
payment, "is_paid", False
)
if provider == PaymentMethod.MULENPAY:
return status_lower in {"created", "processing", "hold"} and not getattr(
payment, "is_paid", False
)
if provider == PaymentMethod.PAL24:
return status_lower in {"new", "process", "underpaid"} and not getattr(
payment, "is_paid", False
)
if provider == PaymentMethod.WATA:
return status_lower not in {"closed", "paid"} and not getattr(
payment, "is_paid", False
)
if provider == PaymentMethod.HELEKET:
return status_lower not in {"paid", "paid_over"} and getattr(
payment, "transaction_id", None
) is None
if provider == PaymentMethod.CRYPTOBOT:
return status_lower == "active"
return False
def _is_within_age(self, payment: Any, max_age_hours: int) -> bool:
created_at = getattr(payment, "created_at", None)
if created_at is None:
return True
cutoff = datetime.utcnow() - timedelta(hours=max_age_hours)
return created_at >= cutoff
+3 -182
View File
@@ -3,24 +3,14 @@ from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
from fastapi import APIRouter, Depends, Query, Security
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PaymentMethod, Transaction
from app.services.pending_payment_service import PendingPaymentService, PendingPaymentError, PendingPaymentNotFoundError, PendingPaymentNotPendingError, PendingPaymentTooOldError, PendingPaymentUnsupportedError
from app.database.models import Transaction
from ..dependencies import get_db_session, require_api_token
from ..schemas.transactions import (
PendingPaymentBulkCheckResponse,
PendingPaymentCheckResponse,
PendingPaymentDetailResponse,
PendingPaymentListResponse,
PendingPaymentResponse,
PendingPaymentUserResponse,
TransactionListResponse,
TransactionResponse,
)
from ..schemas.transactions import TransactionListResponse, TransactionResponse
router = APIRouter()
@@ -87,172 +77,3 @@ async def list_transactions(
limit=limit,
offset=offset,
)
def _parse_provider(service: PendingPaymentService, provider: str) -> PaymentMethod:
try:
method = PaymentMethod(provider)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown payment provider") from error
if method not in service.SUPPORTED_METHODS:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unsupported payment provider")
return method
def _build_pending_summary(data: dict) -> PendingPaymentResponse:
user_data = data.get("user") or {}
return PendingPaymentResponse(
id=data.get("id"),
provider=data.get("provider"),
user=PendingPaymentUserResponse(**user_data),
amount_kopeks=data.get("amount_kopeks", 0),
amount_rubles=data.get("amount_rubles", 0.0),
currency=data.get("currency", "RUB"),
status=data.get("status"),
is_paid=data.get("is_paid", False),
description=data.get("description"),
payment_url=data.get("payment_url"),
external_id=data.get("external_id"),
transaction_id=data.get("transaction_id"),
created_at=data.get("created_at"),
updated_at=data.get("updated_at"),
expires_at=data.get("expires_at"),
is_pending=data.get("is_pending", False),
)
def _build_pending_detail(data: dict) -> PendingPaymentDetailResponse:
summary = _build_pending_summary(data)
return PendingPaymentDetailResponse(
**summary.dict(),
metadata=data.get("metadata"),
)
@router.get("/pending-payments", response_model=PendingPaymentListResponse)
async def list_pending_payments(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
provider: Optional[str] = Query(default=None),
max_age_hours: int = Query(default=24, ge=1, le=72),
) -> PendingPaymentListResponse:
service = PendingPaymentService()
payment_method = _parse_provider(service, provider) if provider else None
raw_items = await service.list_pending_payments(
db,
max_age_hours=max_age_hours,
provider=payment_method,
)
items = [_build_pending_summary(item) for item in raw_items]
return PendingPaymentListResponse(
items=items,
total=len(items),
)
@router.get(
"/pending-payments/{provider}/{payment_id}",
response_model=PendingPaymentDetailResponse,
)
async def get_pending_payment(
provider: str,
payment_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PendingPaymentDetailResponse:
service = PendingPaymentService()
payment_method = _parse_provider(service, provider)
try:
payment = await service.get_payment(db, payment_method, payment_id)
except PendingPaymentNotFoundError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Payment not found") from error
return _build_pending_detail(payment)
@router.post(
"/pending-payments/{provider}/{payment_id}/check",
response_model=PendingPaymentCheckResponse,
)
async def check_pending_payment(
provider: str,
payment_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
max_age_hours: int = Query(default=24, ge=1, le=72),
) -> PendingPaymentCheckResponse:
service = PendingPaymentService()
payment_method = _parse_provider(service, provider)
try:
result = await service.run_manual_check(
db,
payment_method,
payment_id,
max_age_hours=max_age_hours,
)
except PendingPaymentNotFoundError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Payment not found") from error
except PendingPaymentNotPendingError as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Payment is not pending") from error
except PendingPaymentTooOldError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Payment is older than the allowed interval") from error
except PendingPaymentUnsupportedError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
except PendingPaymentError as error:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to check payment") from error
detail = _build_pending_detail(result)
return PendingPaymentCheckResponse(
payment=detail,
check_performed=bool(result.get("check_performed")),
status_before=result.get("status_before"),
status_after=result.get("status_after"),
completed=bool(result.get("completed")),
)
@router.post(
"/pending-payments/check",
response_model=PendingPaymentBulkCheckResponse,
)
async def check_all_pending_payments(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
provider: Optional[str] = Query(default=None),
max_age_hours: int = Query(default=24, ge=1, le=72),
) -> PendingPaymentBulkCheckResponse:
service = PendingPaymentService()
payment_method = _parse_provider(service, provider) if provider else None
summary = await service.run_bulk_check(
db,
max_age_hours=max_age_hours,
provider=payment_method,
)
results = [
PendingPaymentCheckResponse(
payment=_build_pending_detail(item),
check_performed=bool(item.get("check_performed")),
status_before=item.get("status_before"),
status_after=item.get("status_after"),
completed=bool(item.get("completed")),
)
for item in summary.get("results", [])
]
return PendingPaymentBulkCheckResponse(
total=summary.get("total", 0),
checked=summary.get("checked", 0),
completed=summary.get("completed", 0),
skipped=summary.get("skipped", 0),
results=results,
)
+1 -53
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
from typing import Optional
from pydantic import BaseModel
@@ -25,55 +25,3 @@ class TransactionListResponse(BaseModel):
total: int
limit: int
offset: int
class PendingPaymentUserResponse(BaseModel):
id: Optional[int] = None
telegram_id: Optional[int] = None
username: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
class PendingPaymentResponse(BaseModel):
id: int
provider: str
user: PendingPaymentUserResponse
amount_kopeks: int
amount_rubles: float
currency: str
status: Optional[str] = None
is_paid: bool
description: Optional[str] = None
payment_url: Optional[str] = None
external_id: Optional[str] = None
transaction_id: Optional[int] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
expires_at: Optional[datetime] = None
is_pending: bool
class PendingPaymentDetailResponse(PendingPaymentResponse):
metadata: Optional[dict[str, Any]] = None
class PendingPaymentListResponse(BaseModel):
items: list[PendingPaymentResponse]
total: int
class PendingPaymentCheckResponse(BaseModel):
payment: PendingPaymentDetailResponse
check_performed: bool
status_before: Optional[str] = None
status_after: Optional[str] = None
completed: bool
class PendingPaymentBulkCheckResponse(BaseModel):
total: int
checked: int
completed: int
skipped: int
results: list[PendingPaymentCheckResponse]