Compare commits
160 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4441cf79ff | |||
| 7428bd5cf7 | |||
| 76c524e980 | |||
| 5a9fa167c2 | |||
| c61e9c78ba | |||
| e5e3a9e4b5 | |||
| a1f47e13e3 | |||
| 7ff0ed1248 | |||
| 5eef24fb91 | |||
| 05ba26d3ac | |||
| ef68ce19ce | |||
| 2fa84f6dc9 | |||
| 32409d7471 | |||
| 270282a340 | |||
| 87eaa06652 | |||
| 845f03e873 | |||
| 97eabb0604 | |||
| f0d714d787 | |||
| 27a34d6e9f | |||
| a4e97e0317 | |||
| 97dcd0b7cc | |||
| 9b57904cf4 | |||
| 6ebdae0cae | |||
| b9d4a8f8e9 | |||
| 707c3c5daf | |||
| d075abfeea | |||
| d3399c569c | |||
| e36e3f7f8f | |||
| 583e01f5e6 | |||
| 784b5e0af6 | |||
| cc42d33fbe | |||
| 9c3d8fd701 | |||
| d281eeb69d | |||
| b510ffb7d8 | |||
| 6855b7765c | |||
| 9ed1129268 | |||
| 0c4ba3c0d0 | |||
| 70337ac310 | |||
| e981082713 | |||
| 104d98542e | |||
| fe63ef5fc5 | |||
| b1ff964dd6 | |||
| aba1d5dbe5 | |||
| 0af7e4ac68 | |||
| 7d9a572e6e | |||
| de6557d7e7 | |||
| a052c7bb80 | |||
| 8c47f3c49b | |||
| 323ad903e7 | |||
| 8105fe285d | |||
| 82a19025f1 | |||
| e592b3e5c4 | |||
| 2da12354a1 | |||
| fc65b62d65 | |||
| 28603a87e1 | |||
| 8956aafc9a | |||
| 2ee9b263b8 | |||
| 3a5d838be3 | |||
| bf25fcfda6 | |||
| ecdf2fcae4 | |||
| 6fec14de4b | |||
| 7b4cbb27b7 | |||
| d7d7795257 | |||
| 93554c7034 | |||
| fbe7febdde | |||
| f091b3ea5e | |||
| aae469853e | |||
| dac68ccd8c | |||
| fb140e82f6 | |||
| 8cac442de1 | |||
| 8d8a465c43 | |||
| 51f2c660e8 | |||
| 39ecca306d | |||
| 7896a24e13 | |||
| c21325f260 | |||
| 18df7395a2 | |||
| f139251333 | |||
| 369e384b7d | |||
| 008f62e02b | |||
| 6e8981a245 | |||
| bb17cf7361 | |||
| d13c6767e6 | |||
| bf2ee37f33 | |||
| b82929ac3d | |||
| 9b0ea9bf2a | |||
| 5bde7196dc | |||
| 71c3e7fe0d | |||
| 201543c263 | |||
| 5ce7417c80 | |||
| e98564b5ab | |||
| 37dd48cc20 | |||
| 9dff6e8045 | |||
| 572094050e | |||
| 2b41337bbf | |||
| ca94c402bd | |||
| 6fdc46c3c6 | |||
| 34fc568f97 | |||
| 38664ff853 | |||
| e44284d4ba | |||
| d54dc822cc | |||
| dbe9c2d5e9 | |||
| 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 | |||
| 7be8757964 |
@@ -261,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.3-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.5-$(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.3-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.5-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.3-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.5-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.3-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.5-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.5.3-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.5-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.5.3-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.5-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.5.3"
|
||||
ARG VERSION="v2.5.5"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
### ⚡ **Полная автоматизация VPN бизнеса**
|
||||
- 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня
|
||||
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + YooKassa (СБП + карты) + MulenPay + PayPalych (СБП + карты) + WATA
|
||||
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + Heleket + YooKassa (СБП + карты) + MulenPay + PayPalych (СБП + карты) + WATA
|
||||
- 🔄 **Автоматизация 99%** - от регистрации до продления подписок
|
||||
- - 📱 **MiniApp лк** - личный кабинет с возможностью покупки/продления подписки
|
||||
- 📊 **Детальная аналитика** - полная картина вашего бизнеса
|
||||
@@ -605,6 +605,13 @@ CRYPTOBOT_ENABLED=false
|
||||
CRYPTOBOT_API_TOKEN=
|
||||
CRYPTOBOT_WEBHOOK_PATH=/cryptobot-webhook
|
||||
|
||||
# Heleket
|
||||
HELEKET_ENABLED=false
|
||||
HELEKET_MERCHANT_ID=
|
||||
HELEKET_API_KEY=
|
||||
HELEKET_WEBHOOK_PATH=/heleket-webhook
|
||||
HELEKET_WEBHOOK_PORT=8086
|
||||
|
||||
# MulenPay
|
||||
MULENPAY_ENABLED=false
|
||||
MULENPAY_API_KEY=
|
||||
@@ -710,6 +717,7 @@ LOG_FILE=logs/bot.log
|
||||
- 💳 Tribute
|
||||
- 💳 YooKassa (СБП + банковские карты)
|
||||
- 💰 CryptoBot (USDT, TON, BTC, ETH и др.)
|
||||
- 🪙 Heleket (криптовалюта с наценкой)
|
||||
- 💳 MulenPay (СБП)
|
||||
- 🏦 PayPalych/Pal24 (СБП + карты)
|
||||
- 💳 **WATA**
|
||||
@@ -907,6 +915,7 @@ LOG_FILE=logs/bot.log
|
||||
- **Tribute**: Настрой webhook на `https://your-domain.com/tribute-webhook`
|
||||
- **YooKassa**: Настрой webhook на `https://your-domain.com/yookassa-webhook`
|
||||
- **CryptoBot**: Настрой webhook на `https://your-domain.com/cryptobot-webhook`
|
||||
- **Heleket**: Настрой webhook на `https://your-domain.com/heleket-webhook`
|
||||
- **MulenPay**: Настрой webhook на `https://your-domain.com/mulenpay-webhook`
|
||||
- **PayPalych**: Укажи Result URL `https://your-domain.com/pal24-webhook` в кабинете Pal24
|
||||
- **WATA**: Настрой webhook на `https://your-domain.com/wata-webhook`
|
||||
@@ -1310,7 +1319,7 @@ docker stats
|
||||
|
||||
| Метрика | Значение |
|
||||
|---------|----------|
|
||||
| 💳 **Платёжных систем** | 7 (Stars, YooKassa, Tribute, CryptoBot, MulenPay, Pal24, WATA) |
|
||||
| 💳 **Платёжных систем** | 8 (Stars, YooKassa, Tribute, CryptoBot, Heleket, MulenPay, Pal24, WATA) |
|
||||
| 🌍 **Языков интерфейса** | 2 (RU, EN) с возможностью расширения |
|
||||
| 📊 **Периодов подписки** | 6 (от 14 дней до года) |
|
||||
| 🎁 **Типов промо-акций** | 5 (коды, группы, предложения, скидки, кампании) |
|
||||
|
||||
+14
-2
@@ -16,9 +16,18 @@ from app.services.maintenance_service import maintenance_service
|
||||
from app.utils.cache import cache
|
||||
|
||||
from app.handlers import (
|
||||
start, menu, subscription, balance, promocode,
|
||||
referral, support, server_status, common, tickets
|
||||
start,
|
||||
menu,
|
||||
subscription,
|
||||
balance,
|
||||
promocode,
|
||||
referral,
|
||||
support,
|
||||
server_status,
|
||||
common,
|
||||
tickets,
|
||||
)
|
||||
from app.handlers import polls as user_polls
|
||||
from app.handlers import simple_subscription
|
||||
from app.handlers.admin import (
|
||||
main as admin_main,
|
||||
@@ -31,6 +40,7 @@ from app.handlers.admin import (
|
||||
rules as admin_rules,
|
||||
remnawave as admin_remnawave,
|
||||
statistics as admin_statistics,
|
||||
polls as admin_polls,
|
||||
servers as admin_servers,
|
||||
maintenance as admin_maintenance,
|
||||
promo_groups as admin_promo_groups,
|
||||
@@ -145,6 +155,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_rules.register_handlers(dp)
|
||||
admin_remnawave.register_handlers(dp)
|
||||
admin_statistics.register_handlers(dp)
|
||||
admin_polls.register_handlers(dp)
|
||||
admin_promo_groups.register_handlers(dp)
|
||||
admin_campaigns.register_handlers(dp)
|
||||
admin_promo_offers.register_handlers(dp)
|
||||
@@ -163,6 +174,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_faq.register_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
user_polls.register_handlers(dp)
|
||||
simple_subscription.register_simple_subscription_handlers(dp)
|
||||
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
|
||||
logger.info("⚡ Зарегистрированы обработчики простой покупки")
|
||||
|
||||
@@ -206,6 +206,21 @@ 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
|
||||
@@ -786,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
|
||||
@@ -834,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
|
||||
@@ -0,0 +1,265 @@
|
||||
import logging
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
from sqlalchemy import and_, delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
Poll,
|
||||
PollAnswer,
|
||||
PollOption,
|
||||
PollQuestion,
|
||||
PollResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_poll(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
title: str,
|
||||
description: str | None,
|
||||
reward_enabled: bool,
|
||||
reward_amount_kopeks: int,
|
||||
created_by: int | None,
|
||||
questions: Sequence[dict[str, Iterable[str]]],
|
||||
) -> Poll:
|
||||
poll = Poll(
|
||||
title=title,
|
||||
description=description,
|
||||
reward_enabled=reward_enabled,
|
||||
reward_amount_kopeks=reward_amount_kopeks if reward_enabled else 0,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(poll)
|
||||
await db.flush()
|
||||
|
||||
for order, question_data in enumerate(questions, start=1):
|
||||
question_text = question_data.get("text", "").strip()
|
||||
if not question_text:
|
||||
continue
|
||||
|
||||
question = PollQuestion(
|
||||
poll_id=poll.id,
|
||||
text=question_text,
|
||||
order=order,
|
||||
)
|
||||
db.add(question)
|
||||
await db.flush()
|
||||
|
||||
for option_order, option_text in enumerate(question_data.get("options", []), start=1):
|
||||
option_text = option_text.strip()
|
||||
if not option_text:
|
||||
continue
|
||||
option = PollOption(
|
||||
question_id=question.id,
|
||||
text=option_text,
|
||||
order=option_order,
|
||||
)
|
||||
db.add(option)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(
|
||||
poll,
|
||||
attribute_names=["questions"],
|
||||
)
|
||||
return poll
|
||||
|
||||
|
||||
async def list_polls(db: AsyncSession) -> list[Poll]:
|
||||
result = await db.execute(
|
||||
select(Poll)
|
||||
.options(
|
||||
selectinload(Poll.questions).options(selectinload(PollQuestion.options))
|
||||
)
|
||||
.order_by(Poll.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_poll_by_id(db: AsyncSession, poll_id: int) -> Poll | None:
|
||||
result = await db.execute(
|
||||
select(Poll)
|
||||
.options(
|
||||
selectinload(Poll.questions).options(selectinload(PollQuestion.options)),
|
||||
selectinload(Poll.responses),
|
||||
)
|
||||
.where(Poll.id == poll_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def delete_poll(db: AsyncSession, poll_id: int) -> bool:
|
||||
poll = await db.get(Poll, poll_id)
|
||||
if not poll:
|
||||
return False
|
||||
|
||||
await db.delete(poll)
|
||||
await db.commit()
|
||||
logger.info("🗑️ Удалён опрос %s", poll_id)
|
||||
return True
|
||||
|
||||
|
||||
async def create_poll_response(
|
||||
db: AsyncSession,
|
||||
poll_id: int,
|
||||
user_id: int,
|
||||
) -> PollResponse:
|
||||
result = await db.execute(
|
||||
select(PollResponse)
|
||||
.where(
|
||||
and_(
|
||||
PollResponse.poll_id == poll_id,
|
||||
PollResponse.user_id == user_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
response = result.scalar_one_or_none()
|
||||
if response:
|
||||
return response
|
||||
|
||||
response = PollResponse(
|
||||
poll_id=poll_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
db.add(response)
|
||||
await db.commit()
|
||||
await db.refresh(response)
|
||||
return response
|
||||
|
||||
|
||||
async def get_poll_response_by_id(
|
||||
db: AsyncSession,
|
||||
response_id: int,
|
||||
) -> PollResponse | None:
|
||||
result = await db.execute(
|
||||
select(PollResponse)
|
||||
.options(
|
||||
selectinload(PollResponse.poll)
|
||||
.options(selectinload(Poll.questions).options(selectinload(PollQuestion.options))),
|
||||
selectinload(PollResponse.answers),
|
||||
selectinload(PollResponse.user),
|
||||
)
|
||||
.where(PollResponse.id == response_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def record_poll_answer(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
response_id: int,
|
||||
question_id: int,
|
||||
option_id: int,
|
||||
) -> PollAnswer:
|
||||
result = await db.execute(
|
||||
select(PollAnswer)
|
||||
.where(
|
||||
and_(
|
||||
PollAnswer.response_id == response_id,
|
||||
PollAnswer.question_id == question_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
answer = result.scalar_one_or_none()
|
||||
if answer:
|
||||
answer.option_id = option_id
|
||||
await db.commit()
|
||||
await db.refresh(answer)
|
||||
return answer
|
||||
|
||||
answer = PollAnswer(
|
||||
response_id=response_id,
|
||||
question_id=question_id,
|
||||
option_id=option_id,
|
||||
)
|
||||
db.add(answer)
|
||||
await db.commit()
|
||||
await db.refresh(answer)
|
||||
return answer
|
||||
|
||||
|
||||
async def reset_poll_answers(db: AsyncSession, response_id: int) -> None:
|
||||
await db.execute(
|
||||
delete(PollAnswer).where(PollAnswer.response_id == response_id)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_poll_statistics(db: AsyncSession, poll_id: int) -> dict:
|
||||
totals_result = await db.execute(
|
||||
select(
|
||||
func.count(PollResponse.id),
|
||||
func.count(PollResponse.completed_at),
|
||||
func.coalesce(func.sum(PollResponse.reward_amount_kopeks), 0),
|
||||
).where(PollResponse.poll_id == poll_id)
|
||||
)
|
||||
total_responses, completed_responses, reward_sum = totals_result.one()
|
||||
|
||||
option_counts_result = await db.execute(
|
||||
select(
|
||||
PollQuestion.id,
|
||||
PollQuestion.text,
|
||||
PollQuestion.order,
|
||||
PollOption.id,
|
||||
PollOption.text,
|
||||
PollOption.order,
|
||||
func.count(PollAnswer.id),
|
||||
)
|
||||
.join(PollOption, PollOption.question_id == PollQuestion.id)
|
||||
.outerjoin(
|
||||
PollAnswer,
|
||||
and_(
|
||||
PollAnswer.question_id == PollQuestion.id,
|
||||
PollAnswer.option_id == PollOption.id,
|
||||
),
|
||||
)
|
||||
.where(PollQuestion.poll_id == poll_id)
|
||||
.group_by(
|
||||
PollQuestion.id,
|
||||
PollQuestion.text,
|
||||
PollQuestion.order,
|
||||
PollOption.id,
|
||||
PollOption.text,
|
||||
PollOption.order,
|
||||
)
|
||||
.order_by(PollQuestion.order.asc(), PollOption.order.asc())
|
||||
)
|
||||
|
||||
questions_map: dict[int, dict] = {}
|
||||
for (
|
||||
question_id,
|
||||
question_text,
|
||||
question_order,
|
||||
option_id,
|
||||
option_text,
|
||||
option_order,
|
||||
answer_count,
|
||||
) in option_counts_result:
|
||||
question_entry = questions_map.setdefault(
|
||||
question_id,
|
||||
{
|
||||
"id": question_id,
|
||||
"text": question_text,
|
||||
"order": question_order,
|
||||
"options": [],
|
||||
},
|
||||
)
|
||||
question_entry["options"].append(
|
||||
{
|
||||
"id": option_id,
|
||||
"text": option_text,
|
||||
"count": answer_count,
|
||||
}
|
||||
)
|
||||
|
||||
questions = sorted(questions_map.values(), key=lambda item: item["order"])
|
||||
|
||||
return {
|
||||
"total_responses": total_responses,
|
||||
"completed_responses": completed_responses,
|
||||
"reward_sum_kopeks": reward_sum,
|
||||
"questions": questions,
|
||||
}
|
||||
@@ -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]:
|
||||
|
||||
@@ -1309,12 +1309,10 @@ async def activate_pending_subscription(
|
||||
|
||||
# Если указан период, обновляем дату окончания
|
||||
if period_days is not None:
|
||||
if pending_subscription.end_date <= current_time:
|
||||
# Если текущая дата окончания уже прошла, устанавливаем новую
|
||||
pending_subscription.end_date = current_time + timedelta(days=period_days)
|
||||
else:
|
||||
# Если дата окончания в будущем, продляем её
|
||||
pending_subscription.end_date = pending_subscription.end_date + timedelta(days=period_days)
|
||||
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:
|
||||
|
||||
@@ -219,7 +219,8 @@ async def add_user_balance(
|
||||
amount_kopeks: int,
|
||||
description: str = "Пополнение баланса",
|
||||
create_transaction: bool = True,
|
||||
bot = None
|
||||
transaction_type: TransactionType = TransactionType.DEPOSIT,
|
||||
bot = None
|
||||
) -> bool:
|
||||
try:
|
||||
old_balance = user.balance_kopeks
|
||||
@@ -228,12 +229,11 @@ async def add_user_balance(
|
||||
|
||||
if create_transaction:
|
||||
from app.database.crud.transaction import create_transaction as create_trans
|
||||
from app.database.models import TransactionType
|
||||
|
||||
|
||||
await create_trans(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
type=transaction_type,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description
|
||||
)
|
||||
@@ -253,9 +253,10 @@ async def add_user_balance(
|
||||
|
||||
async def add_user_balance_by_id(
|
||||
db: AsyncSession,
|
||||
telegram_id: int,
|
||||
telegram_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str = "Пополнение баланса"
|
||||
description: str = "Пополнение баланса",
|
||||
transaction_type: TransactionType = TransactionType.DEPOSIT,
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
@@ -263,7 +264,13 @@ async def add_user_balance_by_id(
|
||||
logger.error(f"Пользователь с telegram_id {telegram_id} не найден")
|
||||
return False
|
||||
|
||||
return await add_user_balance(db, user, amount_kopeks, description)
|
||||
return await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
amount_kopeks,
|
||||
description,
|
||||
transaction_type=transaction_type,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка пополнения баланса пользователя {telegram_id}: {e}")
|
||||
|
||||
+177
-8
@@ -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 (
|
||||
@@ -58,11 +58,12 @@ class SubscriptionStatus(Enum):
|
||||
|
||||
|
||||
class TransactionType(Enum):
|
||||
DEPOSIT = "deposit"
|
||||
WITHDRAWAL = "withdrawal"
|
||||
SUBSCRIPTION_PAYMENT = "subscription_payment"
|
||||
REFUND = "refund"
|
||||
REFERRAL_REWARD = "referral_reward"
|
||||
DEPOSIT = "deposit"
|
||||
WITHDRAWAL = "withdrawal"
|
||||
SUBSCRIPTION_PAYMENT = "subscription_payment"
|
||||
REFUND = "refund"
|
||||
REFERRAL_REWARD = "referral_reward"
|
||||
POLL_REWARD = "poll_reward"
|
||||
|
||||
|
||||
class PromoCodeType(Enum):
|
||||
@@ -76,6 +77,7 @@ class PaymentMethod(Enum):
|
||||
TRIBUTE = "tribute"
|
||||
YOOKASSA = "yookassa"
|
||||
CRYPTOBOT = "cryptobot"
|
||||
HELEKET = "heleket"
|
||||
MULENPAY = "mulenpay"
|
||||
PAL24 = "pal24"
|
||||
WATA = "wata"
|
||||
@@ -190,6 +192,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"
|
||||
|
||||
@@ -463,6 +531,7 @@ class User(Base):
|
||||
has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
promo_group = relationship("PromoGroup", back_populates="users")
|
||||
poll_responses = relationship("PollResponse", back_populates="user")
|
||||
|
||||
@property
|
||||
def balance_rubles(self) -> float:
|
||||
@@ -994,9 +1063,9 @@ class PromoOfferLog(Base):
|
||||
|
||||
class BroadcastHistory(Base):
|
||||
__tablename__ = "broadcast_history"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
target_type = Column(String(100), nullable=False)
|
||||
target_type = Column(String(100), nullable=False)
|
||||
message_text = Column(Text, nullable=False)
|
||||
has_media = Column(Boolean, default=False)
|
||||
media_type = Column(String(20), nullable=True)
|
||||
@@ -1012,6 +1081,106 @@ class BroadcastHistory(Base):
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
admin = relationship("User", back_populates="broadcasts")
|
||||
|
||||
|
||||
class Poll(Base):
|
||||
__tablename__ = "polls"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
reward_enabled = Column(Boolean, nullable=False, default=False)
|
||||
reward_amount_kopeks = Column(Integer, nullable=False, default=0)
|
||||
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
creator = relationship("User", backref="created_polls", foreign_keys=[created_by])
|
||||
questions = relationship(
|
||||
"PollQuestion",
|
||||
back_populates="poll",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="PollQuestion.order",
|
||||
)
|
||||
responses = relationship(
|
||||
"PollResponse",
|
||||
back_populates="poll",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class PollQuestion(Base):
|
||||
__tablename__ = "poll_questions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
text = Column(Text, nullable=False)
|
||||
order = Column(Integer, nullable=False, default=0)
|
||||
|
||||
poll = relationship("Poll", back_populates="questions")
|
||||
options = relationship(
|
||||
"PollOption",
|
||||
back_populates="question",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="PollOption.order",
|
||||
)
|
||||
answers = relationship("PollAnswer", back_populates="question")
|
||||
|
||||
|
||||
class PollOption(Base):
|
||||
__tablename__ = "poll_options"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
text = Column(Text, nullable=False)
|
||||
order = Column(Integer, nullable=False, default=0)
|
||||
|
||||
question = relationship("PollQuestion", back_populates="options")
|
||||
answers = relationship("PollAnswer", back_populates="option")
|
||||
|
||||
|
||||
class PollResponse(Base):
|
||||
__tablename__ = "poll_responses"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
sent_at = Column(DateTime, default=func.now(), nullable=False)
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
reward_given = Column(Boolean, nullable=False, default=False)
|
||||
reward_amount_kopeks = Column(Integer, nullable=False, default=0)
|
||||
|
||||
poll = relationship("Poll", back_populates="responses")
|
||||
user = relationship("User", back_populates="poll_responses")
|
||||
answers = relationship(
|
||||
"PollAnswer",
|
||||
back_populates="response",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("poll_id", "user_id", name="uq_poll_user"),
|
||||
)
|
||||
|
||||
|
||||
class PollAnswer(Base):
|
||||
__tablename__ = "poll_answers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
response_id = Column(Integer, ForeignKey("poll_responses.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
option_id = Column(Integer, ForeignKey("poll_options.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
created_at = Column(DateTime, default=func.now(), nullable=False)
|
||||
|
||||
response = relationship("PollResponse", back_populates="answers")
|
||||
question = relationship("PollQuestion", back_populates="answers")
|
||||
option = relationship("PollOption", back_populates="answers")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("response_id", "question_id", name="uq_poll_answer_unique"),
|
||||
)
|
||||
|
||||
|
||||
class ServerSquad(Base):
|
||||
__tablename__ = "server_squads"
|
||||
|
||||
|
||||
@@ -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,6 +3541,13 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей CryptoBot payments")
|
||||
|
||||
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()
|
||||
@@ -3695,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 добавлены")
|
||||
@@ -3712,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,
|
||||
@@ -3745,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')
|
||||
@@ -3797,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 сервер корректно остановлен")
|
||||
@@ -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": "🎁 Пробный период",
|
||||
@@ -1417,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(
|
||||
@@ -1645,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(
|
||||
@@ -2519,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:"),
|
||||
@@ -2557,4 +2801,3 @@ def register_handlers(dp: Dispatcher) -> None:
|
||||
handle_import_message,
|
||||
BotConfigStates.waiting_for_import_file,
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,258 @@
|
||||
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
|
||||
|
||||
language = getattr(payment.user, "language", None) or settings.DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
if payment.is_paid:
|
||||
message = texts.t("HELEKET_PAYMENT_ALREADY_PAID", "✅ Платёж уже зачислен")
|
||||
await callback.answer(message, show_alert=True)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(callback.bot)
|
||||
updated_payment = await payment_service.sync_heleket_payment_status(
|
||||
db,
|
||||
local_payment_id=local_payment_id,
|
||||
)
|
||||
|
||||
if updated_payment:
|
||||
payment = updated_payment
|
||||
|
||||
if payment.is_paid:
|
||||
message = texts.t("HELEKET_PAYMENT_SUCCESS", "✅ Платёж зачислен на баланс")
|
||||
await callback.answer(message, show_alert=True)
|
||||
return
|
||||
|
||||
status_normalized = (payment.status or "").lower()
|
||||
status_messages = {
|
||||
"check": texts.t("HELEKET_STATUS_CHECK", "⏳ Ожидание оплаты"),
|
||||
"process": texts.t("HELEKET_STATUS_PROCESS", "⚙️ Платёж обрабатывается"),
|
||||
"confirm_check": texts.t("HELEKET_STATUS_CONFIRM_CHECK", "⛓ Ожидание подтверждений сети"),
|
||||
"wrong_amount": texts.t("HELEKET_STATUS_WRONG_AMOUNT", "❗️ Оплачена неверная сумма"),
|
||||
"wrong_amount_waiting": texts.t(
|
||||
"HELEKET_STATUS_WRONG_AMOUNT_WAITING",
|
||||
"❗️ Недостаточная сумма, ожидаем доплату",
|
||||
),
|
||||
"paid_over": texts.t("HELEKET_STATUS_PAID_OVER", "✅ Платёж зачислен (с переплатой)"),
|
||||
"paid": texts.t("HELEKET_STATUS_PAID", "✅ Платёж зачислен"),
|
||||
"cancel": texts.t("HELEKET_STATUS_CANCEL", "🚫 Платёж отменён"),
|
||||
"fail": texts.t("HELEKET_STATUS_FAIL", "❌ Ошибка при оплате"),
|
||||
"system_fail": texts.t("HELEKET_STATUS_SYSTEM_FAIL", "❌ Системная ошибка Heleket"),
|
||||
"refund_process": texts.t("HELEKET_STATUS_REFUND_PROCESS", "↩️ Возврат обрабатывается"),
|
||||
"refund_fail": texts.t("HELEKET_STATUS_REFUND_FAIL", "⚠️ Ошибка возврата"),
|
||||
"refund_paid": texts.t("HELEKET_STATUS_REFUND_PAID", "✅ Возврат выполнен"),
|
||||
"locked": texts.t("HELEKET_STATUS_LOCKED", "🔒 Средства заблокированы"),
|
||||
}
|
||||
|
||||
message = status_messages.get(status_normalized)
|
||||
if message is None:
|
||||
template = texts.t("HELEKET_STATUS_UNKNOWN", "ℹ️ Статус платежа: {status}")
|
||||
status_value = payment.status or status_normalized or "—"
|
||||
try:
|
||||
message = template.format(status=status_value)
|
||||
except Exception: # pragma: no cover - defensive formatting
|
||||
message = f"ℹ️ Статус платежа: {status_value}"
|
||||
|
||||
await callback.answer(message, 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("Неизвестный способ оплаты")
|
||||
|
||||
@@ -519,6 +524,14 @@ async def handle_quick_amount_selection(
|
||||
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
|
||||
|
||||
@@ -589,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(
|
||||
@@ -712,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,
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.poll import (
|
||||
get_poll_response_by_id,
|
||||
record_poll_answer,
|
||||
)
|
||||
from app.database.models import PollQuestion, User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.poll_service import get_next_question, get_question_option, reward_user_for_poll
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _delete_message_later(bot, chat_id: int, message_id: int, delay: int = 10) -> None:
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
await bot.delete_message(chat_id, message_id)
|
||||
except Exception as error: # pragma: no cover - cleanup best effort
|
||||
logger.debug("Не удалось удалить сообщение опроса %s: %s", message_id, error)
|
||||
|
||||
|
||||
async def _render_question_text(
|
||||
poll_title: str,
|
||||
question: PollQuestion,
|
||||
current_index: int,
|
||||
total: int,
|
||||
language: str,
|
||||
) -> str:
|
||||
texts = get_texts(language)
|
||||
header = texts.t("POLL_QUESTION_HEADER", "<b>Вопрос {current}/{total}</b>").format(
|
||||
current=current_index,
|
||||
total=total,
|
||||
)
|
||||
lines = [f"🗳️ <b>{poll_title}</b>", "", header, "", question.text]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def _update_poll_message(
|
||||
message: types.Message,
|
||||
text: str,
|
||||
*,
|
||||
reply_markup: types.InlineKeyboardMarkup | None = None,
|
||||
parse_mode: str | None = "HTML",
|
||||
) -> bool:
|
||||
try:
|
||||
await message.edit_text(
|
||||
text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
return True
|
||||
except TelegramBadRequest as error:
|
||||
error_text = str(error).lower()
|
||||
if "message is not modified" in error_text:
|
||||
logger.debug(
|
||||
"Опросное сообщение уже актуально, пропускаем обновление: %s",
|
||||
error,
|
||||
)
|
||||
return True
|
||||
|
||||
logger.warning(
|
||||
"Не удалось обновить сообщение опроса %s: %s",
|
||||
message.message_id,
|
||||
error,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.exception(
|
||||
"Непредвиденная ошибка при обновлении сообщения опроса %s: %s",
|
||||
message.message_id,
|
||||
error,
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _build_options_keyboard(response_id: int, question: PollQuestion) -> types.InlineKeyboardMarkup:
|
||||
buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
for option in sorted(question.options, key=lambda o: o.order):
|
||||
buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=option.text,
|
||||
callback_data=f"poll_answer:{response_id}:{question.id}:{option.id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
async def handle_poll_start(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
response_id = int(callback.data.split(":")[1])
|
||||
except (IndexError, ValueError):
|
||||
await callback.answer("❌ Опрос не найден", show_alert=True)
|
||||
return
|
||||
|
||||
response = await get_poll_response_by_id(db, response_id)
|
||||
if not response or response.user_id != db_user.id:
|
||||
await callback.answer("❌ Опрос не найден", show_alert=True)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if response.completed_at:
|
||||
await callback.answer(texts.t("POLL_ALREADY_COMPLETED", "Вы уже прошли этот опрос."), show_alert=True)
|
||||
return
|
||||
|
||||
if not response.poll or not response.poll.questions:
|
||||
await callback.answer(texts.t("POLL_EMPTY", "Опрос пока недоступен."), show_alert=True)
|
||||
return
|
||||
|
||||
if not response.started_at:
|
||||
response.started_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
index, question = await get_next_question(response)
|
||||
if not question:
|
||||
await callback.answer(texts.t("POLL_ERROR", "Не удалось загрузить вопросы."), show_alert=True)
|
||||
return
|
||||
|
||||
question_text = await _render_question_text(
|
||||
response.poll.title,
|
||||
question,
|
||||
index,
|
||||
len(response.poll.questions),
|
||||
db_user.language,
|
||||
)
|
||||
|
||||
if not await _update_poll_message(
|
||||
callback.message,
|
||||
question_text,
|
||||
reply_markup=_build_options_keyboard(response.id, question),
|
||||
):
|
||||
await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True)
|
||||
return
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_poll_answer(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
_, response_id, question_id, option_id = callback.data.split(":", 3)
|
||||
response_id = int(response_id)
|
||||
question_id = int(question_id)
|
||||
option_id = int(option_id)
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
response = await get_poll_response_by_id(db, response_id)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not response or response.user_id != db_user.id:
|
||||
await callback.answer("❌ Опрос не найден", show_alert=True)
|
||||
return
|
||||
|
||||
if not response.poll:
|
||||
await callback.answer(texts.t("POLL_ERROR", "Опрос недоступен."), show_alert=True)
|
||||
return
|
||||
|
||||
if response.completed_at:
|
||||
await callback.answer(texts.t("POLL_ALREADY_COMPLETED", "Вы уже прошли этот опрос."), show_alert=True)
|
||||
return
|
||||
|
||||
question = next((q for q in response.poll.questions if q.id == question_id), None)
|
||||
if not question:
|
||||
await callback.answer(texts.t("POLL_ERROR", "Вопрос не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
option = await get_question_option(question, option_id)
|
||||
if not option:
|
||||
await callback.answer(texts.t("POLL_ERROR", "Вариант ответа не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
await record_poll_answer(
|
||||
db,
|
||||
response_id=response.id,
|
||||
question_id=question.id,
|
||||
option_id=option.id,
|
||||
)
|
||||
|
||||
try:
|
||||
await db.refresh(response, attribute_names=["answers"])
|
||||
except Exception as error: # pragma: no cover - defensive cache busting
|
||||
logger.debug(
|
||||
"Не удалось обновить локальные ответы опроса %s: %s",
|
||||
response.id,
|
||||
error,
|
||||
)
|
||||
response = await get_poll_response_by_id(db, response.id)
|
||||
if not response:
|
||||
await callback.answer(texts.t("POLL_ERROR", "Опрос недоступен."), show_alert=True)
|
||||
return
|
||||
index, next_question = await get_next_question(response)
|
||||
|
||||
if next_question:
|
||||
question_text = await _render_question_text(
|
||||
response.poll.title,
|
||||
next_question,
|
||||
index,
|
||||
len(response.poll.questions),
|
||||
db_user.language,
|
||||
)
|
||||
if not await _update_poll_message(
|
||||
callback.message,
|
||||
question_text,
|
||||
reply_markup=_build_options_keyboard(response.id, next_question),
|
||||
):
|
||||
await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True)
|
||||
return
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
response.completed_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
reward_amount = await reward_user_for_poll(db, response)
|
||||
|
||||
thanks_lines = [texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!")]
|
||||
if reward_amount:
|
||||
thanks_lines.append(
|
||||
texts.t(
|
||||
"POLL_REWARD_GRANTED",
|
||||
"Награда {amount} зачислена на ваш баланс.",
|
||||
).format(amount=settings.format_price(reward_amount))
|
||||
)
|
||||
|
||||
if not await _update_poll_message(
|
||||
callback.message,
|
||||
"\n\n".join(thanks_lines),
|
||||
):
|
||||
await callback.answer(texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!"))
|
||||
return
|
||||
asyncio.create_task(
|
||||
_delete_message_later(callback.bot, callback.message.chat.id, callback.message.message_id)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(handle_poll_start, F.data.startswith("poll_start:"))
|
||||
dp.callback_query.register(handle_poll_answer, F.data.startswith("poll_answer:"))
|
||||
+1208
-116
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
|
||||
@@ -63,6 +64,7 @@ from app.states import SubscriptionStates
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.utils.pricing_utils import (
|
||||
calculate_months_from_days,
|
||||
compute_simple_subscription_price,
|
||||
get_remaining_months,
|
||||
calculate_prorated_price,
|
||||
validate_pricing_calculation,
|
||||
@@ -80,14 +82,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,23 +579,13 @@ async def start_subscription_purchase(
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Если включена простая покупка, показываем дополнительную кнопку
|
||||
keyboard = get_subscription_period_keyboard(db_user.language)
|
||||
|
||||
if settings.SIMPLE_SUBSCRIPTION_ENABLED:
|
||||
# Добавляем кнопку простой подписки в начало клавиатуры
|
||||
simple_subscription_button = [types.InlineKeyboardButton(
|
||||
text="⚡ Простая покупка",
|
||||
callback_data="simple_subscription_purchase"
|
||||
)]
|
||||
|
||||
# Вставляем кнопку в начало списка кнопок
|
||||
keyboard.inline_keyboard.insert(0, simple_subscription_button)
|
||||
prompt_text = await _build_subscription_period_prompt(db_user, texts, db)
|
||||
|
||||
await callback.message.edit_text(
|
||||
await _build_subscription_period_prompt(db_user, texts, db),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
await _edit_message_text_or_caption(
|
||||
callback.message,
|
||||
prompt_text,
|
||||
keyboard,
|
||||
)
|
||||
|
||||
subscription = getattr(db_user, 'subscription', None)
|
||||
@@ -568,6 +610,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,
|
||||
@@ -2302,8 +2384,19 @@ async def handle_simple_subscription_purchase(
|
||||
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)
|
||||
# Продлеваем существующую подписку
|
||||
await _extend_existing_subscription(
|
||||
callback=callback,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
current_subscription=current_subscription,
|
||||
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
|
||||
)
|
||||
return
|
||||
|
||||
# Подготовим параметры простой подписки
|
||||
@@ -2320,7 +2413,27 @@ async def handle_simple_subscription_purchase(
|
||||
# Проверяем баланс пользователя
|
||||
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
|
||||
# Рассчитываем цену подписки
|
||||
price_kopeks = _calculate_simple_subscription_price(subscription_params)
|
||||
price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
|
||||
db,
|
||||
subscription_params,
|
||||
user=db_user,
|
||||
resolved_squad_uuid=subscription_params.get("squad_uuid"),
|
||||
)
|
||||
logger.debug(
|
||||
"SIMPLE_SUBSCRIPTION_PURCHASE_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s",
|
||||
db_user.id,
|
||||
price_kopeks,
|
||||
price_breakdown.get("base_price", 0),
|
||||
price_breakdown.get("traffic_price", 0),
|
||||
price_breakdown.get("devices_price", 0),
|
||||
price_breakdown.get("servers_price", 0),
|
||||
price_breakdown.get("total_discount", 0),
|
||||
)
|
||||
traffic_text = (
|
||||
"Безлимит"
|
||||
if subscription_params["traffic_limit_gb"] == 0
|
||||
else f"{subscription_params['traffic_limit_gb']} ГБ"
|
||||
)
|
||||
|
||||
if user_balance_kopeks >= price_kopeks:
|
||||
# Если баланс достаточный, предлагаем оплатить с баланса
|
||||
@@ -2328,7 +2441,7 @@ async def handle_simple_subscription_purchase(
|
||||
f"⚡ <b>Простая покупка подписки</b>\n\n"
|
||||
f"📅 Период: {subscription_params['period_days']} дней\n"
|
||||
f"📱 Устройства: {subscription_params['device_limit']}\n"
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\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"
|
||||
@@ -2346,7 +2459,7 @@ async def handle_simple_subscription_purchase(
|
||||
f"⚡ <b>Простая покупка подписки</b>\n\n"
|
||||
f"📅 Период: {subscription_params['period_days']} дней\n"
|
||||
f"📱 Устройства: {subscription_params['device_limit']}\n"
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\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"
|
||||
@@ -2367,16 +2480,22 @@ async def handle_simple_subscription_purchase(
|
||||
|
||||
|
||||
|
||||
def _calculate_simple_subscription_price(params: dict) -> int:
|
||||
async def _calculate_simple_subscription_price(
|
||||
db: AsyncSession,
|
||||
params: dict,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
resolved_squad_uuid: Optional[str] = None,
|
||||
) -> Tuple[int, Dict[str, Any]]:
|
||||
"""Рассчитывает цену простой подписки."""
|
||||
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
|
||||
|
||||
resolved_uuids = [resolved_squad_uuid] if resolved_squad_uuid else None
|
||||
return await compute_simple_subscription_price(
|
||||
db,
|
||||
params,
|
||||
user=user,
|
||||
resolved_squad_uuids=resolved_uuids,
|
||||
)
|
||||
|
||||
|
||||
def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyboardMarkup:
|
||||
@@ -2437,3 +2556,210 @@ def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyb
|
||||
)])
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
async def _extend_existing_subscription(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
current_subscription: Subscription,
|
||||
period_days: int,
|
||||
device_limit: int,
|
||||
traffic_limit_gb: int,
|
||||
squad_uuid: str
|
||||
):
|
||||
"""Продлевает существующую подписку."""
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import TransactionType
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.pricing_utils import calculate_months_from_days
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Рассчитываем цену подписки
|
||||
subscription_params = {
|
||||
"period_days": period_days,
|
||||
"device_limit": device_limit,
|
||||
"traffic_limit_gb": traffic_limit_gb,
|
||||
"squad_uuid": squad_uuid
|
||||
}
|
||||
price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
|
||||
db,
|
||||
subscription_params,
|
||||
user=db_user,
|
||||
resolved_squad_uuid=squad_uuid,
|
||||
)
|
||||
logger.debug(
|
||||
"SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s",
|
||||
db_user.id,
|
||||
price_kopeks,
|
||||
price_breakdown.get("base_price", 0),
|
||||
price_breakdown.get("traffic_price", 0),
|
||||
price_breakdown.get("devices_price", 0),
|
||||
price_breakdown.get("servers_price", 0),
|
||||
price_breakdown.get("total_discount", 0),
|
||||
)
|
||||
|
||||
# Проверяем баланс пользователя
|
||||
if db_user.balance_kopeks < price_kopeks:
|
||||
missing_kopeks = price_kopeks - db_user.balance_kopeks
|
||||
message_text = texts.t(
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE",
|
||||
(
|
||||
"⚠️ <b>Недостаточно средств</b>\n\n"
|
||||
"Стоимость услуги: {required}\n"
|
||||
"На балансе: {balance}\n"
|
||||
"Не хватает: {missing}\n\n"
|
||||
"Выберите способ пополнения. Сумма подставится автоматически."
|
||||
),
|
||||
).format(
|
||||
required=texts.format_price(price_kopeks),
|
||||
balance=texts.format_price(db_user.balance_kopeks),
|
||||
missing=texts.format_price(missing_kopeks),
|
||||
)
|
||||
|
||||
# Подготовим данные для сохранения в корзину
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
cart_data = {
|
||||
'period_days': period_days,
|
||||
'total_price': price_kopeks,
|
||||
'user_id': db_user.id,
|
||||
'saved_cart': True,
|
||||
'missing_amount': missing_kopeks,
|
||||
'return_to_cart': True
|
||||
}
|
||||
|
||||
await user_cart_service.save_user_cart(db_user.id, cart_data)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=get_insufficient_balance_keyboard(
|
||||
db_user.language,
|
||||
amount_kopeks=missing_kopeks,
|
||||
has_saved_cart=True
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Списываем средства
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
db_user,
|
||||
price_kopeks,
|
||||
f"Продление подписки на {period_days} дней",
|
||||
consume_promo_offer=False, # Простая покупка не использует промо-скидки
|
||||
)
|
||||
|
||||
if not success:
|
||||
await callback.answer("⚠ Ошибка списания средств", show_alert=True)
|
||||
return
|
||||
|
||||
# Обновляем параметры подписки
|
||||
current_time = datetime.utcnow()
|
||||
old_end_date = current_subscription.end_date
|
||||
|
||||
# Обновляем параметры в зависимости от типа текущей подписки
|
||||
if current_subscription.is_trial:
|
||||
# При продлении триальной подписки переводим её в обычную
|
||||
current_subscription.is_trial = False
|
||||
current_subscription.status = "active"
|
||||
# Убираем ограничения с триальной подписки
|
||||
current_subscription.traffic_limit_gb = traffic_limit_gb
|
||||
current_subscription.device_limit = device_limit
|
||||
# Если указан squad_uuid, добавляем его к существующим серверам
|
||||
if squad_uuid and squad_uuid not in current_subscription.connected_squads:
|
||||
# Используем += для безопасного добавления в список SQLAlchemy
|
||||
current_subscription.connected_squads = current_subscription.connected_squads + [squad_uuid]
|
||||
else:
|
||||
# Для обычной подписки просто продлеваем
|
||||
# Обновляем трафик и устройства, если нужно
|
||||
if traffic_limit_gb != 0: # Если не безлимит, обновляем
|
||||
current_subscription.traffic_limit_gb = traffic_limit_gb
|
||||
if device_limit > current_subscription.device_limit:
|
||||
current_subscription.device_limit = device_limit
|
||||
# Если указан squad_uuid и его ещё нет в подписке, добавляем
|
||||
if squad_uuid and squad_uuid not in current_subscription.connected_squads:
|
||||
# Используем += для безопасного добавления в список SQLAlchemy
|
||||
current_subscription.connected_squads = current_subscription.connected_squads + [squad_uuid]
|
||||
|
||||
# Продлеваем подписку
|
||||
if current_subscription.end_date > current_time:
|
||||
# Если подписка ещё активна, добавляем дни к текущей дате окончания
|
||||
new_end_date = current_subscription.end_date + timedelta(days=period_days)
|
||||
else:
|
||||
# Если подписка уже истекла, начинаем от текущего времени
|
||||
new_end_date = current_time + timedelta(days=period_days)
|
||||
|
||||
current_subscription.end_date = new_end_date
|
||||
current_subscription.updated_at = current_time
|
||||
|
||||
# Сохраняем изменения
|
||||
await db.commit()
|
||||
await db.refresh(current_subscription)
|
||||
await db.refresh(db_user)
|
||||
|
||||
# Обновляем пользователя в Remnawave
|
||||
subscription_service = SubscriptionService()
|
||||
try:
|
||||
remnawave_result = await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
current_subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_reason="продление подписки",
|
||||
)
|
||||
if remnawave_result:
|
||||
logger.info("✅ RemnaWave обновлен успешно")
|
||||
else:
|
||||
logger.error("⚠ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE")
|
||||
except Exception as e:
|
||||
logger.error(f"⚠ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE: {e}")
|
||||
|
||||
# Создаём транзакцию
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=price_kopeks,
|
||||
description=f"Продление подписки на {period_days} дней"
|
||||
)
|
||||
|
||||
# Отправляем уведомление админу
|
||||
try:
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_extension_notification(
|
||||
db,
|
||||
db_user,
|
||||
current_subscription,
|
||||
transaction,
|
||||
period_days,
|
||||
old_end_date,
|
||||
new_end_date=new_end_date,
|
||||
balance_after=db_user.balance_kopeks,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о продлении: {e}")
|
||||
|
||||
# Отправляем сообщение пользователю
|
||||
success_message = (
|
||||
"✅ Подписка успешно продлена!\n\n"
|
||||
f"⏰ Добавлено: {period_days} дней\n"
|
||||
f"Действует до: {new_end_date.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
f"💰 Списано: {texts.format_price(price_kopeks)}"
|
||||
)
|
||||
|
||||
# Если это была триальная подписка, добавляем информацию о преобразовании
|
||||
if current_subscription.is_trial:
|
||||
success_message += "\n🎯 Триальная подписка преобразована в платную"
|
||||
|
||||
await callback.message.edit_text(
|
||||
success_message,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} продлил подписку на {period_days} дней за {price_kopeks / 100}₽")
|
||||
await callback.answer()
|
||||
|
||||
@@ -101,6 +101,12 @@ def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKey
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_COMMUNICATIONS_POLLS", "🗳️ Опросы"),
|
||||
callback_data="admin_polls",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_COMMUNICATIONS_PROMO_OFFERS", "🎯 Промо-предложения"),
|
||||
|
||||
+57
-49
@@ -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:
|
||||
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,48 +346,35 @@ def get_main_menu_keyboard(
|
||||
subscription_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="menu_buy")
|
||||
)
|
||||
|
||||
# Добавляем кнопку простой покупки после кнопки "Купить подписку"
|
||||
if settings.SIMPLE_SUBSCRIPTION_ENABLED:
|
||||
subscription_buttons.append(
|
||||
InlineKeyboardButton(text="⚡ Простая покупка", callback_data="simple_subscription_purchase")
|
||||
)
|
||||
|
||||
if subscription_buttons:
|
||||
if len(subscription_buttons) == 2:
|
||||
keyboard.append(subscription_buttons)
|
||||
elif len(subscription_buttons) == 1:
|
||||
keyboard.append([subscription_buttons[0]])
|
||||
elif len(subscription_buttons) > 2:
|
||||
# Если больше 2 кнопок, добавляем по отдельности
|
||||
for button in subscription_buttons:
|
||||
keyboard.append([button])
|
||||
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:
|
||||
@@ -387,21 +383,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}")
|
||||
|
||||
@@ -1136,6 +1137,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", "🛠️ Через поддержку"),
|
||||
@@ -1372,7 +1381,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}"
|
||||
)
|
||||
])
|
||||
@@ -1384,13 +1393,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 "дней"
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -976,6 +976,24 @@
|
||||
"PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)",
|
||||
"PAYMENT_CHARGE_ERROR": "⚠️ Failed to charge the payment",
|
||||
"PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)",
|
||||
"PAYMENT_HELEKET": "🪙 Cryptocurrency (Heleket)",
|
||||
"HELEKET_PAYMENT_ALREADY_PAID": "✅ Payment has already been credited",
|
||||
"HELEKET_PAYMENT_SUCCESS": "✅ Payment credited to your balance",
|
||||
"HELEKET_STATUS_CHECK": "⏳ Waiting for payment",
|
||||
"HELEKET_STATUS_PROCESS": "⚙️ Payment is being processed",
|
||||
"HELEKET_STATUS_CONFIRM_CHECK": "⛓ Waiting for network confirmations",
|
||||
"HELEKET_STATUS_WRONG_AMOUNT": "❗️ Incorrect amount paid",
|
||||
"HELEKET_STATUS_WRONG_AMOUNT_WAITING": "❗️ Not enough amount, waiting for additional payment",
|
||||
"HELEKET_STATUS_PAID_OVER": "✅ Payment credited (overpaid)",
|
||||
"HELEKET_STATUS_PAID": "✅ Payment credited",
|
||||
"HELEKET_STATUS_CANCEL": "🚫 Payment was cancelled",
|
||||
"HELEKET_STATUS_FAIL": "❌ Payment failed",
|
||||
"HELEKET_STATUS_SYSTEM_FAIL": "❌ Heleket system error",
|
||||
"HELEKET_STATUS_REFUND_PROCESS": "↩️ Refund in progress",
|
||||
"HELEKET_STATUS_REFUND_FAIL": "⚠️ Refund failed",
|
||||
"HELEKET_STATUS_REFUND_PAID": "✅ Refund completed",
|
||||
"HELEKET_STATUS_LOCKED": "🔒 Funds are locked",
|
||||
"HELEKET_STATUS_UNKNOWN": "ℹ️ Payment status: {status}",
|
||||
"PAYMENT_METHODS_FOOTER": "Choose a top-up method:",
|
||||
"PAYMENT_METHODS_ONLY_SUPPORT": "💳 <b>Balance top-up methods</b>\n\n⚠️ Automated payment methods are temporarily unavailable.\nContact support to top up your balance.\n\nChoose a top-up method:",
|
||||
"PAYMENT_METHODS_PROMPT": "Choose the payment method that suits you:",
|
||||
@@ -983,6 +1001,8 @@
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Cryptocurrency</b>",
|
||||
"PAYMENT_METHOD_HELEKET_DESCRIPTION": "via Heleket",
|
||||
"PAYMENT_METHOD_HELEKET_NAME": "🪙 <b>Cryptocurrency (Heleket)</b>",
|
||||
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via {mulenpay_name}",
|
||||
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card ({mulenpay_name})</b>",
|
||||
"PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System",
|
||||
@@ -999,6 +1019,8 @@
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "via YooKassa Fast Payment System",
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 <b>SBP (YooKassa)</b>",
|
||||
"PAYMENT_HELEKET_MARKUP_LABEL": "Provider markup",
|
||||
"PAYMENT_HELEKET_DISCOUNT_LABEL": "Provider discount",
|
||||
"PAYMENT_RETURN_HOME_BUTTON": "🏠 Main menu",
|
||||
"PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)",
|
||||
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
|
||||
@@ -1347,5 +1369,56 @@
|
||||
"WATA_TOPUP_PROMPT": "💳 <b>Pay via WATA</b>\n\nEnter the amount to top up. Minimum amount — {min_amount}, maximum — {max_amount}.\nPayments are processed through the secure WATA form.",
|
||||
"WELCOME": "\n🎉 <b>Welcome to VPN Service!</b>\n\nOur service provides fast and secure internet access without restrictions.\n\n🔐 <b>Advantages:</b>\n• High connection speed\n• Servers in different countries \n• Reliable data protection\n• 24/7 support\n\nTo get started, select interface language:\n",
|
||||
"WELCOME_FALLBACK": "Welcome, {user_name}!",
|
||||
"YES": "✅ Yes"
|
||||
"YES": "✅ Yes",
|
||||
"SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_ACTIVE": "ℹ️ Your trial subscription is active for another {days} days. Purchasing a new plan will extend the validity.",
|
||||
"SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_TRIAL": "ℹ️ Your trial is still running (expires in {days} days). Buying a paid plan will extend it automatically.",
|
||||
"SIMPLE_SUBSCRIPTION_SERVER_ANY": "Any available",
|
||||
"SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Selected",
|
||||
"SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Assigned automatically",
|
||||
"MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase",
|
||||
"ADMIN_COMMUNICATIONS_POLLS": "🗳️ Polls",
|
||||
"ADMIN_POLLS_CREATE": "➕ Create poll",
|
||||
"ADMIN_POLLS_REWARD_ENABLED": "Reward: {amount}",
|
||||
"ADMIN_POLLS_REWARD_DISABLED": "Reward disabled",
|
||||
"ADMIN_POLLS_SEND": "📤 Send",
|
||||
"ADMIN_POLLS_STATS": "📊 Stats",
|
||||
"ADMIN_POLLS_DELETE": "🗑️ Delete",
|
||||
"ADMIN_POLLS_BACK": "⬅️ Back to list",
|
||||
"ADMIN_POLLS_CUSTOM_TARGET": "⚙️ Custom filters",
|
||||
"ADMIN_POLLS_SEND_CONFIRM_BUTTON": "✅ Send",
|
||||
"ADMIN_POLLS_LIST_TITLE": "🗳️ <b>Polls</b>",
|
||||
"ADMIN_POLLS_LIST_EMPTY": "No polls yet.",
|
||||
"ADMIN_POLLS_QUESTIONS_COUNT": "Questions: {count}",
|
||||
"ADMIN_POLLS_CREATION_TITLE_PROMPT": "🗳️ <b>Create poll</b>\n\nEnter poll title:",
|
||||
"ADMIN_POLLS_CREATION_CANCELLED": "❌ Poll creation cancelled.",
|
||||
"ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT": "Enter poll description. HTML is allowed.\nSend /skip to omit.",
|
||||
"ADMIN_POLLS_CREATION_INVALID_HTML": "❌ HTML error: {error}",
|
||||
"ADMIN_POLLS_CREATION_REWARD_PROMPT": "Enter reward amount in RUB. Send 0 to disable reward.",
|
||||
"ADMIN_POLLS_CREATION_REWARD_INVALID": "❌ Invalid amount. Try again.",
|
||||
"ADMIN_POLLS_CREATION_QUESTION_PROMPT": "Send the question and answer options.\nEach line is a separate option.\nThe first line is the question text.\nSend /done when finished.",
|
||||
"ADMIN_POLLS_CREATION_NEEDS_QUESTION": "❌ Add at least one question.",
|
||||
"ADMIN_POLLS_CREATION_FINISHED": "✅ Poll “{title}” created. Questions: {count}. {reward}",
|
||||
"ADMIN_POLLS_CREATION_MIN_OPTIONS": "❌ Provide a question and at least two answer options.",
|
||||
"ADMIN_POLLS_CREATION_ADDED_QUESTION": "Question added: “{question}”. Add another question or send /done.",
|
||||
"ADMIN_POLLS_QUESTION_LIST_HEADER": "<b>Questions:</b>",
|
||||
"ADMIN_POLLS_OPTION_BULLET": " • {option}",
|
||||
"ADMIN_POLLS_SEND_CHOOSE_TARGET": "🎯 Select audience for the poll:",
|
||||
"ADMIN_POLLS_CUSTOM_PROMPT": "Choose an additional audience filter:",
|
||||
"ADMIN_POLLS_SEND_CONFIRM": "📤 Send poll “{title}” to “{audience}”? Users: {count}",
|
||||
"ADMIN_POLLS_SENDING": "📤 Sending poll...",
|
||||
"ADMIN_POLLS_SEND_RESULT": "📤 Poll finished\nDelivered: {sent}\nFailed: {failed}\nSkipped: {skipped}\nTotal: {total}",
|
||||
"ADMIN_POLLS_STATS_HEADER": "📊 <b>Poll statistics</b>",
|
||||
"ADMIN_POLLS_STATS_OVERVIEW": "Invited: {total}\nCompleted: {completed}\nRewards paid: {reward}",
|
||||
"ADMIN_POLLS_STATS_OPTION_LINE": "• {option}: {count}",
|
||||
"ADMIN_POLLS_CONFIRM_DELETE": "Delete poll “{title}”?",
|
||||
"ADMIN_POLLS_DELETED": "🗑️ Poll deleted.",
|
||||
"POLL_INVITATION_REWARD": "🎁 You will receive {amount} for participating.",
|
||||
"POLL_INVITATION_START": "Tap the button below to answer the poll.",
|
||||
"POLL_START_BUTTON": "📝 Take the poll",
|
||||
"POLL_QUESTION_HEADER": "<b>Question {current}/{total}</b>",
|
||||
"POLL_ALREADY_COMPLETED": "You have already completed this poll.",
|
||||
"POLL_EMPTY": "Poll is not available yet.",
|
||||
"POLL_ERROR": "Unable to process the poll. Please try again later.",
|
||||
"POLL_COMPLETED": "🙏 Thanks for completing the poll!",
|
||||
"POLL_REWARD_GRANTED": "Reward {amount} has been credited to your balance."
|
||||
}
|
||||
|
||||
@@ -745,7 +745,7 @@
|
||||
"BALANCE_INFO": "\n💰 <b>Баланс: {balance}</b>\n\nВыберите действие:\n",
|
||||
"BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку",
|
||||
"BALANCE_TOPUP": "💳 Пополнить баланс",
|
||||
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\\n💡 <b>Требуется пополнение баланса</b>\\n\\nВ вашей корзине находятся товары на общую сумму {total_amount}, но на балансе недостаточно средств.\\n\\n💳 <b>Пополните баланс</b>, чтобы завершить покупку.\\n\\nВыберите способ пополнения:",
|
||||
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\n💡 <b>Требуется пополнение баланса</b>\n\nВ вашей корзине находятся товары на общую сумму {total_amount}, но на балансе недостаточно средств.\n\n💳 <b>Пополните баланс</b>, чтобы завершить покупку.\n\nВыберите способ пополнения:",
|
||||
"BALANCE_TOP_UP": "💳 Пополнить",
|
||||
"BLOCK_BY_TIME": "⏳ Блокировка по времени",
|
||||
"BLOCK_FOREVER": "🚫 Заблокировать",
|
||||
@@ -976,6 +976,24 @@
|
||||
"PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)",
|
||||
"PAYMENT_CHARGE_ERROR": "⚠️ Ошибка списания средств",
|
||||
"PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)",
|
||||
"PAYMENT_HELEKET": "🪙 Криптовалюта (Heleket)",
|
||||
"HELEKET_PAYMENT_ALREADY_PAID": "✅ Платёж уже зачислен",
|
||||
"HELEKET_PAYMENT_SUCCESS": "✅ Платёж зачислен на баланс",
|
||||
"HELEKET_STATUS_CHECK": "⏳ Ожидаем оплату",
|
||||
"HELEKET_STATUS_PROCESS": "⚙️ Платёж обрабатывается",
|
||||
"HELEKET_STATUS_CONFIRM_CHECK": "⛓ Ожидание подтверждений сети",
|
||||
"HELEKET_STATUS_WRONG_AMOUNT": "❗️ Оплачена неверная сумма",
|
||||
"HELEKET_STATUS_WRONG_AMOUNT_WAITING": "❗️ Недостаточная сумма, ожидаем доплату",
|
||||
"HELEKET_STATUS_PAID_OVER": "✅ Платёж зачислен (с переплатой)",
|
||||
"HELEKET_STATUS_PAID": "✅ Платёж зачислен",
|
||||
"HELEKET_STATUS_CANCEL": "🚫 Платёж отменён",
|
||||
"HELEKET_STATUS_FAIL": "❌ Ошибка при оплате",
|
||||
"HELEKET_STATUS_SYSTEM_FAIL": "❌ Системная ошибка Heleket",
|
||||
"HELEKET_STATUS_REFUND_PROCESS": "↩️ Возврат обрабатывается",
|
||||
"HELEKET_STATUS_REFUND_FAIL": "⚠️ Ошибка возврата",
|
||||
"HELEKET_STATUS_REFUND_PAID": "✅ Возврат выполнен",
|
||||
"HELEKET_STATUS_LOCKED": "🔒 Средства заблокированы",
|
||||
"HELEKET_STATUS_UNKNOWN": "ℹ️ Статус платежа: {status}",
|
||||
"PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:",
|
||||
"PAYMENT_METHODS_ONLY_SUPPORT": "💳 <b>Способы пополнения баланса</b>\n\n⚠️ В данный момент автоматические способы оплаты временно недоступны.\nОбратитесь в техподдержку для пополнения баланса.\n\nВыберите способ пополнения:",
|
||||
"PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:",
|
||||
@@ -983,6 +1001,8 @@
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Криптовалюта</b>",
|
||||
"PAYMENT_METHOD_HELEKET_DESCRIPTION": "через Heleket",
|
||||
"PAYMENT_METHOD_HELEKET_NAME": "🪙 <b>Криптовалюта (Heleket)</b>",
|
||||
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через {mulenpay_name}",
|
||||
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта ({mulenpay_name})</b>",
|
||||
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей",
|
||||
@@ -999,6 +1019,8 @@
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему быстрых платежей YooKassa",
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 <b>СБП (YooKassa)</b>",
|
||||
"PAYMENT_HELEKET_MARKUP_LABEL": "Наценка провайдера",
|
||||
"PAYMENT_HELEKET_DISCOUNT_LABEL": "Скидка провайдера",
|
||||
"PAYMENT_RETURN_HOME_BUTTON": "🏠 На главную",
|
||||
"PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)",
|
||||
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
|
||||
@@ -1347,5 +1369,56 @@
|
||||
"WATA_TOPUP_PROMPT": "💳 <b>Оплата через WATA</b>\n\nВведите сумму пополнения. Минимальная сумма — {min_amount}, максимальная — {max_amount}.\nОплата происходит через защищенную форму WATA.",
|
||||
"WELCOME": "\n🎉 <b>Добро пожаловать в VPN сервис!</b>\n\nНаш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.\n\n🔐 <b>Преимущества:</b>\n• Высокая скорость подключения\n• Серверы в разных странах\n• Надежная защита данных\n• Круглосуточная поддержка\n\nДля начала работы выберите язык интерфейса:\n",
|
||||
"WELCOME_FALLBACK": "Добро пожаловать, {user_name}!",
|
||||
"YES": "✅ Да"
|
||||
"YES": "✅ Да",
|
||||
"SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_ACTIVE": "ℹ️ У вас уже есть активная триальная подписка. Она будет действовать ещё {days} дн. После покупки к сроку добавится оплаченный период.",
|
||||
"SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_TRIAL": "ℹ️ У вас сейчас триальная подписка. Она истекает через {days} дн. Покупка новой продлит срок автоматически.",
|
||||
"SIMPLE_SUBSCRIPTION_SERVER_ANY": "Любой доступный",
|
||||
"SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Выбранный",
|
||||
"SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Назначен автоматически",
|
||||
"MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка",
|
||||
"ADMIN_COMMUNICATIONS_POLLS": "🗳️ Опросы",
|
||||
"ADMIN_POLLS_CREATE": "➕ Создать опрос",
|
||||
"ADMIN_POLLS_REWARD_ENABLED": "Награда: {amount}",
|
||||
"ADMIN_POLLS_REWARD_DISABLED": "Награда отключена",
|
||||
"ADMIN_POLLS_SEND": "📤 Отправить",
|
||||
"ADMIN_POLLS_STATS": "📊 Статистика",
|
||||
"ADMIN_POLLS_DELETE": "🗑️ Удалить",
|
||||
"ADMIN_POLLS_BACK": "⬅️ К списку",
|
||||
"ADMIN_POLLS_CUSTOM_TARGET": "⚙️ По критериям",
|
||||
"ADMIN_POLLS_SEND_CONFIRM_BUTTON": "✅ Отправить",
|
||||
"ADMIN_POLLS_LIST_TITLE": "🗳️ <b>Опросы</b>",
|
||||
"ADMIN_POLLS_LIST_EMPTY": "Опросов пока нет.",
|
||||
"ADMIN_POLLS_QUESTIONS_COUNT": "Вопросов: {count}",
|
||||
"ADMIN_POLLS_CREATION_TITLE_PROMPT": "🗳️ <b>Создание опроса</b>\n\nВведите заголовок опроса:",
|
||||
"ADMIN_POLLS_CREATION_CANCELLED": "❌ Создание опроса отменено.",
|
||||
"ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT": "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.",
|
||||
"ADMIN_POLLS_CREATION_INVALID_HTML": "❌ Ошибка в HTML: {error}",
|
||||
"ADMIN_POLLS_CREATION_REWARD_PROMPT": "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.",
|
||||
"ADMIN_POLLS_CREATION_REWARD_INVALID": "❌ Некорректная сумма. Попробуйте ещё раз.",
|
||||
"ADMIN_POLLS_CREATION_QUESTION_PROMPT": "Введите вопрос и варианты ответов.\nКаждая строка — отдельный вариант.\nПервая строка — текст вопроса.\nОтправьте /done, когда вопросы будут добавлены.",
|
||||
"ADMIN_POLLS_CREATION_NEEDS_QUESTION": "❌ Добавьте хотя бы один вопрос.",
|
||||
"ADMIN_POLLS_CREATION_FINISHED": "✅ Опрос «{title}» создан. Вопросов: {count}. {reward}",
|
||||
"ADMIN_POLLS_CREATION_MIN_OPTIONS": "❌ Нужен вопрос и минимум два варианта ответа.",
|
||||
"ADMIN_POLLS_CREATION_ADDED_QUESTION": "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.",
|
||||
"ADMIN_POLLS_QUESTION_LIST_HEADER": "<b>Вопросы:</b>",
|
||||
"ADMIN_POLLS_OPTION_BULLET": " • {option}",
|
||||
"ADMIN_POLLS_SEND_CHOOSE_TARGET": "🎯 Выберите аудиторию для отправки опроса:",
|
||||
"ADMIN_POLLS_CUSTOM_PROMPT": "Выберите дополнительный критерий аудитории:",
|
||||
"ADMIN_POLLS_SEND_CONFIRM": "📤 Отправить опрос «{title}» аудитории «{audience}»? Пользователей: {count}",
|
||||
"ADMIN_POLLS_SENDING": "📤 Запускаю отправку опроса...",
|
||||
"ADMIN_POLLS_SEND_RESULT": "📤 Отправка завершена\nУспешно: {sent}\nОшибок: {failed}\nПропущено: {skipped}\nВсего: {total}",
|
||||
"ADMIN_POLLS_STATS_HEADER": "📊 <b>Статистика опроса</b>",
|
||||
"ADMIN_POLLS_STATS_OVERVIEW": "Всего приглашено: {total}\nЗавершили: {completed}\nВыплачено наград: {reward}",
|
||||
"ADMIN_POLLS_STATS_OPTION_LINE": "• {option}: {count}",
|
||||
"ADMIN_POLLS_CONFIRM_DELETE": "Вы уверены, что хотите удалить опрос «{title}»?",
|
||||
"ADMIN_POLLS_DELETED": "🗑️ Опрос удалён.",
|
||||
"POLL_INVITATION_REWARD": "🎁 За участие вы получите {amount}.",
|
||||
"POLL_INVITATION_START": "Нажмите кнопку ниже, чтобы пройти опрос.",
|
||||
"POLL_START_BUTTON": "📝 Пройти опрос",
|
||||
"POLL_QUESTION_HEADER": "<b>Вопрос {current}/{total}</b>",
|
||||
"POLL_ALREADY_COMPLETED": "Вы уже прошли этот опрос.",
|
||||
"POLL_EMPTY": "Опрос пока недоступен.",
|
||||
"POLL_ERROR": "Не удалось обработать опрос. Попробуйте позже.",
|
||||
"POLL_COMPLETED": "🙏 Спасибо за участие в опросе!",
|
||||
"POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс."
|
||||
}
|
||||
|
||||
@@ -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,447 @@
|
||||
"""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_payload(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
metadata_key: str,
|
||||
) -> Optional["HeleketPayment"]:
|
||||
if not isinstance(payload, dict):
|
||||
logger.error("Heleket webhook payload не является словарём: %s", payload)
|
||||
return None
|
||||
|
||||
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 None
|
||||
|
||||
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 None
|
||||
|
||||
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={metadata_key: payload},
|
||||
)
|
||||
|
||||
if updated_payment is None:
|
||||
return None
|
||||
|
||||
if updated_payment.transaction_id:
|
||||
logger.info(
|
||||
"Heleket платеж %s уже связан с транзакцией %s",
|
||||
updated_payment.uuid,
|
||||
updated_payment.transaction_id,
|
||||
)
|
||||
return updated_payment
|
||||
|
||||
status_normalized = (status or "").lower()
|
||||
if status_normalized not in {"paid", "paid_over"}:
|
||||
logger.info("Heleket платеж %s в статусе %s, зачисление не требуется", updated_payment.uuid, status)
|
||||
return updated_payment
|
||||
|
||||
amount_kopeks = updated_payment.amount_kopeks
|
||||
if amount_kopeks <= 0:
|
||||
logger.error("Heleket платеж %s имеет некорректную сумму: %s", updated_payment.uuid, updated_payment.amount)
|
||||
return None
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
linked_payment = await heleket_crud.link_heleket_payment_to_transaction(
|
||||
db,
|
||||
updated_payment.uuid,
|
||||
transaction.id,
|
||||
)
|
||||
if linked_payment:
|
||||
updated_payment = linked_payment
|
||||
|
||||
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 None
|
||||
|
||||
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 updated_payment
|
||||
|
||||
async def process_heleket_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: Dict[str, Any],
|
||||
) -> bool:
|
||||
result = await self._process_heleket_payload(
|
||||
db,
|
||||
payload,
|
||||
metadata_key="last_webhook",
|
||||
)
|
||||
|
||||
return result is not None
|
||||
|
||||
async def sync_heleket_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
local_payment_id: int,
|
||||
) -> Optional["HeleketPayment"]:
|
||||
if not getattr(self, "heleket_service", None):
|
||||
logger.error("Heleket сервис не инициализирован")
|
||||
return None
|
||||
|
||||
heleket_crud = import_module("app.database.crud.heleket")
|
||||
|
||||
payment = await heleket_crud.get_heleket_payment_by_id(db, local_payment_id)
|
||||
if not payment:
|
||||
logger.error("Heleket платеж с id=%s не найден", local_payment_id)
|
||||
return None
|
||||
|
||||
try:
|
||||
response = await self.heleket_service.get_payment_info( # type: ignore[union-attr]
|
||||
uuid=payment.uuid,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive
|
||||
logger.exception("Ошибка получения статуса Heleket платежа %s: %s", payment.uuid, error)
|
||||
return payment
|
||||
|
||||
if not response:
|
||||
logger.warning(
|
||||
"Heleket API вернул пустой ответ при проверке платежа %s", payment.uuid
|
||||
)
|
||||
return payment
|
||||
|
||||
result = response.get("result") if isinstance(response, dict) else None
|
||||
if not isinstance(result, dict):
|
||||
logger.error("Некорректный ответ Heleket API при проверке платежа %s: %s", payment.uuid, response)
|
||||
return payment
|
||||
|
||||
payload: Dict[str, Any] = dict(result)
|
||||
payload.setdefault("uuid", payment.uuid)
|
||||
payload.setdefault("order_id", payment.order_id)
|
||||
|
||||
updated_payment = await self._process_heleket_payload(
|
||||
db,
|
||||
payload,
|
||||
metadata_key="last_status_check",
|
||||
)
|
||||
|
||||
return updated_payment or payment
|
||||
@@ -246,6 +246,22 @@ class MulenPayPaymentMixin:
|
||||
f"Пополнение {display_name}: {payment.amount_kopeks // 100}₽",
|
||||
)
|
||||
|
||||
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(
|
||||
"Ошибка обработки реферального пополнения %s: %s",
|
||||
display_name,
|
||||
error,
|
||||
)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
@@ -550,13 +550,13 @@ class YooKassaPaymentMixin:
|
||||
payment.status = event_object.get("status", payment.status)
|
||||
payment.confirmation_url = event_object.get("confirmation_url")
|
||||
|
||||
current_paid = getattr(payment, "paid", False)
|
||||
payment.paid = event_object.get("paid", current_paid)
|
||||
current_paid = bool(getattr(payment, "is_paid", getattr(payment, "paid", False)))
|
||||
payment.is_paid = bool(event_object.get("paid", current_paid))
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
if payment.status == "succeeded" and payment.paid:
|
||||
if payment.status == "succeeded" and payment.is_paid:
|
||||
return await self._process_successful_yookassa_payment(db, payment)
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -211,10 +242,11 @@ class PaymentService(
|
||||
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.debug(
|
||||
"PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, %s=%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),
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from typing import Iterable
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import add_user_balance
|
||||
from app.database.models import (
|
||||
Poll,
|
||||
PollOption,
|
||||
PollQuestion,
|
||||
PollResponse,
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_poll_invitation_text(poll: Poll, language: str) -> str:
|
||||
texts = get_texts(language)
|
||||
|
||||
lines: list[str] = [f"🗳️ <b>{poll.title}</b>"]
|
||||
if poll.description:
|
||||
lines.append(poll.description)
|
||||
|
||||
if poll.reward_enabled and poll.reward_amount_kopeks > 0:
|
||||
reward_line = texts.t(
|
||||
"POLL_INVITATION_REWARD",
|
||||
"🎁 За участие вы получите {amount}.",
|
||||
).format(amount=settings.format_price(poll.reward_amount_kopeks))
|
||||
lines.append(reward_line)
|
||||
|
||||
lines.append(
|
||||
texts.t(
|
||||
"POLL_INVITATION_START",
|
||||
"Нажмите кнопку ниже, чтобы пройти опрос.",
|
||||
)
|
||||
)
|
||||
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
def build_start_keyboard(response_id: int, language: str) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("POLL_START_BUTTON", "📝 Пройти опрос"),
|
||||
callback_data=f"poll_start:{response_id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def send_poll_to_users(
|
||||
bot: Bot,
|
||||
db: AsyncSession,
|
||||
poll: Poll,
|
||||
users: Iterable[User],
|
||||
) -> dict:
|
||||
sent = 0
|
||||
failed = 0
|
||||
skipped = 0
|
||||
|
||||
poll_id = poll.id
|
||||
poll_snapshot = SimpleNamespace(
|
||||
title=poll.title,
|
||||
description=poll.description,
|
||||
reward_enabled=poll.reward_enabled,
|
||||
reward_amount_kopeks=poll.reward_amount_kopeks,
|
||||
)
|
||||
|
||||
user_snapshots = [
|
||||
SimpleNamespace(
|
||||
id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
language=user.language,
|
||||
)
|
||||
for user in users
|
||||
]
|
||||
|
||||
for index, user in enumerate(user_snapshots, start=1):
|
||||
existing_response = await db.execute(
|
||||
select(PollResponse.id).where(
|
||||
and_(
|
||||
PollResponse.poll_id == poll_id,
|
||||
PollResponse.user_id == user.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
if existing_response.scalar_one_or_none():
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
response = PollResponse(
|
||||
poll_id=poll_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
db.add(response)
|
||||
|
||||
try:
|
||||
await db.flush()
|
||||
|
||||
text = _build_poll_invitation_text(poll_snapshot, user.language)
|
||||
keyboard = build_start_keyboard(response.id, user.language)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
sent += 1
|
||||
|
||||
if index % 20 == 0:
|
||||
await asyncio.sleep(1)
|
||||
except TelegramBadRequest as error:
|
||||
error_text = str(error).lower()
|
||||
if "chat not found" in error_text or "bot was blocked by the user" in error_text:
|
||||
skipped += 1
|
||||
logger.info(
|
||||
"ℹ️ Пропуск пользователя %s при отправке опроса %s: %s",
|
||||
user.telegram_id,
|
||||
poll_id,
|
||||
error,
|
||||
)
|
||||
else: # pragma: no cover - unexpected telegram error
|
||||
failed += 1
|
||||
logger.error(
|
||||
"❌ Ошибка отправки опроса %s пользователю %s: %s",
|
||||
poll_id,
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
await db.rollback()
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
failed += 1
|
||||
logger.error(
|
||||
"❌ Ошибка отправки опроса %s пользователю %s: %s",
|
||||
poll_id,
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
await db.rollback()
|
||||
|
||||
return {
|
||||
"sent": sent,
|
||||
"failed": failed,
|
||||
"skipped": skipped,
|
||||
"total": sent + failed + skipped,
|
||||
}
|
||||
|
||||
|
||||
async def reward_user_for_poll(
|
||||
db: AsyncSession,
|
||||
response: PollResponse,
|
||||
) -> int:
|
||||
poll = response.poll
|
||||
if not poll.reward_enabled or poll.reward_amount_kopeks <= 0:
|
||||
return 0
|
||||
|
||||
if response.reward_given:
|
||||
return response.reward_amount_kopeks
|
||||
|
||||
user = response.user
|
||||
description = f"Награда за участие в опросе \"{poll.title}\""
|
||||
|
||||
success = await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
poll.reward_amount_kopeks,
|
||||
description,
|
||||
transaction_type=TransactionType.POLL_REWARD,
|
||||
)
|
||||
|
||||
if not success:
|
||||
return 0
|
||||
|
||||
response.reward_given = True
|
||||
response.reward_amount_kopeks = poll.reward_amount_kopeks
|
||||
await db.commit()
|
||||
|
||||
return poll.reward_amount_kopeks
|
||||
|
||||
|
||||
async def get_next_question(response: PollResponse) -> tuple[int | None, PollQuestion | None]:
|
||||
if not response.poll or not response.poll.questions:
|
||||
return None, None
|
||||
|
||||
answered_question_ids = {answer.question_id for answer in response.answers}
|
||||
ordered_questions = sorted(response.poll.questions, key=lambda q: q.order)
|
||||
|
||||
for index, question in enumerate(ordered_questions, start=1):
|
||||
if question.id not in answered_question_ids:
|
||||
return index, question
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
async def get_question_option(question: PollQuestion, option_id: int) -> PollOption | None:
|
||||
for option in question.options:
|
||||
if option.id == option_id:
|
||||
return option
|
||||
return None
|
||||
+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(",", " ")
|
||||
|
||||
|
||||
|
||||
@@ -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,6 +74,7 @@ class BotConfigurationService:
|
||||
"PAYMENT": "💳 Общие платежные настройки",
|
||||
"TELEGRAM": "⭐ Telegram Stars",
|
||||
"CRYPTOBOT": "🪙 CryptoBot",
|
||||
"HELEKET": "🪙 Heleket",
|
||||
"YOOKASSA": "🟣 YooKassa",
|
||||
"TRIBUTE": "🎁 Tribute",
|
||||
"MULENPAY": "💰 {mulenpay_name}",
|
||||
@@ -81,6 +82,7 @@ class BotConfigurationService:
|
||||
"WATA": "💠 Wata",
|
||||
"EXTERNAL_ADMIN": "🛡️ Внешняя админка",
|
||||
"SUBSCRIPTIONS_CORE": "📅 Подписки и лимиты",
|
||||
"SIMPLE_SUBSCRIPTION": "⚡ Простая покупка",
|
||||
"PERIODS": "📆 Периоды подписок",
|
||||
"SUBSCRIPTION_PRICES": "💵 Стоимость тарифов",
|
||||
"TRAFFIC": "📊 Трафик",
|
||||
@@ -124,6 +126,7 @@ class BotConfigurationService:
|
||||
"PAYMENT": "Общие тексты платежей, описания чеков и шаблоны.",
|
||||
"YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.",
|
||||
"CRYPTOBOT": "CryptoBot и криптоплатежи через Telegram.",
|
||||
"HELEKET": "Heleket: криптоплатежи, ключи мерчанта и вебхуки.",
|
||||
"MULENPAY": "Платежи {mulenpay_name} и параметры магазина.",
|
||||
"PAL24": "PAL24 / PayPalych подключения и лимиты.",
|
||||
"TRIBUTE": "Tribute и донат-сервисы.",
|
||||
@@ -131,6 +134,7 @@ class BotConfigurationService:
|
||||
"WATA": "Wata: токен доступа, тип платежа и пределы сумм.",
|
||||
"EXTERNAL_ADMIN": "Токен внешней админки для проверки запросов.",
|
||||
"SUBSCRIPTIONS_CORE": "Лимиты устройств, трафика и базовые цены подписок.",
|
||||
"SIMPLE_SUBSCRIPTION": "Параметры упрощённой покупки: период, трафик, устройства и сквады.",
|
||||
"PERIODS": "Доступные периоды подписок и продлений.",
|
||||
"SUBSCRIPTION_PRICES": "Стоимость подписок по периодам в копейках.",
|
||||
"TRAFFIC": "Лимиты трафика и стратегии сброса.",
|
||||
@@ -230,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",
|
||||
@@ -279,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",
|
||||
@@ -394,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": "Булево значение.",
|
||||
@@ -422,7 +468,10 @@ class BotConfigurationService:
|
||||
"dependencies": "MAINTENANCE_MESSAGE, MAINTENANCE_CHECK_INTERVAL",
|
||||
},
|
||||
"DISPLAY_NAME_BANNED_KEYWORDS": {
|
||||
"description": "Список слов и фрагментов, при наличии которых в отображаемом имени пользователь будет заблокирован.",
|
||||
"description": (
|
||||
"Список слов и фрагментов, при наличии которых в отображаемом имени "
|
||||
"пользователь будет заблокирован."
|
||||
),
|
||||
"format": "Перечислите ключевые слова через запятую или с новой строки.",
|
||||
"example": "support, security, служебн",
|
||||
"warning": "Слишком агрессивные фильтры могут блокировать добросовестных пользователей.",
|
||||
@@ -443,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": {
|
||||
@@ -515,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 "—"
|
||||
|
||||
@@ -743,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))
|
||||
@@ -1055,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__)
|
||||
|
||||
|
||||
@@ -81,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({
|
||||
@@ -163,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:
|
||||
@@ -180,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
|
||||
}
|
||||
|
||||
@@ -202,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
|
||||
|
||||
+220
-1
@@ -1,7 +1,14 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Tuple
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple, TYPE_CHECKING
|
||||
import logging
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from app.database.models import User, PromoGroup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -68,6 +75,218 @@ def apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]:
|
||||
return discounted_amount, discount_value
|
||||
|
||||
|
||||
def resolve_discount_percent(
|
||||
user: Optional["User"],
|
||||
promo_group: Optional["PromoGroup"],
|
||||
category: str,
|
||||
*,
|
||||
period_days: Optional[int] = None,
|
||||
) -> int:
|
||||
"""Определяет размер скидки для указанной категории."""
|
||||
|
||||
if user is not None:
|
||||
try:
|
||||
return user.get_promo_discount(category, period_days)
|
||||
except AttributeError: # pragma: no cover - defensive guard
|
||||
pass
|
||||
|
||||
if promo_group is not None:
|
||||
return promo_group.get_discount_percent(category, period_days)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
async def compute_simple_subscription_price(
|
||||
db: AsyncSession,
|
||||
params: Dict[str, Any],
|
||||
*,
|
||||
user: Optional["User"] = None,
|
||||
resolved_squad_uuids: Optional[Sequence[str]] = None,
|
||||
) -> Tuple[int, Dict[str, Any]]:
|
||||
"""Вычисляет стоимость простой подписки с учетом всех доплат и скидок."""
|
||||
|
||||
period_days = int(params.get("period_days", 30) or 30)
|
||||
attr_name = f"PRICE_{period_days}_DAYS"
|
||||
base_price_original = getattr(settings, attr_name, settings.BASE_SUBSCRIPTION_PRICE)
|
||||
|
||||
traffic_limit_raw = params.get("traffic_limit_gb")
|
||||
try:
|
||||
traffic_limit = int(traffic_limit_raw) if traffic_limit_raw is not None else None
|
||||
except (TypeError, ValueError): # pragma: no cover - defensive conversion
|
||||
traffic_limit = None
|
||||
|
||||
if traffic_limit is None or traffic_limit <= 0:
|
||||
# Default simple subscriptions already include unlimited traffic.
|
||||
traffic_price_original = 0
|
||||
else:
|
||||
traffic_price_original = settings.get_traffic_price(traffic_limit)
|
||||
|
||||
device_limit_raw = params.get("device_limit", settings.DEFAULT_DEVICE_LIMIT)
|
||||
try:
|
||||
device_limit = int(device_limit_raw)
|
||||
except (TypeError, ValueError): # pragma: no cover - defensive conversion
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
additional_devices = max(0, device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_original = additional_devices * settings.PRICE_PER_DEVICE
|
||||
|
||||
promo_group: Optional["PromoGroup"] = params.get("promo_group")
|
||||
|
||||
if promo_group is None:
|
||||
promo_group_id = params.get("promo_group_id")
|
||||
if promo_group_id:
|
||||
from app.database.crud.promo_group import get_promo_group_by_id
|
||||
|
||||
promo_group = await get_promo_group_by_id(db, int(promo_group_id))
|
||||
|
||||
if promo_group is None and user is not None:
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
|
||||
period_discount_percent = resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"period",
|
||||
period_days=period_days,
|
||||
)
|
||||
base_discount = base_price_original * period_discount_percent // 100
|
||||
discounted_base = base_price_original - base_discount
|
||||
|
||||
traffic_discount_percent = resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount = traffic_price_original * traffic_discount_percent // 100
|
||||
discounted_traffic = traffic_price_original - traffic_discount
|
||||
|
||||
devices_discount_percent = resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount = devices_price_original * devices_discount_percent // 100
|
||||
discounted_devices = devices_price_original - devices_discount
|
||||
|
||||
servers_discount_percent = resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_days,
|
||||
)
|
||||
|
||||
resolved_uuids: List[str] = []
|
||||
if resolved_squad_uuids:
|
||||
resolved_uuids.extend([uuid for uuid in resolved_squad_uuids if uuid])
|
||||
else:
|
||||
raw_squad = params.get("squad_uuid")
|
||||
if isinstance(raw_squad, (list, tuple, set)):
|
||||
resolved_uuids.extend([str(uuid) for uuid in raw_squad if uuid])
|
||||
elif raw_squad:
|
||||
resolved_uuids.append(str(raw_squad))
|
||||
|
||||
from app.database.crud.server_squad import get_server_squad_by_uuid
|
||||
|
||||
server_breakdown: List[Dict[str, Any]] = []
|
||||
servers_price_original = 0
|
||||
servers_discount_total = 0
|
||||
|
||||
for squad_uuid in resolved_uuids:
|
||||
server = await get_server_squad_by_uuid(db, squad_uuid)
|
||||
if not server:
|
||||
logger.warning(
|
||||
"SIMPLE_SUBSCRIPTION_PRICE_SERVER_NOT_FOUND | squad=%s",
|
||||
squad_uuid,
|
||||
)
|
||||
server_breakdown.append(
|
||||
{
|
||||
"uuid": squad_uuid,
|
||||
"name": None,
|
||||
"available": False,
|
||||
"original_price": 0,
|
||||
"discount": 0,
|
||||
"final_price": 0,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if not server.is_available or server.is_full:
|
||||
logger.warning(
|
||||
"SIMPLE_SUBSCRIPTION_PRICE_SERVER_UNAVAILABLE | squad=%s | available=%s | full=%s",
|
||||
squad_uuid,
|
||||
server.is_available,
|
||||
server.is_full,
|
||||
)
|
||||
server_breakdown.append(
|
||||
{
|
||||
"uuid": squad_uuid,
|
||||
"name": server.display_name,
|
||||
"available": False,
|
||||
"original_price": 0,
|
||||
"discount": 0,
|
||||
"final_price": 0,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
original_price = server.price_kopeks
|
||||
discount_value = original_price * servers_discount_percent // 100
|
||||
final_price = original_price - discount_value
|
||||
|
||||
servers_price_original += original_price
|
||||
servers_discount_total += discount_value
|
||||
|
||||
server_breakdown.append(
|
||||
{
|
||||
"uuid": squad_uuid,
|
||||
"name": server.display_name,
|
||||
"available": True,
|
||||
"original_price": original_price,
|
||||
"discount": discount_value,
|
||||
"final_price": final_price,
|
||||
}
|
||||
)
|
||||
|
||||
total_before_discount = (
|
||||
base_price_original
|
||||
+ traffic_price_original
|
||||
+ devices_price_original
|
||||
+ servers_price_original
|
||||
)
|
||||
|
||||
total_discount = (
|
||||
base_discount
|
||||
+ traffic_discount
|
||||
+ devices_discount
|
||||
+ servers_discount_total
|
||||
)
|
||||
|
||||
total_price = max(0, total_before_discount - total_discount)
|
||||
|
||||
breakdown = {
|
||||
"base_price": base_price_original,
|
||||
"base_discount": base_discount,
|
||||
"traffic_price": traffic_price_original,
|
||||
"traffic_discount": traffic_discount,
|
||||
"devices_price": devices_price_original,
|
||||
"devices_discount": devices_discount,
|
||||
"servers_price": servers_price_original,
|
||||
"servers_discount": servers_discount_total,
|
||||
"servers_final": sum(item["final_price"] for item in server_breakdown),
|
||||
"server_details": server_breakdown,
|
||||
"total_before_discount": total_before_discount,
|
||||
"total_discount": total_discount,
|
||||
"resolved_squad_uuids": resolved_uuids,
|
||||
"applied_promo_group_id": getattr(promo_group, "id", None) if promo_group else None,
|
||||
"period_discount_percent": period_discount_percent,
|
||||
"traffic_discount_percent": traffic_discount_percent,
|
||||
"devices_discount_percent": devices_discount_percent,
|
||||
"servers_discount_percent": servers_discount_percent,
|
||||
}
|
||||
|
||||
return total_price, breakdown
|
||||
|
||||
|
||||
def format_period_description(days: int, language: str = "ru") -> str:
|
||||
months = calculate_months_from_days(days)
|
||||
|
||||
|
||||
@@ -715,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(
|
||||
@@ -733,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))
|
||||
|
||||
@@ -1071,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")
|
||||
@@ -1163,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":
|
||||
@@ -1552,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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -78,6 +78,7 @@ services:
|
||||
- "${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`
|
||||
|
||||
@@ -15,6 +15,7 @@ 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
|
||||
@@ -74,6 +75,7 @@ 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
|
||||
@@ -304,6 +306,21 @@ async def main():
|
||||
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(
|
||||
"Служба мониторинга",
|
||||
"📈",
|
||||
@@ -436,6 +453,18 @@ async def main():
|
||||
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:
|
||||
@@ -491,6 +520,14 @@ async def main():
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "9f0f2d5a1c7b"
|
||||
down_revision: Union[str, None] = "8fd1e338eb45"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"polls",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"reward_enabled",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
sa.Column(
|
||||
"reward_amount_kopeks",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
sa.Column("created_by", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index("ix_polls_id", "polls", ["id"])
|
||||
|
||||
op.create_table(
|
||||
"poll_questions",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("poll_id", sa.Integer(), nullable=False),
|
||||
sa.Column("text", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"order",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
sa.ForeignKeyConstraint(["poll_id"], ["polls.id"], ondelete="CASCADE"),
|
||||
)
|
||||
op.create_index("ix_poll_questions_id", "poll_questions", ["id"])
|
||||
op.create_index("ix_poll_questions_poll_id", "poll_questions", ["poll_id"])
|
||||
|
||||
op.create_table(
|
||||
"poll_options",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("question_id", sa.Integer(), nullable=False),
|
||||
sa.Column("text", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"order",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
sa.ForeignKeyConstraint(["question_id"], ["poll_questions.id"], ondelete="CASCADE"),
|
||||
)
|
||||
op.create_index("ix_poll_options_id", "poll_options", ["id"])
|
||||
op.create_index("ix_poll_options_question_id", "poll_options", ["question_id"])
|
||||
|
||||
op.create_table(
|
||||
"poll_responses",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("poll_id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"sent_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column("started_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column(
|
||||
"reward_given",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
sa.Column(
|
||||
"reward_amount_kopeks",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
sa.ForeignKeyConstraint(["poll_id"], ["polls.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.UniqueConstraint("poll_id", "user_id", name="uq_poll_user"),
|
||||
)
|
||||
op.create_index("ix_poll_responses_id", "poll_responses", ["id"])
|
||||
op.create_index("ix_poll_responses_poll_id", "poll_responses", ["poll_id"])
|
||||
op.create_index("ix_poll_responses_user_id", "poll_responses", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"poll_answers",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("response_id", sa.Integer(), nullable=False),
|
||||
sa.Column("question_id", sa.Integer(), nullable=False),
|
||||
sa.Column("option_id", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.ForeignKeyConstraint(["option_id"], ["poll_options.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["question_id"], ["poll_questions.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["response_id"], ["poll_responses.id"], ondelete="CASCADE"),
|
||||
sa.UniqueConstraint("response_id", "question_id", name="uq_poll_answer_unique"),
|
||||
)
|
||||
op.create_index("ix_poll_answers_id", "poll_answers", ["id"])
|
||||
op.create_index("ix_poll_answers_response_id", "poll_answers", ["response_id"])
|
||||
op.create_index("ix_poll_answers_question_id", "poll_answers", ["question_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_poll_answers_question_id", table_name="poll_answers")
|
||||
op.drop_index("ix_poll_answers_response_id", table_name="poll_answers")
|
||||
op.drop_index("ix_poll_answers_id", table_name="poll_answers")
|
||||
op.drop_table("poll_answers")
|
||||
|
||||
op.drop_index("ix_poll_responses_user_id", table_name="poll_responses")
|
||||
op.drop_index("ix_poll_responses_poll_id", table_name="poll_responses")
|
||||
op.drop_index("ix_poll_responses_id", table_name="poll_responses")
|
||||
op.drop_table("poll_responses")
|
||||
|
||||
op.drop_index("ix_poll_options_question_id", table_name="poll_options")
|
||||
op.drop_index("ix_poll_options_id", table_name="poll_options")
|
||||
op.drop_table("poll_options")
|
||||
|
||||
op.drop_index("ix_poll_questions_poll_id", table_name="poll_questions")
|
||||
op.drop_index("ix_poll_questions_id", table_name="poll_questions")
|
||||
op.drop_table("poll_questions")
|
||||
|
||||
op.drop_index("ix_polls_id", table_name="polls")
|
||||
op.drop_table("polls")
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Глобальные фикстуры и настройки окружения для тестов."""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
@@ -149,3 +151,50 @@ if "yookassa" not in sys.modules:
|
||||
def fixed_datetime() -> datetime:
|
||||
"""Возвращает фиксированную отметку времени для воспроизводимых проверок."""
|
||||
return datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def pytest_configure(config: pytest.Config) -> None:
|
||||
"""Регистрируем маркеры для асинхронных тестов."""
|
||||
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"asyncio: запуск асинхронного теста через встроенный цикл событий",
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"anyio: запуск асинхронного теста через встроенный цикл событий",
|
||||
)
|
||||
|
||||
|
||||
def _unwrap_test(obj): # noqa: ANN001 - вспомогательная функция для определения coroutine
|
||||
"""Возвращает исходную функцию, снимая обёртки pytest и декораторов."""
|
||||
|
||||
unwrapped = obj
|
||||
while hasattr(unwrapped, "__wrapped__"):
|
||||
unwrapped = unwrapped.__wrapped__
|
||||
return unwrapped
|
||||
|
||||
|
||||
@pytest.hookimpl(tryfirst=True)
|
||||
def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> bool | None:
|
||||
"""Позволяет запускать async def тесты без дополнительных плагинов."""
|
||||
|
||||
test_func = _unwrap_test(pyfuncitem.obj)
|
||||
if not inspect.iscoroutinefunction(test_func):
|
||||
return None
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
asyncio.set_event_loop(loop)
|
||||
signature = inspect.signature(test_func)
|
||||
call_kwargs = {
|
||||
name: value
|
||||
for name, value in pyfuncitem.funcargs.items()
|
||||
if name in signature.parameters
|
||||
}
|
||||
loop.run_until_complete(pyfuncitem.obj(**call_kwargs))
|
||||
finally:
|
||||
asyncio.set_event_loop(None)
|
||||
loop.close()
|
||||
|
||||
return True
|
||||
|
||||
@@ -77,7 +77,7 @@ async def test_create_bill_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
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]["custom"] == {"extra": "value"}
|
||||
assert client.calls[0]["payment_method"] == "BANK_CARD"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
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]],
|
||||
*,
|
||||
info_response: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
self.response = response
|
||||
self.info_response = info_response
|
||||
self.calls: list[Dict[str, Any]] = []
|
||||
self.info_calls: list[Dict[str, Optional[str]]] = []
|
||||
|
||||
async def create_payment(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
self.calls.append(payload)
|
||||
return self.response
|
||||
|
||||
async def get_payment_info(
|
||||
self,
|
||||
*,
|
||||
uuid: Optional[str] = None,
|
||||
order_id: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
self.info_calls.append({"uuid": uuid, "order_id": order_id})
|
||||
return self.info_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
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_sync_heleket_payment_status_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
info_response = {
|
||||
"state": 0,
|
||||
"result": {
|
||||
"uuid": "heleket-uuid",
|
||||
"order_id": "order-123",
|
||||
"status": "paid",
|
||||
"payment_amount": "100.00",
|
||||
},
|
||||
}
|
||||
stub = StubHeleketService(response=None, info_response=info_response)
|
||||
service = _make_service(stub)
|
||||
db = DummySession()
|
||||
|
||||
payment = SimpleNamespace(
|
||||
id=55,
|
||||
uuid="heleket-uuid",
|
||||
order_id="order-123",
|
||||
status="check",
|
||||
user_id=7,
|
||||
)
|
||||
|
||||
async def fake_get_by_id(db, payment_id):
|
||||
assert payment_id == payment.id
|
||||
return payment
|
||||
|
||||
captured: Dict[str, Any] = {}
|
||||
|
||||
async def fake_process(self, db, payload, *, metadata_key):
|
||||
captured["payload"] = payload
|
||||
captured["metadata_key"] = metadata_key
|
||||
return SimpleNamespace(transaction_id=999, **payload)
|
||||
|
||||
monkeypatch.setattr(heleket_crud, "get_heleket_payment_by_id", fake_get_by_id, raising=False)
|
||||
monkeypatch.setattr(PaymentService, "_process_heleket_payload", fake_process, raising=False)
|
||||
|
||||
result = await service.sync_heleket_payment_status(db, local_payment_id=payment.id)
|
||||
|
||||
assert result is not None
|
||||
assert result.transaction_id == 999
|
||||
assert captured["metadata_key"] == "last_status_check"
|
||||
assert captured["payload"]["uuid"] == payment.uuid
|
||||
assert stub.info_calls == [{"uuid": payment.uuid, "order_id": payment.order_id}]
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_sync_heleket_payment_status_without_response(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
stub = StubHeleketService(response=None, info_response=None)
|
||||
service = _make_service(stub)
|
||||
db = DummySession()
|
||||
|
||||
payment = SimpleNamespace(
|
||||
id=12,
|
||||
uuid="heleket-uuid",
|
||||
order_id="order-123",
|
||||
status="check",
|
||||
user_id=5,
|
||||
)
|
||||
|
||||
async def fake_get_by_id(db, payment_id):
|
||||
assert payment_id == payment.id
|
||||
return payment
|
||||
|
||||
async def fake_process(*args, **kwargs): # pragma: no cover - ensure not called
|
||||
raise AssertionError("_process_heleket_payload should not be called")
|
||||
|
||||
monkeypatch.setattr(heleket_crud, "get_heleket_payment_by_id", fake_get_by_id, raising=False)
|
||||
monkeypatch.setattr(PaymentService, "_process_heleket_payload", fake_process, raising=False)
|
||||
|
||||
result = await service.sync_heleket_payment_status(db, local_payment_id=payment.id)
|
||||
|
||||
assert result is payment
|
||||
assert stub.info_calls == [{"uuid": payment.uuid, "order_id": payment.order_id}]
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ 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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -255,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()
|
||||
@@ -346,6 +490,7 @@ async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch)
|
||||
assert result is True
|
||||
assert transactions and transactions[0]["amount_kopeks"] == 10000
|
||||
assert payment.transaction_id == 999
|
||||
assert payment.is_paid is True
|
||||
assert user.balance_kopeks == 10000
|
||||
assert bot.sent_messages
|
||||
assert admin_calls
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ async def test_delete_user_cart_not_found(user_cart_service):
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_has_user_cart(user_cart_service, mock_redis):
|
||||
"""Тест проверки наличия корзины пользователя"""
|
||||
|
||||
Reference in New Issue
Block a user