Compare commits
166 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b036d090c6 | |||
| d44b303dd3 | |||
| e69bfdac20 | |||
| 53f6c4e71b | |||
| c67223a332 | |||
| 2c253382d5 | |||
| 02b65cb1c9 | |||
| 49ace78a68 | |||
| 0669086742 | |||
| 2f2d9bc1af | |||
| 9b2b11ee44 | |||
| d318738596 | |||
| 6ae41369e9 | |||
| ce9f2e0d41 | |||
| aa7bd42375 | |||
| a6b19e4b95 | |||
| 7a21518ce2 | |||
| ba92f7da6c | |||
| f62f37581f | |||
| 0d08c2c7f0 | |||
| 078e59f09c | |||
| ab708a78a8 | |||
| 630688ecf5 | |||
| 2b6901e87a | |||
| 0e43daf48b | |||
| e9badff1d8 | |||
| f588191931 | |||
| e17dfbff35 | |||
| b7e9770588 | |||
| 9371524257 | |||
| c1f8fe69cf | |||
| 87e3449444 | |||
| e8ef1bc255 | |||
| fe3a593da0 | |||
| bf96d85edb | |||
| 700d718da6 | |||
| 0c9ad3cec8 | |||
| 73d789d3f7 | |||
| 0a09ae4290 | |||
| 0758988b5f | |||
| 089f668e14 | |||
| 1e5bf143c4 | |||
| d1132c82f8 | |||
| 62545276e4 | |||
| 2878bc3c3d | |||
| eed0fe39e7 | |||
| 6bbee5586f | |||
| 7ac2354197 | |||
| 3463f08162 | |||
| 4b0aaa02c7 | |||
| a946f7b501 | |||
| 7f2e4615ee | |||
| 28ab7719f5 | |||
| 31b5101f64 | |||
| 62777f8995 | |||
| f9cff3e169 | |||
| 04c255d748 | |||
| aae771764e | |||
| d4e1544fc8 | |||
| 6db5cae013 | |||
| 61089a9f30 | |||
| 3ad1f0fc97 | |||
| 67ac3c933f | |||
| d6f4751274 | |||
| 6fdb02af10 | |||
| ce456c4f7a | |||
| 314b17ce79 | |||
| 598d9ad0a1 | |||
| 734711dab7 | |||
| c4c5d6a018 | |||
| 883cd5a952 | |||
| 8cf7445b99 | |||
| 4c11af4644 | |||
| 47036d5616 | |||
| 025330e052 | |||
| 8d18c78fb2 | |||
| d93a2a0682 | |||
| 0c93516056 | |||
| bf126bdadb | |||
| d421d43522 | |||
| 29cda4dcd7 | |||
| dde90f9ca4 | |||
| 8415c68063 | |||
| 9edde19b8a | |||
| 83a473606f | |||
| 849f06ad2b | |||
| e27e1299f7 | |||
| 03a45e39d4 | |||
| 4663464ea4 | |||
| 338b98bae3 | |||
| 7326d64fbe | |||
| bee94bcad0 | |||
| 414b796700 | |||
| f76abce939 | |||
| 99460665f1 | |||
| 730a6ae185 | |||
| ab8a5ca336 | |||
| d1f0cf5854 | |||
| 1f3c85e182 | |||
| bfcfe0de3d | |||
| f55f745080 | |||
| a26524e6ea | |||
| b5f57d4d0e | |||
| 5aa9c1316e | |||
| 524b4f5e64 | |||
| a2bb0b8341 | |||
| 9cabbf72d2 | |||
| 5f368f84ce | |||
| ed01d2bc6f | |||
| 7107070624 | |||
| c361f0c1d4 | |||
| ab95bc2192 | |||
| bf6a68ae27 | |||
| 1083651591 | |||
| b35fb824ec | |||
| ad839fb63d | |||
| 377a731ca2 | |||
| 3395021fcf | |||
| a4ce1a83be | |||
| 8b2f9e1803 | |||
| a40c07a6af | |||
| 2dbc23fa80 | |||
| 233401c057 | |||
| 66f17f90fd | |||
| 9748cc6b3e | |||
| bcab53cfe3 | |||
| 1af387de2b | |||
| 25c54c588b | |||
| 6ba7c6ed5e | |||
| 222c1a488f | |||
| c1a92adf41 | |||
| 972bb18344 | |||
| de11de4cd0 | |||
| f8c3daac23 | |||
| 91a3b14a1f | |||
| d310896168 | |||
| d4df56b173 | |||
| 3eab0b3848 | |||
| 609a557502 | |||
| 6a4ebad89f | |||
| e229b625f6 | |||
| 3ff5dec4d3 | |||
| 6dfd7db115 | |||
| 782dff4f82 | |||
| 1540d2665f | |||
| 567d0c1a8d | |||
| 6b031d4e9c | |||
| 72046d1ccf | |||
| d41af2cd85 | |||
| 174647c963 | |||
| e5e52b839c | |||
| ab19becdc8 | |||
| f5e9a6f2b9 | |||
| b492437b54 | |||
| 07546da0fd | |||
| fc4423d9de | |||
| c9a0f59e28 | |||
| 2d31bc6f23 | |||
| dac2e1c85f | |||
| f4d9b0d219 | |||
| 9b485bf822 | |||
| af3e8eab4e | |||
| 63d7500398 | |||
| 545f52f207 | |||
| 7fbb4cf2da | |||
| 7be8757964 |
+31
-3
@@ -113,12 +113,24 @@ FIXED_TRAFFIC_LIMIT_GB=100
|
||||
AVAILABLE_SUBSCRIPTION_PERIODS=30,90,180
|
||||
AVAILABLE_RENEWAL_PERIODS=30,90,180
|
||||
|
||||
# ===== НАСТРОЙКИ ПРОСТОЙ ПОКУПКИ =====
|
||||
# Включить упрощённую покупку из меню
|
||||
SIMPLE_SUBSCRIPTION_ENABLED=false
|
||||
# Стандартный период (должен совпадать с одним из AVAILABLE_SUBSCRIPTION_PERIODS)
|
||||
SIMPLE_SUBSCRIPTION_PERIOD_DAYS=30
|
||||
# Сколько устройств выдаётся в рамках простой подписки
|
||||
SIMPLE_SUBSCRIPTION_DEVICE_LIMIT=1
|
||||
# Лимит трафика в ГБ (0 — безлимит)
|
||||
SIMPLE_SUBSCRIPTION_TRAFFIC_GB=0
|
||||
# UUID сквада (оставьте пустым, чтобы использовать сквады по умолчанию)
|
||||
# SIMPLE_SUBSCRIPTION_SQUAD_UUID=
|
||||
|
||||
# ===== ЦЕНЫ (в копейках) =====
|
||||
BASE_SUBSCRIPTION_PRICE=0
|
||||
|
||||
# Цены за периоды
|
||||
PRICE_14_DAYS=7000
|
||||
PRICE_30_DAYS=9900
|
||||
PRICE_30_DAYS=1000
|
||||
PRICE_60_DAYS=25900
|
||||
PRICE_90_DAYS=36900
|
||||
PRICE_180_DAYS=69900
|
||||
@@ -130,10 +142,10 @@ BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED=false
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
|
||||
|
||||
# Выводимые пакеты трафика и их цены в копейках
|
||||
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
|
||||
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:0:true"
|
||||
|
||||
# Цена за дополнительное устройство (DEFAULT_DEVICE_LIMIT идет бесплатно!)
|
||||
PRICE_PER_DEVICE=5000
|
||||
PRICE_PER_DEVICE=10000
|
||||
|
||||
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
|
||||
REFERRAL_PROGRAM_ENABLED=true
|
||||
@@ -249,6 +261,22 @@ CRYPTOBOT_DEFAULT_ASSET=USDT
|
||||
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
|
||||
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
|
||||
|
||||
# HELEKET
|
||||
HELEKET_ENABLED=false
|
||||
HELEKET_MERCHANT_ID=
|
||||
HELEKET_API_KEY=
|
||||
HELEKET_BASE_URL=https://api.heleket.com/v1
|
||||
HELEKET_DEFAULT_CURRENCY=USDT
|
||||
HELEKET_DEFAULT_NETWORK=
|
||||
HELEKET_INVOICE_LIFETIME=3600
|
||||
HELEKET_MARKUP_PERCENT=0
|
||||
HELEKET_WEBHOOK_PATH=/heleket-webhook
|
||||
HELEKET_WEBHOOK_HOST=0.0.0.0
|
||||
HELEKET_WEBHOOK_PORT=8086
|
||||
HELEKET_CALLBACK_URL=
|
||||
HELEKET_RETURN_URL=
|
||||
HELEKET_SUCCESS_URL=
|
||||
|
||||
# MULENPAY
|
||||
MULENPAY_ENABLED=false
|
||||
MULENPAY_API_KEY=
|
||||
|
||||
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.5.2-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.4-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.5.2-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.4-dev-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.5.2-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.4-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.5.2-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.4-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.5.2-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.4-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.5.2-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.4-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -15,8 +15,6 @@
|
||||
# Разрешаем папку app/ и все её содержимое рекурсивно
|
||||
!app/
|
||||
!app/**
|
||||
!locales/
|
||||
!locales/**
|
||||
!tests/
|
||||
!tests/**
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.5.2"
|
||||
ARG VERSION="v2.5.4"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from app.handlers import (
|
||||
start, menu, subscription, balance, promocode,
|
||||
referral, support, server_status, common, tickets
|
||||
)
|
||||
from app.handlers import simple_subscription
|
||||
from app.handlers.admin import (
|
||||
main as admin_main,
|
||||
users as admin_users,
|
||||
@@ -162,7 +163,10 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_faq.register_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
simple_subscription.register_simple_subscription_handlers(dp)
|
||||
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
|
||||
logger.info("⚡ Зарегистрированы обработчики простой покупки")
|
||||
logger.info("⚡ Зарегистрированы обработчики простой подписки")
|
||||
|
||||
try:
|
||||
await maintenance_service.start_monitoring()
|
||||
|
||||
@@ -182,6 +182,13 @@ class Settings(BaseSettings):
|
||||
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
|
||||
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
|
||||
DISABLE_TOPUP_BUTTONS: bool = False
|
||||
|
||||
# Настройки простой покупки
|
||||
SIMPLE_SUBSCRIPTION_ENABLED: bool = False
|
||||
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
|
||||
SIMPLE_SUBSCRIPTION_DEVICE_LIMIT: int = 1
|
||||
SIMPLE_SUBSCRIPTION_TRAFFIC_GB: int = 0 # 0 означает безлимит
|
||||
SIMPLE_SUBSCRIPTION_SQUAD_UUID: Optional[str] = None
|
||||
PAYMENT_BALANCE_DESCRIPTION: str = "Пополнение баланса"
|
||||
PAYMENT_SUBSCRIPTION_DESCRIPTION: str = "Оплата подписки"
|
||||
PAYMENT_SERVICE_NAME: str = "Интернет-сервис"
|
||||
@@ -199,12 +206,28 @@ class Settings(BaseSettings):
|
||||
CRYPTOBOT_ASSETS: str = "USDT,TON,BTC,ETH"
|
||||
CRYPTOBOT_INVOICE_EXPIRES_HOURS: int = 24
|
||||
|
||||
HELEKET_ENABLED: bool = False
|
||||
HELEKET_MERCHANT_ID: Optional[str] = None
|
||||
HELEKET_API_KEY: Optional[str] = None
|
||||
HELEKET_BASE_URL: str = "https://api.heleket.com/v1"
|
||||
HELEKET_DEFAULT_CURRENCY: str = "USDT"
|
||||
HELEKET_DEFAULT_NETWORK: Optional[str] = None
|
||||
HELEKET_INVOICE_LIFETIME: int = 3600
|
||||
HELEKET_MARKUP_PERCENT: float = 0.0
|
||||
HELEKET_WEBHOOK_PATH: str = "/heleket-webhook"
|
||||
HELEKET_WEBHOOK_HOST: str = "0.0.0.0"
|
||||
HELEKET_WEBHOOK_PORT: int = 8086
|
||||
HELEKET_CALLBACK_URL: Optional[str] = None
|
||||
HELEKET_RETURN_URL: Optional[str] = None
|
||||
HELEKET_SUCCESS_URL: Optional[str] = None
|
||||
|
||||
MULENPAY_ENABLED: bool = False
|
||||
MULENPAY_API_KEY: Optional[str] = None
|
||||
MULENPAY_SECRET_KEY: Optional[str] = None
|
||||
MULENPAY_SHOP_ID: Optional[int] = None
|
||||
MULENPAY_BASE_URL: str = "https://mulenpay.ru/api"
|
||||
MULENPAY_WEBHOOK_PATH: str = "/mulenpay-webhook"
|
||||
MULENPAY_DISPLAY_NAME: str = "Mulen Pay"
|
||||
MULENPAY_DESCRIPTION: str = "Пополнение баланса"
|
||||
MULENPAY_LANGUAGE: str = "ru"
|
||||
MULENPAY_VAT_CODE: int = 0
|
||||
@@ -245,6 +268,11 @@ class Settings(BaseSettings):
|
||||
WATA_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
WATA_MAX_AMOUNT_KOPEKS: int = 100000000
|
||||
WATA_REQUEST_TIMEOUT: int = 30
|
||||
WATA_WEBHOOK_PATH: str = "/wata-webhook"
|
||||
WATA_WEBHOOK_HOST: str = "0.0.0.0"
|
||||
WATA_WEBHOOK_PORT: int = 8085
|
||||
WATA_PUBLIC_KEY_URL: Optional[str] = None
|
||||
WATA_PUBLIC_KEY_CACHE_SECONDS: int = 3600
|
||||
|
||||
MAIN_MENU_MODE: str = "default"
|
||||
CONNECT_BUTTON_MODE: str = "guide"
|
||||
@@ -773,6 +801,13 @@ class Settings(BaseSettings):
|
||||
return (self.CRYPTOBOT_ENABLED and
|
||||
self.CRYPTOBOT_API_TOKEN is not None)
|
||||
|
||||
def is_heleket_enabled(self) -> bool:
|
||||
return (
|
||||
self.HELEKET_ENABLED
|
||||
and self.HELEKET_MERCHANT_ID is not None
|
||||
and self.HELEKET_API_KEY is not None
|
||||
)
|
||||
|
||||
def is_mulenpay_enabled(self) -> bool:
|
||||
return (
|
||||
self.MULENPAY_ENABLED
|
||||
@@ -781,6 +816,15 @@ class Settings(BaseSettings):
|
||||
and self.MULENPAY_SHOP_ID is not None
|
||||
)
|
||||
|
||||
def get_mulenpay_display_name(self) -> str:
|
||||
name = (self.MULENPAY_DISPLAY_NAME or "").strip()
|
||||
if not name:
|
||||
return "Mulen Pay"
|
||||
return name
|
||||
|
||||
def get_mulenpay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_mulenpay_display_name())
|
||||
|
||||
def is_pal24_enabled(self) -> bool:
|
||||
return (
|
||||
self.PAL24_ENABLED
|
||||
@@ -812,6 +856,26 @@ class Settings(BaseSettings):
|
||||
def get_cryptobot_invoice_expires_seconds(self) -> int:
|
||||
return self.CRYPTOBOT_INVOICE_EXPIRES_HOURS * 3600
|
||||
|
||||
def get_heleket_markup_percent(self) -> float:
|
||||
try:
|
||||
return float(self.HELEKET_MARKUP_PERCENT)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
def get_heleket_lifetime(self) -> int:
|
||||
try:
|
||||
value = int(self.HELEKET_INVOICE_LIFETIME)
|
||||
except (TypeError, ValueError):
|
||||
value = 3600
|
||||
return max(300, min(43200, value))
|
||||
|
||||
def get_heleket_callback_url(self) -> Optional[str]:
|
||||
if self.HELEKET_CALLBACK_URL:
|
||||
return self.HELEKET_CALLBACK_URL
|
||||
if self.WEBHOOK_URL:
|
||||
return f"{self.WEBHOOK_URL}{self.HELEKET_WEBHOOK_PATH}"
|
||||
return None
|
||||
|
||||
def is_happ_cryptolink_mode(self) -> bool:
|
||||
return self.CONNECT_BUTTON_MODE == "happ_cryptolink"
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import HeleketPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_heleket_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
uuid: str,
|
||||
order_id: str,
|
||||
amount: str,
|
||||
currency: str,
|
||||
status: str,
|
||||
payer_amount: Optional[str] = None,
|
||||
payer_currency: Optional[str] = None,
|
||||
exchange_rate: Optional[float] = None,
|
||||
discount_percent: Optional[int] = None,
|
||||
payment_url: Optional[str] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> HeleketPayment:
|
||||
payment = HeleketPayment(
|
||||
user_id=user_id,
|
||||
uuid=uuid,
|
||||
order_id=order_id,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
status=status,
|
||||
payer_amount=payer_amount,
|
||||
payer_currency=payer_currency,
|
||||
exchange_rate=exchange_rate,
|
||||
discount_percent=discount_percent,
|
||||
payment_url=payment_url,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata or {},
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Создан Heleket платеж: uuid=%s order_id=%s amount=%s %s для пользователя %s",
|
||||
uuid,
|
||||
order_id,
|
||||
amount,
|
||||
currency,
|
||||
user_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def get_heleket_payment_by_uuid(
|
||||
db: AsyncSession,
|
||||
uuid: str,
|
||||
) -> Optional[HeleketPayment]:
|
||||
result = await db.execute(
|
||||
select(HeleketPayment)
|
||||
.options(selectinload(HeleketPayment.user))
|
||||
.where(HeleketPayment.uuid == uuid)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_heleket_payment_by_order_id(
|
||||
db: AsyncSession,
|
||||
order_id: str,
|
||||
) -> Optional[HeleketPayment]:
|
||||
result = await db.execute(
|
||||
select(HeleketPayment)
|
||||
.options(selectinload(HeleketPayment.user))
|
||||
.where(HeleketPayment.order_id == order_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_heleket_payment_by_id(
|
||||
db: AsyncSession,
|
||||
payment_id: int,
|
||||
) -> Optional[HeleketPayment]:
|
||||
result = await db.execute(
|
||||
select(HeleketPayment)
|
||||
.options(selectinload(HeleketPayment.user))
|
||||
.where(HeleketPayment.id == payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_heleket_payment(
|
||||
db: AsyncSession,
|
||||
uuid: str,
|
||||
*,
|
||||
status: Optional[str] = None,
|
||||
payer_amount: Optional[str] = None,
|
||||
payer_currency: Optional[str] = None,
|
||||
exchange_rate: Optional[float] = None,
|
||||
discount_percent: Optional[int] = None,
|
||||
paid_at: Optional[datetime] = None,
|
||||
payment_url: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[HeleketPayment]:
|
||||
payment = await get_heleket_payment_by_uuid(db, uuid)
|
||||
|
||||
if not payment:
|
||||
logger.error("Heleket платеж с uuid=%s не найден", uuid)
|
||||
return None
|
||||
|
||||
if status is not None:
|
||||
payment.status = status
|
||||
if payer_amount is not None:
|
||||
payment.payer_amount = payer_amount
|
||||
if payer_currency is not None:
|
||||
payment.payer_currency = payer_currency
|
||||
if exchange_rate is not None:
|
||||
payment.exchange_rate = exchange_rate
|
||||
if discount_percent is not None:
|
||||
payment.discount_percent = discount_percent
|
||||
if payment_url is not None:
|
||||
payment.payment_url = payment_url
|
||||
if metadata is not None:
|
||||
existing = dict(payment.metadata_json or {})
|
||||
existing.update(metadata)
|
||||
payment.metadata_json = existing
|
||||
if paid_at is not None:
|
||||
payment.paid_at = paid_at
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Обновлен Heleket платеж %s: статус=%s payer_amount=%s %s",
|
||||
uuid,
|
||||
payment.status,
|
||||
payment.payer_amount,
|
||||
payment.payer_currency,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def link_heleket_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
uuid: str,
|
||||
transaction_id: int,
|
||||
) -> Optional[HeleketPayment]:
|
||||
payment = await get_heleket_payment_by_uuid(db, uuid)
|
||||
|
||||
if not payment:
|
||||
logger.error("Не найден Heleket платеж для связи с транзакцией: %s", uuid)
|
||||
return None
|
||||
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Heleket платеж %s связан с транзакцией %s",
|
||||
uuid,
|
||||
transaction_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
@@ -5,6 +5,7 @@ from typing import Optional
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import MulenPayPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -40,7 +41,8 @@ async def create_mulenpay_payment(
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Создан MulenPay платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s",
|
||||
"Создан %s платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s",
|
||||
settings.get_mulenpay_display_name(),
|
||||
payment.mulen_payment_id,
|
||||
uuid,
|
||||
amount_kopeks,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy import select, update
|
||||
@@ -87,6 +88,7 @@ async def update_pal24_payment_status(
|
||||
status: str,
|
||||
is_active: Optional[bool] = None,
|
||||
is_paid: Optional[bool] = None,
|
||||
paid_at: Optional[datetime] = None,
|
||||
payment_id: Optional[str] = None,
|
||||
payment_status: Optional[str] = None,
|
||||
payment_method: Optional[str] = None,
|
||||
@@ -103,6 +105,8 @@ async def update_pal24_payment_status(
|
||||
update_values["is_active"] = is_active
|
||||
if is_paid is not None:
|
||||
update_values["is_paid"] = is_paid
|
||||
if paid_at is not None:
|
||||
update_values["paid_at"] = paid_at
|
||||
if payment_id is not None:
|
||||
update_values["payment_id"] = payment_id
|
||||
if payment_status is not None:
|
||||
|
||||
@@ -167,6 +167,58 @@ async def get_available_server_squads(
|
||||
return result.scalars().unique().all()
|
||||
|
||||
|
||||
async def get_active_server_squads(db: AsyncSession) -> List[ServerSquad]:
|
||||
"""Возвращает список активных серверов, доступных для подключения."""
|
||||
|
||||
squads = await get_available_server_squads(db)
|
||||
|
||||
if not squads:
|
||||
return []
|
||||
|
||||
eligible: List[ServerSquad] = []
|
||||
|
||||
for squad in squads:
|
||||
max_users = squad.max_users
|
||||
current_users = squad.current_users or 0
|
||||
|
||||
if max_users is not None and current_users >= max_users:
|
||||
continue
|
||||
|
||||
eligible.append(squad)
|
||||
|
||||
if eligible:
|
||||
return eligible
|
||||
|
||||
return squads
|
||||
|
||||
|
||||
async def choose_random_active_server_squad(
|
||||
db: AsyncSession,
|
||||
) -> Optional[ServerSquad]:
|
||||
"""Возвращает случайный активный сервер."""
|
||||
|
||||
squads = await get_active_server_squads(db)
|
||||
|
||||
if not squads:
|
||||
return None
|
||||
|
||||
return random.choice(squads)
|
||||
|
||||
|
||||
async def get_random_active_squad_uuid(
|
||||
db: AsyncSession,
|
||||
fallback_uuid: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Возвращает UUID случайного активного сервера или запасной UUID."""
|
||||
|
||||
squad = await choose_random_active_server_squad(db)
|
||||
|
||||
if squad:
|
||||
return squad.squad_uuid
|
||||
|
||||
return fallback_uuid
|
||||
|
||||
|
||||
async def update_server_squad_promo_groups(
|
||||
db: AsyncSession, server_id: int, promo_group_ids: Iterable[int]
|
||||
) -> Optional[ServerSquad]:
|
||||
|
||||
@@ -30,6 +30,7 @@ async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Optiona
|
||||
subscription = result.scalar_one_or_none()
|
||||
|
||||
if subscription:
|
||||
logger.info(f"🔍 Загружена подписка {subscription.id} для пользователя {user_id}, статус: {subscription.status}")
|
||||
subscription = await check_and_update_subscription_status(db, subscription)
|
||||
|
||||
return subscription
|
||||
@@ -149,7 +150,7 @@ async def create_paid_subscription(
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"💎 Создана платная подписка для пользователя {user_id}")
|
||||
logger.info(f"💎 Создана платная подписка для пользователя {user_id}, ID: {subscription.id}, статус: {subscription.status}")
|
||||
|
||||
squad_uuids = list(connected_squads or [])
|
||||
if update_server_counters and squad_uuids:
|
||||
@@ -223,6 +224,9 @@ async def extend_subscription(
|
||||
if subscription.user:
|
||||
subscription.user.has_had_paid_subscription = True
|
||||
|
||||
# Логируем статус подписки перед проверкой
|
||||
logger.info(f"🔄 Продление подписки {subscription.id}, текущий статус: {subscription.status}, дни: {days}")
|
||||
|
||||
if days > 0 and subscription.status in (
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
@@ -234,6 +238,12 @@ async def extend_subscription(
|
||||
subscription.id,
|
||||
previous_status,
|
||||
)
|
||||
elif days > 0 and subscription.status == SubscriptionStatus.PENDING.value:
|
||||
logger.warning(
|
||||
"⚠️ Попытка продлить PENDING подписку %s, дни: %s",
|
||||
subscription.id,
|
||||
days
|
||||
)
|
||||
|
||||
if settings.RESET_TRAFFIC_ON_PAYMENT:
|
||||
subscription.traffic_used_gb = 0.0
|
||||
@@ -1117,6 +1127,8 @@ async def check_and_update_subscription_status(
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
logger.info(f"🔍 Проверка статуса подписки {subscription.id}, текущий статус: {subscription.status}, дата окончания: {subscription.end_date}, текущее время: {current_time}")
|
||||
|
||||
if (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date <= current_time):
|
||||
|
||||
@@ -1127,6 +1139,8 @@ async def check_and_update_subscription_status(
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"⏰ Статус подписки пользователя {subscription.user_id} изменен на 'expired'")
|
||||
elif subscription.status == SubscriptionStatus.PENDING.value:
|
||||
logger.info(f"ℹ️ Проверка PENDING подписки {subscription.id}, статус остается без изменений")
|
||||
|
||||
return subscription
|
||||
|
||||
@@ -1183,3 +1197,130 @@ async def create_subscription(
|
||||
|
||||
logger.info(f"✅ Создана подписка для пользователя {user_id}")
|
||||
return subscription
|
||||
|
||||
|
||||
async def create_pending_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
duration_days: int,
|
||||
traffic_limit_gb: int = 0,
|
||||
device_limit: int = 1,
|
||||
connected_squads: List[str] = None,
|
||||
payment_method: str = "pending",
|
||||
total_price_kopeks: int = 0
|
||||
) -> Subscription:
|
||||
"""Creates a pending subscription that will be activated after payment."""
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
end_date = current_time + timedelta(days=duration_days)
|
||||
|
||||
existing_subscription = await get_subscription_by_user_id(db, user_id)
|
||||
|
||||
if existing_subscription:
|
||||
if (
|
||||
existing_subscription.status == SubscriptionStatus.ACTIVE.value
|
||||
and existing_subscription.end_date > current_time
|
||||
):
|
||||
logger.warning(
|
||||
"⚠️ Попытка создать pending подписку для активного пользователя %s. Возвращаем существующую запись.",
|
||||
user_id,
|
||||
)
|
||||
return existing_subscription
|
||||
|
||||
existing_subscription.status = SubscriptionStatus.PENDING.value
|
||||
existing_subscription.is_trial = False
|
||||
existing_subscription.start_date = current_time
|
||||
existing_subscription.end_date = end_date
|
||||
existing_subscription.traffic_limit_gb = traffic_limit_gb
|
||||
existing_subscription.device_limit = device_limit
|
||||
existing_subscription.connected_squads = connected_squads or []
|
||||
existing_subscription.traffic_used_gb = 0.0
|
||||
existing_subscription.updated_at = current_time
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_subscription)
|
||||
|
||||
logger.info(
|
||||
"♻️ Обновлена ожидающая подписка пользователя %s, ID: %s, метод оплаты: %s",
|
||||
user_id,
|
||||
existing_subscription.id,
|
||||
payment_method,
|
||||
)
|
||||
return existing_subscription
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.PENDING.value,
|
||||
is_trial=False,
|
||||
start_date=current_time,
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads or [],
|
||||
autopay_enabled=settings.is_autopay_enabled_by_default(),
|
||||
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(
|
||||
"💳 Создана ожидающая подписка для пользователя %s, ID: %s, метод оплаты: %s",
|
||||
user_id,
|
||||
subscription.id,
|
||||
payment_method,
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
async def activate_pending_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
period_days: int = None
|
||||
) -> Optional[Subscription]:
|
||||
"""Активирует pending подписку пользователя, меняя её статус на ACTIVE."""
|
||||
from sqlalchemy import and_
|
||||
|
||||
logger.info(f"Активация pending подписки: пользователь {user_id}, период {period_days} дней")
|
||||
|
||||
# Находим pending подписку пользователя
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.where(
|
||||
and_(
|
||||
Subscription.user_id == user_id,
|
||||
Subscription.status == SubscriptionStatus.PENDING.value
|
||||
)
|
||||
)
|
||||
)
|
||||
pending_subscription = result.scalar_one_or_none()
|
||||
|
||||
if not pending_subscription:
|
||||
logger.warning(f"Не найдена pending подписка для пользователя {user_id}")
|
||||
return None
|
||||
|
||||
logger.info(f"Найдена pending подписка {pending_subscription.id} для пользователя {user_id}, статус: {pending_subscription.status}")
|
||||
|
||||
# Обновляем статус подписки на ACTIVE
|
||||
current_time = datetime.utcnow()
|
||||
pending_subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
|
||||
# Если указан период, обновляем дату окончания
|
||||
if period_days is not None:
|
||||
effective_start = pending_subscription.start_date or current_time
|
||||
if effective_start < current_time:
|
||||
effective_start = current_time
|
||||
pending_subscription.end_date = effective_start + timedelta(days=period_days)
|
||||
|
||||
# Обновляем дату начала, если она не установлена или в прошлом
|
||||
if not pending_subscription.start_date or pending_subscription.start_date < current_time:
|
||||
pending_subscription.start_date = current_time
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(pending_subscription)
|
||||
|
||||
logger.info(f"Подписка пользователя {user_id} активирована, ID: {pending_subscription.id}")
|
||||
|
||||
return pending_subscription
|
||||
|
||||
@@ -274,6 +274,30 @@ class TicketCRUD:
|
||||
db, ticket_id, TicketStatus.CLOSED.value, datetime.utcnow()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def close_all_open_tickets(
|
||||
db: AsyncSession,
|
||||
) -> List[int]:
|
||||
"""Закрыть все открытые тикеты. Возвращает список идентификаторов закрытых тикетов."""
|
||||
open_statuses = [TicketStatus.OPEN.value, TicketStatus.ANSWERED.value]
|
||||
result = await db.execute(
|
||||
select(Ticket.id).where(Ticket.status.in_(open_statuses))
|
||||
)
|
||||
ticket_ids = result.scalars().all()
|
||||
|
||||
if not ticket_ids:
|
||||
return []
|
||||
|
||||
now = datetime.utcnow()
|
||||
await db.execute(
|
||||
update(Ticket)
|
||||
.where(Ticket.id.in_(ticket_ids))
|
||||
.values(status=TicketStatus.CLOSED.value, closed_at=now, updated_at=now)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return ticket_ids
|
||||
|
||||
@staticmethod
|
||||
async def add_support_audit(
|
||||
db: AsyncSession,
|
||||
|
||||
+69
-1
@@ -1,5 +1,5 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict
|
||||
from typing import Optional, List, Dict, Any
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import (
|
||||
@@ -54,6 +54,7 @@ class SubscriptionStatus(Enum):
|
||||
ACTIVE = "active"
|
||||
EXPIRED = "expired"
|
||||
DISABLED = "disabled"
|
||||
PENDING = "pending"
|
||||
|
||||
|
||||
class TransactionType(Enum):
|
||||
@@ -75,6 +76,7 @@ class PaymentMethod(Enum):
|
||||
TRIBUTE = "tribute"
|
||||
YOOKASSA = "yookassa"
|
||||
CRYPTOBOT = "cryptobot"
|
||||
HELEKET = "heleket"
|
||||
MULENPAY = "mulenpay"
|
||||
PAL24 = "pal24"
|
||||
WATA = "wata"
|
||||
@@ -189,6 +191,72 @@ class CryptoBotPayment(Base):
|
||||
return f"<CryptoBotPayment(id={self.id}, invoice_id={self.invoice_id}, amount={self.amount} {self.asset}, status={self.status})>"
|
||||
|
||||
|
||||
class HeleketPayment(Base):
|
||||
__tablename__ = "heleket_payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
uuid = Column(String(255), unique=True, nullable=False, index=True)
|
||||
order_id = Column(String(128), unique=True, nullable=False, index=True)
|
||||
|
||||
amount = Column(String(50), nullable=False)
|
||||
currency = Column(String(10), nullable=False)
|
||||
payer_amount = Column(String(50), nullable=True)
|
||||
payer_currency = Column(String(10), nullable=True)
|
||||
exchange_rate = Column(Float, nullable=True)
|
||||
discount_percent = Column(Integer, nullable=True)
|
||||
|
||||
status = Column(String(50), nullable=False)
|
||||
payment_url = Column(Text, nullable=True)
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
|
||||
paid_at = Column(DateTime, nullable=True)
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship("User", backref="heleket_payments")
|
||||
transaction = relationship("Transaction", backref="heleket_payment")
|
||||
|
||||
@property
|
||||
def amount_float(self) -> float:
|
||||
try:
|
||||
return float(self.amount)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def amount_kopeks(self) -> int:
|
||||
return int(round(self.amount_float * 100))
|
||||
|
||||
@property
|
||||
def payer_amount_float(self) -> float:
|
||||
try:
|
||||
return float(self.payer_amount) if self.payer_amount is not None else 0.0
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def is_paid(self) -> bool:
|
||||
return self.status in {"paid", "paid_over"}
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
"<HeleketPayment(id={id}, uuid={uuid}, order_id={order_id}, amount={amount}"
|
||||
" {currency}, status={status})>"
|
||||
).format(
|
||||
id=self.id,
|
||||
uuid=self.uuid,
|
||||
order_id=self.order_id,
|
||||
amount=self.amount,
|
||||
currency=self.currency,
|
||||
status=self.status,
|
||||
)
|
||||
|
||||
|
||||
class MulenPayPayment(Base):
|
||||
__tablename__ = "mulenpay_payments"
|
||||
|
||||
|
||||
@@ -576,6 +576,120 @@ async def create_cryptobot_payments_table():
|
||||
return False
|
||||
|
||||
|
||||
async def create_heleket_payments_table():
|
||||
table_exists = await check_table_exists('heleket_payments')
|
||||
if table_exists:
|
||||
logger.info("Таблица heleket_payments уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
create_sql = """
|
||||
CREATE TABLE heleket_payments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
uuid VARCHAR(255) UNIQUE NOT NULL,
|
||||
order_id VARCHAR(128) UNIQUE NOT NULL,
|
||||
amount VARCHAR(50) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
payer_amount VARCHAR(50) NULL,
|
||||
payer_currency VARCHAR(10) NULL,
|
||||
exchange_rate DOUBLE PRECISION NULL,
|
||||
discount_percent INTEGER NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
payment_url TEXT NULL,
|
||||
metadata_json JSON NULL,
|
||||
paid_at DATETIME NULL,
|
||||
expires_at DATETIME NULL,
|
||||
transaction_id INTEGER NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_heleket_payments_user_id ON heleket_payments(user_id);
|
||||
CREATE INDEX idx_heleket_payments_uuid ON heleket_payments(uuid);
|
||||
CREATE INDEX idx_heleket_payments_order_id ON heleket_payments(order_id);
|
||||
CREATE INDEX idx_heleket_payments_status ON heleket_payments(status);
|
||||
"""
|
||||
|
||||
elif db_type == 'postgresql':
|
||||
create_sql = """
|
||||
CREATE TABLE heleket_payments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
uuid VARCHAR(255) UNIQUE NOT NULL,
|
||||
order_id VARCHAR(128) UNIQUE NOT NULL,
|
||||
amount VARCHAR(50) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
payer_amount VARCHAR(50) NULL,
|
||||
payer_currency VARCHAR(10) NULL,
|
||||
exchange_rate DOUBLE PRECISION NULL,
|
||||
discount_percent INTEGER NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
payment_url TEXT NULL,
|
||||
metadata_json JSON NULL,
|
||||
paid_at TIMESTAMP NULL,
|
||||
expires_at TIMESTAMP NULL,
|
||||
transaction_id INTEGER NULL REFERENCES transactions(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_heleket_payments_user_id ON heleket_payments(user_id);
|
||||
CREATE INDEX idx_heleket_payments_uuid ON heleket_payments(uuid);
|
||||
CREATE INDEX idx_heleket_payments_order_id ON heleket_payments(order_id);
|
||||
CREATE INDEX idx_heleket_payments_status ON heleket_payments(status);
|
||||
"""
|
||||
|
||||
elif db_type == 'mysql':
|
||||
create_sql = """
|
||||
CREATE TABLE heleket_payments (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
uuid VARCHAR(255) UNIQUE NOT NULL,
|
||||
order_id VARCHAR(128) UNIQUE NOT NULL,
|
||||
amount VARCHAR(50) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
payer_amount VARCHAR(50) NULL,
|
||||
payer_currency VARCHAR(10) NULL,
|
||||
exchange_rate DOUBLE NULL,
|
||||
discount_percent INT NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
payment_url TEXT NULL,
|
||||
metadata_json JSON NULL,
|
||||
paid_at DATETIME NULL,
|
||||
expires_at DATETIME NULL,
|
||||
transaction_id INT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_heleket_payments_user_id ON heleket_payments(user_id);
|
||||
CREATE INDEX idx_heleket_payments_uuid ON heleket_payments(uuid);
|
||||
CREATE INDEX idx_heleket_payments_order_id ON heleket_payments(order_id);
|
||||
CREATE INDEX idx_heleket_payments_status ON heleket_payments(status);
|
||||
"""
|
||||
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для таблицы heleket_payments: {db_type}")
|
||||
return False
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("Таблица heleket_payments успешно создана")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания таблицы heleket_payments: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_mulenpay_payments_table():
|
||||
table_exists = await check_table_exists('mulenpay_payments')
|
||||
if table_exists:
|
||||
@@ -3427,18 +3541,26 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей CryptoBot payments")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ MULEN PAY ===")
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ HELEKET ===")
|
||||
heleket_created = await create_heleket_payments_table()
|
||||
if heleket_created:
|
||||
logger.info("✅ Таблица Heleket payments готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей Heleket payments")
|
||||
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ %s ===", mulenpay_name)
|
||||
mulenpay_created = await create_mulenpay_payments_table()
|
||||
if mulenpay_created:
|
||||
logger.info("✅ Таблица Mulen Pay payments готова")
|
||||
logger.info("✅ Таблица %s payments готова", mulenpay_name)
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей Mulen Pay payments")
|
||||
logger.warning("⚠️ Проблемы с таблицей %s payments", mulenpay_name)
|
||||
|
||||
mulenpay_schema_ok = await ensure_mulenpay_payment_schema()
|
||||
if mulenpay_schema_ok:
|
||||
logger.info("✅ Схема Mulen Pay payments актуальна")
|
||||
logger.info("✅ Схема %s payments актуальна", mulenpay_name)
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось обновить схему Mulen Pay payments")
|
||||
logger.warning("⚠️ Не удалось обновить схему %s payments", mulenpay_name)
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PAL24 ===")
|
||||
pal24_created = await create_pal24_payments_table()
|
||||
@@ -3694,6 +3816,7 @@ async def run_universal_migration():
|
||||
logger.info("=== МИГРАЦИЯ ЗАВЕРШЕНА УСПЕШНО ===")
|
||||
logger.info("✅ Реферальная система обновлена")
|
||||
logger.info("✅ CryptoBot таблица готова")
|
||||
logger.info("✅ Heleket таблица готова")
|
||||
logger.info("✅ Таблица конверсий подписок создана")
|
||||
logger.info("✅ Таблица welcome_texts с полем is_enabled готова")
|
||||
logger.info("✅ Медиа поля в broadcast_history добавлены")
|
||||
@@ -3711,6 +3834,7 @@ async def check_migration_status():
|
||||
status = {
|
||||
"has_made_first_topup_column": False,
|
||||
"cryptobot_table": False,
|
||||
"heleket_table": False,
|
||||
"user_messages_table": False,
|
||||
"welcome_texts_table": False,
|
||||
"welcome_texts_is_enabled_column": False,
|
||||
@@ -3744,6 +3868,7 @@ async def check_migration_status():
|
||||
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
|
||||
|
||||
status["cryptobot_table"] = await check_table_exists('cryptobot_payments')
|
||||
status["heleket_table"] = await check_table_exists('heleket_payments')
|
||||
status["user_messages_table"] = await check_table_exists('user_messages')
|
||||
status["welcome_texts_table"] = await check_table_exists('welcome_texts')
|
||||
status["privacy_policies_table"] = await check_table_exists('privacy_policies')
|
||||
@@ -3796,6 +3921,7 @@ async def check_migration_status():
|
||||
check_names = {
|
||||
"has_made_first_topup_column": "Колонка реферальной системы",
|
||||
"cryptobot_table": "Таблица CryptoBot payments",
|
||||
"heleket_table": "Таблица Heleket payments",
|
||||
"user_messages_table": "Таблица пользовательских сообщений",
|
||||
"welcome_texts_table": "Таблица приветственных текстов",
|
||||
"privacy_policies_table": "Таблица политик конфиденциальности",
|
||||
|
||||
Vendored
+129
@@ -0,0 +1,129 @@
|
||||
"""HTTP client for Heleket payment API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HeleketService:
|
||||
"""Minimal wrapper around Heleket API endpoints."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.base_url = settings.HELEKET_BASE_URL.rstrip("/")
|
||||
self.merchant_id = settings.HELEKET_MERCHANT_ID
|
||||
self.api_key = settings.HELEKET_API_KEY
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.merchant_id and self.api_key)
|
||||
|
||||
def _prepare_body(self, payload: Dict[str, Any]) -> str:
|
||||
cleaned = {key: value for key, value in payload.items() if value is not None}
|
||||
serialized = json.dumps(cleaned, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
||||
if "/" in serialized:
|
||||
serialized = serialized.replace("/", "\\/")
|
||||
return serialized
|
||||
|
||||
def _generate_signature(self, body: str) -> str:
|
||||
api_key = self.api_key or ""
|
||||
encoded = base64.b64encode(body.encode("utf-8")).decode("utf-8")
|
||||
raw = f"{encoded}{api_key}"
|
||||
return hashlib.md5(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
async def _request(self, endpoint: str, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
if not self.is_configured:
|
||||
logger.error("Heleket сервис не настроен: merchant или api_key отсутствуют")
|
||||
return None
|
||||
|
||||
body = self._prepare_body(payload)
|
||||
signature = self._generate_signature(body)
|
||||
|
||||
url = f"{self.base_url}/{endpoint.lstrip('/')}"
|
||||
headers = {
|
||||
"merchant": self.merchant_id or "",
|
||||
"sign": signature,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(url, data=body.encode("utf-8"), headers=headers) as response:
|
||||
text = await response.text()
|
||||
if response.content_type != "application/json":
|
||||
logger.error("Ответ Heleket не JSON (%s): %s", response.content_type, text)
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Ошибка парсинга Heleket JSON: %s", text)
|
||||
return None
|
||||
|
||||
if response.status >= 400:
|
||||
logger.error("Heleket API %s вернул статус %s: %s", endpoint, response.status, data)
|
||||
return None
|
||||
|
||||
if isinstance(data, dict) and data.get("state") == 0:
|
||||
return data
|
||||
|
||||
logger.error("Heleket API вернул ошибку: %s", data)
|
||||
return None
|
||||
except Exception as error: # pragma: no cover - defensive
|
||||
logger.error("Ошибка запроса к Heleket API: %s", error)
|
||||
return None
|
||||
|
||||
async def create_payment(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
return await self._request("payment", payload)
|
||||
|
||||
async def get_payment_info(
|
||||
self,
|
||||
*,
|
||||
uuid: Optional[str] = None,
|
||||
order_id: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not uuid and not order_id:
|
||||
raise ValueError("Нужно указать uuid или order_id для Heleket payment/info")
|
||||
|
||||
payload: Dict[str, Any] = {}
|
||||
if uuid:
|
||||
payload["uuid"] = uuid
|
||||
if order_id:
|
||||
payload["order_id"] = order_id
|
||||
|
||||
return await self._request("payment/info", payload)
|
||||
|
||||
def verify_webhook_signature(self, payload: Dict[str, Any]) -> bool:
|
||||
if not self.is_configured:
|
||||
logger.warning("Heleket сервис не настроен, подпись пропускается")
|
||||
return True
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
logger.error("Heleket webhook payload не dict: %s", payload)
|
||||
return False
|
||||
|
||||
signature = payload.get("sign")
|
||||
if not signature:
|
||||
logger.error("Heleket webhook без подписи")
|
||||
return False
|
||||
|
||||
data = {key: value for key, value in payload.items() if key != "sign"}
|
||||
body = self._prepare_body(data)
|
||||
expected = self._generate_signature(body)
|
||||
|
||||
is_valid = hmac.compare_digest(expected, str(signature))
|
||||
|
||||
if not is_valid:
|
||||
logger.error("Неверная подпись Heleket webhook: ожидается %s, получено %s", expected, signature)
|
||||
return is_valid
|
||||
Vendored
+111
@@ -0,0 +1,111 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.external.heleket import HeleketService
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HeleketWebhookHandler:
|
||||
def __init__(self, payment_service: PaymentService) -> None:
|
||||
self.payment_service = payment_service
|
||||
self.service = HeleketService()
|
||||
|
||||
async def handle(self, request: web.Request) -> web.Response:
|
||||
if not settings.is_heleket_enabled():
|
||||
logger.warning("Получен Heleket webhook, но сервис отключен")
|
||||
return web.json_response({"status": "error", "reason": "disabled"}, status=503)
|
||||
|
||||
try:
|
||||
payload: Dict[str, Any] = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Некорректный JSON Heleket webhook")
|
||||
return web.json_response({"status": "error", "reason": "invalid_json"}, status=400)
|
||||
|
||||
if not self.service.verify_webhook_signature(payload):
|
||||
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
|
||||
|
||||
processed: Optional[bool] = None
|
||||
async for db in get_db():
|
||||
processed = await self.payment_service.process_heleket_webhook(db, payload)
|
||||
|
||||
if processed:
|
||||
return web.json_response({"status": "ok"}, status=200)
|
||||
|
||||
return web.json_response({"status": "error", "reason": "not_processed"}, status=400)
|
||||
|
||||
async def health_check(self, _: web.Request) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "heleket_webhook",
|
||||
"enabled": settings.is_heleket_enabled(),
|
||||
"path": settings.HELEKET_WEBHOOK_PATH,
|
||||
}
|
||||
)
|
||||
|
||||
async def options_handler(self, _: web.Request) -> web.Response:
|
||||
return web.Response(
|
||||
status=200,
|
||||
headers={
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def create_heleket_app(payment_service: PaymentService) -> web.Application:
|
||||
handler = HeleketWebhookHandler(payment_service)
|
||||
app = web.Application()
|
||||
app.router.add_post(settings.HELEKET_WEBHOOK_PATH, handler.handle)
|
||||
app.router.add_get("/heleket/health", handler.health_check)
|
||||
app.router.add_get("/health", handler.health_check)
|
||||
app.router.add_options(settings.HELEKET_WEBHOOK_PATH, handler.options_handler)
|
||||
return app
|
||||
|
||||
|
||||
async def start_heleket_webhook_server(payment_service: PaymentService) -> None:
|
||||
if not settings.is_heleket_enabled():
|
||||
logger.info("Heleket отключен, webhook сервер не запускается")
|
||||
return
|
||||
|
||||
app = create_heleket_app(payment_service)
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
|
||||
site = web.TCPSite(
|
||||
runner,
|
||||
host=settings.HELEKET_WEBHOOK_HOST,
|
||||
port=settings.HELEKET_WEBHOOK_PORT,
|
||||
)
|
||||
|
||||
try:
|
||||
await site.start()
|
||||
logger.info(
|
||||
"Heleket webhook сервер запущен на %s:%s",
|
||||
settings.HELEKET_WEBHOOK_HOST,
|
||||
settings.HELEKET_WEBHOOK_PORT,
|
||||
)
|
||||
logger.info(
|
||||
"Heleket webhook URL: http://%s:%s%s",
|
||||
settings.HELEKET_WEBHOOK_HOST,
|
||||
settings.HELEKET_WEBHOOK_PORT,
|
||||
settings.HELEKET_WEBHOOK_PATH,
|
||||
)
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Heleket webhook сервер остановлен по запросу")
|
||||
finally:
|
||||
await site.stop()
|
||||
await runner.cleanup()
|
||||
logger.info("Heleket webhook сервер корректно остановлен")
|
||||
Vendored
+12
-5
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from concurrent.futures import TimeoutError as FuturesTimeoutError
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
@@ -13,7 +14,7 @@ from flask import Flask, jsonify, request
|
||||
from werkzeug.serving import make_server
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.services.pal24_service import Pal24Service, Pal24APIError
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
@@ -56,6 +57,8 @@ def create_pal24_flask_app(
|
||||
logger.error("Pal24 webhook получен, но сервис не настроен")
|
||||
return jsonify({"status": "error", "reason": "service_not_configured"}), 503
|
||||
|
||||
logger.debug("Получен Pal24 webhook: headers=%s", dict(request.headers))
|
||||
|
||||
payload = _normalize_payload()
|
||||
if not payload:
|
||||
logger.warning("Пустой Pal24 webhook")
|
||||
@@ -68,15 +71,19 @@ def create_pal24_flask_app(
|
||||
return jsonify({"status": "error", "reason": str(error)}), 400
|
||||
|
||||
async def process() -> bool:
|
||||
async for db in get_db():
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
return await payment_service.process_pal24_postback(db, parsed_payload)
|
||||
finally:
|
||||
await db.close()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(process(), loop)
|
||||
processed = future.result()
|
||||
processed = future.result(timeout=settings.PAL24_REQUEST_TIMEOUT)
|
||||
except FuturesTimeoutError:
|
||||
logger.error("Обработка Pal24 webhook превысила таймаут %sс", settings.PAL24_REQUEST_TIMEOUT)
|
||||
return jsonify({"status": "error", "reason": "timeout"}), 504
|
||||
except Exception as error: # pragma: no cover - defensive
|
||||
logger.exception("Критическая ошибка обработки Pal24 webhook: %s", error)
|
||||
return jsonify({"status": "error", "reason": "internal_error"}), 500
|
||||
|
||||
Vendored
+262
@@ -0,0 +1,262 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WataPublicKeyProvider:
|
||||
"""Loads and caches the WATA public key used for webhook signature validation."""
|
||||
|
||||
def __init__(self, *, cache_seconds: Optional[int] = None) -> None:
|
||||
self._cache_seconds = cache_seconds or int(settings.WATA_PUBLIC_KEY_CACHE_SECONDS)
|
||||
self._cached_key: Optional[str] = None
|
||||
self._expires_at: Optional[datetime] = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get_public_key(self) -> Optional[str]:
|
||||
"""Returns a cached public key or fetches a new one from WATA."""
|
||||
|
||||
now = datetime.utcnow()
|
||||
if self._cached_key and self._expires_at and now < self._expires_at:
|
||||
return self._cached_key
|
||||
|
||||
async with self._lock:
|
||||
now = datetime.utcnow()
|
||||
if self._cached_key and self._expires_at and now < self._expires_at:
|
||||
return self._cached_key
|
||||
|
||||
key = await self._fetch_public_key()
|
||||
if key:
|
||||
self._cached_key = key
|
||||
if self._cache_seconds > 0:
|
||||
self._expires_at = datetime.utcnow() + timedelta(seconds=self._cache_seconds)
|
||||
else:
|
||||
self._expires_at = None
|
||||
logger.debug("Получен и закеширован публичный ключ WATA")
|
||||
return self._cached_key
|
||||
|
||||
if self._cached_key:
|
||||
logger.warning("Используем ранее закешированный публичный ключ WATA")
|
||||
return self._cached_key
|
||||
|
||||
logger.error("Публичный ключ WATA недоступен")
|
||||
return None
|
||||
|
||||
async def _fetch_public_key(self) -> Optional[str]:
|
||||
url = settings.WATA_PUBLIC_KEY_URL or f"{settings.WATA_BASE_URL.rstrip('/')}/public-key"
|
||||
timeout = aiohttp.ClientTimeout(total=settings.WATA_REQUEST_TIMEOUT)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url) as response:
|
||||
text = await response.text()
|
||||
if response.status >= 400:
|
||||
logger.error(
|
||||
"Ошибка получения публичного ключа WATA %s: %s",
|
||||
response.status,
|
||||
text,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = await response.json()
|
||||
except aiohttp.ContentTypeError:
|
||||
logger.error("Ответ WATA public-key не является JSON: %s", text)
|
||||
return None
|
||||
|
||||
if isinstance(payload, dict):
|
||||
value = payload.get("value")
|
||||
if value:
|
||||
return value
|
||||
logger.error("Ответ WATA public-key не содержит ключ: %s", payload)
|
||||
else:
|
||||
logger.error("Неожиданный формат ответа WATA public-key: %s", payload)
|
||||
except Exception as error:
|
||||
logger.error("Ошибка запроса публичного ключа WATA: %s", error)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class WataWebhookHandler:
|
||||
"""Processes webhook callbacks coming from WATA."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
payment_service: PaymentService,
|
||||
*,
|
||||
public_key_provider: Optional[WataPublicKeyProvider] = None,
|
||||
) -> None:
|
||||
self.payment_service = payment_service
|
||||
self.public_key_provider = public_key_provider or WataPublicKeyProvider()
|
||||
|
||||
async def _verify_signature(self, raw_body: str, signature: str) -> bool:
|
||||
signature = (signature or "").strip()
|
||||
if not signature:
|
||||
logger.error("WATA webhook без подписи")
|
||||
return False
|
||||
|
||||
public_key_pem = await self.public_key_provider.get_public_key()
|
||||
if not public_key_pem:
|
||||
logger.error("Публичный ключ WATA отсутствует, проверка подписи невозможна")
|
||||
return False
|
||||
|
||||
try:
|
||||
signature_bytes = base64.b64decode(signature)
|
||||
except (ValueError, TypeError):
|
||||
logger.error("Некорректная подпись WATA (не Base64)")
|
||||
return False
|
||||
|
||||
try:
|
||||
public_key = serialization.load_pem_public_key(public_key_pem.encode("utf-8"))
|
||||
except ValueError as error:
|
||||
logger.error("Ошибка загрузки публичного ключа WATA: %s", error)
|
||||
return False
|
||||
|
||||
try:
|
||||
public_key.verify(
|
||||
signature_bytes,
|
||||
raw_body.encode("utf-8"),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA512(),
|
||||
)
|
||||
return True
|
||||
except InvalidSignature:
|
||||
logger.warning("Подпись WATA webhook не прошла проверку")
|
||||
return False
|
||||
except Exception as error:
|
||||
logger.error("Ошибка проверки подписи WATA: %s", error)
|
||||
return False
|
||||
|
||||
async def handle_webhook(self, request: web.Request) -> web.Response:
|
||||
if not settings.is_wata_enabled():
|
||||
logger.warning("Получен WATA webhook, но сервис отключен")
|
||||
return web.json_response({"status": "error", "reason": "wata_disabled"}, status=503)
|
||||
|
||||
raw_body = await request.text()
|
||||
if not raw_body:
|
||||
logger.warning("Получен пустой WATA webhook")
|
||||
return web.json_response({"status": "error", "reason": "empty_body"}, status=400)
|
||||
|
||||
signature = request.headers.get("X-Signature")
|
||||
if not await self._verify_signature(raw_body, signature or ""):
|
||||
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
|
||||
|
||||
try:
|
||||
payload: Dict[str, Any] = json.loads(raw_body)
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Некорректный JSON WATA webhook")
|
||||
return web.json_response({"status": "error", "reason": "invalid_json"}, status=400)
|
||||
|
||||
logger.info(
|
||||
"Получен WATA webhook: order_id=%s, status=%s",
|
||||
payload.get("orderId"),
|
||||
payload.get("transactionStatus"),
|
||||
)
|
||||
|
||||
processed: Optional[bool] = None
|
||||
async for db in get_db():
|
||||
processed = await self.payment_service.process_wata_webhook(db, payload)
|
||||
# Allow the generator to finish naturally so it can commit/rollback.
|
||||
# get_db() yields only once, so exiting the loop body without breaking
|
||||
# triggers the generator cleanup logic on the next iteration attempt.
|
||||
|
||||
if processed is None:
|
||||
logger.error("Не удалось обработать WATA webhook: нет сессии БД")
|
||||
return web.json_response({"status": "error", "reason": "db_session_unavailable"}, status=500)
|
||||
|
||||
if processed:
|
||||
return web.json_response({"status": "ok"}, status=200)
|
||||
|
||||
return web.json_response({"status": "error", "reason": "not_processed"}, status=400)
|
||||
|
||||
async def health_check(self, request: web.Request) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "wata_webhook",
|
||||
"enabled": settings.is_wata_enabled(),
|
||||
"path": settings.WATA_WEBHOOK_PATH,
|
||||
}
|
||||
)
|
||||
|
||||
async def options_handler(self, _: web.Request) -> web.Response:
|
||||
return web.Response(
|
||||
status=200,
|
||||
headers={
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, X-Signature",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def create_wata_webhook_app(payment_service: PaymentService) -> web.Application:
|
||||
app = web.Application()
|
||||
handler = WataWebhookHandler(payment_service)
|
||||
|
||||
app.router.add_post(settings.WATA_WEBHOOK_PATH, handler.handle_webhook)
|
||||
app.router.add_get(settings.WATA_WEBHOOK_PATH, handler.health_check)
|
||||
app.router.add_options(settings.WATA_WEBHOOK_PATH, handler.options_handler)
|
||||
app.router.add_get("/health", handler.health_check)
|
||||
|
||||
logger.info(
|
||||
"Настроен WATA webhook endpoint на %s",
|
||||
settings.WATA_WEBHOOK_PATH,
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def start_wata_webhook_server(payment_service: PaymentService) -> None:
|
||||
if not settings.is_wata_enabled():
|
||||
logger.info("WATA отключен, webhook сервер не запускается")
|
||||
return
|
||||
|
||||
app = create_wata_webhook_app(payment_service)
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
|
||||
site = web.TCPSite(
|
||||
runner,
|
||||
host=settings.WATA_WEBHOOK_HOST,
|
||||
port=settings.WATA_WEBHOOK_PORT,
|
||||
)
|
||||
|
||||
try:
|
||||
await site.start()
|
||||
logger.info(
|
||||
"WATA webhook сервер запущен на %s:%s",
|
||||
settings.WATA_WEBHOOK_HOST,
|
||||
settings.WATA_WEBHOOK_PORT,
|
||||
)
|
||||
logger.info(
|
||||
"WATA webhook URL: http://%s:%s%s",
|
||||
settings.WATA_WEBHOOK_HOST,
|
||||
settings.WATA_WEBHOOK_PORT,
|
||||
settings.WATA_WEBHOOK_PATH,
|
||||
)
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("WATA webhook сервер остановлен по запросу")
|
||||
finally:
|
||||
await site.stop()
|
||||
await runner.cleanup()
|
||||
logger.info("WATA webhook сервер корректно остановлен")
|
||||
Vendored
+44
-15
@@ -48,7 +48,12 @@ class WebhookServer:
|
||||
logger.info(f"Webhook сервер настроен:")
|
||||
logger.info(f" - Tribute webhook: POST {settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
if settings.is_mulenpay_enabled():
|
||||
logger.info(f" - Mulen Pay webhook: POST {settings.MULENPAY_WEBHOOK_PATH}")
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.info(
|
||||
" - %s webhook: POST %s",
|
||||
mulenpay_name,
|
||||
settings.MULENPAY_WEBHOOK_PATH,
|
||||
)
|
||||
if settings.is_cryptobot_enabled():
|
||||
logger.info(f" - CryptoBot webhook: POST {settings.CRYPTOBOT_WEBHOOK_PATH}")
|
||||
logger.info(f" - Health check: GET /health")
|
||||
@@ -84,8 +89,10 @@ class WebhookServer:
|
||||
settings.TRIBUTE_WEBHOOK_PATH,
|
||||
)
|
||||
if settings.is_mulenpay_enabled():
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.info(
|
||||
"Mulen Pay webhook URL: http://%s:%s%s",
|
||||
"%s webhook URL: http://%s:%s%s",
|
||||
mulenpay_name,
|
||||
settings.TRIBUTE_WEBHOOK_HOST,
|
||||
settings.TRIBUTE_WEBHOOK_PORT,
|
||||
settings.MULENPAY_WEBHOOK_PATH,
|
||||
@@ -128,24 +135,37 @@ class WebhookServer:
|
||||
|
||||
async def _mulenpay_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
logger.info(f"Получен Mulen Pay webhook: {request.method} {request.path}")
|
||||
logger.info(f"MulenPay webhook headers: {dict(request.headers)}")
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.info(
|
||||
"%s webhook: %s %s",
|
||||
mulenpay_name,
|
||||
request.method,
|
||||
request.path,
|
||||
)
|
||||
logger.info(
|
||||
"%s webhook headers: %s",
|
||||
mulenpay_name,
|
||||
dict(request.headers),
|
||||
)
|
||||
raw_body = await request.read()
|
||||
|
||||
if not raw_body:
|
||||
logger.warning("Пустой Mulen Pay webhook")
|
||||
logger.warning("Пустой %s webhook", mulenpay_name)
|
||||
return web.json_response({"status": "error", "reason": "empty_body"}, status=400)
|
||||
|
||||
# Временно отключаем проверку подписи для отладки
|
||||
# TODO: Включить обратно после настройки MulenPay
|
||||
if not self._verify_mulenpay_signature(request, raw_body):
|
||||
logger.warning("MulenPay webhook signature verification failed, but processing anyway for debugging")
|
||||
logger.warning(
|
||||
"%s webhook signature verification failed, but processing anyway for debugging",
|
||||
mulenpay_name,
|
||||
)
|
||||
# return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_body.decode('utf-8'))
|
||||
except json.JSONDecodeError as error:
|
||||
logger.error(f"Ошибка парсинга Mulen Pay webhook: {error}")
|
||||
logger.error(f"Ошибка парсинга {mulenpay_name} webhook: {error}")
|
||||
return web.json_response({"status": "error", "reason": "invalid_json"}, status=400)
|
||||
|
||||
payment_service = PaymentService(self.bot)
|
||||
@@ -160,7 +180,10 @@ class WebhookServer:
|
||||
return web.json_response({"status": "ok"}, status=200)
|
||||
return web.json_response({"status": "error", "reason": "processing_failed"}, status=400)
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка обработки Mulen Pay webhook: {error}", exc_info=True)
|
||||
logger.error(
|
||||
f"Ошибка обработки {mulenpay_name} webhook: {error}",
|
||||
exc_info=True,
|
||||
)
|
||||
return web.json_response({"status": "error", "reason": "internal_error"}, status=500)
|
||||
finally:
|
||||
try:
|
||||
@@ -169,7 +192,11 @@ class WebhookServer:
|
||||
pass
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Критическая ошибка Mulen Pay webhook: {error}", exc_info=True)
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.error(
|
||||
f"Критическая ошибка {mulenpay_name} webhook: {error}",
|
||||
exc_info=True,
|
||||
)
|
||||
return web.json_response({"status": "error", "reason": "internal_error", "message": str(error)}, status=500)
|
||||
|
||||
@staticmethod
|
||||
@@ -183,12 +210,13 @@ class WebhookServer:
|
||||
@staticmethod
|
||||
def _verify_mulenpay_signature(request: web.Request, raw_body: bytes) -> bool:
|
||||
secret_key = settings.MULENPAY_SECRET_KEY
|
||||
display_name = settings.get_mulenpay_display_name()
|
||||
if not secret_key:
|
||||
logger.error("Mulen Pay secret key is not configured")
|
||||
logger.error("%s secret key is not configured", display_name)
|
||||
return False
|
||||
|
||||
# Логируем все заголовки для отладки
|
||||
logger.info("MulenPay webhook headers for signature verification:")
|
||||
logger.info("%s webhook headers for signature verification:", display_name)
|
||||
for header_name, header_value in request.headers.items():
|
||||
if any(keyword in header_name.lower() for keyword in ['signature', 'sign', 'token', 'auth']):
|
||||
logger.info(f" {header_name}: {header_value}")
|
||||
@@ -239,7 +267,7 @@ class WebhookServer:
|
||||
if hmac.compare_digest(normalized_signature_no_padding, expected_urlsafe_base64_signature.rstrip('=')):
|
||||
return True
|
||||
|
||||
logger.error("Неверная подпись Mulen Pay webhook")
|
||||
logger.error("Неверная подпись %s webhook", display_name)
|
||||
return False
|
||||
|
||||
authorization_header = request.headers.get('Authorization')
|
||||
@@ -252,7 +280,7 @@ class WebhookServer:
|
||||
if hmac.compare_digest(token, secret_key):
|
||||
return True
|
||||
|
||||
logger.error("Неверный %s токен Mulen Pay webhook", scheme)
|
||||
logger.error("Неверный %s токен %s webhook", scheme, display_name)
|
||||
return False
|
||||
|
||||
if not value and hmac.compare_digest(token, secret_key):
|
||||
@@ -270,11 +298,12 @@ class WebhookServer:
|
||||
return True
|
||||
|
||||
logger.info(
|
||||
"Mulen Pay webhook headers received: %s",
|
||||
"%s webhook headers received: %s",
|
||||
display_name,
|
||||
{key: value for key, value in request.headers.items() if 'authorization' not in key.lower()}
|
||||
)
|
||||
|
||||
logger.error("Отсутствует подпись Mulen Pay webhook")
|
||||
logger.error("Отсутствует подпись %s webhook", display_name)
|
||||
return False
|
||||
|
||||
async def _tribute_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
|
||||
@@ -13,6 +13,11 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import SystemSetting, User
|
||||
from app.database.crud.server_squad import (
|
||||
get_all_server_squads,
|
||||
get_server_squad_by_id,
|
||||
get_server_squad_by_uuid,
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.config import settings
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
@@ -30,6 +35,7 @@ from app.external.telegram_stars import TelegramStarsService
|
||||
|
||||
CATEGORY_PAGE_SIZE = 10
|
||||
SETTINGS_PAGE_SIZE = 8
|
||||
SIMPLE_SUBSCRIPTION_SQUADS_PAGE_SIZE = 6
|
||||
|
||||
CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
"core": {
|
||||
@@ -46,12 +52,13 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
},
|
||||
"payments": {
|
||||
"title": "💳 Платежные системы",
|
||||
"description": "YooKassa, CryptoBot, MulenPay, PAL24, Wata, Tribute и Telegram Stars.",
|
||||
"description": "YooKassa, CryptoBot, Heleket, MulenPay, PAL24, Wata, Tribute и Telegram Stars.",
|
||||
"icon": "💳",
|
||||
"categories": (
|
||||
"PAYMENT",
|
||||
"YOOKASSA",
|
||||
"CRYPTOBOT",
|
||||
"HELEKET",
|
||||
"MULENPAY",
|
||||
"PAL24",
|
||||
"WATA",
|
||||
@@ -61,9 +68,17 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
},
|
||||
"subscriptions": {
|
||||
"title": "📅 Подписки и цены",
|
||||
"description": "Тарифы, периоды, лимиты трафика и автопродление.",
|
||||
"description": "Тарифы, простая покупка, периоды, лимиты трафика и автопродление.",
|
||||
"icon": "📅",
|
||||
"categories": ("SUBSCRIPTIONS_CORE", "PERIODS", "SUBSCRIPTION_PRICES", "TRAFFIC", "TRAFFIC_PACKAGES", "AUTOPAY"),
|
||||
"categories": (
|
||||
"SUBSCRIPTIONS_CORE",
|
||||
"SIMPLE_SUBSCRIPTION",
|
||||
"PERIODS",
|
||||
"SUBSCRIPTION_PRICES",
|
||||
"TRAFFIC",
|
||||
"TRAFFIC_PACKAGES",
|
||||
"AUTOPAY",
|
||||
),
|
||||
},
|
||||
"trial": {
|
||||
"title": "🎁 Пробный период",
|
||||
@@ -1303,7 +1318,10 @@ def _build_settings_keyboard(
|
||||
label = texts.t("PAYMENT_CARD_TRIBUTE", "💳 Банковская карта (Tribute)")
|
||||
test_payment_buttons.append([_test_button(f"{label} · тест", "tribute")])
|
||||
elif category_key == "MULENPAY":
|
||||
label = texts.t("PAYMENT_CARD_MULENPAY", "💳 Банковская карта (Mulen Pay)")
|
||||
label = texts.t(
|
||||
"PAYMENT_CARD_MULENPAY",
|
||||
"💳 Банковская карта ({mulenpay_name})",
|
||||
).format(mulenpay_name=settings.get_mulenpay_display_name())
|
||||
test_payment_buttons.append([_test_button(f"{label} · тест", "mulenpay")])
|
||||
elif category_key == "WATA":
|
||||
label = texts.t("PAYMENT_CARD_WATA", "💳 Банковская карта (WATA)")
|
||||
@@ -1414,6 +1432,16 @@ def _build_setting_keyboard(
|
||||
for chunk in _chunk(choice_buttons, 2):
|
||||
rows.append(list(chunk))
|
||||
|
||||
if key == "SIMPLE_SUBSCRIPTION_SQUAD_UUID" and not is_read_only:
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="🌍 Выбрать сквад",
|
||||
callback_data=(
|
||||
f"botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{callback_token}:1"
|
||||
),
|
||||
)
|
||||
])
|
||||
|
||||
if definition.python_type is bool and not is_read_only:
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
@@ -1642,6 +1670,217 @@ async def show_bot_config_category(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_simple_subscription_squad_selector(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
parts = callback.data.split(":", 5)
|
||||
group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY
|
||||
try:
|
||||
category_page = max(1, int(parts[2])) if len(parts) > 2 else 1
|
||||
except ValueError:
|
||||
category_page = 1
|
||||
try:
|
||||
settings_page = max(1, int(parts[3])) if len(parts) > 3 else 1
|
||||
except ValueError:
|
||||
settings_page = 1
|
||||
token = parts[4] if len(parts) > 4 else ""
|
||||
|
||||
try:
|
||||
key = bot_configuration_service.resolve_callback_token(token)
|
||||
except KeyError:
|
||||
await callback.answer("Эта настройка больше недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
if key != "SIMPLE_SUBSCRIPTION_SQUAD_UUID":
|
||||
await callback.answer("Эта настройка больше недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
page = max(1, int(parts[5])) if len(parts) > 5 else 1
|
||||
except ValueError:
|
||||
page = 1
|
||||
|
||||
limit = SIMPLE_SUBSCRIPTION_SQUADS_PAGE_SIZE
|
||||
squads, total_count = await get_all_server_squads(
|
||||
db,
|
||||
available_only=False,
|
||||
page=page,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
total_count = total_count or 0
|
||||
total_pages = max(1, math.ceil(total_count / limit)) if total_count else 1
|
||||
if total_count and page > total_pages:
|
||||
page = total_pages
|
||||
squads, total_count = await get_all_server_squads(
|
||||
db,
|
||||
available_only=False,
|
||||
page=page,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
current_uuid = bot_configuration_service.get_current_value(key) or ""
|
||||
current_display = "Любой доступный"
|
||||
|
||||
if current_uuid:
|
||||
selected_server = next((srv for srv in squads if srv.squad_uuid == current_uuid), None)
|
||||
if not selected_server:
|
||||
selected_server = await get_server_squad_by_uuid(db, current_uuid)
|
||||
if selected_server:
|
||||
current_display = selected_server.display_name
|
||||
else:
|
||||
current_display = current_uuid
|
||||
|
||||
lines = [
|
||||
"🌍 <b>Выберите сквад для простой покупки</b>",
|
||||
"",
|
||||
f"Текущий выбор: {html.escape(current_display)}" if current_display else "Текущий выбор: —",
|
||||
"",
|
||||
]
|
||||
|
||||
if total_count == 0:
|
||||
lines.append("❌ Доступные сервера не найдены.")
|
||||
else:
|
||||
lines.append("Выберите сервер из списка ниже.")
|
||||
if total_pages > 1:
|
||||
lines.append(f"Страница {page}/{total_pages}")
|
||||
|
||||
text = "\n".join(lines)
|
||||
|
||||
keyboard_rows: List[List[types.InlineKeyboardButton]] = []
|
||||
|
||||
for server in squads:
|
||||
status_icon = (
|
||||
"✅" if server.squad_uuid == current_uuid else ("🟢" if server.is_available else "🔒")
|
||||
)
|
||||
label_parts = [status_icon, server.display_name]
|
||||
if server.country_code:
|
||||
label_parts.append(f"({server.country_code.upper()})")
|
||||
if isinstance(server.price_kopeks, int) and server.price_kopeks > 0:
|
||||
try:
|
||||
label_parts.append(f"— {settings.format_price(server.price_kopeks)}")
|
||||
except Exception:
|
||||
pass
|
||||
label = " ".join(label_parts)
|
||||
|
||||
keyboard_rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=label,
|
||||
callback_data=(
|
||||
f"botcfg_simple_squad_select:{group_key}:{category_page}:{settings_page}:{token}:{server.id}:{page}"
|
||||
),
|
||||
)
|
||||
])
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row: List[types.InlineKeyboardButton] = []
|
||||
if page > 1:
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=(
|
||||
f"botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{token}:{page - 1}"
|
||||
),
|
||||
)
|
||||
)
|
||||
if page < total_pages:
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=(
|
||||
f"botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{token}:{page + 1}"
|
||||
),
|
||||
)
|
||||
)
|
||||
if nav_row:
|
||||
keyboard_rows.append(nav_row)
|
||||
|
||||
keyboard_rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data=(
|
||||
f"botcfg_setting:{group_key}:{category_page}:{settings_page}:{token}"
|
||||
),
|
||||
)
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def select_simple_subscription_squad(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
parts = callback.data.split(":", 6)
|
||||
group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY
|
||||
try:
|
||||
category_page = max(1, int(parts[2])) if len(parts) > 2 else 1
|
||||
except ValueError:
|
||||
category_page = 1
|
||||
try:
|
||||
settings_page = max(1, int(parts[3])) if len(parts) > 3 else 1
|
||||
except ValueError:
|
||||
settings_page = 1
|
||||
token = parts[4] if len(parts) > 4 else ""
|
||||
try:
|
||||
server_id = int(parts[5]) if len(parts) > 5 else None
|
||||
except ValueError:
|
||||
server_id = None
|
||||
|
||||
if server_id is None:
|
||||
await callback.answer("Не удалось определить сервер", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
key = bot_configuration_service.resolve_callback_token(token)
|
||||
except KeyError:
|
||||
await callback.answer("Эта настройка больше недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
if bot_configuration_service.is_read_only(key):
|
||||
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
|
||||
return
|
||||
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if not server:
|
||||
await callback.answer("Сервер не найден", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
await bot_configuration_service.set_value(db, key, server.squad_uuid)
|
||||
except ReadOnlySettingError:
|
||||
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
|
||||
return
|
||||
|
||||
await db.commit()
|
||||
|
||||
text = _render_setting_text(key)
|
||||
keyboard = _build_setting_keyboard(key, group_key, category_page, settings_page)
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await _store_setting_context(
|
||||
state,
|
||||
key=key,
|
||||
group_key=group_key,
|
||||
category_page=category_page,
|
||||
settings_page=settings_page,
|
||||
)
|
||||
await callback.answer("Сквад выбран")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def test_remnawave_connection(
|
||||
@@ -1834,8 +2073,13 @@ async def test_payment_provider(
|
||||
return
|
||||
|
||||
if method == "mulenpay":
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
mulenpay_name_html = settings.get_mulenpay_display_name_html()
|
||||
if not settings.is_mulenpay_enabled():
|
||||
await callback.answer("❌ MulenPay отключен", show_alert=True)
|
||||
await callback.answer(
|
||||
f"❌ {mulenpay_name} отключен",
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
amount_kopeks = 1 * 100
|
||||
@@ -1843,18 +2087,21 @@ async def test_payment_provider(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description="Тестовый платеж MulenPay (админ)",
|
||||
description=f"Тестовый платеж {mulenpay_name} (админ)",
|
||||
language=language,
|
||||
)
|
||||
|
||||
if not payment_result or not payment_result.get("payment_url"):
|
||||
await callback.answer("❌ Не удалось создать платеж MulenPay", show_alert=True)
|
||||
await callback.answer(
|
||||
f"❌ Не удалось создать платеж {mulenpay_name}",
|
||||
show_alert=True,
|
||||
)
|
||||
await _refresh_markup()
|
||||
return
|
||||
|
||||
payment_url = payment_result["payment_url"]
|
||||
message_text = (
|
||||
"🧪 <b>Тестовый платеж MulenPay</b>\n\n"
|
||||
f"🧪 <b>Тестовый платеж {mulenpay_name_html}</b>\n\n"
|
||||
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
|
||||
f"🆔 ID: {payment_result['mulen_payment_id']}"
|
||||
)
|
||||
@@ -1875,7 +2122,10 @@ async def test_payment_provider(
|
||||
]
|
||||
)
|
||||
await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode="HTML")
|
||||
await callback.answer("✅ Ссылка на платеж MulenPay отправлена", show_alert=True)
|
||||
await callback.answer(
|
||||
f"✅ Ссылка на платеж {mulenpay_name} отправлена",
|
||||
show_alert=True,
|
||||
)
|
||||
await _refresh_markup()
|
||||
return
|
||||
|
||||
@@ -2505,6 +2755,14 @@ def register_handlers(dp: Dispatcher) -> None:
|
||||
test_payment_provider,
|
||||
F.data.startswith("botcfg_test_payment:"),
|
||||
)
|
||||
dp.callback_query.register(
|
||||
select_simple_subscription_squad,
|
||||
F.data.startswith("botcfg_simple_squad_select:"),
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_simple_subscription_squad_selector,
|
||||
F.data.startswith("botcfg_simple_squad:"),
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_bot_config_setting,
|
||||
F.data.startswith("botcfg_setting:"),
|
||||
@@ -2543,4 +2801,3 @@ def register_handlers(dp: Dispatcher) -> None:
|
||||
handle_import_message,
|
||||
BotConfigStates.waiting_for_import_file,
|
||||
)
|
||||
|
||||
|
||||
@@ -207,6 +207,7 @@ async def show_support_audit(
|
||||
'close_ticket': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET", "Закрытие тикета"),
|
||||
'block_user_timed': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED", "Блокировка (время)"),
|
||||
'block_user_perm': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM", "Блокировка (навсегда)"),
|
||||
'close_all_tickets': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_ALL_TICKETS", "Массовое закрытие тикетов"),
|
||||
'unblock_user': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK", "Снятие блока"),
|
||||
}
|
||||
action_text = action_map.get(log.action, log.action)
|
||||
@@ -215,6 +216,8 @@ async def show_support_audit(
|
||||
extra = ""
|
||||
if log.action == 'block_user_timed' and 'minutes' in details:
|
||||
extra = f" ({details['minutes']} мин)"
|
||||
elif log.action == 'close_all_tickets' and 'count' in details:
|
||||
extra = f" ({details['count']})"
|
||||
lines.append(f"{ts} • {role} <code>{log.actor_telegram_id}</code> — {action_text}{ticket_part}{extra}")
|
||||
|
||||
# keyboard with pagination
|
||||
|
||||
@@ -461,6 +461,81 @@ async def mark_ticket_as_answered(
|
||||
)
|
||||
|
||||
|
||||
async def close_all_open_admin_tickets(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Закрыть все открытые тикеты."""
|
||||
if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
closed_ticket_ids = await TicketCRUD.close_all_open_tickets(db)
|
||||
except Exception as error:
|
||||
logger.error("Error closing all open tickets: %s", error)
|
||||
await callback.answer(
|
||||
texts.t("TICKET_UPDATE_ERROR", "❌ Ошибка при обновлении тикета."),
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
closed_count = len(closed_ticket_ids)
|
||||
|
||||
if closed_count == 0:
|
||||
await callback.answer(
|
||||
texts.t("ADMIN_CLOSE_ALL_OPEN_TICKETS_EMPTY", "ℹ️ Нет открытых тикетов для закрытия."),
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
is_moderator = (
|
||||
not settings.is_admin(callback.from_user.id)
|
||||
and SupportSettingsService.is_moderator(callback.from_user.id)
|
||||
)
|
||||
await TicketCRUD.add_support_audit(
|
||||
db,
|
||||
actor_user_id=db_user.id if db_user else None,
|
||||
actor_telegram_id=callback.from_user.id,
|
||||
is_moderator=is_moderator,
|
||||
action="close_all_tickets",
|
||||
ticket_id=None,
|
||||
target_user_id=None,
|
||||
details={
|
||||
"count": closed_count,
|
||||
"ticket_ids": closed_ticket_ids,
|
||||
}
|
||||
)
|
||||
except Exception as audit_error:
|
||||
logger.warning("Failed to add support audit for bulk close: %s", audit_error)
|
||||
|
||||
# Обновляем список тикетов
|
||||
await show_admin_tickets(callback, db_user, db)
|
||||
|
||||
success_text = texts.t(
|
||||
"ADMIN_CLOSE_ALL_OPEN_TICKETS_SUCCESS",
|
||||
"✅ Закрыто открытых тикетов: {count}"
|
||||
).format(count=closed_count)
|
||||
|
||||
notification_keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.answer(success_text, reply_markup=notification_keyboard)
|
||||
except Exception:
|
||||
# Если не удалось отправить отдельное сообщение, пробуем ответить алертом
|
||||
try:
|
||||
await callback.answer(success_text, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def close_admin_ticket(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
@@ -935,7 +1010,8 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets")
|
||||
dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets_scope_open")
|
||||
dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets_scope_closed")
|
||||
|
||||
dp.callback_query.register(close_all_open_admin_tickets, F.data == "admin_tickets_close_all_open")
|
||||
|
||||
dp.callback_query.register(view_admin_ticket, F.data.startswith("admin_view_ticket_"))
|
||||
|
||||
# Ответы на тикеты
|
||||
|
||||
+113
-1
@@ -2,6 +2,7 @@ import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -1644,6 +1645,107 @@ async def start_balance_edit(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_send_user_message(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
target_user = await get_user_by_id(db, user_id)
|
||||
if not target_user:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
await state.update_data(direct_message_user_id=user_id)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
prompt = (
|
||||
texts.t("ADMIN_USER_SEND_MESSAGE_PROMPT",
|
||||
"✉️ <b>Отправка сообщения пользователю</b>\n\n"
|
||||
"Введите текст, который бот отправит пользователю."
|
||||
"\n\nВы можете отменить действие командой /cancel или кнопкой ниже." )
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
prompt,
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_manage_{user_id}")]
|
||||
]
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.sending_user_message)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_send_user_message(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
data = await state.get_data()
|
||||
user_id = data.get("direct_message_user_id")
|
||||
|
||||
if not user_id:
|
||||
await message.answer(texts.t("ADMIN_USER_SEND_MESSAGE_ERROR_NOT_FOUND", "❌ Пользователь для отправки сообщения не найден"))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
target_user = await get_user_by_id(db, int(user_id))
|
||||
if not target_user:
|
||||
await message.answer(texts.t("ADMIN_USER_SEND_MESSAGE_ERROR_NOT_FOUND", "❌ Пользователь не найден или был удалён"))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
text = (message.text or "").strip()
|
||||
if not text:
|
||||
await message.answer(texts.t("ADMIN_USER_SEND_MESSAGE_EMPTY", "❌ Пожалуйста, введите непустое сообщение"))
|
||||
return
|
||||
|
||||
confirmation_keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="👤 К пользователю", callback_data=f"admin_user_manage_{user_id}")]]
|
||||
)
|
||||
|
||||
try:
|
||||
await message.bot.send_message(target_user.telegram_id, text)
|
||||
await message.answer(
|
||||
texts.t("ADMIN_USER_SEND_MESSAGE_SUCCESS", "✅ Сообщение отправлено пользователю"),
|
||||
reply_markup=confirmation_keyboard,
|
||||
)
|
||||
except TelegramForbiddenError:
|
||||
await message.answer(
|
||||
texts.t("ADMIN_USER_SEND_MESSAGE_FORBIDDEN", "⚠️ Пользователь заблокировал бота или не может получить сообщения."),
|
||||
reply_markup=confirmation_keyboard,
|
||||
)
|
||||
except TelegramBadRequest as err:
|
||||
logger.error("Ошибка отправки сообщения пользователю %s: %s", target_user.telegram_id, err)
|
||||
await message.answer(
|
||||
texts.t("ADMIN_USER_SEND_MESSAGE_BAD_REQUEST", "❌ Telegram отклонил сообщение. Проверьте текст и попробуйте ещё раз."),
|
||||
reply_markup=confirmation_keyboard,
|
||||
)
|
||||
return
|
||||
except Exception as err:
|
||||
logger.error("Неожиданная ошибка отправки сообщения пользователю %s: %s", target_user.telegram_id, err)
|
||||
await message.answer(
|
||||
texts.t("ADMIN_USER_SEND_MESSAGE_ERROR", "❌ Не удалось отправить сообщение. Попробуйте позже."),
|
||||
reply_markup=confirmation_keyboard,
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_balance_edit(
|
||||
@@ -4014,11 +4116,21 @@ def register_handlers(dp: Dispatcher):
|
||||
start_balance_edit,
|
||||
F.data.startswith("admin_user_balance_")
|
||||
)
|
||||
|
||||
|
||||
dp.message.register(
|
||||
process_balance_edit,
|
||||
AdminStates.editing_user_balance
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_send_user_message,
|
||||
F.data.startswith("admin_user_send_message_")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_send_user_message,
|
||||
AdminStates.sending_user_message
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_inactive_users,
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_heleket_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_heleket_enabled():
|
||||
await callback.answer("❌ Оплата через Heleket недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
markup = settings.get_heleket_markup_percent()
|
||||
markup_text: Optional[str]
|
||||
if markup > 0:
|
||||
label = texts.t("PAYMENT_HELEKET_MARKUP_LABEL", "Наценка провайдера")
|
||||
markup_text = f"{label}: {markup:.0f}%"
|
||||
elif markup < 0:
|
||||
label = texts.t("PAYMENT_HELEKET_DISCOUNT_LABEL", "Скидка провайдера")
|
||||
markup_text = f"{label}: {abs(markup):.0f}%"
|
||||
else:
|
||||
markup_text = None
|
||||
|
||||
message_lines = [
|
||||
"🪙 <b>Пополнение через Heleket</b>",
|
||||
"\n",
|
||||
"Введите сумму пополнения от 100 до 100,000 ₽:",
|
||||
"",
|
||||
"⚡ Мгновенное зачисление", "🔒 Безопасная оплата",
|
||||
]
|
||||
|
||||
if markup_text:
|
||||
message_lines.extend(["", markup_text])
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_buttons = get_quick_amount_buttons(db_user.language)
|
||||
if quick_buttons:
|
||||
keyboard.inline_keyboard = quick_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(filter(None, message_lines)),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="heleket")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_heleket_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_heleket_enabled():
|
||||
await message.answer("❌ Оплата через Heleket недоступна")
|
||||
return
|
||||
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
if amount_rubles < 100:
|
||||
await message.answer("Минимальная сумма пополнения: 100 ₽")
|
||||
return
|
||||
|
||||
if amount_rubles > 100000:
|
||||
await message.answer("Максимальная сумма пополнения: 100,000 ₽")
|
||||
return
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
|
||||
result = await payment_service.create_heleket_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение баланса на {amount_rubles:.0f} ₽",
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not result:
|
||||
await message.answer(
|
||||
"❌ Не удалось создать счёт в Heleket. Попробуйте позже или обратитесь в поддержку."
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
payment_url = result.get("payment_url")
|
||||
if not payment_url:
|
||||
await message.answer("❌ Не удалось получить ссылку для оплаты Heleket")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
payer_amount = result.get("payer_amount")
|
||||
payer_currency = result.get("payer_currency")
|
||||
exchange_rate = result.get("exchange_rate")
|
||||
discount_percent = result.get("discount_percent")
|
||||
|
||||
details = [
|
||||
"🪙 <b>Оплата через Heleket</b>",
|
||||
"",
|
||||
f"💰 Сумма к зачислению: {amount_rubles:.0f} ₽",
|
||||
]
|
||||
|
||||
if payer_amount and payer_currency:
|
||||
details.append(f"🪙 К оплате: {payer_amount} {payer_currency}")
|
||||
|
||||
markup_percent: Optional[float] = None
|
||||
if discount_percent is not None:
|
||||
try:
|
||||
discount_int = int(discount_percent)
|
||||
markup_percent = -discount_int
|
||||
except (TypeError, ValueError):
|
||||
markup_percent = None
|
||||
|
||||
if markup_percent:
|
||||
label_markup = texts.t("PAYMENT_HELEKET_MARKUP_LABEL", "Наценка провайдера")
|
||||
label_discount = texts.t("PAYMENT_HELEKET_DISCOUNT_LABEL", "Скидка провайдера")
|
||||
absolute = abs(markup_percent)
|
||||
if markup_percent > 0:
|
||||
details.append(f"📈 {label_markup}: +{absolute}%")
|
||||
else:
|
||||
details.append(f"📉 {label_discount}: {absolute}%")
|
||||
|
||||
if payer_amount and payer_currency:
|
||||
try:
|
||||
payer_amount_float = float(payer_amount)
|
||||
if payer_amount_float > 0:
|
||||
rub_per_currency = amount_rubles / payer_amount_float
|
||||
details.append(
|
||||
f"💱 Курс: 1 {payer_currency} ≈ {rub_per_currency:.2f} ₽"
|
||||
)
|
||||
except (TypeError, ValueError, ZeroDivisionError):
|
||||
pass
|
||||
|
||||
details.extend(
|
||||
[
|
||||
"",
|
||||
"📱 Инструкция:",
|
||||
"1. Нажмите кнопку 'Оплатить'",
|
||||
"2. Перейдите на страницу Heleket",
|
||||
"3. Оплатите указанную сумму",
|
||||
"4. Баланс пополнится автоматически",
|
||||
]
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.t("PAY_WITH_COINS_BUTTON", "🪙 Оплатить"), url=payment_url)],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_heleket_{result['local_payment_id']}"
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
])
|
||||
|
||||
await message.answer("\n".join(details), parse_mode="HTML", reply_markup=keyboard)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_heleket_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
try:
|
||||
local_payment_id = int(callback.data.split("_")[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("Некорректный идентификатор платежа", show_alert=True)
|
||||
return
|
||||
|
||||
from app.database.crud.heleket import get_heleket_payment_by_id
|
||||
|
||||
payment = await get_heleket_payment_by_id(db, local_payment_id)
|
||||
if not payment:
|
||||
await callback.answer("Платёж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
if payment.is_paid:
|
||||
await callback.answer("✅ Платёж уже оплачен", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer("Платёж ещё не оплачен", show_alert=True)
|
||||
@@ -406,6 +406,11 @@ async def process_topup_amount(
|
||||
from .cryptobot import process_cryptobot_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_cryptobot_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "heleket":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .heleket import process_heleket_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_heleket_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
else:
|
||||
await message.answer("Неизвестный способ оплаты")
|
||||
|
||||
@@ -511,10 +516,32 @@ async def handle_quick_amount_selection(
|
||||
await process_pal24_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "cryptobot":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .cryptobot import process_cryptobot_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_cryptobot_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "heleket":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .heleket import process_heleket_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_heleket_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "stars":
|
||||
from .stars import process_stars_payment_amount
|
||||
|
||||
await process_stars_payment_amount(
|
||||
callback.message, db_user, amount_kopeks, state
|
||||
)
|
||||
else:
|
||||
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
|
||||
return
|
||||
|
||||
|
||||
except ValueError:
|
||||
await callback.answer("❌ Ошибка обработки суммы", show_alert=True)
|
||||
except Exception as e:
|
||||
@@ -575,6 +602,13 @@ async def handle_topup_amount_callback(
|
||||
await process_cryptobot_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "heleket":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .heleket import process_heleket_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_heleket_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "stars":
|
||||
from .stars import process_stars_payment_amount
|
||||
await process_stars_payment_amount(
|
||||
@@ -652,6 +686,11 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
start_pal24_payment,
|
||||
F.data == "topup_pal24"
|
||||
)
|
||||
from .pal24 import handle_pal24_method_selection
|
||||
dp.callback_query.register(
|
||||
handle_pal24_method_selection,
|
||||
F.data.startswith("pal24_method_"),
|
||||
)
|
||||
|
||||
from .yookassa import check_yookassa_payment_status
|
||||
dp.callback_query.register(
|
||||
@@ -693,6 +732,16 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
F.data.startswith("check_cryptobot_")
|
||||
)
|
||||
|
||||
from .heleket import start_heleket_payment, check_heleket_payment_status
|
||||
dp.callback_query.register(
|
||||
start_heleket_payment,
|
||||
F.data == "topup_heleket"
|
||||
)
|
||||
dp.callback_query.register(
|
||||
check_heleket_payment_status,
|
||||
F.data.startswith("check_heleket_")
|
||||
)
|
||||
|
||||
from .mulenpay import check_mulenpay_payment_status
|
||||
dp.callback_query.register(
|
||||
check_mulenpay_payment_status,
|
||||
|
||||
@@ -21,19 +21,28 @@ async def start_mulenpay_payment(
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
mulenpay_name_html = settings.get_mulenpay_display_name_html()
|
||||
|
||||
if not settings.is_mulenpay_enabled():
|
||||
await callback.answer("❌ Оплата через Mulen Pay временно недоступна", show_alert=True)
|
||||
await callback.answer(
|
||||
f"❌ Оплата через {mulenpay_name} временно недоступна",
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
message_text = texts.t(
|
||||
message_template = texts.t(
|
||||
"MULENPAY_TOPUP_PROMPT",
|
||||
(
|
||||
"💳 <b>Оплата через Mulen Pay</b>\n\n"
|
||||
"💳 <b>Оплата через {mulenpay_name_html}</b>\n\n"
|
||||
"Введите сумму для пополнения от 100 до 100 000 ₽.\n"
|
||||
"Оплата происходит через защищенную платформу Mulen Pay."
|
||||
"Оплата происходит через защищенную платформу {mulenpay_name}."
|
||||
),
|
||||
)
|
||||
message_text = message_template.format(
|
||||
mulenpay_name=mulenpay_name,
|
||||
mulenpay_name_html=mulenpay_name_html,
|
||||
)
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
@@ -63,9 +72,11 @@ async def process_mulenpay_payment_amount(
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
mulenpay_name_html = settings.get_mulenpay_display_name_html()
|
||||
|
||||
if not settings.is_mulenpay_enabled():
|
||||
await message.answer("❌ Оплата через Mulen Pay временно недоступна")
|
||||
await message.answer(f"❌ Оплата через {mulenpay_name} временно недоступна")
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
|
||||
@@ -96,8 +107,8 @@ async def process_mulenpay_payment_amount(
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"MULENPAY_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
"❌ Ошибка создания платежа {mulenpay_name}. Попробуйте позже или обратитесь в поддержку.",
|
||||
).format(mulenpay_name=mulenpay_name)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
@@ -112,8 +123,8 @@ async def process_mulenpay_payment_amount(
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"MULENPAY_PAY_BUTTON",
|
||||
"💳 Оплатить через Mulen Pay",
|
||||
),
|
||||
"💳 Оплатить через {mulenpay_name}",
|
||||
).format(mulenpay_name=mulenpay_name),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
@@ -132,11 +143,11 @@ async def process_mulenpay_payment_amount(
|
||||
message_template = texts.t(
|
||||
"MULENPAY_PAYMENT_INSTRUCTIONS",
|
||||
(
|
||||
"💳 <b>Оплата через Mulen Pay</b>\n\n"
|
||||
"💳 <b>Оплата через {mulenpay_name_html}</b>\n\n"
|
||||
"💰 Сумма: {amount}\n"
|
||||
"🆔 ID платежа: {payment_id}\n\n"
|
||||
"📱 <b>Инструкция:</b>\n"
|
||||
"1. Нажмите кнопку 'Оплатить через Mulen Pay'\n"
|
||||
"1. Нажмите кнопку 'Оплатить через {mulenpay_name}'\n"
|
||||
"2. Следуйте подсказкам платежной системы\n"
|
||||
"3. Подтвердите перевод\n"
|
||||
"4. Средства зачислятся автоматически\n\n"
|
||||
@@ -148,6 +159,8 @@ async def process_mulenpay_payment_amount(
|
||||
amount=settings.format_price(amount_kopeks),
|
||||
payment_id=payment_id_display,
|
||||
support=settings.get_support_contact_display_html(),
|
||||
mulenpay_name=mulenpay_name,
|
||||
mulenpay_name_html=mulenpay_name_html,
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
@@ -159,19 +172,20 @@ async def process_mulenpay_payment_amount(
|
||||
await state.clear()
|
||||
|
||||
logger.info(
|
||||
"Создан MulenPay платеж для пользователя %s: %s₽, ID: %s",
|
||||
"Создан %s платеж для пользователя %s: %s₽, ID: %s",
|
||||
mulenpay_name,
|
||||
db_user.telegram_id,
|
||||
amount_rubles,
|
||||
payment_id_display,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания MulenPay платежа: {e}")
|
||||
logger.error(f"Ошибка создания {mulenpay_name} платежа: {e}")
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"MULENPAY_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
"❌ Ошибка создания платежа {mulenpay_name}. Попробуйте позже или обратитесь в поддержку.",
|
||||
).format(mulenpay_name=mulenpay_name)
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
@@ -204,8 +218,9 @@ async def check_mulenpay_payment_status(
|
||||
|
||||
emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно"))
|
||||
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
message_lines = [
|
||||
"💳 Статус платежа Mulen Pay:\n\n",
|
||||
f"💳 Статус платежа {mulenpay_name}:\n\n",
|
||||
f"🆔 ID: {payment.mulen_payment_id or payment.id}\n",
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n",
|
||||
f"📊 Статус: {emoji} {status_text}\n",
|
||||
@@ -234,5 +249,7 @@ async def check_mulenpay_payment_status(
|
||||
await callback.answer(message_text, show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки статуса MulenPay: {e}")
|
||||
logger.error(
|
||||
f"Ошибка проверки статуса {settings.get_mulenpay_display_name()}: {e}"
|
||||
)
|
||||
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
|
||||
+192
-74
@@ -1,10 +1,12 @@
|
||||
import html
|
||||
import logging
|
||||
from aiogram import types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
@@ -15,81 +17,27 @@ from app.states import BalanceStates
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_pal24_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_pal24_enabled():
|
||||
await callback.answer("❌ Оплата через PayPalych временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
# Формируем текст сообщения в зависимости от доступных способов оплаты
|
||||
if settings.is_pal24_sbp_button_visible() and settings.is_pal24_card_button_visible():
|
||||
payment_methods_text = "СБП и банковской картой"
|
||||
elif settings.is_pal24_sbp_button_visible():
|
||||
payment_methods_text = "СБП"
|
||||
elif settings.is_pal24_card_button_visible():
|
||||
payment_methods_text = "банковской картой"
|
||||
else:
|
||||
# Если обе кнопки отключены, используем общий текст
|
||||
payment_methods_text = "доступными способами"
|
||||
|
||||
message_text = texts.t(
|
||||
"PAL24_TOPUP_PROMPT",
|
||||
(
|
||||
f"🏦 <b>Оплата через PayPalych ({payment_methods_text})</b>\n\n"
|
||||
"Введите сумму для пополнения от 100 до 1 000 000 ₽.\n"
|
||||
f"Оплата проходит через PayPalych ({payment_methods_text})."
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="pal24")
|
||||
await callback.answer()
|
||||
def _get_available_pal24_methods() -> list[str]:
|
||||
methods: list[str] = []
|
||||
if settings.is_pal24_sbp_button_visible():
|
||||
methods.append("sbp")
|
||||
if settings.is_pal24_card_button_visible():
|
||||
methods.append("card")
|
||||
if not methods:
|
||||
methods.append("sbp")
|
||||
return methods
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_pal24_payment_amount(
|
||||
async def _send_pal24_payment_message(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
payment_method: str,
|
||||
state: FSMContext,
|
||||
):
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_pal24_enabled():
|
||||
await message.answer("❌ Оплата через PayPalych временно недоступна")
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
|
||||
min_rubles = settings.PAL24_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Минимальная сумма для оплаты через PayPalych: {min_rubles:.0f} ₽")
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
|
||||
max_rubles = settings.PAL24_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Максимальная сумма для оплаты через PayPalych: {max_rubles:,.0f} ₽".replace(',', ' '))
|
||||
return
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(message.bot)
|
||||
payment_result = await payment_service.create_pal24_payment(
|
||||
@@ -98,6 +46,7 @@ async def process_pal24_payment_amount(
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
language=db_user.language,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
if not payment_result:
|
||||
@@ -262,14 +211,15 @@ async def process_pal24_payment_amount(
|
||||
await state.clear()
|
||||
|
||||
logger.info(
|
||||
"Создан PayPalych счет для пользователя %s: %s₽, ID: %s",
|
||||
"Создан PayPalych счет для пользователя %s: %s₽, ID: %s, метод: %s",
|
||||
db_user.telegram_id,
|
||||
amount_kopeks / 100,
|
||||
bill_id,
|
||||
payment_method,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания PayPalych платежа: {e}")
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка создания PayPalych платежа: {error}")
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAL24_PAYMENT_ERROR",
|
||||
@@ -278,6 +228,168 @@ async def process_pal24_payment_amount(
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
@error_handler
|
||||
async def start_pal24_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_pal24_enabled():
|
||||
await callback.answer("❌ Оплата через PayPalych временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
# Формируем текст сообщения в зависимости от доступных способов оплаты
|
||||
if settings.is_pal24_sbp_button_visible() and settings.is_pal24_card_button_visible():
|
||||
payment_methods_text = "СБП и банковской картой"
|
||||
elif settings.is_pal24_sbp_button_visible():
|
||||
payment_methods_text = "СБП"
|
||||
elif settings.is_pal24_card_button_visible():
|
||||
payment_methods_text = "банковской картой"
|
||||
else:
|
||||
# Если обе кнопки отключены, используем общий текст
|
||||
payment_methods_text = "доступными способами"
|
||||
|
||||
message_text = texts.t(
|
||||
"PAL24_TOPUP_PROMPT",
|
||||
(
|
||||
f"🏦 <b>Оплата через PayPalych ({payment_methods_text})</b>\n\n"
|
||||
"Введите сумму для пополнения от 100 до 1 000 000 ₽.\n"
|
||||
f"Оплата проходит через PayPalych ({payment_methods_text})."
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="pal24")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_pal24_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_pal24_enabled():
|
||||
await message.answer("❌ Оплата через PayPalych временно недоступна")
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
|
||||
min_rubles = settings.PAL24_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Минимальная сумма для оплаты через PayPalych: {min_rubles:.0f} ₽")
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
|
||||
max_rubles = settings.PAL24_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(
|
||||
f"❌ Максимальная сумма для оплаты через PayPalych: {max_rubles:,.0f} ₽".replace(',', ' ')
|
||||
)
|
||||
return
|
||||
|
||||
available_methods = _get_available_pal24_methods()
|
||||
|
||||
if len(available_methods) == 1:
|
||||
await _send_pal24_payment_message(
|
||||
message,
|
||||
db_user,
|
||||
db,
|
||||
amount_kopeks,
|
||||
available_methods[0],
|
||||
state,
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(pal24_amount_kopeks=amount_kopeks)
|
||||
await state.set_state(BalanceStates.waiting_for_pal24_method)
|
||||
|
||||
method_buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
if "sbp" in available_methods:
|
||||
method_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=settings.get_pal24_sbp_button_text(
|
||||
texts.t("PAL24_SBP_PAY_BUTTON", "🏦 Оплатить через PayPalych (СБП)")
|
||||
),
|
||||
callback_data="pal24_method_sbp",
|
||||
)
|
||||
]
|
||||
)
|
||||
if "card" in available_methods:
|
||||
method_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=settings.get_pal24_card_button_text(
|
||||
texts.t("PAL24_CARD_PAY_BUTTON", "💳 Оплатить банковской картой (PayPalych)")
|
||||
),
|
||||
callback_data="pal24_method_card",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
method_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")])
|
||||
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAL24_SELECT_PAYMENT_METHOD",
|
||||
"Выберите способ оплаты PayPalych:",
|
||||
),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=method_buttons),
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_pal24_method_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
data = await state.get_data()
|
||||
amount_kopeks = data.get("pal24_amount_kopeks")
|
||||
if not amount_kopeks:
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PAL24_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
method = "sbp" if callback.data.endswith("_sbp") else "card"
|
||||
|
||||
await callback.answer()
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await _send_pal24_payment_message(
|
||||
callback.message,
|
||||
db_user,
|
||||
db,
|
||||
int(amount_kopeks),
|
||||
method,
|
||||
state,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_pal24_payment_status(
|
||||
@@ -353,11 +465,17 @@ async def check_pal24_payment_status(
|
||||
])
|
||||
|
||||
await callback.answer()
|
||||
await callback.message.edit_text(
|
||||
"\n".join(message_lines),
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
"\n".join(message_lines),
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except TelegramBadRequest as error:
|
||||
if "message is not modified" in str(error).lower():
|
||||
await callback.answer(texts.t("CHECK_STATUS_NO_CHANGES", "Статус не изменился"))
|
||||
else:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки статуса PayPalych: {e}")
|
||||
|
||||
@@ -246,34 +246,125 @@ async def process_yookassa_sbp_payment_amount(
|
||||
return
|
||||
|
||||
confirmation_url = payment_result.get("confirmation_url")
|
||||
if not confirmation_url:
|
||||
await message.answer("❌ Ошибка получения ссылки для оплаты через СБП. Обратитесь в поддержку.")
|
||||
qr_confirmation_data = payment_result.get("qr_confirmation_data")
|
||||
|
||||
if not confirmation_url and not qr_confirmation_data:
|
||||
await message.answer("❌ Ошибка получения данных для оплаты через СБП. Обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🏦 Оплатить через СБП", url=confirmation_url)],
|
||||
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
# Подготовим QR-код для вставки в основное сообщение
|
||||
qr_photo = None
|
||||
if qr_confirmation_data:
|
||||
try:
|
||||
# Импортируем необходимые модули для генерации QR-кода
|
||||
import base64
|
||||
from io import BytesIO
|
||||
import qrcode
|
||||
from aiogram.types import BufferedInputFile
|
||||
|
||||
# Создаем QR-код из полученных данных
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
||||
qr.add_data(qr_confirmation_data)
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
# Сохраняем изображение в байты
|
||||
img_bytes = BytesIO()
|
||||
img.save(img_bytes, format='PNG')
|
||||
img_bytes.seek(0)
|
||||
|
||||
qr_photo = BufferedInputFile(img_bytes.getvalue(), filename="qrcode.png")
|
||||
except ImportError:
|
||||
logger.warning("qrcode библиотека не установлена, QR-код не будет сгенерирован")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка генерации QR-кода: {e}")
|
||||
|
||||
await message.answer(
|
||||
f"🏦 <b>Оплата через СБП</b>\n\n"
|
||||
# Если нет QR-данных из YooKassa, но есть URL, генерируем QR-код из URL
|
||||
if not qr_photo and confirmation_url:
|
||||
try:
|
||||
# Импортируем необходимые модули для генерации QR-кода
|
||||
import base64
|
||||
from io import BytesIO
|
||||
import qrcode
|
||||
from aiogram.types import BufferedInputFile
|
||||
|
||||
# Создаем QR-код из URL
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
||||
qr.add_data(confirmation_url)
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
# Сохраняем изображение в байты
|
||||
img_bytes = BytesIO()
|
||||
img.save(img_bytes, format='PNG')
|
||||
img_bytes.seek(0)
|
||||
|
||||
qr_photo = BufferedInputFile(img_bytes.getvalue(), filename="qrcode.png")
|
||||
except ImportError:
|
||||
logger.warning("qrcode библиотека не установлена, QR-код не будет сгенерирован")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка генерации QR-кода из URL: {e}")
|
||||
|
||||
# Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса
|
||||
keyboard_buttons = []
|
||||
|
||||
# Добавляем кнопку оплаты, если доступна ссылка
|
||||
if confirmation_url:
|
||||
keyboard_buttons.append([types.InlineKeyboardButton(text="🔗 Перейти к оплате", url=confirmation_url)])
|
||||
else:
|
||||
# Если ссылка недоступна, предлагаем оплатить через ID платежа в приложении банка
|
||||
keyboard_buttons.append([types.InlineKeyboardButton(text="📱 Оплатить в приложении банка", callback_data="temp_disabled")])
|
||||
|
||||
# Добавляем общие кнопки
|
||||
keyboard_buttons.append([types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")])
|
||||
keyboard_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")])
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
|
||||
|
||||
# Подготавливаем текст сообщения
|
||||
message_text = (
|
||||
f"🔗 <b>Оплата через СБП</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
|
||||
f"📱 <b>Инструкция:</b>\n"
|
||||
f"1. Нажмите кнопку 'Оплатить через СБП'\n"
|
||||
f"2. Вас перенаправит в приложение вашего банка\n"
|
||||
f"3. Подтвердите платеж через СБП\n"
|
||||
f"4. Деньги поступят на баланс автоматически\n\n"
|
||||
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
|
||||
f"✅ Принимаем СБП от всех банков-участников\n\n"
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
# Добавляем инструкции в зависимости от доступных способов оплаты
|
||||
if not confirmation_url:
|
||||
message_text += (
|
||||
f"📱 <b>Инструкция по оплате:</b>\n"
|
||||
f"1. Откройте приложение вашего банка\n"
|
||||
f"2. Найдите функцию оплаты по реквизитам или перевод по СБП\n"
|
||||
f"3. Введите ID платежа: <code>{payment_result['yookassa_payment_id']}</code>\n"
|
||||
f"4. Подтвердите платеж в приложении банка\n"
|
||||
f"5. Деньги поступят на баланс автоматически\n\n"
|
||||
)
|
||||
|
||||
message_text += (
|
||||
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
|
||||
f"✅ Принимаем СБП от всех банков-участников\n\n"
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}"
|
||||
)
|
||||
|
||||
# Отправляем сообщение с инструкциями и клавиатурой
|
||||
# Если есть QR-код, отправляем его как медиа-сообщение
|
||||
if qr_photo:
|
||||
# Используем метод отправки медиа-группы или фото с описанием
|
||||
await message.answer_photo(
|
||||
photo=qr_photo,
|
||||
caption=message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
# Если QR-код недоступен, отправляем обычное текстовое сообщение
|
||||
await message.answer(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(f"Создан платеж YooKassa СБП для пользователя {db_user.telegram_id}: "
|
||||
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
|
||||
@@ -284,6 +375,9 @@ async def process_yookassa_sbp_payment_amount(
|
||||
await state.clear()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_yookassa_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
|
||||
+38
-28
@@ -153,7 +153,7 @@ async def show_main_menu(
|
||||
db_user.last_activity = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
has_active_subscription = bool(db_user.subscription)
|
||||
has_active_subscription = bool(db_user.subscription and db_user.subscription.is_active)
|
||||
subscription_is_active = False
|
||||
|
||||
if db_user.subscription:
|
||||
@@ -892,7 +892,7 @@ async def handle_back_to_menu(
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
has_active_subscription = db_user.subscription is not None
|
||||
has_active_subscription = bool(db_user.subscription and db_user.subscription.is_active)
|
||||
subscription_is_active = False
|
||||
|
||||
if db_user.subscription:
|
||||
@@ -946,64 +946,74 @@ async def handle_back_to_menu(
|
||||
await callback.answer()
|
||||
|
||||
def _get_subscription_status(user: User, texts) -> str:
|
||||
if not user.subscription:
|
||||
subscription = getattr(user, "subscription", None)
|
||||
if not subscription:
|
||||
return texts.t("SUB_STATUS_NONE", "❌ Отсутствует")
|
||||
|
||||
subscription = user.subscription
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if subscription.end_date <= current_time:
|
||||
actual_status = (subscription.actual_status or "").lower()
|
||||
end_date_text = subscription.end_date.strftime("%d.%m.%Y")
|
||||
days_left = 0
|
||||
|
||||
if subscription.end_date > current_time:
|
||||
days_left = (subscription.end_date - current_time).days
|
||||
|
||||
if actual_status == "pending":
|
||||
return texts.t("SUBSCRIPTION_NONE", "❌ Нет активной подписки")
|
||||
|
||||
if actual_status == "disabled":
|
||||
return texts.t("SUB_STATUS_DISABLED", "⚫ Отключена")
|
||||
|
||||
if actual_status == "expired":
|
||||
return texts.t(
|
||||
"SUB_STATUS_EXPIRED",
|
||||
"🔴 Истекла\n📅 {end_date}",
|
||||
).format(end_date=subscription.end_date.strftime('%d.%m.%Y'))
|
||||
|
||||
days_left = (subscription.end_date - current_time).days
|
||||
|
||||
if subscription.is_trial:
|
||||
).format(end_date=end_date_text)
|
||||
|
||||
if actual_status == "trial":
|
||||
if days_left > 1:
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_ACTIVE",
|
||||
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
|
||||
).format(
|
||||
end_date=subscription.end_date.strftime('%d.%m.%Y'),
|
||||
end_date=end_date_text,
|
||||
days=days_left,
|
||||
)
|
||||
elif days_left == 1:
|
||||
if days_left == 1:
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_TOMORROW",
|
||||
"🎁 Тестовая подписка\n⚠️ истекает завтра!",
|
||||
)
|
||||
else:
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_TODAY",
|
||||
"🎁 Тестовая подписка\n⚠️ истекает сегодня!",
|
||||
)
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_TODAY",
|
||||
"🎁 Тестовая подписка\n⚠️ истекает сегодня!",
|
||||
)
|
||||
|
||||
else:
|
||||
if actual_status == "active":
|
||||
if days_left > 7:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_LONG",
|
||||
"💎 Активна\n📅 до {end_date} ({days} дн.)",
|
||||
).format(
|
||||
end_date=subscription.end_date.strftime('%d.%m.%Y'),
|
||||
end_date=end_date_text,
|
||||
days=days_left,
|
||||
)
|
||||
elif days_left > 1:
|
||||
if days_left > 1:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_FEW_DAYS",
|
||||
"💎 Активна\n⚠️ истекает через {days} дн.",
|
||||
).format(days=days_left)
|
||||
elif days_left == 1:
|
||||
if days_left == 1:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_TOMORROW",
|
||||
"💎 Активна\n⚠️ истекает завтра!",
|
||||
)
|
||||
else:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_TODAY",
|
||||
"💎 Активна\n⚠️ истекает сегодня!",
|
||||
)
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_TODAY",
|
||||
"💎 Активна\n⚠️ истекает сегодня!",
|
||||
)
|
||||
|
||||
return texts.t("SUB_STATUS_UNKNOWN", "❓ Неизвестно")
|
||||
|
||||
|
||||
def _insert_random_message(base_text: str, random_message: str, action_prompt: str) -> str:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -231,6 +231,16 @@ async def handle_subscription_config_back(
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
|
||||
elif current_state == SubscriptionStates.confirming_purchase.state:
|
||||
data = await state.get_data()
|
||||
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_DEVICES,
|
||||
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_devices)
|
||||
|
||||
else:
|
||||
from app.handlers.menu import show_main_menu
|
||||
await show_main_menu(callback, db_user, db)
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Tuple, Optional
|
||||
from urllib.parse import quote
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -80,14 +81,64 @@ from app.utils.promo_offer import (
|
||||
)
|
||||
|
||||
from .common import _apply_promo_offer_discount, _get_promo_offer_discount_percent, logger, update_traffic_prices
|
||||
from .autopay import handle_autopay_menu, handle_subscription_cancel, handle_subscription_config_back, set_autopay_days, show_autopay_days, toggle_autopay
|
||||
from .countries import _get_available_countries, _should_show_countries_management, apply_countries_changes, countries_continue, handle_add_countries, handle_manage_country, select_country
|
||||
from .devices import confirm_add_devices, confirm_change_devices, confirm_reset_devices, execute_change_devices, get_current_devices_count, get_servers_display_names, handle_all_devices_reset_from_management, handle_app_selection, handle_change_devices, handle_device_guide, handle_device_management, handle_devices_page, handle_reset_devices, handle_single_device_reset, handle_specific_app_guide, show_device_connection_help
|
||||
from .happ import handle_happ_download_back, handle_happ_download_close, handle_happ_download_platform_choice, handle_happ_download_request
|
||||
from .autopay import (
|
||||
handle_autopay_menu,
|
||||
handle_subscription_cancel,
|
||||
handle_subscription_config_back,
|
||||
set_autopay_days,
|
||||
show_autopay_days,
|
||||
toggle_autopay,
|
||||
)
|
||||
from .countries import (
|
||||
_get_available_countries,
|
||||
_should_show_countries_management,
|
||||
apply_countries_changes,
|
||||
countries_continue,
|
||||
handle_add_countries,
|
||||
handle_manage_country,
|
||||
select_country,
|
||||
)
|
||||
from .devices import (
|
||||
confirm_add_devices,
|
||||
confirm_change_devices,
|
||||
confirm_reset_devices,
|
||||
execute_change_devices,
|
||||
get_current_devices_count,
|
||||
get_servers_display_names,
|
||||
handle_all_devices_reset_from_management,
|
||||
handle_app_selection,
|
||||
handle_change_devices,
|
||||
handle_device_guide,
|
||||
handle_device_management,
|
||||
handle_devices_page,
|
||||
handle_reset_devices,
|
||||
handle_single_device_reset,
|
||||
handle_specific_app_guide,
|
||||
show_device_connection_help,
|
||||
)
|
||||
from .happ import (
|
||||
handle_happ_download_back,
|
||||
handle_happ_download_close,
|
||||
handle_happ_download_platform_choice,
|
||||
handle_happ_download_request,
|
||||
)
|
||||
from .links import handle_connect_subscription, handle_open_subscription_link
|
||||
from .pricing import _build_subscription_period_prompt, _prepare_subscription_summary
|
||||
from .promo import _build_promo_group_discount_text, _get_promo_offer_hint, claim_discount_offer, handle_promo_offer_close
|
||||
from .traffic import confirm_reset_traffic, confirm_switch_traffic, execute_switch_traffic, handle_no_traffic_packages, handle_reset_traffic, handle_switch_traffic, select_traffic
|
||||
from .promo import (
|
||||
_build_promo_group_discount_text,
|
||||
_get_promo_offer_hint,
|
||||
claim_discount_offer,
|
||||
handle_promo_offer_close,
|
||||
)
|
||||
from .traffic import (
|
||||
confirm_reset_traffic,
|
||||
confirm_switch_traffic,
|
||||
execute_switch_traffic,
|
||||
handle_no_traffic_packages,
|
||||
handle_reset_traffic,
|
||||
handle_switch_traffic,
|
||||
select_traffic,
|
||||
)
|
||||
|
||||
async def show_subscription_info(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -527,10 +578,13 @@ async def start_subscription_purchase(
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
await _build_subscription_period_prompt(db_user, texts, db),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
keyboard = get_subscription_period_keyboard(db_user.language)
|
||||
prompt_text = await _build_subscription_period_prompt(db_user, texts, db)
|
||||
|
||||
await _edit_message_text_or_caption(
|
||||
callback.message,
|
||||
prompt_text,
|
||||
keyboard,
|
||||
)
|
||||
|
||||
subscription = getattr(db_user, 'subscription', None)
|
||||
@@ -555,6 +609,46 @@ async def start_subscription_purchase(
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _edit_message_text_or_caption(
|
||||
message: types.Message,
|
||||
text: str,
|
||||
reply_markup: InlineKeyboardMarkup,
|
||||
parse_mode: Optional[str] = "HTML",
|
||||
) -> None:
|
||||
"""Edits message text when possible, falls back to caption or re-sends message."""
|
||||
|
||||
try:
|
||||
await message.edit_text(
|
||||
text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
except TelegramBadRequest as error:
|
||||
error_message = str(error).lower()
|
||||
|
||||
if "message is not modified" in error_message:
|
||||
return
|
||||
|
||||
if "there is no text in the message to edit" in error_message:
|
||||
if message.caption is not None:
|
||||
await message.edit_caption(
|
||||
caption=text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
return
|
||||
|
||||
await message.delete()
|
||||
await message.answer(
|
||||
text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
return
|
||||
|
||||
raise
|
||||
|
||||
async def save_cart_and_redirect_to_topup(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
@@ -1999,7 +2093,7 @@ def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
start_subscription_purchase,
|
||||
F.data.in_(["menu_buy", "subscription_upgrade"])
|
||||
F.data.in_(["menu_buy", "subscription_upgrade", "subscription_purchase"])
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
@@ -2264,3 +2358,168 @@ def register_handlers(dp: Dispatcher):
|
||||
show_device_connection_help,
|
||||
F.data == "device_connection_help"
|
||||
)
|
||||
|
||||
# Регистрируем обработчик для простой покупки
|
||||
dp.callback_query.register(
|
||||
handle_simple_subscription_purchase,
|
||||
F.data == "simple_subscription_purchase"
|
||||
)
|
||||
|
||||
|
||||
async def handle_simple_subscription_purchase(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""Обрабатывает простую покупку подписки."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.SIMPLE_SUBSCRIPTION_ENABLED:
|
||||
await callback.answer("❌ Простая покупка подписки временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем, есть ли у пользователя активная подписка
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
current_subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
|
||||
if current_subscription and current_subscription.is_active:
|
||||
await callback.answer("❌ У вас уже есть активная подписка", show_alert=True)
|
||||
return
|
||||
|
||||
# Подготовим параметры простой подписки
|
||||
subscription_params = {
|
||||
"period_days": settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS,
|
||||
"device_limit": settings.SIMPLE_SUBSCRIPTION_DEVICE_LIMIT,
|
||||
"traffic_limit_gb": settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
|
||||
"squad_uuid": settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID
|
||||
}
|
||||
|
||||
# Сохраняем параметры в состояние
|
||||
await state.update_data(subscription_params=subscription_params)
|
||||
|
||||
# Проверяем баланс пользователя
|
||||
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
|
||||
# Рассчитываем цену подписки
|
||||
price_kopeks = _calculate_simple_subscription_price(subscription_params)
|
||||
traffic_text = (
|
||||
"Безлимит"
|
||||
if subscription_params["traffic_limit_gb"] == 0
|
||||
else f"{subscription_params['traffic_limit_gb']} ГБ"
|
||||
)
|
||||
|
||||
if user_balance_kopeks >= price_kopeks:
|
||||
# Если баланс достаточный, предлагаем оплатить с баланса
|
||||
message_text = (
|
||||
f"⚡ <b>Простая покупка подписки</b>\n\n"
|
||||
f"📅 Период: {subscription_params['period_days']} дней\n"
|
||||
f"📱 Устройства: {subscription_params['device_limit']}\n"
|
||||
f"📊 Трафик: {traffic_text}\n"
|
||||
f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}\n\n"
|
||||
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
|
||||
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}\n\n"
|
||||
f"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты."
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="✅ Оплатить с баланса", callback_data="simple_subscription_pay_with_balance")],
|
||||
[types.InlineKeyboardButton(text="💳 Другие способы оплаты", callback_data="simple_subscription_other_payment_methods")],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")]
|
||||
])
|
||||
else:
|
||||
# Если баланс недостаточный, предлагаем внешние способы оплаты
|
||||
message_text = (
|
||||
f"⚡ <b>Простая покупка подписки</b>\n\n"
|
||||
f"📅 Период: {subscription_params['period_days']} дней\n"
|
||||
f"📱 Устройства: {subscription_params['device_limit']}\n"
|
||||
f"📊 Трафик: {traffic_text}\n"
|
||||
f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}\n\n"
|
||||
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
|
||||
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}\n\n"
|
||||
f"Выберите способ оплаты:"
|
||||
)
|
||||
|
||||
keyboard = _get_simple_subscription_payment_keyboard(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(SubscriptionStates.waiting_for_simple_subscription_payment_method)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
|
||||
|
||||
def _calculate_simple_subscription_price(params: dict) -> int:
|
||||
"""Рассчитывает цену простой подписки."""
|
||||
period_days = params.get("period_days", 30)
|
||||
|
||||
# Получаем цену для стандартного периода
|
||||
if hasattr(settings, f'PRICE_{period_days}_DAYS'):
|
||||
return getattr(settings, f'PRICE_{period_days}_DAYS')
|
||||
else:
|
||||
# Если нет цены для конкретного периода, используем базовую цену
|
||||
return settings.BASE_SUBSCRIPTION_PRICE
|
||||
|
||||
|
||||
def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyboardMarkup:
|
||||
"""Создает клавиатуру с методами оплаты для простой подписки."""
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
# Добавляем доступные методы оплаты
|
||||
if settings.TELEGRAM_STARS_ENABLED:
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text="⭐ Telegram Stars",
|
||||
callback_data="simple_subscription_stars"
|
||||
)])
|
||||
|
||||
if settings.is_yookassa_enabled():
|
||||
yookassa_methods = []
|
||||
if settings.YOOKASSA_SBP_ENABLED:
|
||||
yookassa_methods.append(types.InlineKeyboardButton(
|
||||
text="🏦 YooKassa (СБП)",
|
||||
callback_data="simple_subscription_yookassa_sbp"
|
||||
))
|
||||
yookassa_methods.append(types.InlineKeyboardButton(
|
||||
text="💳 YooKassa (Карта)",
|
||||
callback_data="simple_subscription_yookassa"
|
||||
))
|
||||
if yookassa_methods:
|
||||
keyboard.append(yookassa_methods)
|
||||
|
||||
if settings.is_cryptobot_enabled():
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text="🪙 CryptoBot",
|
||||
callback_data="simple_subscription_cryptobot"
|
||||
)])
|
||||
|
||||
if settings.is_mulenpay_enabled():
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text=f"💳 {mulenpay_name}",
|
||||
callback_data="simple_subscription_mulenpay"
|
||||
)])
|
||||
|
||||
if settings.is_pal24_enabled():
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text="💳 PayPalych",
|
||||
callback_data="simple_subscription_pal24"
|
||||
)])
|
||||
|
||||
if settings.is_wata_enabled():
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text="💳 WATA",
|
||||
callback_data="simple_subscription_wata"
|
||||
)])
|
||||
|
||||
# Кнопка назад
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="subscription_purchase"
|
||||
)])
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
@@ -762,6 +762,13 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
|
||||
]
|
||||
]
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_USER_SEND_MESSAGE", "✉️ Отправить сообщение"),
|
||||
callback_data=f"admin_user_send_message_{user_id}"
|
||||
)
|
||||
])
|
||||
|
||||
if user_status == "active":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
|
||||
+73
-40
@@ -257,7 +257,8 @@ def get_main_menu_keyboard(
|
||||
"💰 Баланс: {balance}",
|
||||
).format(balance=texts.format_price(balance_kopeks))
|
||||
|
||||
keyboard = []
|
||||
keyboard: list[list[InlineKeyboardButton]] = []
|
||||
paired_buttons: list[InlineKeyboardButton] = []
|
||||
|
||||
if has_active_subscription and subscription_is_active:
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
@@ -312,22 +313,30 @@ def get_main_menu_keyboard(
|
||||
happ_row = get_happ_download_button_row(texts)
|
||||
if happ_row:
|
||||
keyboard.append(happ_row)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance"),
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription")
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")
|
||||
])
|
||||
)
|
||||
|
||||
keyboard.append([InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")])
|
||||
|
||||
show_trial = not has_had_paid_subscription and not has_active_subscription
|
||||
|
||||
|
||||
show_buy = not has_active_subscription or not subscription_is_active
|
||||
|
||||
subscription_buttons = []
|
||||
|
||||
current_subscription = subscription
|
||||
has_active_paid_subscription = bool(
|
||||
current_subscription
|
||||
and not getattr(current_subscription, "is_trial", False)
|
||||
and getattr(current_subscription, "is_active", False)
|
||||
)
|
||||
simple_purchase_button = None
|
||||
if settings.SIMPLE_SUBSCRIPTION_ENABLED and not has_active_paid_subscription:
|
||||
simple_purchase_button = InlineKeyboardButton(
|
||||
text=texts.MENU_SIMPLE_SUBSCRIPTION,
|
||||
callback_data="simple_subscription_purchase",
|
||||
)
|
||||
|
||||
subscription_buttons: list[InlineKeyboardButton] = []
|
||||
|
||||
if show_trial:
|
||||
subscription_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_TRIAL, callback_data="menu_trial")
|
||||
@@ -337,38 +346,38 @@ def get_main_menu_keyboard(
|
||||
subscription_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="menu_buy")
|
||||
)
|
||||
if simple_purchase_button:
|
||||
subscription_buttons.append(simple_purchase_button)
|
||||
simple_purchase_button = None
|
||||
|
||||
if subscription_buttons:
|
||||
if len(subscription_buttons) == 2:
|
||||
keyboard.append(subscription_buttons)
|
||||
else:
|
||||
keyboard.append([subscription_buttons[0]])
|
||||
paired_buttons.extend(subscription_buttons)
|
||||
if simple_purchase_button:
|
||||
paired_buttons.append(simple_purchase_button)
|
||||
|
||||
if show_resume_checkout or has_saved_cart:
|
||||
keyboard.append([
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
)
|
||||
])
|
||||
)
|
||||
|
||||
if custom_buttons:
|
||||
for button in custom_buttons:
|
||||
if isinstance(button, InlineKeyboardButton):
|
||||
keyboard.append([button])
|
||||
paired_buttons.append(button)
|
||||
|
||||
# Добавляем кнопки промокода и рефералов, учитывая настройки
|
||||
additional_buttons = [
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode")
|
||||
]
|
||||
)
|
||||
|
||||
# Добавляем кнопку рефералов только если программа включена
|
||||
if settings.is_referral_program_enabled():
|
||||
additional_buttons.append(
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_REFERRALS, callback_data="menu_referrals")
|
||||
)
|
||||
|
||||
keyboard.append(additional_buttons)
|
||||
|
||||
# Support button is configurable (runtime via service)
|
||||
try:
|
||||
@@ -377,21 +386,26 @@ def get_main_menu_keyboard(
|
||||
except Exception:
|
||||
support_enabled = settings.SUPPORT_MENU_ENABLED
|
||||
if support_enabled:
|
||||
keyboard.append([
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support")
|
||||
])
|
||||
)
|
||||
|
||||
keyboard.append([
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MENU_INFO", "ℹ️ Инфо"),
|
||||
callback_data="menu_info",
|
||||
)
|
||||
])
|
||||
)
|
||||
|
||||
if settings.is_language_selection_enabled():
|
||||
keyboard.append([
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data="menu_language")
|
||||
])
|
||||
)
|
||||
|
||||
for i in range(0, len(paired_buttons), 2):
|
||||
row = paired_buttons[i : i + 2]
|
||||
keyboard.append(row)
|
||||
|
||||
if settings.DEBUG:
|
||||
print(f"DEBUG KEYBOARD: is_admin={is_admin}, добавляем админ кнопку: {is_admin}")
|
||||
|
||||
@@ -870,6 +884,8 @@ def get_subscription_period_keyboard(language: str = DEFAULT_LANGUAGE) -> Inline
|
||||
)
|
||||
])
|
||||
|
||||
# Кнопка "Простая покупка" была убрана из выбора периода подписки
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
])
|
||||
@@ -1089,9 +1105,13 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
])
|
||||
|
||||
if settings.is_mulenpay_enabled():
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_CARD_MULENPAY", "💳 Банковская карта (Mulen Pay)"),
|
||||
text=texts.t(
|
||||
"PAYMENT_CARD_MULENPAY",
|
||||
"💳 Банковская карта ({mulenpay_name})",
|
||||
).format(mulenpay_name=mulenpay_name),
|
||||
callback_data=_build_callback("mulenpay")
|
||||
)
|
||||
])
|
||||
@@ -1120,6 +1140,14 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
)
|
||||
])
|
||||
|
||||
if settings.is_heleket_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_HELEKET", "🪙 Криптовалюта (Heleket)"),
|
||||
callback_data=_build_callback("heleket")
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_VIA_SUPPORT", "🛠️ Через поддержку"),
|
||||
@@ -1356,7 +1384,7 @@ def get_autopay_days_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboar
|
||||
for days in [1, 3, 7, 14]:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{days} дн{_get_days_suffix(days)}",
|
||||
text=f"{days} {_get_days_word(days)}",
|
||||
callback_data=f"autopay_days_{days}"
|
||||
)
|
||||
])
|
||||
@@ -1368,13 +1396,12 @@ def get_autopay_days_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboar
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def _get_days_suffix(days: int) -> str:
|
||||
if days == 1:
|
||||
return "ь"
|
||||
elif 2 <= days <= 4:
|
||||
return "я"
|
||||
else:
|
||||
return "ей"
|
||||
def _get_days_word(days: int) -> str:
|
||||
if days % 10 == 1 and days % 100 != 11:
|
||||
return "день"
|
||||
if 2 <= days % 10 <= 4 and not (12 <= days % 100 <= 14):
|
||||
return "дня"
|
||||
return "дней"
|
||||
|
||||
|
||||
|
||||
@@ -2300,6 +2327,12 @@ def get_admin_tickets_keyboard(
|
||||
keyboard.append(switch_row)
|
||||
|
||||
if open_rows and scope in ("all", "open"):
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_CLOSE_ALL_OPEN_TICKETS", "🔒 Закрыть все открытые"),
|
||||
callback_data="admin_tickets_close_all_open"
|
||||
)
|
||||
])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.t("OPEN_TICKETS_HEADER", "Открытые тикеты"), callback_data="noop")])
|
||||
keyboard.extend(open_rows)
|
||||
if closed_rows and scope in ("all", "closed"):
|
||||
|
||||
+68
-10
@@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
import shutil
|
||||
import tempfile
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
@@ -161,6 +163,41 @@ def _normalize_locale_dict(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return normalized
|
||||
|
||||
|
||||
def _directory_is_writable(directory: Path) -> bool:
|
||||
try:
|
||||
current_user = f"{os.geteuid()}:{os.getegid()}"
|
||||
user_hint = f" (running as UID:GID {current_user})"
|
||||
except Exception: # pragma: no cover - best effort only
|
||||
user_hint = ""
|
||||
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(dir=directory, prefix=".locale_write_test_", delete=True):
|
||||
pass
|
||||
return True
|
||||
except PermissionError as error:
|
||||
_logger.warning(
|
||||
"Locale directory %s is not writable%s. Ensure the mounted directory allows writes for the container user or configure LOCALES_PATH to a writable path. (%s)",
|
||||
directory,
|
||||
user_hint,
|
||||
error,
|
||||
)
|
||||
except OSError as error:
|
||||
_logger.warning(
|
||||
"Unable to prepare locale directory %s for writing%s: %s. Configure LOCALES_PATH to a writable path.",
|
||||
directory,
|
||||
user_hint,
|
||||
error,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
_logger.warning(
|
||||
"Unexpected error while checking locale directory %s%s: %s",
|
||||
directory,
|
||||
user_hint,
|
||||
error,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def ensure_locale_templates() -> None:
|
||||
destination = _resolve_user_locales_dir()
|
||||
try:
|
||||
@@ -169,27 +206,48 @@ def ensure_locale_templates() -> None:
|
||||
_logger.warning("Unable to create locales directory %s: %s", destination, error)
|
||||
return
|
||||
|
||||
if any(destination.glob("*")):
|
||||
return
|
||||
|
||||
if not _DEFAULT_LOCALES_DIR.exists():
|
||||
_logger.debug("Default locales directory %s is missing", _DEFAULT_LOCALES_DIR)
|
||||
return
|
||||
|
||||
for template in _DEFAULT_LOCALES_DIR.iterdir():
|
||||
if not template.is_file():
|
||||
continue
|
||||
target_path = destination / template.name
|
||||
if not _directory_is_writable(destination):
|
||||
return
|
||||
|
||||
destination_has_files = any(destination.glob("*"))
|
||||
|
||||
def _copy_locale(source: Path, target: Path) -> None:
|
||||
try:
|
||||
shutil.copyfile(template, target_path)
|
||||
shutil.copyfile(source, target)
|
||||
except Exception as error:
|
||||
_logger.warning(
|
||||
"Failed to copy default locale %s to %s: %s",
|
||||
template,
|
||||
target_path,
|
||||
source,
|
||||
target,
|
||||
error,
|
||||
)
|
||||
|
||||
if not destination_has_files:
|
||||
for template in _DEFAULT_LOCALES_DIR.iterdir():
|
||||
if not template.is_file():
|
||||
continue
|
||||
_copy_locale(template, destination / template.name)
|
||||
return
|
||||
|
||||
for locale_code in ("ru", "en"):
|
||||
source_path = _DEFAULT_LOCALES_DIR / f"{locale_code}.json"
|
||||
target_path = destination / f"{locale_code}.json"
|
||||
|
||||
if target_path.exists():
|
||||
continue
|
||||
|
||||
if not source_path.exists():
|
||||
_logger.debug(
|
||||
"Default locale template %s is missing at %s", locale_code, source_path
|
||||
)
|
||||
continue
|
||||
|
||||
_copy_locale(source_path, target_path)
|
||||
|
||||
|
||||
def _load_default_locale(language: str) -> Dict[str, Any]:
|
||||
default_path = _DEFAULT_LOCALES_DIR / f"{language}.json"
|
||||
|
||||
+1226
-666
File diff suppressed because it is too large
Load Diff
+1119
-593
File diff suppressed because it is too large
Load Diff
@@ -217,9 +217,10 @@ class AdminNotificationService:
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Subscription,
|
||||
transaction: Transaction,
|
||||
transaction: Optional[Transaction],
|
||||
period_days: int,
|
||||
was_trial_conversion: bool = False
|
||||
was_trial_conversion: bool = False,
|
||||
amount_kopeks: Optional[int] = None,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
@@ -235,11 +236,14 @@ class AdminNotificationService:
|
||||
user_status = "🆕 Первая покупка"
|
||||
|
||||
servers_info = await self._get_servers_info(subscription.connected_squads)
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method) if transaction else "Баланс"
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
|
||||
total_amount = amount_kopeks if amount_kopeks is not None else (transaction.amount_kopeks if transaction else 0)
|
||||
transaction_id = transaction.id if transaction else "—"
|
||||
|
||||
message = f"""💎 <b>{event_type}</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
@@ -250,9 +254,9 @@ class AdminNotificationService:
|
||||
{promo_block}
|
||||
|
||||
💰 <b>Платеж:</b>
|
||||
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
|
||||
💵 Сумма: {settings.format_price(total_amount)}
|
||||
💳 Способ: {payment_method}
|
||||
🆔 ID транзакции: {transaction.id}
|
||||
🆔 ID транзакции: {transaction_id}
|
||||
|
||||
📱 <b>Параметры подписки:</b>
|
||||
📅 Период: {period_days} дней
|
||||
@@ -798,11 +802,12 @@ class AdminNotificationService:
|
||||
return self.enabled and bool(self.chat_id)
|
||||
|
||||
def _get_payment_method_display(self, payment_method: Optional[str]) -> str:
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
method_names = {
|
||||
'telegram_stars': '⭐ Telegram Stars',
|
||||
'yookassa': '💳 YooKassa (карта)',
|
||||
'tribute': '💎 Tribute (карта)',
|
||||
'mulenpay': '💳 Mulen Pay (карта)',
|
||||
'mulenpay': f'💳 {mulenpay_name} (карта)',
|
||||
'pal24': '🏦 PayPalych (СБП)',
|
||||
'manual': '🛠️ Вручную (админ)',
|
||||
'balance': '💰 С баланса'
|
||||
|
||||
@@ -9,6 +9,7 @@ from .stars import TelegramStarsMixin
|
||||
from .yookassa import YooKassaPaymentMixin
|
||||
from .tribute import TributePaymentMixin
|
||||
from .cryptobot import CryptoBotPaymentMixin
|
||||
from .heleket import HeleketPaymentMixin
|
||||
from .mulenpay import MulenPayPaymentMixin
|
||||
from .pal24 import Pal24PaymentMixin
|
||||
from .wata import WataPaymentMixin
|
||||
@@ -19,6 +20,7 @@ __all__ = [
|
||||
"YooKassaPaymentMixin",
|
||||
"TributePaymentMixin",
|
||||
"CryptoBotPaymentMixin",
|
||||
"HeleketPaymentMixin",
|
||||
"MulenPayPaymentMixin",
|
||||
"Pal24PaymentMixin",
|
||||
"WataPaymentMixin",
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
"""Mixin with Heleket payment flow implementation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime
|
||||
from importlib import import_module
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HeleketPaymentMixin:
|
||||
"""Provides helpers to create and process Heleket payments."""
|
||||
|
||||
async def create_heleket_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
*,
|
||||
language: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not getattr(self, "heleket_service", None):
|
||||
logger.error("Heleket сервис не инициализирован")
|
||||
return None
|
||||
|
||||
if amount_kopeks <= 0:
|
||||
logger.error("Сумма Heleket должна быть положительной: %s", amount_kopeks)
|
||||
return None
|
||||
|
||||
amount_rubles = amount_kopeks / 100
|
||||
amount_str = f"{amount_rubles:.2f}"
|
||||
|
||||
order_id = f"heleket_{user_id}_{int(time.time())}_{secrets.token_hex(3)}"
|
||||
|
||||
markup_percent = settings.get_heleket_markup_percent()
|
||||
discount_percent: Optional[int] = None
|
||||
if markup_percent:
|
||||
try:
|
||||
rounded = int(round(markup_percent))
|
||||
if rounded != 0:
|
||||
discount_percent = -rounded
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Некорректная наценка Heleket: %s", markup_percent)
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"amount": amount_str,
|
||||
"currency": "RUB",
|
||||
"order_id": order_id,
|
||||
"lifetime": settings.get_heleket_lifetime(),
|
||||
}
|
||||
|
||||
to_currency = (settings.HELEKET_DEFAULT_CURRENCY or "").strip()
|
||||
if to_currency:
|
||||
payload["to_currency"] = to_currency
|
||||
|
||||
network = (settings.HELEKET_DEFAULT_NETWORK or "").strip()
|
||||
if network:
|
||||
payload["network"] = network
|
||||
|
||||
callback_url = settings.get_heleket_callback_url()
|
||||
if callback_url:
|
||||
payload["url_callback"] = callback_url
|
||||
|
||||
if settings.HELEKET_RETURN_URL:
|
||||
payload["url_return"] = settings.HELEKET_RETURN_URL
|
||||
if settings.HELEKET_SUCCESS_URL:
|
||||
payload["url_success"] = settings.HELEKET_SUCCESS_URL
|
||||
|
||||
if discount_percent is not None:
|
||||
payload["discount_percent"] = discount_percent
|
||||
|
||||
metadata: Dict[str, Any] = {
|
||||
"language": language or settings.DEFAULT_LANGUAGE,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self.heleket_service.create_payment(payload) # type: ignore[union-attr]
|
||||
except Exception as error: # pragma: no cover - safety net
|
||||
logger.exception("Ошибка создания Heleket платежа: %s", error)
|
||||
return None
|
||||
|
||||
if not response:
|
||||
logger.error("Heleket API вернул пустой ответ при создании платежа")
|
||||
return None
|
||||
|
||||
payment_result = response.get("result") if isinstance(response, dict) else None
|
||||
if not payment_result:
|
||||
logger.error("Некорректный ответ Heleket API: %s", response)
|
||||
return None
|
||||
|
||||
uuid = str(payment_result.get("uuid"))
|
||||
response_order_id = payment_result.get("order_id")
|
||||
if response_order_id:
|
||||
order_id = str(response_order_id)
|
||||
|
||||
url = payment_result.get("url")
|
||||
status = payment_result.get("status") or payment_result.get("payment_status") or "check"
|
||||
payer_amount = payment_result.get("payer_amount")
|
||||
payer_currency = payment_result.get("payer_currency")
|
||||
exchange_rate = payment_result.get("payer_amount_exchange_rate")
|
||||
|
||||
try:
|
||||
exchange_rate_value = float(exchange_rate) if exchange_rate is not None else None
|
||||
except (TypeError, ValueError):
|
||||
exchange_rate_value = None
|
||||
|
||||
if exchange_rate_value is None and payer_amount:
|
||||
try:
|
||||
exchange_rate_value = float(payer_amount) / amount_rubles if amount_rubles else None
|
||||
except (TypeError, ValueError, ZeroDivisionError):
|
||||
exchange_rate_value = None
|
||||
|
||||
expires_at_raw = payment_result.get("expired_at")
|
||||
expires_at: Optional[datetime] = None
|
||||
if expires_at_raw:
|
||||
try:
|
||||
expires_at = datetime.fromtimestamp(int(expires_at_raw))
|
||||
except (TypeError, ValueError, OSError):
|
||||
expires_at = None
|
||||
|
||||
heleket_crud = import_module("app.database.crud.heleket")
|
||||
|
||||
local_payment = await heleket_crud.create_heleket_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
uuid=uuid,
|
||||
order_id=order_id,
|
||||
amount=amount_str,
|
||||
currency="RUB",
|
||||
status=status,
|
||||
payer_amount=payer_amount,
|
||||
payer_currency=payer_currency,
|
||||
exchange_rate=exchange_rate_value,
|
||||
discount_percent=discount_percent,
|
||||
payment_url=url,
|
||||
expires_at=expires_at,
|
||||
metadata={"raw_response": payment_result, **metadata},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Создан Heleket платеж %s на %s₽ для пользователя %s",
|
||||
uuid,
|
||||
amount_str,
|
||||
user_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"local_payment_id": local_payment.id,
|
||||
"uuid": uuid,
|
||||
"order_id": order_id,
|
||||
"amount": amount_str,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"payment_url": url,
|
||||
"status": status,
|
||||
"payer_amount": payer_amount,
|
||||
"payer_currency": payer_currency,
|
||||
"exchange_rate": exchange_rate_value,
|
||||
"discount_percent": discount_percent,
|
||||
}
|
||||
|
||||
async def process_heleket_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: Dict[str, Any],
|
||||
) -> bool:
|
||||
if not isinstance(payload, dict):
|
||||
logger.error("Heleket webhook payload не является словарём: %s", payload)
|
||||
return False
|
||||
|
||||
heleket_crud = import_module("app.database.crud.heleket")
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
uuid = str(payload.get("uuid") or "").strip()
|
||||
order_id = str(payload.get("order_id") or "").strip()
|
||||
status = payload.get("status") or payload.get("payment_status")
|
||||
|
||||
if not uuid and not order_id:
|
||||
logger.error("Heleket webhook без uuid/order_id: %s", payload)
|
||||
return False
|
||||
|
||||
payment = None
|
||||
if uuid:
|
||||
payment = await heleket_crud.get_heleket_payment_by_uuid(db, uuid)
|
||||
if payment is None and order_id:
|
||||
payment = await heleket_crud.get_heleket_payment_by_order_id(db, order_id)
|
||||
|
||||
if not payment:
|
||||
logger.error(
|
||||
"Heleket платеж не найден (uuid=%s order_id=%s)",
|
||||
uuid,
|
||||
order_id,
|
||||
)
|
||||
return False
|
||||
|
||||
payer_amount = payload.get("payer_amount") or payload.get("payment_amount")
|
||||
payer_currency = payload.get("payer_currency") or payload.get("currency")
|
||||
discount_percent = payload.get("discount_percent")
|
||||
exchange_rate_raw = payload.get("payer_amount_exchange_rate")
|
||||
payment_url = payload.get("url")
|
||||
|
||||
exchange_rate: Optional[float] = None
|
||||
if exchange_rate_raw is not None:
|
||||
try:
|
||||
exchange_rate = float(exchange_rate_raw)
|
||||
except (TypeError, ValueError):
|
||||
exchange_rate = None
|
||||
|
||||
if exchange_rate is None and payer_amount:
|
||||
try:
|
||||
exchange_rate = float(payer_amount) / payment.amount_float if payment.amount_float else None
|
||||
except (TypeError, ValueError, ZeroDivisionError):
|
||||
exchange_rate = None
|
||||
|
||||
paid_at: Optional[datetime] = None
|
||||
paid_at_raw = payload.get("paid_at") or payload.get("updated_at")
|
||||
if paid_at_raw:
|
||||
try:
|
||||
if isinstance(paid_at_raw, (int, float)):
|
||||
paid_at = datetime.utcfromtimestamp(float(paid_at_raw))
|
||||
else:
|
||||
paid_at = datetime.fromisoformat(str(paid_at_raw).replace("Z", "+00:00"))
|
||||
except (ValueError, TypeError):
|
||||
paid_at = None
|
||||
|
||||
updated_payment = await heleket_crud.update_heleket_payment(
|
||||
db,
|
||||
payment.uuid,
|
||||
status=status,
|
||||
payer_amount=str(payer_amount) if payer_amount is not None else None,
|
||||
payer_currency=str(payer_currency) if payer_currency is not None else None,
|
||||
exchange_rate=exchange_rate,
|
||||
discount_percent=int(discount_percent) if isinstance(discount_percent, (int, float)) else None,
|
||||
paid_at=paid_at,
|
||||
payment_url=payment_url,
|
||||
metadata={"last_webhook": payload},
|
||||
)
|
||||
|
||||
if updated_payment is None:
|
||||
return False
|
||||
|
||||
if updated_payment.transaction_id:
|
||||
logger.info(
|
||||
"Heleket платеж %s уже связан с транзакцией %s",
|
||||
updated_payment.uuid,
|
||||
updated_payment.transaction_id,
|
||||
)
|
||||
return True
|
||||
|
||||
status_normalized = (status or "").lower()
|
||||
if status_normalized not in {"paid", "paid_over"}:
|
||||
logger.info("Heleket платеж %s в статусе %s, зачисление не требуется", updated_payment.uuid, status)
|
||||
return True
|
||||
|
||||
amount_kopeks = updated_payment.amount_kopeks
|
||||
if amount_kopeks <= 0:
|
||||
logger.error("Heleket платеж %s имеет некорректную сумму: %s", updated_payment.uuid, updated_payment.amount)
|
||||
return False
|
||||
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
user_id=updated_payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=(
|
||||
"Пополнение через Heleket"
|
||||
if not updated_payment.payer_currency
|
||||
else (
|
||||
"Пополнение через Heleket "
|
||||
f"({updated_payment.payer_amount} {updated_payment.payer_currency})"
|
||||
)
|
||||
),
|
||||
payment_method=PaymentMethod.HELEKET,
|
||||
external_id=updated_payment.uuid,
|
||||
is_completed=True,
|
||||
)
|
||||
|
||||
await heleket_crud.link_heleket_payment_to_transaction(db, updated_payment.uuid, transaction.id)
|
||||
|
||||
get_user_by_id = payment_module.get_user_by_id
|
||||
user = await get_user_by_id(db, updated_payment.user_id)
|
||||
if not user:
|
||||
logger.error("Пользователь %s не найден для Heleket платежа", updated_payment.user_id)
|
||||
return False
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
amount_kopeks,
|
||||
getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive
|
||||
logger.error("Ошибка реферального начисления Heleket: %s", error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
referrer_info = format_referrer_info(user)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error: # pragma: no cover
|
||||
logger.error("Ошибка отправки админ-уведомления Heleket: %s", error)
|
||||
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
|
||||
exchange_rate_value = updated_payment.exchange_rate or 0
|
||||
rate_text = (
|
||||
f"💱 Курс: 1 RUB = {1 / exchange_rate_value:.4f} {updated_payment.payer_currency}"
|
||||
if exchange_rate_value and updated_payment.payer_currency
|
||||
else None
|
||||
)
|
||||
|
||||
message_lines = [
|
||||
"✅ <b>Пополнение успешно!</b>",
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}",
|
||||
"💳 Способ: Heleket",
|
||||
]
|
||||
if updated_payment.payer_amount and updated_payment.payer_currency:
|
||||
message_lines.append(
|
||||
f"🪙 Оплата: {updated_payment.payer_amount} {updated_payment.payer_currency}"
|
||||
)
|
||||
if rate_text:
|
||||
message_lines.append(rate_text)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text="\n".join(message_lines),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error: # pragma: no cover
|
||||
logger.error("Ошибка отправки уведомления пользователю Heleket: %s", error)
|
||||
|
||||
return True
|
||||
@@ -28,13 +28,16 @@ class MulenPayPaymentMixin:
|
||||
language: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Создаёт локальный платеж и инициализирует сессию в MulenPay."""
|
||||
display_name = settings.get_mulenpay_display_name()
|
||||
display_name_html = settings.get_mulenpay_display_name_html()
|
||||
if not getattr(self, "mulenpay_service", None):
|
||||
logger.error("MulenPay сервис не инициализирован")
|
||||
logger.error("%s сервис не инициализирован", display_name)
|
||||
return None
|
||||
|
||||
if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Сумма MulenPay меньше минимальной: %s < %s",
|
||||
"Сумма %s меньше минимальной: %s < %s",
|
||||
display_name,
|
||||
amount_kopeks,
|
||||
settings.MULENPAY_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
@@ -42,7 +45,8 @@ class MulenPayPaymentMixin:
|
||||
|
||||
if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Сумма MulenPay больше максимальной: %s > %s",
|
||||
"Сумма %s больше максимальной: %s > %s",
|
||||
display_name,
|
||||
amount_kopeks,
|
||||
settings.MULENPAY_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
@@ -74,7 +78,7 @@ class MulenPayPaymentMixin:
|
||||
)
|
||||
|
||||
if not response:
|
||||
logger.error("Ошибка создания MulenPay платежа")
|
||||
logger.error("Ошибка создания %s платежа", display_name)
|
||||
return None
|
||||
|
||||
mulen_payment_id = response.get("id")
|
||||
@@ -100,7 +104,8 @@ class MulenPayPaymentMixin:
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Создан MulenPay платеж %s на %s₽ для пользователя %s",
|
||||
"Создан %s платеж %s на %s₽ для пользователя %s",
|
||||
display_name,
|
||||
mulen_payment_id,
|
||||
amount_rubles,
|
||||
user_id,
|
||||
@@ -116,7 +121,7 @@ class MulenPayPaymentMixin:
|
||||
}
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Ошибка создания MulenPay платежа: %s", error)
|
||||
logger.error("Ошибка создания %s платежа: %s", display_name, error)
|
||||
return None
|
||||
|
||||
async def process_mulenpay_callback(
|
||||
@@ -125,10 +130,17 @@ class MulenPayPaymentMixin:
|
||||
callback_data: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""Обрабатывает callback от MulenPay, обновляет статус и начисляет баланс."""
|
||||
display_name = settings.get_mulenpay_display_name()
|
||||
display_name_html = settings.get_mulenpay_display_name_html()
|
||||
try:
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
uuid_value = callback_data.get("uuid")
|
||||
payment_status = (callback_data.get("payment_status") or "").lower()
|
||||
payment_status_raw = (
|
||||
callback_data.get("payment_status")
|
||||
or callback_data.get("status")
|
||||
or callback_data.get("paymentStatus")
|
||||
)
|
||||
payment_status = (payment_status_raw or "").lower()
|
||||
mulen_payment_id_raw = callback_data.get("id")
|
||||
mulen_payment_id_int: Optional[int] = None
|
||||
if mulen_payment_id_raw is not None:
|
||||
@@ -138,14 +150,15 @@ class MulenPayPaymentMixin:
|
||||
mulen_payment_id_int = None
|
||||
amount_value = callback_data.get("amount")
|
||||
logger.debug(
|
||||
"MulenPay callback: uuid=%s, status=%s, amount=%s",
|
||||
"%s callback: uuid=%s, status=%s, amount=%s",
|
||||
display_name,
|
||||
uuid_value,
|
||||
payment_status,
|
||||
amount_value,
|
||||
)
|
||||
|
||||
if not uuid_value and mulen_payment_id_raw is None:
|
||||
logger.error("MulenPay callback без uuid и id")
|
||||
logger.error("%s callback без uuid и id", display_name)
|
||||
return False
|
||||
|
||||
payment = None
|
||||
@@ -159,7 +172,8 @@ class MulenPayPaymentMixin:
|
||||
|
||||
if not payment:
|
||||
logger.error(
|
||||
"MulenPay платеж не найден (uuid=%s, id=%s)",
|
||||
"%s платеж не найден (uuid=%s, id=%s)",
|
||||
display_name,
|
||||
uuid_value,
|
||||
mulen_payment_id_raw,
|
||||
)
|
||||
@@ -167,7 +181,8 @@ class MulenPayPaymentMixin:
|
||||
|
||||
if payment.is_paid:
|
||||
logger.info(
|
||||
"MulenPay платеж %s уже обработан, игнорируем повторный callback",
|
||||
"%s платеж %s уже обработан, игнорируем повторный callback",
|
||||
display_name,
|
||||
payment.uuid,
|
||||
)
|
||||
return True
|
||||
@@ -183,7 +198,8 @@ class MulenPayPaymentMixin:
|
||||
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
"Для MulenPay платежа %s уже создана транзакция",
|
||||
"Для %s платежа %s уже создана транзакция",
|
||||
display_name,
|
||||
payment.uuid,
|
||||
)
|
||||
return True
|
||||
@@ -199,7 +215,7 @@ class MulenPayPaymentMixin:
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=f"Пополнение через MulenPay: {payment_description}",
|
||||
description=f"Пополнение через {display_name}: {payment_description}",
|
||||
payment_method=PaymentMethod.MULENPAY,
|
||||
external_id=payment.uuid,
|
||||
is_completed=True,
|
||||
@@ -214,8 +230,9 @@ class MulenPayPaymentMixin:
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error(
|
||||
"Пользователь %s не найден при обработке MulenPay",
|
||||
"Пользователь %s не найден при обработке %s",
|
||||
payment.user_id,
|
||||
display_name,
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -226,7 +243,7 @@ class MulenPayPaymentMixin:
|
||||
db,
|
||||
user,
|
||||
payment.amount_kopeks,
|
||||
f"Пополнение MulenPay: {payment.amount_kopeks // 100}₽",
|
||||
f"Пополнение {display_name}: {payment.amount_kopeks // 100}₽",
|
||||
)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
@@ -261,7 +278,8 @@ class MulenPayPaymentMixin:
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о пополнении MulenPay: %s",
|
||||
"Ошибка отправки уведомления о пополнении %s: %s",
|
||||
display_name,
|
||||
error,
|
||||
)
|
||||
|
||||
@@ -273,7 +291,7 @@ class MulenPayPaymentMixin:
|
||||
(
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
|
||||
"🦊 Способ: Mulen Pay\n"
|
||||
f"🦊 Способ: {display_name_html}\n"
|
||||
f"🆔 Транзакция: {transaction.id}\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
@@ -282,7 +300,8 @@ class MulenPayPaymentMixin:
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления пользователю MulenPay: %s",
|
||||
"Ошибка отправки уведомления пользователю %s: %s",
|
||||
display_name,
|
||||
error,
|
||||
)
|
||||
|
||||
@@ -329,7 +348,8 @@ class MulenPayPaymentMixin:
|
||||
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан MulenPay платеж %s для пользователя %s",
|
||||
"✅ Обработан %s платеж %s для пользователя %s",
|
||||
display_name,
|
||||
payment.uuid,
|
||||
payment.user_id,
|
||||
)
|
||||
@@ -343,7 +363,7 @@ class MulenPayPaymentMixin:
|
||||
callback_payload=callback_data,
|
||||
mulen_payment_id=mulen_payment_id_int,
|
||||
)
|
||||
logger.info("MulenPay платеж %s отменен", payment.uuid)
|
||||
logger.info("%s платеж %s отменен", display_name, payment.uuid)
|
||||
return True
|
||||
|
||||
await payment_module.update_mulenpay_payment_status(
|
||||
@@ -354,14 +374,20 @@ class MulenPayPaymentMixin:
|
||||
mulen_payment_id=mulen_payment_id_int,
|
||||
)
|
||||
logger.info(
|
||||
"Получен MulenPay callback со статусом %s для платежа %s",
|
||||
"Получен %s callback со статусом %s для платежа %s",
|
||||
display_name,
|
||||
payment_status,
|
||||
payment.uuid,
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Ошибка обработки MulenPay callback: %s", error, exc_info=True)
|
||||
logger.error(
|
||||
"Ошибка обработки %s callback: %s",
|
||||
display_name,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
def _map_mulenpay_status(self, status_code: Optional[int]) -> str:
|
||||
@@ -383,6 +409,7 @@ class MulenPayPaymentMixin:
|
||||
local_payment_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Возвращает текущее состояние платежа и при необходимости синхронизирует его."""
|
||||
display_name = settings.get_mulenpay_display_name()
|
||||
try:
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
@@ -437,6 +464,9 @@ class MulenPayPaymentMixin:
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка получения статуса MulenPay: %s", error, exc_info=True
|
||||
"Ошибка получения статуса %s: %s",
|
||||
display_name,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
+367
-166
@@ -63,7 +63,8 @@ class Pal24PaymentMixin:
|
||||
"language": language,
|
||||
}
|
||||
|
||||
normalized_payment_method = (payment_method or "SBP").upper()
|
||||
normalized_payment_method = self._normalize_payment_method(payment_method)
|
||||
api_payment_method = self._map_api_payment_method(normalized_payment_method)
|
||||
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
@@ -76,7 +77,7 @@ class Pal24PaymentMixin:
|
||||
ttl_seconds=ttl_seconds,
|
||||
custom_payload=custom_payload,
|
||||
payer_email=payer_email,
|
||||
payment_method=normalized_payment_method,
|
||||
payment_method=api_payment_method,
|
||||
)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка Pal24 API при создании счета: %s", error)
|
||||
@@ -147,6 +148,7 @@ class Pal24PaymentMixin:
|
||||
"description": description,
|
||||
"links": metadata_links,
|
||||
"raw_response": response,
|
||||
"selected_method": normalized_payment_method,
|
||||
}
|
||||
|
||||
payment = await payment_module.create_pal24_payment(
|
||||
@@ -186,6 +188,10 @@ class Pal24PaymentMixin:
|
||||
"payment_method": normalized_payment_method,
|
||||
"metadata_links": metadata_links,
|
||||
"status": payment_status,
|
||||
"sbp_url": transfer_url,
|
||||
"transfer_url": transfer_url,
|
||||
"link_page_url": link_page_url,
|
||||
"payment_url": primary_link,
|
||||
}
|
||||
|
||||
async def process_pal24_postback(
|
||||
@@ -240,175 +246,58 @@ class Pal24PaymentMixin:
|
||||
logger.info("Pal24 платеж %s уже обработан", payment.bill_id)
|
||||
return True
|
||||
|
||||
if status in {"PAID", "SUCCESS"}:
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error(
|
||||
"Пользователь %s не найден для Pal24 платежа",
|
||||
payment.user_id,
|
||||
)
|
||||
return False
|
||||
|
||||
await payment_module.update_pal24_payment_status(
|
||||
if status in {"PAID", "SUCCESS", "OVERPAID"}:
|
||||
payment = await payment_module.update_pal24_payment_status(
|
||||
db,
|
||||
payment,
|
||||
status=status,
|
||||
is_paid=True,
|
||||
paid_at=datetime.utcnow(),
|
||||
postback_payload=postback,
|
||||
payment_id=payment_id,
|
||||
payment_status=postback.get("Status") or status,
|
||||
payment_method=(
|
||||
postback.get("payment_method")
|
||||
or postback.get("PaymentMethod")
|
||||
or (payment.metadata_json or {}).get("selected_method")
|
||||
or getattr(payment, "payment_method", None)
|
||||
),
|
||||
balance_amount=postback.get("BalanceAmount")
|
||||
or postback.get("balance_amount"),
|
||||
balance_currency=postback.get("BalanceCurrency")
|
||||
or postback.get("balance_currency"),
|
||||
payer_account=postback.get("AccountNumber")
|
||||
or postback.get("account")
|
||||
or postback.get("Account"),
|
||||
)
|
||||
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
"Для Pal24 платежа %s уже создана транзакция",
|
||||
payment.bill_id,
|
||||
)
|
||||
return True
|
||||
|
||||
transaction = await payment_module.create_transaction(
|
||||
return await self._finalize_pal24_payment(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=f"Пополнение через Pal24 ({payment_id})",
|
||||
payment_method=PaymentMethod.PAL24,
|
||||
external_id=str(payment_id) if payment_id else payment.bill_id,
|
||||
is_completed=True,
|
||||
payment,
|
||||
payment_id=payment_id,
|
||||
trigger="postback",
|
||||
)
|
||||
|
||||
await payment_module.link_pal24_payment_to_transaction(db, payment, transaction.id)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = (
|
||||
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db, user.id, payment.amount_kopeks, getattr(self, "bot", None)
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка обработки реферального пополнения Pal24: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import (
|
||||
AdminNotificationService,
|
||||
)
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки админ уведомления Pal24: %s", error
|
||||
)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
|
||||
"🦊 Способ: PayPalych\n"
|
||||
f"🆔 Транзакция: {transaction.id}\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления пользователю Pal24: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||
try:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from aiogram import types
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.t(
|
||||
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
|
||||
"🛒 У вас есть неоформленный заказ.\n\n"
|
||||
"Вы можете продолжить оформление с теми же параметрами."
|
||||
)
|
||||
|
||||
# Создаем клавиатуру с кнопками
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
callback_data="menu_balance"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="back_to_menu"
|
||||
)]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан Pal24 платеж %s для пользователя %s",
|
||||
payment.bill_id,
|
||||
payment.user_id,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
await payment_module.update_pal24_payment_status(
|
||||
db,
|
||||
payment,
|
||||
status=status or "UNKNOWN",
|
||||
is_paid=False,
|
||||
postback_payload=postback,
|
||||
payment_id=payment_id,
|
||||
payment_status=postback.get("Status") or status,
|
||||
payment_method=(
|
||||
postback.get("payment_method")
|
||||
or postback.get("PaymentMethod")
|
||||
or getattr(payment, "payment_method", None)
|
||||
),
|
||||
balance_amount=postback.get("BalanceAmount")
|
||||
or postback.get("balance_amount"),
|
||||
balance_currency=postback.get("BalanceCurrency")
|
||||
or postback.get("balance_currency"),
|
||||
payer_account=postback.get("AccountNumber")
|
||||
or postback.get("account")
|
||||
or postback.get("Account"),
|
||||
)
|
||||
logger.info(
|
||||
"Обновили Pal24 платеж %s до статуса %s",
|
||||
@@ -421,6 +310,189 @@ class Pal24PaymentMixin:
|
||||
logger.error("Ошибка обработки Pal24 postback: %s", error, exc_info=True)
|
||||
return False
|
||||
|
||||
async def _finalize_pal24_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
payment_id: Optional[str],
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления."""
|
||||
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
"Pal24 платеж %s уже привязан к транзакции (trigger=%s)",
|
||||
payment.bill_id,
|
||||
trigger,
|
||||
)
|
||||
return True
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error(
|
||||
"Пользователь %s не найден для Pal24 платежа %s (trigger=%s)",
|
||||
payment.user_id,
|
||||
payment.bill_id,
|
||||
trigger,
|
||||
)
|
||||
return False
|
||||
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=f"Пополнение через Pal24 ({payment_id or payment.bill_id})",
|
||||
payment_method=PaymentMethod.PAL24,
|
||||
external_id=str(payment_id) if payment_id else payment.bill_id,
|
||||
is_completed=True,
|
||||
)
|
||||
|
||||
await payment_module.link_pal24_payment_to_transaction(db, payment, transaction.id)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db, user.id, payment.amount_kopeks, getattr(self, "bot", None)
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка обработки реферального пополнения Pal24: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(payment)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import (
|
||||
AdminNotificationService,
|
||||
)
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки админ уведомления Pal24: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
|
||||
"🦊 Способ: PayPalych\n"
|
||||
f"🆔 Транзакция: {transaction.id}\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления пользователю Pal24: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
try:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from aiogram import types
|
||||
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.t(
|
||||
"BALANCE_TOPUP_CART_REMINDER",
|
||||
"У вас есть незавершенное оформление подписки. Вернуться?",
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"BALANCE_TOPUP_CART_BUTTON",
|
||||
"🛒 Продолжить оформление",
|
||||
),
|
||||
callback_data="subscription_resume_checkout",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=(
|
||||
"✅ Баланс пополнен на "
|
||||
f"{settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}"
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
|
||||
user.id,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
|
||||
user.id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан Pal24 платеж %s для пользователя %s (trigger=%s)",
|
||||
payment.bill_id,
|
||||
payment.user_id,
|
||||
trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def get_pal24_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -434,32 +506,72 @@ class Pal24PaymentMixin:
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
remote_status = None
|
||||
remote_data = None
|
||||
remote_status: Optional[str] = None
|
||||
remote_data: Optional[Dict[str, Any]] = None
|
||||
|
||||
service = getattr(self, "pal24_service", None)
|
||||
if service and payment.bill_id:
|
||||
try:
|
||||
response = await service.get_bill_status(payment.bill_id)
|
||||
remote_data = response
|
||||
remote_status = response.get("status") or response.get(
|
||||
"bill", {}
|
||||
).get("status")
|
||||
remote_status = response.get("status") or response.get("bill", {}).get("status")
|
||||
|
||||
if remote_status and remote_status != payment.status:
|
||||
await payment_module.update_pal24_payment_status(
|
||||
payment_info = self._extract_remote_payment_info(response)
|
||||
|
||||
if remote_status:
|
||||
normalized_remote = str(remote_status).upper()
|
||||
update_kwargs: Dict[str, Any] = {
|
||||
"status": normalized_remote,
|
||||
"payment_status": payment_info.get("status") or remote_status,
|
||||
}
|
||||
|
||||
if payment_info.get("id"):
|
||||
update_kwargs["payment_id"] = payment_info["id"]
|
||||
if payment_info.get("method"):
|
||||
update_kwargs["payment_method"] = payment_info["method"]
|
||||
if payment_info.get("balance_amount"):
|
||||
update_kwargs["balance_amount"] = payment_info["balance_amount"]
|
||||
if payment_info.get("balance_currency"):
|
||||
update_kwargs["balance_currency"] = payment_info["balance_currency"]
|
||||
if payment_info.get("account"):
|
||||
update_kwargs["payer_account"] = payment_info["account"]
|
||||
|
||||
if normalized_remote in getattr(service, "BILL_SUCCESS_STATES", {"SUCCESS"}):
|
||||
update_kwargs["is_paid"] = True
|
||||
if not payment.paid_at:
|
||||
update_kwargs["paid_at"] = datetime.utcnow()
|
||||
elif normalized_remote in getattr(service, "BILL_FAILED_STATES", {"FAIL"}):
|
||||
update_kwargs["is_paid"] = False
|
||||
elif normalized_remote in getattr(service, "BILL_PENDING_STATES", {"NEW", "PROCESS"}):
|
||||
update_kwargs.setdefault("is_paid", False)
|
||||
|
||||
payment = await payment_module.update_pal24_payment_status(
|
||||
db,
|
||||
payment,
|
||||
status=str(remote_status).upper(),
|
||||
)
|
||||
payment = await payment_module.get_pal24_payment_by_id(
|
||||
db, local_payment_id
|
||||
**update_kwargs,
|
||||
)
|
||||
except Pal24APIError as error:
|
||||
logger.error(
|
||||
"Ошибка Pal24 API при получении статуса: %s", error
|
||||
)
|
||||
|
||||
if payment.is_paid and not payment.transaction_id:
|
||||
try:
|
||||
finalized = await self._finalize_pal24_payment(
|
||||
db,
|
||||
payment,
|
||||
payment_id=getattr(payment, "payment_id", None),
|
||||
trigger="status_check",
|
||||
)
|
||||
if finalized:
|
||||
payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка автоматического начисления по Pal24 статусу: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return {
|
||||
"payment": payment,
|
||||
"status": payment.status,
|
||||
@@ -471,3 +583,92 @@ class Pal24PaymentMixin:
|
||||
except Exception as error:
|
||||
logger.error("Ошибка получения статуса Pal24: %s", error, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _extract_remote_payment_info(remote_data: Any) -> Dict[str, Optional[str]]:
|
||||
"""Извлекает данные о платеже из ответа Pal24."""
|
||||
|
||||
def _pick_candidate(value: Any) -> Optional[Dict[str, Any]]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
if isinstance(item, dict):
|
||||
return item
|
||||
return None
|
||||
|
||||
def _normalize(candidate: Dict[str, Any]) -> Dict[str, Optional[str]]:
|
||||
def _stringify(value: Any) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
return str(value)
|
||||
|
||||
return {
|
||||
"id": _stringify(candidate.get("id") or candidate.get("payment_id")),
|
||||
"status": _stringify(candidate.get("status")),
|
||||
"method": _stringify(candidate.get("method") or candidate.get("payment_method")),
|
||||
"balance_amount": _stringify(
|
||||
candidate.get("balance_amount")
|
||||
or candidate.get("amount")
|
||||
or candidate.get("BalanceAmount")
|
||||
),
|
||||
"balance_currency": _stringify(
|
||||
candidate.get("balance_currency") or candidate.get("BalanceCurrency")
|
||||
),
|
||||
"account": _stringify(
|
||||
candidate.get("account")
|
||||
or candidate.get("payer_account")
|
||||
or candidate.get("AccountNumber")
|
||||
),
|
||||
}
|
||||
|
||||
if not isinstance(remote_data, dict):
|
||||
return {}
|
||||
|
||||
search_spaces = [remote_data]
|
||||
bill_section = remote_data.get("bill") or remote_data.get("Bill")
|
||||
if isinstance(bill_section, dict):
|
||||
search_spaces.append(bill_section)
|
||||
|
||||
for space in search_spaces:
|
||||
for key in ("payment", "Payment", "payment_info", "PaymentInfo"):
|
||||
candidate = _pick_candidate(space.get(key))
|
||||
if candidate:
|
||||
return _normalize(candidate)
|
||||
for key in ("payments", "Payments"):
|
||||
candidate = _pick_candidate(space.get(key))
|
||||
if candidate:
|
||||
return _normalize(candidate)
|
||||
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_payment_method(payment_method: Optional[str]) -> str:
|
||||
mapping = {
|
||||
"sbp": "sbp",
|
||||
"fast": "sbp",
|
||||
"fastpay": "sbp",
|
||||
"fast_payment": "sbp",
|
||||
"card": "card",
|
||||
"bank_card": "card",
|
||||
"bankcard": "card",
|
||||
"bank-card": "card",
|
||||
}
|
||||
|
||||
if not payment_method:
|
||||
return "sbp"
|
||||
|
||||
normalized = payment_method.strip().lower()
|
||||
return mapping.get(normalized, "sbp")
|
||||
|
||||
@staticmethod
|
||||
def _map_api_payment_method(normalized_payment_method: str) -> Optional[str]:
|
||||
"""Преобразует нормализованный метод оплаты в значение для Pal24 API."""
|
||||
|
||||
api_mapping = {
|
||||
"sbp": "SBP",
|
||||
"card": "BANK_CARD",
|
||||
}
|
||||
|
||||
return api_mapping.get(normalized_payment_method)
|
||||
|
||||
@@ -120,6 +120,99 @@ class WataPaymentMixin:
|
||||
"order_id": order_id,
|
||||
}
|
||||
|
||||
async def process_wata_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""Handles asynchronous webhook notifications from WATA."""
|
||||
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
logger.error("WATA webhook payload не является словарём: %s", payload)
|
||||
return False
|
||||
|
||||
order_id_raw = payload.get("orderId")
|
||||
payment_link_raw = payload.get("paymentLinkId") or payload.get("id")
|
||||
transaction_status_raw = payload.get("transactionStatus")
|
||||
|
||||
order_id = str(order_id_raw) if order_id_raw else None
|
||||
payment_link_id = str(payment_link_raw) if payment_link_raw else None
|
||||
transaction_status = (transaction_status_raw or "").strip()
|
||||
|
||||
if not order_id and not payment_link_id:
|
||||
logger.error(
|
||||
"WATA webhook без orderId и paymentLinkId: %s",
|
||||
payload,
|
||||
)
|
||||
return False
|
||||
|
||||
if not transaction_status:
|
||||
logger.error("WATA webhook без статуса транзакции: %s", payload)
|
||||
return False
|
||||
|
||||
payment = None
|
||||
if order_id:
|
||||
payment = await payment_module.get_wata_payment_by_order_id(db, order_id)
|
||||
if not payment and payment_link_id:
|
||||
payment = await payment_module.get_wata_payment_by_link_id(db, payment_link_id)
|
||||
|
||||
if not payment:
|
||||
logger.error(
|
||||
"WATA платеж не найден (order_id=%s, payment_link_id=%s)",
|
||||
order_id,
|
||||
payment_link_id,
|
||||
)
|
||||
return False
|
||||
|
||||
status_lower = transaction_status.lower()
|
||||
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||
metadata["last_webhook"] = payload
|
||||
terminal_public_id = (
|
||||
payload.get("terminalPublicId")
|
||||
or payload.get("terminal_public_id")
|
||||
or payload.get("terminalPublicID")
|
||||
)
|
||||
|
||||
update_kwargs: Dict[str, Any] = {
|
||||
"metadata": metadata,
|
||||
"callback_payload": payload,
|
||||
"terminal_public_id": terminal_public_id,
|
||||
}
|
||||
|
||||
if transaction_status:
|
||||
update_kwargs["status"] = transaction_status
|
||||
update_kwargs["last_status"] = transaction_status
|
||||
|
||||
if status_lower != "paid" and not payment.is_paid:
|
||||
update_kwargs["is_paid"] = False
|
||||
|
||||
payment = await payment_module.update_wata_payment_status(
|
||||
db,
|
||||
payment=payment,
|
||||
**update_kwargs,
|
||||
)
|
||||
|
||||
if status_lower == "paid":
|
||||
if payment.is_paid:
|
||||
logger.info(
|
||||
"WATA платеж %s уже помечен как оплачен",
|
||||
payment.payment_link_id,
|
||||
)
|
||||
return True
|
||||
|
||||
await self._finalize_wata_payment(db, payment, payload)
|
||||
return True
|
||||
|
||||
if status_lower == "declined":
|
||||
logger.info(
|
||||
"WATA платеж %s отклонён",
|
||||
payment.payment_link_id,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def get_wata_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
|
||||
+263
-121
@@ -172,7 +172,7 @@ class YooKassaPaymentMixin:
|
||||
currency="RUB",
|
||||
description=description,
|
||||
status=yookassa_response["status"],
|
||||
confirmation_url=yookassa_response.get("confirmation_url"),
|
||||
confirmation_url=yookassa_response.get("confirmation_url"), # Используем confirmation URL
|
||||
metadata_json=payment_metadata,
|
||||
payment_method_type="bank_card",
|
||||
yookassa_created_at=None,
|
||||
@@ -193,7 +193,8 @@ class YooKassaPaymentMixin:
|
||||
return {
|
||||
"local_payment_id": local_payment.id,
|
||||
"yookassa_payment_id": yookassa_response["id"],
|
||||
"confirmation_url": yookassa_response.get("confirmation_url"),
|
||||
"confirmation_url": yookassa_response.get("confirmation_url"), # URL для подтверждения
|
||||
"qr_confirmation_data": yookassa_response.get("qr_confirmation_data"), # Данные для QR-кода
|
||||
"confirmation_token": confirmation_token,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"amount_rubles": amount_rubles,
|
||||
@@ -216,12 +217,39 @@ class YooKassaPaymentMixin:
|
||||
|
||||
payment_description = getattr(payment, "description", "YooKassa платеж")
|
||||
|
||||
payment_metadata: Dict[str, Any] = {}
|
||||
try:
|
||||
if hasattr(payment, "metadata_json") and payment.metadata_json:
|
||||
import json
|
||||
|
||||
if isinstance(payment.metadata_json, str):
|
||||
payment_metadata = json.loads(payment.metadata_json)
|
||||
elif isinstance(payment.metadata_json, dict):
|
||||
payment_metadata = payment.metadata_json
|
||||
logger.info(f"Метаданные платежа: {payment_metadata}")
|
||||
except Exception as parse_error:
|
||||
logger.error(f"Ошибка парсинга метаданных платежа: {parse_error}")
|
||||
|
||||
payment_purpose = payment_metadata.get("payment_purpose", "")
|
||||
is_simple_subscription = payment_purpose == "simple_subscription_purchase"
|
||||
|
||||
transaction_type = (
|
||||
TransactionType.SUBSCRIPTION_PAYMENT
|
||||
if is_simple_subscription
|
||||
else TransactionType.DEPOSIT
|
||||
)
|
||||
transaction_description = (
|
||||
f"Оплата подписки через YooKassa: {payment_description}"
|
||||
if is_simple_subscription
|
||||
else f"Пополнение через YooKassa: {payment_description}"
|
||||
)
|
||||
|
||||
transaction = await payment_module.create_transaction(
|
||||
db=db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
type=transaction_type,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=f"Пополнение через YooKassa: {payment_description}",
|
||||
description=transaction_description,
|
||||
payment_method=PaymentMethod.YOOKASSA,
|
||||
external_id=payment.yookassa_payment_id,
|
||||
is_completed=True,
|
||||
@@ -235,143 +263,257 @@ class YooKassaPaymentMixin:
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if user:
|
||||
old_balance = getattr(user, "balance_kopeks", 0)
|
||||
was_first_topup = not getattr(user, "has_made_first_topup", False)
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = ("🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение")
|
||||
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
if is_simple_subscription:
|
||||
logger.info(
|
||||
"YooKassa платеж %s обработан как покупка подписки. Баланс пользователя %s не изменяется.",
|
||||
payment.yookassa_payment_id,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка обработки реферального пополнения YooKassa: %s",
|
||||
error,
|
||||
else:
|
||||
old_balance = getattr(user, "balance_kopeks", 0)
|
||||
was_first_topup = not getattr(user, "has_made_first_topup", False)
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = (
|
||||
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
)
|
||||
|
||||
if was_first_topup and not getattr(user, "has_made_first_topup", False):
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
# Отправляем уведомления админам
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import (
|
||||
AdminNotificationService,
|
||||
)
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
logger.info("Уведомление админам о пополнении отправлено успешно")
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления админам о YooKassa пополнении: %s",
|
||||
error,
|
||||
exc_info=True # Добавляем полный стек вызовов для отладки
|
||||
)
|
||||
|
||||
# Отправляем уведомление пользователю
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
# Передаем только простые данные, чтобы избежать проблем с ленивой загрузкой
|
||||
await self._send_payment_success_notification(
|
||||
user.telegram_id,
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
user=None, # Передаем None, чтобы _ensure_user_snapshot загрузил данные сам
|
||||
db=db,
|
||||
payment_method_title="Банковская карта (YooKassa)",
|
||||
getattr(self, "bot", None),
|
||||
)
|
||||
logger.info("Уведомление пользователю о платеже отправлено успешно")
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о платеже: %s",
|
||||
"Ошибка обработки реферального пополнения YooKassa: %s",
|
||||
error,
|
||||
exc_info=True # Добавляем полный стек вызовов для отладки
|
||||
)
|
||||
|
||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||
# ВАЖНО: этот код должен выполняться даже при ошибках в уведомлениях
|
||||
logger.info(f"Проверяем наличие сохраненной корзины для пользователя {user.id}")
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
try:
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
logger.info(f"Результат проверки корзины для пользователя {user.id}: {has_saved_cart}")
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
from aiogram import types
|
||||
if was_first_topup and not getattr(user, "has_made_first_topup", False):
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
# Отправляем уведомления админам
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import (
|
||||
AdminNotificationService,
|
||||
)
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
logger.info("Уведомление админам о пополнении отправлено успешно")
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления админам о YooKassa пополнении: %s",
|
||||
error,
|
||||
exc_info=True, # Добавляем полный стек вызовов для отладки
|
||||
)
|
||||
|
||||
# Отправляем уведомление пользователю
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
# Передаем только простые данные, чтобы избежать проблем с ленивой загрузкой
|
||||
await self._send_payment_success_notification(
|
||||
user.telegram_id,
|
||||
payment.amount_kopeks,
|
||||
user=None, # Передаем None, чтобы _ensure_user_snapshot загрузил данные сам
|
||||
db=db,
|
||||
payment_method_title="Банковская карта (YooKassa)",
|
||||
)
|
||||
logger.info("Уведомление пользователю о платеже отправлено успешно")
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о платеже: %s",
|
||||
error,
|
||||
exc_info=True, # Добавляем полный стек вызовов для отладки
|
||||
)
|
||||
|
||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||
# ВАЖНО: этот код должен выполняться даже при ошибках в уведомлениях
|
||||
logger.info(f"Проверяем наличие сохраненной корзины для пользователя {user.id}")
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
try:
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
logger.info(f"Результат проверки корзины для пользователя {user.id}: {has_saved_cart}")
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
from aiogram import types
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
|
||||
total_amount=settings.format_price(payment.amount_kopeks)
|
||||
)
|
||||
|
||||
# Создаем клавиатуру с кнопками
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
callback_data="menu_balance",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}"
|
||||
)
|
||||
else:
|
||||
logger.info(f"У пользователя {user.id} нет сохраненной корзины или бот недоступен")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Критическая ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if is_simple_subscription:
|
||||
logger.info(f"Обнаружен платеж простой покупки подписки для пользователя {user.id}")
|
||||
try:
|
||||
# Активируем подписку
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
|
||||
total_amount=settings.format_price(payment.amount_kopeks)
|
||||
# Получаем параметры подписки из метаданных
|
||||
subscription_period = int(payment_metadata.get("subscription_period", 30))
|
||||
order_id = payment_metadata.get("order_id")
|
||||
|
||||
logger.info(f"Активация подписки: период={subscription_period} дней, заказ={order_id}")
|
||||
|
||||
# Активируем pending подписку пользователя
|
||||
from app.database.crud.subscription import activate_pending_subscription
|
||||
subscription = await activate_pending_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
period_days=subscription_period
|
||||
)
|
||||
|
||||
# Создаем клавиатуру с кнопками
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
callback_data="menu_balance"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="back_to_menu"
|
||||
)]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
|
||||
else:
|
||||
logger.info(f"У пользователя {user.id} нет сохраненной корзины или бот недоступен")
|
||||
except Exception as e:
|
||||
logger.error(f"Критическая ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
|
||||
if subscription:
|
||||
logger.info(f"Подписка успешно активирована для пользователя {user.id}")
|
||||
|
||||
logger.info(
|
||||
"Успешно обработан платеж YooKassa %s: пользователь %s получил %s₽",
|
||||
payment.yookassa_payment_id,
|
||||
payment.user_id,
|
||||
payment.amount_kopeks / 100,
|
||||
)
|
||||
# Обновляем данные подписки в RemnaWave, чтобы получить актуальные ссылки
|
||||
try:
|
||||
remnawave_user = await subscription_service.create_remnawave_user(db, subscription)
|
||||
if remnawave_user:
|
||||
await db.refresh(subscription)
|
||||
except Exception as sync_error:
|
||||
logger.error(
|
||||
"Ошибка синхронизации подписки с RemnaWave для пользователя %s: %s",
|
||||
user.id,
|
||||
sync_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Отправляем уведомление пользователю об активации подписки
|
||||
if getattr(self, "bot", None):
|
||||
from app.localization.texts import get_texts
|
||||
from aiogram import types
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
success_message = (
|
||||
f"✅ <b>Подписка успешно активирована!</b>\n\n"
|
||||
f"📅 Период: {subscription_period} дней\n"
|
||||
f"📱 Устройства: 1\n"
|
||||
f"📊 Трафик: Безлимит\n"
|
||||
f"💳 Оплата: {settings.format_price(payment.amount_kopeks)} (YooKassa)\n\n"
|
||||
f"🔗 Для подключения перейдите в раздел 'Моя подписка'"
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
|
||||
[types.InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=success_message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Успешно обработан платеж YooKassa %s: пользователь %s получил %s₽",
|
||||
payment.yookassa_payment_id,
|
||||
payment.user_id,
|
||||
payment.amount_kopeks / 100,
|
||||
)
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import (
|
||||
AdminNotificationService,
|
||||
)
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
transaction,
|
||||
subscription_period,
|
||||
was_trial_conversion=False,
|
||||
)
|
||||
except Exception as admin_error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления админам о покупке подписки через YooKassa: %s",
|
||||
admin_error,
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
logger.error(f"Ошибка активации подписки для пользователя {user.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка активации подписки для пользователя {user.id}: {e}", exc_info=True)
|
||||
|
||||
if is_simple_subscription:
|
||||
logger.info(
|
||||
"Успешно обработан платеж YooKassa %s как покупка подписки: пользователь %s, сумма %s₽",
|
||||
payment.yookassa_payment_id,
|
||||
payment.user_id,
|
||||
payment.amount_kopeks / 100,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Успешно обработан платеж YooKassa %s: пользователь %s пополнил баланс на %s₽",
|
||||
payment.yookassa_payment_id,
|
||||
payment.user_id,
|
||||
payment.amount_kopeks / 100,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -11,11 +11,13 @@ from aiogram import Bot
|
||||
from app.config import settings
|
||||
from app.utils.currency_converter import currency_converter # noqa: F401
|
||||
from app.external.cryptobot import CryptoBotService
|
||||
from app.external.heleket import HeleketService
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.services.mulenpay_service import MulenPayService
|
||||
from app.services.pal24_service import Pal24Service
|
||||
from app.services.payment import (
|
||||
CryptoBotPaymentMixin,
|
||||
HeleketPaymentMixin,
|
||||
MulenPayPaymentMixin,
|
||||
Pal24PaymentMixin,
|
||||
PaymentCommonMixin,
|
||||
@@ -178,12 +180,38 @@ async def link_cryptobot_payment_to_transaction(*args, **kwargs):
|
||||
return await crypto_crud.link_cryptobot_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_heleket_payment(*args, **kwargs):
|
||||
heleket_crud = import_module("app.database.crud.heleket")
|
||||
return await heleket_crud.create_heleket_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_heleket_payment_by_uuid(*args, **kwargs):
|
||||
heleket_crud = import_module("app.database.crud.heleket")
|
||||
return await heleket_crud.get_heleket_payment_by_uuid(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_heleket_payment_by_id(*args, **kwargs):
|
||||
heleket_crud = import_module("app.database.crud.heleket")
|
||||
return await heleket_crud.get_heleket_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_heleket_payment(*args, **kwargs):
|
||||
heleket_crud = import_module("app.database.crud.heleket")
|
||||
return await heleket_crud.update_heleket_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_heleket_payment_to_transaction(*args, **kwargs):
|
||||
heleket_crud = import_module("app.database.crud.heleket")
|
||||
return await heleket_crud.link_heleket_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
class PaymentService(
|
||||
PaymentCommonMixin,
|
||||
TelegramStarsMixin,
|
||||
YooKassaPaymentMixin,
|
||||
TributePaymentMixin,
|
||||
CryptoBotPaymentMixin,
|
||||
HeleketPaymentMixin,
|
||||
MulenPayPaymentMixin,
|
||||
Pal24PaymentMixin,
|
||||
WataPaymentMixin,
|
||||
@@ -201,6 +229,9 @@ class PaymentService(
|
||||
self.cryptobot_service = (
|
||||
CryptoBotService() if settings.is_cryptobot_enabled() else None
|
||||
)
|
||||
self.heleket_service = (
|
||||
HeleketService() if settings.is_heleket_enabled() else None
|
||||
)
|
||||
self.mulenpay_service = (
|
||||
MulenPayService() if settings.is_mulenpay_enabled() else None
|
||||
)
|
||||
@@ -209,12 +240,14 @@ class PaymentService(
|
||||
)
|
||||
self.wata_service = WataService() if settings.is_wata_enabled() else None
|
||||
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.debug(
|
||||
"PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, "
|
||||
"MulenPay=%s, Pal24=%s, Wata=%s)",
|
||||
"PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, Heleket=%s, %s=%s, Pal24=%s, Wata=%s)",
|
||||
bool(self.yookassa_service),
|
||||
bool(self.stars_service),
|
||||
bool(self.cryptobot_service),
|
||||
bool(self.heleket_service),
|
||||
mulenpay_name,
|
||||
bool(self.mulenpay_service),
|
||||
bool(self.pal24_service),
|
||||
bool(self.wata_service),
|
||||
|
||||
+321
-105
@@ -3,13 +3,16 @@ import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, time as datetime_time, timedelta, timezone
|
||||
from enum import Enum
|
||||
from typing import Optional, Tuple
|
||||
from html import escape
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import cast, func, not_, or_, select
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.sql import false, true
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import get_subscriptions_statistics
|
||||
@@ -17,10 +20,12 @@ from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import (
|
||||
Subscription,
|
||||
SubscriptionConversion,
|
||||
SubscriptionStatus,
|
||||
Ticket,
|
||||
TicketStatus,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
@@ -45,7 +50,7 @@ class ReportPeriodRange:
|
||||
|
||||
|
||||
class ReportingService:
|
||||
"""Generates admin summary reports and can schedule daily delivery."""
|
||||
"""Generates admin summary reports (text only, no charts)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.bot: Optional[Bot] = None
|
||||
@@ -170,11 +175,64 @@ class ReportingService:
|
||||
chat_id=chat_id,
|
||||
text=report_text,
|
||||
message_thread_id=topic_id,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
except (TelegramBadRequest, TelegramForbiddenError) as exc:
|
||||
logger.error("Не удалось отправить отчет: %s", exc)
|
||||
raise ReportingServiceError("Не удалось отправить отчет в чат") from exc
|
||||
|
||||
# ---------- referral helpers ----------
|
||||
|
||||
def _referral_markers(self) -> List:
|
||||
"""
|
||||
Набор условий, по которым операция помечается как реферальная (если вдруг записана типом DEPOSIT).
|
||||
"""
|
||||
clauses = []
|
||||
|
||||
# Явные флаги
|
||||
if hasattr(Transaction, "is_referral_bonus"):
|
||||
clauses.append(Transaction.is_referral_bonus == true())
|
||||
if hasattr(Transaction, "is_bonus"):
|
||||
clauses.append(Transaction.is_bonus == true())
|
||||
|
||||
# Источник/причина
|
||||
if hasattr(Transaction, "source"):
|
||||
clauses.append(Transaction.source == "referral")
|
||||
clauses.append(Transaction.source == "referral_bonus")
|
||||
if hasattr(Transaction, "reason"):
|
||||
clauses.append(Transaction.reason == "referral")
|
||||
clauses.append(Transaction.reason == "referral_bonus")
|
||||
clauses.append(Transaction.reason == "referral_reward")
|
||||
|
||||
# Текстовые поля
|
||||
like_patterns = ["%реферал%", "%реферальн%", "%referral%"]
|
||||
if hasattr(Transaction, "description"):
|
||||
for pattern in like_patterns:
|
||||
try:
|
||||
clauses.append(Transaction.description.ilike(pattern))
|
||||
except Exception: # noqa: BLE001 - best effort
|
||||
pass
|
||||
if hasattr(Transaction, "comment"):
|
||||
for pattern in like_patterns:
|
||||
try:
|
||||
clauses.append(Transaction.comment.ilike(pattern))
|
||||
except Exception: # noqa: BLE001 - best effort
|
||||
pass
|
||||
|
||||
return [clause for clause in clauses if clause is not None]
|
||||
|
||||
def _exclude_referral_deposits_condition(self):
|
||||
"""
|
||||
Условие «это НЕ реферальный бонус».
|
||||
Если нет ни одного маркера — ничего не исключаем.
|
||||
"""
|
||||
markers = self._referral_markers()
|
||||
if not markers:
|
||||
return true()
|
||||
return not_(or_(*markers))
|
||||
|
||||
# --------------------------------------
|
||||
|
||||
async def _build_report(
|
||||
self,
|
||||
period: ReportPeriod,
|
||||
@@ -186,38 +244,91 @@ class ReportingService:
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
totals = await self._collect_current_totals(session)
|
||||
period_stats = await self._collect_period_stats(session, start_utc, end_utc)
|
||||
stats = await self._collect_period_stats(session, start_utc, end_utc)
|
||||
top_referrers = await self._get_top_referrers(session, start_utc, end_utc, limit=5)
|
||||
usage = await self._get_user_usage_stats(session)
|
||||
|
||||
conversion_rate = (
|
||||
(stats["trial_to_paid_conversions"] / stats["new_trials"] * 100)
|
||||
if stats["new_trials"] > 0
|
||||
else 0.0
|
||||
)
|
||||
|
||||
lines: List[str] = []
|
||||
header = (
|
||||
f"📊 <b>Отчет за {period_range.label}</b>"
|
||||
if period == ReportPeriod.DAILY
|
||||
else f"📊 <b>Отчет за период {period_range.label}</b>"
|
||||
)
|
||||
lines += [header, ""]
|
||||
|
||||
lines = [
|
||||
header,
|
||||
# TL;DR
|
||||
lines += [
|
||||
"🧭 <b>Итог по периоду</b>",
|
||||
f"• Новых пользователей: <b>{stats['new_users']}</b>",
|
||||
f"• Новых триалов: <b>{stats['new_trials']}</b>",
|
||||
(
|
||||
f"• Конверсий триал → платная: <b>{stats['trial_to_paid_conversions']}</b> "
|
||||
f"(<i>{conversion_rate:.1f}%</i>)"
|
||||
),
|
||||
f"• Новых платных (всего): <b>{stats['new_paid_subscriptions']}</b>",
|
||||
f"• Поступления всего (только пополнения): <b>{self._format_amount(stats['deposits_amount'])}</b>",
|
||||
"",
|
||||
"🎯 <b>Триалы</b>",
|
||||
f"• Активных сейчас: {totals['active_trials']}",
|
||||
f"• Новых за период: {period_stats['new_trials']}",
|
||||
"",
|
||||
"💎 <b>Платные подписки</b>",
|
||||
f"• Активных сейчас: {totals['active_paid']}",
|
||||
f"• Новых за период: {period_stats['new_paid_subscriptions']}",
|
||||
"",
|
||||
"🎟️ <b>Тикеты поддержки</b>",
|
||||
f"• Активных сейчас: {totals['open_tickets']}",
|
||||
f"• Новых за период: {period_stats['new_tickets']}",
|
||||
"",
|
||||
"💰 <b>Платежи</b>",
|
||||
f"• Оплат подписок: {period_stats['subscription_payments_count']} на сумму "
|
||||
f"{self._format_amount(period_stats['subscription_payments_amount'])}",
|
||||
f"• Пополнений: {period_stats['deposits_count']} на сумму "
|
||||
f"{self._format_amount(period_stats['deposits_amount'])}",
|
||||
f"• Всего поступлений: {period_stats['total_payments_count']} на сумму "
|
||||
f"{self._format_amount(period_stats['total_payments_amount'])}",
|
||||
]
|
||||
|
||||
# Подписки
|
||||
lines += [
|
||||
"💎 <b>Подписки</b>",
|
||||
f"• Активные триалы сейчас: {totals['active_trials']}",
|
||||
f"• Активные платные сейчас: {totals['active_paid']}",
|
||||
"",
|
||||
]
|
||||
|
||||
# Финансы
|
||||
lines += [
|
||||
"💰 <b>Финансы</b>",
|
||||
(
|
||||
"• Оплаты подписок: "
|
||||
f"{stats['subscription_payments_count']} на сумму {self._format_amount(stats['subscription_payments_amount'])}"
|
||||
),
|
||||
(
|
||||
"• Пополнения: "
|
||||
f"{stats['deposits_count']} на сумму {self._format_amount(stats['deposits_amount'])}"
|
||||
),
|
||||
(
|
||||
"<i>Примечание: «Поступления всего» учитывают только пополнения; покупки подписок и реферальные бонусы "
|
||||
"исключены.</i>"
|
||||
),
|
||||
"",
|
||||
]
|
||||
|
||||
# Поддержка
|
||||
lines += [
|
||||
"🎟️ <b>Поддержка</b>",
|
||||
f"• Новых тикетов: {stats['new_tickets']}",
|
||||
f"• Активных тикетов сейчас: {totals['open_tickets']}",
|
||||
"",
|
||||
]
|
||||
|
||||
# Активность пользователей
|
||||
lines += [
|
||||
"👤 <b>Активность пользователей</b>",
|
||||
f"• Пользователей с активной платной подпиской: {usage['active_paid_users']}",
|
||||
f"• Пользователей, ни разу не подключившихся: {usage['never_connected_users']}",
|
||||
"",
|
||||
]
|
||||
|
||||
# Топ по рефералам
|
||||
lines += ["🤝 <b>Топ по рефералам (за период)</b>"]
|
||||
if top_referrers:
|
||||
for index, row in enumerate(top_referrers, 1):
|
||||
referrer_label = escape(row["referrer_label"], quote=False)
|
||||
lines.append(
|
||||
f"{index}. {referrer_label}: {row['count']} приглашений"
|
||||
)
|
||||
else:
|
||||
lines.append("— данных нет")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _get_period_range(
|
||||
@@ -273,87 +384,197 @@ class ReportingService:
|
||||
start_utc: datetime,
|
||||
end_utc: datetime,
|
||||
) -> dict:
|
||||
new_trials_result = await session.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.created_at >= start_utc,
|
||||
Subscription.created_at < end_utc,
|
||||
Subscription.is_trial == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
new_trials = int(new_trials_result.scalar() or 0)
|
||||
|
||||
direct_paid_result = await session.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.created_at >= start_utc,
|
||||
Subscription.created_at < end_utc,
|
||||
Subscription.is_trial == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
direct_paid = int(direct_paid_result.scalar() or 0)
|
||||
|
||||
conversions_result = await session.execute(
|
||||
select(func.count(SubscriptionConversion.id)).where(
|
||||
SubscriptionConversion.converted_at >= start_utc,
|
||||
SubscriptionConversion.converted_at < end_utc,
|
||||
)
|
||||
)
|
||||
conversions_count = int(conversions_result.scalar() or 0)
|
||||
|
||||
subscription_payments_row = (
|
||||
await session.execute(
|
||||
select(
|
||||
func.count(Transaction.id),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
|
||||
).where(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.is_completed == True, # noqa: E712
|
||||
Transaction.created_at >= start_utc,
|
||||
Transaction.created_at < end_utc,
|
||||
new_users = int(
|
||||
(
|
||||
await session.execute(
|
||||
select(func.count(User.id)).where(
|
||||
User.created_at >= start_utc,
|
||||
User.created_at < end_utc,
|
||||
)
|
||||
)
|
||||
)
|
||||
).one()
|
||||
|
||||
deposits_row = (
|
||||
await session.execute(
|
||||
select(
|
||||
func.count(Transaction.id),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
|
||||
).where(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True, # noqa: E712
|
||||
Transaction.created_at >= start_utc,
|
||||
Transaction.created_at < end_utc,
|
||||
)
|
||||
)
|
||||
).one()
|
||||
|
||||
subscription_payments_count = int(subscription_payments_row[0] or 0)
|
||||
subscription_payments_amount = int(subscription_payments_row[1] or 0)
|
||||
deposits_count = int(deposits_row[0] or 0)
|
||||
deposits_amount = int(deposits_row[1] or 0)
|
||||
|
||||
total_payments_count = subscription_payments_count + deposits_count
|
||||
total_payments_amount = subscription_payments_amount + deposits_amount
|
||||
new_tickets_result = await session.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
Ticket.created_at >= start_utc,
|
||||
Ticket.created_at < end_utc,
|
||||
)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
new_trials = int(
|
||||
(
|
||||
await session.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.created_at >= start_utc,
|
||||
Subscription.created_at < end_utc,
|
||||
Subscription.is_trial == true(),
|
||||
)
|
||||
)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
direct_paid = int(
|
||||
(
|
||||
await session.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.created_at >= start_utc,
|
||||
Subscription.created_at < end_utc,
|
||||
Subscription.is_trial == false(),
|
||||
)
|
||||
)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
trial_to_paid_conversions = int(
|
||||
(
|
||||
await session.execute(
|
||||
select(func.count(SubscriptionConversion.id)).where(
|
||||
SubscriptionConversion.converted_at >= start_utc,
|
||||
SubscriptionConversion.converted_at < end_utc,
|
||||
)
|
||||
)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
subscription_payments_count, subscription_payments_amount = (
|
||||
(
|
||||
await session.execute(
|
||||
self._txn_query_base(
|
||||
TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
start_utc,
|
||||
end_utc,
|
||||
)
|
||||
)
|
||||
).one()
|
||||
)
|
||||
|
||||
deposits_count, deposits_amount = (
|
||||
(
|
||||
await session.execute(
|
||||
self._deposit_query_excluding_referrals(start_utc, end_utc)
|
||||
)
|
||||
).one()
|
||||
)
|
||||
|
||||
new_tickets = int(
|
||||
(
|
||||
await session.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
Ticket.created_at >= start_utc,
|
||||
Ticket.created_at < end_utc,
|
||||
)
|
||||
)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
new_tickets = int(new_tickets_result.scalar() or 0)
|
||||
|
||||
return {
|
||||
"new_users": new_users,
|
||||
"new_trials": new_trials,
|
||||
"new_paid_subscriptions": direct_paid + conversions_count,
|
||||
"subscription_payments_count": subscription_payments_count,
|
||||
"subscription_payments_amount": subscription_payments_amount,
|
||||
"deposits_count": deposits_count,
|
||||
"deposits_amount": deposits_amount,
|
||||
"total_payments_count": total_payments_count,
|
||||
"total_payments_amount": total_payments_amount,
|
||||
"new_paid_subscriptions": direct_paid + trial_to_paid_conversions,
|
||||
"trial_to_paid_conversions": trial_to_paid_conversions,
|
||||
"subscription_payments_count": int(subscription_payments_count or 0),
|
||||
"subscription_payments_amount": int(subscription_payments_amount or 0),
|
||||
"deposits_count": int(deposits_count or 0),
|
||||
"deposits_amount": int(deposits_amount or 0),
|
||||
"new_tickets": new_tickets,
|
||||
}
|
||||
|
||||
def _txn_query_base(self, txn_type: str, start_utc: datetime, end_utc: datetime):
|
||||
return select(
|
||||
func.count(Transaction.id),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
|
||||
).where(
|
||||
Transaction.type == txn_type,
|
||||
Transaction.is_completed == true(),
|
||||
Transaction.created_at >= start_utc,
|
||||
Transaction.created_at < end_utc,
|
||||
)
|
||||
|
||||
def _deposit_query_excluding_referrals(self, start_utc: datetime, end_utc: datetime):
|
||||
return select(
|
||||
func.count(Transaction.id),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
|
||||
).where(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == true(),
|
||||
Transaction.created_at >= start_utc,
|
||||
Transaction.created_at < end_utc,
|
||||
self._exclude_referral_deposits_condition(),
|
||||
)
|
||||
|
||||
async def _get_top_referrers(
|
||||
self,
|
||||
session,
|
||||
start_utc: datetime,
|
||||
end_utc: datetime,
|
||||
limit: int = 5,
|
||||
) -> List[Dict]:
|
||||
rows = await session.execute(
|
||||
select(
|
||||
User.referred_by_id,
|
||||
func.count(User.id).label("cnt"),
|
||||
)
|
||||
.where(
|
||||
User.created_at >= start_utc,
|
||||
User.created_at < end_utc,
|
||||
User.referred_by_id.isnot(None),
|
||||
)
|
||||
.group_by(User.referred_by_id)
|
||||
.order_by(func.count(User.id).desc())
|
||||
.limit(limit)
|
||||
)
|
||||
rows = rows.all()
|
||||
if not rows:
|
||||
return []
|
||||
ref_ids = [row[0] for row in rows if row[0] is not None]
|
||||
users_map: Dict[int, str] = {}
|
||||
if ref_ids:
|
||||
urows = await session.execute(select(User).where(User.id.in_(ref_ids)))
|
||||
for user in urows.scalars().all():
|
||||
users_map[user.id] = self._user_label(user)
|
||||
return [
|
||||
{"referrer_label": users_map.get(ref_id, f"User #{ref_id}"), "count": int(count or 0)}
|
||||
for ref_id, count in rows
|
||||
]
|
||||
|
||||
async def _get_user_usage_stats(self, session) -> Dict[str, int]:
|
||||
now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
active_paid_q = await session.execute(
|
||||
select(func.count(func.distinct(Subscription.user_id))).where(
|
||||
Subscription.is_trial == false(),
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date > now_utc,
|
||||
)
|
||||
)
|
||||
active_paid_users = int(active_paid_q.scalar() or 0)
|
||||
|
||||
never_connected_q = await session.execute(
|
||||
select(func.count(func.distinct(Subscription.user_id))).where(
|
||||
or_(
|
||||
Subscription.connected_squads.is_(None),
|
||||
func.jsonb_array_length(cast(Subscription.connected_squads, JSONB)) == 0,
|
||||
)
|
||||
)
|
||||
)
|
||||
never_connected_users = int(never_connected_q.scalar() or 0)
|
||||
|
||||
return {
|
||||
"active_paid_users": active_paid_users,
|
||||
"never_connected_users": never_connected_users,
|
||||
}
|
||||
|
||||
def _user_label(self, user: User) -> str:
|
||||
if getattr(user, "username", None):
|
||||
return f"@{user.username}"
|
||||
parts = []
|
||||
if getattr(user, "first_name", None):
|
||||
parts.append(user.first_name)
|
||||
if getattr(user, "last_name", None):
|
||||
parts.append(user.last_name)
|
||||
if parts:
|
||||
return " ".join(parts)
|
||||
return f"User #{getattr(user, 'id', '?')}"
|
||||
|
||||
def _format_period_label(self, start: datetime, end: datetime) -> str:
|
||||
start_date = start.astimezone(self._moscow_tz).date()
|
||||
end_boundary = (end - timedelta(seconds=1)).astimezone(self._moscow_tz)
|
||||
@@ -362,15 +583,10 @@ class ReportingService:
|
||||
if start_date == end_date:
|
||||
return start_date.strftime("%d.%m.%Y")
|
||||
|
||||
return (
|
||||
f"{start_date.strftime('%d.%m.%Y')} - {end_date.strftime('%d.%m.%Y')}"
|
||||
)
|
||||
return f"{start_date.strftime('%d.%m.%Y')} - {end_date.strftime('%d.%m.%Y')}"
|
||||
|
||||
def _format_amount(self, amount_kopeks: int) -> str:
|
||||
if not amount_kopeks:
|
||||
return "0 ₽"
|
||||
|
||||
rubles = amount_kopeks / 100
|
||||
rubles = (amount_kopeks or 0) / 100
|
||||
return f"{rubles:,.2f} ₽".replace(",", " ")
|
||||
|
||||
|
||||
|
||||
@@ -1175,5 +1175,38 @@ class MiniAppSubscriptionPurchaseService:
|
||||
}
|
||||
|
||||
|
||||
class SubscriptionPurchaseService:
|
||||
"""Service for handling simple subscription purchases with predefined parameters."""
|
||||
|
||||
async def create_subscription_order(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
period_days: int,
|
||||
device_limit: int,
|
||||
traffic_limit_gb: int,
|
||||
squad_uuid: str,
|
||||
payment_method: str,
|
||||
total_price_kopeks: int
|
||||
):
|
||||
"""Creates a subscription order with predefined parameters."""
|
||||
from app.database.crud.subscription import create_pending_subscription
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
# Create a pending subscription
|
||||
subscription = await create_pending_subscription(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
duration_days=period_days,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=[squad_uuid] if squad_uuid else [],
|
||||
payment_method=payment_method,
|
||||
total_price_kopeks=total_price_kopeks
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
purchase_service = MiniAppSubscriptionPurchaseService()
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import hashlib
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Tuple, Type, Union, get_args, get_origin
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union, get_args, get_origin
|
||||
|
||||
from app.database.universal_migration import ensure_default_web_api_token
|
||||
|
||||
@@ -74,13 +74,15 @@ class BotConfigurationService:
|
||||
"PAYMENT": "💳 Общие платежные настройки",
|
||||
"TELEGRAM": "⭐ Telegram Stars",
|
||||
"CRYPTOBOT": "🪙 CryptoBot",
|
||||
"HELEKET": "🪙 Heleket",
|
||||
"YOOKASSA": "🟣 YooKassa",
|
||||
"TRIBUTE": "🎁 Tribute",
|
||||
"MULENPAY": "💰 MulenPay",
|
||||
"MULENPAY": "💰 {mulenpay_name}",
|
||||
"PAL24": "🏦 PAL24 / PayPalych",
|
||||
"WATA": "💠 Wata",
|
||||
"EXTERNAL_ADMIN": "🛡️ Внешняя админка",
|
||||
"SUBSCRIPTIONS_CORE": "📅 Подписки и лимиты",
|
||||
"SIMPLE_SUBSCRIPTION": "⚡ Простая покупка",
|
||||
"PERIODS": "📆 Периоды подписок",
|
||||
"SUBSCRIPTION_PRICES": "💵 Стоимость тарифов",
|
||||
"TRAFFIC": "📊 Трафик",
|
||||
@@ -124,13 +126,15 @@ class BotConfigurationService:
|
||||
"PAYMENT": "Общие тексты платежей, описания чеков и шаблоны.",
|
||||
"YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.",
|
||||
"CRYPTOBOT": "CryptoBot и криптоплатежи через Telegram.",
|
||||
"MULENPAY": "Платежи MulenPay и параметры магазина.",
|
||||
"HELEKET": "Heleket: криптоплатежи, ключи мерчанта и вебхуки.",
|
||||
"MULENPAY": "Платежи {mulenpay_name} и параметры магазина.",
|
||||
"PAL24": "PAL24 / PayPalych подключения и лимиты.",
|
||||
"TRIBUTE": "Tribute и донат-сервисы.",
|
||||
"TELEGRAM": "Telegram Stars и их стоимость.",
|
||||
"WATA": "Wata: токен доступа, тип платежа и пределы сумм.",
|
||||
"EXTERNAL_ADMIN": "Токен внешней админки для проверки запросов.",
|
||||
"SUBSCRIPTIONS_CORE": "Лимиты устройств, трафика и базовые цены подписок.",
|
||||
"SIMPLE_SUBSCRIPTION": "Параметры упрощённой покупки: период, трафик, устройства и сквады.",
|
||||
"PERIODS": "Доступные периоды подписок и продлений.",
|
||||
"SUBSCRIPTION_PRICES": "Стоимость подписок по периодам в копейках.",
|
||||
"TRAFFIC": "Лимиты трафика и стратегии сброса.",
|
||||
@@ -166,6 +170,14 @@ class BotConfigurationService:
|
||||
"MODERATION": "Настройки фильтров отображаемых имен и защиты от фишинга.",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _format_dynamic_copy(category_key: Optional[str], value: str) -> str:
|
||||
if not value:
|
||||
return value
|
||||
if category_key == "MULENPAY":
|
||||
return value.format(mulenpay_name=settings.get_mulenpay_display_name())
|
||||
return value
|
||||
|
||||
CATEGORY_KEY_OVERRIDES: Dict[str, str] = {
|
||||
"DATABASE_URL": "DATABASE",
|
||||
"DATABASE_MODE": "DATABASE",
|
||||
@@ -222,6 +234,11 @@ class BotConfigurationService:
|
||||
"PAYMENT_SUBSCRIPTION_DESCRIPTION": "PAYMENT",
|
||||
"PAYMENT_BALANCE_TEMPLATE": "PAYMENT",
|
||||
"PAYMENT_SUBSCRIPTION_TEMPLATE": "PAYMENT",
|
||||
"SIMPLE_SUBSCRIPTION_ENABLED": "SIMPLE_SUBSCRIPTION",
|
||||
"SIMPLE_SUBSCRIPTION_PERIOD_DAYS": "SIMPLE_SUBSCRIPTION",
|
||||
"SIMPLE_SUBSCRIPTION_DEVICE_LIMIT": "SIMPLE_SUBSCRIPTION",
|
||||
"SIMPLE_SUBSCRIPTION_TRAFFIC_GB": "SIMPLE_SUBSCRIPTION",
|
||||
"SIMPLE_SUBSCRIPTION_SQUAD_UUID": "SIMPLE_SUBSCRIPTION",
|
||||
"DISABLE_TOPUP_BUTTONS": "PAYMENT",
|
||||
"ENABLE_NOTIFICATIONS": "NOTIFICATIONS",
|
||||
"NOTIFICATION_RETRY_ATTEMPTS": "NOTIFICATIONS",
|
||||
@@ -271,11 +288,13 @@ class BotConfigurationService:
|
||||
"TRIBUTE_": "TRIBUTE",
|
||||
"YOOKASSA_": "YOOKASSA",
|
||||
"CRYPTOBOT_": "CRYPTOBOT",
|
||||
"HELEKET_": "HELEKET",
|
||||
"MULENPAY_": "MULENPAY",
|
||||
"PAL24_": "PAL24",
|
||||
"PAYMENT_": "PAYMENT",
|
||||
"WATA_": "WATA",
|
||||
"EXTERNAL_ADMIN_": "EXTERNAL_ADMIN",
|
||||
"SIMPLE_SUBSCRIPTION_": "SIMPLE_SUBSCRIPTION",
|
||||
"CONNECT_BUTTON_HAPP": "HAPP",
|
||||
"HAPP_": "HAPP",
|
||||
"SKIP_": "SKIP",
|
||||
@@ -386,12 +405,47 @@ class BotConfigurationService:
|
||||
|
||||
SETTING_HINTS: Dict[str, Dict[str, str]] = {
|
||||
"YOOKASSA_ENABLED": {
|
||||
"description": "Включает оплату через YooKassa. Требует корректных идентификаторов магазина и секретного ключа.",
|
||||
"description": (
|
||||
"Включает оплату через YooKassa. "
|
||||
"Требует корректных идентификаторов магазина и секретного ключа."
|
||||
),
|
||||
"format": "Булево значение: выберите \"Включить\" или \"Выключить\".",
|
||||
"example": "Включено при полностью настроенной интеграции.",
|
||||
"warning": "При включении без Shop ID и Secret Key пользователи увидят ошибки при оплате.",
|
||||
"dependencies": "YOOKASSA_SHOP_ID, YOOKASSA_SECRET_KEY, YOOKASSA_RETURN_URL",
|
||||
},
|
||||
"SIMPLE_SUBSCRIPTION_ENABLED": {
|
||||
"description": "Показывает в меню пункт с быстрой покупкой подписки.",
|
||||
"format": "Булево значение.",
|
||||
"example": "true",
|
||||
"warning": "Если остались не настроенные параметры, предложение может вести себя некорректно.",
|
||||
},
|
||||
"SIMPLE_SUBSCRIPTION_PERIOD_DAYS": {
|
||||
"description": "Период подписки, который предлагается при быстрой покупке.",
|
||||
"format": "Выберите один из доступных периодов.",
|
||||
"example": "30 дн. — 990 ₽",
|
||||
"warning": "Не забудьте настроить цену периода в блоке «Стоимость тарифов».",
|
||||
},
|
||||
"SIMPLE_SUBSCRIPTION_DEVICE_LIMIT": {
|
||||
"description": "Сколько устройств получит пользователь вместе с подпиской по быстрой покупке.",
|
||||
"format": "Выберите число устройств.",
|
||||
"example": "2 устройства",
|
||||
"warning": "Значение не должно превышать допустимый лимит в настройках подписок.",
|
||||
},
|
||||
"SIMPLE_SUBSCRIPTION_TRAFFIC_GB": {
|
||||
"description": "Объём трафика, включённый в простую подписку (0 = безлимит).",
|
||||
"format": "Выберите пакет трафика.",
|
||||
"example": "Безлимит",
|
||||
},
|
||||
"SIMPLE_SUBSCRIPTION_SQUAD_UUID": {
|
||||
"description": (
|
||||
"Привязка быстрой подписки к конкретному скваду. "
|
||||
"Оставьте пустым для любого доступного сервера."
|
||||
),
|
||||
"format": "Выберите сквад из списка или очистите значение.",
|
||||
"example": "d4aa2b8c-9a36-4f31-93a2-6f07dad05fba",
|
||||
"warning": "Убедитесь, что выбранный сквад активен и доступен для подписки.",
|
||||
},
|
||||
"CRYPTOBOT_ENABLED": {
|
||||
"description": "Разрешает принимать криптоплатежи через CryptoBot.",
|
||||
"format": "Булево значение.",
|
||||
@@ -414,7 +468,10 @@ class BotConfigurationService:
|
||||
"dependencies": "MAINTENANCE_MESSAGE, MAINTENANCE_CHECK_INTERVAL",
|
||||
},
|
||||
"DISPLAY_NAME_BANNED_KEYWORDS": {
|
||||
"description": "Список слов и фрагментов, при наличии которых в отображаемом имени пользователь будет заблокирован.",
|
||||
"description": (
|
||||
"Список слов и фрагментов, при наличии которых в отображаемом имени "
|
||||
"пользователь будет заблокирован."
|
||||
),
|
||||
"format": "Перечислите ключевые слова через запятую или с новой строки.",
|
||||
"example": "support, security, служебн",
|
||||
"warning": "Слишком агрессивные фильтры могут блокировать добросовестных пользователей.",
|
||||
@@ -435,10 +492,16 @@ class BotConfigurationService:
|
||||
"dependencies": "REMNAWAVE_AUTO_SYNC_TIMES",
|
||||
},
|
||||
"REMNAWAVE_AUTO_SYNC_TIMES": {
|
||||
"description": "Список времени в формате HH:MM, когда запускается автосинхронизация в течение суток.",
|
||||
"description": (
|
||||
"Список времени в формате HH:MM, когда запускается автосинхронизация "
|
||||
"в течение суток."
|
||||
),
|
||||
"format": "Перечислите время через запятую или с новой строки (например, 03:00, 15:00).",
|
||||
"example": "03:00, 15:00",
|
||||
"warning": "Минимальный интервал между запусками не ограничен, но слишком частые синхронизации нагружают панель.",
|
||||
"warning": (
|
||||
"Минимальный интервал между запусками не ограничен, но слишком частые "
|
||||
"синхронизации нагружают панель."
|
||||
),
|
||||
"dependencies": "REMNAWAVE_AUTO_SYNC_ENABLED",
|
||||
},
|
||||
"EXTERNAL_ADMIN_TOKEN": {
|
||||
@@ -459,7 +522,8 @@ class BotConfigurationService:
|
||||
|
||||
@classmethod
|
||||
def get_category_description(cls, category_key: str) -> str:
|
||||
return cls.CATEGORY_DESCRIPTIONS.get(category_key, "")
|
||||
description = cls.CATEGORY_DESCRIPTIONS.get(category_key, "")
|
||||
return cls._format_dynamic_copy(category_key, description)
|
||||
|
||||
@classmethod
|
||||
def is_toggle(cls, key: str) -> bool:
|
||||
@@ -506,6 +570,14 @@ class BotConfigurationService:
|
||||
|
||||
@classmethod
|
||||
def format_value_human(cls, key: str, value: Any) -> str:
|
||||
if key == "SIMPLE_SUBSCRIPTION_SQUAD_UUID":
|
||||
if value is None:
|
||||
return "Любой доступный"
|
||||
if isinstance(value, str):
|
||||
cleaned_value = value.strip()
|
||||
if not cleaned_value:
|
||||
return "Любой доступный"
|
||||
|
||||
if value is None:
|
||||
return "—"
|
||||
|
||||
@@ -598,6 +670,7 @@ class BotConfigurationService:
|
||||
category_key,
|
||||
category_key.capitalize() if category_key else "Прочее",
|
||||
)
|
||||
category_label = cls._format_dynamic_copy(category_key, category_label)
|
||||
|
||||
cls._definitions[key] = SettingDefinition(
|
||||
key=key,
|
||||
@@ -733,8 +806,179 @@ class BotConfigurationService:
|
||||
@classmethod
|
||||
def get_choice_options(cls, key: str) -> List[ChoiceOption]:
|
||||
cls.initialize_definitions()
|
||||
dynamic = cls._get_dynamic_choice_options(key)
|
||||
if dynamic is not None:
|
||||
cls.CHOICES[key] = dynamic
|
||||
cls._invalidate_choice_cache(key)
|
||||
return dynamic
|
||||
return cls.CHOICES.get(key, [])
|
||||
|
||||
@classmethod
|
||||
def _invalidate_choice_cache(cls, key: str) -> None:
|
||||
cls._choice_tokens.pop(key, None)
|
||||
cls._choice_token_lookup.pop(key, None)
|
||||
|
||||
@classmethod
|
||||
def _get_dynamic_choice_options(cls, key: str) -> Optional[List[ChoiceOption]]:
|
||||
if key == "SIMPLE_SUBSCRIPTION_PERIOD_DAYS":
|
||||
return cls._build_simple_subscription_period_choices()
|
||||
if key == "SIMPLE_SUBSCRIPTION_DEVICE_LIMIT":
|
||||
return cls._build_simple_subscription_device_choices()
|
||||
if key == "SIMPLE_SUBSCRIPTION_TRAFFIC_GB":
|
||||
return cls._build_simple_subscription_traffic_choices()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _build_simple_subscription_period_choices() -> List[ChoiceOption]:
|
||||
raw_periods = str(getattr(settings, "AVAILABLE_SUBSCRIPTION_PERIODS", "") or "")
|
||||
period_values: set[int] = set()
|
||||
|
||||
for segment in raw_periods.split(","):
|
||||
segment = segment.strip()
|
||||
if not segment:
|
||||
continue
|
||||
try:
|
||||
period = int(segment)
|
||||
except ValueError:
|
||||
continue
|
||||
if period > 0:
|
||||
period_values.add(period)
|
||||
|
||||
fallback_period = getattr(settings, "SIMPLE_SUBSCRIPTION_PERIOD_DAYS", 30) or 30
|
||||
try:
|
||||
fallback_period = int(fallback_period)
|
||||
except (TypeError, ValueError):
|
||||
fallback_period = 30
|
||||
period_values.add(max(1, fallback_period))
|
||||
|
||||
options: List[ChoiceOption] = []
|
||||
for days in sorted(period_values):
|
||||
price_attr = f"PRICE_{days}_DAYS"
|
||||
price_value = getattr(settings, price_attr, None)
|
||||
if not isinstance(price_value, int):
|
||||
price_value = settings.BASE_SUBSCRIPTION_PRICE
|
||||
|
||||
label = f"{days} дн."
|
||||
try:
|
||||
if isinstance(price_value, int):
|
||||
label = f"{label} — {settings.format_price(price_value)}"
|
||||
except Exception:
|
||||
logger.debug("Не удалось форматировать цену для периода %s", days, exc_info=True)
|
||||
|
||||
options.append(ChoiceOption(days, label))
|
||||
|
||||
return options
|
||||
|
||||
@classmethod
|
||||
def _build_simple_subscription_device_choices(cls) -> List[ChoiceOption]:
|
||||
default_limit = getattr(settings, "DEFAULT_DEVICE_LIMIT", 1) or 1
|
||||
try:
|
||||
default_limit = int(default_limit)
|
||||
except (TypeError, ValueError):
|
||||
default_limit = 1
|
||||
|
||||
max_limit = getattr(settings, "MAX_DEVICES_LIMIT", default_limit) or default_limit
|
||||
try:
|
||||
max_limit = int(max_limit)
|
||||
except (TypeError, ValueError):
|
||||
max_limit = default_limit
|
||||
|
||||
current_limit = getattr(settings, "SIMPLE_SUBSCRIPTION_DEVICE_LIMIT", default_limit) or default_limit
|
||||
try:
|
||||
current_limit = int(current_limit)
|
||||
except (TypeError, ValueError):
|
||||
current_limit = default_limit
|
||||
|
||||
upper_bound = max(default_limit, max_limit, current_limit, 1)
|
||||
upper_bound = min(max(upper_bound, 1), 50)
|
||||
|
||||
options: List[ChoiceOption] = []
|
||||
for count in range(1, upper_bound + 1):
|
||||
label = f"{count} {cls._pluralize_devices(count)}"
|
||||
if count == default_limit:
|
||||
label = f"{label} (по умолчанию)"
|
||||
options.append(ChoiceOption(count, label))
|
||||
|
||||
return options
|
||||
|
||||
@staticmethod
|
||||
def _build_simple_subscription_traffic_choices() -> List[ChoiceOption]:
|
||||
try:
|
||||
packages = settings.get_traffic_packages()
|
||||
except Exception as error:
|
||||
logger.warning("Не удалось получить пакеты трафика: %s", error, exc_info=True)
|
||||
packages = []
|
||||
|
||||
traffic_values: set[int] = {0}
|
||||
for package in packages:
|
||||
gb_value = package.get("gb")
|
||||
try:
|
||||
gb = int(gb_value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if gb >= 0:
|
||||
traffic_values.add(gb)
|
||||
|
||||
default_limit = getattr(settings, "DEFAULT_TRAFFIC_LIMIT_GB", 0) or 0
|
||||
try:
|
||||
default_limit = int(default_limit)
|
||||
except (TypeError, ValueError):
|
||||
default_limit = 0
|
||||
if default_limit >= 0:
|
||||
traffic_values.add(default_limit)
|
||||
|
||||
current_limit = getattr(settings, "SIMPLE_SUBSCRIPTION_TRAFFIC_GB", default_limit)
|
||||
try:
|
||||
current_limit = int(current_limit)
|
||||
except (TypeError, ValueError):
|
||||
current_limit = default_limit
|
||||
if current_limit >= 0:
|
||||
traffic_values.add(current_limit)
|
||||
|
||||
options: List[ChoiceOption] = []
|
||||
for gb in sorted(traffic_values):
|
||||
if gb <= 0:
|
||||
label = "Безлимит"
|
||||
else:
|
||||
label = f"{gb} ГБ"
|
||||
|
||||
price_label = None
|
||||
for package in packages:
|
||||
try:
|
||||
package_gb = int(package.get("gb"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if package_gb != gb:
|
||||
continue
|
||||
price_raw = package.get("price")
|
||||
try:
|
||||
price_value = int(price_raw)
|
||||
if price_value >= 0:
|
||||
price_label = settings.format_price(price_value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
break
|
||||
|
||||
if price_label:
|
||||
label = f"{label} — {price_label}"
|
||||
|
||||
options.append(ChoiceOption(gb, label))
|
||||
|
||||
return options
|
||||
|
||||
@staticmethod
|
||||
def _pluralize_devices(count: int) -> str:
|
||||
count = abs(int(count))
|
||||
last_two = count % 100
|
||||
last_one = count % 10
|
||||
if 11 <= last_two <= 14:
|
||||
return "устройств"
|
||||
if last_one == 1:
|
||||
return "устройство"
|
||||
if 2 <= last_one <= 4:
|
||||
return "устройства"
|
||||
return "устройств"
|
||||
|
||||
@classmethod
|
||||
def has_choices(cls, key: str) -> bool:
|
||||
return bool(cls.get_choice_options(key))
|
||||
@@ -1045,4 +1289,3 @@ class BotConfigurationService:
|
||||
|
||||
|
||||
bot_configuration_service = BotConfigurationService
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import delete, select, update, func
|
||||
from aiogram import Bot
|
||||
from aiogram import Bot, types
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from app.database.crud.user import (
|
||||
get_user_by_id, get_user_by_telegram_id, get_users_list,
|
||||
@@ -26,6 +26,7 @@ from app.database.models import (
|
||||
TransactionType
|
||||
)
|
||||
from app.config import settings
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -66,10 +67,30 @@ class UserService:
|
||||
f"Если у вас есть вопросы, обратитесь в поддержку."
|
||||
)
|
||||
|
||||
keyboard_rows = []
|
||||
if getattr(user, "subscription", None) and user.subscription.status in {
|
||||
"active",
|
||||
"expired",
|
||||
"trial",
|
||||
}:
|
||||
keyboard_rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=get_texts(user.language).t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"),
|
||||
callback_data="subscription_extend",
|
||||
)
|
||||
])
|
||||
|
||||
reply_markup = (
|
||||
types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
if keyboard_rows
|
||||
else None
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message,
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
reply_markup=reply_markup,
|
||||
)
|
||||
|
||||
logger.info(f"✅ Уведомление о изменении баланса отправлено пользователю {user.telegram_id}")
|
||||
@@ -652,7 +673,10 @@ class UserService:
|
||||
mulenpay_payments = mulenpay_result.scalars().all()
|
||||
|
||||
if mulenpay_payments:
|
||||
logger.info(f"🔄 Удаляем {len(mulenpay_payments)} MulenPay платежей")
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.info(
|
||||
f"🔄 Удаляем {len(mulenpay_payments)} {mulenpay_name} платежей"
|
||||
)
|
||||
await db.execute(
|
||||
update(MulenPayPayment)
|
||||
.where(MulenPayPayment.user_id == user_id)
|
||||
@@ -664,7 +688,9 @@ class UserService:
|
||||
)
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления MulenPay платежей: {e}")
|
||||
logger.error(
|
||||
f"❌ Ошибка удаления {settings.get_mulenpay_display_name()} платежей: {e}"
|
||||
)
|
||||
|
||||
try:
|
||||
pal24_result = await db.execute(
|
||||
|
||||
@@ -102,8 +102,11 @@ class WataService:
|
||||
|
||||
@staticmethod
|
||||
def _format_datetime(value: datetime) -> str:
|
||||
aware = value.astimezone(timezone.utc)
|
||||
return aware.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
if value.tzinfo is None:
|
||||
aware = value.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
aware = value.astimezone(timezone.utc)
|
||||
return aware.isoformat().replace("+00:00", "Z")
|
||||
|
||||
@staticmethod
|
||||
def _parse_datetime(raw: Optional[str]) -> Optional[datetime]:
|
||||
@@ -111,7 +114,10 @@ class WataService:
|
||||
return None
|
||||
try:
|
||||
normalized = raw.replace("Z", "+00:00")
|
||||
return datetime.fromisoformat(normalized)
|
||||
parsed = datetime.fromisoformat(normalized)
|
||||
if parsed.tzinfo is None:
|
||||
return parsed
|
||||
return parsed.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
except (ValueError, TypeError):
|
||||
logger.debug("Failed to parse WATA datetime: %s", raw)
|
||||
return None
|
||||
|
||||
@@ -188,6 +188,8 @@ class YooKassaService:
|
||||
}
|
||||
|
||||
try:
|
||||
# Создаем один платеж с подтверждением через QR
|
||||
# Это позволит получить QR-код для пользователя
|
||||
builder = PaymentRequestBuilder()
|
||||
|
||||
builder.set_amount({
|
||||
@@ -197,6 +199,7 @@ class YooKassaService:
|
||||
|
||||
builder.set_capture(True)
|
||||
|
||||
# Устанавливаем подтверждение через redirect для получения вебхуков
|
||||
builder.set_confirmation({
|
||||
"type": "redirect",
|
||||
"return_url": self.return_url
|
||||
@@ -234,7 +237,7 @@ class YooKassaService:
|
||||
payment_request = builder.build()
|
||||
|
||||
logger.info(
|
||||
f"Создание платежа YooKassa СБП (Idempotence-Key: {idempotence_key}). "
|
||||
f"Создание платежа YooKassa СБП с подтверждением 'qr' (Idempotence-Key: {idempotence_key}). "
|
||||
f"Сумма: {amount} {currency}. Метаданные: {metadata}. Чек: {receipt_data_dict}")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -242,11 +245,14 @@ class YooKassaService:
|
||||
None, lambda: YooKassaPayment.create(payment_request, idempotence_key))
|
||||
|
||||
logger.info(
|
||||
f"Ответ YooKassa Payment.create (СБП): ID={response.id}, Status={response.status}, Paid={response.paid}")
|
||||
f"Ответ YooKassa Payment.create (СБП, qr): ID={response.id}, Status={response.status}, Paid={response.paid}")
|
||||
|
||||
# Возвращаем данные платежа с QR-подтверждением
|
||||
# Пользователь может использовать QR-код или оплатить через приложение банка по ID платежа
|
||||
return {
|
||||
"id": response.id,
|
||||
"confirmation_url": response.confirmation.confirmation_url if response.confirmation else None,
|
||||
"qr_confirmation_data": response.confirmation.confirmation_data if response.confirmation and hasattr(response.confirmation, 'confirmation_data') else None,
|
||||
"confirmation_url": response.confirmation.confirmation_url if response.confirmation and hasattr(response.confirmation, 'confirmation_url') else None,
|
||||
"status": response.status,
|
||||
"metadata": response.metadata,
|
||||
"amount_value": float(response.amount.value),
|
||||
@@ -263,6 +269,105 @@ class YooKassaService:
|
||||
logger.error(f"Ошибка создания платежа YooKassa СБП: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
async def _create_sbp_payment_with_confirmation_type(
|
||||
self,
|
||||
amount: float,
|
||||
currency: str,
|
||||
description: str,
|
||||
metadata: Dict[str, Any],
|
||||
customer_contact_for_receipt: Dict[str, str],
|
||||
confirmation_type: str) -> Optional[Dict[str, Any]]:
|
||||
"""Создает SBP платеж с указанным типом подтверждения"""
|
||||
try:
|
||||
builder = PaymentRequestBuilder()
|
||||
|
||||
builder.set_amount({
|
||||
"value": str(round(amount, 2)),
|
||||
"currency": currency.upper()
|
||||
})
|
||||
|
||||
builder.set_capture(True)
|
||||
|
||||
if confirmation_type == "qr":
|
||||
builder.set_confirmation({
|
||||
"type": "qr"
|
||||
})
|
||||
else: # redirect
|
||||
builder.set_confirmation({
|
||||
"type": "redirect",
|
||||
"return_url": self.return_url
|
||||
})
|
||||
|
||||
builder.set_description(description)
|
||||
|
||||
builder.set_metadata(metadata)
|
||||
|
||||
builder.set_payment_method_data({
|
||||
"type": "sbp"
|
||||
})
|
||||
|
||||
receipt_items_list: List[Dict[str, Any]] = [{
|
||||
"description": description[:128],
|
||||
"quantity": "1.00",
|
||||
"amount": {
|
||||
"value": str(round(amount, 2)),
|
||||
"currency": currency.upper()
|
||||
},
|
||||
"vat_code": str(getattr(settings, 'YOOKASSA_VAT_CODE', 1)),
|
||||
"payment_mode": getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'),
|
||||
"payment_subject": getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service')
|
||||
}]
|
||||
|
||||
receipt_data_dict: Dict[str, Any] = {
|
||||
"customer": customer_contact_for_receipt,
|
||||
"items": receipt_items_list
|
||||
}
|
||||
|
||||
builder.set_receipt(receipt_data_dict)
|
||||
|
||||
idempotence_key = str(uuid.uuid4())
|
||||
|
||||
payment_request = builder.build()
|
||||
|
||||
logger.info(
|
||||
f"Создание платежа YooKassa СБП с подтверждением '{confirmation_type}' (Idempotence-Key: {idempotence_key}). "
|
||||
f"Сумма: {amount} {currency}. Метаданные: {metadata}. Чек: {receipt_data_dict}")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
response = await loop.run_in_executor(
|
||||
None, lambda: YooKassaPayment.create(payment_request, idempotence_key))
|
||||
|
||||
logger.info(
|
||||
f"Ответ YooKassa Payment.create (СБП, {confirmation_type}): ID={response.id}, Status={response.status}, Paid={response.paid}")
|
||||
|
||||
result = {
|
||||
"id": response.id,
|
||||
"status": response.status,
|
||||
"metadata": response.metadata,
|
||||
"amount_value": float(response.amount.value),
|
||||
"amount_currency": response.amount.currency,
|
||||
"idempotence_key_used": idempotence_key,
|
||||
"paid": response.paid,
|
||||
"refundable": response.refundable,
|
||||
"created_at": response.created_at.isoformat() if hasattr(
|
||||
response.created_at, 'isoformat') else str(response.created_at),
|
||||
"description_from_yk": response.description,
|
||||
"test_mode": response.test if hasattr(response, 'test') else None
|
||||
}
|
||||
|
||||
# Добавляем данные подтверждения в зависимости от типа
|
||||
if confirmation_type == "qr":
|
||||
if response.confirmation and hasattr(response.confirmation, 'confirmation_data'):
|
||||
result["confirmation_data"] = response.confirmation.confirmation_data
|
||||
else: # redirect
|
||||
if response.confirmation and hasattr(response.confirmation, 'confirmation_url'):
|
||||
result["confirmation_url"] = response.confirmation.confirmation_url
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания платежа YooKassa СБП с подтверждением '{confirmation_type}': {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
async def get_payment_info(
|
||||
self, payment_id_in_yookassa: str) -> Optional[Dict[str, Any]]:
|
||||
|
||||
|
||||
@@ -18,9 +18,13 @@ class SubscriptionStates(StatesGroup):
|
||||
extending_subscription = State()
|
||||
confirming_traffic_reset = State()
|
||||
cart_saved_for_topup = State()
|
||||
|
||||
# Состояния для простой подписки
|
||||
waiting_for_simple_subscription_payment_method = State()
|
||||
|
||||
class BalanceStates(StatesGroup):
|
||||
waiting_for_amount = State()
|
||||
waiting_for_pal24_method = State()
|
||||
waiting_for_stars_payment = State()
|
||||
waiting_for_support_request = State()
|
||||
|
||||
@@ -32,6 +36,7 @@ class PromoCodeStates(StatesGroup):
|
||||
class AdminStates(StatesGroup):
|
||||
|
||||
waiting_for_user_search = State()
|
||||
sending_user_message = State()
|
||||
editing_user_balance = State()
|
||||
extending_subscription = State()
|
||||
adding_traffic = State()
|
||||
|
||||
@@ -46,11 +46,12 @@ def get_available_payment_methods() -> List[Dict[str, str]]:
|
||||
})
|
||||
|
||||
if settings.is_mulenpay_enabled():
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
methods.append({
|
||||
"id": "mulenpay",
|
||||
"name": "Банковская карта",
|
||||
"icon": "💳",
|
||||
"description": "через Mulen Pay",
|
||||
"description": f"через {mulenpay_name}",
|
||||
"callback": "topup_mulenpay"
|
||||
})
|
||||
|
||||
@@ -80,6 +81,15 @@ def get_available_payment_methods() -> List[Dict[str, str]]:
|
||||
"description": "через CryptoBot",
|
||||
"callback": "topup_cryptobot"
|
||||
})
|
||||
|
||||
if settings.is_heleket_enabled():
|
||||
methods.append({
|
||||
"id": "heleket",
|
||||
"name": "Криптовалюта",
|
||||
"icon": "🪙",
|
||||
"description": "через Heleket",
|
||||
"callback": "topup_heleket"
|
||||
})
|
||||
|
||||
# Поддержка всегда доступна
|
||||
methods.append({
|
||||
@@ -129,6 +139,12 @@ def get_payment_methods_text(language: str) -> str:
|
||||
f"PAYMENT_METHOD_{method_id}_DESCRIPTION",
|
||||
method['description'],
|
||||
)
|
||||
if method_id == "MULENPAY":
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
mulenpay_name_html = settings.get_mulenpay_display_name_html()
|
||||
name = name.format(mulenpay_name=mulenpay_name_html)
|
||||
description = description.format(mulenpay_name=mulenpay_name)
|
||||
|
||||
text += f"{name} - {description}\n"
|
||||
|
||||
text += "\n" + texts.t(
|
||||
@@ -156,6 +172,8 @@ def is_payment_method_available(method_id: str) -> bool:
|
||||
return settings.is_pal24_enabled()
|
||||
elif method_id == "cryptobot":
|
||||
return settings.is_cryptobot_enabled()
|
||||
elif method_id == "heleket":
|
||||
return settings.is_heleket_enabled()
|
||||
elif method_id == "support":
|
||||
return True # Поддержка всегда доступна
|
||||
else:
|
||||
@@ -173,6 +191,7 @@ def get_payment_method_status() -> Dict[str, bool]:
|
||||
"wata": settings.is_wata_enabled(),
|
||||
"pal24": settings.is_pal24_enabled(),
|
||||
"cryptobot": settings.is_cryptobot_enabled(),
|
||||
"heleket": settings.is_heleket_enabled(),
|
||||
"support": True
|
||||
}
|
||||
|
||||
@@ -195,4 +214,6 @@ def get_enabled_payment_methods_count() -> int:
|
||||
count += 1
|
||||
if settings.is_cryptobot_enabled():
|
||||
count += 1
|
||||
if settings.is_heleket_enabled():
|
||||
count += 1
|
||||
return count
|
||||
|
||||
@@ -668,6 +668,7 @@ async def get_payment_methods(
|
||||
methods.append(
|
||||
MiniAppPaymentMethod(
|
||||
id="mulenpay",
|
||||
name=settings.get_mulenpay_display_name(),
|
||||
icon="💳",
|
||||
requires_amount=True,
|
||||
currency="RUB",
|
||||
@@ -714,6 +715,18 @@ async def get_payment_methods(
|
||||
)
|
||||
)
|
||||
|
||||
if settings.is_heleket_enabled():
|
||||
methods.append(
|
||||
MiniAppPaymentMethod(
|
||||
id="heleket",
|
||||
icon="🪙",
|
||||
requires_amount=True,
|
||||
currency="RUB",
|
||||
min_amount_kopeks=100 * 100,
|
||||
max_amount_kopeks=100_000 * 100,
|
||||
)
|
||||
)
|
||||
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
methods.append(
|
||||
MiniAppPaymentMethod(
|
||||
@@ -732,7 +745,8 @@ async def get_payment_methods(
|
||||
"pal24": 5,
|
||||
"wata": 6,
|
||||
"cryptobot": 7,
|
||||
"tribute": 8,
|
||||
"heleket": 8,
|
||||
"tribute": 9,
|
||||
}
|
||||
methods.sort(key=lambda item: order_map.get(item.id, 99))
|
||||
|
||||
@@ -958,7 +972,7 @@ async def create_payment_link(
|
||||
option = (payload.payment_option or "").strip().lower()
|
||||
if option not in {"card", "sbp"}:
|
||||
option = "sbp"
|
||||
provider_method = "CARD" if option == "card" else "SBP"
|
||||
provider_method = "card" if option == "card" else "sbp"
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_pal24_payment(
|
||||
@@ -974,7 +988,7 @@ async def create_payment_link(
|
||||
|
||||
preferred_urls: List[Optional[str]] = []
|
||||
if option == "sbp":
|
||||
preferred_urls.append(result.get("sbp_url"))
|
||||
preferred_urls.append(result.get("sbp_url") or result.get("transfer_url"))
|
||||
elif option == "card":
|
||||
preferred_urls.append(result.get("card_url"))
|
||||
preferred_urls.extend(
|
||||
@@ -998,7 +1012,7 @@ async def create_payment_link(
|
||||
"bill_id": result.get("bill_id"),
|
||||
"order_id": result.get("order_id"),
|
||||
"payment_method": result.get("payment_method") or provider_method,
|
||||
"sbp_url": result.get("sbp_url"),
|
||||
"sbp_url": result.get("sbp_url") or result.get("transfer_url"),
|
||||
"card_url": result.get("card_url"),
|
||||
"link_url": result.get("link_url"),
|
||||
"link_page_url": result.get("link_page_url"),
|
||||
@@ -1070,6 +1084,53 @@ async def create_payment_link(
|
||||
},
|
||||
)
|
||||
|
||||
if method == "heleket":
|
||||
if not settings.is_heleket_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")
|
||||
|
||||
min_amount_kopeks = 100 * 100
|
||||
max_amount_kopeks = 100_000 * 100
|
||||
if amount_kopeks < min_amount_kopeks:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Amount is below minimum ({min_amount_kopeks / 100:.2f} RUB)",
|
||||
)
|
||||
if amount_kopeks > max_amount_kopeks:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Amount exceeds maximum ({max_amount_kopeks / 100:.2f} RUB)",
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_heleket_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,
|
||||
)
|
||||
|
||||
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"),
|
||||
"uuid": result.get("uuid"),
|
||||
"order_id": result.get("order_id"),
|
||||
"payer_amount": result.get("payer_amount"),
|
||||
"payer_currency": result.get("payer_currency"),
|
||||
"discount_percent": result.get("discount_percent"),
|
||||
"exchange_rate": result.get("exchange_rate"),
|
||||
"requested_at": _current_request_timestamp(),
|
||||
},
|
||||
)
|
||||
|
||||
if method == "tribute":
|
||||
if not settings.TRIBUTE_ENABLED:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
|
||||
@@ -1162,6 +1223,8 @@ async def _resolve_payment_status_entry(
|
||||
return await _resolve_pal24_payment_status(payment_service, db, user, query)
|
||||
if method == "cryptobot":
|
||||
return await _resolve_cryptobot_payment_status(db, user, query)
|
||||
if method == "heleket":
|
||||
return await _resolve_heleket_payment_status(db, user, query)
|
||||
if method == "stars":
|
||||
return await _resolve_stars_payment_status(db, user, query)
|
||||
if method == "tribute":
|
||||
@@ -1551,6 +1614,74 @@ async def _resolve_cryptobot_payment_status(
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_heleket_payment_status(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
query: MiniAppPaymentStatusQuery,
|
||||
) -> MiniAppPaymentStatusResult:
|
||||
from app.database.crud.heleket import (
|
||||
get_heleket_payment_by_id,
|
||||
get_heleket_payment_by_order_id,
|
||||
get_heleket_payment_by_uuid,
|
||||
)
|
||||
|
||||
payment = None
|
||||
if query.local_payment_id:
|
||||
payment = await get_heleket_payment_by_id(db, query.local_payment_id)
|
||||
if not payment and query.payment_id:
|
||||
payment = await get_heleket_payment_by_uuid(db, query.payment_id)
|
||||
if not payment and query.invoice_id:
|
||||
payment = await get_heleket_payment_by_uuid(db, query.invoice_id)
|
||||
if not payment and query.bill_id:
|
||||
payment = await get_heleket_payment_by_order_id(db, query.bill_id)
|
||||
|
||||
if not payment or payment.user_id != user.id:
|
||||
return MiniAppPaymentStatusResult(
|
||||
method="heleket",
|
||||
status="pending",
|
||||
is_paid=False,
|
||||
amount_kopeks=query.amount_kopeks,
|
||||
message="Payment not found",
|
||||
extra={
|
||||
"local_payment_id": query.local_payment_id,
|
||||
"uuid": query.payment_id or query.invoice_id,
|
||||
"order_id": query.bill_id,
|
||||
"payload": query.payload,
|
||||
"started_at": query.started_at,
|
||||
},
|
||||
)
|
||||
|
||||
status_raw = payment.status
|
||||
is_paid = bool(payment.is_paid)
|
||||
status = _classify_status(status_raw, is_paid)
|
||||
completed_at = payment.paid_at or payment.updated_at or payment.created_at
|
||||
|
||||
return MiniAppPaymentStatusResult(
|
||||
method="heleket",
|
||||
status=status,
|
||||
is_paid=status == "paid",
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
currency=payment.currency,
|
||||
completed_at=completed_at,
|
||||
transaction_id=payment.transaction_id,
|
||||
external_id=payment.uuid,
|
||||
message=None,
|
||||
extra={
|
||||
"status": payment.status,
|
||||
"local_payment_id": payment.id,
|
||||
"uuid": payment.uuid,
|
||||
"order_id": payment.order_id,
|
||||
"payer_amount": payment.payer_amount,
|
||||
"payer_currency": payment.payer_currency,
|
||||
"discount_percent": payment.discount_percent,
|
||||
"exchange_rate": payment.exchange_rate,
|
||||
"payment_url": payment.payment_url,
|
||||
"payload": query.payload,
|
||||
"started_at": query.started_at,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_stars_payment_status(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
|
||||
@@ -361,6 +361,7 @@ class MiniAppPaymentMethodsRequest(BaseModel):
|
||||
|
||||
class MiniAppPaymentMethod(BaseModel):
|
||||
id: str
|
||||
name: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
requires_amount: bool = False
|
||||
currency: str = "RUB"
|
||||
|
||||
@@ -75,6 +75,10 @@ services:
|
||||
- "${WEB_API_PORT:-8080}:8080"
|
||||
- "${TRIBUTE_WEBHOOK_PORT:-8081}:8081"
|
||||
- "${YOOKASSA_WEBHOOK_PORT:-8082}:8082"
|
||||
- "${CRYPTOBOT_WEBHOOK_PORT:-8083}:8083"
|
||||
- "${PAL24_WEBHOOK_PORT:-8084}:8084"
|
||||
- "${WATA_WEBHOOK_PORT:-8085}:8085"
|
||||
- "${HELEKET_WEBHOOK_PORT:-8086}:8086"
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
|
||||
+5
-1
@@ -35,7 +35,7 @@ services:
|
||||
retries: 3
|
||||
|
||||
bot:
|
||||
image: fr1ngg/remnawave-bedolaga-telegram-bot:latest
|
||||
build: .
|
||||
container_name: remnawave_bot
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
@@ -75,6 +75,10 @@ services:
|
||||
- "${WEB_API_PORT:-8080}:8080"
|
||||
- "${TRIBUTE_WEBHOOK_PORT:-8081}:8081"
|
||||
- "${YOOKASSA_WEBHOOK_PORT:-8082}:8082"
|
||||
- "${CRYPTOBOT_WEBHOOK_PORT:-8083}:8083"
|
||||
- "${PAL24_WEBHOOK_PORT:-8084}:8084"
|
||||
- "${WATA_WEBHOOK_PORT:-8085}:8085"
|
||||
- "${HELEKET_WEBHOOK_PORT:-8086}:8086"
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
|
||||
@@ -74,6 +74,9 @@
|
||||
- `app/database/crud/cryptobot.py` — Python-модуль
|
||||
Классы: нет
|
||||
Функции: нет
|
||||
- `app/database/crud/heleket.py` — Python-модуль
|
||||
Классы: нет
|
||||
Функции: нет
|
||||
- `app/database/crud/discount_offer.py` — Python-модуль
|
||||
Классы: нет
|
||||
Функции: нет
|
||||
@@ -158,6 +161,12 @@
|
||||
- `app/external/cryptobot.py` — Python-модуль
|
||||
Классы: `CryptoBotService` (2 методов)
|
||||
Функции: нет
|
||||
- `app/external/heleket.py` — Python-модуль
|
||||
Классы: `HeleketService` (3 методов)
|
||||
Функции: нет
|
||||
- `app/external/heleket_webhook.py` — Python-модуль
|
||||
Классы: `HeleketWebhookHandler` (3 методов)
|
||||
Функции: `create_heleket_app`, `start_heleket_webhook_server`
|
||||
- `app/external/pal24_client.py` — Async client for PayPalych (Pal24) API.
|
||||
Классы: `Pal24APIError` — Base error for Pal24 API operations., `Pal24Response` (2 методов) — Wrapper for Pal24 API responses., `Pal24Client` (5 методов) — Async client implementing PayPalych API methods.
|
||||
Функции: нет
|
||||
@@ -323,7 +332,7 @@
|
||||
Функции: `_t` — Helper for localized button labels with fallbacks., `get_admin_main_keyboard`, `get_admin_users_submenu_keyboard`, `get_admin_promo_submenu_keyboard`, `get_admin_communications_submenu_keyboard`, `get_admin_support_submenu_keyboard`, `get_admin_settings_submenu_keyboard`, `get_admin_system_submenu_keyboard`, `get_admin_reports_keyboard`, `get_admin_report_result_keyboard`, `get_admin_users_keyboard`, `get_admin_users_filters_keyboard`, `get_admin_subscriptions_keyboard`, `get_admin_promocodes_keyboard`, `get_admin_campaigns_keyboard`, `get_campaign_management_keyboard`, `get_campaign_edit_keyboard`, `get_campaign_bonus_type_keyboard`, `get_promocode_management_keyboard`, `get_admin_messages_keyboard`, `get_admin_monitoring_keyboard`, `get_admin_remnawave_keyboard`, `get_admin_statistics_keyboard`, `get_user_management_keyboard`, `get_user_promo_group_keyboard`, `get_confirmation_keyboard`, `get_promocode_type_keyboard`, `get_promocode_list_keyboard`, `get_broadcast_target_keyboard`, `get_custom_criteria_keyboard`, `get_broadcast_history_keyboard`, `get_sync_options_keyboard`, `get_sync_confirmation_keyboard`, `get_sync_result_keyboard`, `get_period_selection_keyboard`, `get_node_management_keyboard`, `get_squad_management_keyboard`, `get_squad_edit_keyboard`, `get_monitoring_keyboard`, `get_monitoring_logs_keyboard`, `get_monitoring_logs_navigation_keyboard`, `get_log_detail_keyboard`, `get_monitoring_clear_confirm_keyboard`, `get_monitoring_status_keyboard`, `get_monitoring_settings_keyboard`, `get_log_type_filter_keyboard`, `get_admin_servers_keyboard`, `get_server_edit_keyboard`, `get_admin_pagination_keyboard`, `get_maintenance_keyboard`, `get_sync_simplified_keyboard`, `get_welcome_text_keyboard`, `get_broadcast_button_config`, `get_broadcast_button_labels`, `get_message_buttons_selector_keyboard`, `get_broadcast_media_keyboard`, `get_media_confirm_keyboard`, `get_updated_message_buttons_selector_keyboard_with_media`
|
||||
- `app/keyboards/inline.py` — Python-модуль
|
||||
Классы: нет
|
||||
Функции: `_get_localized_value`, `_build_additional_buttons`, `get_rules_keyboard`, `get_channel_sub_keyboard`, `get_post_registration_keyboard`, `get_language_selection_keyboard`, `_build_text_main_menu_keyboard`, `get_main_menu_keyboard`, `get_info_menu_keyboard`, `get_happ_download_button_row`, `get_happ_cryptolink_keyboard`, `get_happ_download_platform_keyboard`, `get_happ_download_link_keyboard`, `get_back_keyboard`, `get_server_status_keyboard`, `get_insufficient_balance_keyboard`, `get_subscription_keyboard`, `get_payment_methods_keyboard_with_cart`, `get_subscription_confirm_keyboard_with_cart`, `get_insufficient_balance_keyboard_with_cart`, `get_trial_keyboard`, `get_subscription_period_keyboard`, `get_traffic_packages_keyboard`, `get_countries_keyboard`, `get_devices_keyboard`, `_get_device_declension`, `get_subscription_confirm_keyboard`, `get_balance_keyboard`, `get_payment_methods_keyboard`, `get_yookassa_payment_keyboard`, `get_autopay_notification_keyboard`, `get_subscription_expiring_keyboard`, `get_referral_keyboard`, `get_support_keyboard`, `get_pagination_keyboard`, `get_confirmation_keyboard`, `get_autopay_keyboard`, `get_autopay_days_keyboard`, `_get_days_suffix`, `get_extend_subscription_keyboard`, `get_add_traffic_keyboard`, `get_change_devices_keyboard`, `get_confirm_change_devices_keyboard`, `get_reset_traffic_confirm_keyboard`, `get_manage_countries_keyboard`, `get_device_selection_keyboard`, `get_connection_guide_keyboard`, `get_app_selection_keyboard`, `get_specific_app_keyboard`, `get_extend_subscription_keyboard_with_prices`, `get_cryptobot_payment_keyboard`, `get_devices_management_keyboard`, `get_updated_subscription_settings_keyboard`, `get_device_reset_confirm_keyboard`, `get_device_management_help_keyboard`, `get_ticket_cancel_keyboard`, `get_my_tickets_keyboard`, `get_ticket_view_keyboard`, `get_ticket_reply_cancel_keyboard`, `get_admin_tickets_keyboard`, `get_admin_ticket_view_keyboard`, `get_admin_ticket_reply_cancel_keyboard`
|
||||
Функции: `_get_localized_value`, `_build_additional_buttons`, `get_rules_keyboard`, `get_channel_sub_keyboard`, `get_post_registration_keyboard`, `get_language_selection_keyboard`, `_build_text_main_menu_keyboard`, `get_main_menu_keyboard`, `get_info_menu_keyboard`, `get_happ_download_button_row`, `get_happ_cryptolink_keyboard`, `get_happ_download_platform_keyboard`, `get_happ_download_link_keyboard`, `get_back_keyboard`, `get_server_status_keyboard`, `get_insufficient_balance_keyboard`, `get_subscription_keyboard`, `get_payment_methods_keyboard_with_cart`, `get_subscription_confirm_keyboard_with_cart`, `get_insufficient_balance_keyboard_with_cart`, `get_trial_keyboard`, `get_subscription_period_keyboard`, `get_traffic_packages_keyboard`, `get_countries_keyboard`, `get_devices_keyboard`, `_get_device_declension`, `get_subscription_confirm_keyboard`, `get_balance_keyboard`, `get_payment_methods_keyboard`, `get_yookassa_payment_keyboard`, `get_autopay_notification_keyboard`, `get_subscription_expiring_keyboard`, `get_referral_keyboard`, `get_support_keyboard`, `get_pagination_keyboard`, `get_confirmation_keyboard`, `get_autopay_keyboard`, `get_autopay_days_keyboard`, `_get_days_word`, `get_extend_subscription_keyboard`, `get_add_traffic_keyboard`, `get_change_devices_keyboard`, `get_confirm_change_devices_keyboard`, `get_reset_traffic_confirm_keyboard`, `get_manage_countries_keyboard`, `get_device_selection_keyboard`, `get_connection_guide_keyboard`, `get_app_selection_keyboard`, `get_specific_app_keyboard`, `get_extend_subscription_keyboard_with_prices`, `get_cryptobot_payment_keyboard`, `get_devices_management_keyboard`, `get_updated_subscription_settings_keyboard`, `get_device_reset_confirm_keyboard`, `get_device_management_help_keyboard`, `get_ticket_cancel_keyboard`, `get_my_tickets_keyboard`, `get_ticket_view_keyboard`, `get_ticket_reply_cancel_keyboard`, `get_admin_tickets_keyboard`, `get_admin_ticket_view_keyboard`, `get_admin_ticket_reply_cancel_keyboard`
|
||||
- `app/keyboards/reply.py` — Python-модуль
|
||||
Классы: нет
|
||||
Функции: `get_main_reply_keyboard`, `get_admin_reply_keyboard`, `get_cancel_keyboard`, `get_confirmation_reply_keyboard`, `get_skip_keyboard`, `remove_keyboard`, `get_contact_keyboard`, `get_location_keyboard`
|
||||
@@ -726,6 +735,9 @@
|
||||
- `tests/services/test_payment_service_cryptobot.py` — Тесты сценариев CryptoBot в PaymentService.
|
||||
Классы: `DummySession` (2 методов), `DummyLocalPayment` (1 методов), `StubCryptoBotService` (1 методов)
|
||||
Функции: `anyio_backend`, `_make_service`
|
||||
- `tests/services/test_payment_service_heleket.py` — Тесты сценариев Heleket в PaymentService.
|
||||
Классы: `DummySession` (2 методов), `DummyLocalPayment` (1 методов), `StubHeleketService` (1 методов)
|
||||
Функции: `anyio_backend`, `_make_service`
|
||||
- `tests/services/test_payment_service_mulenpay.py` — Тесты для сценариев MulenPay в PaymentService.
|
||||
Классы: `DummySession`, `DummyLocalPayment` (1 методов), `StubMulenPayService` (1 методов)
|
||||
Функции: `anyio_backend`, `_make_service`
|
||||
|
||||
-1241
File diff suppressed because it is too large
Load Diff
-1243
File diff suppressed because it is too large
Load Diff
@@ -15,8 +15,10 @@ from app.services.maintenance_service import maintenance_service
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.version_service import version_service
|
||||
from app.external.webhook_server import WebhookServer
|
||||
from app.external.heleket_webhook import start_heleket_webhook_server
|
||||
from app.external.yookassa_webhook import start_yookassa_webhook_server
|
||||
from app.external.pal24_webhook import start_pal24_webhook_server, Pal24WebhookServer
|
||||
from app.external.wata_webhook import start_wata_webhook_server
|
||||
from app.database.universal_migration import run_universal_migration
|
||||
from app.services.backup_service import backup_service
|
||||
from app.services.reporting_service import reporting_service
|
||||
@@ -72,6 +74,8 @@ async def main():
|
||||
|
||||
webhook_server = None
|
||||
yookassa_server_task = None
|
||||
wata_server_task = None
|
||||
heleket_server_task = None
|
||||
pal24_server: Pal24WebhookServer | None = None
|
||||
monitoring_task = None
|
||||
maintenance_task = None
|
||||
@@ -246,7 +250,7 @@ async def main():
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
enabled_services.append("Tribute")
|
||||
if settings.is_mulenpay_enabled():
|
||||
enabled_services.append("Mulen Pay")
|
||||
enabled_services.append(settings.get_mulenpay_display_name())
|
||||
if settings.is_cryptobot_enabled():
|
||||
enabled_services.append("CryptoBot")
|
||||
|
||||
@@ -255,7 +259,9 @@ async def main():
|
||||
stage.log(f"Активированы: {', '.join(enabled_services)}")
|
||||
stage.success("Webhook сервера запущены")
|
||||
else:
|
||||
stage.skip("Tribute, Mulen Pay и CryptoBot отключены")
|
||||
stage.skip(
|
||||
f"Tribute, {settings.get_mulenpay_display_name()} и CryptoBot отключены"
|
||||
)
|
||||
|
||||
async with timeline.stage(
|
||||
"YooKassa webhook",
|
||||
@@ -285,6 +291,36 @@ async def main():
|
||||
else:
|
||||
stage.skip("PayPalych отключен настройками")
|
||||
|
||||
async with timeline.stage(
|
||||
"WATA webhook",
|
||||
"💳",
|
||||
success_message="WATA webhook запущен",
|
||||
) as stage:
|
||||
if settings.is_wata_enabled():
|
||||
wata_server_task = asyncio.create_task(
|
||||
start_wata_webhook_server(payment_service)
|
||||
)
|
||||
stage.log(
|
||||
f"Endpoint: {settings.WEBHOOK_URL}:{settings.WATA_WEBHOOK_PORT}{settings.WATA_WEBHOOK_PATH}"
|
||||
)
|
||||
else:
|
||||
stage.skip("WATA отключен настройками")
|
||||
|
||||
async with timeline.stage(
|
||||
"Heleket webhook",
|
||||
"🪙",
|
||||
success_message="Heleket webhook запущен",
|
||||
) as stage:
|
||||
if settings.is_heleket_enabled():
|
||||
heleket_server_task = asyncio.create_task(
|
||||
start_heleket_webhook_server(payment_service)
|
||||
)
|
||||
stage.log(
|
||||
f"Endpoint: {settings.WEBHOOK_URL}:{settings.HELEKET_WEBHOOK_PORT}{settings.HELEKET_WEBHOOK_PATH}"
|
||||
)
|
||||
else:
|
||||
stage.skip("Heleket отключен настройками")
|
||||
|
||||
async with timeline.stage(
|
||||
"Служба мониторинга",
|
||||
"📈",
|
||||
@@ -355,7 +391,8 @@ async def main():
|
||||
)
|
||||
if settings.is_mulenpay_enabled():
|
||||
webhook_lines.append(
|
||||
f"Mulen Pay: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.MULENPAY_WEBHOOK_PATH}"
|
||||
f"{settings.get_mulenpay_display_name()}: "
|
||||
f"{settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.MULENPAY_WEBHOOK_PATH}"
|
||||
)
|
||||
if settings.is_cryptobot_enabled():
|
||||
webhook_lines.append(
|
||||
@@ -369,6 +406,10 @@ async def main():
|
||||
webhook_lines.append(
|
||||
f"PayPalych: {settings.WEBHOOK_URL}:{settings.PAL24_WEBHOOK_PORT}{settings.PAL24_WEBHOOK_PATH}"
|
||||
)
|
||||
if settings.is_wata_enabled():
|
||||
webhook_lines.append(
|
||||
f"WATA: {settings.WEBHOOK_URL}:{settings.WATA_WEBHOOK_PORT}{settings.WATA_WEBHOOK_PATH}"
|
||||
)
|
||||
|
||||
timeline.log_section(
|
||||
"Активные webhook endpoints",
|
||||
@@ -399,7 +440,31 @@ async def main():
|
||||
yookassa_server_task = asyncio.create_task(
|
||||
start_yookassa_webhook_server(payment_service)
|
||||
)
|
||||
|
||||
|
||||
if wata_server_task and wata_server_task.done():
|
||||
exception = wata_server_task.exception()
|
||||
if exception:
|
||||
logger.error(f"WATA webhook сервер завершился с ошибкой: {exception}")
|
||||
logger.info("🔄 Перезапуск WATA webhook сервера...")
|
||||
if settings.is_wata_enabled():
|
||||
wata_server_task = asyncio.create_task(
|
||||
start_wata_webhook_server(payment_service)
|
||||
)
|
||||
else:
|
||||
wata_server_task = None
|
||||
|
||||
if heleket_server_task and heleket_server_task.done():
|
||||
exception = heleket_server_task.exception()
|
||||
if exception:
|
||||
logger.error(f"Heleket webhook сервер завершился с ошибкой: {exception}")
|
||||
logger.info("🔄 Перезапуск Heleket webhook сервера...")
|
||||
if settings.is_heleket_enabled():
|
||||
heleket_server_task = asyncio.create_task(
|
||||
start_heleket_webhook_server(payment_service)
|
||||
)
|
||||
else:
|
||||
heleket_server_task = None
|
||||
|
||||
if monitoring_task.done():
|
||||
exception = monitoring_task.exception()
|
||||
if exception:
|
||||
@@ -446,7 +511,23 @@ async def main():
|
||||
await yookassa_server_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
if wata_server_task and not wata_server_task.done():
|
||||
logger.info("ℹ️ Остановка WATA webhook сервера...")
|
||||
wata_server_task.cancel()
|
||||
try:
|
||||
await wata_server_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if heleket_server_task and not heleket_server_task.done():
|
||||
logger.info("ℹ️ Остановка Heleket webhook сервера...")
|
||||
heleket_server_task.cancel()
|
||||
try:
|
||||
await heleket_server_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if monitoring_task and not monitoring_task.done():
|
||||
logger.info("ℹ️ Остановка службы мониторинга...")
|
||||
monitoring_service.stop_monitoring()
|
||||
|
||||
+78
-7
@@ -13224,15 +13224,45 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
const typeRaw = String(userData.subscription_type || '').toLowerCase();
|
||||
if (typeRaw === 'trial') {
|
||||
const missing = coerceBoolean(
|
||||
userData.subscription_missing
|
||||
?? userData.subscriptionMissing
|
||||
?? userData.user.subscription_missing
|
||||
?? userData.user.subscriptionMissing,
|
||||
false,
|
||||
);
|
||||
if (missing) {
|
||||
return false;
|
||||
}
|
||||
if (typeRaw === 'paid') {
|
||||
return true;
|
||||
|
||||
const expiredFlag = coerceBoolean(
|
||||
userData.subscription_expired
|
||||
?? userData.subscriptionExpired
|
||||
?? userData.user.subscription_expired
|
||||
?? userData.user.subscriptionExpired,
|
||||
false,
|
||||
);
|
||||
if (expiredFlag) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (userData.user.has_active_subscription === false) {
|
||||
const expirationCandidates = [
|
||||
userData.subscription_expires_at,
|
||||
userData.subscriptionExpiresAt,
|
||||
userData.user.subscription_expires_at,
|
||||
userData.user.subscriptionExpiresAt,
|
||||
userData.user.expires_at,
|
||||
userData.user.expiresAt,
|
||||
userData.expires_at,
|
||||
userData.expiresAt,
|
||||
];
|
||||
const isExpiredByDate = expirationCandidates.some(candidate => {
|
||||
const expiresAt = parseDate(candidate);
|
||||
return expiresAt instanceof Date
|
||||
&& !Number.isNaN(expiresAt.getTime())
|
||||
&& expiresAt.getTime() <= Date.now();
|
||||
});
|
||||
if (isExpiredByDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -13241,11 +13271,52 @@
|
||||
|| userData.user.subscription_status
|
||||
|| ''
|
||||
).toLowerCase();
|
||||
if (['trial', 'expired', 'disabled'].includes(statusRaw)) {
|
||||
|
||||
if (statusRaw) {
|
||||
const inactiveStatuses = [
|
||||
'expired',
|
||||
'disabled',
|
||||
'missing',
|
||||
'inactive',
|
||||
'cancelled',
|
||||
'canceled',
|
||||
'ended',
|
||||
'paused',
|
||||
'stopped',
|
||||
];
|
||||
if (inactiveStatuses.includes(statusRaw)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (['trial', 'trialing'].includes(statusRaw)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (['active', 'paid'].includes(statusRaw)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const hasActiveFlag = userData.user.has_active_subscription;
|
||||
if (hasActiveFlag === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
const typeRaw = String(
|
||||
userData.subscription_type
|
||||
?? userData.user.subscription_type
|
||||
?? ''
|
||||
).toLowerCase();
|
||||
|
||||
if (['trial', 'expired', 'inactive', 'disabled', 'none', 'missing', 'ended'].includes(typeRaw)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasActiveFlag === true && typeRaw !== 'trial') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return typeRaw === 'paid';
|
||||
}
|
||||
|
||||
function normalizeServerEntry(entry) {
|
||||
|
||||
Vendored
+90
@@ -0,0 +1,90 @@
|
||||
"""Unit tests for the WATA webhook handler."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
from app.external.wata_webhook import WataWebhookHandler # noqa: E402
|
||||
|
||||
|
||||
class DummyPaymentService:
|
||||
async def process_wata_webhook(self, *args, **kwargs): # pragma: no cover - not used in tests
|
||||
return True
|
||||
|
||||
|
||||
class StubPublicKeyProvider:
|
||||
def __init__(self, public_key_pem: Optional[str]) -> None:
|
||||
self.public_key_pem = public_key_pem
|
||||
|
||||
async def get_public_key(self) -> Optional[str]:
|
||||
return self.public_key_pem
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_verify_signature_success() -> None:
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
public_key = private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
).decode("utf-8")
|
||||
|
||||
payload = "{\"status\": \"Paid\"}"
|
||||
signature = base64.b64encode(
|
||||
private_key.sign(
|
||||
payload.encode("utf-8"),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA512(),
|
||||
)
|
||||
).decode("utf-8")
|
||||
|
||||
handler = WataWebhookHandler(
|
||||
DummyPaymentService(),
|
||||
public_key_provider=StubPublicKeyProvider(public_key),
|
||||
)
|
||||
|
||||
assert await handler._verify_signature(payload, signature) is True
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_verify_signature_fails_with_invalid_signature() -> None:
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
public_key = private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
).decode("utf-8")
|
||||
|
||||
payload = "{\"status\": \"Paid\"}"
|
||||
bad_signature = base64.b64encode(b"not-a-signature").decode("utf-8")
|
||||
|
||||
handler = WataWebhookHandler(
|
||||
DummyPaymentService(),
|
||||
public_key_provider=StubPublicKeyProvider(public_key),
|
||||
)
|
||||
|
||||
assert await handler._verify_signature(payload, bad_signature) is False
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_verify_signature_fails_without_public_key() -> None:
|
||||
handler = WataWebhookHandler(
|
||||
DummyPaymentService(),
|
||||
public_key_provider=StubPublicKeyProvider(None),
|
||||
)
|
||||
|
||||
assert await handler._verify_signature("{}", "signature") is False
|
||||
@@ -70,13 +70,15 @@ async def test_create_bill_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
ttl_seconds=600,
|
||||
custom_payload={"extra": "value"},
|
||||
payer_email="user@example.com",
|
||||
payment_method="CARD",
|
||||
payment_method="BANK_CARD",
|
||||
)
|
||||
|
||||
assert result["bill_id"] == "BILL42"
|
||||
assert client.calls and client.calls[0]["amount"] == Decimal("500.00")
|
||||
assert client.calls[0]["shop_id"] == "shop42"
|
||||
assert client.calls[0]["description"] == "Пополнение"
|
||||
assert client.calls[0]["custom"] == json.dumps({"extra": "value"}, ensure_ascii=False, separators=(",", ":"))
|
||||
assert client.calls[0]["payment_method"] == "BANK_CARD"
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
from app.services.payment_service import PaymentService # noqa: E402
|
||||
from app.database.crud import heleket as heleket_crud # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
|
||||
|
||||
class DummySession:
|
||||
def __init__(self) -> None:
|
||||
self.added_objects: list[Any] = []
|
||||
|
||||
async def commit(self) -> None: # pragma: no cover - behaviour is mocked in tests
|
||||
return None
|
||||
|
||||
async def refresh(self, obj: Any) -> None: # pragma: no cover
|
||||
return None
|
||||
|
||||
def add(self, obj: Any) -> None: # pragma: no cover
|
||||
self.added_objects.append(obj)
|
||||
|
||||
|
||||
class DummyLocalPayment:
|
||||
def __init__(self, payment_id: int = 123) -> None:
|
||||
self.id = payment_id
|
||||
self.created_at = datetime(2024, 1, 1, 12, 0, 0)
|
||||
|
||||
|
||||
class StubHeleketService:
|
||||
def __init__(self, response: Optional[Dict[str, Any]]) -> None:
|
||||
self.response = response
|
||||
self.calls: list[Dict[str, Any]] = []
|
||||
|
||||
async def create_payment(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
self.calls.append(payload)
|
||||
return self.response
|
||||
|
||||
|
||||
def _make_service(stub: Optional[StubHeleketService]) -> PaymentService:
|
||||
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
|
||||
service.bot = None
|
||||
service.heleket_service = stub
|
||||
service.yookassa_service = None
|
||||
service.stars_service = None
|
||||
service.cryptobot_service = None
|
||||
service.mulenpay_service = None
|
||||
service.pal24_service = None
|
||||
service.wata_service = None
|
||||
return service
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_create_heleket_payment_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
response = {
|
||||
"state": 0,
|
||||
"result": {
|
||||
"uuid": "heleket-uuid",
|
||||
"order_id": "order-123",
|
||||
"url": "https://heleket/pay",
|
||||
"status": "check",
|
||||
"payer_amount": "12.50",
|
||||
"payer_currency": "USDT",
|
||||
"discount_percent": -5,
|
||||
"payer_amount_exchange_rate": "0.0125",
|
||||
"expired_at": 1750000000,
|
||||
},
|
||||
}
|
||||
stub = StubHeleketService(response)
|
||||
service = _make_service(stub)
|
||||
db = DummySession()
|
||||
|
||||
captured_args: Dict[str, Any] = {}
|
||||
|
||||
async def fake_create_heleket_payment(**kwargs: Any) -> DummyLocalPayment:
|
||||
captured_args.update(kwargs)
|
||||
return DummyLocalPayment(payment_id=555)
|
||||
|
||||
monkeypatch.setattr(
|
||||
heleket_crud,
|
||||
"create_heleket_payment",
|
||||
fake_create_heleket_payment,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
result = await service.create_heleket_payment(
|
||||
db=db,
|
||||
user_id=42,
|
||||
amount_kopeks=15000,
|
||||
description="Пополнение",
|
||||
language="ru",
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result["local_payment_id"] == 555
|
||||
assert result["uuid"] == "heleket-uuid"
|
||||
assert result["order_id"] == "order-123"
|
||||
assert result["payment_url"] == "https://heleket/pay"
|
||||
assert stub.calls and stub.calls[0]["amount"] == "150.00"
|
||||
assert captured_args["uuid"] == "heleket-uuid"
|
||||
assert captured_args["user_id"] == 42
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_create_heleket_payment_returns_none_without_service() -> None:
|
||||
service = _make_service(None)
|
||||
db = DummySession()
|
||||
|
||||
result = await service.create_heleket_payment(
|
||||
db=db,
|
||||
user_id=1,
|
||||
amount_kopeks=10000,
|
||||
description="Пополнение",
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_create_heleket_payment_handles_empty_response(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
stub = StubHeleketService(response=None)
|
||||
service = _make_service(stub)
|
||||
db = DummySession()
|
||||
|
||||
called = False
|
||||
|
||||
async def fake_create_heleket_payment(**kwargs: Any) -> DummyLocalPayment:
|
||||
nonlocal called
|
||||
called = True
|
||||
return DummyLocalPayment()
|
||||
|
||||
monkeypatch.setattr(
|
||||
heleket_crud,
|
||||
"create_heleket_payment",
|
||||
fake_create_heleket_payment,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
result = await service.create_heleket_payment(
|
||||
db=db,
|
||||
user_id=1,
|
||||
amount_kopeks=20000,
|
||||
description="Пополнение",
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert called is False
|
||||
@@ -11,6 +11,7 @@ if str(ROOT_DIR) not in sys.path:
|
||||
|
||||
from app.services.payment import ( # noqa: E402
|
||||
CryptoBotPaymentMixin,
|
||||
HeleketPaymentMixin,
|
||||
MulenPayPaymentMixin,
|
||||
Pal24PaymentMixin,
|
||||
PaymentCommonMixin,
|
||||
@@ -30,6 +31,7 @@ def test_payment_service_mro_contains_all_mixins() -> None:
|
||||
YooKassaPaymentMixin,
|
||||
TributePaymentMixin,
|
||||
CryptoBotPaymentMixin,
|
||||
HeleketPaymentMixin,
|
||||
MulenPayPaymentMixin,
|
||||
Pal24PaymentMixin,
|
||||
WataPaymentMixin,
|
||||
@@ -46,6 +48,7 @@ def test_payment_service_mro_contains_all_mixins() -> None:
|
||||
"create_yookassa_payment",
|
||||
"create_tribute_payment",
|
||||
"create_cryptobot_payment",
|
||||
"create_heleket_payment",
|
||||
"create_mulenpay_payment",
|
||||
"create_pal24_payment",
|
||||
"create_wata_payment",
|
||||
|
||||
@@ -50,6 +50,7 @@ def _make_service(stub: Optional[StubMulenPayService]) -> PaymentService:
|
||||
service.yookassa_service = None
|
||||
service.stars_service = None
|
||||
service.cryptobot_service = None
|
||||
service.heleket_service = None
|
||||
return service
|
||||
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ def _make_service(stub: Optional[StubPal24Service]) -> PaymentService:
|
||||
service.yookassa_service = None
|
||||
service.cryptobot_service = None
|
||||
service.stars_service = None
|
||||
service.heleket_service = None
|
||||
return service
|
||||
|
||||
|
||||
@@ -101,13 +102,46 @@ async def test_create_pal24_payment_success(monkeypatch: pytest.MonkeyPatch) ->
|
||||
assert result is not None
|
||||
assert result["local_payment_id"] == 321
|
||||
assert result["bill_id"] == "BILL-1"
|
||||
assert result["payment_method"] == "CARD"
|
||||
assert result["payment_method"] == "card"
|
||||
assert result["link_url"] == "https://pal24/sbp"
|
||||
assert result["card_url"] == "https://pal24/card"
|
||||
assert stub.calls and stub.calls[0]["payment_method"] == "BANK_CARD"
|
||||
assert stub.calls and stub.calls[0]["amount_kopeks"] == 50000
|
||||
assert "links" in captured_args["metadata"]
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_create_pal24_payment_default_method(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
stub = StubPal24Service()
|
||||
service = _make_service(stub)
|
||||
db = DummySession()
|
||||
|
||||
async def fake_create_pal24_payment(*args: Any, **kwargs: Any) -> DummyLocalPayment:
|
||||
return DummyLocalPayment(payment_id=111)
|
||||
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"create_pal24_payment",
|
||||
fake_create_pal24_payment,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(settings, "PAL24_MIN_AMOUNT_KOPEKS", 1000, raising=False)
|
||||
monkeypatch.setattr(settings, "PAL24_MAX_AMOUNT_KOPEKS", 1_000_000, raising=False)
|
||||
|
||||
result = await service.create_pal24_payment(
|
||||
db=db,
|
||||
user_id=42,
|
||||
amount_kopeks=10_000,
|
||||
description="Пополнение",
|
||||
language="ru",
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert stub.calls and stub.calls[0]["payment_method"] == "SBP"
|
||||
assert result["payment_method"] == "sbp"
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_create_pal24_payment_limits_and_configuration(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
stub = StubPal24Service()
|
||||
|
||||
@@ -28,6 +28,7 @@ def _make_service() -> PaymentService:
|
||||
service.pal24_service = None
|
||||
service.cryptobot_service = None
|
||||
service.stars_service = None
|
||||
service.heleket_service = None
|
||||
return service
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Any, Dict, Optional
|
||||
@@ -16,6 +16,7 @@ if str(ROOT_DIR) not in sys.path:
|
||||
import app.services.payment_service as payment_service_module # noqa: E402
|
||||
from app.config import settings # noqa: E402
|
||||
from app.services.payment_service import PaymentService # noqa: E402
|
||||
from app.services.wata_service import WataService # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -47,6 +48,23 @@ class StubWataService:
|
||||
return self.response
|
||||
|
||||
|
||||
class DummyWataPayment:
|
||||
def __init__(self) -> None:
|
||||
self.id = 1
|
||||
self.user_id = 42
|
||||
self.payment_link_id = "link-123"
|
||||
self.order_id = "order-123"
|
||||
self.amount_kopeks = 15_000
|
||||
self.currency = "RUB"
|
||||
self.description = "Пополнение"
|
||||
self.status = "Opened"
|
||||
self.is_paid = False
|
||||
self.metadata_json: Dict[str, Any] = {}
|
||||
self.transaction_id: Optional[int] = None
|
||||
self.callback_payload: Optional[Dict[str, Any]] = None
|
||||
self.terminal_public_id: Optional[str] = None
|
||||
|
||||
|
||||
def _make_service(stub: Optional[StubWataService]) -> PaymentService:
|
||||
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
|
||||
service.bot = None
|
||||
@@ -56,9 +74,26 @@ def _make_service(stub: Optional[StubWataService]) -> PaymentService:
|
||||
service.yookassa_service = None
|
||||
service.stars_service = None
|
||||
service.cryptobot_service = None
|
||||
service.heleket_service = None
|
||||
return service
|
||||
|
||||
|
||||
def test_wata_service_format_datetime_accepts_naive_utc() -> None:
|
||||
value = datetime(2024, 5, 20, 12, 30, 0)
|
||||
formatted = WataService._format_datetime(value)
|
||||
assert formatted == "2024-05-20T12:30:00Z"
|
||||
|
||||
|
||||
def test_wata_service_parse_datetime_returns_naive_utc() -> None:
|
||||
parsed = WataService._parse_datetime("2024-05-20T12:30:00Z")
|
||||
assert parsed == datetime(2024, 5, 20, 12, 30, 0)
|
||||
assert parsed.tzinfo is None
|
||||
|
||||
parsed_with_offset = WataService._parse_datetime("2024-05-20T15:30:00+03:00")
|
||||
assert parsed_with_offset == datetime(2024, 5, 20, 12, 30, 0)
|
||||
assert parsed_with_offset.tzinfo is None
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_create_wata_payment_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
response = {
|
||||
@@ -142,3 +177,197 @@ async def test_create_wata_payment_returns_none_without_service() -> None:
|
||||
description="Пополнение",
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_wata_webhook_updates_status(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
service = _make_service(None)
|
||||
db = DummySession()
|
||||
payment = DummyWataPayment()
|
||||
update_kwargs: Dict[str, Any] = {}
|
||||
link_lookup_called = False
|
||||
|
||||
async def fake_get_by_order_id(db_arg: Any, order_id: str) -> DummyWataPayment:
|
||||
assert db_arg is db
|
||||
assert order_id == payment.order_id
|
||||
return payment
|
||||
|
||||
async def fake_get_by_link_id(*_: Any, **__: Any) -> Optional[DummyWataPayment]:
|
||||
nonlocal link_lookup_called
|
||||
link_lookup_called = True
|
||||
return None
|
||||
|
||||
async def fake_update_status(
|
||||
db_arg: Any,
|
||||
*,
|
||||
payment: DummyWataPayment,
|
||||
**kwargs: Any,
|
||||
) -> DummyWataPayment:
|
||||
assert db_arg is db
|
||||
update_kwargs.update(kwargs)
|
||||
if "status" in kwargs:
|
||||
payment.status = kwargs["status"]
|
||||
if "is_paid" in kwargs:
|
||||
payment.is_paid = kwargs["is_paid"]
|
||||
if "metadata" in kwargs:
|
||||
payment.metadata_json = kwargs["metadata"]
|
||||
if "callback_payload" in kwargs:
|
||||
payment.callback_payload = kwargs["callback_payload"]
|
||||
if "terminal_public_id" in kwargs:
|
||||
payment.terminal_public_id = kwargs["terminal_public_id"]
|
||||
return payment
|
||||
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"get_wata_payment_by_order_id",
|
||||
fake_get_by_order_id,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"get_wata_payment_by_link_id",
|
||||
fake_get_by_link_id,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"update_wata_payment_status",
|
||||
fake_update_status,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
payload = {
|
||||
"orderId": payment.order_id,
|
||||
"transactionStatus": "Declined",
|
||||
"terminalPublicId": "terminal-001",
|
||||
}
|
||||
|
||||
processed = await service.process_wata_webhook(db, payload)
|
||||
|
||||
assert processed is True
|
||||
assert link_lookup_called is False
|
||||
assert payment.status == "Declined"
|
||||
assert payment.is_paid is False
|
||||
assert payment.metadata_json.get("last_webhook") == payload
|
||||
assert payment.callback_payload == payload
|
||||
assert payment.terminal_public_id == "terminal-001"
|
||||
assert update_kwargs["status"] == "Declined"
|
||||
assert update_kwargs["is_paid"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_wata_webhook_finalizes_paid(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
service = _make_service(None)
|
||||
db = DummySession()
|
||||
payment = DummyWataPayment()
|
||||
finalize_called = False
|
||||
|
||||
async def fake_get_by_order_id(*_: Any, **__: Any) -> DummyWataPayment:
|
||||
return payment
|
||||
|
||||
async def fake_update_status(
|
||||
db_arg: Any,
|
||||
*,
|
||||
payment: DummyWataPayment,
|
||||
**kwargs: Any,
|
||||
) -> DummyWataPayment:
|
||||
if "metadata" in kwargs:
|
||||
payment.metadata_json = kwargs["metadata"]
|
||||
if "callback_payload" in kwargs:
|
||||
payment.callback_payload = kwargs["callback_payload"]
|
||||
if "status" in kwargs:
|
||||
payment.status = kwargs["status"]
|
||||
return payment
|
||||
|
||||
async def fake_finalize(
|
||||
db_arg: Any,
|
||||
payment_arg: DummyWataPayment,
|
||||
payload_arg: Dict[str, Any],
|
||||
) -> DummyWataPayment:
|
||||
nonlocal finalize_called
|
||||
finalize_called = True
|
||||
payment_arg.is_paid = True
|
||||
return payment_arg
|
||||
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"get_wata_payment_by_order_id",
|
||||
fake_get_by_order_id,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"get_wata_payment_by_link_id",
|
||||
lambda *args, **kwargs: None,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"update_wata_payment_status",
|
||||
fake_update_status,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_finalize_wata_payment",
|
||||
fake_finalize,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
payload = {
|
||||
"orderId": payment.order_id,
|
||||
"transactionStatus": "Paid",
|
||||
"transactionId": "tx-001",
|
||||
}
|
||||
|
||||
processed = await service.process_wata_webhook(db, payload)
|
||||
|
||||
assert processed is True
|
||||
assert finalize_called is True
|
||||
assert payment.is_paid is True
|
||||
assert payment.metadata_json.get("last_webhook") == payload
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_wata_webhook_returns_false_when_payment_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = _make_service(None)
|
||||
db = DummySession()
|
||||
|
||||
async def fake_get_by_order_id(*_: Any, **__: Any) -> None:
|
||||
return None
|
||||
|
||||
async def fake_get_by_link_id(*_: Any, **__: Any) -> None:
|
||||
return None
|
||||
|
||||
async def fail_update(*_: Any, **__: Any) -> None:
|
||||
pytest.fail("update_wata_payment_status should not be called")
|
||||
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"get_wata_payment_by_order_id",
|
||||
fake_get_by_order_id,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"get_wata_payment_by_link_id",
|
||||
fake_get_by_link_id,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"update_wata_payment_status",
|
||||
fail_update,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
payload = {
|
||||
"orderId": "missing-order",
|
||||
"transactionStatus": "Paid",
|
||||
}
|
||||
|
||||
processed = await service.process_wata_webhook(db, payload)
|
||||
|
||||
assert processed is False
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace, ModuleType
|
||||
from typing import Any, Dict
|
||||
@@ -16,6 +18,7 @@ if str(ROOT_DIR) not in sys.path:
|
||||
|
||||
import app.services.payment_service as payment_service_module # noqa: E402
|
||||
from app.services.payment_service import PaymentService # noqa: E402
|
||||
from app.database.models import PaymentMethod # noqa: E402
|
||||
from app.config import settings # noqa: E402
|
||||
|
||||
|
||||
@@ -54,6 +57,7 @@ def _make_service(bot: DummyBot) -> PaymentService:
|
||||
service.mulenpay_service = None
|
||||
service.pal24_service = None
|
||||
service.cryptobot_service = None
|
||||
service.heleket_service = None
|
||||
return service
|
||||
|
||||
|
||||
@@ -63,7 +67,10 @@ def anyio_backend() -> str:
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_mulenpay_callback_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@pytest.mark.parametrize("status_field", ["payment_status", "status", "paymentStatus"])
|
||||
async def test_process_mulenpay_callback_success(
|
||||
monkeypatch: pytest.MonkeyPatch, status_field: str
|
||||
) -> None:
|
||||
bot = DummyBot()
|
||||
service = _make_service(bot)
|
||||
fake_session = FakeSession()
|
||||
@@ -142,10 +149,10 @@ async def test_process_mulenpay_callback_success(monkeypatch: pytest.MonkeyPatch
|
||||
|
||||
payload = {
|
||||
"uuid": "mulen_uuid",
|
||||
"payment_status": "success",
|
||||
"id": 123,
|
||||
"amount": "50.00",
|
||||
}
|
||||
payload[status_field] = "success"
|
||||
|
||||
result = await service.process_mulenpay_callback(fake_session, payload)
|
||||
|
||||
@@ -252,6 +259,146 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch
|
||||
assert admin_calls
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_heleket_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
bot = DummyBot()
|
||||
service = _make_service(bot)
|
||||
fake_session = FakeSession()
|
||||
|
||||
payment = SimpleNamespace(
|
||||
uuid="heleket-uuid",
|
||||
order_id="heleket-order",
|
||||
user_id=77,
|
||||
amount="150.00",
|
||||
amount_float=150.0,
|
||||
amount_kopeks=15000,
|
||||
status="check",
|
||||
payer_amount=None,
|
||||
payer_currency=None,
|
||||
exchange_rate=None,
|
||||
discount_percent=None,
|
||||
payment_url=None,
|
||||
transaction_id=None,
|
||||
)
|
||||
|
||||
async def fake_get_by_uuid(db, uuid):
|
||||
return payment if uuid == payment.uuid else None
|
||||
|
||||
async def fake_get_by_order(db, order_id):
|
||||
return payment if order_id == payment.order_id else None
|
||||
|
||||
async def fake_update(
|
||||
db,
|
||||
uuid,
|
||||
*,
|
||||
status=None,
|
||||
payer_amount=None,
|
||||
payer_currency=None,
|
||||
exchange_rate=None,
|
||||
discount_percent=None,
|
||||
paid_at=None,
|
||||
payment_url=None,
|
||||
metadata=None,
|
||||
):
|
||||
if status is not None:
|
||||
payment.status = status
|
||||
if payer_amount is not None:
|
||||
payment.payer_amount = payer_amount
|
||||
if payer_currency is not None:
|
||||
payment.payer_currency = payer_currency
|
||||
if exchange_rate is not None:
|
||||
payment.exchange_rate = exchange_rate
|
||||
if discount_percent is not None:
|
||||
payment.discount_percent = discount_percent
|
||||
if payment_url is not None:
|
||||
payment.payment_url = payment_url
|
||||
payment.paid_at = paid_at
|
||||
if metadata:
|
||||
payment.metadata_json = metadata
|
||||
return payment
|
||||
|
||||
async def fake_link(db, uuid, transaction_id):
|
||||
payment.transaction_id = transaction_id
|
||||
return payment
|
||||
|
||||
heleket_module = ModuleType("app.database.crud.heleket")
|
||||
heleket_module.get_heleket_payment_by_uuid = fake_get_by_uuid
|
||||
heleket_module.get_heleket_payment_by_order_id = fake_get_by_order
|
||||
heleket_module.update_heleket_payment = fake_update
|
||||
heleket_module.link_heleket_payment_to_transaction = fake_link
|
||||
monkeypatch.setitem(sys.modules, "app.database.crud.heleket", heleket_module)
|
||||
|
||||
transactions: list[Dict[str, Any]] = []
|
||||
|
||||
async def fake_create_transaction(db, **kwargs):
|
||||
transactions.append(kwargs)
|
||||
return SimpleNamespace(id=321, **kwargs)
|
||||
|
||||
monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction)
|
||||
|
||||
user = SimpleNamespace(
|
||||
id=77,
|
||||
telegram_id=7700,
|
||||
balance_kopeks=0,
|
||||
has_made_first_topup=False,
|
||||
promo_group=None,
|
||||
subscription=None,
|
||||
referred_by_id=None,
|
||||
referrer=None,
|
||||
language="ru",
|
||||
)
|
||||
|
||||
async def fake_get_user(db, user_id):
|
||||
return user if user_id == user.id else None
|
||||
|
||||
monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user)
|
||||
monkeypatch.setattr("app.services.payment.heleket.format_referrer_info", lambda u: "")
|
||||
|
||||
monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}₽", raising=False)
|
||||
|
||||
referral_stub = SimpleNamespace(process_referral_topup=AsyncMock())
|
||||
monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_stub)
|
||||
|
||||
admin_calls: list[Any] = []
|
||||
|
||||
class DummyAdminService:
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
|
||||
async def send_balance_topup_notification(self, *args, **kwargs):
|
||||
admin_calls.append((args, kwargs))
|
||||
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"app.services.admin_notification_service",
|
||||
SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)),
|
||||
)
|
||||
|
||||
service.build_topup_success_keyboard = AsyncMock(return_value=None)
|
||||
|
||||
payload = {
|
||||
"uuid": "heleket-uuid",
|
||||
"status": "paid",
|
||||
"payer_amount": "2.50",
|
||||
"payer_currency": "USDT",
|
||||
"discount_percent": -5,
|
||||
"payer_amount_exchange_rate": "0.0166",
|
||||
"paid_at": "2024-01-02T12:00:00Z",
|
||||
"url": "https://pay.example",
|
||||
}
|
||||
|
||||
result = await service.process_heleket_webhook(fake_session, payload)
|
||||
|
||||
assert result is True
|
||||
assert transactions and transactions[0]["payment_method"] == PaymentMethod.HELEKET
|
||||
assert payment.transaction_id == 321
|
||||
assert user.balance_kopeks == 15000
|
||||
assert user.has_made_first_topup is True
|
||||
assert fake_session.commits >= 1
|
||||
assert bot.sent_messages
|
||||
assert admin_calls
|
||||
referral_stub.process_referral_topup.assert_awaited_once()
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
bot = DummyBot()
|
||||
@@ -372,6 +519,9 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -
|
||||
transaction_id=None,
|
||||
is_paid=False,
|
||||
status="NEW",
|
||||
metadata_json={},
|
||||
payment_method=None,
|
||||
paid_at=None,
|
||||
)
|
||||
|
||||
async def fake_get_by_order(db, order_id):
|
||||
@@ -419,6 +569,7 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -
|
||||
subscription=None,
|
||||
referred_by_id=None,
|
||||
referrer=None,
|
||||
language="ru",
|
||||
)
|
||||
|
||||
async def fake_get_user(db, user_id):
|
||||
@@ -439,7 +590,35 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -
|
||||
async def send_balance_topup_notification(self, *args, **kwargs):
|
||||
admin_calls.append((args, kwargs))
|
||||
|
||||
monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminServicePal(bot)))
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"app.services.admin_notification_service",
|
||||
SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminServicePal(bot)),
|
||||
)
|
||||
|
||||
user_cart_stub = SimpleNamespace(
|
||||
user_cart_service=SimpleNamespace(has_user_cart=AsyncMock(return_value=True))
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "app.services.user_cart_service", user_cart_stub)
|
||||
|
||||
class DummyTypes:
|
||||
class InlineKeyboardMarkup:
|
||||
def __init__(self, inline_keyboard=None, **kwargs):
|
||||
self.inline_keyboard = inline_keyboard or []
|
||||
self.kwargs = kwargs
|
||||
|
||||
class InlineKeyboardButton:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
monkeypatch.setitem(sys.modules, "aiogram", SimpleNamespace(types=DummyTypes))
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"app.localization.texts",
|
||||
SimpleNamespace(get_texts=lambda language: SimpleNamespace(t=lambda key, default=None: default)),
|
||||
)
|
||||
|
||||
service.build_topup_success_keyboard = AsyncMock(return_value=None)
|
||||
|
||||
payload = {
|
||||
@@ -455,9 +634,148 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -
|
||||
assert payment.transaction_id == 654
|
||||
assert user.balance_kopeks == 5000
|
||||
assert bot.sent_messages
|
||||
saved_cart_message = bot.sent_messages[-1]
|
||||
reply_markup = saved_cart_message["kwargs"].get("reply_markup")
|
||||
assert reply_markup is not None
|
||||
assert reply_markup.inline_keyboard[0][0].kwargs["callback_data"] == "subscription_resume_checkout"
|
||||
assert admin_calls
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
bot = DummyBot()
|
||||
service = _make_service(bot)
|
||||
|
||||
class DummyPal24Service:
|
||||
BILL_SUCCESS_STATES = {"SUCCESS", "OVERPAID"}
|
||||
BILL_FAILED_STATES = {"FAIL"}
|
||||
BILL_PENDING_STATES = {"NEW", "PROCESS", "UNDERPAID"}
|
||||
|
||||
async def get_bill_status(self, bill_id: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"status": "SUCCESS",
|
||||
"bill": {
|
||||
"status": "SUCCESS",
|
||||
"payments": [
|
||||
{
|
||||
"id": "trs-auto-1",
|
||||
"status": "SUCCESS",
|
||||
"method": "SBP",
|
||||
"balance_amount": "50.00",
|
||||
"balance_currency": "RUB",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
service.pal24_service = DummyPal24Service()
|
||||
|
||||
fake_session = FakeSession()
|
||||
payment = SimpleNamespace(
|
||||
id=77,
|
||||
bill_id="BILL-AUTO",
|
||||
order_id="order-auto",
|
||||
amount_kopeks=5000,
|
||||
user_id=91,
|
||||
transaction_id=None,
|
||||
is_paid=False,
|
||||
status="NEW",
|
||||
metadata_json={},
|
||||
payment_id=None,
|
||||
payment_method=None,
|
||||
paid_at=None,
|
||||
)
|
||||
|
||||
async def fake_get_payment_by_id(db, local_id):
|
||||
return payment
|
||||
|
||||
async def fake_update_payment(db, payment_obj, **kwargs):
|
||||
for key, value in kwargs.items():
|
||||
setattr(payment, key, value)
|
||||
return payment
|
||||
|
||||
async def fake_link_payment(db, payment_obj, transaction_id):
|
||||
payment.transaction_id = transaction_id
|
||||
return payment
|
||||
|
||||
monkeypatch.setattr(payment_service_module, "get_pal24_payment_by_id", fake_get_payment_by_id)
|
||||
monkeypatch.setattr(payment_service_module, "update_pal24_payment_status", fake_update_payment)
|
||||
monkeypatch.setattr(payment_service_module, "link_pal24_payment_to_transaction", fake_link_payment)
|
||||
|
||||
transactions: list[Dict[str, Any]] = []
|
||||
|
||||
async def fake_create_transaction(db, **kwargs):
|
||||
transactions.append(kwargs)
|
||||
payment.transaction_id = 999
|
||||
return SimpleNamespace(id=999, **kwargs)
|
||||
|
||||
monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction)
|
||||
|
||||
user = SimpleNamespace(
|
||||
id=91,
|
||||
telegram_id=9100,
|
||||
balance_kopeks=0,
|
||||
has_made_first_topup=False,
|
||||
promo_group=None,
|
||||
subscription=None,
|
||||
referred_by_id=None,
|
||||
referrer=None,
|
||||
language="ru",
|
||||
)
|
||||
|
||||
async def fake_get_user(db, user_id):
|
||||
return user
|
||||
|
||||
monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user)
|
||||
monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}₽", raising=False)
|
||||
|
||||
referral_stub = SimpleNamespace(process_referral_topup=AsyncMock())
|
||||
monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_stub)
|
||||
|
||||
admin_notifications: list[Any] = []
|
||||
|
||||
class DummyAdminService:
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
|
||||
async def send_balance_topup_notification(self, *args, **kwargs):
|
||||
admin_notifications.append((args, kwargs))
|
||||
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"app.services.admin_notification_service",
|
||||
SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)),
|
||||
)
|
||||
|
||||
user_cart_stub = SimpleNamespace(
|
||||
user_cart_service=SimpleNamespace(has_user_cart=AsyncMock(return_value=False))
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "app.services.user_cart_service", user_cart_stub)
|
||||
|
||||
class DummyTypes:
|
||||
class InlineKeyboardMarkup:
|
||||
def __init__(self, inline_keyboard=None, **kwargs):
|
||||
self.inline_keyboard = inline_keyboard or []
|
||||
self.kwargs = kwargs
|
||||
|
||||
class InlineKeyboardButton:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
monkeypatch.setitem(sys.modules, "aiogram", SimpleNamespace(types=DummyTypes))
|
||||
|
||||
service.build_topup_success_keyboard = AsyncMock(return_value=None)
|
||||
|
||||
result = await service.get_pal24_payment_status(fake_session, payment.id)
|
||||
|
||||
assert result is not None
|
||||
assert payment.transaction_id == 999
|
||||
assert user.balance_kopeks == 5000
|
||||
assert bot.sent_messages
|
||||
assert admin_notifications
|
||||
assert transactions and transactions[0]["user_id"] == 91
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_pal24_postback_payment_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
bot = DummyBot()
|
||||
|
||||
@@ -68,6 +68,7 @@ def _make_service(yookassa_service: Optional[StubYooKassaService]) -> PaymentSer
|
||||
service.pal24_service = None
|
||||
service.mulenpay_service = None
|
||||
service.cryptobot_service = None
|
||||
service.heleket_service = None
|
||||
return service
|
||||
|
||||
|
||||
|
||||
@@ -86,8 +86,8 @@ async def test_create_payment_link_pal24_uses_selected_option(monkeypatch):
|
||||
|
||||
assert response.payment_url == 'https://card'
|
||||
assert response.extra['selected_option'] == 'card'
|
||||
assert response.extra['payment_method'] == 'CARD'
|
||||
assert captured_calls and captured_calls[0]['payment_method'] == 'CARD'
|
||||
assert response.extra['payment_method'] == 'card'
|
||||
assert captured_calls and captured_calls[0]['payment_method'] == 'card'
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
@@ -265,7 +265,7 @@ async def test_resolve_pal24_status_includes_identifiers(monkeypatch):
|
||||
transaction_id=777,
|
||||
bill_id='BILL99',
|
||||
order_id='ORD99',
|
||||
payment_method='SBP',
|
||||
payment_method='sbp',
|
||||
)
|
||||
|
||||
class StubPal24Service:
|
||||
@@ -297,7 +297,7 @@ async def test_resolve_pal24_status_includes_identifiers(monkeypatch):
|
||||
assert result.extra['local_payment_id'] == 321
|
||||
assert result.extra['bill_id'] == 'BILL99'
|
||||
assert result.extra['order_id'] == 'ORD99'
|
||||
assert result.extra['payment_method'] == 'SBP'
|
||||
assert result.extra['payment_method'] == 'sbp'
|
||||
assert result.extra['payload'] == 'pal24_payload'
|
||||
assert result.extra['started_at'] == '2024-01-01T00:00:00Z'
|
||||
assert result.extra['remote_status'] == 'PAID'
|
||||
|
||||
Reference in New Issue
Block a user