From 44a410babf9bb2700a73899872671c4418804c41 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 17 Jan 2026 08:50:47 +0300 Subject: [PATCH] Add files via upload --- app/cabinet/routes/__init__.py | 2 + app/cabinet/routes/admin_payments.py | 417 +++++++++++++++++++++++++++ 2 files changed, 419 insertions(+) create mode 100644 app/cabinet/routes/admin_payments.py diff --git a/app/cabinet/routes/__init__.py b/app/cabinet/routes/__init__.py index 0c7850c7..ca30d7d9 100644 --- a/app/cabinet/routes/__init__.py +++ b/app/cabinet/routes/__init__.py @@ -28,6 +28,7 @@ from .admin_promocodes import router as admin_promocodes_router from .admin_promocodes import promo_groups_router as admin_promo_groups_router from .admin_campaigns import router as admin_campaigns_router from .admin_users import router as admin_users_router +from .admin_payments import router as admin_payments_router from .media import router as media_router # Main cabinet router @@ -65,5 +66,6 @@ router.include_router(admin_promocodes_router) router.include_router(admin_promo_groups_router) router.include_router(admin_campaigns_router) router.include_router(admin_users_router) +router.include_router(admin_payments_router) __all__ = ["router"] diff --git a/app/cabinet/routes/admin_payments.py b/app/cabinet/routes/admin_payments.py new file mode 100644 index 00000000..a90577ce --- /dev/null +++ b/app/cabinet/routes/admin_payments.py @@ -0,0 +1,417 @@ +"""Admin routes for payment verification in cabinet.""" + +import logging +import math +from typing import List, Optional +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel + +from app.database.models import User, PaymentMethod +from app.services.payment_service import PaymentService +from app.services.payment_verification_service import ( + list_recent_pending_payments, + get_payment_record, + run_manual_check, + SUPPORTED_MANUAL_CHECK_METHODS, + method_display_name, + PendingPayment, +) + +from ..dependencies import get_cabinet_db, get_current_admin_user + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin/payments", tags=["Cabinet Admin Payments"]) + + +# ============ Schemas ============ + +class PendingPaymentResponse(BaseModel): + """Pending payment details.""" + id: int + method: str + method_display: str + identifier: str + amount_kopeks: int + amount_rubles: float + status: str + status_emoji: str + status_text: str + is_paid: bool + is_checkable: bool + created_at: datetime + expires_at: Optional[datetime] = None + payment_url: Optional[str] = None + user_id: Optional[int] = None + user_telegram_id: Optional[int] = None + user_username: Optional[str] = None + + class Config: + from_attributes = True + + +class PendingPaymentListResponse(BaseModel): + """Paginated list of pending payments.""" + items: List[PendingPaymentResponse] + total: int + page: int + per_page: int + pages: int + + +class ManualCheckResponse(BaseModel): + """Response after manual payment status check.""" + success: bool + message: str + payment: Optional[PendingPaymentResponse] = None + status_changed: bool = False + old_status: Optional[str] = None + new_status: Optional[str] = None + + +class PaymentsStatsResponse(BaseModel): + """Statistics about pending payments.""" + total_pending: int + by_method: dict + + +# ============ Helper functions ============ + +def _get_status_info(record: PendingPayment) -> tuple[str, str]: + """Get status emoji and text for a pending payment.""" + status_str = (record.status or "").lower() + + if record.is_paid: + return "✅", "Оплачено" + + if record.method == PaymentMethod.PAL24: + mapping = { + "new": ("⏳", "Ожидает оплаты"), + "process": ("⌛", "Обрабатывается"), + "success": ("✅", "Оплачено"), + "fail": ("❌", "Ошибка"), + "canceled": ("❌", "Отменено"), + } + return mapping.get(status_str, ("❓", "Неизвестно")) + + if record.method == PaymentMethod.MULENPAY: + mapping = { + "created": ("⏳", "Ожидает оплаты"), + "processing": ("⌛", "Обрабатывается"), + "hold": ("🔒", "На удержании"), + "success": ("✅", "Оплачено"), + "canceled": ("❌", "Отменено"), + "error": ("❌", "Ошибка"), + } + return mapping.get(status_str, ("❓", "Неизвестно")) + + if record.method == PaymentMethod.WATA: + mapping = { + "opened": ("⏳", "Ожидает оплаты"), + "pending": ("⏳", "Ожидает оплаты"), + "processing": ("⌛", "Обрабатывается"), + "paid": ("✅", "Оплачено"), + "closed": ("✅", "Оплачено"), + "declined": ("❌", "Отклонено"), + "canceled": ("❌", "Отменено"), + "expired": ("⌛", "Истёк"), + } + return mapping.get(status_str, ("❓", "Неизвестно")) + + if record.method == PaymentMethod.PLATEGA: + mapping = { + "pending": ("⏳", "Ожидает оплаты"), + "inprogress": ("⌛", "Обрабатывается"), + "confirmed": ("✅", "Оплачено"), + "failed": ("❌", "Ошибка"), + "canceled": ("❌", "Отменено"), + "expired": ("⌛", "Истёк"), + } + return mapping.get(status_str, ("❓", "Неизвестно")) + + if record.method == PaymentMethod.HELEKET: + if status_str in {"pending", "created", "waiting", "check", "processing"}: + return "⏳", "Ожидает оплаты" + if status_str in {"paid", "paid_over"}: + return "✅", "Оплачено" + if status_str in {"cancel", "canceled", "fail", "failed", "expired"}: + return "❌", "Отменено" + return "❓", "Неизвестно" + + if record.method == PaymentMethod.YOOKASSA: + mapping = { + "pending": ("⏳", "Ожидает оплаты"), + "waiting_for_capture": ("⌛", "Обрабатывается"), + "succeeded": ("✅", "Оплачено"), + "canceled": ("❌", "Отменено"), + } + return mapping.get(status_str, ("❓", "Неизвестно")) + + if record.method == PaymentMethod.CRYPTOBOT: + mapping = { + "active": ("⏳", "Ожидает оплаты"), + "paid": ("✅", "Оплачено"), + "expired": ("⌛", "Истёк"), + } + return mapping.get(status_str, ("❓", "Неизвестно")) + + if record.method == PaymentMethod.CLOUDPAYMENTS: + mapping = { + "pending": ("⏳", "Ожидает оплаты"), + "authorized": ("⌛", "Авторизовано"), + "completed": ("✅", "Оплачено"), + "failed": ("❌", "Ошибка"), + } + return mapping.get(status_str, ("❓", "Неизвестно")) + + if record.method == PaymentMethod.FREEKASSA: + mapping = { + "pending": ("⏳", "Ожидает оплаты"), + "success": ("✅", "Оплачено"), + "paid": ("✅", "Оплачено"), + "canceled": ("❌", "Отменено"), + "error": ("❌", "Ошибка"), + } + return mapping.get(status_str, ("❓", "Неизвестно")) + + return "❓", "Неизвестно" + + +def _is_checkable(record: PendingPayment) -> bool: + """Check if payment can be manually checked.""" + if record.method not in SUPPORTED_MANUAL_CHECK_METHODS: + return False + if not record.is_recent(): + return False + status_str = (record.status or "").lower() + if record.method == PaymentMethod.PAL24: + return status_str in {"new", "process"} + if record.method == PaymentMethod.MULENPAY: + return status_str in {"created", "processing", "hold"} + if record.method == PaymentMethod.WATA: + return status_str in {"opened", "pending", "processing", "inprogress", "in_progress"} + if record.method == PaymentMethod.PLATEGA: + return status_str in {"pending", "inprogress", "in_progress"} + if record.method == PaymentMethod.HELEKET: + return status_str not in {"paid", "paid_over", "cancel", "canceled", "fail", "failed", "expired"} + if record.method == PaymentMethod.YOOKASSA: + return status_str in {"pending", "waiting_for_capture"} + if record.method == PaymentMethod.CRYPTOBOT: + return status_str in {"active"} + if record.method == PaymentMethod.CLOUDPAYMENTS: + return status_str in {"pending", "authorized"} + if record.method == PaymentMethod.FREEKASSA: + return status_str in {"pending", "created", "processing"} + return False + + +def _get_payment_url(record: PendingPayment) -> Optional[str]: + """Extract payment URL from record.""" + payment = record.payment + payment_url = getattr(payment, "payment_url", None) + + if record.method == PaymentMethod.PAL24: + payment_url = getattr(payment, "link_url", None) or getattr(payment, "link_page_url", None) or payment_url + elif record.method == PaymentMethod.WATA: + payment_url = getattr(payment, "url", None) 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 = ( + getattr(payment, "bot_invoice_url", None) + or getattr(payment, "mini_app_invoice_url", None) + or getattr(payment, "web_app_invoice_url", None) + or payment_url + ) + elif record.method == PaymentMethod.PLATEGA: + payment_url = getattr(payment, "redirect_url", None) or payment_url + elif record.method == PaymentMethod.CLOUDPAYMENTS: + payment_url = getattr(payment, "payment_url", None) or payment_url + elif record.method == PaymentMethod.FREEKASSA: + payment_url = getattr(payment, "payment_url", None) or payment_url + + return payment_url + + +def _record_to_response(record: PendingPayment) -> PendingPaymentResponse: + """Convert PendingPayment to API response.""" + status_emoji, status_text = _get_status_info(record) + return PendingPaymentResponse( + id=record.local_id, + method=record.method.value, + method_display=method_display_name(record.method), + identifier=record.identifier, + amount_kopeks=record.amount_kopeks, + amount_rubles=record.amount_kopeks / 100, + status=record.status or "", + status_emoji=status_emoji, + status_text=status_text, + is_paid=record.is_paid, + is_checkable=_is_checkable(record), + created_at=record.created_at, + expires_at=record.expires_at, + payment_url=_get_payment_url(record), + user_id=record.user.id if record.user else None, + user_telegram_id=record.user.telegram_id if record.user else None, + user_username=record.user.username if record.user else None, + ) + + +# ============ Routes ============ + +@router.get("", response_model=PendingPaymentListResponse) +async def get_all_pending_payments( + page: int = Query(1, ge=1, description="Page number"), + per_page: int = Query(20, ge=1, le=100, description="Items per page"), + method_filter: Optional[str] = Query(None, description="Filter by payment method"), + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get all pending payments for admin verification.""" + all_pending = await list_recent_pending_payments(db) + + # Apply method filter if specified + if method_filter: + try: + filter_method = PaymentMethod(method_filter) + all_pending = [p for p in all_pending if p.method == filter_method] + except ValueError: + pass + + total = len(all_pending) + pages = math.ceil(total / per_page) if total > 0 else 1 + + # Paginate + start_idx = (page - 1) * per_page + page_payments = all_pending[start_idx:start_idx + per_page] + + items = [_record_to_response(p) for p in page_payments] + + return PendingPaymentListResponse( + items=items, + total=total, + page=page, + per_page=per_page, + pages=pages, + ) + + +@router.get("/stats", response_model=PaymentsStatsResponse) +async def get_payments_stats( + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get statistics about pending payments.""" + all_pending = await list_recent_pending_payments(db) + + by_method = {} + for p in all_pending: + method_name = method_display_name(p.method) + if method_name not in by_method: + by_method[method_name] = 0 + by_method[method_name] += 1 + + return PaymentsStatsResponse( + total_pending=len(all_pending), + by_method=by_method, + ) + + +@router.get("/{method}/{payment_id}", response_model=PendingPaymentResponse) +async def get_pending_payment_details( + method: str, + payment_id: int, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get details of a specific pending payment.""" + try: + payment_method = PaymentMethod(method) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid payment method: {method}", + ) + + record = await get_payment_record(db, payment_method, payment_id) + + if not record: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Payment not found", + ) + + return _record_to_response(record) + + +@router.post("/{method}/{payment_id}/check", response_model=ManualCheckResponse) +async def check_payment_status( + method: str, + payment_id: int, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Manually check and update payment status.""" + try: + payment_method = PaymentMethod(method) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid payment method: {method}", + ) + + # Get current record + record = await get_payment_record(db, payment_method, payment_id) + + if not record: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Payment not found", + ) + + # Check if manual check is available + if not _is_checkable(record): + return ManualCheckResponse( + success=False, + message="Ручная проверка недоступна для этого платежа", + payment=_record_to_response(record), + status_changed=False, + ) + + old_status = record.status + old_is_paid = record.is_paid + + # Run manual check + payment_service = PaymentService() + updated = await run_manual_check(db, payment_method, payment_id, payment_service) + + if not updated: + return ManualCheckResponse( + success=False, + message="Не удалось проверить статус платежа", + payment=_record_to_response(record), + status_changed=False, + ) + + status_changed = updated.status != old_status or updated.is_paid != old_is_paid + + if status_changed: + _, new_status_text = _get_status_info(updated) + message = f"Статус обновлён: {new_status_text}" + logger.info( + f"Admin {admin.id} checked payment {method}/{payment_id}: {old_status} -> {updated.status}" + ) + else: + message = "Статус не изменился" + + return ManualCheckResponse( + success=True, + message=message, + payment=_record_to_response(updated), + status_changed=status_changed, + old_status=old_status, + new_status=updated.status, + )