Compare commits

...

107 Commits

Author SHA1 Message Date
Egor d4e1544fc8 Update Python version to v2.5.3 in Dockerfile 2025-10-19 02:41:35 +03:00
Egor 6db5cae013 Update docker-registry.yml 2025-10-19 02:41:28 +03:00
Egor 61089a9f30 Update docker-hub.yml 2025-10-19 02:41:19 +03:00
Egor 3ad1f0fc97 Merge pull request #1408 from Fr1ngg/dev4
Dev4
2025-10-19 02:29:38 +03:00
Egor 67ac3c933f Merge pull request #1407 from Fr1ngg/g0v1v5-bedolaga/add-button-to-close-all-open-tickets
Add admin bulk close control for support tickets
2025-10-19 02:27:39 +03:00
Egor d6f4751274 Add bulk close control for admin tickets 2025-10-19 02:27:19 +03:00
Egor 6fdb02af10 Merge pull request #1406 from Fr1ngg/qbwjrp-bedolaga/fix-mulenpay-callback-handling
Handle alternative MulenPay webhook status fields
2025-10-19 02:25:09 +03:00
Egor ce456c4f7a Fix MulenPay webhook status parsing 2025-10-19 02:24:55 +03:00
Egor 314b17ce79 Merge pull request #1405 from Fr1ngg/revert-1404-zcb4ql-bedolaga/fix-mulenpay-callback-handling
Revert "Fix MulenPay callback paid status handling"
2025-10-19 02:24:41 +03:00
Egor 598d9ad0a1 Revert "Fix MulenPay callback paid status handling" 2025-10-19 02:23:39 +03:00
Egor 734711dab7 Merge pull request #1404 from Fr1ngg/zcb4ql-bedolaga/fix-mulenpay-callback-handling
Fix MulenPay callback paid status handling
2025-10-19 02:21:10 +03:00
Egor c4c5d6a018 Fix MulenPay callback marking payments as paid 2025-10-19 02:20:55 +03:00
Egor 883cd5a952 Merge pull request #1403 from Fr1ngg/p642y1-bedolaga/fix-paid-status-check-expiry-validation
Validate subscription expiry before treating paid status as active
2025-10-19 02:18:49 +03:00
Egor 8cf7445b99 Ensure paid subscriptions respect expiration dates 2025-10-19 02:18:32 +03:00
Egor 4c11af4644 Merge pull request #1402 from Fr1ngg/h2w24i-bedolaga/add-subscription-prompt-after-expiry
Fix subscription purchase availability after expiry
2025-10-19 02:12:11 +03:00
Egor 47036d5616 Fix subscription purchase availability after expiry 2025-10-19 02:11:55 +03:00
Egor 025330e052 Merge pull request #1401 from Fr1ngg/hc9da4-bedolaga/fix-typeerror-in-stage.skip-call
Fix stage skip message formatting
2025-10-19 02:04:14 +03:00
Egor 8d18c78fb2 Fix stage skip message format 2025-10-19 02:03:58 +03:00
Egor d93a2a0682 Merge pull request #1400 from Fr1ngg/7xyspp-bedolaga/add-urlpay-payment-method-for-mulenpay
feat: make MulenPay display name configurable
2025-10-19 02:01:48 +03:00
Egor 0c93516056 feat: make MulenPay display name configurable 2025-10-19 01:55:10 +03:00
Egor bf126bdadb Merge pull request #1399 from Fr1ngg/m7z0jw-bedolaga/fix-datetime-error-in-payment-processing
Fix WATA datetime timezone handling
2025-10-19 01:25:15 +03:00
Egor d421d43522 Fix WATA datetime timezone handling 2025-10-19 01:24:57 +03:00
Egor 29cda4dcd7 Merge pull request #1398 from Gy9vin/main
Простая покупка подписки!
2025-10-19 01:06:51 +03:00
gy9vin dde90f9ca4 продление подписки после пополнения баланса 2025-10-18 21:10:02 +03:00
gy9vin 8415c68063 Отправка конкретному пользователю сообщения непосредственно из карточки пользователя 2025-10-18 20:56:22 +03:00
gy9vin 9edde19b8a Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-10-18 19:14:28 +03:00
gy9vin 83a473606f Простая покупка подписки 2025-10-18 18:42:03 +03:00
Egor 849f06ad2b Merge pull request #1397 from Fr1ngg/dev4
Dev4
2025-10-18 02:17:57 +03:00
Egor e27e1299f7 Merge pull request #1396 from Fr1ngg/hppcmp-bedolaga/fix-wata-webhook-not-triggering
Allow WATA webhook generator to commit before responding
2025-10-18 02:17:27 +03:00
Egor 03a45e39d4 Let WATA webhook session generator finish before responding 2025-10-18 02:09:50 +03:00
Egor 4663464ea4 Merge pull request #1394 from Fr1ngg/revert-1392-94xvf3-bedolaga/fix-wata-webhook-not-triggering
Revert "Ensure WATA webhook lets session commit before responding"
2025-10-18 02:09:28 +03:00
Egor 338b98bae3 Revert "Ensure WATA webhook lets session commit before responding" 2025-10-18 02:09:19 +03:00
Egor 7326d64fbe Merge pull request #1393 from Fr1ngg/dev4
Dev4
2025-10-18 01:59:58 +03:00
Egor bee94bcad0 Merge pull request #1392 from Fr1ngg/94xvf3-bedolaga/fix-wata-webhook-not-triggering
Ensure WATA webhook lets session commit before responding
2025-10-18 01:59:22 +03:00
Egor 414b796700 Ensure WATA webhook session commits before responding 2025-10-18 01:58:33 +03:00
Egor f76abce939 Merge pull request #1391 from Fr1ngg/revert-1388-qnq9d9-bedolaga/fix-wata-webhook-not-triggering
Revert "Fix WATA webhook session handling"
2025-10-18 01:58:19 +03:00
Egor 99460665f1 Revert "Fix WATA webhook session handling" 2025-10-18 01:58:06 +03:00
Egor 730a6ae185 Merge pull request #1390 from Fr1ngg/dev4
Dev4
2025-10-18 01:51:14 +03:00
Egor ab8a5ca336 Merge pull request #1388 from Fr1ngg/qnq9d9-bedolaga/fix-wata-webhook-not-triggering
Fix WATA webhook session handling
2025-10-18 01:50:47 +03:00
Egor d1f0cf5854 Merge pull request #1389 from Fr1ngg/revert-1385-wigsy6-bedolaga/fix-wata-webhook-not-triggering
Revert "Expose payment webhook ports in docker-compose"
2025-10-18 01:50:38 +03:00
Egor 1f3c85e182 Revert "Expose payment webhook ports in docker-compose" 2025-10-18 01:50:30 +03:00
Egor bfcfe0de3d Fix WATA webhook session handling 2025-10-18 01:50:04 +03:00
Egor f55f745080 Merge pull request #1386 from Fr1ngg/dev4
Dev4
2025-10-18 01:40:35 +03:00
Egor a26524e6ea Merge pull request #1385 from Fr1ngg/wigsy6-bedolaga/fix-wata-webhook-not-triggering
Expose payment webhook ports in docker-compose
2025-10-18 01:39:51 +03:00
Egor b5f57d4d0e Expose payment webhook ports in compose 2025-10-18 01:39:32 +03:00
Egor 5aa9c1316e Merge pull request #1384 from Fr1ngg/revert-1383-gb3ntc-bedolaga/fix-wata-webhook-not-triggering
Revert "Add WATA webhook listener with RSA signature verification"
2025-10-18 01:39:18 +03:00
Egor 524b4f5e64 Revert "Add WATA webhook listener with RSA signature verification" 2025-10-18 01:39:08 +03:00
Egor a2bb0b8341 Merge pull request #1383 from Fr1ngg/gb3ntc-bedolaga/fix-wata-webhook-not-triggering
Add WATA webhook listener with RSA signature verification
2025-10-18 01:21:19 +03:00
Egor 9cabbf72d2 Add WATA webhook support with signature verification 2025-10-18 01:20:51 +03:00
Egor 5f368f84ce Merge pull request #1382 from Fr1ngg/bedolaga-3a5xcg
Fix quick top-up buttons for Stars and CryptoBot
2025-10-18 01:02:15 +03:00
Egor ed01d2bc6f Fix quick top-up buttons for stars and cryptobot 2025-10-18 01:01:56 +03:00
Egor 7107070624 Merge pull request #1381 from Fr1ngg/dev4
Dev4
2025-10-17 17:23:05 +03:00
Egor c361f0c1d4 Merge pull request #1379 from Fr1ngg/noejf1-bedolaga/fix-bot-not-recognizing-payment-webhook
Fix Pal24 cart reminder callback
2025-10-17 17:22:21 +03:00
Egor ab95bc2192 Merge pull request #1380 from Fr1ngg/revert-1377-gkxf4k-bedolaga/fix-bot-not-recognizing-payment-webhook
Revert "Handle Pal24 payments when webhooks are missing"
2025-10-17 17:22:09 +03:00
Egor bf6a68ae27 Revert "Handle Pal24 payments when webhooks are missing" 2025-10-17 17:21:58 +03:00
Egor 1083651591 Fix Pal24 cart reminder callback 2025-10-17 17:21:11 +03:00
Egor b35fb824ec Merge pull request #1378 from Fr1ngg/dev4
Рефактор палыча
2025-10-17 17:14:29 +03:00
Egor ad839fb63d Merge pull request #1377 from Fr1ngg/gkxf4k-bedolaga/fix-bot-not-recognizing-payment-webhook
Handle Pal24 payments when webhooks are missing
2025-10-17 17:09:25 +03:00
Egor 377a731ca2 Handle Pal24 payments when webhooks are missing 2025-10-17 17:08:59 +03:00
Egor 3395021fcf Merge pull request #1376 from Fr1ngg/main
w
2025-10-17 16:48:45 +03:00
Egor a4ce1a83be Merge pull request #1375 from Gy9vin/main
QR-код в ЮКассе для оплаты с другого телефона!
2025-10-17 16:48:06 +03:00
gy9vin 8b2f9e1803 Оплата по qr с другого телефона по СБП через ЮКасса 2025-10-17 11:20:08 +03:00
Mikhail a40c07a6af Merge branch 'Fr1ngg:main' into main 2025-10-17 09:20:11 +03:00
Egor 2dbc23fa80 Merge pull request #1374 from Fr1ngg/dev4
Dev4
2025-10-16 17:24:54 +03:00
Egor 233401c057 Merge branch 'main' into dev4 2025-10-16 17:23:52 +03:00
Egor 66f17f90fd Merge pull request #1371 from Fr1ngg/v2kjyk-bedolaga/fix-paypal-balance-top-up-error
Fix Pal24 payment method values
2025-10-16 17:21:51 +03:00
Egor 9748cc6b3e Merge pull request #1373 from Fr1ngg/revert-1369-58400a-bedolaga/fix-paypal-balance-top-up-error
Revert "Fix Pal24 custom payload serialization"
2025-10-16 17:21:29 +03:00
Egor bcab53cfe3 Revert "Fix Pal24 custom payload serialization" 2025-10-16 17:21:20 +03:00
Egor 1af387de2b Merge pull request #1372 from Fr1ngg/revert-1370-dev4
Revert "Dev4"
2025-10-16 17:21:08 +03:00
Egor 25c54c588b Revert "Dev4" 2025-10-16 17:20:58 +03:00
Egor 6ba7c6ed5e Fix Pal24 payment method values 2025-10-16 17:20:25 +03:00
Egor 222c1a488f Merge pull request #1370 from Fr1ngg/dev4
Dev4
2025-10-16 16:55:24 +03:00
Egor c1a92adf41 Merge pull request #1369 from Fr1ngg/58400a-bedolaga/fix-paypal-balance-top-up-error
Fix Pal24 custom payload serialization
2025-10-16 16:54:58 +03:00
Egor 972bb18344 Serialize Pal24 custom payloads 2025-10-16 16:54:37 +03:00
Egor de11de4cd0 Merge pull request #1368 from Fr1ngg/dev4
Dev4
2025-10-16 16:37:47 +03:00
Egor f8c3daac23 Merge pull request #1367 from Fr1ngg/gpcn99-bedolaga/fix-balance-top-up-via-pal24
Fix Pal24 payment method mapping
2025-10-16 16:37:24 +03:00
Egor 91a3b14a1f Fix Pal24 payment method mapping 2025-10-16 16:36:56 +03:00
Egor d310896168 Merge pull request #1366 from Fr1ngg/revert-1363-xa5gb6-bedolaga/fix-paypal-payment-creation-error
Revert "Retry Pal24 bill creation with multiple method identifiers"
2025-10-16 16:29:15 +03:00
Egor d4df56b173 Revert "Retry Pal24 bill creation with multiple method identifiers" 2025-10-16 16:29:07 +03:00
Egor 3eab0b3848 Merge pull request #1365 from Fr1ngg/revert-1361-revert-1360-dev4
Revert "Revert "Dev4""
2025-10-16 16:28:03 +03:00
Egor 609a557502 Revert "Revert "Dev4"" 2025-10-16 16:25:53 +03:00
Egor 6a4ebad89f Merge pull request #1363 from Fr1ngg/xa5gb6-bedolaga/fix-paypal-payment-creation-error
Retry Pal24 bill creation with multiple method identifiers
2025-10-16 16:22:13 +03:00
Egor e229b625f6 Retry Pal24 bill creation with multiple method identifiers 2025-10-16 16:19:24 +03:00
Egor 3ff5dec4d3 Merge pull request #1362 from Fr1ngg/revert-1359-fc1v04-bedolaga/fix-paypal-payment-creation-error
Revert "Fix Pal24 payment method mapping"
2025-10-16 16:08:37 +03:00
Egor 6dfd7db115 Revert "Fix Pal24 payment method mapping" 2025-10-16 16:08:28 +03:00
Egor 782dff4f82 Merge pull request #1361 from Fr1ngg/revert-1360-dev4
Revert "Dev4"
2025-10-16 16:08:14 +03:00
Egor 1540d2665f Revert "Dev4" 2025-10-16 16:08:03 +03:00
Egor 567d0c1a8d Merge pull request #1360 from Fr1ngg/dev4
Dev4
2025-10-16 01:38:44 +03:00
Egor 6b031d4e9c Merge pull request #1359 from Fr1ngg/fc1v04-bedolaga/fix-paypal-payment-creation-error
Fix Pal24 payment method mapping
2025-10-16 01:36:48 +03:00
Egor 72046d1ccf Fix Pal24 payment method mapping 2025-10-16 01:35:54 +03:00
Egor d41af2cd85 Merge pull request #1358 from Fr1ngg/main
w
2025-10-16 01:29:47 +03:00
Egor 174647c963 Update README.md 2025-10-16 01:21:50 +03:00
Egor e5e52b839c Merge pull request #1357 from Fr1ngg/dev4
Фикс палыча
2025-10-15 23:49:28 +03:00
Egor ab19becdc8 Delete miniapp/new/miniapp directory 2025-10-15 23:47:35 +03:00
Egor f5e9a6f2b9 Merge pull request #1356 from Fr1ngg/4dxbnt-bedolaga/fix-pal24-payment-system-issues
Fix Pal24 webhook and payment flows
2025-10-15 23:39:09 +03:00
Egor b492437b54 Fix Pal24 payment flows and webhook handling 2025-10-15 23:38:51 +03:00
Egor 07546da0fd Merge pull request #1355 from Fr1ngg/main
w
2025-10-15 22:25:35 +03:00
Egor fc4423d9de Merge pull request #1354 from yazhog/main
Снес из репозитория пользовательские локали
2025-10-15 22:24:26 +03:00
Mikhail c9a0f59e28 Merge pull request #1 from Fr1ngg/main
ошибки пофиксил
2025-10-15 21:50:16 +03:00
yazhog 2d31bc6f23 Merge pull request #46 from yazhog/codex/customize-locale-phrases-for-git-pull
Sync default locales with custom keys
2025-10-15 20:33:08 +03:00
yazhog dac2e1c85f docs: document locales directory setup 2025-10-15 20:32:42 +03:00
yazhog f4d9b0d219 Clarify locale directory permission warnings 2025-10-15 20:32:35 +03:00
yazhog 9b485bf822 Handle unwritable locale directories gracefully 2025-10-15 20:21:52 +03:00
yazhog af3e8eab4e Ensure user locales include default templates 2025-10-15 20:15:48 +03:00
yazhog 63d7500398 Remove user locale overrides from repo 2025-10-15 20:09:32 +03:00
yazhog 545f52f207 Sync default locales with custom keys 2025-10-15 20:02:37 +03:00
Egor 7fbb4cf2da Add files via upload 2025-10-15 08:14:17 +03:00
59 changed files with 6922 additions and 5258 deletions
+15 -3
View File
@@ -113,12 +113,24 @@ FIXED_TRAFFIC_LIMIT_GB=100
AVAILABLE_SUBSCRIPTION_PERIODS=30,90,180
AVAILABLE_RENEWAL_PERIODS=30,90,180
# ===== НАСТРОЙКИ ПРОСТОЙ ПОКУПКИ =====
# Включить упрощённую покупку из меню
SIMPLE_SUBSCRIPTION_ENABLED=false
# Стандартный период (должен совпадать с одним из AVAILABLE_SUBSCRIPTION_PERIODS)
SIMPLE_SUBSCRIPTION_PERIOD_DAYS=30
# Сколько устройств выдаётся в рамках простой подписки
SIMPLE_SUBSCRIPTION_DEVICE_LIMIT=1
# Лимит трафика в ГБ (0 — безлимит)
SIMPLE_SUBSCRIPTION_TRAFFIC_GB=0
# UUID сквада (оставьте пустым, чтобы использовать сквады по умолчанию)
# SIMPLE_SUBSCRIPTION_SQUAD_UUID=
# ===== ЦЕНЫ (в копейках) =====
BASE_SUBSCRIPTION_PRICE=0
# Цены за периоды
PRICE_14_DAYS=7000
PRICE_30_DAYS=9900
PRICE_30_DAYS=1000
PRICE_60_DAYS=25900
PRICE_90_DAYS=36900
PRICE_180_DAYS=69900
@@ -130,10 +142,10 @@ BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED=false
BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
# Выводимые пакеты трафика и их цены в копейках
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:0:true"
# Цена за дополнительное устройство (DEFAULT_DEVICE_LIMIT идет бесплатно!)
PRICE_PER_DEVICE=5000
PRICE_PER_DEVICE=10000
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
REFERRAL_PROGRAM_ENABLED=true
+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.2-$(git rev-parse --short HEAD)"
VERSION="v2.5.3-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.5.2-dev-$(git rev-parse --short HEAD)"
VERSION="v2.5.3-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.5.2-pr-$(git rev-parse --short HEAD)"
VERSION="v2.5.3-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.2-$(git rev-parse --short HEAD)"
VERSION="v2.5.3-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.5.2-dev-$(git rev-parse --short HEAD)"
VERSION="v2.5.3-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.5.2-pr-$(git rev-parse --short HEAD)"
VERSION="v2.5.3-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
-2
View File
@@ -15,8 +15,6 @@
# Разрешаем папку app/ и все её содержимое рекурсивно
!app/
!app/**
!locales/
!locales/**
!tests/
!tests/**
+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.2"
ARG VERSION="v2.5.3"
ARG BUILD_DATE
ARG VCS_REF
+655 -957
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -19,6 +19,7 @@ from app.handlers import (
start, menu, subscription, balance, promocode,
referral, support, server_status, common, tickets
)
from app.handlers import simple_subscription
from app.handlers.admin import (
main as admin_main,
users as admin_users,
@@ -162,7 +163,10 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_faq.register_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
simple_subscription.register_simple_subscription_handlers(dp)
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
logger.info("⚡ Зарегистрированы обработчики простой покупки")
logger.info("⚡ Зарегистрированы обработчики простой подписки")
try:
await maintenance_service.start_monitoring()
+22
View File
@@ -182,6 +182,13 @@ class Settings(BaseSettings):
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
DISABLE_TOPUP_BUTTONS: bool = False
# Настройки простой покупки
SIMPLE_SUBSCRIPTION_ENABLED: bool = False
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
SIMPLE_SUBSCRIPTION_DEVICE_LIMIT: int = 1
SIMPLE_SUBSCRIPTION_TRAFFIC_GB: int = 0 # 0 означает безлимит
SIMPLE_SUBSCRIPTION_SQUAD_UUID: Optional[str] = None
PAYMENT_BALANCE_DESCRIPTION: str = "Пополнение баланса"
PAYMENT_SUBSCRIPTION_DESCRIPTION: str = "Оплата подписки"
PAYMENT_SERVICE_NAME: str = "Интернет-сервис"
@@ -205,6 +212,7 @@ class Settings(BaseSettings):
MULENPAY_SHOP_ID: Optional[int] = None
MULENPAY_BASE_URL: str = "https://mulenpay.ru/api"
MULENPAY_WEBHOOK_PATH: str = "/mulenpay-webhook"
MULENPAY_DISPLAY_NAME: str = "Mulen Pay"
MULENPAY_DESCRIPTION: str = "Пополнение баланса"
MULENPAY_LANGUAGE: str = "ru"
MULENPAY_VAT_CODE: int = 0
@@ -245,6 +253,11 @@ class Settings(BaseSettings):
WATA_MIN_AMOUNT_KOPEKS: int = 10000
WATA_MAX_AMOUNT_KOPEKS: int = 100000000
WATA_REQUEST_TIMEOUT: int = 30
WATA_WEBHOOK_PATH: str = "/wata-webhook"
WATA_WEBHOOK_HOST: str = "0.0.0.0"
WATA_WEBHOOK_PORT: int = 8085
WATA_PUBLIC_KEY_URL: Optional[str] = None
WATA_PUBLIC_KEY_CACHE_SECONDS: int = 3600
MAIN_MENU_MODE: str = "default"
CONNECT_BUTTON_MODE: str = "guide"
@@ -781,6 +794,15 @@ class Settings(BaseSettings):
and self.MULENPAY_SHOP_ID is not None
)
def get_mulenpay_display_name(self) -> str:
name = (self.MULENPAY_DISPLAY_NAME or "").strip()
if not name:
return "Mulen Pay"
return name
def get_mulenpay_display_name_html(self) -> str:
return html.escape(self.get_mulenpay_display_name())
def is_pal24_enabled(self) -> bool:
return (
self.PAL24_ENABLED
+3 -1
View File
@@ -5,6 +5,7 @@ from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import MulenPayPayment
logger = logging.getLogger(__name__)
@@ -40,7 +41,8 @@ async def create_mulenpay_payment(
await db.refresh(payment)
logger.info(
"Создан MulenPay платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s",
"Создан %s платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s",
settings.get_mulenpay_display_name(),
payment.mulen_payment_id,
uuid,
amount_kopeks,
+4
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, Dict, Optional
from sqlalchemy import select, update
@@ -87,6 +88,7 @@ async def update_pal24_payment_status(
status: str,
is_active: Optional[bool] = None,
is_paid: Optional[bool] = None,
paid_at: Optional[datetime] = None,
payment_id: Optional[str] = None,
payment_status: Optional[str] = None,
payment_method: Optional[str] = None,
@@ -103,6 +105,8 @@ async def update_pal24_payment_status(
update_values["is_active"] = is_active
if is_paid is not None:
update_values["is_paid"] = is_paid
if paid_at is not None:
update_values["paid_at"] = paid_at
if payment_id is not None:
update_values["payment_id"] = payment_id
if payment_status is not None:
+144 -1
View File
@@ -30,6 +30,7 @@ async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Optiona
subscription = result.scalar_one_or_none()
if subscription:
logger.info(f"🔍 Загружена подписка {subscription.id} для пользователя {user_id}, статус: {subscription.status}")
subscription = await check_and_update_subscription_status(db, subscription)
return subscription
@@ -149,7 +150,7 @@ async def create_paid_subscription(
await db.commit()
await db.refresh(subscription)
logger.info(f"💎 Создана платная подписка для пользователя {user_id}")
logger.info(f"💎 Создана платная подписка для пользователя {user_id}, ID: {subscription.id}, статус: {subscription.status}")
squad_uuids = list(connected_squads or [])
if update_server_counters and squad_uuids:
@@ -223,6 +224,9 @@ async def extend_subscription(
if subscription.user:
subscription.user.has_had_paid_subscription = True
# Логируем статус подписки перед проверкой
logger.info(f"🔄 Продление подписки {subscription.id}, текущий статус: {subscription.status}, дни: {days}")
if days > 0 and subscription.status in (
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
@@ -234,6 +238,12 @@ async def extend_subscription(
subscription.id,
previous_status,
)
elif days > 0 and subscription.status == SubscriptionStatus.PENDING.value:
logger.warning(
"⚠️ Попытка продлить PENDING подписку %s, дни: %s",
subscription.id,
days
)
if settings.RESET_TRAFFIC_ON_PAYMENT:
subscription.traffic_used_gb = 0.0
@@ -1117,6 +1127,8 @@ async def check_and_update_subscription_status(
current_time = datetime.utcnow()
logger.info(f"🔍 Проверка статуса подписки {subscription.id}, текущий статус: {subscription.status}, дата окончания: {subscription.end_date}, текущее время: {current_time}")
if (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date <= current_time):
@@ -1127,6 +1139,8 @@ async def check_and_update_subscription_status(
await db.refresh(subscription)
logger.info(f"⏰ Статус подписки пользователя {subscription.user_id} изменен на 'expired'")
elif subscription.status == SubscriptionStatus.PENDING.value:
logger.info(f"ℹ️ Проверка PENDING подписки {subscription.id}, статус остается без изменений")
return subscription
@@ -1183,3 +1197,132 @@ async def create_subscription(
logger.info(f"✅ Создана подписка для пользователя {user_id}")
return subscription
async def create_pending_subscription(
db: AsyncSession,
user_id: int,
duration_days: int,
traffic_limit_gb: int = 0,
device_limit: int = 1,
connected_squads: List[str] = None,
payment_method: str = "pending",
total_price_kopeks: int = 0
) -> Subscription:
"""Creates a pending subscription that will be activated after payment."""
current_time = datetime.utcnow()
end_date = current_time + timedelta(days=duration_days)
existing_subscription = await get_subscription_by_user_id(db, user_id)
if existing_subscription:
if (
existing_subscription.status == SubscriptionStatus.ACTIVE.value
and existing_subscription.end_date > current_time
):
logger.warning(
"⚠️ Попытка создать pending подписку для активного пользователя %s. Возвращаем существующую запись.",
user_id,
)
return existing_subscription
existing_subscription.status = SubscriptionStatus.PENDING.value
existing_subscription.is_trial = False
existing_subscription.start_date = current_time
existing_subscription.end_date = end_date
existing_subscription.traffic_limit_gb = traffic_limit_gb
existing_subscription.device_limit = device_limit
existing_subscription.connected_squads = connected_squads or []
existing_subscription.traffic_used_gb = 0.0
existing_subscription.updated_at = current_time
await db.commit()
await db.refresh(existing_subscription)
logger.info(
"♻️ Обновлена ожидающая подписка пользователя %s, ID: %s, метод оплаты: %s",
user_id,
existing_subscription.id,
payment_method,
)
return existing_subscription
subscription = Subscription(
user_id=user_id,
status=SubscriptionStatus.PENDING.value,
is_trial=False,
start_date=current_time,
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=connected_squads or [],
autopay_enabled=settings.is_autopay_enabled_by_default(),
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
)
db.add(subscription)
await db.commit()
await db.refresh(subscription)
logger.info(
"💳 Создана ожидающая подписка для пользователя %s, ID: %s, метод оплаты: %s",
user_id,
subscription.id,
payment_method,
)
return subscription
async def activate_pending_subscription(
db: AsyncSession,
user_id: int,
period_days: int = None
) -> Optional[Subscription]:
"""Активирует pending подписку пользователя, меняя её статус на ACTIVE."""
from sqlalchemy import and_
logger.info(f"Активация pending подписки: пользователь {user_id}, период {period_days} дней")
# Находим pending подписку пользователя
result = await db.execute(
select(Subscription)
.where(
and_(
Subscription.user_id == user_id,
Subscription.status == SubscriptionStatus.PENDING.value
)
)
)
pending_subscription = result.scalar_one_or_none()
if not pending_subscription:
logger.warning(f"Не найдена pending подписка для пользователя {user_id}")
return None
logger.info(f"Найдена pending подписка {pending_subscription.id} для пользователя {user_id}, статус: {pending_subscription.status}")
# Обновляем статус подписки на ACTIVE
current_time = datetime.utcnow()
pending_subscription.status = SubscriptionStatus.ACTIVE.value
# Если указан период, обновляем дату окончания
if period_days is not None:
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)
# Обновляем дату начала, если она не установлена или в прошлом
if not pending_subscription.start_date or pending_subscription.start_date < current_time:
pending_subscription.start_date = current_time
await db.commit()
await db.refresh(pending_subscription)
logger.info(f"Подписка пользователя {user_id} активирована, ID: {pending_subscription.id}")
return pending_subscription
+24
View File
@@ -274,6 +274,30 @@ class TicketCRUD:
db, ticket_id, TicketStatus.CLOSED.value, datetime.utcnow()
)
@staticmethod
async def close_all_open_tickets(
db: AsyncSession,
) -> List[int]:
"""Закрыть все открытые тикеты. Возвращает список идентификаторов закрытых тикетов."""
open_statuses = [TicketStatus.OPEN.value, TicketStatus.ANSWERED.value]
result = await db.execute(
select(Ticket.id).where(Ticket.status.in_(open_statuses))
)
ticket_ids = result.scalars().all()
if not ticket_ids:
return []
now = datetime.utcnow()
await db.execute(
update(Ticket)
.where(Ticket.id.in_(ticket_ids))
.values(status=TicketStatus.CLOSED.value, closed_at=now, updated_at=now)
)
await db.commit()
return ticket_ids
@staticmethod
async def add_support_audit(
db: AsyncSession,
+1
View File
@@ -54,6 +54,7 @@ class SubscriptionStatus(Enum):
ACTIVE = "active"
EXPIRED = "expired"
DISABLED = "disabled"
PENDING = "pending"
class TransactionType(Enum):
+6 -5
View File
@@ -3427,18 +3427,19 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей CryptoBot payments")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ MULEN PAY ===")
mulenpay_name = settings.get_mulenpay_display_name()
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ %s ===", mulenpay_name)
mulenpay_created = await create_mulenpay_payments_table()
if mulenpay_created:
logger.info("✅ Таблица Mulen Pay payments готова")
logger.info("✅ Таблица %s payments готова", mulenpay_name)
else:
logger.warning("⚠️ Проблемы с таблицей Mulen Pay payments")
logger.warning("⚠️ Проблемы с таблицей %s payments", mulenpay_name)
mulenpay_schema_ok = await ensure_mulenpay_payment_schema()
if mulenpay_schema_ok:
logger.info("✅ Схема Mulen Pay payments актуальна")
logger.info("✅ Схема %s payments актуальна", mulenpay_name)
else:
logger.warning("⚠️ Не удалось обновить схему Mulen Pay payments")
logger.warning("⚠️ Не удалось обновить схему %s payments", mulenpay_name)
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PAL24 ===")
pal24_created = await create_pal24_payments_table()
+12 -5
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
from concurrent.futures import TimeoutError as FuturesTimeoutError
import json
import logging
import threading
@@ -13,7 +14,7 @@ from flask import Flask, jsonify, request
from werkzeug.serving import make_server
from app.config import settings
from app.database.database import get_db
from app.database.database import AsyncSessionLocal
from app.services.pal24_service import Pal24Service, Pal24APIError
from app.services.payment_service import PaymentService
@@ -56,6 +57,8 @@ def create_pal24_flask_app(
logger.error("Pal24 webhook получен, но сервис не настроен")
return jsonify({"status": "error", "reason": "service_not_configured"}), 503
logger.debug("Получен Pal24 webhook: headers=%s", dict(request.headers))
payload = _normalize_payload()
if not payload:
logger.warning("Пустой Pal24 webhook")
@@ -68,15 +71,19 @@ def create_pal24_flask_app(
return jsonify({"status": "error", "reason": str(error)}), 400
async def process() -> bool:
async for db in get_db():
async with AsyncSessionLocal() as db:
try:
return await payment_service.process_pal24_postback(db, parsed_payload)
finally:
await db.close()
except Exception:
await db.rollback()
raise
try:
future = asyncio.run_coroutine_threadsafe(process(), loop)
processed = future.result()
processed = future.result(timeout=settings.PAL24_REQUEST_TIMEOUT)
except FuturesTimeoutError:
logger.error("Обработка Pal24 webhook превысила таймаут %sс", settings.PAL24_REQUEST_TIMEOUT)
return jsonify({"status": "error", "reason": "timeout"}), 504
except Exception as error: # pragma: no cover - defensive
logger.exception("Критическая ошибка обработки Pal24 webhook: %s", error)
return jsonify({"status": "error", "reason": "internal_error"}), 500
+262
View File
@@ -0,0 +1,262 @@
from __future__ import annotations
import asyncio
import base64
import json
import logging
from datetime import datetime, timedelta
from typing import Any, Dict, Optional
import aiohttp
from aiohttp import web
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from app.config import settings
from app.database.database import get_db
from app.services.payment_service import PaymentService
logger = logging.getLogger(__name__)
class WataPublicKeyProvider:
"""Loads and caches the WATA public key used for webhook signature validation."""
def __init__(self, *, cache_seconds: Optional[int] = None) -> None:
self._cache_seconds = cache_seconds or int(settings.WATA_PUBLIC_KEY_CACHE_SECONDS)
self._cached_key: Optional[str] = None
self._expires_at: Optional[datetime] = None
self._lock = asyncio.Lock()
async def get_public_key(self) -> Optional[str]:
"""Returns a cached public key or fetches a new one from WATA."""
now = datetime.utcnow()
if self._cached_key and self._expires_at and now < self._expires_at:
return self._cached_key
async with self._lock:
now = datetime.utcnow()
if self._cached_key and self._expires_at and now < self._expires_at:
return self._cached_key
key = await self._fetch_public_key()
if key:
self._cached_key = key
if self._cache_seconds > 0:
self._expires_at = datetime.utcnow() + timedelta(seconds=self._cache_seconds)
else:
self._expires_at = None
logger.debug("Получен и закеширован публичный ключ WATA")
return self._cached_key
if self._cached_key:
logger.warning("Используем ранее закешированный публичный ключ WATA")
return self._cached_key
logger.error("Публичный ключ WATA недоступен")
return None
async def _fetch_public_key(self) -> Optional[str]:
url = settings.WATA_PUBLIC_KEY_URL or f"{settings.WATA_BASE_URL.rstrip('/')}/public-key"
timeout = aiohttp.ClientTimeout(total=settings.WATA_REQUEST_TIMEOUT)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url) as response:
text = await response.text()
if response.status >= 400:
logger.error(
"Ошибка получения публичного ключа WATA %s: %s",
response.status,
text,
)
return None
try:
payload = await response.json()
except aiohttp.ContentTypeError:
logger.error("Ответ WATA public-key не является JSON: %s", text)
return None
if isinstance(payload, dict):
value = payload.get("value")
if value:
return value
logger.error("Ответ WATA public-key не содержит ключ: %s", payload)
else:
logger.error("Неожиданный формат ответа WATA public-key: %s", payload)
except Exception as error:
logger.error("Ошибка запроса публичного ключа WATA: %s", error)
return None
class WataWebhookHandler:
"""Processes webhook callbacks coming from WATA."""
def __init__(
self,
payment_service: PaymentService,
*,
public_key_provider: Optional[WataPublicKeyProvider] = None,
) -> None:
self.payment_service = payment_service
self.public_key_provider = public_key_provider or WataPublicKeyProvider()
async def _verify_signature(self, raw_body: str, signature: str) -> bool:
signature = (signature or "").strip()
if not signature:
logger.error("WATA webhook без подписи")
return False
public_key_pem = await self.public_key_provider.get_public_key()
if not public_key_pem:
logger.error("Публичный ключ WATA отсутствует, проверка подписи невозможна")
return False
try:
signature_bytes = base64.b64decode(signature)
except (ValueError, TypeError):
logger.error("Некорректная подпись WATA (не Base64)")
return False
try:
public_key = serialization.load_pem_public_key(public_key_pem.encode("utf-8"))
except ValueError as error:
logger.error("Ошибка загрузки публичного ключа WATA: %s", error)
return False
try:
public_key.verify(
signature_bytes,
raw_body.encode("utf-8"),
padding.PKCS1v15(),
hashes.SHA512(),
)
return True
except InvalidSignature:
logger.warning("Подпись WATA webhook не прошла проверку")
return False
except Exception as error:
logger.error("Ошибка проверки подписи WATA: %s", error)
return False
async def handle_webhook(self, request: web.Request) -> web.Response:
if not settings.is_wata_enabled():
logger.warning("Получен WATA webhook, но сервис отключен")
return web.json_response({"status": "error", "reason": "wata_disabled"}, status=503)
raw_body = await request.text()
if not raw_body:
logger.warning("Получен пустой WATA webhook")
return web.json_response({"status": "error", "reason": "empty_body"}, status=400)
signature = request.headers.get("X-Signature")
if not await self._verify_signature(raw_body, signature or ""):
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
try:
payload: Dict[str, Any] = json.loads(raw_body)
except json.JSONDecodeError:
logger.error("Некорректный JSON WATA webhook")
return web.json_response({"status": "error", "reason": "invalid_json"}, status=400)
logger.info(
"Получен WATA webhook: order_id=%s, status=%s",
payload.get("orderId"),
payload.get("transactionStatus"),
)
processed: Optional[bool] = None
async for db in get_db():
processed = await self.payment_service.process_wata_webhook(db, payload)
# Allow the generator to finish naturally so it can commit/rollback.
# get_db() yields only once, so exiting the loop body without breaking
# triggers the generator cleanup logic on the next iteration attempt.
if processed is None:
logger.error("Не удалось обработать WATA webhook: нет сессии БД")
return web.json_response({"status": "error", "reason": "db_session_unavailable"}, status=500)
if processed:
return web.json_response({"status": "ok"}, status=200)
return web.json_response({"status": "error", "reason": "not_processed"}, status=400)
async def health_check(self, request: web.Request) -> web.Response:
return web.json_response(
{
"status": "ok",
"service": "wata_webhook",
"enabled": settings.is_wata_enabled(),
"path": settings.WATA_WEBHOOK_PATH,
}
)
async def options_handler(self, _: web.Request) -> web.Response:
return web.Response(
status=200,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, X-Signature",
},
)
def create_wata_webhook_app(payment_service: PaymentService) -> web.Application:
app = web.Application()
handler = WataWebhookHandler(payment_service)
app.router.add_post(settings.WATA_WEBHOOK_PATH, handler.handle_webhook)
app.router.add_get(settings.WATA_WEBHOOK_PATH, handler.health_check)
app.router.add_options(settings.WATA_WEBHOOK_PATH, handler.options_handler)
app.router.add_get("/health", handler.health_check)
logger.info(
"Настроен WATA webhook endpoint на %s",
settings.WATA_WEBHOOK_PATH,
)
return app
async def start_wata_webhook_server(payment_service: PaymentService) -> None:
if not settings.is_wata_enabled():
logger.info("WATA отключен, webhook сервер не запускается")
return
app = create_wata_webhook_app(payment_service)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(
runner,
host=settings.WATA_WEBHOOK_HOST,
port=settings.WATA_WEBHOOK_PORT,
)
try:
await site.start()
logger.info(
"WATA webhook сервер запущен на %s:%s",
settings.WATA_WEBHOOK_HOST,
settings.WATA_WEBHOOK_PORT,
)
logger.info(
"WATA webhook URL: http://%s:%s%s",
settings.WATA_WEBHOOK_HOST,
settings.WATA_WEBHOOK_PORT,
settings.WATA_WEBHOOK_PATH,
)
while True:
await asyncio.sleep(1)
except asyncio.CancelledError:
logger.info("WATA webhook сервер остановлен по запросу")
finally:
await site.stop()
await runner.cleanup()
logger.info("WATA webhook сервер корректно остановлен")
+44 -15
View File
@@ -48,7 +48,12 @@ class WebhookServer:
logger.info(f"Webhook сервер настроен:")
logger.info(f" - Tribute webhook: POST {settings.TRIBUTE_WEBHOOK_PATH}")
if settings.is_mulenpay_enabled():
logger.info(f" - Mulen Pay webhook: POST {settings.MULENPAY_WEBHOOK_PATH}")
mulenpay_name = settings.get_mulenpay_display_name()
logger.info(
" - %s webhook: POST %s",
mulenpay_name,
settings.MULENPAY_WEBHOOK_PATH,
)
if settings.is_cryptobot_enabled():
logger.info(f" - CryptoBot webhook: POST {settings.CRYPTOBOT_WEBHOOK_PATH}")
logger.info(f" - Health check: GET /health")
@@ -84,8 +89,10 @@ class WebhookServer:
settings.TRIBUTE_WEBHOOK_PATH,
)
if settings.is_mulenpay_enabled():
mulenpay_name = settings.get_mulenpay_display_name()
logger.info(
"Mulen Pay webhook URL: http://%s:%s%s",
"%s webhook URL: http://%s:%s%s",
mulenpay_name,
settings.TRIBUTE_WEBHOOK_HOST,
settings.TRIBUTE_WEBHOOK_PORT,
settings.MULENPAY_WEBHOOK_PATH,
@@ -128,24 +135,37 @@ class WebhookServer:
async def _mulenpay_webhook_handler(self, request: web.Request) -> web.Response:
try:
logger.info(f"Получен Mulen Pay webhook: {request.method} {request.path}")
logger.info(f"MulenPay webhook headers: {dict(request.headers)}")
mulenpay_name = settings.get_mulenpay_display_name()
logger.info(
"%s webhook: %s %s",
mulenpay_name,
request.method,
request.path,
)
logger.info(
"%s webhook headers: %s",
mulenpay_name,
dict(request.headers),
)
raw_body = await request.read()
if not raw_body:
logger.warning("Пустой Mulen Pay webhook")
logger.warning("Пустой %s webhook", mulenpay_name)
return web.json_response({"status": "error", "reason": "empty_body"}, status=400)
# Временно отключаем проверку подписи для отладки
# TODO: Включить обратно после настройки MulenPay
if not self._verify_mulenpay_signature(request, raw_body):
logger.warning("MulenPay webhook signature verification failed, but processing anyway for debugging")
logger.warning(
"%s webhook signature verification failed, but processing anyway for debugging",
mulenpay_name,
)
# return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
try:
payload = json.loads(raw_body.decode('utf-8'))
except json.JSONDecodeError as error:
logger.error(f"Ошибка парсинга Mulen Pay webhook: {error}")
logger.error(f"Ошибка парсинга {mulenpay_name} webhook: {error}")
return web.json_response({"status": "error", "reason": "invalid_json"}, status=400)
payment_service = PaymentService(self.bot)
@@ -160,7 +180,10 @@ class WebhookServer:
return web.json_response({"status": "ok"}, status=200)
return web.json_response({"status": "error", "reason": "processing_failed"}, status=400)
except Exception as error:
logger.error(f"Ошибка обработки Mulen Pay webhook: {error}", exc_info=True)
logger.error(
f"Ошибка обработки {mulenpay_name} webhook: {error}",
exc_info=True,
)
return web.json_response({"status": "error", "reason": "internal_error"}, status=500)
finally:
try:
@@ -169,7 +192,11 @@ class WebhookServer:
pass
except Exception as error:
logger.error(f"Критическая ошибка Mulen Pay webhook: {error}", exc_info=True)
mulenpay_name = settings.get_mulenpay_display_name()
logger.error(
f"Критическая ошибка {mulenpay_name} webhook: {error}",
exc_info=True,
)
return web.json_response({"status": "error", "reason": "internal_error", "message": str(error)}, status=500)
@staticmethod
@@ -183,12 +210,13 @@ class WebhookServer:
@staticmethod
def _verify_mulenpay_signature(request: web.Request, raw_body: bytes) -> bool:
secret_key = settings.MULENPAY_SECRET_KEY
display_name = settings.get_mulenpay_display_name()
if not secret_key:
logger.error("Mulen Pay secret key is not configured")
logger.error("%s secret key is not configured", display_name)
return False
# Логируем все заголовки для отладки
logger.info("MulenPay webhook headers for signature verification:")
logger.info("%s webhook headers for signature verification:", display_name)
for header_name, header_value in request.headers.items():
if any(keyword in header_name.lower() for keyword in ['signature', 'sign', 'token', 'auth']):
logger.info(f" {header_name}: {header_value}")
@@ -239,7 +267,7 @@ class WebhookServer:
if hmac.compare_digest(normalized_signature_no_padding, expected_urlsafe_base64_signature.rstrip('=')):
return True
logger.error("Неверная подпись Mulen Pay webhook")
logger.error("Неверная подпись %s webhook", display_name)
return False
authorization_header = request.headers.get('Authorization')
@@ -252,7 +280,7 @@ class WebhookServer:
if hmac.compare_digest(token, secret_key):
return True
logger.error("Неверный %s токен Mulen Pay webhook", scheme)
logger.error("Неверный %s токен %s webhook", scheme, display_name)
return False
if not value and hmac.compare_digest(token, secret_key):
@@ -270,11 +298,12 @@ class WebhookServer:
return True
logger.info(
"Mulen Pay webhook headers received: %s",
"%s webhook headers received: %s",
display_name,
{key: value for key, value in request.headers.items() if 'authorization' not in key.lower()}
)
logger.error("Отсутствует подпись Mulen Pay webhook")
logger.error("Отсутствует подпись %s webhook", display_name)
return False
async def _tribute_webhook_handler(self, request: web.Request) -> web.Response:
+20 -6
View File
@@ -1303,7 +1303,10 @@ def _build_settings_keyboard(
label = texts.t("PAYMENT_CARD_TRIBUTE", "💳 Банковская карта (Tribute)")
test_payment_buttons.append([_test_button(f"{label} · тест", "tribute")])
elif category_key == "MULENPAY":
label = texts.t("PAYMENT_CARD_MULENPAY", "💳 Банковская карта (Mulen Pay)")
label = texts.t(
"PAYMENT_CARD_MULENPAY",
"💳 Банковская карта ({mulenpay_name})",
).format(mulenpay_name=settings.get_mulenpay_display_name())
test_payment_buttons.append([_test_button(f"{label} · тест", "mulenpay")])
elif category_key == "WATA":
label = texts.t("PAYMENT_CARD_WATA", "💳 Банковская карта (WATA)")
@@ -1834,8 +1837,13 @@ async def test_payment_provider(
return
if method == "mulenpay":
mulenpay_name = settings.get_mulenpay_display_name()
mulenpay_name_html = settings.get_mulenpay_display_name_html()
if not settings.is_mulenpay_enabled():
await callback.answer("❌ MulenPay отключен", show_alert=True)
await callback.answer(
f"{mulenpay_name} отключен",
show_alert=True,
)
return
amount_kopeks = 1 * 100
@@ -1843,18 +1851,21 @@ async def test_payment_provider(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description="Тестовый платеж MulenPay (админ)",
description=f"Тестовый платеж {mulenpay_name} (админ)",
language=language,
)
if not payment_result or not payment_result.get("payment_url"):
await callback.answer("❌ Не удалось создать платеж MulenPay", show_alert=True)
await callback.answer(
f"❌ Не удалось создать платеж {mulenpay_name}",
show_alert=True,
)
await _refresh_markup()
return
payment_url = payment_result["payment_url"]
message_text = (
"🧪 <b>Тестовый платеж MulenPay</b>\n\n"
f"🧪 <b>Тестовый платеж {mulenpay_name_html}</b>\n\n"
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
f"🆔 ID: {payment_result['mulen_payment_id']}"
)
@@ -1875,7 +1886,10 @@ async def test_payment_provider(
]
)
await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode="HTML")
await callback.answer("✅ Ссылка на платеж MulenPay отправлена", show_alert=True)
await callback.answer(
f"✅ Ссылка на платеж {mulenpay_name} отправлена",
show_alert=True,
)
await _refresh_markup()
return
+3
View File
@@ -207,6 +207,7 @@ async def show_support_audit(
'close_ticket': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET", "Закрытие тикета"),
'block_user_timed': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED", "Блокировка (время)"),
'block_user_perm': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM", "Блокировка (навсегда)"),
'close_all_tickets': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_ALL_TICKETS", "Массовое закрытие тикетов"),
'unblock_user': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK", "Снятие блока"),
}
action_text = action_map.get(log.action, log.action)
@@ -215,6 +216,8 @@ async def show_support_audit(
extra = ""
if log.action == 'block_user_timed' and 'minutes' in details:
extra = f" ({details['minutes']} мин)"
elif log.action == 'close_all_tickets' and 'count' in details:
extra = f" ({details['count']})"
lines.append(f"{ts}{role} <code>{log.actor_telegram_id}</code> — {action_text}{ticket_part}{extra}")
# keyboard with pagination
+77 -1
View File
@@ -461,6 +461,81 @@ async def mark_ticket_as_answered(
)
async def close_all_open_admin_tickets(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Закрыть все открытые тикеты."""
if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)):
texts = get_texts(db_user.language)
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
return
texts = get_texts(db_user.language)
try:
closed_ticket_ids = await TicketCRUD.close_all_open_tickets(db)
except Exception as error:
logger.error("Error closing all open tickets: %s", error)
await callback.answer(
texts.t("TICKET_UPDATE_ERROR", "❌ Ошибка при обновлении тикета."),
show_alert=True
)
return
closed_count = len(closed_ticket_ids)
if closed_count == 0:
await callback.answer(
texts.t("ADMIN_CLOSE_ALL_OPEN_TICKETS_EMPTY", "ℹ️ Нет открытых тикетов для закрытия."),
show_alert=True
)
return
try:
is_moderator = (
not settings.is_admin(callback.from_user.id)
and SupportSettingsService.is_moderator(callback.from_user.id)
)
await TicketCRUD.add_support_audit(
db,
actor_user_id=db_user.id if db_user else None,
actor_telegram_id=callback.from_user.id,
is_moderator=is_moderator,
action="close_all_tickets",
ticket_id=None,
target_user_id=None,
details={
"count": closed_count,
"ticket_ids": closed_ticket_ids,
}
)
except Exception as audit_error:
logger.warning("Failed to add support audit for bulk close: %s", audit_error)
# Обновляем список тикетов
await show_admin_tickets(callback, db_user, db)
success_text = texts.t(
"ADMIN_CLOSE_ALL_OPEN_TICKETS_SUCCESS",
"✅ Закрыто открытых тикетов: {count}"
).format(count=closed_count)
notification_keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
)
try:
await callback.message.answer(success_text, reply_markup=notification_keyboard)
except Exception:
# Если не удалось отправить отдельное сообщение, пробуем ответить алертом
try:
await callback.answer(success_text, show_alert=True)
except Exception:
pass
async def close_admin_ticket(
callback: types.CallbackQuery,
db_user: User,
@@ -935,7 +1010,8 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets")
dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets_scope_open")
dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets_scope_closed")
dp.callback_query.register(close_all_open_admin_tickets, F.data == "admin_tickets_close_all_open")
dp.callback_query.register(view_admin_ticket, F.data.startswith("admin_view_ticket_"))
# Ответы на тикеты
+113 -1
View File
@@ -2,6 +2,7 @@ import logging
from datetime import datetime, timedelta
from typing import Optional
from aiogram import Dispatcher, types, F
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
@@ -1644,6 +1645,107 @@ async def start_balance_edit(
await callback.answer()
@admin_required
@error_handler
async def start_send_user_message(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
user_id = int(callback.data.split('_')[-1])
target_user = await get_user_by_id(db, user_id)
if not target_user:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
await state.update_data(direct_message_user_id=user_id)
texts = get_texts(db_user.language)
prompt = (
texts.t("ADMIN_USER_SEND_MESSAGE_PROMPT",
"✉️ <b>Отправка сообщения пользователю</b>\n\n"
"Введите текст, который бот отправит пользователю."
"\n\nВы можете отменить действие командой /cancel или кнопкой ниже." )
)
await callback.message.edit_text(
prompt,
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_manage_{user_id}")]
]
),
parse_mode="HTML",
)
await state.set_state(AdminStates.sending_user_message)
await callback.answer()
@admin_required
@error_handler
async def process_send_user_message(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
data = await state.get_data()
user_id = data.get("direct_message_user_id")
if not user_id:
await message.answer(texts.t("ADMIN_USER_SEND_MESSAGE_ERROR_NOT_FOUND", "❌ Пользователь для отправки сообщения не найден"))
await state.clear()
return
target_user = await get_user_by_id(db, int(user_id))
if not target_user:
await message.answer(texts.t("ADMIN_USER_SEND_MESSAGE_ERROR_NOT_FOUND", "❌ Пользователь не найден или был удалён"))
await state.clear()
return
text = (message.text or "").strip()
if not text:
await message.answer(texts.t("ADMIN_USER_SEND_MESSAGE_EMPTY", "❌ Пожалуйста, введите непустое сообщение"))
return
confirmation_keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="👤 К пользователю", callback_data=f"admin_user_manage_{user_id}")]]
)
try:
await message.bot.send_message(target_user.telegram_id, text)
await message.answer(
texts.t("ADMIN_USER_SEND_MESSAGE_SUCCESS", "✅ Сообщение отправлено пользователю"),
reply_markup=confirmation_keyboard,
)
except TelegramForbiddenError:
await message.answer(
texts.t("ADMIN_USER_SEND_MESSAGE_FORBIDDEN", "⚠️ Пользователь заблокировал бота или не может получить сообщения."),
reply_markup=confirmation_keyboard,
)
except TelegramBadRequest as err:
logger.error("Ошибка отправки сообщения пользователю %s: %s", target_user.telegram_id, err)
await message.answer(
texts.t("ADMIN_USER_SEND_MESSAGE_BAD_REQUEST", "❌ Telegram отклонил сообщение. Проверьте текст и попробуйте ещё раз."),
reply_markup=confirmation_keyboard,
)
return
except Exception as err:
logger.error("Неожиданная ошибка отправки сообщения пользователю %s: %s", target_user.telegram_id, err)
await message.answer(
texts.t("ADMIN_USER_SEND_MESSAGE_ERROR", "❌ Не удалось отправить сообщение. Попробуйте позже."),
reply_markup=confirmation_keyboard,
)
await state.clear()
return
await state.clear()
@admin_required
@error_handler
async def process_balance_edit(
@@ -4014,11 +4116,21 @@ def register_handlers(dp: Dispatcher):
start_balance_edit,
F.data.startswith("admin_user_balance_")
)
dp.message.register(
process_balance_edit,
AdminStates.editing_user_balance
)
dp.callback_query.register(
start_send_user_message,
F.data.startswith("admin_user_send_message_")
)
dp.message.register(
process_send_user_message,
AdminStates.sending_user_message
)
dp.callback_query.register(
show_inactive_users,
+20 -1
View File
@@ -511,10 +511,24 @@ async def handle_quick_amount_selection(
await process_pal24_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "cryptobot":
from app.database.database import AsyncSessionLocal
from .cryptobot import process_cryptobot_payment_amount
async with AsyncSessionLocal() as db:
await process_cryptobot_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "stars":
from .stars import process_stars_payment_amount
await process_stars_payment_amount(
callback.message, db_user, amount_kopeks, state
)
else:
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
return
except ValueError:
await callback.answer("❌ Ошибка обработки суммы", show_alert=True)
except Exception as e:
@@ -652,6 +666,11 @@ def register_balance_handlers(dp: Dispatcher):
start_pal24_payment,
F.data == "topup_pal24"
)
from .pal24 import handle_pal24_method_selection
dp.callback_query.register(
handle_pal24_method_selection,
F.data.startswith("pal24_method_"),
)
from .yookassa import check_yookassa_payment_status
dp.callback_query.register(
+34 -17
View File
@@ -21,19 +21,28 @@ async def start_mulenpay_payment(
state: FSMContext,
):
texts = get_texts(db_user.language)
mulenpay_name = settings.get_mulenpay_display_name()
mulenpay_name_html = settings.get_mulenpay_display_name_html()
if not settings.is_mulenpay_enabled():
await callback.answer("❌ Оплата через Mulen Pay временно недоступна", show_alert=True)
await callback.answer(
f"❌ Оплата через {mulenpay_name} временно недоступна",
show_alert=True,
)
return
message_text = texts.t(
message_template = texts.t(
"MULENPAY_TOPUP_PROMPT",
(
"💳 <b>Оплата через Mulen Pay</b>\n\n"
"💳 <b>Оплата через {mulenpay_name_html}</b>\n\n"
"Введите сумму для пополнения от 100 до 100 000 ₽.\n"
"Оплата происходит через защищенную платформу Mulen Pay."
"Оплата происходит через защищенную платформу {mulenpay_name}."
),
)
message_text = message_template.format(
mulenpay_name=mulenpay_name,
mulenpay_name_html=mulenpay_name_html,
)
keyboard = get_back_keyboard(db_user.language)
@@ -63,9 +72,11 @@ async def process_mulenpay_payment_amount(
state: FSMContext,
):
texts = get_texts(db_user.language)
mulenpay_name = settings.get_mulenpay_display_name()
mulenpay_name_html = settings.get_mulenpay_display_name_html()
if not settings.is_mulenpay_enabled():
await message.answer("❌ Оплата через Mulen Pay временно недоступна")
await message.answer(f"❌ Оплата через {mulenpay_name} временно недоступна")
return
if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
@@ -96,8 +107,8 @@ async def process_mulenpay_payment_amount(
await message.answer(
texts.t(
"MULENPAY_PAYMENT_ERROR",
"❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.",
)
"❌ Ошибка создания платежа {mulenpay_name}. Попробуйте позже или обратитесь в поддержку.",
).format(mulenpay_name=mulenpay_name)
)
await state.clear()
return
@@ -112,8 +123,8 @@ async def process_mulenpay_payment_amount(
types.InlineKeyboardButton(
text=texts.t(
"MULENPAY_PAY_BUTTON",
"💳 Оплатить через Mulen Pay",
),
"💳 Оплатить через {mulenpay_name}",
).format(mulenpay_name=mulenpay_name),
url=payment_url,
)
],
@@ -132,11 +143,11 @@ async def process_mulenpay_payment_amount(
message_template = texts.t(
"MULENPAY_PAYMENT_INSTRUCTIONS",
(
"💳 <b>Оплата через Mulen Pay</b>\n\n"
"💳 <b>Оплата через {mulenpay_name_html}</b>\n\n"
"💰 Сумма: {amount}\n"
"🆔 ID платежа: {payment_id}\n\n"
"📱 <b>Инструкция:</b>\n"
"1. Нажмите кнопку 'Оплатить через Mulen Pay'\n"
"1. Нажмите кнопку 'Оплатить через {mulenpay_name}'\n"
"2. Следуйте подсказкам платежной системы\n"
"3. Подтвердите перевод\n"
"4. Средства зачислятся автоматически\n\n"
@@ -148,6 +159,8 @@ async def process_mulenpay_payment_amount(
amount=settings.format_price(amount_kopeks),
payment_id=payment_id_display,
support=settings.get_support_contact_display_html(),
mulenpay_name=mulenpay_name,
mulenpay_name_html=mulenpay_name_html,
)
await message.answer(
@@ -159,19 +172,20 @@ async def process_mulenpay_payment_amount(
await state.clear()
logger.info(
"Создан MulenPay платеж для пользователя %s: %s₽, ID: %s",
"Создан %s платеж для пользователя %s: %s₽, ID: %s",
mulenpay_name,
db_user.telegram_id,
amount_rubles,
payment_id_display,
)
except Exception as e:
logger.error(f"Ошибка создания MulenPay платежа: {e}")
logger.error(f"Ошибка создания {mulenpay_name} платежа: {e}")
await message.answer(
texts.t(
"MULENPAY_PAYMENT_ERROR",
"❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.",
)
"❌ Ошибка создания платежа {mulenpay_name}. Попробуйте позже или обратитесь в поддержку.",
).format(mulenpay_name=mulenpay_name)
)
await state.clear()
@@ -204,8 +218,9 @@ async def check_mulenpay_payment_status(
emoji, status_text = status_labels.get(payment.status, ("", "Неизвестно"))
mulenpay_name = settings.get_mulenpay_display_name()
message_lines = [
"💳 Статус платежа Mulen Pay:\n\n",
f"💳 Статус платежа {mulenpay_name}:\n\n",
f"🆔 ID: {payment.mulen_payment_id or payment.id}\n",
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n",
f"📊 Статус: {emoji} {status_text}\n",
@@ -234,5 +249,7 @@ async def check_mulenpay_payment_status(
await callback.answer(message_text, show_alert=True)
except Exception as e:
logger.error(f"Ошибка проверки статуса MulenPay: {e}")
logger.error(
f"Ошибка проверки статуса {settings.get_mulenpay_display_name()}: {e}"
)
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
+192 -74
View File
@@ -1,10 +1,12 @@
import html
import logging
from aiogram import types
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.database import AsyncSessionLocal
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
@@ -15,81 +17,27 @@ from app.states import BalanceStates
logger = logging.getLogger(__name__)
@error_handler
async def start_pal24_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_pal24_enabled():
await callback.answer("❌ Оплата через PayPalych временно недоступна", show_alert=True)
return
# Формируем текст сообщения в зависимости от доступных способов оплаты
if settings.is_pal24_sbp_button_visible() and settings.is_pal24_card_button_visible():
payment_methods_text = "СБП и банковской картой"
elif settings.is_pal24_sbp_button_visible():
payment_methods_text = "СБП"
elif settings.is_pal24_card_button_visible():
payment_methods_text = "банковской картой"
else:
# Если обе кнопки отключены, используем общий текст
payment_methods_text = "доступными способами"
message_text = texts.t(
"PAL24_TOPUP_PROMPT",
(
f"🏦 <b>Оплата через PayPalych ({payment_methods_text})</b>\n\n"
"Введите сумму для пополнения от 100 до 1 000 000 ₽.\n"
f"Оплата проходит через PayPalych ({payment_methods_text})."
),
)
keyboard = get_back_keyboard(db_user.language)
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="pal24")
await callback.answer()
def _get_available_pal24_methods() -> list[str]:
methods: list[str] = []
if settings.is_pal24_sbp_button_visible():
methods.append("sbp")
if settings.is_pal24_card_button_visible():
methods.append("card")
if not methods:
methods.append("sbp")
return methods
@error_handler
async def process_pal24_payment_amount(
async def _send_pal24_payment_message(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
payment_method: str,
state: FSMContext,
):
) -> None:
texts = get_texts(db_user.language)
if not settings.is_pal24_enabled():
await message.answer("❌ Оплата через PayPalych временно недоступна")
return
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
min_rubles = settings.PAL24_MIN_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Минимальная сумма для оплаты через PayPalych: {min_rubles:.0f}")
return
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
max_rubles = settings.PAL24_MAX_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Максимальная сумма для оплаты через PayPalych: {max_rubles:,.0f}".replace(',', ' '))
return
try:
payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_pal24_payment(
@@ -98,6 +46,7 @@ async def process_pal24_payment_amount(
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
language=db_user.language,
payment_method=payment_method,
)
if not payment_result:
@@ -262,14 +211,15 @@ async def process_pal24_payment_amount(
await state.clear()
logger.info(
"Создан PayPalych счет для пользователя %s: %s₽, ID: %s",
"Создан PayPalych счет для пользователя %s: %s₽, ID: %s, метод: %s",
db_user.telegram_id,
amount_kopeks / 100,
bill_id,
payment_method,
)
except Exception as e:
logger.error(f"Ошибка создания PayPalych платежа: {e}")
except Exception as error:
logger.error(f"Ошибка создания PayPalych платежа: {error}")
await message.answer(
texts.t(
"PAL24_PAYMENT_ERROR",
@@ -278,6 +228,168 @@ async def process_pal24_payment_amount(
)
await state.clear()
@error_handler
async def start_pal24_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_pal24_enabled():
await callback.answer("❌ Оплата через PayPalych временно недоступна", show_alert=True)
return
# Формируем текст сообщения в зависимости от доступных способов оплаты
if settings.is_pal24_sbp_button_visible() and settings.is_pal24_card_button_visible():
payment_methods_text = "СБП и банковской картой"
elif settings.is_pal24_sbp_button_visible():
payment_methods_text = "СБП"
elif settings.is_pal24_card_button_visible():
payment_methods_text = "банковской картой"
else:
# Если обе кнопки отключены, используем общий текст
payment_methods_text = "доступными способами"
message_text = texts.t(
"PAL24_TOPUP_PROMPT",
(
f"🏦 <b>Оплата через PayPalych ({payment_methods_text})</b>\n\n"
"Введите сумму для пополнения от 100 до 1 000 000 ₽.\n"
f"Оплата проходит через PayPalych ({payment_methods_text})."
),
)
keyboard = get_back_keyboard(db_user.language)
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="pal24")
await callback.answer()
@error_handler
async def process_pal24_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_pal24_enabled():
await message.answer("❌ Оплата через PayPalych временно недоступна")
return
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
min_rubles = settings.PAL24_MIN_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Минимальная сумма для оплаты через PayPalych: {min_rubles:.0f}")
return
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
max_rubles = settings.PAL24_MAX_AMOUNT_KOPEKS / 100
await message.answer(
f"❌ Максимальная сумма для оплаты через PayPalych: {max_rubles:,.0f}".replace(',', ' ')
)
return
available_methods = _get_available_pal24_methods()
if len(available_methods) == 1:
await _send_pal24_payment_message(
message,
db_user,
db,
amount_kopeks,
available_methods[0],
state,
)
return
await state.update_data(pal24_amount_kopeks=amount_kopeks)
await state.set_state(BalanceStates.waiting_for_pal24_method)
method_buttons: list[list[types.InlineKeyboardButton]] = []
if "sbp" in available_methods:
method_buttons.append(
[
types.InlineKeyboardButton(
text=settings.get_pal24_sbp_button_text(
texts.t("PAL24_SBP_PAY_BUTTON", "🏦 Оплатить через PayPalych (СБП)")
),
callback_data="pal24_method_sbp",
)
]
)
if "card" in available_methods:
method_buttons.append(
[
types.InlineKeyboardButton(
text=settings.get_pal24_card_button_text(
texts.t("PAL24_CARD_PAY_BUTTON", "💳 Оплатить банковской картой (PayPalych)")
),
callback_data="pal24_method_card",
)
]
)
method_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")])
await message.answer(
texts.t(
"PAL24_SELECT_PAYMENT_METHOD",
"Выберите способ оплаты PayPalych:",
),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=method_buttons),
)
@error_handler
async def handle_pal24_method_selection(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
data = await state.get_data()
amount_kopeks = data.get("pal24_amount_kopeks")
if not amount_kopeks:
texts = get_texts(db_user.language)
await callback.answer(
texts.t(
"PAL24_PAYMENT_ERROR",
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
),
show_alert=True,
)
await state.clear()
return
method = "sbp" if callback.data.endswith("_sbp") else "card"
await callback.answer()
async with AsyncSessionLocal() as db:
await _send_pal24_payment_message(
callback.message,
db_user,
db,
int(amount_kopeks),
method,
state,
)
@error_handler
async def check_pal24_payment_status(
@@ -353,11 +465,17 @@ async def check_pal24_payment_status(
])
await callback.answer()
await callback.message.edit_text(
"\n".join(message_lines),
reply_markup=keyboard,
disable_web_page_preview=True,
)
try:
await callback.message.edit_text(
"\n".join(message_lines),
reply_markup=keyboard,
disable_web_page_preview=True,
)
except TelegramBadRequest as error:
if "message is not modified" in str(error).lower():
await callback.answer(texts.t("CHECK_STATUS_NO_CHANGES", "Статус не изменился"))
else:
raise
except Exception as e:
logger.error(f"Ошибка проверки статуса PayPalych: {e}")
+114 -20
View File
@@ -246,34 +246,125 @@ async def process_yookassa_sbp_payment_amount(
return
confirmation_url = payment_result.get("confirmation_url")
if not confirmation_url:
await message.answer("❌ Ошибка получения ссылки для оплаты через СБП. Обратитесь в поддержку.")
qr_confirmation_data = payment_result.get("qr_confirmation_data")
if not confirmation_url and not qr_confirmation_data:
await message.answer("❌ Ошибка получения данных для оплаты через СБП. Обратитесь в поддержку.")
await state.clear()
return
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🏦 Оплатить через СБП", url=confirmation_url)],
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
# Подготовим QR-код для вставки в основное сообщение
qr_photo = None
if qr_confirmation_data:
try:
# Импортируем необходимые модули для генерации QR-кода
import base64
from io import BytesIO
import qrcode
from aiogram.types import BufferedInputFile
# Создаем QR-код из полученных данных
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(qr_confirmation_data)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# Сохраняем изображение в байты
img_bytes = BytesIO()
img.save(img_bytes, format='PNG')
img_bytes.seek(0)
qr_photo = BufferedInputFile(img_bytes.getvalue(), filename="qrcode.png")
except ImportError:
logger.warning("qrcode библиотека не установлена, QR-код не будет сгенерирован")
except Exception as e:
logger.error(f"Ошибка генерации QR-кода: {e}")
await message.answer(
f"🏦 <b>Оплата через СБП</b>\n\n"
# Если нет QR-данных из YooKassa, но есть URL, генерируем QR-код из URL
if not qr_photo and confirmation_url:
try:
# Импортируем необходимые модули для генерации QR-кода
import base64
from io import BytesIO
import qrcode
from aiogram.types import BufferedInputFile
# Создаем QR-код из URL
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(confirmation_url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# Сохраняем изображение в байты
img_bytes = BytesIO()
img.save(img_bytes, format='PNG')
img_bytes.seek(0)
qr_photo = BufferedInputFile(img_bytes.getvalue(), filename="qrcode.png")
except ImportError:
logger.warning("qrcode библиотека не установлена, QR-код не будет сгенерирован")
except Exception as e:
logger.error(f"Ошибка генерации QR-кода из URL: {e}")
# Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса
keyboard_buttons = []
# Добавляем кнопку оплаты, если доступна ссылка
if confirmation_url:
keyboard_buttons.append([types.InlineKeyboardButton(text="🔗 Перейти к оплате", url=confirmation_url)])
else:
# Если ссылка недоступна, предлагаем оплатить через ID платежа в приложении банка
keyboard_buttons.append([types.InlineKeyboardButton(text="📱 Оплатить в приложении банка", callback_data="temp_disabled")])
# Добавляем общие кнопки
keyboard_buttons.append([types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")])
keyboard_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")])
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
# Подготавливаем текст сообщения
message_text = (
f"🔗 <b>Оплата через СБП</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
f"📱 <b>Инструкция:</b>\n"
f"1. Нажмите кнопку 'Оплатить через СБП'\n"
f"2. Вас перенаправит в приложение вашего банка\n"
f"3. Подтвердите платеж через СБП\n"
f"4. Деньги поступят на баланс автоматически\n\n"
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
f"✅ Принимаем СБП от всех банков-участников\n\n"
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}",
reply_markup=keyboard,
parse_mode="HTML"
)
await state.clear()
# Добавляем инструкции в зависимости от доступных способов оплаты
if not confirmation_url:
message_text += (
f"📱 <b>Инструкция по оплате:</b>\n"
f"1. Откройте приложение вашего банка\n"
f"2. Найдите функцию оплаты по реквизитам или перевод по СБП\n"
f"3. Введите ID платежа: <code>{payment_result['yookassa_payment_id']}</code>\n"
f"4. Подтвердите платеж в приложении банка\n"
f"5. Деньги поступят на баланс автоматически\n\n"
)
message_text += (
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
f"✅ Принимаем СБП от всех банков-участников\n\n"
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}"
)
# Отправляем сообщение с инструкциями и клавиатурой
# Если есть QR-код, отправляем его как медиа-сообщение
if qr_photo:
# Используем метод отправки медиа-группы или фото с описанием
await message.answer_photo(
photo=qr_photo,
caption=message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
else:
# Если QR-код недоступен, отправляем обычное текстовое сообщение
await message.answer(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
logger.info(f"Создан платеж YooKassa СБП для пользователя {db_user.telegram_id}: "
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
@@ -284,6 +375,9 @@ async def process_yookassa_sbp_payment_amount(
await state.clear()
@error_handler
async def check_yookassa_payment_status(
callback: types.CallbackQuery,
+38 -28
View File
@@ -153,7 +153,7 @@ async def show_main_menu(
db_user.last_activity = datetime.utcnow()
await db.commit()
has_active_subscription = bool(db_user.subscription)
has_active_subscription = bool(db_user.subscription and db_user.subscription.is_active)
subscription_is_active = False
if db_user.subscription:
@@ -892,7 +892,7 @@ async def handle_back_to_menu(
texts = get_texts(db_user.language)
has_active_subscription = db_user.subscription is not None
has_active_subscription = bool(db_user.subscription and db_user.subscription.is_active)
subscription_is_active = False
if db_user.subscription:
@@ -946,64 +946,74 @@ async def handle_back_to_menu(
await callback.answer()
def _get_subscription_status(user: User, texts) -> str:
if not user.subscription:
subscription = getattr(user, "subscription", None)
if not subscription:
return texts.t("SUB_STATUS_NONE", "❌ Отсутствует")
subscription = user.subscription
current_time = datetime.utcnow()
if subscription.end_date <= current_time:
actual_status = (subscription.actual_status or "").lower()
end_date_text = subscription.end_date.strftime("%d.%m.%Y")
days_left = 0
if subscription.end_date > current_time:
days_left = (subscription.end_date - current_time).days
if actual_status == "pending":
return texts.t("SUBSCRIPTION_NONE", "❌ Нет активной подписки")
if actual_status == "disabled":
return texts.t("SUB_STATUS_DISABLED", "⚫ Отключена")
if actual_status == "expired":
return texts.t(
"SUB_STATUS_EXPIRED",
"🔴 Истекла\n📅 {end_date}",
).format(end_date=subscription.end_date.strftime('%d.%m.%Y'))
days_left = (subscription.end_date - current_time).days
if subscription.is_trial:
).format(end_date=end_date_text)
if actual_status == "trial":
if days_left > 1:
return texts.t(
"SUB_STATUS_TRIAL_ACTIVE",
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
).format(
end_date=subscription.end_date.strftime('%d.%m.%Y'),
end_date=end_date_text,
days=days_left,
)
elif days_left == 1:
if days_left == 1:
return texts.t(
"SUB_STATUS_TRIAL_TOMORROW",
"🎁 Тестовая подписка\n⚠️ истекает завтра!",
)
else:
return texts.t(
"SUB_STATUS_TRIAL_TODAY",
"🎁 Тестовая подписка\n⚠️ истекает сегодня!",
)
return texts.t(
"SUB_STATUS_TRIAL_TODAY",
"🎁 Тестовая подписка\n⚠️ истекает сегодня!",
)
else:
if actual_status == "active":
if days_left > 7:
return texts.t(
"SUB_STATUS_ACTIVE_LONG",
"💎 Активна\n📅 до {end_date} ({days} дн.)",
).format(
end_date=subscription.end_date.strftime('%d.%m.%Y'),
end_date=end_date_text,
days=days_left,
)
elif days_left > 1:
if days_left > 1:
return texts.t(
"SUB_STATUS_ACTIVE_FEW_DAYS",
"💎 Активна\n⚠️ истекает через {days} дн.",
).format(days=days_left)
elif days_left == 1:
if days_left == 1:
return texts.t(
"SUB_STATUS_ACTIVE_TOMORROW",
"💎 Активна\n⚠️ истекает завтра!",
)
else:
return texts.t(
"SUB_STATUS_ACTIVE_TODAY",
"💎 Активна\n⚠️ истекает сегодня!",
)
return texts.t(
"SUB_STATUS_ACTIVE_TODAY",
"💎 Активна\n⚠️ истекает сегодня!",
)
return texts.t("SUB_STATUS_UNKNOWN", "❓ Неизвестно")
def _insert_random_message(base_text: str, random_message: str, action_prompt: str) -> str:
+844
View File
@@ -0,0 +1,844 @@
"""Обработчики для простой покупки подписки."""
import logging
from typing import Optional, Dict, Any
from aiogram import types, F
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, get_happ_download_button_row
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.services.subscription_purchase_service import SubscriptionPurchaseService
from app.utils.decorators import error_handler
from app.states import SubscriptionStates
from app.utils.subscription_utils import get_display_subscription_link
logger = logging.getLogger(__name__)
@error_handler
async def start_simple_subscription_purchase(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
"""Начинает процесс простой покупки подписки."""
texts = get_texts(db_user.language)
if not settings.SIMPLE_SUBSCRIPTION_ENABLED:
await callback.answer("❌ Простая покупка подписки временно недоступна", show_alert=True)
return
# Проверяем, есть ли у пользователя активная подписка
from app.database.crud.subscription import get_subscription_by_user_id
current_subscription = await get_subscription_by_user_id(db, db_user.id)
if current_subscription and current_subscription.is_active:
await callback.answer("❌ У вас уже есть активная подписка", show_alert=True)
return
# Подготовим параметры простой подписки
subscription_params = {
"period_days": settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS,
"device_limit": settings.SIMPLE_SUBSCRIPTION_DEVICE_LIMIT,
"traffic_limit_gb": settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
"squad_uuid": settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID
}
# Сохраняем параметры в состояние
await state.update_data(subscription_params=subscription_params)
# Проверяем баланс пользователя
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
# Рассчитываем цену подписки
price_kopeks = _calculate_simple_subscription_price(subscription_params)
period_days = subscription_params["period_days"]
recorded_price = getattr(settings, f"PRICE_{period_days}_DAYS", price_kopeks)
direct_purchase_min_balance = recorded_price
extra_components = []
traffic_limit = subscription_params.get("traffic_limit_gb", 0)
if traffic_limit and traffic_limit > 0:
traffic_price = settings.get_traffic_price(traffic_limit)
direct_purchase_min_balance += traffic_price
extra_components.append(f"traffic={traffic_limit}GB->{traffic_price}")
device_limit = subscription_params.get("device_limit", 1)
if device_limit and device_limit > settings.DEFAULT_DEVICE_LIMIT:
additional_devices = device_limit - settings.DEFAULT_DEVICE_LIMIT
devices_price = additional_devices * settings.PRICE_PER_DEVICE
direct_purchase_min_balance += devices_price
extra_components.append(f"devices+{additional_devices}->{devices_price}")
logger.warning(
"SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base_price=%s | recorded_price=%s | extras=%s | total=%s | env_PRICE_30=%s",
db_user.id,
period_days,
price_kopeks,
recorded_price,
",".join(extra_components) if extra_components else "none",
direct_purchase_min_balance,
getattr(settings, "PRICE_30_DAYS", None),
)
can_pay_from_balance = user_balance_kopeks >= direct_purchase_min_balance
logger.warning(
"SIMPLE_SUBSCRIPTION_DEBUG_START_BALANCE | user=%s | balance=%s | min_required=%s | can_pay=%s",
db_user.id,
user_balance_kopeks,
direct_purchase_min_balance,
can_pay_from_balance,
)
message_text = (
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"🌍 Сервер: {'Любой доступный' 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"
+ (
"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты."
if can_pay_from_balance
else "Баланс пока недостаточный для мгновенной оплаты. Выберите подходящий способ оплаты:"
)
)
methods_keyboard = _get_simple_subscription_payment_keyboard(db_user.language)
keyboard_rows = []
if can_pay_from_balance:
keyboard_rows.append([
types.InlineKeyboardButton(
text="✅ Оплатить с баланса",
callback_data="simple_subscription_pay_with_balance",
)
])
keyboard_rows.extend(methods_keyboard.inline_keyboard)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await state.set_state(SubscriptionStates.waiting_for_simple_subscription_payment_method)
await callback.answer()
def _calculate_simple_subscription_price(params: dict) -> int:
"""Рассчитывает цену простой подписки."""
period_days = params.get("period_days", 30)
attr_name = f"PRICE_{period_days}_DAYS"
attr_value = getattr(settings, attr_name, None)
logger.warning(
"SIMPLE_SUBSCRIPTION_DEBUG_PRICE_FUNC | period=%s | attr=%s | attr_value=%s | base_price=%s",
period_days,
attr_name,
attr_value,
settings.BASE_SUBSCRIPTION_PRICE,
)
# Получаем цену для стандартного периода
if attr_value is not None:
return attr_value
else:
# Если нет цены для конкретного периода, используем базовую цену
return settings.BASE_SUBSCRIPTION_PRICE
def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyboardMarkup:
"""Создает клавиатуру с методами оплаты для простой подписки."""
texts = get_texts(language)
keyboard = []
# Добавляем доступные методы оплаты
if settings.TELEGRAM_STARS_ENABLED:
keyboard.append([types.InlineKeyboardButton(
text="⭐ Telegram Stars",
callback_data="simple_subscription_stars"
)])
if settings.is_yookassa_enabled():
yookassa_methods = []
if settings.YOOKASSA_SBP_ENABLED:
yookassa_methods.append(types.InlineKeyboardButton(
text="🏦 YooKassa (СБП)",
callback_data="simple_subscription_yookassa_sbp"
))
yookassa_methods.append(types.InlineKeyboardButton(
text="💳 YooKassa (Карта)",
callback_data="simple_subscription_yookassa"
))
if yookassa_methods:
keyboard.append(yookassa_methods)
if settings.is_cryptobot_enabled():
keyboard.append([types.InlineKeyboardButton(
text="🪙 CryptoBot",
callback_data="simple_subscription_cryptobot"
)])
if settings.is_mulenpay_enabled():
mulenpay_name = settings.get_mulenpay_display_name()
keyboard.append([types.InlineKeyboardButton(
text=f"💳 {mulenpay_name}",
callback_data="simple_subscription_mulenpay"
)])
if settings.is_pal24_enabled():
keyboard.append([types.InlineKeyboardButton(
text="💳 PayPalych",
callback_data="simple_subscription_pal24"
)])
if settings.is_wata_enabled():
keyboard.append([types.InlineKeyboardButton(
text="💳 WATA",
callback_data="simple_subscription_wata"
)])
# Кнопка назад
keyboard.append([types.InlineKeyboardButton(
text=texts.BACK,
callback_data="subscription_purchase"
)])
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
@error_handler
async def handle_simple_subscription_pay_with_balance(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
"""Обрабатывает оплату простой подписки с баланса."""
texts = get_texts(db_user.language)
data = await state.get_data()
subscription_params = data.get("subscription_params", {})
if not subscription_params:
await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True)
return
# Рассчитываем цену подписки
price_kopeks = _calculate_simple_subscription_price(subscription_params)
recorded_price = getattr(settings, f"PRICE_{subscription_params['period_days']}_DAYS", price_kopeks)
total_required = recorded_price
extras = []
traffic_limit = subscription_params.get("traffic_limit_gb", 0)
if traffic_limit and traffic_limit > 0:
traffic_price = settings.get_traffic_price(traffic_limit)
total_required += traffic_price
extras.append(f"traffic={traffic_limit}GB->{traffic_price}")
device_limit = subscription_params.get("device_limit", 1)
if device_limit and device_limit > settings.DEFAULT_DEVICE_LIMIT:
additional_devices = device_limit - settings.DEFAULT_DEVICE_LIMIT
devices_price = additional_devices * settings.PRICE_PER_DEVICE
total_required += devices_price
extras.append(f"devices+{additional_devices}->{devices_price}")
logger.warning(
"SIMPLE_SUBSCRIPTION_DEBUG_PAY_BALANCE | user=%s | period=%s | base_price=%s | extras=%s | total_required=%s | balance=%s",
db_user.id,
subscription_params["period_days"],
price_kopeks,
",".join(extras) if extras else "none",
total_required,
getattr(db_user, "balance_kopeks", 0),
)
# Проверяем баланс пользователя
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
if user_balance_kopeks < total_required:
await callback.answer("❌ Недостаточно средств на балансе для оплаты подписки", show_alert=True)
return
try:
# Списываем средства с баланса пользователя
from app.database.crud.user import subtract_user_balance
success = await subtract_user_balance(
db,
db_user,
price_kopeks,
f"Оплата подписки на {subscription_params['period_days']} дней",
consume_promo_offer=False,
)
if not success:
await callback.answer("❌ Ошибка списания средств с баланса", show_alert=True)
return
# Проверяем, есть ли у пользователя уже подписка
from app.database.crud.subscription import get_subscription_by_user_id, extend_subscription
existing_subscription = await get_subscription_by_user_id(db, db_user.id)
if existing_subscription:
# Если подписка уже существует, продлеваем её
subscription = await extend_subscription(
db=db,
subscription=existing_subscription,
days=subscription_params["period_days"]
)
# Обновляем параметры подписки
subscription.traffic_limit_gb = subscription_params["traffic_limit_gb"]
subscription.device_limit = subscription_params["device_limit"]
if subscription_params["squad_uuid"]:
subscription.connected_squads = [subscription_params["squad_uuid"]]
await db.commit()
await db.refresh(subscription)
else:
# Если подписки нет, создаём новую
from app.database.crud.subscription import create_paid_subscription
subscription = await create_paid_subscription(
db=db,
user_id=db_user.id,
duration_days=subscription_params["period_days"],
traffic_limit_gb=subscription_params["traffic_limit_gb"],
device_limit=subscription_params["device_limit"],
connected_squads=[subscription_params["squad_uuid"]] if subscription_params["squad_uuid"] else [],
update_server_counters=True,
)
if not subscription:
# Возвращаем средства на баланс в случае ошибки
from app.services.payment_service import add_user_balance
await add_user_balance(
db,
db_user.id,
price_kopeks,
f"Возврат средств за неудавшуюся подписку на {subscription_params['period_days']} дней",
)
await callback.answer("❌ Ошибка создания подписки. Средства возвращены на баланс.", show_alert=True)
return
# Обновляем баланс пользователя
await db.refresh(db_user)
# Обновляем или создаём ссылку подписки в RemnaWave
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
remnawave_user = await subscription_service.create_remnawave_user(db, subscription)
if remnawave_user:
await db.refresh(subscription)
except Exception as sync_error:
logger.error(f"Ошибка синхронизации подписки с RemnaWave для пользователя {db_user.id}: {sync_error}", exc_info=True)
# Отправляем уведомление об успешной покупке
success_message = (
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"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}\n\n"
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}\n"
f"💳 Ваш баланс: {settings.format_price(db_user.balance_kopeks)}\n\n"
f"🔗 Для подключения перейдите в раздел 'Подключиться'"
)
connect_mode = settings.CONNECT_BUTTON_MODE
subscription_link = get_display_subscription_link(subscription)
connect_button_text = texts.t("CONNECT_BUTTON", "🔗 Подключиться")
def _fallback_connect_button() -> types.InlineKeyboardButton:
return types.InlineKeyboardButton(
text=connect_button_text,
callback_data="subscription_connect",
)
if connect_mode == "miniapp_subscription":
if subscription_link:
connect_row = [
types.InlineKeyboardButton(
text=connect_button_text,
web_app=types.WebAppInfo(url=subscription_link),
)
]
else:
connect_row = [_fallback_connect_button()]
elif connect_mode == "miniapp_custom":
custom_url = settings.MINIAPP_CUSTOM_URL
if custom_url:
connect_row = [
types.InlineKeyboardButton(
text=connect_button_text,
web_app=types.WebAppInfo(url=custom_url),
)
]
else:
connect_row = [_fallback_connect_button()]
elif connect_mode == "link":
if subscription_link:
connect_row = [
types.InlineKeyboardButton(
text=connect_button_text,
url=subscription_link,
)
]
else:
connect_row = [_fallback_connect_button()]
elif connect_mode == "happ_cryptolink":
if subscription_link:
connect_row = [
types.InlineKeyboardButton(
text=connect_button_text,
callback_data="open_subscription_link",
)
]
else:
connect_row = [_fallback_connect_button()]
else:
connect_row = [_fallback_connect_button()]
keyboard_rows = [connect_row]
happ_row = get_happ_download_button_row(texts)
if happ_row:
keyboard_rows.append(happ_row)
keyboard_rows.append(
[types.InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
await callback.message.edit_text(
success_message,
reply_markup=keyboard,
parse_mode="HTML"
)
# Отправляем уведомление админам
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_purchase_notification(
db,
db_user,
subscription,
None, # transaction
subscription_params["period_days"],
False, # was_trial_conversion
amount_kopeks=price_kopeks,
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления админам о покупке: {e}")
await state.clear()
await callback.answer()
logger.info(f"Пользователь {db_user.telegram_id} успешно купил подписку с баланса на {price_kopeks/100}")
except Exception as error:
logger.error(
"Ошибка оплаты простой подписки с баланса для пользователя %s: %s",
db_user.id,
error,
exc_info=True,
)
await callback.answer(
"❌ Ошибка оплаты подписки. Попробуйте позже или обратитесь в поддержку.",
show_alert=True,
)
await state.clear()
@error_handler
async def handle_simple_subscription_pay_with_balance_disabled(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
"""Показывает уведомление, если баланса недостаточно для прямой оплаты."""
await callback.answer(
"❌ Недостаточно средств на балансе. Пополните баланс или выберите другой способ оплаты.",
show_alert=True,
)
@error_handler
async def handle_simple_subscription_other_payment_methods(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
"""Обрабатывает выбор других способов оплаты."""
texts = get_texts(db_user.language)
data = await state.get_data()
subscription_params = data.get("subscription_params", {})
if not subscription_params:
await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True)
return
# Рассчитываем цену подписки
price_kopeks = _calculate_simple_subscription_price(subscription_params)
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
recorded_price = getattr(settings, f"PRICE_{subscription_params['period_days']}_DAYS", price_kopeks)
total_required = recorded_price
if subscription_params.get("traffic_limit_gb", 0) > 0:
total_required += settings.get_traffic_price(subscription_params["traffic_limit_gb"])
if subscription_params.get("device_limit", 1) > settings.DEFAULT_DEVICE_LIMIT:
additional_devices = subscription_params["device_limit"] - settings.DEFAULT_DEVICE_LIMIT
total_required += additional_devices * settings.PRICE_PER_DEVICE
can_pay_from_balance = user_balance_kopeks >= total_required
logger.warning(
"SIMPLE_SUBSCRIPTION_DEBUG_METHODS | user=%s | balance=%s | base_price=%s | total_required=%s | can_pay=%s",
db_user.id,
user_balance_kopeks,
price_kopeks,
total_required,
can_pay_from_balance,
)
# Отображаем доступные методы оплаты
message_text = (
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"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}\n\n"
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n\n"
+ (
"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты:"
if can_pay_from_balance
else "Выберите подходящий способ оплаты:"
)
)
base_keyboard = _get_simple_subscription_payment_keyboard(db_user.language)
keyboard_rows = []
if can_pay_from_balance:
keyboard_rows.append([
types.InlineKeyboardButton(
text="✅ Оплатить с баланса",
callback_data="simple_subscription_pay_with_balance"
)
])
keyboard_rows.extend(base_keyboard.inline_keyboard)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await callback.answer()
@error_handler
async def handle_simple_subscription_payment_method(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
"""Обрабатывает выбор метода оплаты для простой подписки."""
texts = get_texts(db_user.language)
data = await state.get_data()
subscription_params = data.get("subscription_params", {})
if not subscription_params:
await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True)
return
# Рассчитываем цену подписки
price_kopeks = _calculate_simple_subscription_price(subscription_params)
payment_method = callback.data.replace("simple_subscription_", "")
try:
payment_service = PaymentService(callback.bot)
if payment_method == "stars":
# Оплата через Telegram Stars
stars_count = settings.rubles_to_stars(settings.kopeks_to_rubles(price_kopeks))
await callback.bot.send_invoice(
chat_id=callback.from_user.id,
title=f"Подписка на {subscription_params['period_days']} дней",
description=(
f"Простая покупка подписки\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']} ГБ'}"
),
payload=f"simple_sub_{db_user.id}_{subscription_params['period_days']}",
provider_token="", # Пустой токен для Telegram Stars
currency="XTR", # Telegram Stars
prices=[types.LabeledPrice(label="Подписка", amount=stars_count)]
)
await state.clear()
await callback.answer()
elif payment_method in ["yookassa", "yookassa_sbp"]:
# Оплата через YooKassa
if not settings.is_yookassa_enabled():
await callback.answer("❌ Оплата через YooKassa временно недоступна", show_alert=True)
return
if payment_method == "yookassa_sbp" and not settings.YOOKASSA_SBP_ENABLED:
await callback.answer("❌ Оплата через СБП временно недоступна", show_alert=True)
return
# Создаем заказ на подписку
purchase_service = SubscriptionPurchaseService()
order = await purchase_service.create_subscription_order(
db=db,
user_id=db_user.id,
period_days=subscription_params["period_days"],
device_limit=subscription_params["device_limit"],
traffic_limit_gb=subscription_params["traffic_limit_gb"],
squad_uuid=subscription_params["squad_uuid"],
payment_method="yookassa_sbp" if payment_method == "yookassa_sbp" else "yookassa",
total_price_kopeks=price_kopeks
)
if not order:
await callback.answer("❌ Ошибка создания заказа", show_alert=True)
return
# Создаем платеж через YooKassa
if payment_method == "yookassa_sbp":
payment_result = await payment_service.create_yookassa_sbp_payment(
db=db,
user_id=db_user.id,
amount_kopeks=price_kopeks,
description=f"Оплата подписки на {subscription_params['period_days']} дней",
receipt_email=db_user.email if hasattr(db_user, 'email') and db_user.email else None,
receipt_phone=db_user.phone if hasattr(db_user, 'phone') and db_user.phone else None,
metadata={
"user_telegram_id": str(db_user.telegram_id),
"user_username": db_user.username or "",
"order_id": str(order.id),
"subscription_period": str(subscription_params["period_days"]),
"payment_purpose": "simple_subscription_purchase"
}
)
else:
payment_result = await payment_service.create_yookassa_payment(
db=db,
user_id=db_user.id,
amount_kopeks=price_kopeks,
description=f"Оплата подписки на {subscription_params['period_days']} дней",
receipt_email=db_user.email if hasattr(db_user, 'email') and db_user.email else None,
receipt_phone=db_user.phone if hasattr(db_user, 'phone') and db_user.phone else None,
metadata={
"user_telegram_id": str(db_user.telegram_id),
"user_username": db_user.username or "",
"order_id": str(order.id),
"subscription_period": str(subscription_params["period_days"]),
"payment_purpose": "simple_subscription_purchase"
}
)
if not payment_result:
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
return
# Отправляем QR-код и/или ссылку для оплаты
confirmation_url = payment_result.get("confirmation_url")
qr_confirmation_data = payment_result.get("qr_confirmation_data")
if not confirmation_url and not qr_confirmation_data:
await callback.answer("❌ Ошибка получения данных для оплаты", show_alert=True)
return
# Подготовим QR-код для вставки в основное сообщение
qr_photo = None
if qr_confirmation_data or confirmation_url:
try:
# Импортируем необходимые модули для генерации QR-кода
import base64
from io import BytesIO
import qrcode
from aiogram.types import BufferedInputFile
# Используем qr_confirmation_data если доступно, иначе confirmation_url
qr_data = qr_confirmation_data if qr_confirmation_data else confirmation_url
# Создаем QR-код из полученных данных
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(qr_data)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# Сохраняем изображение в байты
img_bytes = BytesIO()
img.save(img_bytes, format='PNG')
img_bytes.seek(0)
qr_photo = BufferedInputFile(img_bytes.getvalue(), filename="qrcode.png")
except ImportError:
logger.warning("qrcode библиотека не установлена, QR-код не будет сгенерирован")
except Exception as e:
logger.error(f"Ошибка генерации QR-кода: {e}")
# Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса
keyboard_buttons = []
# Добавляем кнопку оплаты, если доступна ссылка
if confirmation_url:
keyboard_buttons.append([types.InlineKeyboardButton(text="🔗 Перейти к оплате", url=confirmation_url)])
else:
# Если ссылка недоступна, предлагаем оплатить через ID платежа в приложении банка
keyboard_buttons.append([types.InlineKeyboardButton(text="📱 Оплатить в приложении банка", callback_data="temp_disabled")])
# Добавляем общие кнопки
keyboard_buttons.append([types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")])
keyboard_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")])
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
# Подготавливаем текст сообщения
message_text = (
f"💳 <b>Оплата подписки через YooKassa</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"💰 Сумма: {settings.format_price(price_kopeks)}\n"
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
)
# Добавляем инструкции в зависимости от доступных способов оплаты
if not confirmation_url:
message_text += (
f"📱 <b>Инструкция по оплате:</b>\n"
f"1. Откройте приложение вашего банка\n"
f"2. Найдите функцию оплаты по реквизитам или перевод по СБП\n"
f"3. Введите ID платежа: <code>{payment_result['yookassa_payment_id']}</code>\n"
f"4. Подтвердите платеж в приложении банка\n"
f"5. Деньги поступят на баланс автоматически\n\n"
)
message_text += (
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
f"✅ Принимаем карты: Visa, MasterCard, МИР\n\n"
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}"
)
# Отправляем сообщение с инструкциями и клавиатурой
# Если есть QR-код, отправляем его как медиа-сообщение
if qr_photo:
# Используем метод отправки фото с описанием
await callback.message.edit_media(
media=types.InputMediaPhoto(
media=qr_photo,
caption=message_text,
parse_mode="HTML"
),
reply_markup=keyboard
)
else:
# Если QR-код недоступен, отправляем обычное текстовое сообщение
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await state.clear()
await callback.answer()
elif payment_method == "cryptobot":
# Оплата через CryptoBot
if not settings.is_cryptobot_enabled():
await callback.answer("❌ Оплата через CryptoBot временно недоступна", show_alert=True)
return
# Здесь должна быть реализация оплаты через CryptoBot
await callback.answer("❌ Оплата через CryptoBot пока не реализована", show_alert=True)
elif payment_method == "mulenpay":
# Оплата через MulenPay
mulenpay_name = settings.get_mulenpay_display_name()
if not settings.is_mulenpay_enabled():
await callback.answer(
f"❌ Оплата через {mulenpay_name} временно недоступна",
show_alert=True,
)
return
# Здесь должна быть реализация оплаты через MulenPay
await callback.answer(
f"❌ Оплата через {mulenpay_name} пока не реализована",
show_alert=True,
)
elif payment_method == "pal24":
# Оплата через PayPalych
if not settings.is_pal24_enabled():
await callback.answer("❌ Оплата через PayPalych временно недоступна", show_alert=True)
return
# Здесь должна быть реализация оплаты через PayPalych
await callback.answer("❌ Оплата через PayPalych пока не реализована", show_alert=True)
elif payment_method == "wata":
# Оплата через WATA
if not settings.is_wata_enabled():
await callback.answer("❌ Оплата через WATA временно недоступна", show_alert=True)
return
# Здесь должна быть реализация оплаты через WATA
await callback.answer("❌ Оплата через WATA пока не реализована", show_alert=True)
else:
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
except Exception as e:
logger.error(f"Ошибка обработки метода оплаты простой подписки: {e}")
await callback.answer("❌ Ошибка обработки запроса. Попробуйте позже или обратитесь в поддержку.", show_alert=True)
await state.clear()
def register_simple_subscription_handlers(dp):
"""Регистрирует обработчики простой покупки подписки."""
dp.callback_query.register(
start_simple_subscription_purchase,
F.data == "simple_subscription_purchase"
)
dp.callback_query.register(
handle_simple_subscription_pay_with_balance,
F.data == "simple_subscription_pay_with_balance"
)
dp.callback_query.register(
handle_simple_subscription_pay_with_balance_disabled,
F.data == "simple_subscription_pay_with_balance_disabled"
)
dp.callback_query.register(
handle_simple_subscription_other_payment_methods,
F.data == "simple_subscription_other_payment_methods"
)
dp.callback_query.register(
handle_simple_subscription_payment_method,
F.data.startswith("simple_subscription_")
)
+175 -2
View File
@@ -527,9 +527,22 @@ 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)
await callback.message.edit_text(
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language),
reply_markup=keyboard,
parse_mode="HTML",
)
@@ -1999,7 +2012,7 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(
start_subscription_purchase,
F.data.in_(["menu_buy", "subscription_upgrade"])
F.data.in_(["menu_buy", "subscription_upgrade", "subscription_purchase"])
)
dp.callback_query.register(
@@ -2264,3 +2277,163 @@ def register_handlers(dp: Dispatcher):
show_device_connection_help,
F.data == "device_connection_help"
)
# Регистрируем обработчик для простой покупки
dp.callback_query.register(
handle_simple_subscription_purchase,
F.data == "simple_subscription_purchase"
)
async def handle_simple_subscription_purchase(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession,
):
"""Обрабатывает простую покупку подписки."""
texts = get_texts(db_user.language)
if not settings.SIMPLE_SUBSCRIPTION_ENABLED:
await callback.answer("❌ Простая покупка подписки временно недоступна", show_alert=True)
return
# Проверяем, есть ли у пользователя активная подписка
from app.database.crud.subscription import get_subscription_by_user_id
current_subscription = await get_subscription_by_user_id(db, db_user.id)
if current_subscription and current_subscription.is_active:
await callback.answer("❌ У вас уже есть активная подписка", show_alert=True)
return
# Подготовим параметры простой подписки
subscription_params = {
"period_days": settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS,
"device_limit": settings.SIMPLE_SUBSCRIPTION_DEVICE_LIMIT,
"traffic_limit_gb": settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
"squad_uuid": settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID
}
# Сохраняем параметры в состояние
await state.update_data(subscription_params=subscription_params)
# Проверяем баланс пользователя
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
# Рассчитываем цену подписки
price_kopeks = _calculate_simple_subscription_price(subscription_params)
if user_balance_kopeks >= price_kopeks:
# Если баланс достаточный, предлагаем оплатить с баланса
message_text = (
f"⚡ <b>Простая покупка подписки</b>\n\n"
f"📅 Период: {subscription_params['period_days']} дней\n"
f"📱 Устройства: {subscription_params['device_limit']}\n"
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}\n\n"
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}\n\n"
f"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты."
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="✅ Оплатить с баланса", callback_data="simple_subscription_pay_with_balance")],
[types.InlineKeyboardButton(text="💳 Другие способы оплаты", callback_data="simple_subscription_other_payment_methods")],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")]
])
else:
# Если баланс недостаточный, предлагаем внешние способы оплаты
message_text = (
f"⚡ <b>Простая покупка подписки</b>\n\n"
f"📅 Период: {subscription_params['period_days']} дней\n"
f"📱 Устройства: {subscription_params['device_limit']}\n"
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}\n\n"
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}\n\n"
f"Выберите способ оплаты:"
)
keyboard = _get_simple_subscription_payment_keyboard(db_user.language)
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await state.set_state(SubscriptionStates.waiting_for_simple_subscription_payment_method)
await callback.answer()
def _calculate_simple_subscription_price(params: dict) -> int:
"""Рассчитывает цену простой подписки."""
period_days = params.get("period_days", 30)
# Получаем цену для стандартного периода
if hasattr(settings, f'PRICE_{period_days}_DAYS'):
return getattr(settings, f'PRICE_{period_days}_DAYS')
else:
# Если нет цены для конкретного периода, используем базовую цену
return settings.BASE_SUBSCRIPTION_PRICE
def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyboardMarkup:
"""Создает клавиатуру с методами оплаты для простой подписки."""
texts = get_texts(language)
keyboard = []
# Добавляем доступные методы оплаты
if settings.TELEGRAM_STARS_ENABLED:
keyboard.append([types.InlineKeyboardButton(
text="⭐ Telegram Stars",
callback_data="simple_subscription_stars"
)])
if settings.is_yookassa_enabled():
yookassa_methods = []
if settings.YOOKASSA_SBP_ENABLED:
yookassa_methods.append(types.InlineKeyboardButton(
text="🏦 YooKassa (СБП)",
callback_data="simple_subscription_yookassa_sbp"
))
yookassa_methods.append(types.InlineKeyboardButton(
text="💳 YooKassa (Карта)",
callback_data="simple_subscription_yookassa"
))
if yookassa_methods:
keyboard.append(yookassa_methods)
if settings.is_cryptobot_enabled():
keyboard.append([types.InlineKeyboardButton(
text="🪙 CryptoBot",
callback_data="simple_subscription_cryptobot"
)])
if settings.is_mulenpay_enabled():
mulenpay_name = settings.get_mulenpay_display_name()
keyboard.append([types.InlineKeyboardButton(
text=f"💳 {mulenpay_name}",
callback_data="simple_subscription_mulenpay"
)])
if settings.is_pal24_enabled():
keyboard.append([types.InlineKeyboardButton(
text="💳 PayPalych",
callback_data="simple_subscription_pal24"
)])
if settings.is_wata_enabled():
keyboard.append([types.InlineKeyboardButton(
text="💳 WATA",
callback_data="simple_subscription_wata"
)])
# Кнопка назад
keyboard.append([types.InlineKeyboardButton(
text=texts.BACK,
callback_data="subscription_purchase"
)])
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
+7
View File
@@ -762,6 +762,13 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
]
]
keyboard.append([
InlineKeyboardButton(
text=_t(texts, "ADMIN_USER_SEND_MESSAGE", "✉️ Отправить сообщение"),
callback_data=f"admin_user_send_message_{user_id}"
)
])
if user_status == "active":
keyboard.append([
InlineKeyboardButton(
+24 -2
View File
@@ -337,12 +337,22 @@ def get_main_menu_keyboard(
subscription_buttons.append(
InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="menu_buy")
)
# Добавляем кнопку простой покупки после кнопки "Купить подписку"
if settings.SIMPLE_SUBSCRIPTION_ENABLED:
subscription_buttons.append(
InlineKeyboardButton(text="⚡ Простая покупка", callback_data="simple_subscription_purchase")
)
if subscription_buttons:
if len(subscription_buttons) == 2:
keyboard.append(subscription_buttons)
else:
elif len(subscription_buttons) == 1:
keyboard.append([subscription_buttons[0]])
elif len(subscription_buttons) > 2:
# Если больше 2 кнопок, добавляем по отдельности
for button in subscription_buttons:
keyboard.append([button])
if show_resume_checkout or has_saved_cart:
keyboard.append([
@@ -870,6 +880,8 @@ def get_subscription_period_keyboard(language: str = DEFAULT_LANGUAGE) -> Inline
)
])
# Кнопка "Простая покупка" была убрана из выбора периода подписки
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
])
@@ -1089,9 +1101,13 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
])
if settings.is_mulenpay_enabled():
mulenpay_name = settings.get_mulenpay_display_name()
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_CARD_MULENPAY", "💳 Банковская карта (Mulen Pay)"),
text=texts.t(
"PAYMENT_CARD_MULENPAY",
"💳 Банковская карта ({mulenpay_name})",
).format(mulenpay_name=mulenpay_name),
callback_data=_build_callback("mulenpay")
)
])
@@ -2300,6 +2316,12 @@ def get_admin_tickets_keyboard(
keyboard.append(switch_row)
if open_rows and scope in ("all", "open"):
keyboard.append([
InlineKeyboardButton(
text=texts.t("ADMIN_CLOSE_ALL_OPEN_TICKETS", "🔒 Закрыть все открытые"),
callback_data="admin_tickets_close_all_open"
)
])
keyboard.append([InlineKeyboardButton(text=texts.t("OPEN_TICKETS_HEADER", "Открытые тикеты"), callback_data="noop")])
keyboard.extend(open_rows)
if closed_rows and scope in ("all", "closed"):
+68 -10
View File
@@ -1,8 +1,10 @@
from __future__ import annotations
import json
import os
import logging
import shutil
import tempfile
from functools import lru_cache
from pathlib import Path
from typing import Any, Dict
@@ -161,6 +163,41 @@ def _normalize_locale_dict(data: Dict[str, Any]) -> Dict[str, Any]:
return normalized
def _directory_is_writable(directory: Path) -> bool:
try:
current_user = f"{os.geteuid()}:{os.getegid()}"
user_hint = f" (running as UID:GID {current_user})"
except Exception: # pragma: no cover - best effort only
user_hint = ""
try:
with tempfile.NamedTemporaryFile(dir=directory, prefix=".locale_write_test_", delete=True):
pass
return True
except PermissionError as error:
_logger.warning(
"Locale directory %s is not writable%s. Ensure the mounted directory allows writes for the container user or configure LOCALES_PATH to a writable path. (%s)",
directory,
user_hint,
error,
)
except OSError as error:
_logger.warning(
"Unable to prepare locale directory %s for writing%s: %s. Configure LOCALES_PATH to a writable path.",
directory,
user_hint,
error,
)
except Exception as error: # pragma: no cover - defensive logging
_logger.warning(
"Unexpected error while checking locale directory %s%s: %s",
directory,
user_hint,
error,
)
return False
def ensure_locale_templates() -> None:
destination = _resolve_user_locales_dir()
try:
@@ -169,27 +206,48 @@ def ensure_locale_templates() -> None:
_logger.warning("Unable to create locales directory %s: %s", destination, error)
return
if any(destination.glob("*")):
return
if not _DEFAULT_LOCALES_DIR.exists():
_logger.debug("Default locales directory %s is missing", _DEFAULT_LOCALES_DIR)
return
for template in _DEFAULT_LOCALES_DIR.iterdir():
if not template.is_file():
continue
target_path = destination / template.name
if not _directory_is_writable(destination):
return
destination_has_files = any(destination.glob("*"))
def _copy_locale(source: Path, target: Path) -> None:
try:
shutil.copyfile(template, target_path)
shutil.copyfile(source, target)
except Exception as error:
_logger.warning(
"Failed to copy default locale %s to %s: %s",
template,
target_path,
source,
target,
error,
)
if not destination_has_files:
for template in _DEFAULT_LOCALES_DIR.iterdir():
if not template.is_file():
continue
_copy_locale(template, destination / template.name)
return
for locale_code in ("ru", "en"):
source_path = _DEFAULT_LOCALES_DIR / f"{locale_code}.json"
target_path = destination / f"{locale_code}.json"
if target_path.exists():
continue
if not source_path.exists():
_logger.debug(
"Default locale template %s is missing at %s", locale_code, source_path
)
continue
_copy_locale(source_path, target_path)
def _load_default_locale(language: str) -> Dict[str, Any]:
default_path = _DEFAULT_LOCALES_DIR / f"{language}.json"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+11 -6
View File
@@ -217,9 +217,10 @@ class AdminNotificationService:
db: AsyncSession,
user: User,
subscription: Subscription,
transaction: Transaction,
transaction: Optional[Transaction],
period_days: int,
was_trial_conversion: bool = False
was_trial_conversion: bool = False,
amount_kopeks: Optional[int] = None,
) -> bool:
if not self._is_enabled():
return False
@@ -235,11 +236,14 @@ class AdminNotificationService:
user_status = "🆕 Первая покупка"
servers_info = await self._get_servers_info(subscription.connected_squads)
payment_method = self._get_payment_method_display(transaction.payment_method)
payment_method = self._get_payment_method_display(transaction.payment_method) if transaction else "Баланс"
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
total_amount = amount_kopeks if amount_kopeks is not None else (transaction.amount_kopeks if transaction else 0)
transaction_id = transaction.id if transaction else ""
message = f"""💎 <b>{event_type}</b>
👤 <b>Пользователь:</b> {user.full_name}
@@ -250,9 +254,9 @@ class AdminNotificationService:
{promo_block}
💰 <b>Платеж:</b>
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
💵 Сумма: {settings.format_price(total_amount)}
💳 Способ: {payment_method}
🆔 ID транзакции: {transaction.id}
🆔 ID транзакции: {transaction_id}
📱 <b>Параметры подписки:</b>
📅 Период: {period_days} дней
@@ -798,11 +802,12 @@ class AdminNotificationService:
return self.enabled and bool(self.chat_id)
def _get_payment_method_display(self, payment_method: Optional[str]) -> str:
mulenpay_name = settings.get_mulenpay_display_name()
method_names = {
'telegram_stars': '⭐ Telegram Stars',
'yookassa': '💳 YooKassa (карта)',
'tribute': '💎 Tribute (карта)',
'mulenpay': '💳 Mulen Pay (карта)',
'mulenpay': f'💳 {mulenpay_name} (карта)',
'pal24': '🏦 PayPalych (СБП)',
'manual': '🛠️ Вручную (админ)',
'balance': '💰 С баланса'
+53 -23
View File
@@ -28,13 +28,16 @@ class MulenPayPaymentMixin:
language: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Создаёт локальный платеж и инициализирует сессию в MulenPay."""
display_name = settings.get_mulenpay_display_name()
display_name_html = settings.get_mulenpay_display_name_html()
if not getattr(self, "mulenpay_service", None):
logger.error("MulenPay сервис не инициализирован")
logger.error("%s сервис не инициализирован", display_name)
return None
if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
logger.warning(
"Сумма MulenPay меньше минимальной: %s < %s",
"Сумма %s меньше минимальной: %s < %s",
display_name,
amount_kopeks,
settings.MULENPAY_MIN_AMOUNT_KOPEKS,
)
@@ -42,7 +45,8 @@ class MulenPayPaymentMixin:
if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS:
logger.warning(
"Сумма MulenPay больше максимальной: %s > %s",
"Сумма %s больше максимальной: %s > %s",
display_name,
amount_kopeks,
settings.MULENPAY_MAX_AMOUNT_KOPEKS,
)
@@ -74,7 +78,7 @@ class MulenPayPaymentMixin:
)
if not response:
logger.error("Ошибка создания MulenPay платежа")
logger.error("Ошибка создания %s платежа", display_name)
return None
mulen_payment_id = response.get("id")
@@ -100,7 +104,8 @@ class MulenPayPaymentMixin:
)
logger.info(
"Создан MulenPay платеж %s на %s₽ для пользователя %s",
"Создан %s платеж %s на %s₽ для пользователя %s",
display_name,
mulen_payment_id,
amount_rubles,
user_id,
@@ -116,7 +121,7 @@ class MulenPayPaymentMixin:
}
except Exception as error:
logger.error("Ошибка создания MulenPay платежа: %s", error)
logger.error("Ошибка создания %s платежа: %s", display_name, error)
return None
async def process_mulenpay_callback(
@@ -125,10 +130,17 @@ class MulenPayPaymentMixin:
callback_data: Dict[str, Any],
) -> bool:
"""Обрабатывает callback от MulenPay, обновляет статус и начисляет баланс."""
display_name = settings.get_mulenpay_display_name()
display_name_html = settings.get_mulenpay_display_name_html()
try:
payment_module = import_module("app.services.payment_service")
uuid_value = callback_data.get("uuid")
payment_status = (callback_data.get("payment_status") or "").lower()
payment_status_raw = (
callback_data.get("payment_status")
or callback_data.get("status")
or callback_data.get("paymentStatus")
)
payment_status = (payment_status_raw or "").lower()
mulen_payment_id_raw = callback_data.get("id")
mulen_payment_id_int: Optional[int] = None
if mulen_payment_id_raw is not None:
@@ -138,14 +150,15 @@ class MulenPayPaymentMixin:
mulen_payment_id_int = None
amount_value = callback_data.get("amount")
logger.debug(
"MulenPay callback: uuid=%s, status=%s, amount=%s",
"%s callback: uuid=%s, status=%s, amount=%s",
display_name,
uuid_value,
payment_status,
amount_value,
)
if not uuid_value and mulen_payment_id_raw is None:
logger.error("MulenPay callback без uuid и id")
logger.error("%s callback без uuid и id", display_name)
return False
payment = None
@@ -159,7 +172,8 @@ class MulenPayPaymentMixin:
if not payment:
logger.error(
"MulenPay платеж не найден (uuid=%s, id=%s)",
"%s платеж не найден (uuid=%s, id=%s)",
display_name,
uuid_value,
mulen_payment_id_raw,
)
@@ -167,7 +181,8 @@ class MulenPayPaymentMixin:
if payment.is_paid:
logger.info(
"MulenPay платеж %s уже обработан, игнорируем повторный callback",
"%s платеж %s уже обработан, игнорируем повторный callback",
display_name,
payment.uuid,
)
return True
@@ -183,7 +198,8 @@ class MulenPayPaymentMixin:
if payment.transaction_id:
logger.info(
"Для MulenPay платежа %s уже создана транзакция",
"Для %s платежа %s уже создана транзакция",
display_name,
payment.uuid,
)
return True
@@ -199,7 +215,7 @@ class MulenPayPaymentMixin:
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=f"Пополнение через MulenPay: {payment_description}",
description=f"Пополнение через {display_name}: {payment_description}",
payment_method=PaymentMethod.MULENPAY,
external_id=payment.uuid,
is_completed=True,
@@ -214,8 +230,9 @@ class MulenPayPaymentMixin:
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error(
"Пользователь %s не найден при обработке MulenPay",
"Пользователь %s не найден при обработке %s",
payment.user_id,
display_name,
)
return False
@@ -226,7 +243,7 @@ class MulenPayPaymentMixin:
db,
user,
payment.amount_kopeks,
f"Пополнение MulenPay: {payment.amount_kopeks // 100}",
f"Пополнение {display_name}: {payment.amount_kopeks // 100}",
)
if was_first_topup and not user.has_made_first_topup:
@@ -261,7 +278,8 @@ class MulenPayPaymentMixin:
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о пополнении MulenPay: %s",
"Ошибка отправки уведомления о пополнении %s: %s",
display_name,
error,
)
@@ -273,7 +291,7 @@ class MulenPayPaymentMixin:
(
"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
"🦊 Способ: Mulen Pay\n"
f"🦊 Способ: {display_name_html}\n"
f"🆔 Транзакция: {transaction.id}\n\n"
"Баланс пополнен автоматически!"
),
@@ -282,7 +300,8 @@ class MulenPayPaymentMixin:
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления пользователю MulenPay: %s",
"Ошибка отправки уведомления пользователю %s: %s",
display_name,
error,
)
@@ -329,7 +348,8 @@ class MulenPayPaymentMixin:
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
logger.info(
"✅ Обработан MulenPay платеж %s для пользователя %s",
"✅ Обработан %s платеж %s для пользователя %s",
display_name,
payment.uuid,
payment.user_id,
)
@@ -343,7 +363,7 @@ class MulenPayPaymentMixin:
callback_payload=callback_data,
mulen_payment_id=mulen_payment_id_int,
)
logger.info("MulenPay платеж %s отменен", payment.uuid)
logger.info("%s платеж %s отменен", display_name, payment.uuid)
return True
await payment_module.update_mulenpay_payment_status(
@@ -354,14 +374,20 @@ class MulenPayPaymentMixin:
mulen_payment_id=mulen_payment_id_int,
)
logger.info(
"Получен MulenPay callback со статусом %s для платежа %s",
"Получен %s callback со статусом %s для платежа %s",
display_name,
payment_status,
payment.uuid,
)
return True
except Exception as error:
logger.error("Ошибка обработки MulenPay callback: %s", error, exc_info=True)
logger.error(
"Ошибка обработки %s callback: %s",
display_name,
error,
exc_info=True,
)
return False
def _map_mulenpay_status(self, status_code: Optional[int]) -> str:
@@ -383,6 +409,7 @@ class MulenPayPaymentMixin:
local_payment_id: int,
) -> Optional[Dict[str, Any]]:
"""Возвращает текущее состояние платежа и при необходимости синхронизирует его."""
display_name = settings.get_mulenpay_display_name()
try:
payment_module = import_module("app.services.payment_service")
@@ -437,6 +464,9 @@ class MulenPayPaymentMixin:
except Exception as error:
logger.error(
"Ошибка получения статуса MulenPay: %s", error, exc_info=True
"Ошибка получения статуса %s: %s",
display_name,
error,
exc_info=True,
)
return None
+367 -166
View File
@@ -63,7 +63,8 @@ class Pal24PaymentMixin:
"language": language,
}
normalized_payment_method = (payment_method or "SBP").upper()
normalized_payment_method = self._normalize_payment_method(payment_method)
api_payment_method = self._map_api_payment_method(normalized_payment_method)
payment_module = import_module("app.services.payment_service")
@@ -76,7 +77,7 @@ class Pal24PaymentMixin:
ttl_seconds=ttl_seconds,
custom_payload=custom_payload,
payer_email=payer_email,
payment_method=normalized_payment_method,
payment_method=api_payment_method,
)
except Pal24APIError as error:
logger.error("Ошибка Pal24 API при создании счета: %s", error)
@@ -147,6 +148,7 @@ class Pal24PaymentMixin:
"description": description,
"links": metadata_links,
"raw_response": response,
"selected_method": normalized_payment_method,
}
payment = await payment_module.create_pal24_payment(
@@ -186,6 +188,10 @@ class Pal24PaymentMixin:
"payment_method": normalized_payment_method,
"metadata_links": metadata_links,
"status": payment_status,
"sbp_url": transfer_url,
"transfer_url": transfer_url,
"link_page_url": link_page_url,
"payment_url": primary_link,
}
async def process_pal24_postback(
@@ -240,175 +246,58 @@ class Pal24PaymentMixin:
logger.info("Pal24 платеж %s уже обработан", payment.bill_id)
return True
if status in {"PAID", "SUCCESS"}:
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error(
"Пользователь %s не найден для Pal24 платежа",
payment.user_id,
)
return False
await payment_module.update_pal24_payment_status(
if status in {"PAID", "SUCCESS", "OVERPAID"}:
payment = await payment_module.update_pal24_payment_status(
db,
payment,
status=status,
is_paid=True,
paid_at=datetime.utcnow(),
postback_payload=postback,
payment_id=payment_id,
payment_status=postback.get("Status") or status,
payment_method=(
postback.get("payment_method")
or postback.get("PaymentMethod")
or (payment.metadata_json or {}).get("selected_method")
or getattr(payment, "payment_method", None)
),
balance_amount=postback.get("BalanceAmount")
or postback.get("balance_amount"),
balance_currency=postback.get("BalanceCurrency")
or postback.get("balance_currency"),
payer_account=postback.get("AccountNumber")
or postback.get("account")
or postback.get("Account"),
)
if payment.transaction_id:
logger.info(
"Для Pal24 платежа %s уже создана транзакция",
payment.bill_id,
)
return True
transaction = await payment_module.create_transaction(
return await self._finalize_pal24_payment(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=f"Пополнение через Pal24 ({payment_id})",
payment_method=PaymentMethod.PAL24,
external_id=str(payment_id) if payment_id else payment.bill_id,
is_completed=True,
payment,
payment_id=payment_id,
trigger="postback",
)
await payment_module.link_pal24_payment_to_transaction(db, payment, transaction.id)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = getattr(user, "promo_group", None)
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = (
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
)
await db.commit()
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db, user.id, payment.amount_kopeks, getattr(self, "bot", None)
)
except Exception as error:
logger.error(
"Ошибка обработки реферального пополнения Pal24: %s",
error,
)
if was_first_topup and not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import (
AdminNotificationService,
)
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error(
"Ошибка отправки админ уведомления Pal24: %s", error
)
if getattr(self, "bot", None):
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
"🦊 Способ: PayPalych\n"
f"🆔 Транзакция: {transaction.id}\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
reply_markup=keyboard,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления пользователю Pal24: %s",
error,
)
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
try:
from app.services.user_cart_service import user_cart_service
from aiogram import types
has_saved_cart = await user_cart_service.has_user_cart(user.id)
if has_saved_cart and getattr(self, "bot", None):
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
from app.localization.texts import get_texts
texts = get_texts(user.language)
cart_message = texts.t(
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
"🛒 У вас есть неоформленный заказ.\n\n"
"Вы можете продолжить оформление с теми же параметрами."
)
# Создаем клавиатуру с кнопками
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout"
)],
[types.InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance"
)],
[types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu"
)]
])
await self.bot.send_message(
chat_id=user.telegram_id,
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
reply_markup=keyboard
)
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
except Exception as e:
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
logger.info(
"✅ Обработан Pal24 платеж %s для пользователя %s",
payment.bill_id,
payment.user_id,
)
return True
await payment_module.update_pal24_payment_status(
db,
payment,
status=status or "UNKNOWN",
is_paid=False,
postback_payload=postback,
payment_id=payment_id,
payment_status=postback.get("Status") or status,
payment_method=(
postback.get("payment_method")
or postback.get("PaymentMethod")
or getattr(payment, "payment_method", None)
),
balance_amount=postback.get("BalanceAmount")
or postback.get("balance_amount"),
balance_currency=postback.get("BalanceCurrency")
or postback.get("balance_currency"),
payer_account=postback.get("AccountNumber")
or postback.get("account")
or postback.get("Account"),
)
logger.info(
"Обновили Pal24 платеж %s до статуса %s",
@@ -421,6 +310,189 @@ class Pal24PaymentMixin:
logger.error("Ошибка обработки Pal24 postback: %s", error, exc_info=True)
return False
async def _finalize_pal24_payment(
self,
db: AsyncSession,
payment: Any,
*,
payment_id: Optional[str],
trigger: str,
) -> bool:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления."""
payment_module = import_module("app.services.payment_service")
if payment.transaction_id:
logger.info(
"Pal24 платеж %s уже привязан к транзакции (trigger=%s)",
payment.bill_id,
trigger,
)
return True
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error(
"Пользователь %s не найден для Pal24 платежа %s (trigger=%s)",
payment.user_id,
payment.bill_id,
trigger,
)
return False
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=f"Пополнение через Pal24 ({payment_id or payment.bill_id})",
payment_method=PaymentMethod.PAL24,
external_id=str(payment_id) if payment_id else payment.bill_id,
is_completed=True,
)
await payment_module.link_pal24_payment_to_transaction(db, payment, transaction.id)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = getattr(user, "promo_group", None)
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
await db.commit()
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db, user.id, payment.amount_kopeks, getattr(self, "bot", None)
)
except Exception as error:
logger.error(
"Ошибка обработки реферального пополнения Pal24: %s",
error,
)
if was_first_topup and not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
await db.refresh(payment)
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import (
AdminNotificationService,
)
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error(
"Ошибка отправки админ уведомления Pal24: %s",
error,
)
if getattr(self, "bot", None):
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
"🦊 Способ: PayPalych\n"
f"🆔 Транзакция: {transaction.id}\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
reply_markup=keyboard,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления пользователю Pal24: %s",
error,
)
try:
from app.services.user_cart_service import user_cart_service
from aiogram import types
has_saved_cart = await user_cart_service.has_user_cart(user.id)
if has_saved_cart and getattr(self, "bot", None):
from app.localization.texts import get_texts
texts = get_texts(user.language)
cart_message = texts.t(
"BALANCE_TOPUP_CART_REMINDER",
"У вас есть незавершенное оформление подписки. Вернуться?",
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"BALANCE_TOPUP_CART_BUTTON",
"🛒 Продолжить оформление",
),
callback_data="subscription_resume_checkout",
)
],
[
types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu",
)
],
]
)
await self.bot.send_message(
chat_id=user.telegram_id,
text=(
"✅ Баланс пополнен на "
f"{settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}"
),
reply_markup=keyboard,
)
logger.info(
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
user.id,
)
except Exception as error:
logger.error(
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
user.id,
error,
exc_info=True,
)
logger.info(
"✅ Обработан Pal24 платеж %s для пользователя %s (trigger=%s)",
payment.bill_id,
payment.user_id,
trigger,
)
return True
async def get_pal24_payment_status(
self,
db: AsyncSession,
@@ -434,32 +506,72 @@ class Pal24PaymentMixin:
if not payment:
return None
remote_status = None
remote_data = None
remote_status: Optional[str] = None
remote_data: Optional[Dict[str, Any]] = None
service = getattr(self, "pal24_service", None)
if service and payment.bill_id:
try:
response = await service.get_bill_status(payment.bill_id)
remote_data = response
remote_status = response.get("status") or response.get(
"bill", {}
).get("status")
remote_status = response.get("status") or response.get("bill", {}).get("status")
if remote_status and remote_status != payment.status:
await payment_module.update_pal24_payment_status(
payment_info = self._extract_remote_payment_info(response)
if remote_status:
normalized_remote = str(remote_status).upper()
update_kwargs: Dict[str, Any] = {
"status": normalized_remote,
"payment_status": payment_info.get("status") or remote_status,
}
if payment_info.get("id"):
update_kwargs["payment_id"] = payment_info["id"]
if payment_info.get("method"):
update_kwargs["payment_method"] = payment_info["method"]
if payment_info.get("balance_amount"):
update_kwargs["balance_amount"] = payment_info["balance_amount"]
if payment_info.get("balance_currency"):
update_kwargs["balance_currency"] = payment_info["balance_currency"]
if payment_info.get("account"):
update_kwargs["payer_account"] = payment_info["account"]
if normalized_remote in getattr(service, "BILL_SUCCESS_STATES", {"SUCCESS"}):
update_kwargs["is_paid"] = True
if not payment.paid_at:
update_kwargs["paid_at"] = datetime.utcnow()
elif normalized_remote in getattr(service, "BILL_FAILED_STATES", {"FAIL"}):
update_kwargs["is_paid"] = False
elif normalized_remote in getattr(service, "BILL_PENDING_STATES", {"NEW", "PROCESS"}):
update_kwargs.setdefault("is_paid", False)
payment = await payment_module.update_pal24_payment_status(
db,
payment,
status=str(remote_status).upper(),
)
payment = await payment_module.get_pal24_payment_by_id(
db, local_payment_id
**update_kwargs,
)
except Pal24APIError as error:
logger.error(
"Ошибка Pal24 API при получении статуса: %s", error
)
if payment.is_paid and not payment.transaction_id:
try:
finalized = await self._finalize_pal24_payment(
db,
payment,
payment_id=getattr(payment, "payment_id", None),
trigger="status_check",
)
if finalized:
payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id)
except Exception as error:
logger.error(
"Ошибка автоматического начисления по Pal24 статусу: %s",
error,
exc_info=True,
)
return {
"payment": payment,
"status": payment.status,
@@ -471,3 +583,92 @@ class Pal24PaymentMixin:
except Exception as error:
logger.error("Ошибка получения статуса Pal24: %s", error, exc_info=True)
return None
@staticmethod
def _extract_remote_payment_info(remote_data: Any) -> Dict[str, Optional[str]]:
"""Извлекает данные о платеже из ответа Pal24."""
def _pick_candidate(value: Any) -> Optional[Dict[str, Any]]:
if isinstance(value, dict):
return value
if isinstance(value, list):
for item in value:
if isinstance(item, dict):
return item
return None
def _normalize(candidate: Dict[str, Any]) -> Dict[str, Optional[str]]:
def _stringify(value: Any) -> Optional[str]:
if value is None:
return None
return str(value)
return {
"id": _stringify(candidate.get("id") or candidate.get("payment_id")),
"status": _stringify(candidate.get("status")),
"method": _stringify(candidate.get("method") or candidate.get("payment_method")),
"balance_amount": _stringify(
candidate.get("balance_amount")
or candidate.get("amount")
or candidate.get("BalanceAmount")
),
"balance_currency": _stringify(
candidate.get("balance_currency") or candidate.get("BalanceCurrency")
),
"account": _stringify(
candidate.get("account")
or candidate.get("payer_account")
or candidate.get("AccountNumber")
),
}
if not isinstance(remote_data, dict):
return {}
search_spaces = [remote_data]
bill_section = remote_data.get("bill") or remote_data.get("Bill")
if isinstance(bill_section, dict):
search_spaces.append(bill_section)
for space in search_spaces:
for key in ("payment", "Payment", "payment_info", "PaymentInfo"):
candidate = _pick_candidate(space.get(key))
if candidate:
return _normalize(candidate)
for key in ("payments", "Payments"):
candidate = _pick_candidate(space.get(key))
if candidate:
return _normalize(candidate)
return {}
@staticmethod
def _normalize_payment_method(payment_method: Optional[str]) -> str:
mapping = {
"sbp": "sbp",
"fast": "sbp",
"fastpay": "sbp",
"fast_payment": "sbp",
"card": "card",
"bank_card": "card",
"bankcard": "card",
"bank-card": "card",
}
if not payment_method:
return "sbp"
normalized = payment_method.strip().lower()
return mapping.get(normalized, "sbp")
@staticmethod
def _map_api_payment_method(normalized_payment_method: str) -> Optional[str]:
"""Преобразует нормализованный метод оплаты в значение для Pal24 API."""
api_mapping = {
"sbp": "SBP",
"card": "BANK_CARD",
}
return api_mapping.get(normalized_payment_method)
+93
View File
@@ -120,6 +120,99 @@ class WataPaymentMixin:
"order_id": order_id,
}
async def process_wata_webhook(
self,
db: AsyncSession,
payload: Dict[str, Any],
) -> bool:
"""Handles asynchronous webhook notifications from WATA."""
payment_module = import_module("app.services.payment_service")
if not isinstance(payload, dict):
logger.error("WATA webhook payload не является словарём: %s", payload)
return False
order_id_raw = payload.get("orderId")
payment_link_raw = payload.get("paymentLinkId") or payload.get("id")
transaction_status_raw = payload.get("transactionStatus")
order_id = str(order_id_raw) if order_id_raw else None
payment_link_id = str(payment_link_raw) if payment_link_raw else None
transaction_status = (transaction_status_raw or "").strip()
if not order_id and not payment_link_id:
logger.error(
"WATA webhook без orderId и paymentLinkId: %s",
payload,
)
return False
if not transaction_status:
logger.error("WATA webhook без статуса транзакции: %s", payload)
return False
payment = None
if order_id:
payment = await payment_module.get_wata_payment_by_order_id(db, order_id)
if not payment and payment_link_id:
payment = await payment_module.get_wata_payment_by_link_id(db, payment_link_id)
if not payment:
logger.error(
"WATA платеж не найден (order_id=%s, payment_link_id=%s)",
order_id,
payment_link_id,
)
return False
status_lower = transaction_status.lower()
metadata = dict(getattr(payment, "metadata_json", {}) or {})
metadata["last_webhook"] = payload
terminal_public_id = (
payload.get("terminalPublicId")
or payload.get("terminal_public_id")
or payload.get("terminalPublicID")
)
update_kwargs: Dict[str, Any] = {
"metadata": metadata,
"callback_payload": payload,
"terminal_public_id": terminal_public_id,
}
if transaction_status:
update_kwargs["status"] = transaction_status
update_kwargs["last_status"] = transaction_status
if status_lower != "paid" and not payment.is_paid:
update_kwargs["is_paid"] = False
payment = await payment_module.update_wata_payment_status(
db,
payment=payment,
**update_kwargs,
)
if status_lower == "paid":
if payment.is_paid:
logger.info(
"WATA платеж %s уже помечен как оплачен",
payment.payment_link_id,
)
return True
await self._finalize_wata_payment(db, payment, payload)
return True
if status_lower == "declined":
logger.info(
"WATA платеж %s отклонён",
payment.payment_link_id,
)
return True
async def get_wata_payment_status(
self,
db: AsyncSession,
+263 -121
View File
@@ -172,7 +172,7 @@ class YooKassaPaymentMixin:
currency="RUB",
description=description,
status=yookassa_response["status"],
confirmation_url=yookassa_response.get("confirmation_url"),
confirmation_url=yookassa_response.get("confirmation_url"), # Используем confirmation URL
metadata_json=payment_metadata,
payment_method_type="bank_card",
yookassa_created_at=None,
@@ -193,7 +193,8 @@ class YooKassaPaymentMixin:
return {
"local_payment_id": local_payment.id,
"yookassa_payment_id": yookassa_response["id"],
"confirmation_url": yookassa_response.get("confirmation_url"),
"confirmation_url": yookassa_response.get("confirmation_url"), # URL для подтверждения
"qr_confirmation_data": yookassa_response.get("qr_confirmation_data"), # Данные для QR-кода
"confirmation_token": confirmation_token,
"amount_kopeks": amount_kopeks,
"amount_rubles": amount_rubles,
@@ -216,12 +217,39 @@ class YooKassaPaymentMixin:
payment_description = getattr(payment, "description", "YooKassa платеж")
payment_metadata: Dict[str, Any] = {}
try:
if hasattr(payment, "metadata_json") and payment.metadata_json:
import json
if isinstance(payment.metadata_json, str):
payment_metadata = json.loads(payment.metadata_json)
elif isinstance(payment.metadata_json, dict):
payment_metadata = payment.metadata_json
logger.info(f"Метаданные платежа: {payment_metadata}")
except Exception as parse_error:
logger.error(f"Ошибка парсинга метаданных платежа: {parse_error}")
payment_purpose = payment_metadata.get("payment_purpose", "")
is_simple_subscription = payment_purpose == "simple_subscription_purchase"
transaction_type = (
TransactionType.SUBSCRIPTION_PAYMENT
if is_simple_subscription
else TransactionType.DEPOSIT
)
transaction_description = (
f"Оплата подписки через YooKassa: {payment_description}"
if is_simple_subscription
else f"Пополнение через YooKassa: {payment_description}"
)
transaction = await payment_module.create_transaction(
db=db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
type=transaction_type,
amount_kopeks=payment.amount_kopeks,
description=f"Пополнение через YooKassa: {payment_description}",
description=transaction_description,
payment_method=PaymentMethod.YOOKASSA,
external_id=payment.yookassa_payment_id,
is_completed=True,
@@ -235,143 +263,257 @@ class YooKassaPaymentMixin:
user = await payment_module.get_user_by_id(db, payment.user_id)
if user:
old_balance = getattr(user, "balance_kopeks", 0)
was_first_topup = not getattr(user, "has_made_first_topup", False)
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = getattr(user, "promo_group", None)
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = ("🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение")
await db.commit()
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
if is_simple_subscription:
logger.info(
"YooKassa платеж %s обработан как покупка подписки. Баланс пользователя %s не изменяется.",
payment.yookassa_payment_id,
user.id,
payment.amount_kopeks,
getattr(self, "bot", None),
)
except Exception as error:
logger.error(
"Ошибка обработки реферального пополнения YooKassa: %s",
error,
else:
old_balance = getattr(user, "balance_kopeks", 0)
was_first_topup = not getattr(user, "has_made_first_topup", False)
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = getattr(user, "promo_group", None)
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = (
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
)
if was_first_topup and not getattr(user, "has_made_first_topup", False):
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
# Отправляем уведомления админам
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import (
AdminNotificationService,
)
from app.services.referral_service import process_referral_topup
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
logger.info("Уведомление админам о пополнении отправлено успешно")
except Exception as error:
logger.error(
"Ошибка отправки уведомления админам о YooKassa пополнении: %s",
error,
exc_info=True # Добавляем полный стек вызовов для отладки
)
# Отправляем уведомление пользователю
if getattr(self, "bot", None):
try:
# Передаем только простые данные, чтобы избежать проблем с ленивой загрузкой
await self._send_payment_success_notification(
user.telegram_id,
await process_referral_topup(
db,
user.id,
payment.amount_kopeks,
user=None, # Передаем None, чтобы _ensure_user_snapshot загрузил данные сам
db=db,
payment_method_title="Банковская карта (YooKassa)",
getattr(self, "bot", None),
)
logger.info("Уведомление пользователю о платеже отправлено успешно")
except Exception as error:
logger.error(
"Ошибка отправки уведомления о платеже: %s",
"Ошибка обработки реферального пополнения YooKassa: %s",
error,
exc_info=True # Добавляем полный стек вызовов для отладки
)
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
# ВАЖНО: этот код должен выполняться даже при ошибках в уведомлениях
logger.info(f"Проверяем наличие сохраненной корзины для пользователя {user.id}")
from app.services.user_cart_service import user_cart_service
try:
has_saved_cart = await user_cart_service.has_user_cart(user.id)
logger.info(f"Результат проверки корзины для пользователя {user.id}: {has_saved_cart}")
if has_saved_cart and getattr(self, "bot", None):
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
from app.localization.texts import get_texts
from aiogram import types
if was_first_topup and not getattr(user, "has_made_first_topup", False):
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
# Отправляем уведомления админам
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import (
AdminNotificationService,
)
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
logger.info("Уведомление админам о пополнении отправлено успешно")
except Exception as error:
logger.error(
"Ошибка отправки уведомления админам о YooKassa пополнении: %s",
error,
exc_info=True, # Добавляем полный стек вызовов для отладки
)
# Отправляем уведомление пользователю
if getattr(self, "bot", None):
try:
# Передаем только простые данные, чтобы избежать проблем с ленивой загрузкой
await self._send_payment_success_notification(
user.telegram_id,
payment.amount_kopeks,
user=None, # Передаем None, чтобы _ensure_user_snapshot загрузил данные сам
db=db,
payment_method_title="Банковская карта (YooKassa)",
)
logger.info("Уведомление пользователю о платеже отправлено успешно")
except Exception as error:
logger.error(
"Ошибка отправки уведомления о платеже: %s",
error,
exc_info=True, # Добавляем полный стек вызовов для отладки
)
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
# ВАЖНО: этот код должен выполняться даже при ошибках в уведомлениях
logger.info(f"Проверяем наличие сохраненной корзины для пользователя {user.id}")
from app.services.user_cart_service import user_cart_service
try:
has_saved_cart = await user_cart_service.has_user_cart(user.id)
logger.info(f"Результат проверки корзины для пользователя {user.id}: {has_saved_cart}")
if has_saved_cart and getattr(self, "bot", None):
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
from app.localization.texts import get_texts
from aiogram import types
texts = get_texts(user.language)
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
total_amount=settings.format_price(payment.amount_kopeks)
)
# Создаем клавиатуру с кнопками
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout",
)
],
[
types.InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance",
)
],
[
types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu",
)
],
]
)
await self.bot.send_message(
chat_id=user.telegram_id,
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
reply_markup=keyboard,
)
logger.info(
f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}"
)
else:
logger.info(f"У пользователя {user.id} нет сохраненной корзины или бот недоступен")
except Exception as e:
logger.error(
f"Критическая ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}",
exc_info=True,
)
if is_simple_subscription:
logger.info(f"Обнаружен платеж простой покупки подписки для пользователя {user.id}")
try:
# Активируем подписку
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
texts = get_texts(user.language)
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
total_amount=settings.format_price(payment.amount_kopeks)
# Получаем параметры подписки из метаданных
subscription_period = int(payment_metadata.get("subscription_period", 30))
order_id = payment_metadata.get("order_id")
logger.info(f"Активация подписки: период={subscription_period} дней, заказ={order_id}")
# Активируем pending подписку пользователя
from app.database.crud.subscription import activate_pending_subscription
subscription = await activate_pending_subscription(
db=db,
user_id=user.id,
period_days=subscription_period
)
# Создаем клавиатуру с кнопками
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout"
)],
[types.InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance"
)],
[types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu"
)]
])
await self.bot.send_message(
chat_id=user.telegram_id,
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
reply_markup=keyboard
)
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
else:
logger.info(f"У пользователя {user.id} нет сохраненной корзины или бот недоступен")
except Exception as e:
logger.error(f"Критическая ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
if subscription:
logger.info(f"Подписка успешно активирована для пользователя {user.id}")
logger.info(
"Успешно обработан платеж YooKassa %s: пользователь %s получил %s",
payment.yookassa_payment_id,
payment.user_id,
payment.amount_kopeks / 100,
)
# Обновляем данные подписки в RemnaWave, чтобы получить актуальные ссылки
try:
remnawave_user = await subscription_service.create_remnawave_user(db, subscription)
if remnawave_user:
await db.refresh(subscription)
except Exception as sync_error:
logger.error(
"Ошибка синхронизации подписки с RemnaWave для пользователя %s: %s",
user.id,
sync_error,
exc_info=True,
)
# Отправляем уведомление пользователю об активации подписки
if getattr(self, "bot", None):
from app.localization.texts import get_texts
from aiogram import types
texts = get_texts(user.language)
success_message = (
f"✅ <b>Подписка успешно активирована!</b>\n\n"
f"📅 Период: {subscription_period} дней\n"
f"📱 Устройства: 1\n"
f"📊 Трафик: Безлимит\n"
f"💳 Оплата: {settings.format_price(payment.amount_kopeks)} (YooKassa)\n\n"
f"🔗 Для подключения перейдите в раздел 'Моя подписка'"
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
[types.InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
])
await self.bot.send_message(
chat_id=user.telegram_id,
text=success_message,
reply_markup=keyboard,
parse_mode="HTML"
)
logger.info(
"Успешно обработан платеж YooKassa %s: пользователь %s получил %s",
payment.yookassa_payment_id,
payment.user_id,
payment.amount_kopeks / 100,
)
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import (
AdminNotificationService,
)
notification_service = AdminNotificationService(self.bot)
await notification_service.send_subscription_purchase_notification(
db,
user,
subscription,
transaction,
subscription_period,
was_trial_conversion=False,
)
except Exception as admin_error:
logger.error(
"Ошибка отправки уведомления админам о покупке подписки через YooKassa: %s",
admin_error,
exc_info=True,
)
else:
logger.error(f"Ошибка активации подписки для пользователя {user.id}")
except Exception as e:
logger.error(f"Ошибка активации подписки для пользователя {user.id}: {e}", exc_info=True)
if is_simple_subscription:
logger.info(
"Успешно обработан платеж YooKassa %s как покупка подписки: пользователь %s, сумма %s",
payment.yookassa_payment_id,
payment.user_id,
payment.amount_kopeks / 100,
)
else:
logger.info(
"Успешно обработан платеж YooKassa %s: пользователь %s пополнил баланс на %s",
payment.yookassa_payment_id,
payment.user_id,
payment.amount_kopeks / 100,
)
return True
+3 -2
View File
@@ -209,12 +209,13 @@ class PaymentService(
)
self.wata_service = WataService() if settings.is_wata_enabled() else None
mulenpay_name = settings.get_mulenpay_display_name()
logger.debug(
"PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, "
"MulenPay=%s, Pal24=%s, Wata=%s)",
"PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, %s=%s, Pal24=%s, Wata=%s)",
bool(self.yookassa_service),
bool(self.stars_service),
bool(self.cryptobot_service),
mulenpay_name,
bool(self.mulenpay_service),
bool(self.pal24_service),
bool(self.wata_service),
@@ -1175,5 +1175,38 @@ class MiniAppSubscriptionPurchaseService:
}
class SubscriptionPurchaseService:
"""Service for handling simple subscription purchases with predefined parameters."""
async def create_subscription_order(
self,
db: AsyncSession,
user_id: int,
period_days: int,
device_limit: int,
traffic_limit_gb: int,
squad_uuid: str,
payment_method: str,
total_price_kopeks: int
):
"""Creates a subscription order with predefined parameters."""
from app.database.crud.subscription import create_pending_subscription
from app.database.models import SubscriptionStatus
# Create a pending subscription
subscription = await create_pending_subscription(
db=db,
user_id=user_id,
duration_days=period_days,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=[squad_uuid] if squad_uuid else [],
payment_method=payment_method,
total_price_kopeks=total_price_kopeks
)
return subscription
purchase_service = MiniAppSubscriptionPurchaseService()
+13 -3
View File
@@ -76,7 +76,7 @@ class BotConfigurationService:
"CRYPTOBOT": "🪙 CryptoBot",
"YOOKASSA": "🟣 YooKassa",
"TRIBUTE": "🎁 Tribute",
"MULENPAY": "💰 MulenPay",
"MULENPAY": "💰 {mulenpay_name}",
"PAL24": "🏦 PAL24 / PayPalych",
"WATA": "💠 Wata",
"EXTERNAL_ADMIN": "🛡️ Внешняя админка",
@@ -124,7 +124,7 @@ class BotConfigurationService:
"PAYMENT": "Общие тексты платежей, описания чеков и шаблоны.",
"YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.",
"CRYPTOBOT": "CryptoBot и криптоплатежи через Telegram.",
"MULENPAY": "Платежи MulenPay и параметры магазина.",
"MULENPAY": "Платежи {mulenpay_name} и параметры магазина.",
"PAL24": "PAL24 / PayPalych подключения и лимиты.",
"TRIBUTE": "Tribute и донат-сервисы.",
"TELEGRAM": "Telegram Stars и их стоимость.",
@@ -166,6 +166,14 @@ class BotConfigurationService:
"MODERATION": "Настройки фильтров отображаемых имен и защиты от фишинга.",
}
@staticmethod
def _format_dynamic_copy(category_key: Optional[str], value: str) -> str:
if not value:
return value
if category_key == "MULENPAY":
return value.format(mulenpay_name=settings.get_mulenpay_display_name())
return value
CATEGORY_KEY_OVERRIDES: Dict[str, str] = {
"DATABASE_URL": "DATABASE",
"DATABASE_MODE": "DATABASE",
@@ -459,7 +467,8 @@ class BotConfigurationService:
@classmethod
def get_category_description(cls, category_key: str) -> str:
return cls.CATEGORY_DESCRIPTIONS.get(category_key, "")
description = cls.CATEGORY_DESCRIPTIONS.get(category_key, "")
return cls._format_dynamic_copy(category_key, description)
@classmethod
def is_toggle(cls, key: str) -> bool:
@@ -598,6 +607,7 @@ class BotConfigurationService:
category_key,
category_key.capitalize() if category_key else "Прочее",
)
category_label = cls._format_dynamic_copy(category_key, category_label)
cls._definitions[key] = SettingDefinition(
key=key,
+28 -3
View File
@@ -66,10 +66,30 @@ class UserService:
f"Если у вас есть вопросы, обратитесь в поддержку."
)
keyboard_rows = []
if getattr(user, "subscription", None) and user.subscription.status in {
"active",
"expired",
"trial",
}:
keyboard_rows.append([
types.InlineKeyboardButton(
text=get_texts(user.language).t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"),
callback_data="subscription_extend",
)
])
reply_markup = (
types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
if keyboard_rows
else None
)
await bot.send_message(
chat_id=user.telegram_id,
text=message,
parse_mode="HTML"
parse_mode="HTML",
reply_markup=reply_markup,
)
logger.info(f"✅ Уведомление о изменении баланса отправлено пользователю {user.telegram_id}")
@@ -652,7 +672,10 @@ class UserService:
mulenpay_payments = mulenpay_result.scalars().all()
if mulenpay_payments:
logger.info(f"🔄 Удаляем {len(mulenpay_payments)} MulenPay платежей")
mulenpay_name = settings.get_mulenpay_display_name()
logger.info(
f"🔄 Удаляем {len(mulenpay_payments)} {mulenpay_name} платежей"
)
await db.execute(
update(MulenPayPayment)
.where(MulenPayPayment.user_id == user_id)
@@ -664,7 +687,9 @@ class UserService:
)
await db.flush()
except Exception as e:
logger.error(f"❌ Ошибка удаления MulenPay платежей: {e}")
logger.error(
f"❌ Ошибка удаления {settings.get_mulenpay_display_name()} платежей: {e}"
)
try:
pal24_result = await db.execute(
+9 -3
View File
@@ -102,8 +102,11 @@ class WataService:
@staticmethod
def _format_datetime(value: datetime) -> str:
aware = value.astimezone(timezone.utc)
return aware.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
if value.tzinfo is None:
aware = value.replace(tzinfo=timezone.utc)
else:
aware = value.astimezone(timezone.utc)
return aware.isoformat().replace("+00:00", "Z")
@staticmethod
def _parse_datetime(raw: Optional[str]) -> Optional[datetime]:
@@ -111,7 +114,10 @@ class WataService:
return None
try:
normalized = raw.replace("Z", "+00:00")
return datetime.fromisoformat(normalized)
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
return parsed
return parsed.astimezone(timezone.utc).replace(tzinfo=None)
except (ValueError, TypeError):
logger.debug("Failed to parse WATA datetime: %s", raw)
return None
+108 -3
View File
@@ -188,6 +188,8 @@ class YooKassaService:
}
try:
# Создаем один платеж с подтверждением через QR
# Это позволит получить QR-код для пользователя
builder = PaymentRequestBuilder()
builder.set_amount({
@@ -197,6 +199,7 @@ class YooKassaService:
builder.set_capture(True)
# Устанавливаем подтверждение через redirect для получения вебхуков
builder.set_confirmation({
"type": "redirect",
"return_url": self.return_url
@@ -234,7 +237,7 @@ class YooKassaService:
payment_request = builder.build()
logger.info(
f"Создание платежа YooKassa СБП (Idempotence-Key: {idempotence_key}). "
f"Создание платежа YooKassa СБП с подтверждением 'qr' (Idempotence-Key: {idempotence_key}). "
f"Сумма: {amount} {currency}. Метаданные: {metadata}. Чек: {receipt_data_dict}")
loop = asyncio.get_running_loop()
@@ -242,11 +245,14 @@ class YooKassaService:
None, lambda: YooKassaPayment.create(payment_request, idempotence_key))
logger.info(
f"Ответ YooKassa Payment.create (СБП): ID={response.id}, Status={response.status}, Paid={response.paid}")
f"Ответ YooKassa Payment.create (СБП, qr): ID={response.id}, Status={response.status}, Paid={response.paid}")
# Возвращаем данные платежа с QR-подтверждением
# Пользователь может использовать QR-код или оплатить через приложение банка по ID платежа
return {
"id": response.id,
"confirmation_url": response.confirmation.confirmation_url if response.confirmation else None,
"qr_confirmation_data": response.confirmation.confirmation_data if response.confirmation and hasattr(response.confirmation, 'confirmation_data') else None,
"confirmation_url": response.confirmation.confirmation_url if response.confirmation and hasattr(response.confirmation, 'confirmation_url') else None,
"status": response.status,
"metadata": response.metadata,
"amount_value": float(response.amount.value),
@@ -263,6 +269,105 @@ class YooKassaService:
logger.error(f"Ошибка создания платежа YooKassa СБП: {e}", exc_info=True)
return None
async def _create_sbp_payment_with_confirmation_type(
self,
amount: float,
currency: str,
description: str,
metadata: Dict[str, Any],
customer_contact_for_receipt: Dict[str, str],
confirmation_type: str) -> Optional[Dict[str, Any]]:
"""Создает SBP платеж с указанным типом подтверждения"""
try:
builder = PaymentRequestBuilder()
builder.set_amount({
"value": str(round(amount, 2)),
"currency": currency.upper()
})
builder.set_capture(True)
if confirmation_type == "qr":
builder.set_confirmation({
"type": "qr"
})
else: # redirect
builder.set_confirmation({
"type": "redirect",
"return_url": self.return_url
})
builder.set_description(description)
builder.set_metadata(metadata)
builder.set_payment_method_data({
"type": "sbp"
})
receipt_items_list: List[Dict[str, Any]] = [{
"description": description[:128],
"quantity": "1.00",
"amount": {
"value": str(round(amount, 2)),
"currency": currency.upper()
},
"vat_code": str(getattr(settings, 'YOOKASSA_VAT_CODE', 1)),
"payment_mode": getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'),
"payment_subject": getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service')
}]
receipt_data_dict: Dict[str, Any] = {
"customer": customer_contact_for_receipt,
"items": receipt_items_list
}
builder.set_receipt(receipt_data_dict)
idempotence_key = str(uuid.uuid4())
payment_request = builder.build()
logger.info(
f"Создание платежа YooKassa СБП с подтверждением '{confirmation_type}' (Idempotence-Key: {idempotence_key}). "
f"Сумма: {amount} {currency}. Метаданные: {metadata}. Чек: {receipt_data_dict}")
loop = asyncio.get_running_loop()
response = await loop.run_in_executor(
None, lambda: YooKassaPayment.create(payment_request, idempotence_key))
logger.info(
f"Ответ YooKassa Payment.create (СБП, {confirmation_type}): ID={response.id}, Status={response.status}, Paid={response.paid}")
result = {
"id": response.id,
"status": response.status,
"metadata": response.metadata,
"amount_value": float(response.amount.value),
"amount_currency": response.amount.currency,
"idempotence_key_used": idempotence_key,
"paid": response.paid,
"refundable": response.refundable,
"created_at": response.created_at.isoformat() if hasattr(
response.created_at, 'isoformat') else str(response.created_at),
"description_from_yk": response.description,
"test_mode": response.test if hasattr(response, 'test') else None
}
# Добавляем данные подтверждения в зависимости от типа
if confirmation_type == "qr":
if response.confirmation and hasattr(response.confirmation, 'confirmation_data'):
result["confirmation_data"] = response.confirmation.confirmation_data
else: # redirect
if response.confirmation and hasattr(response.confirmation, 'confirmation_url'):
result["confirmation_url"] = response.confirmation.confirmation_url
return result
except Exception as e:
logger.error(f"Ошибка создания платежа YooKassa СБП с подтверждением '{confirmation_type}': {e}", exc_info=True)
return None
async def get_payment_info(
self, payment_id_in_yookassa: str) -> Optional[Dict[str, Any]]:
+5
View File
@@ -18,9 +18,13 @@ class SubscriptionStates(StatesGroup):
extending_subscription = State()
confirming_traffic_reset = State()
cart_saved_for_topup = State()
# Состояния для простой подписки
waiting_for_simple_subscription_payment_method = State()
class BalanceStates(StatesGroup):
waiting_for_amount = State()
waiting_for_pal24_method = State()
waiting_for_stars_payment = State()
waiting_for_support_request = State()
@@ -32,6 +36,7 @@ class PromoCodeStates(StatesGroup):
class AdminStates(StatesGroup):
waiting_for_user_search = State()
sending_user_message = State()
editing_user_balance = State()
extending_subscription = State()
adding_traffic = State()
+8 -1
View File
@@ -46,11 +46,12 @@ def get_available_payment_methods() -> List[Dict[str, str]]:
})
if settings.is_mulenpay_enabled():
mulenpay_name = settings.get_mulenpay_display_name()
methods.append({
"id": "mulenpay",
"name": "Банковская карта",
"icon": "💳",
"description": "через Mulen Pay",
"description": f"через {mulenpay_name}",
"callback": "topup_mulenpay"
})
@@ -129,6 +130,12 @@ def get_payment_methods_text(language: str) -> str:
f"PAYMENT_METHOD_{method_id}_DESCRIPTION",
method['description'],
)
if method_id == "MULENPAY":
mulenpay_name = settings.get_mulenpay_display_name()
mulenpay_name_html = settings.get_mulenpay_display_name_html()
name = name.format(mulenpay_name=mulenpay_name_html)
description = description.format(mulenpay_name=mulenpay_name)
text += f"{name} - {description}\n"
text += "\n" + texts.t(
+4 -3
View File
@@ -668,6 +668,7 @@ async def get_payment_methods(
methods.append(
MiniAppPaymentMethod(
id="mulenpay",
name=settings.get_mulenpay_display_name(),
icon="💳",
requires_amount=True,
currency="RUB",
@@ -958,7 +959,7 @@ async def create_payment_link(
option = (payload.payment_option or "").strip().lower()
if option not in {"card", "sbp"}:
option = "sbp"
provider_method = "CARD" if option == "card" else "SBP"
provider_method = "card" if option == "card" else "sbp"
payment_service = PaymentService()
result = await payment_service.create_pal24_payment(
@@ -974,7 +975,7 @@ async def create_payment_link(
preferred_urls: List[Optional[str]] = []
if option == "sbp":
preferred_urls.append(result.get("sbp_url"))
preferred_urls.append(result.get("sbp_url") or result.get("transfer_url"))
elif option == "card":
preferred_urls.append(result.get("card_url"))
preferred_urls.extend(
@@ -998,7 +999,7 @@ async def create_payment_link(
"bill_id": result.get("bill_id"),
"order_id": result.get("order_id"),
"payment_method": result.get("payment_method") or provider_method,
"sbp_url": result.get("sbp_url"),
"sbp_url": result.get("sbp_url") or result.get("transfer_url"),
"card_url": result.get("card_url"),
"link_url": result.get("link_url"),
"link_page_url": result.get("link_page_url"),
+1
View File
@@ -361,6 +361,7 @@ class MiniAppPaymentMethodsRequest(BaseModel):
class MiniAppPaymentMethod(BaseModel):
id: str
name: Optional[str] = None
icon: Optional[str] = None
requires_amount: bool = False
currency: str = "RUB"
+4 -1
View File
@@ -35,7 +35,7 @@ services:
retries: 3
bot:
image: fr1ngg/remnawave-bedolaga-telegram-bot:latest
build: .
container_name: remnawave_bot
restart: unless-stopped
depends_on:
@@ -75,6 +75,9 @@ 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"
networks:
- bot_network
healthcheck:
-1241
View File
File diff suppressed because it is too large Load Diff
-1243
View File
File diff suppressed because it is too large Load Diff
+49 -5
View File
@@ -17,6 +17,7 @@ from app.services.version_service import version_service
from app.external.webhook_server import WebhookServer
from app.external.yookassa_webhook import start_yookassa_webhook_server
from app.external.pal24_webhook import start_pal24_webhook_server, Pal24WebhookServer
from app.external.wata_webhook import start_wata_webhook_server
from app.database.universal_migration import run_universal_migration
from app.services.backup_service import backup_service
from app.services.reporting_service import reporting_service
@@ -72,6 +73,7 @@ async def main():
webhook_server = None
yookassa_server_task = None
wata_server_task = None
pal24_server: Pal24WebhookServer | None = None
monitoring_task = None
maintenance_task = None
@@ -246,7 +248,7 @@ async def main():
if settings.TRIBUTE_ENABLED:
enabled_services.append("Tribute")
if settings.is_mulenpay_enabled():
enabled_services.append("Mulen Pay")
enabled_services.append(settings.get_mulenpay_display_name())
if settings.is_cryptobot_enabled():
enabled_services.append("CryptoBot")
@@ -255,7 +257,9 @@ async def main():
stage.log(f"Активированы: {', '.join(enabled_services)}")
stage.success("Webhook сервера запущены")
else:
stage.skip("Tribute, Mulen Pay и CryptoBot отключены")
stage.skip(
f"Tribute, {settings.get_mulenpay_display_name()} и CryptoBot отключены"
)
async with timeline.stage(
"YooKassa webhook",
@@ -285,6 +289,21 @@ async def main():
else:
stage.skip("PayPalych отключен настройками")
async with timeline.stage(
"WATA webhook",
"💳",
success_message="WATA webhook запущен",
) as stage:
if settings.is_wata_enabled():
wata_server_task = asyncio.create_task(
start_wata_webhook_server(payment_service)
)
stage.log(
f"Endpoint: {settings.WEBHOOK_URL}:{settings.WATA_WEBHOOK_PORT}{settings.WATA_WEBHOOK_PATH}"
)
else:
stage.skip("WATA отключен настройками")
async with timeline.stage(
"Служба мониторинга",
"📈",
@@ -355,7 +374,8 @@ async def main():
)
if settings.is_mulenpay_enabled():
webhook_lines.append(
f"Mulen Pay: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.MULENPAY_WEBHOOK_PATH}"
f"{settings.get_mulenpay_display_name()}: "
f"{settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.MULENPAY_WEBHOOK_PATH}"
)
if settings.is_cryptobot_enabled():
webhook_lines.append(
@@ -369,6 +389,10 @@ async def main():
webhook_lines.append(
f"PayPalych: {settings.WEBHOOK_URL}:{settings.PAL24_WEBHOOK_PORT}{settings.PAL24_WEBHOOK_PATH}"
)
if settings.is_wata_enabled():
webhook_lines.append(
f"WATA: {settings.WEBHOOK_URL}:{settings.WATA_WEBHOOK_PORT}{settings.WATA_WEBHOOK_PATH}"
)
timeline.log_section(
"Активные webhook endpoints",
@@ -399,7 +423,19 @@ async def main():
yookassa_server_task = asyncio.create_task(
start_yookassa_webhook_server(payment_service)
)
if wata_server_task and wata_server_task.done():
exception = wata_server_task.exception()
if exception:
logger.error(f"WATA webhook сервер завершился с ошибкой: {exception}")
logger.info("🔄 Перезапуск WATA webhook сервера...")
if settings.is_wata_enabled():
wata_server_task = asyncio.create_task(
start_wata_webhook_server(payment_service)
)
else:
wata_server_task = None
if monitoring_task.done():
exception = monitoring_task.exception()
if exception:
@@ -446,7 +482,15 @@ async def main():
await yookassa_server_task
except asyncio.CancelledError:
pass
if wata_server_task and not wata_server_task.done():
logger.info("ℹ️ Остановка WATA webhook сервера...")
wata_server_task.cancel()
try:
await wata_server_task
except asyncio.CancelledError:
pass
if monitoring_task and not monitoring_task.done():
logger.info("ℹ️ Остановка службы мониторинга...")
monitoring_service.stop_monitoring()
+78 -7
View File
@@ -13224,15 +13224,45 @@
return false;
}
const typeRaw = String(userData.subscription_type || '').toLowerCase();
if (typeRaw === 'trial') {
const missing = coerceBoolean(
userData.subscription_missing
?? userData.subscriptionMissing
?? userData.user.subscription_missing
?? userData.user.subscriptionMissing,
false,
);
if (missing) {
return false;
}
if (typeRaw === 'paid') {
return true;
const expiredFlag = coerceBoolean(
userData.subscription_expired
?? userData.subscriptionExpired
?? userData.user.subscription_expired
?? userData.user.subscriptionExpired,
false,
);
if (expiredFlag) {
return false;
}
if (userData.user.has_active_subscription === false) {
const expirationCandidates = [
userData.subscription_expires_at,
userData.subscriptionExpiresAt,
userData.user.subscription_expires_at,
userData.user.subscriptionExpiresAt,
userData.user.expires_at,
userData.user.expiresAt,
userData.expires_at,
userData.expiresAt,
];
const isExpiredByDate = expirationCandidates.some(candidate => {
const expiresAt = parseDate(candidate);
return expiresAt instanceof Date
&& !Number.isNaN(expiresAt.getTime())
&& expiresAt.getTime() <= Date.now();
});
if (isExpiredByDate) {
return false;
}
@@ -13241,11 +13271,52 @@
|| userData.user.subscription_status
|| ''
).toLowerCase();
if (['trial', 'expired', 'disabled'].includes(statusRaw)) {
if (statusRaw) {
const inactiveStatuses = [
'expired',
'disabled',
'missing',
'inactive',
'cancelled',
'canceled',
'ended',
'paused',
'stopped',
];
if (inactiveStatuses.includes(statusRaw)) {
return false;
}
if (['trial', 'trialing'].includes(statusRaw)) {
return false;
}
if (['active', 'paid'].includes(statusRaw)) {
return true;
}
}
const hasActiveFlag = userData.user.has_active_subscription;
if (hasActiveFlag === false) {
return false;
}
return true;
const typeRaw = String(
userData.subscription_type
?? userData.user.subscription_type
?? ''
).toLowerCase();
if (['trial', 'expired', 'inactive', 'disabled', 'none', 'missing', 'ended'].includes(typeRaw)) {
return false;
}
if (hasActiveFlag === true && typeRaw !== 'trial') {
return true;
}
return typeRaw === 'paid';
}
function normalizeServerEntry(entry) {
+90
View File
@@ -0,0 +1,90 @@
"""Unit tests for the WATA webhook handler."""
from __future__ import annotations
import base64
from pathlib import Path
import sys
from typing import Optional
import pytest
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
from app.external.wata_webhook import WataWebhookHandler # noqa: E402
class DummyPaymentService:
async def process_wata_webhook(self, *args, **kwargs): # pragma: no cover - not used in tests
return True
class StubPublicKeyProvider:
def __init__(self, public_key_pem: Optional[str]) -> None:
self.public_key_pem = public_key_pem
async def get_public_key(self) -> Optional[str]:
return self.public_key_pem
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
@pytest.mark.anyio("asyncio")
async def test_verify_signature_success() -> None:
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode("utf-8")
payload = "{\"status\": \"Paid\"}"
signature = base64.b64encode(
private_key.sign(
payload.encode("utf-8"),
padding.PKCS1v15(),
hashes.SHA512(),
)
).decode("utf-8")
handler = WataWebhookHandler(
DummyPaymentService(),
public_key_provider=StubPublicKeyProvider(public_key),
)
assert await handler._verify_signature(payload, signature) is True
@pytest.mark.anyio("asyncio")
async def test_verify_signature_fails_with_invalid_signature() -> None:
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode("utf-8")
payload = "{\"status\": \"Paid\"}"
bad_signature = base64.b64encode(b"not-a-signature").decode("utf-8")
handler = WataWebhookHandler(
DummyPaymentService(),
public_key_provider=StubPublicKeyProvider(public_key),
)
assert await handler._verify_signature(payload, bad_signature) is False
@pytest.mark.anyio("asyncio")
async def test_verify_signature_fails_without_public_key() -> None:
handler = WataWebhookHandler(
DummyPaymentService(),
public_key_provider=StubPublicKeyProvider(None),
)
assert await handler._verify_signature("{}", "signature") is False
+3 -1
View File
@@ -70,13 +70,15 @@ async def test_create_bill_success(monkeypatch: pytest.MonkeyPatch) -> None:
ttl_seconds=600,
custom_payload={"extra": "value"},
payer_email="user@example.com",
payment_method="CARD",
payment_method="BANK_CARD",
)
assert result["bill_id"] == "BILL42"
assert client.calls and client.calls[0]["amount"] == Decimal("500.00")
assert client.calls[0]["shop_id"] == "shop42"
assert client.calls[0]["description"] == "Пополнение"
assert client.calls[0]["custom"] == json.dumps({"extra": "value"}, ensure_ascii=False, separators=(",", ":"))
assert client.calls[0]["payment_method"] == "BANK_CARD"
@pytest.mark.anyio("asyncio")
+34 -1
View File
@@ -101,13 +101,46 @@ async def test_create_pal24_payment_success(monkeypatch: pytest.MonkeyPatch) ->
assert result is not None
assert result["local_payment_id"] == 321
assert result["bill_id"] == "BILL-1"
assert result["payment_method"] == "CARD"
assert result["payment_method"] == "card"
assert result["link_url"] == "https://pal24/sbp"
assert result["card_url"] == "https://pal24/card"
assert stub.calls and stub.calls[0]["payment_method"] == "BANK_CARD"
assert stub.calls and stub.calls[0]["amount_kopeks"] == 50000
assert "links" in captured_args["metadata"]
@pytest.mark.anyio("asyncio")
async def test_create_pal24_payment_default_method(monkeypatch: pytest.MonkeyPatch) -> None:
stub = StubPal24Service()
service = _make_service(stub)
db = DummySession()
async def fake_create_pal24_payment(*args: Any, **kwargs: Any) -> DummyLocalPayment:
return DummyLocalPayment(payment_id=111)
monkeypatch.setattr(
payment_service_module,
"create_pal24_payment",
fake_create_pal24_payment,
raising=False,
)
monkeypatch.setattr(settings, "PAL24_MIN_AMOUNT_KOPEKS", 1000, raising=False)
monkeypatch.setattr(settings, "PAL24_MAX_AMOUNT_KOPEKS", 1_000_000, raising=False)
result = await service.create_pal24_payment(
db=db,
user_id=42,
amount_kopeks=10_000,
description="Пополнение",
language="ru",
)
assert result is not None
assert stub.calls and stub.calls[0]["payment_method"] == "SBP"
assert result["payment_method"] == "sbp"
@pytest.mark.anyio("asyncio")
async def test_create_pal24_payment_limits_and_configuration(monkeypatch: pytest.MonkeyPatch) -> None:
stub = StubPal24Service()
+229 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
import sys
from typing import Any, Dict, Optional
@@ -16,6 +16,7 @@ if str(ROOT_DIR) not in sys.path:
import app.services.payment_service as payment_service_module # noqa: E402
from app.config import settings # noqa: E402
from app.services.payment_service import PaymentService # noqa: E402
from app.services.wata_service import WataService # noqa: E402
@pytest.fixture
@@ -47,6 +48,23 @@ class StubWataService:
return self.response
class DummyWataPayment:
def __init__(self) -> None:
self.id = 1
self.user_id = 42
self.payment_link_id = "link-123"
self.order_id = "order-123"
self.amount_kopeks = 15_000
self.currency = "RUB"
self.description = "Пополнение"
self.status = "Opened"
self.is_paid = False
self.metadata_json: Dict[str, Any] = {}
self.transaction_id: Optional[int] = None
self.callback_payload: Optional[Dict[str, Any]] = None
self.terminal_public_id: Optional[str] = None
def _make_service(stub: Optional[StubWataService]) -> PaymentService:
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
service.bot = None
@@ -59,6 +77,22 @@ def _make_service(stub: Optional[StubWataService]) -> PaymentService:
return service
def test_wata_service_format_datetime_accepts_naive_utc() -> None:
value = datetime(2024, 5, 20, 12, 30, 0)
formatted = WataService._format_datetime(value)
assert formatted == "2024-05-20T12:30:00Z"
def test_wata_service_parse_datetime_returns_naive_utc() -> None:
parsed = WataService._parse_datetime("2024-05-20T12:30:00Z")
assert parsed == datetime(2024, 5, 20, 12, 30, 0)
assert parsed.tzinfo is None
parsed_with_offset = WataService._parse_datetime("2024-05-20T15:30:00+03:00")
assert parsed_with_offset == datetime(2024, 5, 20, 12, 30, 0)
assert parsed_with_offset.tzinfo is None
@pytest.mark.anyio("asyncio")
async def test_create_wata_payment_success(monkeypatch: pytest.MonkeyPatch) -> None:
response = {
@@ -142,3 +176,197 @@ async def test_create_wata_payment_returns_none_without_service() -> None:
description="Пополнение",
)
assert result is None
@pytest.mark.anyio("asyncio")
async def test_process_wata_webhook_updates_status(monkeypatch: pytest.MonkeyPatch) -> None:
service = _make_service(None)
db = DummySession()
payment = DummyWataPayment()
update_kwargs: Dict[str, Any] = {}
link_lookup_called = False
async def fake_get_by_order_id(db_arg: Any, order_id: str) -> DummyWataPayment:
assert db_arg is db
assert order_id == payment.order_id
return payment
async def fake_get_by_link_id(*_: Any, **__: Any) -> Optional[DummyWataPayment]:
nonlocal link_lookup_called
link_lookup_called = True
return None
async def fake_update_status(
db_arg: Any,
*,
payment: DummyWataPayment,
**kwargs: Any,
) -> DummyWataPayment:
assert db_arg is db
update_kwargs.update(kwargs)
if "status" in kwargs:
payment.status = kwargs["status"]
if "is_paid" in kwargs:
payment.is_paid = kwargs["is_paid"]
if "metadata" in kwargs:
payment.metadata_json = kwargs["metadata"]
if "callback_payload" in kwargs:
payment.callback_payload = kwargs["callback_payload"]
if "terminal_public_id" in kwargs:
payment.terminal_public_id = kwargs["terminal_public_id"]
return payment
monkeypatch.setattr(
payment_service_module,
"get_wata_payment_by_order_id",
fake_get_by_order_id,
raising=False,
)
monkeypatch.setattr(
payment_service_module,
"get_wata_payment_by_link_id",
fake_get_by_link_id,
raising=False,
)
monkeypatch.setattr(
payment_service_module,
"update_wata_payment_status",
fake_update_status,
raising=False,
)
payload = {
"orderId": payment.order_id,
"transactionStatus": "Declined",
"terminalPublicId": "terminal-001",
}
processed = await service.process_wata_webhook(db, payload)
assert processed is True
assert link_lookup_called is False
assert payment.status == "Declined"
assert payment.is_paid is False
assert payment.metadata_json.get("last_webhook") == payload
assert payment.callback_payload == payload
assert payment.terminal_public_id == "terminal-001"
assert update_kwargs["status"] == "Declined"
assert update_kwargs["is_paid"] is False
@pytest.mark.anyio("asyncio")
async def test_process_wata_webhook_finalizes_paid(monkeypatch: pytest.MonkeyPatch) -> None:
service = _make_service(None)
db = DummySession()
payment = DummyWataPayment()
finalize_called = False
async def fake_get_by_order_id(*_: Any, **__: Any) -> DummyWataPayment:
return payment
async def fake_update_status(
db_arg: Any,
*,
payment: DummyWataPayment,
**kwargs: Any,
) -> DummyWataPayment:
if "metadata" in kwargs:
payment.metadata_json = kwargs["metadata"]
if "callback_payload" in kwargs:
payment.callback_payload = kwargs["callback_payload"]
if "status" in kwargs:
payment.status = kwargs["status"]
return payment
async def fake_finalize(
db_arg: Any,
payment_arg: DummyWataPayment,
payload_arg: Dict[str, Any],
) -> DummyWataPayment:
nonlocal finalize_called
finalize_called = True
payment_arg.is_paid = True
return payment_arg
monkeypatch.setattr(
payment_service_module,
"get_wata_payment_by_order_id",
fake_get_by_order_id,
raising=False,
)
monkeypatch.setattr(
payment_service_module,
"get_wata_payment_by_link_id",
lambda *args, **kwargs: None,
raising=False,
)
monkeypatch.setattr(
payment_service_module,
"update_wata_payment_status",
fake_update_status,
raising=False,
)
monkeypatch.setattr(
service,
"_finalize_wata_payment",
fake_finalize,
raising=False,
)
payload = {
"orderId": payment.order_id,
"transactionStatus": "Paid",
"transactionId": "tx-001",
}
processed = await service.process_wata_webhook(db, payload)
assert processed is True
assert finalize_called is True
assert payment.is_paid is True
assert payment.metadata_json.get("last_webhook") == payload
@pytest.mark.anyio("asyncio")
async def test_process_wata_webhook_returns_false_when_payment_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = _make_service(None)
db = DummySession()
async def fake_get_by_order_id(*_: Any, **__: Any) -> None:
return None
async def fake_get_by_link_id(*_: Any, **__: Any) -> None:
return None
async def fail_update(*_: Any, **__: Any) -> None:
pytest.fail("update_wata_payment_status should not be called")
monkeypatch.setattr(
payment_service_module,
"get_wata_payment_by_order_id",
fake_get_by_order_id,
raising=False,
)
monkeypatch.setattr(
payment_service_module,
"get_wata_payment_by_link_id",
fake_get_by_link_id,
raising=False,
)
monkeypatch.setattr(
payment_service_module,
"update_wata_payment_status",
fail_update,
raising=False,
)
payload = {
"orderId": "missing-order",
"transactionStatus": "Paid",
}
processed = await service.process_wata_webhook(db, payload)
assert processed is False
+177 -3
View File
@@ -63,7 +63,10 @@ def anyio_backend() -> str:
@pytest.mark.anyio("asyncio")
async def test_process_mulenpay_callback_success(monkeypatch: pytest.MonkeyPatch) -> None:
@pytest.mark.parametrize("status_field", ["payment_status", "status", "paymentStatus"])
async def test_process_mulenpay_callback_success(
monkeypatch: pytest.MonkeyPatch, status_field: str
) -> None:
bot = DummyBot()
service = _make_service(bot)
fake_session = FakeSession()
@@ -142,10 +145,10 @@ async def test_process_mulenpay_callback_success(monkeypatch: pytest.MonkeyPatch
payload = {
"uuid": "mulen_uuid",
"payment_status": "success",
"id": 123,
"amount": "50.00",
}
payload[status_field] = "success"
result = await service.process_mulenpay_callback(fake_session, payload)
@@ -372,6 +375,9 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -
transaction_id=None,
is_paid=False,
status="NEW",
metadata_json={},
payment_method=None,
paid_at=None,
)
async def fake_get_by_order(db, order_id):
@@ -419,6 +425,7 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -
subscription=None,
referred_by_id=None,
referrer=None,
language="ru",
)
async def fake_get_user(db, user_id):
@@ -439,7 +446,35 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -
async def send_balance_topup_notification(self, *args, **kwargs):
admin_calls.append((args, kwargs))
monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminServicePal(bot)))
monkeypatch.setitem(
sys.modules,
"app.services.admin_notification_service",
SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminServicePal(bot)),
)
user_cart_stub = SimpleNamespace(
user_cart_service=SimpleNamespace(has_user_cart=AsyncMock(return_value=True))
)
monkeypatch.setitem(sys.modules, "app.services.user_cart_service", user_cart_stub)
class DummyTypes:
class InlineKeyboardMarkup:
def __init__(self, inline_keyboard=None, **kwargs):
self.inline_keyboard = inline_keyboard or []
self.kwargs = kwargs
class InlineKeyboardButton:
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
monkeypatch.setitem(sys.modules, "aiogram", SimpleNamespace(types=DummyTypes))
monkeypatch.setitem(
sys.modules,
"app.localization.texts",
SimpleNamespace(get_texts=lambda language: SimpleNamespace(t=lambda key, default=None: default)),
)
service.build_topup_success_keyboard = AsyncMock(return_value=None)
payload = {
@@ -455,9 +490,148 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -
assert payment.transaction_id == 654
assert user.balance_kopeks == 5000
assert bot.sent_messages
saved_cart_message = bot.sent_messages[-1]
reply_markup = saved_cart_message["kwargs"].get("reply_markup")
assert reply_markup is not None
assert reply_markup.inline_keyboard[0][0].kwargs["callback_data"] == "subscription_resume_checkout"
assert admin_calls
@pytest.mark.anyio("asyncio")
async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.MonkeyPatch) -> None:
bot = DummyBot()
service = _make_service(bot)
class DummyPal24Service:
BILL_SUCCESS_STATES = {"SUCCESS", "OVERPAID"}
BILL_FAILED_STATES = {"FAIL"}
BILL_PENDING_STATES = {"NEW", "PROCESS", "UNDERPAID"}
async def get_bill_status(self, bill_id: str) -> Dict[str, Any]:
return {
"status": "SUCCESS",
"bill": {
"status": "SUCCESS",
"payments": [
{
"id": "trs-auto-1",
"status": "SUCCESS",
"method": "SBP",
"balance_amount": "50.00",
"balance_currency": "RUB",
}
],
},
}
service.pal24_service = DummyPal24Service()
fake_session = FakeSession()
payment = SimpleNamespace(
id=77,
bill_id="BILL-AUTO",
order_id="order-auto",
amount_kopeks=5000,
user_id=91,
transaction_id=None,
is_paid=False,
status="NEW",
metadata_json={},
payment_id=None,
payment_method=None,
paid_at=None,
)
async def fake_get_payment_by_id(db, local_id):
return payment
async def fake_update_payment(db, payment_obj, **kwargs):
for key, value in kwargs.items():
setattr(payment, key, value)
return payment
async def fake_link_payment(db, payment_obj, transaction_id):
payment.transaction_id = transaction_id
return payment
monkeypatch.setattr(payment_service_module, "get_pal24_payment_by_id", fake_get_payment_by_id)
monkeypatch.setattr(payment_service_module, "update_pal24_payment_status", fake_update_payment)
monkeypatch.setattr(payment_service_module, "link_pal24_payment_to_transaction", fake_link_payment)
transactions: list[Dict[str, Any]] = []
async def fake_create_transaction(db, **kwargs):
transactions.append(kwargs)
payment.transaction_id = 999
return SimpleNamespace(id=999, **kwargs)
monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction)
user = SimpleNamespace(
id=91,
telegram_id=9100,
balance_kopeks=0,
has_made_first_topup=False,
promo_group=None,
subscription=None,
referred_by_id=None,
referrer=None,
language="ru",
)
async def fake_get_user(db, user_id):
return user
monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user)
monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}", raising=False)
referral_stub = SimpleNamespace(process_referral_topup=AsyncMock())
monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_stub)
admin_notifications: list[Any] = []
class DummyAdminService:
def __init__(self, bot):
self.bot = bot
async def send_balance_topup_notification(self, *args, **kwargs):
admin_notifications.append((args, kwargs))
monkeypatch.setitem(
sys.modules,
"app.services.admin_notification_service",
SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)),
)
user_cart_stub = SimpleNamespace(
user_cart_service=SimpleNamespace(has_user_cart=AsyncMock(return_value=False))
)
monkeypatch.setitem(sys.modules, "app.services.user_cart_service", user_cart_stub)
class DummyTypes:
class InlineKeyboardMarkup:
def __init__(self, inline_keyboard=None, **kwargs):
self.inline_keyboard = inline_keyboard or []
self.kwargs = kwargs
class InlineKeyboardButton:
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
monkeypatch.setitem(sys.modules, "aiogram", SimpleNamespace(types=DummyTypes))
service.build_topup_success_keyboard = AsyncMock(return_value=None)
result = await service.get_pal24_payment_status(fake_session, payment.id)
assert result is not None
assert payment.transaction_id == 999
assert user.balance_kopeks == 5000
assert bot.sent_messages
assert admin_notifications
assert transactions and transactions[0]["user_id"] == 91
@pytest.mark.anyio("asyncio")
async def test_process_pal24_postback_payment_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
bot = DummyBot()
+4 -4
View File
@@ -86,8 +86,8 @@ async def test_create_payment_link_pal24_uses_selected_option(monkeypatch):
assert response.payment_url == 'https://card'
assert response.extra['selected_option'] == 'card'
assert response.extra['payment_method'] == 'CARD'
assert captured_calls and captured_calls[0]['payment_method'] == 'CARD'
assert response.extra['payment_method'] == 'card'
assert captured_calls and captured_calls[0]['payment_method'] == 'card'
@pytest.mark.anyio("asyncio")
@@ -265,7 +265,7 @@ async def test_resolve_pal24_status_includes_identifiers(monkeypatch):
transaction_id=777,
bill_id='BILL99',
order_id='ORD99',
payment_method='SBP',
payment_method='sbp',
)
class StubPal24Service:
@@ -297,7 +297,7 @@ async def test_resolve_pal24_status_includes_identifiers(monkeypatch):
assert result.extra['local_payment_id'] == 321
assert result.extra['bill_id'] == 'BILL99'
assert result.extra['order_id'] == 'ORD99'
assert result.extra['payment_method'] == 'SBP'
assert result.extra['payment_method'] == 'sbp'
assert result.extra['payload'] == 'pal24_payload'
assert result.extra['started_at'] == '2024-01-01T00:00:00Z'
assert result.extra['remote_status'] == 'PAID'