Compare commits

...

59 Commits

Author SHA1 Message Date
Egor b036d090c6 Update Dockerfile 2025-10-21 11:31:44 +03:00
Egor d44b303dd3 Update docker-registry.yml 2025-10-21 11:31:34 +03:00
Egor e69bfdac20 Update docker-hub.yml 2025-10-21 11:31:20 +03:00
Egor 53f6c4e71b Merge pull request #1429 from Fr1ngg/dev4
Heleket
2025-10-21 11:22:48 +03:00
Egor c67223a332 Merge pull request #1428 from Fr1ngg/7mhcv8-bedolaga/move-heleket-to-payment-systems
Move Heleket settings into payment systems group
2025-10-21 11:21:57 +03:00
Egor 2c253382d5 Move Heleket settings into payment systems group 2025-10-21 11:21:37 +03:00
Egor 02b65cb1c9 Merge pull request #1427 from Fr1ngg/kbxpuv-bedolaga/add-heleket-balance-top-up-method
Polish Heleket payment integration
2025-10-21 11:13:09 +03:00
Egor 49ace78a68 Polish Heleket payment integration 2025-10-21 11:12:47 +03:00
Egor 0669086742 Merge pull request #1426 from Fr1ngg/k9cky4-bedolaga/add-balance-refill-method-for-heleket
Add Heleket payment provider integration
2025-10-21 10:59:36 +03:00
Egor 2f2d9bc1af Add Heleket payment provider integration 2025-10-21 10:59:19 +03:00
Egor 9b2b11ee44 Merge pull request #1425 from Fr1ngg/1n7xv6-bedolaga/update-simple-subscription-logic
Fix simple subscription start state data usage
2025-10-21 10:09:57 +03:00
Egor d318738596 Fix state data lookup during simple subscription start 2025-10-21 10:09:43 +03:00
Egor 6ae41369e9 Merge pull request #1424 from Fr1ngg/revert-1423-v20afv-bedolaga/update-simple-subscription-logic
Revert "Fix simple subscription button visibility and random squad assignment"
2025-10-21 10:09:19 +03:00
Egor ce9f2e0d41 Revert "Fix simple subscription button visibility and random squad assignment" 2025-10-21 10:09:08 +03:00
Egor aa7bd42375 Merge pull request #1423 from Fr1ngg/v20afv-bedolaga/update-simple-subscription-logic
Fix simple subscription button visibility and random squad assignment
2025-10-21 10:01:17 +03:00
Egor a6b19e4b95 Adjust simple subscription menu and squad assignment 2025-10-21 10:01:01 +03:00
Egor 7a21518ce2 Merge pull request #1422 from Fr1ngg/dev4
Dev4
2025-10-21 09:45:23 +03:00
Egor ba92f7da6c Merge pull request #1421 from Fr1ngg/52n798-bedolaga/escape-referrer-names-in-html-report
Escape referrer names in HTML report
2025-10-21 09:36:43 +03:00
Egor f62f37581f Escape referrer names in HTML report 2025-10-21 09:36:30 +03:00
Egor 0d08c2c7f0 Merge pull request #1420 from Fr1ngg/8jof80-bedolaga/fix-syntaxerror-in-purchase.py
Fix syntax issues in subscription purchase handler
2025-10-21 09:31:32 +03:00
Egor 078e59f09c Fix syntax issues in subscription purchase handler 2025-10-21 09:31:18 +03:00
Egor ab708a78a8 Merge pull request #1419 from Fr1ngg/mjma6t-bedolaga/integrate-reporting-service-code
feat: enhance reporting statistics
2025-10-21 09:26:09 +03:00
Egor 630688ecf5 feat: enhance reporting statistics 2025-10-21 09:25:53 +03:00
Egor 2b6901e87a Merge pull request #1418 from Fr1ngg/xfpbps-bedolaga/fix-button-in-subscription-renewal-section
Fix autopay day button label
2025-10-21 09:10:41 +03:00
Egor 0e43daf48b Fix autopay day button label 2025-10-21 09:10:26 +03:00
Egor e9badff1d8 Merge pull request #1417 from Fr1ngg/x4oldj-bedolaga/fix-subscription_config_back-button-behavior
Fix back navigation during subscription confirmation
2025-10-21 09:06:03 +03:00
Egor f588191931 Fix subscription back navigation restoring configuration 2025-10-21 09:05:44 +03:00
Egor e17dfbff35 Merge pull request #1415 from Fr1ngg/uq97y3-bedolaga/fix-back-button-issue-in-subscription-purchase
Handle back navigation after simple subscription QR message
2025-10-21 09:00:24 +03:00
Egor b7e9770588 Handle back navigation after simple subscription QR 2025-10-21 09:00:08 +03:00
Egor 9371524257 Merge pull request #1414 from Fr1ngg/main
w
2025-10-21 07:42:49 +03:00
Egor c1f8fe69cf Update requirements.txt 2025-10-21 00:04:51 +03:00
Egor 87e3449444 Update Dockerfile 2025-10-21 00:04:31 +03:00
Egor e8ef1bc255 Update requirements.txt 2025-10-21 00:00:50 +03:00
Egor fe3a593da0 Update requirements.txt 2025-10-20 23:58:29 +03:00
Egor bf96d85edb Update requirements.txt 2025-10-20 23:56:30 +03:00
Egor 700d718da6 Update Dockerfile 2025-10-20 23:52:48 +03:00
Egor 0c9ad3cec8 Merge pull request #1413 from Fr1ngg/revert-1295-dependabot/pip/python-dependencies-e109b03380
Revert "deps(deps): bump the python-dependencies group across 1 directory with 10 updates"
2025-10-20 23:48:46 +03:00
Egor 73d789d3f7 Revert "deps(deps): bump the python-dependencies group across 1 directory with 10 updates" 2025-10-20 23:48:35 +03:00
Egor 0a09ae4290 Update requirements.txt 2025-10-20 23:44:04 +03:00
Egor 0758988b5f Update requirements.txt 2025-10-20 23:42:11 +03:00
Egor 089f668e14 Merge pull request #1295 from Fr1ngg/dependabot/pip/python-dependencies-e109b03380
deps(deps): bump the python-dependencies group across 1 directory with 10 updates
2025-10-20 23:37:14 +03:00
dependabot[bot] 1e5bf143c4 deps(deps): bump the python-dependencies group across 1 directory with 10 updates
---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: sqlalchemy
  dependency-version: 2.0.44
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
- dependency-name: alembic
  dependency-version: 1.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pydantic
  dependency-version: 2.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pydantic-settings
  dependency-version: 2.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pyyaml
  dependency-version: 6.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
- dependency-name: fastapi
  dependency-version: 0.119.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: uvicorn
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: yookassa
  dependency-version: 3.7.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
- dependency-name: flask
  dependency-version: 3.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-20 07:03:13 +00:00
Egor d1132c82f8 Merge pull request #1309 from Fr1ngg/dependabot/docker/python-3.14-slim
docker(deps): bump python from 3.13-slim to 3.14-slim
2025-10-20 00:40:11 +03:00
Egor 62545276e4 Merge pull request #1296 from Fr1ngg/dependabot/pip/aiofiles-25.1.0
deps(deps): bump aiofiles from 23.2.1 to 25.1.0
2025-10-20 00:40:01 +03:00
Egor 2878bc3c3d Merge pull request #1412 from Gy9vin/main
Локализация и перенос кнопки простая покупка в главное меню на постоя…
2025-10-20 00:38:41 +03:00
gy9vin eed0fe39e7 Фикс расположения кнопок в главном меню 2025-10-19 16:23:40 +03:00
Mikhail 6bbee5586f Merge branch 'Fr1ngg:main' into main 2025-10-19 16:09:12 +03:00
gy9vin 7ac2354197 Локализация и перенос кнопки простая покупка в главное меню на постоянку! 2025-10-19 16:08:23 +03:00
Egor 3463f08162 Merge pull request #1411 from Gy9vin/main
Фимк мули в простой подписке
2025-10-19 16:01:14 +03:00
gy9vin 4b0aaa02c7 Фимк мули в простой подписке 2025-10-19 15:54:01 +03:00
Egor a946f7b501 Merge pull request #1410 from Gy9vin/main
Фикс простой покупки
2025-10-19 15:53:44 +03:00
gy9vin 7f2e4615ee Фикс простой покупки 2025-10-19 15:52:10 +03:00
Egor 28ab7719f5 Merge pull request #1409 from Gy9vin/main
Добовление всех платежек у простую покупку
2025-10-19 15:37:01 +03:00
gy9vin 31b5101f64 Исправление локализации 2025-10-19 15:23:09 +03:00
gy9vin 62777f8995 Возможность настройки простой покупки 2025-10-19 15:10:22 +03:00
gy9vin f9cff3e169 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-10-19 13:38:28 +03:00
gy9vin 04c255d748 Добовление оставшихся платежек в простую подписку 2025-10-19 13:33:56 +03:00
dependabot[bot] aae771764e docker(deps): bump python from 3.13-slim to 3.14-slim
Bumps python from 3.13-slim to 3.14-slim.

---
updated-dependencies:
- dependency-name: python
  dependency-version: 3.14-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-18 23:42:42 +00:00
dependabot[bot] 7be8757964 deps(deps): bump aiofiles from 23.2.1 to 25.1.0
Bumps [aiofiles](https://github.com/Tinche/aiofiles) from 23.2.1 to 25.1.0.
- [Release notes](https://github.com/Tinche/aiofiles/releases)
- [Changelog](https://github.com/Tinche/aiofiles/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Tinche/aiofiles/compare/v23.2.1...v25.1.0)

---
updated-dependencies:
- dependency-name: aiofiles
  dependency-version: 25.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-13 07:22:49 +00:00
42 changed files with 4031 additions and 242 deletions
+16
View File
@@ -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=
+3 -3
View File
@@ -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.4-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.5.3-dev-$(git rev-parse --short HEAD)"
VERSION="v2.5.4-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.5.3-pr-$(git rev-parse --short HEAD)"
VERSION="v2.5.4-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+3 -3
View File
@@ -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.4-$(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.4-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.4-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+1 -1
View File
@@ -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.4"
ARG BUILD_DATE
ARG VCS_REF
+11 -2
View File
@@ -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 (коды, группы, предложения, скидки, кампании) |
+42
View File
@@ -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"
+176
View File
@@ -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
+52
View File
@@ -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]:
+4 -6
View File
@@ -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:
+68 -1
View File
@@ -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 (
@@ -76,6 +76,7 @@ class PaymentMethod(Enum):
TRIBUTE = "tribute"
YOOKASSA = "yookassa"
CRYPTOBOT = "cryptobot"
HELEKET = "heleket"
MULENPAY = "mulenpay"
PAL24 = "pal24"
WATA = "wata"
@@ -190,6 +191,72 @@ class CryptoBotPayment(Base):
return f"<CryptoBotPayment(id={self.id}, invoice_id={self.invoice_id}, amount={self.amount} {self.asset}, status={self.status})>"
class HeleketPayment(Base):
__tablename__ = "heleket_payments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
uuid = Column(String(255), unique=True, nullable=False, index=True)
order_id = Column(String(128), unique=True, nullable=False, index=True)
amount = Column(String(50), nullable=False)
currency = Column(String(10), nullable=False)
payer_amount = Column(String(50), nullable=True)
payer_currency = Column(String(10), nullable=True)
exchange_rate = Column(Float, nullable=True)
discount_percent = Column(Integer, nullable=True)
status = Column(String(50), nullable=False)
payment_url = Column(Text, nullable=True)
metadata_json = Column(JSON, nullable=True)
paid_at = Column(DateTime, nullable=True)
expires_at = Column(DateTime, nullable=True)
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
user = relationship("User", backref="heleket_payments")
transaction = relationship("Transaction", backref="heleket_payment")
@property
def amount_float(self) -> float:
try:
return float(self.amount)
except (TypeError, ValueError):
return 0.0
@property
def amount_kopeks(self) -> int:
return int(round(self.amount_float * 100))
@property
def payer_amount_float(self) -> float:
try:
return float(self.payer_amount) if self.payer_amount is not None else 0.0
except (TypeError, ValueError):
return 0.0
@property
def is_paid(self) -> bool:
return self.status in {"paid", "paid_over"}
def __repr__(self):
return (
"<HeleketPayment(id={id}, uuid={uuid}, order_id={order_id}, amount={amount}"
" {currency}, status={status})>"
).format(
id=self.id,
uuid=self.uuid,
order_id=self.order_id,
amount=self.amount,
currency=self.currency,
status=self.status,
)
class MulenPayPayment(Base):
__tablename__ = "mulenpay_payments"
+125
View File
@@ -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": "Таблица политик конфиденциальности",
+129
View File
@@ -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
+111
View File
@@ -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 сервер корректно остановлен")
+247 -4
View File
@@ -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,
)
+210
View File
@@ -0,0 +1,210 @@
import logging
from typing import Optional
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
@error_handler
async def start_heleket_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
) -> None:
texts = get_texts(db_user.language)
if not settings.is_heleket_enabled():
await callback.answer("❌ Оплата через Heleket недоступна", show_alert=True)
return
markup = settings.get_heleket_markup_percent()
markup_text: Optional[str]
if markup > 0:
label = texts.t("PAYMENT_HELEKET_MARKUP_LABEL", "Наценка провайдера")
markup_text = f"{label}: {markup:.0f}%"
elif markup < 0:
label = texts.t("PAYMENT_HELEKET_DISCOUNT_LABEL", "Скидка провайдера")
markup_text = f"{label}: {abs(markup):.0f}%"
else:
markup_text = None
message_lines = [
"🪙 <b>Пополнение через Heleket</b>",
"\n",
"Введите сумму пополнения от 100 до 100,000 ₽:",
"",
"⚡ Мгновенное зачисление", "🔒 Безопасная оплата",
]
if markup_text:
message_lines.extend(["", markup_text])
keyboard = get_back_keyboard(db_user.language)
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_buttons = get_quick_amount_buttons(db_user.language)
if quick_buttons:
keyboard.inline_keyboard = quick_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
"\n".join(filter(None, message_lines)),
reply_markup=keyboard,
parse_mode="HTML",
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="heleket")
await callback.answer()
@error_handler
async def process_heleket_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
) -> None:
texts = get_texts(db_user.language)
if not settings.is_heleket_enabled():
await message.answer("❌ Оплата через Heleket недоступна")
return
amount_rubles = amount_kopeks / 100
if amount_rubles < 100:
await message.answer("Минимальная сумма пополнения: 100 ₽")
return
if amount_rubles > 100000:
await message.answer("Максимальная сумма пополнения: 100,000 ₽")
return
payment_service = PaymentService(message.bot)
result = await payment_service.create_heleket_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=f"Пополнение баланса на {amount_rubles:.0f}",
language=db_user.language,
)
if not result:
await message.answer(
"❌ Не удалось создать счёт в Heleket. Попробуйте позже или обратитесь в поддержку."
)
await state.clear()
return
payment_url = result.get("payment_url")
if not payment_url:
await message.answer("❌ Не удалось получить ссылку для оплаты Heleket")
await state.clear()
return
payer_amount = result.get("payer_amount")
payer_currency = result.get("payer_currency")
exchange_rate = result.get("exchange_rate")
discount_percent = result.get("discount_percent")
details = [
"🪙 <b>Оплата через Heleket</b>",
"",
f"💰 Сумма к зачислению: {amount_rubles:.0f}",
]
if payer_amount and payer_currency:
details.append(f"🪙 К оплате: {payer_amount} {payer_currency}")
markup_percent: Optional[float] = None
if discount_percent is not None:
try:
discount_int = int(discount_percent)
markup_percent = -discount_int
except (TypeError, ValueError):
markup_percent = None
if markup_percent:
label_markup = texts.t("PAYMENT_HELEKET_MARKUP_LABEL", "Наценка провайдера")
label_discount = texts.t("PAYMENT_HELEKET_DISCOUNT_LABEL", "Скидка провайдера")
absolute = abs(markup_percent)
if markup_percent > 0:
details.append(f"📈 {label_markup}: +{absolute}%")
else:
details.append(f"📉 {label_discount}: {absolute}%")
if payer_amount and payer_currency:
try:
payer_amount_float = float(payer_amount)
if payer_amount_float > 0:
rub_per_currency = amount_rubles / payer_amount_float
details.append(
f"💱 Курс: 1 {payer_currency}{rub_per_currency:.2f}"
)
except (TypeError, ValueError, ZeroDivisionError):
pass
details.extend(
[
"",
"📱 Инструкция:",
"1. Нажмите кнопку 'Оплатить'",
"2. Перейдите на страницу Heleket",
"3. Оплатите указанную сумму",
"4. Баланс пополнится автоматически",
]
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text=texts.t("PAY_WITH_COINS_BUTTON", "🪙 Оплатить"), url=payment_url)],
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
callback_data=f"check_heleket_{result['local_payment_id']}"
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
])
await message.answer("\n".join(details), parse_mode="HTML", reply_markup=keyboard)
await state.clear()
@error_handler
async def check_heleket_payment_status(
callback: types.CallbackQuery,
db: AsyncSession,
) -> None:
try:
local_payment_id = int(callback.data.split("_")[-1])
except (ValueError, IndexError):
await callback.answer("Некорректный идентификатор платежа", show_alert=True)
return
from app.database.crud.heleket import get_heleket_payment_by_id
payment = await get_heleket_payment_by_id(db, local_payment_id)
if not payment:
await callback.answer("Платёж не найден", show_alert=True)
return
if payment.is_paid:
await callback.answer("✅ Платёж уже оплачен", show_alert=True)
return
await callback.answer("Платёж ещё не оплачен", show_alert=True)
+30
View File
@@ -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,
File diff suppressed because it is too large Load Diff
+10
View File
@@ -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)
+109 -23
View File
@@ -5,6 +5,7 @@ from datetime import datetime, timedelta
from typing import Dict, List, Any, Tuple, Optional
from urllib.parse import quote
from aiogram import Dispatcher, types, F
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
@@ -80,14 +81,64 @@ from app.utils.promo_offer import (
)
from .common import _apply_promo_offer_discount, _get_promo_offer_discount_percent, logger, update_traffic_prices
from .autopay import handle_autopay_menu, handle_subscription_cancel, handle_subscription_config_back, set_autopay_days, show_autopay_days, toggle_autopay
from .countries import _get_available_countries, _should_show_countries_management, apply_countries_changes, countries_continue, handle_add_countries, handle_manage_country, select_country
from .devices import confirm_add_devices, confirm_change_devices, confirm_reset_devices, execute_change_devices, get_current_devices_count, get_servers_display_names, handle_all_devices_reset_from_management, handle_app_selection, handle_change_devices, handle_device_guide, handle_device_management, handle_devices_page, handle_reset_devices, handle_single_device_reset, handle_specific_app_guide, show_device_connection_help
from .happ import handle_happ_download_back, handle_happ_download_close, handle_happ_download_platform_choice, handle_happ_download_request
from .autopay import (
handle_autopay_menu,
handle_subscription_cancel,
handle_subscription_config_back,
set_autopay_days,
show_autopay_days,
toggle_autopay,
)
from .countries import (
_get_available_countries,
_should_show_countries_management,
apply_countries_changes,
countries_continue,
handle_add_countries,
handle_manage_country,
select_country,
)
from .devices import (
confirm_add_devices,
confirm_change_devices,
confirm_reset_devices,
execute_change_devices,
get_current_devices_count,
get_servers_display_names,
handle_all_devices_reset_from_management,
handle_app_selection,
handle_change_devices,
handle_device_guide,
handle_device_management,
handle_devices_page,
handle_reset_devices,
handle_single_device_reset,
handle_specific_app_guide,
show_device_connection_help,
)
from .happ import (
handle_happ_download_back,
handle_happ_download_close,
handle_happ_download_platform_choice,
handle_happ_download_request,
)
from .links import handle_connect_subscription, handle_open_subscription_link
from .pricing import _build_subscription_period_prompt, _prepare_subscription_summary
from .promo import _build_promo_group_discount_text, _get_promo_offer_hint, claim_discount_offer, handle_promo_offer_close
from .traffic import confirm_reset_traffic, confirm_switch_traffic, execute_switch_traffic, handle_no_traffic_packages, handle_reset_traffic, handle_switch_traffic, select_traffic
from .promo import (
_build_promo_group_discount_text,
_get_promo_offer_hint,
claim_discount_offer,
handle_promo_offer_close,
)
from .traffic import (
confirm_reset_traffic,
confirm_switch_traffic,
execute_switch_traffic,
handle_no_traffic_packages,
handle_reset_traffic,
handle_switch_traffic,
select_traffic,
)
async def show_subscription_info(
callback: types.CallbackQuery,
@@ -527,23 +578,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 +609,46 @@ async def start_subscription_purchase(
await state.set_state(SubscriptionStates.selecting_period)
await callback.answer()
async def _edit_message_text_or_caption(
message: types.Message,
text: str,
reply_markup: InlineKeyboardMarkup,
parse_mode: Optional[str] = "HTML",
) -> None:
"""Edits message text when possible, falls back to caption or re-sends message."""
try:
await message.edit_text(
text,
reply_markup=reply_markup,
parse_mode=parse_mode,
)
except TelegramBadRequest as error:
error_message = str(error).lower()
if "message is not modified" in error_message:
return
if "there is no text in the message to edit" in error_message:
if message.caption is not None:
await message.edit_caption(
caption=text,
reply_markup=reply_markup,
parse_mode=parse_mode,
)
return
await message.delete()
await message.answer(
text,
reply_markup=reply_markup,
parse_mode=parse_mode,
)
return
raise
async def save_cart_and_redirect_to_topup(
callback: types.CallbackQuery,
state: FSMContext,
@@ -2321,6 +2402,11 @@ async def handle_simple_subscription_purchase(
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
# Рассчитываем цену подписки
price_kopeks = _calculate_simple_subscription_price(subscription_params)
traffic_text = (
"Безлимит"
if subscription_params["traffic_limit_gb"] == 0
else f"{subscription_params['traffic_limit_gb']} ГБ"
)
if user_balance_kopeks >= price_kopeks:
# Если баланс достаточный, предлагаем оплатить с баланса
@@ -2328,7 +2414,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 +2432,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"
+60 -49
View File
@@ -257,7 +257,8 @@ def get_main_menu_keyboard(
"💰 Баланс: {balance}",
).format(balance=texts.format_price(balance_kopeks))
keyboard = []
keyboard: list[list[InlineKeyboardButton]] = []
paired_buttons: list[InlineKeyboardButton] = []
if has_active_subscription and subscription_is_active:
connect_mode = settings.CONNECT_BUTTON_MODE
@@ -312,22 +313,30 @@ def get_main_menu_keyboard(
happ_row = get_happ_download_button_row(texts)
if happ_row:
keyboard.append(happ_row)
keyboard.append([
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance"),
paired_buttons.append(
InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription")
])
else:
keyboard.append([
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")
])
)
keyboard.append([InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")])
show_trial = not has_had_paid_subscription and not has_active_subscription
show_buy = not has_active_subscription or not subscription_is_active
subscription_buttons = []
current_subscription = subscription
has_active_paid_subscription = bool(
current_subscription
and not getattr(current_subscription, "is_trial", False)
and getattr(current_subscription, "is_active", False)
)
simple_purchase_button = None
if settings.SIMPLE_SUBSCRIPTION_ENABLED and not has_active_paid_subscription:
simple_purchase_button = InlineKeyboardButton(
text=texts.MENU_SIMPLE_SUBSCRIPTION,
callback_data="simple_subscription_purchase",
)
subscription_buttons: list[InlineKeyboardButton] = []
if show_trial:
subscription_buttons.append(
InlineKeyboardButton(text=texts.MENU_TRIAL, callback_data="menu_trial")
@@ -337,48 +346,38 @@ 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 simple_purchase_button:
subscription_buttons.append(simple_purchase_button)
simple_purchase_button = None
if subscription_buttons:
if len(subscription_buttons) == 2:
keyboard.append(subscription_buttons)
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 +386,26 @@ def get_main_menu_keyboard(
except Exception:
support_enabled = settings.SUPPORT_MENU_ENABLED
if support_enabled:
keyboard.append([
paired_buttons.append(
InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support")
])
)
keyboard.append([
paired_buttons.append(
InlineKeyboardButton(
text=texts.t("MENU_INFO", "️ Инфо"),
callback_data="menu_info",
)
])
)
if settings.is_language_selection_enabled():
keyboard.append([
paired_buttons.append(
InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data="menu_language")
])
)
for i in range(0, len(paired_buttons), 2):
row = paired_buttons[i : i + 2]
keyboard.append(row)
if settings.DEBUG:
print(f"DEBUG KEYBOARD: is_admin={is_admin}, добавляем админ кнопку: {is_admin}")
@@ -1136,6 +1140,14 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
])
if settings.is_heleket_enabled():
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_HELEKET", "🪙 Криптовалюта (Heleket)"),
callback_data=_build_callback("heleket")
)
])
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_VIA_SUPPORT", "🛠️ Через поддержку"),
@@ -1372,7 +1384,7 @@ def get_autopay_days_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboar
for days in [1, 3, 7, 14]:
keyboard.append([
InlineKeyboardButton(
text=f"{days} дн{_get_days_suffix(days)}",
text=f"{days} {_get_days_word(days)}",
callback_data=f"autopay_days_{days}"
)
])
@@ -1384,13 +1396,12 @@ def get_autopay_days_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboar
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def _get_days_suffix(days: int) -> str:
if days == 1:
return "ь"
elif 2 <= days <= 4:
return "я"
else:
return "ей"
def _get_days_word(days: int) -> str:
if days % 10 == 1 and days % 100 != 11:
return "день"
if 2 <= days % 10 <= 4 and not (12 <= days % 100 <= 14):
return "дня"
return "дней"
+12 -1
View File
@@ -976,6 +976,7 @@
"PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)",
"PAYMENT_CHARGE_ERROR": "⚠️ Failed to charge the payment",
"PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)",
"PAYMENT_HELEKET": "🪙 Cryptocurrency (Heleket)",
"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 +984,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 +1002,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 +1352,11 @@
"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"
}
+13 -2
View File
@@ -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,7 @@
"PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)",
"PAYMENT_CHARGE_ERROR": "⚠️ Ошибка списания средств",
"PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)",
"PAYMENT_HELEKET": "🪙 Криптовалюта (Heleket)",
"PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:",
"PAYMENT_METHODS_ONLY_SUPPORT": "💳 <b>Способы пополнения баланса</b>\n\n⚠️ В данный момент автоматические способы оплаты временно недоступны.\nОбратитесь в техподдержку для пополнения баланса.\n\nВыберите способ пополнения:",
"PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:",
@@ -983,6 +984,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 +1002,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 +1352,11 @@
"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": "⚡ Простая покупка"
}
+2
View File
@@ -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",
+377
View File
@@ -0,0 +1,377 @@
"""Mixin with Heleket payment flow implementation."""
from __future__ import annotations
import logging
import secrets
import time
from datetime import datetime
from importlib import import_module
from typing import Any, Dict, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
class HeleketPaymentMixin:
"""Provides helpers to create and process Heleket payments."""
async def create_heleket_payment(
self,
db: AsyncSession,
user_id: int,
amount_kopeks: int,
description: str,
*,
language: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
if not getattr(self, "heleket_service", None):
logger.error("Heleket сервис не инициализирован")
return None
if amount_kopeks <= 0:
logger.error("Сумма Heleket должна быть положительной: %s", amount_kopeks)
return None
amount_rubles = amount_kopeks / 100
amount_str = f"{amount_rubles:.2f}"
order_id = f"heleket_{user_id}_{int(time.time())}_{secrets.token_hex(3)}"
markup_percent = settings.get_heleket_markup_percent()
discount_percent: Optional[int] = None
if markup_percent:
try:
rounded = int(round(markup_percent))
if rounded != 0:
discount_percent = -rounded
except (TypeError, ValueError):
logger.warning("Некорректная наценка Heleket: %s", markup_percent)
payload: Dict[str, Any] = {
"amount": amount_str,
"currency": "RUB",
"order_id": order_id,
"lifetime": settings.get_heleket_lifetime(),
}
to_currency = (settings.HELEKET_DEFAULT_CURRENCY or "").strip()
if to_currency:
payload["to_currency"] = to_currency
network = (settings.HELEKET_DEFAULT_NETWORK or "").strip()
if network:
payload["network"] = network
callback_url = settings.get_heleket_callback_url()
if callback_url:
payload["url_callback"] = callback_url
if settings.HELEKET_RETURN_URL:
payload["url_return"] = settings.HELEKET_RETURN_URL
if settings.HELEKET_SUCCESS_URL:
payload["url_success"] = settings.HELEKET_SUCCESS_URL
if discount_percent is not None:
payload["discount_percent"] = discount_percent
metadata: Dict[str, Any] = {
"language": language or settings.DEFAULT_LANGUAGE,
"created_at": datetime.utcnow().isoformat(),
}
try:
response = await self.heleket_service.create_payment(payload) # type: ignore[union-attr]
except Exception as error: # pragma: no cover - safety net
logger.exception("Ошибка создания Heleket платежа: %s", error)
return None
if not response:
logger.error("Heleket API вернул пустой ответ при создании платежа")
return None
payment_result = response.get("result") if isinstance(response, dict) else None
if not payment_result:
logger.error("Некорректный ответ Heleket API: %s", response)
return None
uuid = str(payment_result.get("uuid"))
response_order_id = payment_result.get("order_id")
if response_order_id:
order_id = str(response_order_id)
url = payment_result.get("url")
status = payment_result.get("status") or payment_result.get("payment_status") or "check"
payer_amount = payment_result.get("payer_amount")
payer_currency = payment_result.get("payer_currency")
exchange_rate = payment_result.get("payer_amount_exchange_rate")
try:
exchange_rate_value = float(exchange_rate) if exchange_rate is not None else None
except (TypeError, ValueError):
exchange_rate_value = None
if exchange_rate_value is None and payer_amount:
try:
exchange_rate_value = float(payer_amount) / amount_rubles if amount_rubles else None
except (TypeError, ValueError, ZeroDivisionError):
exchange_rate_value = None
expires_at_raw = payment_result.get("expired_at")
expires_at: Optional[datetime] = None
if expires_at_raw:
try:
expires_at = datetime.fromtimestamp(int(expires_at_raw))
except (TypeError, ValueError, OSError):
expires_at = None
heleket_crud = import_module("app.database.crud.heleket")
local_payment = await heleket_crud.create_heleket_payment(
db=db,
user_id=user_id,
uuid=uuid,
order_id=order_id,
amount=amount_str,
currency="RUB",
status=status,
payer_amount=payer_amount,
payer_currency=payer_currency,
exchange_rate=exchange_rate_value,
discount_percent=discount_percent,
payment_url=url,
expires_at=expires_at,
metadata={"raw_response": payment_result, **metadata},
)
logger.info(
"Создан Heleket платеж %s на %s₽ для пользователя %s",
uuid,
amount_str,
user_id,
)
return {
"local_payment_id": local_payment.id,
"uuid": uuid,
"order_id": order_id,
"amount": amount_str,
"amount_kopeks": amount_kopeks,
"payment_url": url,
"status": status,
"payer_amount": payer_amount,
"payer_currency": payer_currency,
"exchange_rate": exchange_rate_value,
"discount_percent": discount_percent,
}
async def process_heleket_webhook(
self,
db: AsyncSession,
payload: Dict[str, Any],
) -> bool:
if not isinstance(payload, dict):
logger.error("Heleket webhook payload не является словарём: %s", payload)
return False
heleket_crud = import_module("app.database.crud.heleket")
payment_module = import_module("app.services.payment_service")
uuid = str(payload.get("uuid") or "").strip()
order_id = str(payload.get("order_id") or "").strip()
status = payload.get("status") or payload.get("payment_status")
if not uuid and not order_id:
logger.error("Heleket webhook без uuid/order_id: %s", payload)
return False
payment = None
if uuid:
payment = await heleket_crud.get_heleket_payment_by_uuid(db, uuid)
if payment is None and order_id:
payment = await heleket_crud.get_heleket_payment_by_order_id(db, order_id)
if not payment:
logger.error(
"Heleket платеж не найден (uuid=%s order_id=%s)",
uuid,
order_id,
)
return False
payer_amount = payload.get("payer_amount") or payload.get("payment_amount")
payer_currency = payload.get("payer_currency") or payload.get("currency")
discount_percent = payload.get("discount_percent")
exchange_rate_raw = payload.get("payer_amount_exchange_rate")
payment_url = payload.get("url")
exchange_rate: Optional[float] = None
if exchange_rate_raw is not None:
try:
exchange_rate = float(exchange_rate_raw)
except (TypeError, ValueError):
exchange_rate = None
if exchange_rate is None and payer_amount:
try:
exchange_rate = float(payer_amount) / payment.amount_float if payment.amount_float else None
except (TypeError, ValueError, ZeroDivisionError):
exchange_rate = None
paid_at: Optional[datetime] = None
paid_at_raw = payload.get("paid_at") or payload.get("updated_at")
if paid_at_raw:
try:
if isinstance(paid_at_raw, (int, float)):
paid_at = datetime.utcfromtimestamp(float(paid_at_raw))
else:
paid_at = datetime.fromisoformat(str(paid_at_raw).replace("Z", "+00:00"))
except (ValueError, TypeError):
paid_at = None
updated_payment = await heleket_crud.update_heleket_payment(
db,
payment.uuid,
status=status,
payer_amount=str(payer_amount) if payer_amount is not None else None,
payer_currency=str(payer_currency) if payer_currency is not None else None,
exchange_rate=exchange_rate,
discount_percent=int(discount_percent) if isinstance(discount_percent, (int, float)) else None,
paid_at=paid_at,
payment_url=payment_url,
metadata={"last_webhook": payload},
)
if updated_payment is None:
return False
if updated_payment.transaction_id:
logger.info(
"Heleket платеж %s уже связан с транзакцией %s",
updated_payment.uuid,
updated_payment.transaction_id,
)
return True
status_normalized = (status or "").lower()
if status_normalized not in {"paid", "paid_over"}:
logger.info("Heleket платеж %s в статусе %s, зачисление не требуется", updated_payment.uuid, status)
return True
amount_kopeks = updated_payment.amount_kopeks
if amount_kopeks <= 0:
logger.error("Heleket платеж %s имеет некорректную сумму: %s", updated_payment.uuid, updated_payment.amount)
return False
transaction = await payment_module.create_transaction(
db,
user_id=updated_payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=(
"Пополнение через Heleket"
if not updated_payment.payer_currency
else (
"Пополнение через Heleket "
f"({updated_payment.payer_amount} {updated_payment.payer_currency})"
)
),
payment_method=PaymentMethod.HELEKET,
external_id=updated_payment.uuid,
is_completed=True,
)
await heleket_crud.link_heleket_payment_to_transaction(db, updated_payment.uuid, transaction.id)
get_user_by_id = payment_module.get_user_by_id
user = await get_user_by_id(db, updated_payment.user_id)
if not user:
logger.error("Пользователь %s не найден для Heleket платежа", updated_payment.user_id)
return False
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
amount_kopeks,
getattr(self, "bot", None),
)
except Exception as error: # pragma: no cover - defensive
logger.error("Ошибка реферального начисления Heleket: %s", error)
if was_first_topup and not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
if getattr(self, "bot", None):
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
referrer_info = format_referrer_info(user)
subscription = getattr(user, "subscription", None)
promo_group = getattr(user, "promo_group", None)
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error: # pragma: no cover
logger.error("Ошибка отправки админ-уведомления Heleket: %s", error)
try:
keyboard = await self.build_topup_success_keyboard(user)
exchange_rate_value = updated_payment.exchange_rate or 0
rate_text = (
f"💱 Курс: 1 RUB = {1 / exchange_rate_value:.4f} {updated_payment.payer_currency}"
if exchange_rate_value and updated_payment.payer_currency
else None
)
message_lines = [
"✅ <b>Пополнение успешно!</b>",
f"💰 Сумма: {settings.format_price(amount_kopeks)}",
"💳 Способ: Heleket",
]
if updated_payment.payer_amount and updated_payment.payer_currency:
message_lines.append(
f"🪙 Оплата: {updated_payment.payer_amount} {updated_payment.payer_currency}"
)
if rate_text:
message_lines.append(rate_text)
await self.bot.send_message(
chat_id=user.telegram_id,
text="\n".join(message_lines),
parse_mode="HTML",
reply_markup=keyboard,
)
except Exception as error: # pragma: no cover
logger.error("Ошибка отправки уведомления пользователю Heleket: %s", error)
return True
+33 -1
View File
@@ -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),
+321 -105
View File
@@ -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(",", " ")
+239 -6
View File
@@ -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
+2 -1
View File
@@ -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__)
+14
View File
@@ -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
+131 -1
View File
@@ -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,
+4
View File
@@ -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:
+1
View File
@@ -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:
+13 -1
View File
@@ -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`
+37
View File
@@ -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,158 @@
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
import pytest
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
from app.services.payment_service import PaymentService # noqa: E402
from app.database.crud import heleket as heleket_crud # noqa: E402
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
class DummySession:
def __init__(self) -> None:
self.added_objects: list[Any] = []
async def commit(self) -> None: # pragma: no cover - behaviour is mocked in tests
return None
async def refresh(self, obj: Any) -> None: # pragma: no cover
return None
def add(self, obj: Any) -> None: # pragma: no cover
self.added_objects.append(obj)
class DummyLocalPayment:
def __init__(self, payment_id: int = 123) -> None:
self.id = payment_id
self.created_at = datetime(2024, 1, 1, 12, 0, 0)
class StubHeleketService:
def __init__(self, response: Optional[Dict[str, Any]]) -> None:
self.response = response
self.calls: list[Dict[str, Any]] = []
async def create_payment(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
self.calls.append(payload)
return self.response
def _make_service(stub: Optional[StubHeleketService]) -> PaymentService:
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
service.bot = None
service.heleket_service = stub
service.yookassa_service = None
service.stars_service = None
service.cryptobot_service = None
service.mulenpay_service = None
service.pal24_service = None
service.wata_service = None
return service
@pytest.mark.anyio("asyncio")
async def test_create_heleket_payment_success(monkeypatch: pytest.MonkeyPatch) -> None:
response = {
"state": 0,
"result": {
"uuid": "heleket-uuid",
"order_id": "order-123",
"url": "https://heleket/pay",
"status": "check",
"payer_amount": "12.50",
"payer_currency": "USDT",
"discount_percent": -5,
"payer_amount_exchange_rate": "0.0125",
"expired_at": 1750000000,
},
}
stub = StubHeleketService(response)
service = _make_service(stub)
db = DummySession()
captured_args: Dict[str, Any] = {}
async def fake_create_heleket_payment(**kwargs: Any) -> DummyLocalPayment:
captured_args.update(kwargs)
return DummyLocalPayment(payment_id=555)
monkeypatch.setattr(
heleket_crud,
"create_heleket_payment",
fake_create_heleket_payment,
raising=False,
)
result = await service.create_heleket_payment(
db=db,
user_id=42,
amount_kopeks=15000,
description="Пополнение",
language="ru",
)
assert result is not None
assert result["local_payment_id"] == 555
assert result["uuid"] == "heleket-uuid"
assert result["order_id"] == "order-123"
assert result["payment_url"] == "https://heleket/pay"
assert stub.calls and stub.calls[0]["amount"] == "150.00"
assert captured_args["uuid"] == "heleket-uuid"
assert captured_args["user_id"] == 42
@pytest.mark.anyio("asyncio")
async def test_create_heleket_payment_returns_none_without_service() -> None:
service = _make_service(None)
db = DummySession()
result = await service.create_heleket_payment(
db=db,
user_id=1,
amount_kopeks=10000,
description="Пополнение",
)
assert result is None
@pytest.mark.anyio("asyncio")
async def test_create_heleket_payment_handles_empty_response(monkeypatch: pytest.MonkeyPatch) -> None:
stub = StubHeleketService(response=None)
service = _make_service(stub)
db = DummySession()
called = False
async def fake_create_heleket_payment(**kwargs: Any) -> DummyLocalPayment:
nonlocal called
called = True
return DummyLocalPayment()
monkeypatch.setattr(
heleket_crud,
"create_heleket_payment",
fake_create_heleket_payment,
raising=False,
)
result = await service.create_heleket_payment(
db=db,
user_id=1,
amount_kopeks=20000,
description="Пополнение",
)
assert result is None
assert called is False
@@ -11,6 +11,7 @@ if str(ROOT_DIR) not in sys.path:
from app.services.payment import ( # noqa: E402
CryptoBotPaymentMixin,
HeleketPaymentMixin,
MulenPayPaymentMixin,
Pal24PaymentMixin,
PaymentCommonMixin,
@@ -30,6 +31,7 @@ def test_payment_service_mro_contains_all_mixins() -> None:
YooKassaPaymentMixin,
TributePaymentMixin,
CryptoBotPaymentMixin,
HeleketPaymentMixin,
MulenPayPaymentMixin,
Pal24PaymentMixin,
WataPaymentMixin,
@@ -46,6 +48,7 @@ def test_payment_service_mro_contains_all_mixins() -> None:
"create_yookassa_payment",
"create_tribute_payment",
"create_cryptobot_payment",
"create_heleket_payment",
"create_mulenpay_payment",
"create_pal24_payment",
"create_wata_payment",
@@ -50,6 +50,7 @@ def _make_service(stub: Optional[StubMulenPayService]) -> PaymentService:
service.yookassa_service = None
service.stars_service = None
service.cryptobot_service = None
service.heleket_service = None
return service
@@ -61,6 +61,7 @@ def _make_service(stub: Optional[StubPal24Service]) -> PaymentService:
service.yookassa_service = None
service.cryptobot_service = None
service.stars_service = None
service.heleket_service = None
return service
@@ -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()
@@ -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