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:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,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]
|
||||
|
||||
Reference in New Issue
Block a user