mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-22 12:21:26 +00:00
Revert "Align miniapp payment limits and status UX"
This commit is contained in:
@@ -828,7 +828,6 @@ class PaymentService:
|
||||
language: str,
|
||||
ttl_seconds: Optional[int] = None,
|
||||
payer_email: Optional[str] = None,
|
||||
payment_method: str = "SBP",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not self.pal24_service or not self.pal24_service.is_configured:
|
||||
@@ -868,7 +867,7 @@ class PaymentService:
|
||||
ttl_seconds=ttl_seconds,
|
||||
custom_payload=custom_payload,
|
||||
payer_email=payer_email,
|
||||
payment_method=payment_method,
|
||||
payment_method="SBP",
|
||||
)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка Pal24 API при создании счета: %s", error)
|
||||
|
||||
@@ -2,13 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from aiogram import Bot
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -31,8 +29,6 @@ from app.database.models import (
|
||||
Subscription,
|
||||
SubscriptionTemporaryAccess,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
PaymentMethod,
|
||||
User,
|
||||
)
|
||||
from app.services.faq_service import FaqService
|
||||
@@ -42,12 +38,9 @@ from app.services.remnawave_service import (
|
||||
RemnaWaveConfigurationError,
|
||||
RemnaWaveService,
|
||||
)
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.promo_offer_service import promo_offer_service
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.tribute_service import TributeService
|
||||
from app.utils.currency_converter import currency_converter
|
||||
from app.utils.subscription_utils import get_happ_cryptolink_redirect_link
|
||||
from app.utils.telegram_webapp import (
|
||||
TelegramWebAppAuthError,
|
||||
@@ -68,14 +61,6 @@ from ..schemas.miniapp import (
|
||||
MiniAppFaq,
|
||||
MiniAppFaqItem,
|
||||
MiniAppLegalDocuments,
|
||||
MiniAppPaymentCreateRequest,
|
||||
MiniAppPaymentCreateResponse,
|
||||
MiniAppPaymentMethod,
|
||||
MiniAppPaymentOption,
|
||||
MiniAppPaymentMethodsRequest,
|
||||
MiniAppPaymentMethodsResponse,
|
||||
MiniAppPaymentStatusRequest,
|
||||
MiniAppPaymentStatusResponse,
|
||||
MiniAppPromoCode,
|
||||
MiniAppPromoCodeActivationRequest,
|
||||
MiniAppPromoCodeActivationResponse,
|
||||
@@ -104,20 +89,6 @@ router = APIRouter()
|
||||
promo_code_service = PromoCodeService()
|
||||
|
||||
|
||||
_CRYPTOBOT_STATUS_LABELS = {
|
||||
"paid": "paid",
|
||||
"active": "waiting_payment",
|
||||
"created": "waiting_payment",
|
||||
"expired": "expired",
|
||||
"failed": "failed",
|
||||
"cancelled": "cancelled",
|
||||
"canceled": "cancelled",
|
||||
}
|
||||
|
||||
_CRYPTOBOT_FAILED_STATUSES = {"failed", "expired", "cancelled", "canceled"}
|
||||
_CRYPTOBOT_FINAL_STATUSES = _CRYPTOBOT_FAILED_STATUSES | {"paid"}
|
||||
|
||||
|
||||
def _format_gb(value: Optional[float]) -> float:
|
||||
if value is None:
|
||||
return 0.0
|
||||
@@ -142,711 +113,6 @@ def _format_limit_label(limit: Optional[int]) -> str:
|
||||
return f"{limit} GB"
|
||||
|
||||
|
||||
async def _resolve_user_from_init_data(
|
||||
db: AsyncSession,
|
||||
init_data: str,
|
||||
) -> Tuple[User, Dict[str, Any]]:
|
||||
if not init_data:
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing initData",
|
||||
)
|
||||
|
||||
try:
|
||||
webapp_data = parse_webapp_init_data(init_data, settings.BOT_TOKEN)
|
||||
except TelegramWebAppAuthError as error:
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
detail=str(error),
|
||||
) from error
|
||||
|
||||
telegram_user = webapp_data.get("user")
|
||||
if not isinstance(telegram_user, dict) or "id" not in telegram_user:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid Telegram user payload",
|
||||
)
|
||||
|
||||
try:
|
||||
telegram_id = int(telegram_user["id"])
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid Telegram user identifier",
|
||||
) from None
|
||||
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
return user, webapp_data
|
||||
|
||||
|
||||
def _normalize_amount_kopeks(
|
||||
amount_rubles: Optional[float],
|
||||
amount_kopeks: Optional[int],
|
||||
) -> Optional[int]:
|
||||
if amount_kopeks is not None:
|
||||
try:
|
||||
normalized = int(amount_kopeks)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return normalized if normalized >= 0 else None
|
||||
|
||||
if amount_rubles is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
decimal_amount = Decimal(str(amount_rubles)).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
|
||||
normalized = int((decimal_amount * 100).to_integral_value(rounding=ROUND_HALF_UP))
|
||||
return normalized if normalized >= 0 else None
|
||||
|
||||
|
||||
@router.post(
|
||||
"/payments/methods",
|
||||
response_model=MiniAppPaymentMethodsResponse,
|
||||
)
|
||||
async def get_payment_methods(
|
||||
payload: MiniAppPaymentMethodsRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MiniAppPaymentMethodsResponse:
|
||||
_, _ = await _resolve_user_from_init_data(db, payload.init_data)
|
||||
|
||||
methods: List[MiniAppPaymentMethod] = []
|
||||
|
||||
if settings.TELEGRAM_STARS_ENABLED:
|
||||
methods.append(
|
||||
MiniAppPaymentMethod(
|
||||
id="stars",
|
||||
icon="⭐",
|
||||
requires_amount=True,
|
||||
currency="RUB",
|
||||
)
|
||||
)
|
||||
|
||||
if settings.is_yookassa_enabled():
|
||||
methods.append(
|
||||
MiniAppPaymentMethod(
|
||||
id="yookassa",
|
||||
icon="💳",
|
||||
requires_amount=True,
|
||||
currency="RUB",
|
||||
min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS,
|
||||
max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
)
|
||||
|
||||
if settings.is_mulenpay_enabled():
|
||||
methods.append(
|
||||
MiniAppPaymentMethod(
|
||||
id="mulenpay",
|
||||
icon="💳",
|
||||
requires_amount=True,
|
||||
currency="RUB",
|
||||
min_amount_kopeks=settings.MULENPAY_MIN_AMOUNT_KOPEKS,
|
||||
max_amount_kopeks=settings.MULENPAY_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
)
|
||||
|
||||
if settings.is_pal24_enabled():
|
||||
methods.append(
|
||||
MiniAppPaymentMethod(
|
||||
id="pal24",
|
||||
icon="🏦",
|
||||
requires_amount=True,
|
||||
currency="RUB",
|
||||
min_amount_kopeks=settings.PAL24_MIN_AMOUNT_KOPEKS,
|
||||
max_amount_kopeks=settings.PAL24_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
)
|
||||
|
||||
if settings.is_cryptobot_enabled():
|
||||
cryptobot_rate: Optional[Decimal] = None
|
||||
try:
|
||||
rate_value = await currency_converter.get_usd_to_rub_rate()
|
||||
if rate_value:
|
||||
cryptobot_rate = Decimal(str(rate_value))
|
||||
except Exception:
|
||||
cryptobot_rate = None
|
||||
|
||||
if not cryptobot_rate or cryptobot_rate <= 0:
|
||||
cryptobot_rate = Decimal("95")
|
||||
|
||||
min_rubles = (Decimal("1") * cryptobot_rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
max_rubles = (Decimal("1000") * cryptobot_rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
min_amount_kopeks = int((min_rubles * 100).to_integral_value(rounding=ROUND_HALF_UP))
|
||||
max_amount_kopeks = int((max_rubles * 100).to_integral_value(rounding=ROUND_HALF_UP))
|
||||
|
||||
methods.append(
|
||||
MiniAppPaymentMethod(
|
||||
id="cryptobot",
|
||||
icon="🪙",
|
||||
requires_amount=True,
|
||||
currency="RUB",
|
||||
min_amount_kopeks=min_amount_kopeks,
|
||||
max_amount_kopeks=max_amount_kopeks,
|
||||
)
|
||||
)
|
||||
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
methods.append(
|
||||
MiniAppPaymentMethod(
|
||||
id="tribute",
|
||||
icon="💎",
|
||||
requires_amount=False,
|
||||
currency="RUB",
|
||||
)
|
||||
)
|
||||
|
||||
if settings.is_pal24_enabled():
|
||||
for method in methods:
|
||||
if method.id == "pal24":
|
||||
method.options = [
|
||||
MiniAppPaymentOption(id="sbp", icon="🏦", label="SBP"),
|
||||
MiniAppPaymentOption(id="card", icon="💳", label="Card"),
|
||||
]
|
||||
break
|
||||
|
||||
order_map = {
|
||||
"stars": 1,
|
||||
"yookassa": 2,
|
||||
"mulenpay": 3,
|
||||
"pal24": 4,
|
||||
"cryptobot": 5,
|
||||
"tribute": 6,
|
||||
}
|
||||
methods.sort(key=lambda item: order_map.get(item.id, 99))
|
||||
|
||||
return MiniAppPaymentMethodsResponse(methods=methods)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/payments/create",
|
||||
response_model=MiniAppPaymentCreateResponse,
|
||||
)
|
||||
async def create_payment_link(
|
||||
payload: MiniAppPaymentCreateRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MiniAppPaymentCreateResponse:
|
||||
user, _ = await _resolve_user_from_init_data(db, payload.init_data)
|
||||
|
||||
method = (payload.method or "").strip().lower()
|
||||
if not method:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail="Payment method is required",
|
||||
)
|
||||
|
||||
amount_kopeks = _normalize_amount_kopeks(
|
||||
payload.amount_rubles,
|
||||
payload.amount_kopeks,
|
||||
)
|
||||
payment_option = (payload.payment_option or "").strip().lower() or None
|
||||
|
||||
if method == "stars":
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
|
||||
if amount_kopeks is None or amount_kopeks <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive")
|
||||
if not settings.BOT_TOKEN:
|
||||
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Bot token is not configured")
|
||||
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
payment_service = PaymentService(bot)
|
||||
invoice_link = await payment_service.create_stars_invoice(
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
payload=f"balance_{user.id}_{amount_kopeks}",
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
|
||||
if not invoice_link:
|
||||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create invoice")
|
||||
|
||||
return MiniAppPaymentCreateResponse(
|
||||
method=method,
|
||||
payment_url=invoice_link,
|
||||
amount_kopeks=amount_kopeks,
|
||||
)
|
||||
|
||||
if method == "yookassa":
|
||||
if not settings.is_yookassa_enabled():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
|
||||
if amount_kopeks is None or amount_kopeks <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive")
|
||||
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum")
|
||||
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum")
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_yookassa_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
)
|
||||
if not result or not result.get("confirmation_url"):
|
||||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment")
|
||||
|
||||
return MiniAppPaymentCreateResponse(
|
||||
method=method,
|
||||
payment_url=result["confirmation_url"],
|
||||
amount_kopeks=amount_kopeks,
|
||||
extra={
|
||||
"local_payment_id": result.get("local_payment_id"),
|
||||
"payment_id": result.get("yookassa_payment_id"),
|
||||
"status": result.get("status"),
|
||||
},
|
||||
)
|
||||
|
||||
if method == "mulenpay":
|
||||
if not settings.is_mulenpay_enabled():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
|
||||
if amount_kopeks is None or amount_kopeks <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive")
|
||||
if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum")
|
||||
if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum")
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_mulenpay_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
language=user.language,
|
||||
)
|
||||
if not result or not result.get("payment_url"):
|
||||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment")
|
||||
|
||||
return MiniAppPaymentCreateResponse(
|
||||
method=method,
|
||||
payment_url=result["payment_url"],
|
||||
amount_kopeks=amount_kopeks,
|
||||
extra={
|
||||
"local_payment_id": result.get("local_payment_id"),
|
||||
"payment_id": result.get("mulen_payment_id"),
|
||||
},
|
||||
)
|
||||
|
||||
if method == "pal24":
|
||||
if not settings.is_pal24_enabled():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
|
||||
if amount_kopeks is None or amount_kopeks <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive")
|
||||
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum")
|
||||
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum")
|
||||
|
||||
selected_option = payment_option or "sbp"
|
||||
if selected_option not in {"sbp", "card"}:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Unsupported payment option")
|
||||
|
||||
pal24_payment_method = "CARD" if selected_option == "card" else "SBP"
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_pal24_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
language=user.language or settings.DEFAULT_LANGUAGE,
|
||||
payment_method=pal24_payment_method,
|
||||
)
|
||||
if not result or not result.get("payment_url"):
|
||||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment")
|
||||
|
||||
links = {
|
||||
"sbp": result.get("sbp_url") or result.get("transfer_url"),
|
||||
"card": result.get("card_url"),
|
||||
"page": result.get("link_page_url"),
|
||||
}
|
||||
|
||||
preferred_url = None
|
||||
if selected_option == "card":
|
||||
preferred_url = links.get("card") or links.get("page") or links.get("sbp")
|
||||
else:
|
||||
preferred_url = links.get("sbp") or links.get("page") or links.get("card")
|
||||
|
||||
if not preferred_url:
|
||||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to obtain payment url")
|
||||
|
||||
normalized_links = {key: value for key, value in links.items() if value}
|
||||
|
||||
return MiniAppPaymentCreateResponse(
|
||||
method=method,
|
||||
payment_url=preferred_url,
|
||||
amount_kopeks=amount_kopeks,
|
||||
extra={
|
||||
"local_payment_id": result.get("local_payment_id"),
|
||||
"bill_id": result.get("bill_id"),
|
||||
"links": normalized_links,
|
||||
"selected_option": selected_option,
|
||||
},
|
||||
)
|
||||
|
||||
if method == "cryptobot":
|
||||
if not settings.is_cryptobot_enabled():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
|
||||
if amount_kopeks is None or amount_kopeks <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive")
|
||||
|
||||
try:
|
||||
rate_value = await currency_converter.get_usd_to_rub_rate()
|
||||
rate_decimal = Decimal(str(rate_value)) if rate_value else None
|
||||
except Exception:
|
||||
rate_decimal = None
|
||||
|
||||
if not rate_decimal or rate_decimal <= 0:
|
||||
rate_decimal = Decimal("95")
|
||||
|
||||
min_rubles = (Decimal("1") * rate_decimal).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
max_rubles = (Decimal("1000") * rate_decimal).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
min_amount_kopeks = int((min_rubles * 100).to_integral_value(rounding=ROUND_HALF_UP))
|
||||
max_amount_kopeks = int((max_rubles * 100).to_integral_value(rounding=ROUND_HALF_UP))
|
||||
|
||||
if amount_kopeks < min_amount_kopeks:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum")
|
||||
if amount_kopeks > max_amount_kopeks:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum")
|
||||
|
||||
amount_rubles_decimal = Decimal(amount_kopeks) / Decimal(100)
|
||||
amount_usd_decimal = (amount_rubles_decimal / rate_decimal).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
amount_usd = float(amount_usd_decimal)
|
||||
rate = float(rate_decimal)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_cryptobot_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_usd=amount_usd,
|
||||
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
payload=f"balance_{user.id}_{amount_kopeks}",
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment")
|
||||
|
||||
payment_url = (
|
||||
result.get("bot_invoice_url")
|
||||
or result.get("mini_app_invoice_url")
|
||||
or result.get("web_app_invoice_url")
|
||||
)
|
||||
if not payment_url:
|
||||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to obtain payment url")
|
||||
|
||||
return MiniAppPaymentCreateResponse(
|
||||
method=method,
|
||||
payment_url=payment_url,
|
||||
amount_kopeks=amount_kopeks,
|
||||
extra={
|
||||
"local_payment_id": result.get("local_payment_id"),
|
||||
"invoice_id": result.get("invoice_id"),
|
||||
"amount_usd": amount_usd,
|
||||
"rate": rate,
|
||||
"min_amount_kopeks": min_amount_kopeks,
|
||||
"max_amount_kopeks": max_amount_kopeks,
|
||||
},
|
||||
)
|
||||
|
||||
if method == "tribute":
|
||||
if not settings.TRIBUTE_ENABLED:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
|
||||
if not settings.BOT_TOKEN:
|
||||
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Bot token is not configured")
|
||||
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
tribute_service = TributeService(bot)
|
||||
payment_url = await tribute_service.create_payment_link(
|
||||
user_id=user.telegram_id,
|
||||
amount_kopeks=amount_kopeks or 0,
|
||||
description=settings.get_balance_payment_description(amount_kopeks or 0),
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
|
||||
if not payment_url:
|
||||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment")
|
||||
|
||||
return MiniAppPaymentCreateResponse(
|
||||
method=method,
|
||||
payment_url=payment_url,
|
||||
amount_kopeks=amount_kopeks,
|
||||
extra={
|
||||
"payment_option": payment_option,
|
||||
} if payment_option else {},
|
||||
)
|
||||
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Unknown payment method")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/payments/status",
|
||||
response_model=MiniAppPaymentStatusResponse,
|
||||
)
|
||||
async def get_payment_status(
|
||||
payload: MiniAppPaymentStatusRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MiniAppPaymentStatusResponse:
|
||||
user, _ = await _resolve_user_from_init_data(db, payload.init_data)
|
||||
|
||||
method = (payload.method or "").strip().lower()
|
||||
if not method:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail="Payment method is required",
|
||||
)
|
||||
|
||||
status_code = "pending"
|
||||
status_label: Optional[str] = None
|
||||
is_paid = False
|
||||
is_failed = False
|
||||
is_final = False
|
||||
updated_at: Optional[datetime] = None
|
||||
details: Dict[str, Any] = {}
|
||||
|
||||
payment_service = PaymentService()
|
||||
|
||||
if method == "yookassa":
|
||||
if payload.local_payment_id is None:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Missing payment identifier")
|
||||
|
||||
from app.database.crud.yookassa import get_yookassa_payment_by_local_id
|
||||
|
||||
payment = await get_yookassa_payment_by_local_id(db, payload.local_payment_id)
|
||||
if not payment:
|
||||
return MiniAppPaymentStatusResponse(
|
||||
method=method,
|
||||
status="not_found",
|
||||
status_label="not_found",
|
||||
is_failed=True,
|
||||
is_final=True,
|
||||
details={"local_payment_id": payload.local_payment_id},
|
||||
)
|
||||
|
||||
status_code = payment.status or "pending"
|
||||
status_label = status_code
|
||||
is_paid = bool(payment.is_succeeded)
|
||||
is_failed = bool(payment.is_failed)
|
||||
is_final = is_paid or is_failed or status_code in {"canceled", "failed"}
|
||||
updated_at = payment.updated_at or payment.created_at
|
||||
details = {
|
||||
"local_payment_id": payment.id,
|
||||
"payment_id": payment.yookassa_payment_id,
|
||||
"status": payment.status,
|
||||
}
|
||||
|
||||
elif method == "mulenpay":
|
||||
if payload.local_payment_id is None:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Missing payment identifier")
|
||||
|
||||
status_info = await payment_service.get_mulenpay_payment_status(db, payload.local_payment_id)
|
||||
if not status_info:
|
||||
return MiniAppPaymentStatusResponse(
|
||||
method=method,
|
||||
status="not_found",
|
||||
status_label="not_found",
|
||||
is_failed=True,
|
||||
is_final=True,
|
||||
details={"local_payment_id": payload.local_payment_id},
|
||||
)
|
||||
|
||||
payment = status_info["payment"]
|
||||
status_code = (payment.status or "unknown").lower()
|
||||
status_label = payment.status
|
||||
is_paid = bool(payment.is_paid)
|
||||
is_failed = status_code in {"canceled", "error"}
|
||||
is_final = is_paid or is_failed
|
||||
updated_at = payment.updated_at or payment.created_at
|
||||
details = {
|
||||
"local_payment_id": payment.id,
|
||||
"mulen_payment_id": payment.mulen_payment_id,
|
||||
"status": payment.status,
|
||||
"remote_status": status_info.get("remote_status"),
|
||||
}
|
||||
if payment.payment_url and not is_paid:
|
||||
details["payment_url"] = payment.payment_url
|
||||
|
||||
elif method == "pal24":
|
||||
if payload.local_payment_id is None:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Missing payment identifier")
|
||||
|
||||
status_info = await payment_service.get_pal24_payment_status(db, payload.local_payment_id)
|
||||
if not status_info:
|
||||
return MiniAppPaymentStatusResponse(
|
||||
method=method,
|
||||
status="not_found",
|
||||
status_label="not_found",
|
||||
is_failed=True,
|
||||
is_final=True,
|
||||
details={"local_payment_id": payload.local_payment_id},
|
||||
)
|
||||
|
||||
payment = status_info["payment"]
|
||||
status_code = (payment.status or "UNKNOWN").lower()
|
||||
status_label = payment.status
|
||||
is_paid = bool(payment.is_paid)
|
||||
is_failed = payment.status in {"FAIL", "UNDERPAID"}
|
||||
is_final = is_paid or is_failed or payment.status in {"OVERPAID"}
|
||||
updated_at = payment.updated_at or payment.created_at
|
||||
|
||||
metadata = payment.metadata_json if isinstance(payment.metadata_json, dict) else {}
|
||||
links_meta = metadata.get("links") if isinstance(metadata, dict) else {}
|
||||
|
||||
details = {
|
||||
"local_payment_id": payment.id,
|
||||
"bill_id": payment.bill_id,
|
||||
"status": payment.status,
|
||||
"links": links_meta,
|
||||
"payment_id": payment.payment_id,
|
||||
}
|
||||
|
||||
elif method == "cryptobot":
|
||||
from app.database.crud.cryptobot import (
|
||||
get_cryptobot_payment_by_id,
|
||||
get_cryptobot_payment_by_invoice_id,
|
||||
)
|
||||
|
||||
payment = None
|
||||
if payload.invoice_id:
|
||||
payment = await get_cryptobot_payment_by_invoice_id(db, str(payload.invoice_id))
|
||||
if not payment and payload.local_payment_id is not None:
|
||||
payment = await get_cryptobot_payment_by_id(db, payload.local_payment_id)
|
||||
|
||||
if not payment:
|
||||
return MiniAppPaymentStatusResponse(
|
||||
method=method,
|
||||
status="not_found",
|
||||
status_label="not_found",
|
||||
is_failed=True,
|
||||
is_final=True,
|
||||
details={
|
||||
"local_payment_id": payload.local_payment_id,
|
||||
"invoice_id": payload.invoice_id,
|
||||
},
|
||||
)
|
||||
|
||||
raw_status = payment.status or "unknown"
|
||||
status_code = raw_status.lower()
|
||||
status_label = _CRYPTOBOT_STATUS_LABELS.get(status_code, status_code)
|
||||
is_paid = bool(payment.is_paid) or status_code == "paid"
|
||||
is_failed = status_code in _CRYPTOBOT_FAILED_STATUSES
|
||||
is_final = is_paid or status_code in _CRYPTOBOT_FINAL_STATUSES
|
||||
updated_at = payment.updated_at or payment.created_at
|
||||
details = {
|
||||
"local_payment_id": payment.id,
|
||||
"invoice_id": payment.invoice_id,
|
||||
"status": raw_status,
|
||||
"amount": payment.amount,
|
||||
"asset": payment.asset,
|
||||
}
|
||||
if payment.paid_at:
|
||||
details["paid_at"] = payment.paid_at
|
||||
if payment.transaction_id:
|
||||
details["transaction_id"] = payment.transaction_id
|
||||
|
||||
elif method in {"tribute", "stars"}:
|
||||
started_at = payload.started_at
|
||||
if isinstance(started_at, datetime):
|
||||
started = started_at.replace(tzinfo=None)
|
||||
elif isinstance(started_at, str) and started_at:
|
||||
try:
|
||||
started = datetime.fromisoformat(started_at.replace("Z", "+00:00")).replace(tzinfo=None)
|
||||
except ValueError:
|
||||
started = None
|
||||
else:
|
||||
started = None
|
||||
|
||||
if not started:
|
||||
started = datetime.utcnow() - timedelta(minutes=30)
|
||||
|
||||
tolerance = timedelta(minutes=5)
|
||||
amount_filter = payload.amount_kopeks or 0
|
||||
|
||||
method_key = (
|
||||
PaymentMethod.TELEGRAM_STARS.value
|
||||
if method == "stars"
|
||||
else PaymentMethod.TRIBUTE.value
|
||||
)
|
||||
|
||||
query = (
|
||||
select(Transaction)
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id == user.id,
|
||||
Transaction.payment_method == method_key,
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.created_at >= started - tolerance,
|
||||
)
|
||||
)
|
||||
.order_by(Transaction.created_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
transactions = list(result.scalars())
|
||||
|
||||
matched_transaction = None
|
||||
for transaction in transactions:
|
||||
if amount_filter and transaction.amount_kopeks < amount_filter:
|
||||
continue
|
||||
matched_transaction = transaction
|
||||
break
|
||||
|
||||
if matched_transaction:
|
||||
status_code = "paid"
|
||||
status_label = "paid"
|
||||
is_paid = True
|
||||
is_final = True
|
||||
updated_at = matched_transaction.updated_at or matched_transaction.created_at
|
||||
details = {
|
||||
"transaction_id": matched_transaction.id,
|
||||
"amount_kopeks": matched_transaction.amount_kopeks,
|
||||
}
|
||||
else:
|
||||
status_code = "pending"
|
||||
status_label = "pending"
|
||||
is_paid = False
|
||||
is_failed = False
|
||||
is_final = False
|
||||
updated_at = datetime.utcnow()
|
||||
details = {
|
||||
"checked_transactions": [
|
||||
transaction.id for transaction in transactions
|
||||
]
|
||||
}
|
||||
|
||||
else:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Unsupported payment method")
|
||||
|
||||
return MiniAppPaymentStatusResponse(
|
||||
method=method,
|
||||
status=status_code,
|
||||
status_label=status_label,
|
||||
is_paid=is_paid,
|
||||
is_failed=is_failed,
|
||||
is_final=is_final,
|
||||
updated_at=updated_at,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
_TEMPLATE_ID_PATTERN = re.compile(r"promo_template_(?P<template_id>\d+)$")
|
||||
_OFFER_TYPE_ICONS = {
|
||||
"extend_discount": "💎",
|
||||
|
||||
@@ -253,69 +253,6 @@ class MiniAppReferralInfo(BaseModel):
|
||||
referrals: Optional[MiniAppReferralList] = None
|
||||
|
||||
|
||||
class MiniAppPaymentMethodsRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
|
||||
|
||||
class MiniAppPaymentOption(BaseModel):
|
||||
id: str
|
||||
label: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppPaymentMethod(BaseModel):
|
||||
id: str
|
||||
icon: Optional[str] = None
|
||||
requires_amount: bool = False
|
||||
currency: str = "RUB"
|
||||
min_amount_kopeks: Optional[int] = None
|
||||
max_amount_kopeks: Optional[int] = None
|
||||
amount_step_kopeks: Optional[int] = None
|
||||
options: List[MiniAppPaymentOption] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MiniAppPaymentMethodsResponse(BaseModel):
|
||||
methods: List[MiniAppPaymentMethod] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MiniAppPaymentCreateRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
method: str
|
||||
amount_rubles: Optional[float] = Field(default=None, alias="amountRubles")
|
||||
amount_kopeks: Optional[int] = Field(default=None, alias="amountKopeks")
|
||||
payment_option: Optional[str] = Field(default=None, alias="paymentOption")
|
||||
|
||||
|
||||
class MiniAppPaymentCreateResponse(BaseModel):
|
||||
success: bool = True
|
||||
method: str
|
||||
payment_url: Optional[str] = None
|
||||
amount_kopeks: Optional[int] = None
|
||||
extra: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MiniAppPaymentStatusRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
method: str
|
||||
local_payment_id: Optional[int] = Field(default=None, alias="localPaymentId")
|
||||
provider_payment_id: Optional[str] = Field(default=None, alias="providerPaymentId")
|
||||
invoice_id: Optional[str] = Field(default=None, alias="invoiceId")
|
||||
amount_kopeks: Optional[int] = Field(default=None, alias="amountKopeks")
|
||||
started_at: Optional[datetime] = Field(default=None, alias="startedAt")
|
||||
|
||||
|
||||
class MiniAppPaymentStatusResponse(BaseModel):
|
||||
success: bool = True
|
||||
method: str
|
||||
status: str
|
||||
status_label: Optional[str] = None
|
||||
is_paid: bool = False
|
||||
is_failed: bool = False
|
||||
is_final: bool = False
|
||||
updated_at: Optional[datetime] = None
|
||||
details: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MiniAppSubscriptionResponse(BaseModel):
|
||||
success: bool = True
|
||||
subscription_id: int
|
||||
|
||||
1616
miniapp/index.html
1616
miniapp/index.html
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user