Compare commits

...

166 Commits

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-13 07:22:49 +00:00
75 changed files with 10877 additions and 5424 deletions
+31 -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
@@ -249,6 +261,22 @@ CRYPTOBOT_DEFAULT_ASSET=USDT
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
# HELEKET
HELEKET_ENABLED=false
HELEKET_MERCHANT_ID=
HELEKET_API_KEY=
HELEKET_BASE_URL=https://api.heleket.com/v1
HELEKET_DEFAULT_CURRENCY=USDT
HELEKET_DEFAULT_NETWORK=
HELEKET_INVOICE_LIFETIME=3600
HELEKET_MARKUP_PERCENT=0
HELEKET_WEBHOOK_PATH=/heleket-webhook
HELEKET_WEBHOOK_HOST=0.0.0.0
HELEKET_WEBHOOK_PORT=8086
HELEKET_CALLBACK_URL=
HELEKET_RETURN_URL=
HELEKET_SUCCESS_URL=
# MULENPAY
MULENPAY_ENABLED=false
MULENPAY_API_KEY=
+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.4-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.5.2-dev-$(git rev-parse --short HEAD)"
VERSION="v2.5.4-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.5.2-pr-$(git rev-parse --short HEAD)"
VERSION="v2.5.4-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+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.4-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.5.2-dev-$(git rev-parse --short HEAD)"
VERSION="v2.5.4-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.5.2-pr-$(git rev-parse --short HEAD)"
VERSION="v2.5.4-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
-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.4"
ARG BUILD_DATE
ARG VCS_REF
+664 -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()
+64
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 = "Интернет-сервис"
@@ -199,12 +206,28 @@ class Settings(BaseSettings):
CRYPTOBOT_ASSETS: str = "USDT,TON,BTC,ETH"
CRYPTOBOT_INVOICE_EXPIRES_HOURS: int = 24
HELEKET_ENABLED: bool = False
HELEKET_MERCHANT_ID: Optional[str] = None
HELEKET_API_KEY: Optional[str] = None
HELEKET_BASE_URL: str = "https://api.heleket.com/v1"
HELEKET_DEFAULT_CURRENCY: str = "USDT"
HELEKET_DEFAULT_NETWORK: Optional[str] = None
HELEKET_INVOICE_LIFETIME: int = 3600
HELEKET_MARKUP_PERCENT: float = 0.0
HELEKET_WEBHOOK_PATH: str = "/heleket-webhook"
HELEKET_WEBHOOK_HOST: str = "0.0.0.0"
HELEKET_WEBHOOK_PORT: int = 8086
HELEKET_CALLBACK_URL: Optional[str] = None
HELEKET_RETURN_URL: Optional[str] = None
HELEKET_SUCCESS_URL: Optional[str] = None
MULENPAY_ENABLED: bool = False
MULENPAY_API_KEY: Optional[str] = None
MULENPAY_SECRET_KEY: Optional[str] = None
MULENPAY_SHOP_ID: Optional[int] = None
MULENPAY_BASE_URL: str = "https://mulenpay.ru/api"
MULENPAY_WEBHOOK_PATH: str = "/mulenpay-webhook"
MULENPAY_DISPLAY_NAME: str = "Mulen Pay"
MULENPAY_DESCRIPTION: str = "Пополнение баланса"
MULENPAY_LANGUAGE: str = "ru"
MULENPAY_VAT_CODE: int = 0
@@ -245,6 +268,11 @@ class Settings(BaseSettings):
WATA_MIN_AMOUNT_KOPEKS: int = 10000
WATA_MAX_AMOUNT_KOPEKS: int = 100000000
WATA_REQUEST_TIMEOUT: int = 30
WATA_WEBHOOK_PATH: str = "/wata-webhook"
WATA_WEBHOOK_HOST: str = "0.0.0.0"
WATA_WEBHOOK_PORT: int = 8085
WATA_PUBLIC_KEY_URL: Optional[str] = None
WATA_PUBLIC_KEY_CACHE_SECONDS: int = 3600
MAIN_MENU_MODE: str = "default"
CONNECT_BUTTON_MODE: str = "guide"
@@ -773,6 +801,13 @@ class Settings(BaseSettings):
return (self.CRYPTOBOT_ENABLED and
self.CRYPTOBOT_API_TOKEN is not None)
def is_heleket_enabled(self) -> bool:
return (
self.HELEKET_ENABLED
and self.HELEKET_MERCHANT_ID is not None
and self.HELEKET_API_KEY is not None
)
def is_mulenpay_enabled(self) -> bool:
return (
self.MULENPAY_ENABLED
@@ -781,6 +816,15 @@ class Settings(BaseSettings):
and self.MULENPAY_SHOP_ID is not None
)
def get_mulenpay_display_name(self) -> str:
name = (self.MULENPAY_DISPLAY_NAME or "").strip()
if not name:
return "Mulen Pay"
return name
def get_mulenpay_display_name_html(self) -> str:
return html.escape(self.get_mulenpay_display_name())
def is_pal24_enabled(self) -> bool:
return (
self.PAL24_ENABLED
@@ -812,6 +856,26 @@ class Settings(BaseSettings):
def get_cryptobot_invoice_expires_seconds(self) -> int:
return self.CRYPTOBOT_INVOICE_EXPIRES_HOURS * 3600
def get_heleket_markup_percent(self) -> float:
try:
return float(self.HELEKET_MARKUP_PERCENT)
except (TypeError, ValueError):
return 0.0
def get_heleket_lifetime(self) -> int:
try:
value = int(self.HELEKET_INVOICE_LIFETIME)
except (TypeError, ValueError):
value = 3600
return max(300, min(43200, value))
def get_heleket_callback_url(self) -> Optional[str]:
if self.HELEKET_CALLBACK_URL:
return self.HELEKET_CALLBACK_URL
if self.WEBHOOK_URL:
return f"{self.WEBHOOK_URL}{self.HELEKET_WEBHOOK_PATH}"
return None
def is_happ_cryptolink_mode(self) -> bool:
return self.CONNECT_BUTTON_MODE == "happ_cryptolink"
+176
View File
@@ -0,0 +1,176 @@
import logging
from datetime import datetime
from typing import Any, Dict, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import HeleketPayment
logger = logging.getLogger(__name__)
async def create_heleket_payment(
db: AsyncSession,
*,
user_id: int,
uuid: str,
order_id: str,
amount: str,
currency: str,
status: str,
payer_amount: Optional[str] = None,
payer_currency: Optional[str] = None,
exchange_rate: Optional[float] = None,
discount_percent: Optional[int] = None,
payment_url: Optional[str] = None,
expires_at: Optional[datetime] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> HeleketPayment:
payment = HeleketPayment(
user_id=user_id,
uuid=uuid,
order_id=order_id,
amount=amount,
currency=currency,
status=status,
payer_amount=payer_amount,
payer_currency=payer_currency,
exchange_rate=exchange_rate,
discount_percent=discount_percent,
payment_url=payment_url,
expires_at=expires_at,
metadata_json=metadata or {},
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(
"Создан Heleket платеж: uuid=%s order_id=%s amount=%s %s для пользователя %s",
uuid,
order_id,
amount,
currency,
user_id,
)
return payment
async def get_heleket_payment_by_uuid(
db: AsyncSession,
uuid: str,
) -> Optional[HeleketPayment]:
result = await db.execute(
select(HeleketPayment)
.options(selectinload(HeleketPayment.user))
.where(HeleketPayment.uuid == uuid)
)
return result.scalar_one_or_none()
async def get_heleket_payment_by_order_id(
db: AsyncSession,
order_id: str,
) -> Optional[HeleketPayment]:
result = await db.execute(
select(HeleketPayment)
.options(selectinload(HeleketPayment.user))
.where(HeleketPayment.order_id == order_id)
)
return result.scalar_one_or_none()
async def get_heleket_payment_by_id(
db: AsyncSession,
payment_id: int,
) -> Optional[HeleketPayment]:
result = await db.execute(
select(HeleketPayment)
.options(selectinload(HeleketPayment.user))
.where(HeleketPayment.id == payment_id)
)
return result.scalar_one_or_none()
async def update_heleket_payment(
db: AsyncSession,
uuid: str,
*,
status: Optional[str] = None,
payer_amount: Optional[str] = None,
payer_currency: Optional[str] = None,
exchange_rate: Optional[float] = None,
discount_percent: Optional[int] = None,
paid_at: Optional[datetime] = None,
payment_url: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> Optional[HeleketPayment]:
payment = await get_heleket_payment_by_uuid(db, uuid)
if not payment:
logger.error("Heleket платеж с uuid=%s не найден", uuid)
return None
if status is not None:
payment.status = status
if payer_amount is not None:
payment.payer_amount = payer_amount
if payer_currency is not None:
payment.payer_currency = payer_currency
if exchange_rate is not None:
payment.exchange_rate = exchange_rate
if discount_percent is not None:
payment.discount_percent = discount_percent
if payment_url is not None:
payment.payment_url = payment_url
if metadata is not None:
existing = dict(payment.metadata_json or {})
existing.update(metadata)
payment.metadata_json = existing
if paid_at is not None:
payment.paid_at = paid_at
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
logger.info(
"Обновлен Heleket платеж %s: статус=%s payer_amount=%s %s",
uuid,
payment.status,
payment.payer_amount,
payment.payer_currency,
)
return payment
async def link_heleket_payment_to_transaction(
db: AsyncSession,
uuid: str,
transaction_id: int,
) -> Optional[HeleketPayment]:
payment = await get_heleket_payment_by_uuid(db, uuid)
if not payment:
logger.error("Не найден Heleket платеж для связи с транзакцией: %s", uuid)
return None
payment.transaction_id = transaction_id
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
logger.info(
"Heleket платеж %s связан с транзакцией %s",
uuid,
transaction_id,
)
return payment
+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:
+52
View File
@@ -167,6 +167,58 @@ async def get_available_server_squads(
return result.scalars().unique().all()
async def get_active_server_squads(db: AsyncSession) -> List[ServerSquad]:
"""Возвращает список активных серверов, доступных для подключения."""
squads = await get_available_server_squads(db)
if not squads:
return []
eligible: List[ServerSquad] = []
for squad in squads:
max_users = squad.max_users
current_users = squad.current_users or 0
if max_users is not None and current_users >= max_users:
continue
eligible.append(squad)
if eligible:
return eligible
return squads
async def choose_random_active_server_squad(
db: AsyncSession,
) -> Optional[ServerSquad]:
"""Возвращает случайный активный сервер."""
squads = await get_active_server_squads(db)
if not squads:
return None
return random.choice(squads)
async def get_random_active_squad_uuid(
db: AsyncSession,
fallback_uuid: Optional[str] = None,
) -> Optional[str]:
"""Возвращает UUID случайного активного сервера или запасной UUID."""
squad = await choose_random_active_server_squad(db)
if squad:
return squad.squad_uuid
return fallback_uuid
async def update_server_squad_promo_groups(
db: AsyncSession, server_id: int, promo_group_ids: Iterable[int]
) -> Optional[ServerSquad]:
+142 -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,130 @@ async def create_subscription(
logger.info(f"✅ Создана подписка для пользователя {user_id}")
return subscription
async def create_pending_subscription(
db: AsyncSession,
user_id: int,
duration_days: int,
traffic_limit_gb: int = 0,
device_limit: int = 1,
connected_squads: List[str] = None,
payment_method: str = "pending",
total_price_kopeks: int = 0
) -> Subscription:
"""Creates a pending subscription that will be activated after payment."""
current_time = datetime.utcnow()
end_date = current_time + timedelta(days=duration_days)
existing_subscription = await get_subscription_by_user_id(db, user_id)
if existing_subscription:
if (
existing_subscription.status == SubscriptionStatus.ACTIVE.value
and existing_subscription.end_date > current_time
):
logger.warning(
"⚠️ Попытка создать pending подписку для активного пользователя %s. Возвращаем существующую запись.",
user_id,
)
return existing_subscription
existing_subscription.status = SubscriptionStatus.PENDING.value
existing_subscription.is_trial = False
existing_subscription.start_date = current_time
existing_subscription.end_date = end_date
existing_subscription.traffic_limit_gb = traffic_limit_gb
existing_subscription.device_limit = device_limit
existing_subscription.connected_squads = connected_squads or []
existing_subscription.traffic_used_gb = 0.0
existing_subscription.updated_at = current_time
await db.commit()
await db.refresh(existing_subscription)
logger.info(
"♻️ Обновлена ожидающая подписка пользователя %s, ID: %s, метод оплаты: %s",
user_id,
existing_subscription.id,
payment_method,
)
return existing_subscription
subscription = Subscription(
user_id=user_id,
status=SubscriptionStatus.PENDING.value,
is_trial=False,
start_date=current_time,
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=connected_squads or [],
autopay_enabled=settings.is_autopay_enabled_by_default(),
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
)
db.add(subscription)
await db.commit()
await db.refresh(subscription)
logger.info(
"💳 Создана ожидающая подписка для пользователя %s, ID: %s, метод оплаты: %s",
user_id,
subscription.id,
payment_method,
)
return subscription
async def activate_pending_subscription(
db: AsyncSession,
user_id: int,
period_days: int = None
) -> Optional[Subscription]:
"""Активирует pending подписку пользователя, меняя её статус на ACTIVE."""
from sqlalchemy import and_
logger.info(f"Активация pending подписки: пользователь {user_id}, период {period_days} дней")
# Находим pending подписку пользователя
result = await db.execute(
select(Subscription)
.where(
and_(
Subscription.user_id == user_id,
Subscription.status == SubscriptionStatus.PENDING.value
)
)
)
pending_subscription = result.scalar_one_or_none()
if not pending_subscription:
logger.warning(f"Не найдена pending подписка для пользователя {user_id}")
return None
logger.info(f"Найдена pending подписка {pending_subscription.id} для пользователя {user_id}, статус: {pending_subscription.status}")
# Обновляем статус подписки на ACTIVE
current_time = datetime.utcnow()
pending_subscription.status = SubscriptionStatus.ACTIVE.value
# Если указан период, обновляем дату окончания
if period_days is not None:
effective_start = pending_subscription.start_date or current_time
if effective_start < current_time:
effective_start = current_time
pending_subscription.end_date = effective_start + timedelta(days=period_days)
# Обновляем дату начала, если она не установлена или в прошлом
if not pending_subscription.start_date or pending_subscription.start_date < current_time:
pending_subscription.start_date = current_time
await db.commit()
await db.refresh(pending_subscription)
logger.info(f"Подписка пользователя {user_id} активирована, ID: {pending_subscription.id}")
return pending_subscription
+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,
+69 -1
View File
@@ -1,5 +1,5 @@
from datetime import datetime, timedelta
from typing import Optional, List, Dict
from typing import Optional, List, Dict, Any
from enum import Enum
from sqlalchemy import (
@@ -54,6 +54,7 @@ class SubscriptionStatus(Enum):
ACTIVE = "active"
EXPIRED = "expired"
DISABLED = "disabled"
PENDING = "pending"
class TransactionType(Enum):
@@ -75,6 +76,7 @@ class PaymentMethod(Enum):
TRIBUTE = "tribute"
YOOKASSA = "yookassa"
CRYPTOBOT = "cryptobot"
HELEKET = "heleket"
MULENPAY = "mulenpay"
PAL24 = "pal24"
WATA = "wata"
@@ -189,6 +191,72 @@ class CryptoBotPayment(Base):
return f"<CryptoBotPayment(id={self.id}, invoice_id={self.invoice_id}, amount={self.amount} {self.asset}, status={self.status})>"
class HeleketPayment(Base):
__tablename__ = "heleket_payments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
uuid = Column(String(255), unique=True, nullable=False, index=True)
order_id = Column(String(128), unique=True, nullable=False, index=True)
amount = Column(String(50), nullable=False)
currency = Column(String(10), nullable=False)
payer_amount = Column(String(50), nullable=True)
payer_currency = Column(String(10), nullable=True)
exchange_rate = Column(Float, nullable=True)
discount_percent = Column(Integer, nullable=True)
status = Column(String(50), nullable=False)
payment_url = Column(Text, nullable=True)
metadata_json = Column(JSON, nullable=True)
paid_at = Column(DateTime, nullable=True)
expires_at = Column(DateTime, nullable=True)
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
user = relationship("User", backref="heleket_payments")
transaction = relationship("Transaction", backref="heleket_payment")
@property
def amount_float(self) -> float:
try:
return float(self.amount)
except (TypeError, ValueError):
return 0.0
@property
def amount_kopeks(self) -> int:
return int(round(self.amount_float * 100))
@property
def payer_amount_float(self) -> float:
try:
return float(self.payer_amount) if self.payer_amount is not None else 0.0
except (TypeError, ValueError):
return 0.0
@property
def is_paid(self) -> bool:
return self.status in {"paid", "paid_over"}
def __repr__(self):
return (
"<HeleketPayment(id={id}, uuid={uuid}, order_id={order_id}, amount={amount}"
" {currency}, status={status})>"
).format(
id=self.id,
uuid=self.uuid,
order_id=self.order_id,
amount=self.amount,
currency=self.currency,
status=self.status,
)
class MulenPayPayment(Base):
__tablename__ = "mulenpay_payments"
+131 -5
View File
@@ -576,6 +576,120 @@ async def create_cryptobot_payments_table():
return False
async def create_heleket_payments_table():
table_exists = await check_table_exists('heleket_payments')
if table_exists:
logger.info("Таблица heleket_payments уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
create_sql = """
CREATE TABLE heleket_payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
uuid VARCHAR(255) UNIQUE NOT NULL,
order_id VARCHAR(128) UNIQUE NOT NULL,
amount VARCHAR(50) NOT NULL,
currency VARCHAR(10) NOT NULL,
payer_amount VARCHAR(50) NULL,
payer_currency VARCHAR(10) NULL,
exchange_rate DOUBLE PRECISION NULL,
discount_percent INTEGER NULL,
status VARCHAR(50) NOT NULL,
payment_url TEXT NULL,
metadata_json JSON NULL,
paid_at DATETIME NULL,
expires_at DATETIME NULL,
transaction_id INTEGER NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
);
CREATE INDEX idx_heleket_payments_user_id ON heleket_payments(user_id);
CREATE INDEX idx_heleket_payments_uuid ON heleket_payments(uuid);
CREATE INDEX idx_heleket_payments_order_id ON heleket_payments(order_id);
CREATE INDEX idx_heleket_payments_status ON heleket_payments(status);
"""
elif db_type == 'postgresql':
create_sql = """
CREATE TABLE heleket_payments (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
uuid VARCHAR(255) UNIQUE NOT NULL,
order_id VARCHAR(128) UNIQUE NOT NULL,
amount VARCHAR(50) NOT NULL,
currency VARCHAR(10) NOT NULL,
payer_amount VARCHAR(50) NULL,
payer_currency VARCHAR(10) NULL,
exchange_rate DOUBLE PRECISION NULL,
discount_percent INTEGER NULL,
status VARCHAR(50) NOT NULL,
payment_url TEXT NULL,
metadata_json JSON NULL,
paid_at TIMESTAMP NULL,
expires_at TIMESTAMP NULL,
transaction_id INTEGER NULL REFERENCES transactions(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_heleket_payments_user_id ON heleket_payments(user_id);
CREATE INDEX idx_heleket_payments_uuid ON heleket_payments(uuid);
CREATE INDEX idx_heleket_payments_order_id ON heleket_payments(order_id);
CREATE INDEX idx_heleket_payments_status ON heleket_payments(status);
"""
elif db_type == 'mysql':
create_sql = """
CREATE TABLE heleket_payments (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
uuid VARCHAR(255) UNIQUE NOT NULL,
order_id VARCHAR(128) UNIQUE NOT NULL,
amount VARCHAR(50) NOT NULL,
currency VARCHAR(10) NOT NULL,
payer_amount VARCHAR(50) NULL,
payer_currency VARCHAR(10) NULL,
exchange_rate DOUBLE NULL,
discount_percent INT NULL,
status VARCHAR(50) NOT NULL,
payment_url TEXT NULL,
metadata_json JSON NULL,
paid_at DATETIME NULL,
expires_at DATETIME NULL,
transaction_id INT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
);
CREATE INDEX idx_heleket_payments_user_id ON heleket_payments(user_id);
CREATE INDEX idx_heleket_payments_uuid ON heleket_payments(uuid);
CREATE INDEX idx_heleket_payments_order_id ON heleket_payments(order_id);
CREATE INDEX idx_heleket_payments_status ON heleket_payments(status);
"""
else:
logger.error(f"Неподдерживаемый тип БД для таблицы heleket_payments: {db_type}")
return False
await conn.execute(text(create_sql))
logger.info("Таблица heleket_payments успешно создана")
return True
except Exception as e:
logger.error(f"Ошибка создания таблицы heleket_payments: {e}")
return False
async def create_mulenpay_payments_table():
table_exists = await check_table_exists('mulenpay_payments')
if table_exists:
@@ -3427,18 +3541,26 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей CryptoBot payments")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ MULEN PAY ===")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ HELEKET ===")
heleket_created = await create_heleket_payments_table()
if heleket_created:
logger.info("✅ Таблица Heleket payments готова")
else:
logger.warning("⚠️ Проблемы с таблицей Heleket payments")
mulenpay_name = settings.get_mulenpay_display_name()
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ %s ===", mulenpay_name)
mulenpay_created = await create_mulenpay_payments_table()
if mulenpay_created:
logger.info("✅ Таблица Mulen Pay payments готова")
logger.info("✅ Таблица %s payments готова", mulenpay_name)
else:
logger.warning("⚠️ Проблемы с таблицей Mulen Pay payments")
logger.warning("⚠️ Проблемы с таблицей %s payments", mulenpay_name)
mulenpay_schema_ok = await ensure_mulenpay_payment_schema()
if mulenpay_schema_ok:
logger.info("✅ Схема Mulen Pay payments актуальна")
logger.info("✅ Схема %s payments актуальна", mulenpay_name)
else:
logger.warning("⚠️ Не удалось обновить схему Mulen Pay payments")
logger.warning("⚠️ Не удалось обновить схему %s payments", mulenpay_name)
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PAL24 ===")
pal24_created = await create_pal24_payments_table()
@@ -3694,6 +3816,7 @@ async def run_universal_migration():
logger.info("=== МИГРАЦИЯ ЗАВЕРШЕНА УСПЕШНО ===")
logger.info("✅ Реферальная система обновлена")
logger.info("✅ CryptoBot таблица готова")
logger.info("✅ Heleket таблица готова")
logger.info("✅ Таблица конверсий подписок создана")
logger.info("✅ Таблица welcome_texts с полем is_enabled готова")
logger.info("✅ Медиа поля в broadcast_history добавлены")
@@ -3711,6 +3834,7 @@ async def check_migration_status():
status = {
"has_made_first_topup_column": False,
"cryptobot_table": False,
"heleket_table": False,
"user_messages_table": False,
"welcome_texts_table": False,
"welcome_texts_is_enabled_column": False,
@@ -3744,6 +3868,7 @@ async def check_migration_status():
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
status["cryptobot_table"] = await check_table_exists('cryptobot_payments')
status["heleket_table"] = await check_table_exists('heleket_payments')
status["user_messages_table"] = await check_table_exists('user_messages')
status["welcome_texts_table"] = await check_table_exists('welcome_texts')
status["privacy_policies_table"] = await check_table_exists('privacy_policies')
@@ -3796,6 +3921,7 @@ async def check_migration_status():
check_names = {
"has_made_first_topup_column": "Колонка реферальной системы",
"cryptobot_table": "Таблица CryptoBot payments",
"heleket_table": "Таблица Heleket payments",
"user_messages_table": "Таблица пользовательских сообщений",
"welcome_texts_table": "Таблица приветственных текстов",
"privacy_policies_table": "Таблица политик конфиденциальности",
+129
View File
@@ -0,0 +1,129 @@
"""HTTP client for Heleket payment API."""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import logging
from typing import Any, Dict, Optional
import aiohttp
from app.config import settings
logger = logging.getLogger(__name__)
class HeleketService:
"""Minimal wrapper around Heleket API endpoints."""
def __init__(self) -> None:
self.base_url = settings.HELEKET_BASE_URL.rstrip("/")
self.merchant_id = settings.HELEKET_MERCHANT_ID
self.api_key = settings.HELEKET_API_KEY
@property
def is_configured(self) -> bool:
return bool(self.merchant_id and self.api_key)
def _prepare_body(self, payload: Dict[str, Any]) -> str:
cleaned = {key: value for key, value in payload.items() if value is not None}
serialized = json.dumps(cleaned, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
if "/" in serialized:
serialized = serialized.replace("/", "\\/")
return serialized
def _generate_signature(self, body: str) -> str:
api_key = self.api_key or ""
encoded = base64.b64encode(body.encode("utf-8")).decode("utf-8")
raw = f"{encoded}{api_key}"
return hashlib.md5(raw.encode("utf-8")).hexdigest()
async def _request(self, endpoint: str, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
if not self.is_configured:
logger.error("Heleket сервис не настроен: merchant или api_key отсутствуют")
return None
body = self._prepare_body(payload)
signature = self._generate_signature(body)
url = f"{self.base_url}/{endpoint.lstrip('/')}"
headers = {
"merchant": self.merchant_id or "",
"sign": signature,
"Content-Type": "application/json",
}
try:
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, data=body.encode("utf-8"), headers=headers) as response:
text = await response.text()
if response.content_type != "application/json":
logger.error("Ответ Heleket не JSON (%s): %s", response.content_type, text)
return None
try:
data = json.loads(text)
except json.JSONDecodeError:
logger.error("Ошибка парсинга Heleket JSON: %s", text)
return None
if response.status >= 400:
logger.error("Heleket API %s вернул статус %s: %s", endpoint, response.status, data)
return None
if isinstance(data, dict) and data.get("state") == 0:
return data
logger.error("Heleket API вернул ошибку: %s", data)
return None
except Exception as error: # pragma: no cover - defensive
logger.error("Ошибка запроса к Heleket API: %s", error)
return None
async def create_payment(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return await self._request("payment", payload)
async def get_payment_info(
self,
*,
uuid: Optional[str] = None,
order_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
if not uuid and not order_id:
raise ValueError("Нужно указать uuid или order_id для Heleket payment/info")
payload: Dict[str, Any] = {}
if uuid:
payload["uuid"] = uuid
if order_id:
payload["order_id"] = order_id
return await self._request("payment/info", payload)
def verify_webhook_signature(self, payload: Dict[str, Any]) -> bool:
if not self.is_configured:
logger.warning("Heleket сервис не настроен, подпись пропускается")
return True
if not isinstance(payload, dict):
logger.error("Heleket webhook payload не dict: %s", payload)
return False
signature = payload.get("sign")
if not signature:
logger.error("Heleket webhook без подписи")
return False
data = {key: value for key, value in payload.items() if key != "sign"}
body = self._prepare_body(data)
expected = self._generate_signature(body)
is_valid = hmac.compare_digest(expected, str(signature))
if not is_valid:
logger.error("Неверная подпись Heleket webhook: ожидается %s, получено %s", expected, signature)
return is_valid
+111
View File
@@ -0,0 +1,111 @@
import asyncio
import json
import logging
from typing import Any, Dict, Optional
from aiohttp import web
from app.config import settings
from app.database.database import get_db
from app.external.heleket import HeleketService
from app.services.payment_service import PaymentService
logger = logging.getLogger(__name__)
class HeleketWebhookHandler:
def __init__(self, payment_service: PaymentService) -> None:
self.payment_service = payment_service
self.service = HeleketService()
async def handle(self, request: web.Request) -> web.Response:
if not settings.is_heleket_enabled():
logger.warning("Получен Heleket webhook, но сервис отключен")
return web.json_response({"status": "error", "reason": "disabled"}, status=503)
try:
payload: Dict[str, Any] = await request.json()
except json.JSONDecodeError:
logger.error("Некорректный JSON Heleket webhook")
return web.json_response({"status": "error", "reason": "invalid_json"}, status=400)
if not self.service.verify_webhook_signature(payload):
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
processed: Optional[bool] = None
async for db in get_db():
processed = await self.payment_service.process_heleket_webhook(db, payload)
if processed:
return web.json_response({"status": "ok"}, status=200)
return web.json_response({"status": "error", "reason": "not_processed"}, status=400)
async def health_check(self, _: web.Request) -> web.Response:
return web.json_response(
{
"status": "ok",
"service": "heleket_webhook",
"enabled": settings.is_heleket_enabled(),
"path": settings.HELEKET_WEBHOOK_PATH,
}
)
async def options_handler(self, _: web.Request) -> web.Response:
return web.Response(
status=200,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
)
def create_heleket_app(payment_service: PaymentService) -> web.Application:
handler = HeleketWebhookHandler(payment_service)
app = web.Application()
app.router.add_post(settings.HELEKET_WEBHOOK_PATH, handler.handle)
app.router.add_get("/heleket/health", handler.health_check)
app.router.add_get("/health", handler.health_check)
app.router.add_options(settings.HELEKET_WEBHOOK_PATH, handler.options_handler)
return app
async def start_heleket_webhook_server(payment_service: PaymentService) -> None:
if not settings.is_heleket_enabled():
logger.info("Heleket отключен, webhook сервер не запускается")
return
app = create_heleket_app(payment_service)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(
runner,
host=settings.HELEKET_WEBHOOK_HOST,
port=settings.HELEKET_WEBHOOK_PORT,
)
try:
await site.start()
logger.info(
"Heleket webhook сервер запущен на %s:%s",
settings.HELEKET_WEBHOOK_HOST,
settings.HELEKET_WEBHOOK_PORT,
)
logger.info(
"Heleket webhook URL: http://%s:%s%s",
settings.HELEKET_WEBHOOK_HOST,
settings.HELEKET_WEBHOOK_PORT,
settings.HELEKET_WEBHOOK_PATH,
)
while True:
await asyncio.sleep(1)
except asyncio.CancelledError:
logger.info("Heleket webhook сервер остановлен по запросу")
finally:
await site.stop()
await runner.cleanup()
logger.info("Heleket webhook сервер корректно остановлен")
+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:
+267 -10
View File
@@ -13,6 +13,11 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import SystemSetting, User
from app.database.crud.server_squad import (
get_all_server_squads,
get_server_squad_by_id,
get_server_squad_by_uuid,
)
from app.localization.texts import get_texts
from app.config import settings
from app.services.remnawave_service import RemnaWaveService
@@ -30,6 +35,7 @@ from app.external.telegram_stars import TelegramStarsService
CATEGORY_PAGE_SIZE = 10
SETTINGS_PAGE_SIZE = 8
SIMPLE_SUBSCRIPTION_SQUADS_PAGE_SIZE = 6
CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
"core": {
@@ -46,12 +52,13 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
},
"payments": {
"title": "💳 Платежные системы",
"description": "YooKassa, CryptoBot, MulenPay, PAL24, Wata, Tribute и Telegram Stars.",
"description": "YooKassa, CryptoBot, Heleket, MulenPay, PAL24, Wata, Tribute и Telegram Stars.",
"icon": "💳",
"categories": (
"PAYMENT",
"YOOKASSA",
"CRYPTOBOT",
"HELEKET",
"MULENPAY",
"PAL24",
"WATA",
@@ -61,9 +68,17 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
},
"subscriptions": {
"title": "📅 Подписки и цены",
"description": "Тарифы, периоды, лимиты трафика и автопродление.",
"description": "Тарифы, простая покупка, периоды, лимиты трафика и автопродление.",
"icon": "📅",
"categories": ("SUBSCRIPTIONS_CORE", "PERIODS", "SUBSCRIPTION_PRICES", "TRAFFIC", "TRAFFIC_PACKAGES", "AUTOPAY"),
"categories": (
"SUBSCRIPTIONS_CORE",
"SIMPLE_SUBSCRIPTION",
"PERIODS",
"SUBSCRIPTION_PRICES",
"TRAFFIC",
"TRAFFIC_PACKAGES",
"AUTOPAY",
),
},
"trial": {
"title": "🎁 Пробный период",
@@ -1303,7 +1318,10 @@ def _build_settings_keyboard(
label = texts.t("PAYMENT_CARD_TRIBUTE", "💳 Банковская карта (Tribute)")
test_payment_buttons.append([_test_button(f"{label} · тест", "tribute")])
elif category_key == "MULENPAY":
label = texts.t("PAYMENT_CARD_MULENPAY", "💳 Банковская карта (Mulen Pay)")
label = texts.t(
"PAYMENT_CARD_MULENPAY",
"💳 Банковская карта ({mulenpay_name})",
).format(mulenpay_name=settings.get_mulenpay_display_name())
test_payment_buttons.append([_test_button(f"{label} · тест", "mulenpay")])
elif category_key == "WATA":
label = texts.t("PAYMENT_CARD_WATA", "💳 Банковская карта (WATA)")
@@ -1414,6 +1432,16 @@ def _build_setting_keyboard(
for chunk in _chunk(choice_buttons, 2):
rows.append(list(chunk))
if key == "SIMPLE_SUBSCRIPTION_SQUAD_UUID" and not is_read_only:
rows.append([
types.InlineKeyboardButton(
text="🌍 Выбрать сквад",
callback_data=(
f"botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{callback_token}:1"
),
)
])
if definition.python_type is bool and not is_read_only:
rows.append([
types.InlineKeyboardButton(
@@ -1642,6 +1670,217 @@ async def show_bot_config_category(
await callback.answer()
@admin_required
@error_handler
async def show_simple_subscription_squad_selector(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
parts = callback.data.split(":", 5)
group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY
try:
category_page = max(1, int(parts[2])) if len(parts) > 2 else 1
except ValueError:
category_page = 1
try:
settings_page = max(1, int(parts[3])) if len(parts) > 3 else 1
except ValueError:
settings_page = 1
token = parts[4] if len(parts) > 4 else ""
try:
key = bot_configuration_service.resolve_callback_token(token)
except KeyError:
await callback.answer("Эта настройка больше недоступна", show_alert=True)
return
if key != "SIMPLE_SUBSCRIPTION_SQUAD_UUID":
await callback.answer("Эта настройка больше недоступна", show_alert=True)
return
try:
page = max(1, int(parts[5])) if len(parts) > 5 else 1
except ValueError:
page = 1
limit = SIMPLE_SUBSCRIPTION_SQUADS_PAGE_SIZE
squads, total_count = await get_all_server_squads(
db,
available_only=False,
page=page,
limit=limit,
)
total_count = total_count or 0
total_pages = max(1, math.ceil(total_count / limit)) if total_count else 1
if total_count and page > total_pages:
page = total_pages
squads, total_count = await get_all_server_squads(
db,
available_only=False,
page=page,
limit=limit,
)
current_uuid = bot_configuration_service.get_current_value(key) or ""
current_display = "Любой доступный"
if current_uuid:
selected_server = next((srv for srv in squads if srv.squad_uuid == current_uuid), None)
if not selected_server:
selected_server = await get_server_squad_by_uuid(db, current_uuid)
if selected_server:
current_display = selected_server.display_name
else:
current_display = current_uuid
lines = [
"🌍 <b>Выберите сквад для простой покупки</b>",
"",
f"Текущий выбор: {html.escape(current_display)}" if current_display else "Текущий выбор: —",
"",
]
if total_count == 0:
lines.append("❌ Доступные сервера не найдены.")
else:
lines.append("Выберите сервер из списка ниже.")
if total_pages > 1:
lines.append(f"Страница {page}/{total_pages}")
text = "\n".join(lines)
keyboard_rows: List[List[types.InlineKeyboardButton]] = []
for server in squads:
status_icon = (
"" if server.squad_uuid == current_uuid else ("🟢" if server.is_available else "🔒")
)
label_parts = [status_icon, server.display_name]
if server.country_code:
label_parts.append(f"({server.country_code.upper()})")
if isinstance(server.price_kopeks, int) and server.price_kopeks > 0:
try:
label_parts.append(f"{settings.format_price(server.price_kopeks)}")
except Exception:
pass
label = " ".join(label_parts)
keyboard_rows.append([
types.InlineKeyboardButton(
text=label,
callback_data=(
f"botcfg_simple_squad_select:{group_key}:{category_page}:{settings_page}:{token}:{server.id}:{page}"
),
)
])
if total_pages > 1:
nav_row: List[types.InlineKeyboardButton] = []
if page > 1:
nav_row.append(
types.InlineKeyboardButton(
text="⬅️",
callback_data=(
f"botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{token}:{page - 1}"
),
)
)
if page < total_pages:
nav_row.append(
types.InlineKeyboardButton(
text="➡️",
callback_data=(
f"botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{token}:{page + 1}"
),
)
)
if nav_row:
keyboard_rows.append(nav_row)
keyboard_rows.append([
types.InlineKeyboardButton(
text="⬅️ Назад",
callback_data=(
f"botcfg_setting:{group_key}:{category_page}:{settings_page}:{token}"
),
)
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
parse_mode="HTML",
)
await callback.answer()
@admin_required
@error_handler
async def select_simple_subscription_squad(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
parts = callback.data.split(":", 6)
group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY
try:
category_page = max(1, int(parts[2])) if len(parts) > 2 else 1
except ValueError:
category_page = 1
try:
settings_page = max(1, int(parts[3])) if len(parts) > 3 else 1
except ValueError:
settings_page = 1
token = parts[4] if len(parts) > 4 else ""
try:
server_id = int(parts[5]) if len(parts) > 5 else None
except ValueError:
server_id = None
if server_id is None:
await callback.answer("Не удалось определить сервер", show_alert=True)
return
try:
key = bot_configuration_service.resolve_callback_token(token)
except KeyError:
await callback.answer("Эта настройка больше недоступна", show_alert=True)
return
if bot_configuration_service.is_read_only(key):
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
return
server = await get_server_squad_by_id(db, server_id)
if not server:
await callback.answer("Сервер не найден", show_alert=True)
return
try:
await bot_configuration_service.set_value(db, key, server.squad_uuid)
except ReadOnlySettingError:
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
return
await db.commit()
text = _render_setting_text(key)
keyboard = _build_setting_keyboard(key, group_key, category_page, settings_page)
await callback.message.edit_text(text, reply_markup=keyboard)
await _store_setting_context(
state,
key=key,
group_key=group_key,
category_page=category_page,
settings_page=settings_page,
)
await callback.answer("Сквад выбран")
@admin_required
@error_handler
async def test_remnawave_connection(
@@ -1834,8 +2073,13 @@ async def test_payment_provider(
return
if method == "mulenpay":
mulenpay_name = settings.get_mulenpay_display_name()
mulenpay_name_html = settings.get_mulenpay_display_name_html()
if not settings.is_mulenpay_enabled():
await callback.answer("❌ MulenPay отключен", show_alert=True)
await callback.answer(
f"{mulenpay_name} отключен",
show_alert=True,
)
return
amount_kopeks = 1 * 100
@@ -1843,18 +2087,21 @@ async def test_payment_provider(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description="Тестовый платеж MulenPay (админ)",
description=f"Тестовый платеж {mulenpay_name} (админ)",
language=language,
)
if not payment_result or not payment_result.get("payment_url"):
await callback.answer("❌ Не удалось создать платеж MulenPay", show_alert=True)
await callback.answer(
f"❌ Не удалось создать платеж {mulenpay_name}",
show_alert=True,
)
await _refresh_markup()
return
payment_url = payment_result["payment_url"]
message_text = (
"🧪 <b>Тестовый платеж MulenPay</b>\n\n"
f"🧪 <b>Тестовый платеж {mulenpay_name_html}</b>\n\n"
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
f"🆔 ID: {payment_result['mulen_payment_id']}"
)
@@ -1875,7 +2122,10 @@ async def test_payment_provider(
]
)
await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode="HTML")
await callback.answer("✅ Ссылка на платеж MulenPay отправлена", show_alert=True)
await callback.answer(
f"✅ Ссылка на платеж {mulenpay_name} отправлена",
show_alert=True,
)
await _refresh_markup()
return
@@ -2505,6 +2755,14 @@ def register_handlers(dp: Dispatcher) -> None:
test_payment_provider,
F.data.startswith("botcfg_test_payment:"),
)
dp.callback_query.register(
select_simple_subscription_squad,
F.data.startswith("botcfg_simple_squad_select:"),
)
dp.callback_query.register(
show_simple_subscription_squad_selector,
F.data.startswith("botcfg_simple_squad:"),
)
dp.callback_query.register(
show_bot_config_setting,
F.data.startswith("botcfg_setting:"),
@@ -2543,4 +2801,3 @@ def register_handlers(dp: Dispatcher) -> None:
handle_import_message,
BotConfigStates.waiting_for_import_file,
)
+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,
+210
View File
@@ -0,0 +1,210 @@
import logging
from typing import Optional
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
@error_handler
async def start_heleket_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
) -> None:
texts = get_texts(db_user.language)
if not settings.is_heleket_enabled():
await callback.answer("❌ Оплата через Heleket недоступна", show_alert=True)
return
markup = settings.get_heleket_markup_percent()
markup_text: Optional[str]
if markup > 0:
label = texts.t("PAYMENT_HELEKET_MARKUP_LABEL", "Наценка провайдера")
markup_text = f"{label}: {markup:.0f}%"
elif markup < 0:
label = texts.t("PAYMENT_HELEKET_DISCOUNT_LABEL", "Скидка провайдера")
markup_text = f"{label}: {abs(markup):.0f}%"
else:
markup_text = None
message_lines = [
"🪙 <b>Пополнение через Heleket</b>",
"\n",
"Введите сумму пополнения от 100 до 100,000 ₽:",
"",
"⚡ Мгновенное зачисление", "🔒 Безопасная оплата",
]
if markup_text:
message_lines.extend(["", markup_text])
keyboard = get_back_keyboard(db_user.language)
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_buttons = get_quick_amount_buttons(db_user.language)
if quick_buttons:
keyboard.inline_keyboard = quick_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
"\n".join(filter(None, message_lines)),
reply_markup=keyboard,
parse_mode="HTML",
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="heleket")
await callback.answer()
@error_handler
async def process_heleket_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
) -> None:
texts = get_texts(db_user.language)
if not settings.is_heleket_enabled():
await message.answer("❌ Оплата через Heleket недоступна")
return
amount_rubles = amount_kopeks / 100
if amount_rubles < 100:
await message.answer("Минимальная сумма пополнения: 100 ₽")
return
if amount_rubles > 100000:
await message.answer("Максимальная сумма пополнения: 100,000 ₽")
return
payment_service = PaymentService(message.bot)
result = await payment_service.create_heleket_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=f"Пополнение баланса на {amount_rubles:.0f}",
language=db_user.language,
)
if not result:
await message.answer(
"❌ Не удалось создать счёт в Heleket. Попробуйте позже или обратитесь в поддержку."
)
await state.clear()
return
payment_url = result.get("payment_url")
if not payment_url:
await message.answer("❌ Не удалось получить ссылку для оплаты Heleket")
await state.clear()
return
payer_amount = result.get("payer_amount")
payer_currency = result.get("payer_currency")
exchange_rate = result.get("exchange_rate")
discount_percent = result.get("discount_percent")
details = [
"🪙 <b>Оплата через Heleket</b>",
"",
f"💰 Сумма к зачислению: {amount_rubles:.0f}",
]
if payer_amount and payer_currency:
details.append(f"🪙 К оплате: {payer_amount} {payer_currency}")
markup_percent: Optional[float] = None
if discount_percent is not None:
try:
discount_int = int(discount_percent)
markup_percent = -discount_int
except (TypeError, ValueError):
markup_percent = None
if markup_percent:
label_markup = texts.t("PAYMENT_HELEKET_MARKUP_LABEL", "Наценка провайдера")
label_discount = texts.t("PAYMENT_HELEKET_DISCOUNT_LABEL", "Скидка провайдера")
absolute = abs(markup_percent)
if markup_percent > 0:
details.append(f"📈 {label_markup}: +{absolute}%")
else:
details.append(f"📉 {label_discount}: {absolute}%")
if payer_amount and payer_currency:
try:
payer_amount_float = float(payer_amount)
if payer_amount_float > 0:
rub_per_currency = amount_rubles / payer_amount_float
details.append(
f"💱 Курс: 1 {payer_currency}{rub_per_currency:.2f}"
)
except (TypeError, ValueError, ZeroDivisionError):
pass
details.extend(
[
"",
"📱 Инструкция:",
"1. Нажмите кнопку 'Оплатить'",
"2. Перейдите на страницу Heleket",
"3. Оплатите указанную сумму",
"4. Баланс пополнится автоматически",
]
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text=texts.t("PAY_WITH_COINS_BUTTON", "🪙 Оплатить"), url=payment_url)],
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
callback_data=f"check_heleket_{result['local_payment_id']}"
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
])
await message.answer("\n".join(details), parse_mode="HTML", reply_markup=keyboard)
await state.clear()
@error_handler
async def check_heleket_payment_status(
callback: types.CallbackQuery,
db: AsyncSession,
) -> None:
try:
local_payment_id = int(callback.data.split("_")[-1])
except (ValueError, IndexError):
await callback.answer("Некорректный идентификатор платежа", show_alert=True)
return
from app.database.crud.heleket import get_heleket_payment_by_id
payment = await get_heleket_payment_by_id(db, local_payment_id)
if not payment:
await callback.answer("Платёж не найден", show_alert=True)
return
if payment.is_paid:
await callback.answer("✅ Платёж уже оплачен", show_alert=True)
return
await callback.answer("Платёж ещё не оплачен", show_alert=True)
+50 -1
View File
@@ -406,6 +406,11 @@ async def process_topup_amount(
from .cryptobot import process_cryptobot_payment_amount
async with AsyncSessionLocal() as db:
await process_cryptobot_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "heleket":
from app.database.database import AsyncSessionLocal
from .heleket import process_heleket_payment_amount
async with AsyncSessionLocal() as db:
await process_heleket_payment_amount(message, db_user, db, amount_kopeks, state)
else:
await message.answer("Неизвестный способ оплаты")
@@ -511,10 +516,32 @@ async def handle_quick_amount_selection(
await process_pal24_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "cryptobot":
from app.database.database import AsyncSessionLocal
from .cryptobot import process_cryptobot_payment_amount
async with AsyncSessionLocal() as db:
await process_cryptobot_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "heleket":
from app.database.database import AsyncSessionLocal
from .heleket import process_heleket_payment_amount
async with AsyncSessionLocal() as db:
await process_heleket_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "stars":
from .stars import process_stars_payment_amount
await process_stars_payment_amount(
callback.message, db_user, amount_kopeks, state
)
else:
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
return
except ValueError:
await callback.answer("❌ Ошибка обработки суммы", show_alert=True)
except Exception as e:
@@ -575,6 +602,13 @@ async def handle_topup_amount_callback(
await process_cryptobot_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "heleket":
from app.database.database import AsyncSessionLocal
from .heleket import process_heleket_payment_amount
async with AsyncSessionLocal() as db:
await process_heleket_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "stars":
from .stars import process_stars_payment_amount
await process_stars_payment_amount(
@@ -652,6 +686,11 @@ def register_balance_handlers(dp: Dispatcher):
start_pal24_payment,
F.data == "topup_pal24"
)
from .pal24 import handle_pal24_method_selection
dp.callback_query.register(
handle_pal24_method_selection,
F.data.startswith("pal24_method_"),
)
from .yookassa import check_yookassa_payment_status
dp.callback_query.register(
@@ -693,6 +732,16 @@ def register_balance_handlers(dp: Dispatcher):
F.data.startswith("check_cryptobot_")
)
from .heleket import start_heleket_payment, check_heleket_payment_status
dp.callback_query.register(
start_heleket_payment,
F.data == "topup_heleket"
)
dp.callback_query.register(
check_heleket_payment_status,
F.data.startswith("check_heleket_")
)
from .mulenpay import check_mulenpay_payment_status
dp.callback_query.register(
check_mulenpay_payment_status,
+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:
File diff suppressed because it is too large Load Diff
+10
View File
@@ -231,6 +231,16 @@ async def handle_subscription_config_back(
)
await state.set_state(SubscriptionStates.selecting_period)
elif current_state == SubscriptionStates.confirming_purchase.state:
data = await state.get_data()
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
await callback.message.edit_text(
texts.SELECT_DEVICES,
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_devices)
else:
from app.handlers.menu import show_main_menu
await show_main_menu(callback, db_user, db)
+270 -11
View File
@@ -5,6 +5,7 @@ from datetime import datetime, timedelta
from typing import Dict, List, Any, Tuple, Optional
from urllib.parse import quote
from aiogram import Dispatcher, types, F
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
@@ -80,14 +81,64 @@ from app.utils.promo_offer import (
)
from .common import _apply_promo_offer_discount, _get_promo_offer_discount_percent, logger, update_traffic_prices
from .autopay import handle_autopay_menu, handle_subscription_cancel, handle_subscription_config_back, set_autopay_days, show_autopay_days, toggle_autopay
from .countries import _get_available_countries, _should_show_countries_management, apply_countries_changes, countries_continue, handle_add_countries, handle_manage_country, select_country
from .devices import confirm_add_devices, confirm_change_devices, confirm_reset_devices, execute_change_devices, get_current_devices_count, get_servers_display_names, handle_all_devices_reset_from_management, handle_app_selection, handle_change_devices, handle_device_guide, handle_device_management, handle_devices_page, handle_reset_devices, handle_single_device_reset, handle_specific_app_guide, show_device_connection_help
from .happ import handle_happ_download_back, handle_happ_download_close, handle_happ_download_platform_choice, handle_happ_download_request
from .autopay import (
handle_autopay_menu,
handle_subscription_cancel,
handle_subscription_config_back,
set_autopay_days,
show_autopay_days,
toggle_autopay,
)
from .countries import (
_get_available_countries,
_should_show_countries_management,
apply_countries_changes,
countries_continue,
handle_add_countries,
handle_manage_country,
select_country,
)
from .devices import (
confirm_add_devices,
confirm_change_devices,
confirm_reset_devices,
execute_change_devices,
get_current_devices_count,
get_servers_display_names,
handle_all_devices_reset_from_management,
handle_app_selection,
handle_change_devices,
handle_device_guide,
handle_device_management,
handle_devices_page,
handle_reset_devices,
handle_single_device_reset,
handle_specific_app_guide,
show_device_connection_help,
)
from .happ import (
handle_happ_download_back,
handle_happ_download_close,
handle_happ_download_platform_choice,
handle_happ_download_request,
)
from .links import handle_connect_subscription, handle_open_subscription_link
from .pricing import _build_subscription_period_prompt, _prepare_subscription_summary
from .promo import _build_promo_group_discount_text, _get_promo_offer_hint, claim_discount_offer, handle_promo_offer_close
from .traffic import confirm_reset_traffic, confirm_switch_traffic, execute_switch_traffic, handle_no_traffic_packages, handle_reset_traffic, handle_switch_traffic, select_traffic
from .promo import (
_build_promo_group_discount_text,
_get_promo_offer_hint,
claim_discount_offer,
handle_promo_offer_close,
)
from .traffic import (
confirm_reset_traffic,
confirm_switch_traffic,
execute_switch_traffic,
handle_no_traffic_packages,
handle_reset_traffic,
handle_switch_traffic,
select_traffic,
)
async def show_subscription_info(
callback: types.CallbackQuery,
@@ -527,10 +578,13 @@ async def start_subscription_purchase(
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language),
parse_mode="HTML",
keyboard = get_subscription_period_keyboard(db_user.language)
prompt_text = await _build_subscription_period_prompt(db_user, texts, db)
await _edit_message_text_or_caption(
callback.message,
prompt_text,
keyboard,
)
subscription = getattr(db_user, 'subscription', None)
@@ -555,6 +609,46 @@ async def start_subscription_purchase(
await state.set_state(SubscriptionStates.selecting_period)
await callback.answer()
async def _edit_message_text_or_caption(
message: types.Message,
text: str,
reply_markup: InlineKeyboardMarkup,
parse_mode: Optional[str] = "HTML",
) -> None:
"""Edits message text when possible, falls back to caption or re-sends message."""
try:
await message.edit_text(
text,
reply_markup=reply_markup,
parse_mode=parse_mode,
)
except TelegramBadRequest as error:
error_message = str(error).lower()
if "message is not modified" in error_message:
return
if "there is no text in the message to edit" in error_message:
if message.caption is not None:
await message.edit_caption(
caption=text,
reply_markup=reply_markup,
parse_mode=parse_mode,
)
return
await message.delete()
await message.answer(
text,
reply_markup=reply_markup,
parse_mode=parse_mode,
)
return
raise
async def save_cart_and_redirect_to_topup(
callback: types.CallbackQuery,
state: FSMContext,
@@ -1999,7 +2093,7 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(
start_subscription_purchase,
F.data.in_(["menu_buy", "subscription_upgrade"])
F.data.in_(["menu_buy", "subscription_upgrade", "subscription_purchase"])
)
dp.callback_query.register(
@@ -2264,3 +2358,168 @@ def register_handlers(dp: Dispatcher):
show_device_connection_help,
F.data == "device_connection_help"
)
# Регистрируем обработчик для простой покупки
dp.callback_query.register(
handle_simple_subscription_purchase,
F.data == "simple_subscription_purchase"
)
async def handle_simple_subscription_purchase(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession,
):
"""Обрабатывает простую покупку подписки."""
texts = get_texts(db_user.language)
if not settings.SIMPLE_SUBSCRIPTION_ENABLED:
await callback.answer("❌ Простая покупка подписки временно недоступна", show_alert=True)
return
# Проверяем, есть ли у пользователя активная подписка
from app.database.crud.subscription import get_subscription_by_user_id
current_subscription = await get_subscription_by_user_id(db, db_user.id)
if current_subscription and current_subscription.is_active:
await callback.answer("❌ У вас уже есть активная подписка", show_alert=True)
return
# Подготовим параметры простой подписки
subscription_params = {
"period_days": settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS,
"device_limit": settings.SIMPLE_SUBSCRIPTION_DEVICE_LIMIT,
"traffic_limit_gb": settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
"squad_uuid": settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID
}
# Сохраняем параметры в состояние
await state.update_data(subscription_params=subscription_params)
# Проверяем баланс пользователя
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
# Рассчитываем цену подписки
price_kopeks = _calculate_simple_subscription_price(subscription_params)
traffic_text = (
"Безлимит"
if subscription_params["traffic_limit_gb"] == 0
else f"{subscription_params['traffic_limit_gb']} ГБ"
)
if user_balance_kopeks >= price_kopeks:
# Если баланс достаточный, предлагаем оплатить с баланса
message_text = (
f"⚡ <b>Простая покупка подписки</b>\n\n"
f"📅 Период: {subscription_params['period_days']} дней\n"
f"📱 Устройства: {subscription_params['device_limit']}\n"
f"📊 Трафик: {traffic_text}\n"
f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}\n\n"
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}\n\n"
f"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты."
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="✅ Оплатить с баланса", callback_data="simple_subscription_pay_with_balance")],
[types.InlineKeyboardButton(text="💳 Другие способы оплаты", callback_data="simple_subscription_other_payment_methods")],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")]
])
else:
# Если баланс недостаточный, предлагаем внешние способы оплаты
message_text = (
f"⚡ <b>Простая покупка подписки</b>\n\n"
f"📅 Период: {subscription_params['period_days']} дней\n"
f"📱 Устройства: {subscription_params['device_limit']}\n"
f"📊 Трафик: {traffic_text}\n"
f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}\n\n"
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}\n\n"
f"Выберите способ оплаты:"
)
keyboard = _get_simple_subscription_payment_keyboard(db_user.language)
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await state.set_state(SubscriptionStates.waiting_for_simple_subscription_payment_method)
await callback.answer()
def _calculate_simple_subscription_price(params: dict) -> int:
"""Рассчитывает цену простой подписки."""
period_days = params.get("period_days", 30)
# Получаем цену для стандартного периода
if hasattr(settings, f'PRICE_{period_days}_DAYS'):
return getattr(settings, f'PRICE_{period_days}_DAYS')
else:
# Если нет цены для конкретного периода, используем базовую цену
return settings.BASE_SUBSCRIPTION_PRICE
def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyboardMarkup:
"""Создает клавиатуру с методами оплаты для простой подписки."""
texts = get_texts(language)
keyboard = []
# Добавляем доступные методы оплаты
if settings.TELEGRAM_STARS_ENABLED:
keyboard.append([types.InlineKeyboardButton(
text="⭐ Telegram Stars",
callback_data="simple_subscription_stars"
)])
if settings.is_yookassa_enabled():
yookassa_methods = []
if settings.YOOKASSA_SBP_ENABLED:
yookassa_methods.append(types.InlineKeyboardButton(
text="🏦 YooKassa (СБП)",
callback_data="simple_subscription_yookassa_sbp"
))
yookassa_methods.append(types.InlineKeyboardButton(
text="💳 YooKassa (Карта)",
callback_data="simple_subscription_yookassa"
))
if yookassa_methods:
keyboard.append(yookassa_methods)
if settings.is_cryptobot_enabled():
keyboard.append([types.InlineKeyboardButton(
text="🪙 CryptoBot",
callback_data="simple_subscription_cryptobot"
)])
if settings.is_mulenpay_enabled():
mulenpay_name = settings.get_mulenpay_display_name()
keyboard.append([types.InlineKeyboardButton(
text=f"💳 {mulenpay_name}",
callback_data="simple_subscription_mulenpay"
)])
if settings.is_pal24_enabled():
keyboard.append([types.InlineKeyboardButton(
text="💳 PayPalych",
callback_data="simple_subscription_pal24"
)])
if settings.is_wata_enabled():
keyboard.append([types.InlineKeyboardButton(
text="💳 WATA",
callback_data="simple_subscription_wata"
)])
# Кнопка назад
keyboard.append([types.InlineKeyboardButton(
text=texts.BACK,
callback_data="subscription_purchase"
)])
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
+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(
+73 -40
View File
@@ -257,7 +257,8 @@ def get_main_menu_keyboard(
"💰 Баланс: {balance}",
).format(balance=texts.format_price(balance_kopeks))
keyboard = []
keyboard: list[list[InlineKeyboardButton]] = []
paired_buttons: list[InlineKeyboardButton] = []
if has_active_subscription and subscription_is_active:
connect_mode = settings.CONNECT_BUTTON_MODE
@@ -312,22 +313,30 @@ def get_main_menu_keyboard(
happ_row = get_happ_download_button_row(texts)
if happ_row:
keyboard.append(happ_row)
keyboard.append([
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance"),
paired_buttons.append(
InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription")
])
else:
keyboard.append([
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")
])
)
keyboard.append([InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")])
show_trial = not has_had_paid_subscription and not has_active_subscription
show_buy = not has_active_subscription or not subscription_is_active
subscription_buttons = []
current_subscription = subscription
has_active_paid_subscription = bool(
current_subscription
and not getattr(current_subscription, "is_trial", False)
and getattr(current_subscription, "is_active", False)
)
simple_purchase_button = None
if settings.SIMPLE_SUBSCRIPTION_ENABLED and not has_active_paid_subscription:
simple_purchase_button = InlineKeyboardButton(
text=texts.MENU_SIMPLE_SUBSCRIPTION,
callback_data="simple_subscription_purchase",
)
subscription_buttons: list[InlineKeyboardButton] = []
if show_trial:
subscription_buttons.append(
InlineKeyboardButton(text=texts.MENU_TRIAL, callback_data="menu_trial")
@@ -337,38 +346,38 @@ def get_main_menu_keyboard(
subscription_buttons.append(
InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="menu_buy")
)
if simple_purchase_button:
subscription_buttons.append(simple_purchase_button)
simple_purchase_button = None
if subscription_buttons:
if len(subscription_buttons) == 2:
keyboard.append(subscription_buttons)
else:
keyboard.append([subscription_buttons[0]])
paired_buttons.extend(subscription_buttons)
if simple_purchase_button:
paired_buttons.append(simple_purchase_button)
if show_resume_checkout or has_saved_cart:
keyboard.append([
paired_buttons.append(
InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout",
)
])
)
if custom_buttons:
for button in custom_buttons:
if isinstance(button, InlineKeyboardButton):
keyboard.append([button])
paired_buttons.append(button)
# Добавляем кнопки промокода и рефералов, учитывая настройки
additional_buttons = [
paired_buttons.append(
InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode")
]
)
# Добавляем кнопку рефералов только если программа включена
if settings.is_referral_program_enabled():
additional_buttons.append(
paired_buttons.append(
InlineKeyboardButton(text=texts.MENU_REFERRALS, callback_data="menu_referrals")
)
keyboard.append(additional_buttons)
# Support button is configurable (runtime via service)
try:
@@ -377,21 +386,26 @@ def get_main_menu_keyboard(
except Exception:
support_enabled = settings.SUPPORT_MENU_ENABLED
if support_enabled:
keyboard.append([
paired_buttons.append(
InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support")
])
)
keyboard.append([
paired_buttons.append(
InlineKeyboardButton(
text=texts.t("MENU_INFO", "️ Инфо"),
callback_data="menu_info",
)
])
)
if settings.is_language_selection_enabled():
keyboard.append([
paired_buttons.append(
InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data="menu_language")
])
)
for i in range(0, len(paired_buttons), 2):
row = paired_buttons[i : i + 2]
keyboard.append(row)
if settings.DEBUG:
print(f"DEBUG KEYBOARD: is_admin={is_admin}, добавляем админ кнопку: {is_admin}")
@@ -870,6 +884,8 @@ def get_subscription_period_keyboard(language: str = DEFAULT_LANGUAGE) -> Inline
)
])
# Кнопка "Простая покупка" была убрана из выбора периода подписки
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
])
@@ -1089,9 +1105,13 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
])
if settings.is_mulenpay_enabled():
mulenpay_name = settings.get_mulenpay_display_name()
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_CARD_MULENPAY", "💳 Банковская карта (Mulen Pay)"),
text=texts.t(
"PAYMENT_CARD_MULENPAY",
"💳 Банковская карта ({mulenpay_name})",
).format(mulenpay_name=mulenpay_name),
callback_data=_build_callback("mulenpay")
)
])
@@ -1120,6 +1140,14 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
])
if settings.is_heleket_enabled():
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_HELEKET", "🪙 Криптовалюта (Heleket)"),
callback_data=_build_callback("heleket")
)
])
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_VIA_SUPPORT", "🛠️ Через поддержку"),
@@ -1356,7 +1384,7 @@ def get_autopay_days_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboar
for days in [1, 3, 7, 14]:
keyboard.append([
InlineKeyboardButton(
text=f"{days} дн{_get_days_suffix(days)}",
text=f"{days} {_get_days_word(days)}",
callback_data=f"autopay_days_{days}"
)
])
@@ -1368,13 +1396,12 @@ def get_autopay_days_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboar
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def _get_days_suffix(days: int) -> str:
if days == 1:
return "ь"
elif 2 <= days <= 4:
return "я"
else:
return "ей"
def _get_days_word(days: int) -> str:
if days % 10 == 1 and days % 100 != 11:
return "день"
if 2 <= days % 10 <= 4 and not (12 <= days % 100 <= 14):
return "дня"
return "дней"
@@ -2300,6 +2327,12 @@ def get_admin_tickets_keyboard(
keyboard.append(switch_row)
if open_rows and scope in ("all", "open"):
keyboard.append([
InlineKeyboardButton(
text=texts.t("ADMIN_CLOSE_ALL_OPEN_TICKETS", "🔒 Закрыть все открытые"),
callback_data="admin_tickets_close_all_open"
)
])
keyboard.append([InlineKeyboardButton(text=texts.t("OPEN_TICKETS_HEADER", "Открытые тикеты"), callback_data="noop")])
keyboard.extend(open_rows)
if closed_rows and scope in ("all", "closed"):
+68 -10
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': '💰 С баланса'
+2
View File
@@ -9,6 +9,7 @@ from .stars import TelegramStarsMixin
from .yookassa import YooKassaPaymentMixin
from .tribute import TributePaymentMixin
from .cryptobot import CryptoBotPaymentMixin
from .heleket import HeleketPaymentMixin
from .mulenpay import MulenPayPaymentMixin
from .pal24 import Pal24PaymentMixin
from .wata import WataPaymentMixin
@@ -19,6 +20,7 @@ __all__ = [
"YooKassaPaymentMixin",
"TributePaymentMixin",
"CryptoBotPaymentMixin",
"HeleketPaymentMixin",
"MulenPayPaymentMixin",
"Pal24PaymentMixin",
"WataPaymentMixin",
+377
View File
@@ -0,0 +1,377 @@
"""Mixin with Heleket payment flow implementation."""
from __future__ import annotations
import logging
import secrets
import time
from datetime import datetime
from importlib import import_module
from typing import Any, Dict, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
class HeleketPaymentMixin:
"""Provides helpers to create and process Heleket payments."""
async def create_heleket_payment(
self,
db: AsyncSession,
user_id: int,
amount_kopeks: int,
description: str,
*,
language: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
if not getattr(self, "heleket_service", None):
logger.error("Heleket сервис не инициализирован")
return None
if amount_kopeks <= 0:
logger.error("Сумма Heleket должна быть положительной: %s", amount_kopeks)
return None
amount_rubles = amount_kopeks / 100
amount_str = f"{amount_rubles:.2f}"
order_id = f"heleket_{user_id}_{int(time.time())}_{secrets.token_hex(3)}"
markup_percent = settings.get_heleket_markup_percent()
discount_percent: Optional[int] = None
if markup_percent:
try:
rounded = int(round(markup_percent))
if rounded != 0:
discount_percent = -rounded
except (TypeError, ValueError):
logger.warning("Некорректная наценка Heleket: %s", markup_percent)
payload: Dict[str, Any] = {
"amount": amount_str,
"currency": "RUB",
"order_id": order_id,
"lifetime": settings.get_heleket_lifetime(),
}
to_currency = (settings.HELEKET_DEFAULT_CURRENCY or "").strip()
if to_currency:
payload["to_currency"] = to_currency
network = (settings.HELEKET_DEFAULT_NETWORK or "").strip()
if network:
payload["network"] = network
callback_url = settings.get_heleket_callback_url()
if callback_url:
payload["url_callback"] = callback_url
if settings.HELEKET_RETURN_URL:
payload["url_return"] = settings.HELEKET_RETURN_URL
if settings.HELEKET_SUCCESS_URL:
payload["url_success"] = settings.HELEKET_SUCCESS_URL
if discount_percent is not None:
payload["discount_percent"] = discount_percent
metadata: Dict[str, Any] = {
"language": language or settings.DEFAULT_LANGUAGE,
"created_at": datetime.utcnow().isoformat(),
}
try:
response = await self.heleket_service.create_payment(payload) # type: ignore[union-attr]
except Exception as error: # pragma: no cover - safety net
logger.exception("Ошибка создания Heleket платежа: %s", error)
return None
if not response:
logger.error("Heleket API вернул пустой ответ при создании платежа")
return None
payment_result = response.get("result") if isinstance(response, dict) else None
if not payment_result:
logger.error("Некорректный ответ Heleket API: %s", response)
return None
uuid = str(payment_result.get("uuid"))
response_order_id = payment_result.get("order_id")
if response_order_id:
order_id = str(response_order_id)
url = payment_result.get("url")
status = payment_result.get("status") or payment_result.get("payment_status") or "check"
payer_amount = payment_result.get("payer_amount")
payer_currency = payment_result.get("payer_currency")
exchange_rate = payment_result.get("payer_amount_exchange_rate")
try:
exchange_rate_value = float(exchange_rate) if exchange_rate is not None else None
except (TypeError, ValueError):
exchange_rate_value = None
if exchange_rate_value is None and payer_amount:
try:
exchange_rate_value = float(payer_amount) / amount_rubles if amount_rubles else None
except (TypeError, ValueError, ZeroDivisionError):
exchange_rate_value = None
expires_at_raw = payment_result.get("expired_at")
expires_at: Optional[datetime] = None
if expires_at_raw:
try:
expires_at = datetime.fromtimestamp(int(expires_at_raw))
except (TypeError, ValueError, OSError):
expires_at = None
heleket_crud = import_module("app.database.crud.heleket")
local_payment = await heleket_crud.create_heleket_payment(
db=db,
user_id=user_id,
uuid=uuid,
order_id=order_id,
amount=amount_str,
currency="RUB",
status=status,
payer_amount=payer_amount,
payer_currency=payer_currency,
exchange_rate=exchange_rate_value,
discount_percent=discount_percent,
payment_url=url,
expires_at=expires_at,
metadata={"raw_response": payment_result, **metadata},
)
logger.info(
"Создан Heleket платеж %s на %s₽ для пользователя %s",
uuid,
amount_str,
user_id,
)
return {
"local_payment_id": local_payment.id,
"uuid": uuid,
"order_id": order_id,
"amount": amount_str,
"amount_kopeks": amount_kopeks,
"payment_url": url,
"status": status,
"payer_amount": payer_amount,
"payer_currency": payer_currency,
"exchange_rate": exchange_rate_value,
"discount_percent": discount_percent,
}
async def process_heleket_webhook(
self,
db: AsyncSession,
payload: Dict[str, Any],
) -> bool:
if not isinstance(payload, dict):
logger.error("Heleket webhook payload не является словарём: %s", payload)
return False
heleket_crud = import_module("app.database.crud.heleket")
payment_module = import_module("app.services.payment_service")
uuid = str(payload.get("uuid") or "").strip()
order_id = str(payload.get("order_id") or "").strip()
status = payload.get("status") or payload.get("payment_status")
if not uuid and not order_id:
logger.error("Heleket webhook без uuid/order_id: %s", payload)
return False
payment = None
if uuid:
payment = await heleket_crud.get_heleket_payment_by_uuid(db, uuid)
if payment is None and order_id:
payment = await heleket_crud.get_heleket_payment_by_order_id(db, order_id)
if not payment:
logger.error(
"Heleket платеж не найден (uuid=%s order_id=%s)",
uuid,
order_id,
)
return False
payer_amount = payload.get("payer_amount") or payload.get("payment_amount")
payer_currency = payload.get("payer_currency") or payload.get("currency")
discount_percent = payload.get("discount_percent")
exchange_rate_raw = payload.get("payer_amount_exchange_rate")
payment_url = payload.get("url")
exchange_rate: Optional[float] = None
if exchange_rate_raw is not None:
try:
exchange_rate = float(exchange_rate_raw)
except (TypeError, ValueError):
exchange_rate = None
if exchange_rate is None and payer_amount:
try:
exchange_rate = float(payer_amount) / payment.amount_float if payment.amount_float else None
except (TypeError, ValueError, ZeroDivisionError):
exchange_rate = None
paid_at: Optional[datetime] = None
paid_at_raw = payload.get("paid_at") or payload.get("updated_at")
if paid_at_raw:
try:
if isinstance(paid_at_raw, (int, float)):
paid_at = datetime.utcfromtimestamp(float(paid_at_raw))
else:
paid_at = datetime.fromisoformat(str(paid_at_raw).replace("Z", "+00:00"))
except (ValueError, TypeError):
paid_at = None
updated_payment = await heleket_crud.update_heleket_payment(
db,
payment.uuid,
status=status,
payer_amount=str(payer_amount) if payer_amount is not None else None,
payer_currency=str(payer_currency) if payer_currency is not None else None,
exchange_rate=exchange_rate,
discount_percent=int(discount_percent) if isinstance(discount_percent, (int, float)) else None,
paid_at=paid_at,
payment_url=payment_url,
metadata={"last_webhook": payload},
)
if updated_payment is None:
return False
if updated_payment.transaction_id:
logger.info(
"Heleket платеж %s уже связан с транзакцией %s",
updated_payment.uuid,
updated_payment.transaction_id,
)
return True
status_normalized = (status or "").lower()
if status_normalized not in {"paid", "paid_over"}:
logger.info("Heleket платеж %s в статусе %s, зачисление не требуется", updated_payment.uuid, status)
return True
amount_kopeks = updated_payment.amount_kopeks
if amount_kopeks <= 0:
logger.error("Heleket платеж %s имеет некорректную сумму: %s", updated_payment.uuid, updated_payment.amount)
return False
transaction = await payment_module.create_transaction(
db,
user_id=updated_payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=(
"Пополнение через Heleket"
if not updated_payment.payer_currency
else (
"Пополнение через Heleket "
f"({updated_payment.payer_amount} {updated_payment.payer_currency})"
)
),
payment_method=PaymentMethod.HELEKET,
external_id=updated_payment.uuid,
is_completed=True,
)
await heleket_crud.link_heleket_payment_to_transaction(db, updated_payment.uuid, transaction.id)
get_user_by_id = payment_module.get_user_by_id
user = await get_user_by_id(db, updated_payment.user_id)
if not user:
logger.error("Пользователь %s не найден для Heleket платежа", updated_payment.user_id)
return False
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
amount_kopeks,
getattr(self, "bot", None),
)
except Exception as error: # pragma: no cover - defensive
logger.error("Ошибка реферального начисления Heleket: %s", error)
if was_first_topup and not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
if getattr(self, "bot", None):
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
referrer_info = format_referrer_info(user)
subscription = getattr(user, "subscription", None)
promo_group = getattr(user, "promo_group", None)
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error: # pragma: no cover
logger.error("Ошибка отправки админ-уведомления Heleket: %s", error)
try:
keyboard = await self.build_topup_success_keyboard(user)
exchange_rate_value = updated_payment.exchange_rate or 0
rate_text = (
f"💱 Курс: 1 RUB = {1 / exchange_rate_value:.4f} {updated_payment.payer_currency}"
if exchange_rate_value and updated_payment.payer_currency
else None
)
message_lines = [
"✅ <b>Пополнение успешно!</b>",
f"💰 Сумма: {settings.format_price(amount_kopeks)}",
"💳 Способ: Heleket",
]
if updated_payment.payer_amount and updated_payment.payer_currency:
message_lines.append(
f"🪙 Оплата: {updated_payment.payer_amount} {updated_payment.payer_currency}"
)
if rate_text:
message_lines.append(rate_text)
await self.bot.send_message(
chat_id=user.telegram_id,
text="\n".join(message_lines),
parse_mode="HTML",
reply_markup=keyboard,
)
except Exception as error: # pragma: no cover
logger.error("Ошибка отправки уведомления пользователю Heleket: %s", error)
return True
+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
+35 -2
View File
@@ -11,11 +11,13 @@ from aiogram import Bot
from app.config import settings
from app.utils.currency_converter import currency_converter # noqa: F401
from app.external.cryptobot import CryptoBotService
from app.external.heleket import HeleketService
from app.external.telegram_stars import TelegramStarsService
from app.services.mulenpay_service import MulenPayService
from app.services.pal24_service import Pal24Service
from app.services.payment import (
CryptoBotPaymentMixin,
HeleketPaymentMixin,
MulenPayPaymentMixin,
Pal24PaymentMixin,
PaymentCommonMixin,
@@ -178,12 +180,38 @@ async def link_cryptobot_payment_to_transaction(*args, **kwargs):
return await crypto_crud.link_cryptobot_payment_to_transaction(*args, **kwargs)
async def create_heleket_payment(*args, **kwargs):
heleket_crud = import_module("app.database.crud.heleket")
return await heleket_crud.create_heleket_payment(*args, **kwargs)
async def get_heleket_payment_by_uuid(*args, **kwargs):
heleket_crud = import_module("app.database.crud.heleket")
return await heleket_crud.get_heleket_payment_by_uuid(*args, **kwargs)
async def get_heleket_payment_by_id(*args, **kwargs):
heleket_crud = import_module("app.database.crud.heleket")
return await heleket_crud.get_heleket_payment_by_id(*args, **kwargs)
async def update_heleket_payment(*args, **kwargs):
heleket_crud = import_module("app.database.crud.heleket")
return await heleket_crud.update_heleket_payment(*args, **kwargs)
async def link_heleket_payment_to_transaction(*args, **kwargs):
heleket_crud = import_module("app.database.crud.heleket")
return await heleket_crud.link_heleket_payment_to_transaction(*args, **kwargs)
class PaymentService(
PaymentCommonMixin,
TelegramStarsMixin,
YooKassaPaymentMixin,
TributePaymentMixin,
CryptoBotPaymentMixin,
HeleketPaymentMixin,
MulenPayPaymentMixin,
Pal24PaymentMixin,
WataPaymentMixin,
@@ -201,6 +229,9 @@ class PaymentService(
self.cryptobot_service = (
CryptoBotService() if settings.is_cryptobot_enabled() else None
)
self.heleket_service = (
HeleketService() if settings.is_heleket_enabled() else None
)
self.mulenpay_service = (
MulenPayService() if settings.is_mulenpay_enabled() else None
)
@@ -209,12 +240,14 @@ class PaymentService(
)
self.wata_service = WataService() if settings.is_wata_enabled() else None
mulenpay_name = settings.get_mulenpay_display_name()
logger.debug(
"PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, "
"MulenPay=%s, Pal24=%s, Wata=%s)",
"PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, Heleket=%s, %s=%s, Pal24=%s, Wata=%s)",
bool(self.yookassa_service),
bool(self.stars_service),
bool(self.cryptobot_service),
bool(self.heleket_service),
mulenpay_name,
bool(self.mulenpay_service),
bool(self.pal24_service),
bool(self.wata_service),
+321 -105
View File
@@ -3,13 +3,16 @@ import logging
from dataclasses import dataclass
from datetime import date, datetime, time as datetime_time, timedelta, timezone
from enum import Enum
from typing import Optional, Tuple
from html import escape
from typing import Dict, List, Optional, Tuple
from zoneinfo import ZoneInfo
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from sqlalchemy import func, select
from sqlalchemy import cast, func, not_, or_, select
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.sql import false, true
from app.config import settings
from app.database.crud.subscription import get_subscriptions_statistics
@@ -17,10 +20,12 @@ from app.database.database import AsyncSessionLocal
from app.database.models import (
Subscription,
SubscriptionConversion,
SubscriptionStatus,
Ticket,
TicketStatus,
Transaction,
TransactionType,
User,
)
@@ -45,7 +50,7 @@ class ReportPeriodRange:
class ReportingService:
"""Generates admin summary reports and can schedule daily delivery."""
"""Generates admin summary reports (text only, no charts)."""
def __init__(self) -> None:
self.bot: Optional[Bot] = None
@@ -170,11 +175,64 @@ class ReportingService:
chat_id=chat_id,
text=report_text,
message_thread_id=topic_id,
parse_mode="HTML",
)
except (TelegramBadRequest, TelegramForbiddenError) as exc:
logger.error("Не удалось отправить отчет: %s", exc)
raise ReportingServiceError("Не удалось отправить отчет в чат") from exc
# ---------- referral helpers ----------
def _referral_markers(self) -> List:
"""
Набор условий, по которым операция помечается как реферальная (если вдруг записана типом DEPOSIT).
"""
clauses = []
# Явные флаги
if hasattr(Transaction, "is_referral_bonus"):
clauses.append(Transaction.is_referral_bonus == true())
if hasattr(Transaction, "is_bonus"):
clauses.append(Transaction.is_bonus == true())
# Источник/причина
if hasattr(Transaction, "source"):
clauses.append(Transaction.source == "referral")
clauses.append(Transaction.source == "referral_bonus")
if hasattr(Transaction, "reason"):
clauses.append(Transaction.reason == "referral")
clauses.append(Transaction.reason == "referral_bonus")
clauses.append(Transaction.reason == "referral_reward")
# Текстовые поля
like_patterns = ["%реферал%", "%реферальн%", "%referral%"]
if hasattr(Transaction, "description"):
for pattern in like_patterns:
try:
clauses.append(Transaction.description.ilike(pattern))
except Exception: # noqa: BLE001 - best effort
pass
if hasattr(Transaction, "comment"):
for pattern in like_patterns:
try:
clauses.append(Transaction.comment.ilike(pattern))
except Exception: # noqa: BLE001 - best effort
pass
return [clause for clause in clauses if clause is not None]
def _exclude_referral_deposits_condition(self):
"""
Условие «это НЕ реферальный бонус».
Если нет ни одного маркера ничего не исключаем.
"""
markers = self._referral_markers()
if not markers:
return true()
return not_(or_(*markers))
# --------------------------------------
async def _build_report(
self,
period: ReportPeriod,
@@ -186,38 +244,91 @@ class ReportingService:
async with AsyncSessionLocal() as session:
totals = await self._collect_current_totals(session)
period_stats = await self._collect_period_stats(session, start_utc, end_utc)
stats = await self._collect_period_stats(session, start_utc, end_utc)
top_referrers = await self._get_top_referrers(session, start_utc, end_utc, limit=5)
usage = await self._get_user_usage_stats(session)
conversion_rate = (
(stats["trial_to_paid_conversions"] / stats["new_trials"] * 100)
if stats["new_trials"] > 0
else 0.0
)
lines: List[str] = []
header = (
f"📊 <b>Отчет за {period_range.label}</b>"
if period == ReportPeriod.DAILY
else f"📊 <b>Отчет за период {period_range.label}</b>"
)
lines += [header, ""]
lines = [
header,
# TL;DR
lines += [
"🧭 <b>Итог по периоду</b>",
f"• Новых пользователей: <b>{stats['new_users']}</b>",
f"• Новых триалов: <b>{stats['new_trials']}</b>",
(
f"• Конверсий триал → платная: <b>{stats['trial_to_paid_conversions']}</b> "
f"(<i>{conversion_rate:.1f}%</i>)"
),
f"• Новых платных (всего): <b>{stats['new_paid_subscriptions']}</b>",
f"• Поступления всего (только пополнения): <b>{self._format_amount(stats['deposits_amount'])}</b>",
"",
"🎯 <b>Триалы</b>",
f"• Активных сейчас: {totals['active_trials']}",
f"• Новых за период: {period_stats['new_trials']}",
"",
"💎 <b>Платные подписки</b>",
f"• Активных сейчас: {totals['active_paid']}",
f"• Новых за период: {period_stats['new_paid_subscriptions']}",
"",
"🎟️ <b>Тикеты поддержки</b>",
f"• Активных сейчас: {totals['open_tickets']}",
f"• Новых за период: {period_stats['new_tickets']}",
"",
"💰 <b>Платежи</b>",
f"• Оплат подписок: {period_stats['subscription_payments_count']} на сумму "
f"{self._format_amount(period_stats['subscription_payments_amount'])}",
f"• Пополнений: {period_stats['deposits_count']} на сумму "
f"{self._format_amount(period_stats['deposits_amount'])}",
f"• Всего поступлений: {period_stats['total_payments_count']} на сумму "
f"{self._format_amount(period_stats['total_payments_amount'])}",
]
# Подписки
lines += [
"💎 <b>Подписки</b>",
f"• Активные триалы сейчас: {totals['active_trials']}",
f"• Активные платные сейчас: {totals['active_paid']}",
"",
]
# Финансы
lines += [
"💰 <b>Финансы</b>",
(
"• Оплаты подписок: "
f"{stats['subscription_payments_count']} на сумму {self._format_amount(stats['subscription_payments_amount'])}"
),
(
"• Пополнения: "
f"{stats['deposits_count']} на сумму {self._format_amount(stats['deposits_amount'])}"
),
(
"<i>Примечание: «Поступления всего» учитывают только пополнения; покупки подписок и реферальные бонусы "
"исключены.</i>"
),
"",
]
# Поддержка
lines += [
"🎟️ <b>Поддержка</b>",
f"• Новых тикетов: {stats['new_tickets']}",
f"• Активных тикетов сейчас: {totals['open_tickets']}",
"",
]
# Активность пользователей
lines += [
"👤 <b>Активность пользователей</b>",
f"• Пользователей с активной платной подпиской: {usage['active_paid_users']}",
f"• Пользователей, ни разу не подключившихся: {usage['never_connected_users']}",
"",
]
# Топ по рефералам
lines += ["🤝 <b>Топ по рефералам (за период)</b>"]
if top_referrers:
for index, row in enumerate(top_referrers, 1):
referrer_label = escape(row["referrer_label"], quote=False)
lines.append(
f"{index}. {referrer_label}: {row['count']} приглашений"
)
else:
lines.append("— данных нет")
return "\n".join(lines)
def _get_period_range(
@@ -273,87 +384,197 @@ class ReportingService:
start_utc: datetime,
end_utc: datetime,
) -> dict:
new_trials_result = await session.execute(
select(func.count(Subscription.id)).where(
Subscription.created_at >= start_utc,
Subscription.created_at < end_utc,
Subscription.is_trial == True, # noqa: E712
)
)
new_trials = int(new_trials_result.scalar() or 0)
direct_paid_result = await session.execute(
select(func.count(Subscription.id)).where(
Subscription.created_at >= start_utc,
Subscription.created_at < end_utc,
Subscription.is_trial == False, # noqa: E712
)
)
direct_paid = int(direct_paid_result.scalar() or 0)
conversions_result = await session.execute(
select(func.count(SubscriptionConversion.id)).where(
SubscriptionConversion.converted_at >= start_utc,
SubscriptionConversion.converted_at < end_utc,
)
)
conversions_count = int(conversions_result.scalar() or 0)
subscription_payments_row = (
await session.execute(
select(
func.count(Transaction.id),
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
).where(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.is_completed == True, # noqa: E712
Transaction.created_at >= start_utc,
Transaction.created_at < end_utc,
new_users = int(
(
await session.execute(
select(func.count(User.id)).where(
User.created_at >= start_utc,
User.created_at < end_utc,
)
)
)
).one()
deposits_row = (
await session.execute(
select(
func.count(Transaction.id),
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
).where(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True, # noqa: E712
Transaction.created_at >= start_utc,
Transaction.created_at < end_utc,
)
)
).one()
subscription_payments_count = int(subscription_payments_row[0] or 0)
subscription_payments_amount = int(subscription_payments_row[1] or 0)
deposits_count = int(deposits_row[0] or 0)
deposits_amount = int(deposits_row[1] or 0)
total_payments_count = subscription_payments_count + deposits_count
total_payments_amount = subscription_payments_amount + deposits_amount
new_tickets_result = await session.execute(
select(func.count(Ticket.id)).where(
Ticket.created_at >= start_utc,
Ticket.created_at < end_utc,
)
).scalar()
or 0
)
new_trials = int(
(
await session.execute(
select(func.count(Subscription.id)).where(
Subscription.created_at >= start_utc,
Subscription.created_at < end_utc,
Subscription.is_trial == true(),
)
)
).scalar()
or 0
)
direct_paid = int(
(
await session.execute(
select(func.count(Subscription.id)).where(
Subscription.created_at >= start_utc,
Subscription.created_at < end_utc,
Subscription.is_trial == false(),
)
)
).scalar()
or 0
)
trial_to_paid_conversions = int(
(
await session.execute(
select(func.count(SubscriptionConversion.id)).where(
SubscriptionConversion.converted_at >= start_utc,
SubscriptionConversion.converted_at < end_utc,
)
)
).scalar()
or 0
)
subscription_payments_count, subscription_payments_amount = (
(
await session.execute(
self._txn_query_base(
TransactionType.SUBSCRIPTION_PAYMENT.value,
start_utc,
end_utc,
)
)
).one()
)
deposits_count, deposits_amount = (
(
await session.execute(
self._deposit_query_excluding_referrals(start_utc, end_utc)
)
).one()
)
new_tickets = int(
(
await session.execute(
select(func.count(Ticket.id)).where(
Ticket.created_at >= start_utc,
Ticket.created_at < end_utc,
)
)
).scalar()
or 0
)
new_tickets = int(new_tickets_result.scalar() or 0)
return {
"new_users": new_users,
"new_trials": new_trials,
"new_paid_subscriptions": direct_paid + conversions_count,
"subscription_payments_count": subscription_payments_count,
"subscription_payments_amount": subscription_payments_amount,
"deposits_count": deposits_count,
"deposits_amount": deposits_amount,
"total_payments_count": total_payments_count,
"total_payments_amount": total_payments_amount,
"new_paid_subscriptions": direct_paid + trial_to_paid_conversions,
"trial_to_paid_conversions": trial_to_paid_conversions,
"subscription_payments_count": int(subscription_payments_count or 0),
"subscription_payments_amount": int(subscription_payments_amount or 0),
"deposits_count": int(deposits_count or 0),
"deposits_amount": int(deposits_amount or 0),
"new_tickets": new_tickets,
}
def _txn_query_base(self, txn_type: str, start_utc: datetime, end_utc: datetime):
return select(
func.count(Transaction.id),
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
).where(
Transaction.type == txn_type,
Transaction.is_completed == true(),
Transaction.created_at >= start_utc,
Transaction.created_at < end_utc,
)
def _deposit_query_excluding_referrals(self, start_utc: datetime, end_utc: datetime):
return select(
func.count(Transaction.id),
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
).where(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == true(),
Transaction.created_at >= start_utc,
Transaction.created_at < end_utc,
self._exclude_referral_deposits_condition(),
)
async def _get_top_referrers(
self,
session,
start_utc: datetime,
end_utc: datetime,
limit: int = 5,
) -> List[Dict]:
rows = await session.execute(
select(
User.referred_by_id,
func.count(User.id).label("cnt"),
)
.where(
User.created_at >= start_utc,
User.created_at < end_utc,
User.referred_by_id.isnot(None),
)
.group_by(User.referred_by_id)
.order_by(func.count(User.id).desc())
.limit(limit)
)
rows = rows.all()
if not rows:
return []
ref_ids = [row[0] for row in rows if row[0] is not None]
users_map: Dict[int, str] = {}
if ref_ids:
urows = await session.execute(select(User).where(User.id.in_(ref_ids)))
for user in urows.scalars().all():
users_map[user.id] = self._user_label(user)
return [
{"referrer_label": users_map.get(ref_id, f"User #{ref_id}"), "count": int(count or 0)}
for ref_id, count in rows
]
async def _get_user_usage_stats(self, session) -> Dict[str, int]:
now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
active_paid_q = await session.execute(
select(func.count(func.distinct(Subscription.user_id))).where(
Subscription.is_trial == false(),
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > now_utc,
)
)
active_paid_users = int(active_paid_q.scalar() or 0)
never_connected_q = await session.execute(
select(func.count(func.distinct(Subscription.user_id))).where(
or_(
Subscription.connected_squads.is_(None),
func.jsonb_array_length(cast(Subscription.connected_squads, JSONB)) == 0,
)
)
)
never_connected_users = int(never_connected_q.scalar() or 0)
return {
"active_paid_users": active_paid_users,
"never_connected_users": never_connected_users,
}
def _user_label(self, user: User) -> str:
if getattr(user, "username", None):
return f"@{user.username}"
parts = []
if getattr(user, "first_name", None):
parts.append(user.first_name)
if getattr(user, "last_name", None):
parts.append(user.last_name)
if parts:
return " ".join(parts)
return f"User #{getattr(user, 'id', '?')}"
def _format_period_label(self, start: datetime, end: datetime) -> str:
start_date = start.astimezone(self._moscow_tz).date()
end_boundary = (end - timedelta(seconds=1)).astimezone(self._moscow_tz)
@@ -362,15 +583,10 @@ class ReportingService:
if start_date == end_date:
return start_date.strftime("%d.%m.%Y")
return (
f"{start_date.strftime('%d.%m.%Y')} - {end_date.strftime('%d.%m.%Y')}"
)
return f"{start_date.strftime('%d.%m.%Y')} - {end_date.strftime('%d.%m.%Y')}"
def _format_amount(self, amount_kopeks: int) -> str:
if not amount_kopeks:
return "0 ₽"
rubles = amount_kopeks / 100
rubles = (amount_kopeks or 0) / 100
return f"{rubles:,.2f}".replace(",", " ")
@@ -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()
+252 -9
View File
@@ -2,7 +2,7 @@ import hashlib
import json
import logging
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple, Type, Union, get_args, get_origin
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union, get_args, get_origin
from app.database.universal_migration import ensure_default_web_api_token
@@ -74,13 +74,15 @@ class BotConfigurationService:
"PAYMENT": "💳 Общие платежные настройки",
"TELEGRAM": "⭐ Telegram Stars",
"CRYPTOBOT": "🪙 CryptoBot",
"HELEKET": "🪙 Heleket",
"YOOKASSA": "🟣 YooKassa",
"TRIBUTE": "🎁 Tribute",
"MULENPAY": "💰 MulenPay",
"MULENPAY": "💰 {mulenpay_name}",
"PAL24": "🏦 PAL24 / PayPalych",
"WATA": "💠 Wata",
"EXTERNAL_ADMIN": "🛡️ Внешняя админка",
"SUBSCRIPTIONS_CORE": "📅 Подписки и лимиты",
"SIMPLE_SUBSCRIPTION": "⚡ Простая покупка",
"PERIODS": "📆 Периоды подписок",
"SUBSCRIPTION_PRICES": "💵 Стоимость тарифов",
"TRAFFIC": "📊 Трафик",
@@ -124,13 +126,15 @@ class BotConfigurationService:
"PAYMENT": "Общие тексты платежей, описания чеков и шаблоны.",
"YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.",
"CRYPTOBOT": "CryptoBot и криптоплатежи через Telegram.",
"MULENPAY": "Платежи MulenPay и параметры магазина.",
"HELEKET": "Heleket: криптоплатежи, ключи мерчанта и вебхуки.",
"MULENPAY": "Платежи {mulenpay_name} и параметры магазина.",
"PAL24": "PAL24 / PayPalych подключения и лимиты.",
"TRIBUTE": "Tribute и донат-сервисы.",
"TELEGRAM": "Telegram Stars и их стоимость.",
"WATA": "Wata: токен доступа, тип платежа и пределы сумм.",
"EXTERNAL_ADMIN": "Токен внешней админки для проверки запросов.",
"SUBSCRIPTIONS_CORE": "Лимиты устройств, трафика и базовые цены подписок.",
"SIMPLE_SUBSCRIPTION": "Параметры упрощённой покупки: период, трафик, устройства и сквады.",
"PERIODS": "Доступные периоды подписок и продлений.",
"SUBSCRIPTION_PRICES": "Стоимость подписок по периодам в копейках.",
"TRAFFIC": "Лимиты трафика и стратегии сброса.",
@@ -166,6 +170,14 @@ class BotConfigurationService:
"MODERATION": "Настройки фильтров отображаемых имен и защиты от фишинга.",
}
@staticmethod
def _format_dynamic_copy(category_key: Optional[str], value: str) -> str:
if not value:
return value
if category_key == "MULENPAY":
return value.format(mulenpay_name=settings.get_mulenpay_display_name())
return value
CATEGORY_KEY_OVERRIDES: Dict[str, str] = {
"DATABASE_URL": "DATABASE",
"DATABASE_MODE": "DATABASE",
@@ -222,6 +234,11 @@ class BotConfigurationService:
"PAYMENT_SUBSCRIPTION_DESCRIPTION": "PAYMENT",
"PAYMENT_BALANCE_TEMPLATE": "PAYMENT",
"PAYMENT_SUBSCRIPTION_TEMPLATE": "PAYMENT",
"SIMPLE_SUBSCRIPTION_ENABLED": "SIMPLE_SUBSCRIPTION",
"SIMPLE_SUBSCRIPTION_PERIOD_DAYS": "SIMPLE_SUBSCRIPTION",
"SIMPLE_SUBSCRIPTION_DEVICE_LIMIT": "SIMPLE_SUBSCRIPTION",
"SIMPLE_SUBSCRIPTION_TRAFFIC_GB": "SIMPLE_SUBSCRIPTION",
"SIMPLE_SUBSCRIPTION_SQUAD_UUID": "SIMPLE_SUBSCRIPTION",
"DISABLE_TOPUP_BUTTONS": "PAYMENT",
"ENABLE_NOTIFICATIONS": "NOTIFICATIONS",
"NOTIFICATION_RETRY_ATTEMPTS": "NOTIFICATIONS",
@@ -271,11 +288,13 @@ class BotConfigurationService:
"TRIBUTE_": "TRIBUTE",
"YOOKASSA_": "YOOKASSA",
"CRYPTOBOT_": "CRYPTOBOT",
"HELEKET_": "HELEKET",
"MULENPAY_": "MULENPAY",
"PAL24_": "PAL24",
"PAYMENT_": "PAYMENT",
"WATA_": "WATA",
"EXTERNAL_ADMIN_": "EXTERNAL_ADMIN",
"SIMPLE_SUBSCRIPTION_": "SIMPLE_SUBSCRIPTION",
"CONNECT_BUTTON_HAPP": "HAPP",
"HAPP_": "HAPP",
"SKIP_": "SKIP",
@@ -386,12 +405,47 @@ class BotConfigurationService:
SETTING_HINTS: Dict[str, Dict[str, str]] = {
"YOOKASSA_ENABLED": {
"description": "Включает оплату через YooKassa. Требует корректных идентификаторов магазина и секретного ключа.",
"description": (
"Включает оплату через YooKassa. "
"Требует корректных идентификаторов магазина и секретного ключа."
),
"format": "Булево значение: выберите \"Включить\" или \"Выключить\".",
"example": "Включено при полностью настроенной интеграции.",
"warning": "При включении без Shop ID и Secret Key пользователи увидят ошибки при оплате.",
"dependencies": "YOOKASSA_SHOP_ID, YOOKASSA_SECRET_KEY, YOOKASSA_RETURN_URL",
},
"SIMPLE_SUBSCRIPTION_ENABLED": {
"description": "Показывает в меню пункт с быстрой покупкой подписки.",
"format": "Булево значение.",
"example": "true",
"warning": "Если остались не настроенные параметры, предложение может вести себя некорректно.",
},
"SIMPLE_SUBSCRIPTION_PERIOD_DAYS": {
"description": "Период подписки, который предлагается при быстрой покупке.",
"format": "Выберите один из доступных периодов.",
"example": "30 дн. — 990 ₽",
"warning": "Не забудьте настроить цену периода в блоке «Стоимость тарифов».",
},
"SIMPLE_SUBSCRIPTION_DEVICE_LIMIT": {
"description": "Сколько устройств получит пользователь вместе с подпиской по быстрой покупке.",
"format": "Выберите число устройств.",
"example": "2 устройства",
"warning": "Значение не должно превышать допустимый лимит в настройках подписок.",
},
"SIMPLE_SUBSCRIPTION_TRAFFIC_GB": {
"description": "Объём трафика, включённый в простую подписку (0 = безлимит).",
"format": "Выберите пакет трафика.",
"example": "Безлимит",
},
"SIMPLE_SUBSCRIPTION_SQUAD_UUID": {
"description": (
"Привязка быстрой подписки к конкретному скваду. "
"Оставьте пустым для любого доступного сервера."
),
"format": "Выберите сквад из списка или очистите значение.",
"example": "d4aa2b8c-9a36-4f31-93a2-6f07dad05fba",
"warning": "Убедитесь, что выбранный сквад активен и доступен для подписки.",
},
"CRYPTOBOT_ENABLED": {
"description": "Разрешает принимать криптоплатежи через CryptoBot.",
"format": "Булево значение.",
@@ -414,7 +468,10 @@ class BotConfigurationService:
"dependencies": "MAINTENANCE_MESSAGE, MAINTENANCE_CHECK_INTERVAL",
},
"DISPLAY_NAME_BANNED_KEYWORDS": {
"description": "Список слов и фрагментов, при наличии которых в отображаемом имени пользователь будет заблокирован.",
"description": (
"Список слов и фрагментов, при наличии которых в отображаемом имени "
"пользователь будет заблокирован."
),
"format": "Перечислите ключевые слова через запятую или с новой строки.",
"example": "support, security, служебн",
"warning": "Слишком агрессивные фильтры могут блокировать добросовестных пользователей.",
@@ -435,10 +492,16 @@ class BotConfigurationService:
"dependencies": "REMNAWAVE_AUTO_SYNC_TIMES",
},
"REMNAWAVE_AUTO_SYNC_TIMES": {
"description": "Список времени в формате HH:MM, когда запускается автосинхронизация в течение суток.",
"description": (
"Список времени в формате HH:MM, когда запускается автосинхронизация "
"в течение суток."
),
"format": "Перечислите время через запятую или с новой строки (например, 03:00, 15:00).",
"example": "03:00, 15:00",
"warning": "Минимальный интервал между запусками не ограничен, но слишком частые синхронизации нагружают панель.",
"warning": (
"Минимальный интервал между запусками не ограничен, но слишком частые "
"синхронизации нагружают панель."
),
"dependencies": "REMNAWAVE_AUTO_SYNC_ENABLED",
},
"EXTERNAL_ADMIN_TOKEN": {
@@ -459,7 +522,8 @@ class BotConfigurationService:
@classmethod
def get_category_description(cls, category_key: str) -> str:
return cls.CATEGORY_DESCRIPTIONS.get(category_key, "")
description = cls.CATEGORY_DESCRIPTIONS.get(category_key, "")
return cls._format_dynamic_copy(category_key, description)
@classmethod
def is_toggle(cls, key: str) -> bool:
@@ -506,6 +570,14 @@ class BotConfigurationService:
@classmethod
def format_value_human(cls, key: str, value: Any) -> str:
if key == "SIMPLE_SUBSCRIPTION_SQUAD_UUID":
if value is None:
return "Любой доступный"
if isinstance(value, str):
cleaned_value = value.strip()
if not cleaned_value:
return "Любой доступный"
if value is None:
return ""
@@ -598,6 +670,7 @@ class BotConfigurationService:
category_key,
category_key.capitalize() if category_key else "Прочее",
)
category_label = cls._format_dynamic_copy(category_key, category_label)
cls._definitions[key] = SettingDefinition(
key=key,
@@ -733,8 +806,179 @@ class BotConfigurationService:
@classmethod
def get_choice_options(cls, key: str) -> List[ChoiceOption]:
cls.initialize_definitions()
dynamic = cls._get_dynamic_choice_options(key)
if dynamic is not None:
cls.CHOICES[key] = dynamic
cls._invalidate_choice_cache(key)
return dynamic
return cls.CHOICES.get(key, [])
@classmethod
def _invalidate_choice_cache(cls, key: str) -> None:
cls._choice_tokens.pop(key, None)
cls._choice_token_lookup.pop(key, None)
@classmethod
def _get_dynamic_choice_options(cls, key: str) -> Optional[List[ChoiceOption]]:
if key == "SIMPLE_SUBSCRIPTION_PERIOD_DAYS":
return cls._build_simple_subscription_period_choices()
if key == "SIMPLE_SUBSCRIPTION_DEVICE_LIMIT":
return cls._build_simple_subscription_device_choices()
if key == "SIMPLE_SUBSCRIPTION_TRAFFIC_GB":
return cls._build_simple_subscription_traffic_choices()
return None
@staticmethod
def _build_simple_subscription_period_choices() -> List[ChoiceOption]:
raw_periods = str(getattr(settings, "AVAILABLE_SUBSCRIPTION_PERIODS", "") or "")
period_values: set[int] = set()
for segment in raw_periods.split(","):
segment = segment.strip()
if not segment:
continue
try:
period = int(segment)
except ValueError:
continue
if period > 0:
period_values.add(period)
fallback_period = getattr(settings, "SIMPLE_SUBSCRIPTION_PERIOD_DAYS", 30) or 30
try:
fallback_period = int(fallback_period)
except (TypeError, ValueError):
fallback_period = 30
period_values.add(max(1, fallback_period))
options: List[ChoiceOption] = []
for days in sorted(period_values):
price_attr = f"PRICE_{days}_DAYS"
price_value = getattr(settings, price_attr, None)
if not isinstance(price_value, int):
price_value = settings.BASE_SUBSCRIPTION_PRICE
label = f"{days} дн."
try:
if isinstance(price_value, int):
label = f"{label}{settings.format_price(price_value)}"
except Exception:
logger.debug("Не удалось форматировать цену для периода %s", days, exc_info=True)
options.append(ChoiceOption(days, label))
return options
@classmethod
def _build_simple_subscription_device_choices(cls) -> List[ChoiceOption]:
default_limit = getattr(settings, "DEFAULT_DEVICE_LIMIT", 1) or 1
try:
default_limit = int(default_limit)
except (TypeError, ValueError):
default_limit = 1
max_limit = getattr(settings, "MAX_DEVICES_LIMIT", default_limit) or default_limit
try:
max_limit = int(max_limit)
except (TypeError, ValueError):
max_limit = default_limit
current_limit = getattr(settings, "SIMPLE_SUBSCRIPTION_DEVICE_LIMIT", default_limit) or default_limit
try:
current_limit = int(current_limit)
except (TypeError, ValueError):
current_limit = default_limit
upper_bound = max(default_limit, max_limit, current_limit, 1)
upper_bound = min(max(upper_bound, 1), 50)
options: List[ChoiceOption] = []
for count in range(1, upper_bound + 1):
label = f"{count} {cls._pluralize_devices(count)}"
if count == default_limit:
label = f"{label} (по умолчанию)"
options.append(ChoiceOption(count, label))
return options
@staticmethod
def _build_simple_subscription_traffic_choices() -> List[ChoiceOption]:
try:
packages = settings.get_traffic_packages()
except Exception as error:
logger.warning("Не удалось получить пакеты трафика: %s", error, exc_info=True)
packages = []
traffic_values: set[int] = {0}
for package in packages:
gb_value = package.get("gb")
try:
gb = int(gb_value)
except (TypeError, ValueError):
continue
if gb >= 0:
traffic_values.add(gb)
default_limit = getattr(settings, "DEFAULT_TRAFFIC_LIMIT_GB", 0) or 0
try:
default_limit = int(default_limit)
except (TypeError, ValueError):
default_limit = 0
if default_limit >= 0:
traffic_values.add(default_limit)
current_limit = getattr(settings, "SIMPLE_SUBSCRIPTION_TRAFFIC_GB", default_limit)
try:
current_limit = int(current_limit)
except (TypeError, ValueError):
current_limit = default_limit
if current_limit >= 0:
traffic_values.add(current_limit)
options: List[ChoiceOption] = []
for gb in sorted(traffic_values):
if gb <= 0:
label = "Безлимит"
else:
label = f"{gb} ГБ"
price_label = None
for package in packages:
try:
package_gb = int(package.get("gb"))
except (TypeError, ValueError):
continue
if package_gb != gb:
continue
price_raw = package.get("price")
try:
price_value = int(price_raw)
if price_value >= 0:
price_label = settings.format_price(price_value)
except (TypeError, ValueError):
continue
break
if price_label:
label = f"{label}{price_label}"
options.append(ChoiceOption(gb, label))
return options
@staticmethod
def _pluralize_devices(count: int) -> str:
count = abs(int(count))
last_two = count % 100
last_one = count % 10
if 11 <= last_two <= 14:
return "устройств"
if last_one == 1:
return "устройство"
if 2 <= last_one <= 4:
return "устройства"
return "устройств"
@classmethod
def has_choices(cls, key: str) -> bool:
return bool(cls.get_choice_options(key))
@@ -1045,4 +1289,3 @@ class BotConfigurationService:
bot_configuration_service = BotConfigurationService
+30 -4
View File
@@ -3,7 +3,7 @@ from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete, select, update, func
from aiogram import Bot
from aiogram import Bot, types
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from app.database.crud.user import (
get_user_by_id, get_user_by_telegram_id, get_users_list,
@@ -26,6 +26,7 @@ from app.database.models import (
TransactionType
)
from app.config import settings
from app.localization.texts import get_texts
logger = logging.getLogger(__name__)
@@ -66,10 +67,30 @@ class UserService:
f"Если у вас есть вопросы, обратитесь в поддержку."
)
keyboard_rows = []
if getattr(user, "subscription", None) and user.subscription.status in {
"active",
"expired",
"trial",
}:
keyboard_rows.append([
types.InlineKeyboardButton(
text=get_texts(user.language).t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"),
callback_data="subscription_extend",
)
])
reply_markup = (
types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
if keyboard_rows
else None
)
await bot.send_message(
chat_id=user.telegram_id,
text=message,
parse_mode="HTML"
parse_mode="HTML",
reply_markup=reply_markup,
)
logger.info(f"✅ Уведомление о изменении баланса отправлено пользователю {user.telegram_id}")
@@ -652,7 +673,10 @@ class UserService:
mulenpay_payments = mulenpay_result.scalars().all()
if mulenpay_payments:
logger.info(f"🔄 Удаляем {len(mulenpay_payments)} MulenPay платежей")
mulenpay_name = settings.get_mulenpay_display_name()
logger.info(
f"🔄 Удаляем {len(mulenpay_payments)} {mulenpay_name} платежей"
)
await db.execute(
update(MulenPayPayment)
.where(MulenPayPayment.user_id == user_id)
@@ -664,7 +688,9 @@ class UserService:
)
await db.flush()
except Exception as e:
logger.error(f"❌ Ошибка удаления MulenPay платежей: {e}")
logger.error(
f"❌ Ошибка удаления {settings.get_mulenpay_display_name()} платежей: {e}"
)
try:
pal24_result = await db.execute(
+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()
+22 -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"
})
@@ -80,6 +81,15 @@ def get_available_payment_methods() -> List[Dict[str, str]]:
"description": "через CryptoBot",
"callback": "topup_cryptobot"
})
if settings.is_heleket_enabled():
methods.append({
"id": "heleket",
"name": "Криптовалюта",
"icon": "🪙",
"description": "через Heleket",
"callback": "topup_heleket"
})
# Поддержка всегда доступна
methods.append({
@@ -129,6 +139,12 @@ def get_payment_methods_text(language: str) -> str:
f"PAYMENT_METHOD_{method_id}_DESCRIPTION",
method['description'],
)
if method_id == "MULENPAY":
mulenpay_name = settings.get_mulenpay_display_name()
mulenpay_name_html = settings.get_mulenpay_display_name_html()
name = name.format(mulenpay_name=mulenpay_name_html)
description = description.format(mulenpay_name=mulenpay_name)
text += f"{name} - {description}\n"
text += "\n" + texts.t(
@@ -156,6 +172,8 @@ def is_payment_method_available(method_id: str) -> bool:
return settings.is_pal24_enabled()
elif method_id == "cryptobot":
return settings.is_cryptobot_enabled()
elif method_id == "heleket":
return settings.is_heleket_enabled()
elif method_id == "support":
return True # Поддержка всегда доступна
else:
@@ -173,6 +191,7 @@ def get_payment_method_status() -> Dict[str, bool]:
"wata": settings.is_wata_enabled(),
"pal24": settings.is_pal24_enabled(),
"cryptobot": settings.is_cryptobot_enabled(),
"heleket": settings.is_heleket_enabled(),
"support": True
}
@@ -195,4 +214,6 @@ def get_enabled_payment_methods_count() -> int:
count += 1
if settings.is_cryptobot_enabled():
count += 1
if settings.is_heleket_enabled():
count += 1
return count
+135 -4
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",
@@ -714,6 +715,18 @@ async def get_payment_methods(
)
)
if settings.is_heleket_enabled():
methods.append(
MiniAppPaymentMethod(
id="heleket",
icon="🪙",
requires_amount=True,
currency="RUB",
min_amount_kopeks=100 * 100,
max_amount_kopeks=100_000 * 100,
)
)
if settings.TRIBUTE_ENABLED:
methods.append(
MiniAppPaymentMethod(
@@ -732,7 +745,8 @@ async def get_payment_methods(
"pal24": 5,
"wata": 6,
"cryptobot": 7,
"tribute": 8,
"heleket": 8,
"tribute": 9,
}
methods.sort(key=lambda item: order_map.get(item.id, 99))
@@ -958,7 +972,7 @@ async def create_payment_link(
option = (payload.payment_option or "").strip().lower()
if option not in {"card", "sbp"}:
option = "sbp"
provider_method = "CARD" if option == "card" else "SBP"
provider_method = "card" if option == "card" else "sbp"
payment_service = PaymentService()
result = await payment_service.create_pal24_payment(
@@ -974,7 +988,7 @@ async def create_payment_link(
preferred_urls: List[Optional[str]] = []
if option == "sbp":
preferred_urls.append(result.get("sbp_url"))
preferred_urls.append(result.get("sbp_url") or result.get("transfer_url"))
elif option == "card":
preferred_urls.append(result.get("card_url"))
preferred_urls.extend(
@@ -998,7 +1012,7 @@ async def create_payment_link(
"bill_id": result.get("bill_id"),
"order_id": result.get("order_id"),
"payment_method": result.get("payment_method") or provider_method,
"sbp_url": result.get("sbp_url"),
"sbp_url": result.get("sbp_url") or result.get("transfer_url"),
"card_url": result.get("card_url"),
"link_url": result.get("link_url"),
"link_page_url": result.get("link_page_url"),
@@ -1070,6 +1084,53 @@ async def create_payment_link(
},
)
if method == "heleket":
if not settings.is_heleket_enabled():
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
if amount_kopeks is None or amount_kopeks <= 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive")
min_amount_kopeks = 100 * 100
max_amount_kopeks = 100_000 * 100
if amount_kopeks < min_amount_kopeks:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=f"Amount is below minimum ({min_amount_kopeks / 100:.2f} RUB)",
)
if amount_kopeks > max_amount_kopeks:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=f"Amount exceeds maximum ({max_amount_kopeks / 100:.2f} RUB)",
)
payment_service = PaymentService()
result = await payment_service.create_heleket_payment(
db=db,
user_id=user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
language=user.language or settings.DEFAULT_LANGUAGE,
)
if not result or not result.get("payment_url"):
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment")
return MiniAppPaymentCreateResponse(
method=method,
payment_url=result["payment_url"],
amount_kopeks=amount_kopeks,
extra={
"local_payment_id": result.get("local_payment_id"),
"uuid": result.get("uuid"),
"order_id": result.get("order_id"),
"payer_amount": result.get("payer_amount"),
"payer_currency": result.get("payer_currency"),
"discount_percent": result.get("discount_percent"),
"exchange_rate": result.get("exchange_rate"),
"requested_at": _current_request_timestamp(),
},
)
if method == "tribute":
if not settings.TRIBUTE_ENABLED:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
@@ -1162,6 +1223,8 @@ async def _resolve_payment_status_entry(
return await _resolve_pal24_payment_status(payment_service, db, user, query)
if method == "cryptobot":
return await _resolve_cryptobot_payment_status(db, user, query)
if method == "heleket":
return await _resolve_heleket_payment_status(db, user, query)
if method == "stars":
return await _resolve_stars_payment_status(db, user, query)
if method == "tribute":
@@ -1551,6 +1614,74 @@ async def _resolve_cryptobot_payment_status(
)
async def _resolve_heleket_payment_status(
db: AsyncSession,
user: User,
query: MiniAppPaymentStatusQuery,
) -> MiniAppPaymentStatusResult:
from app.database.crud.heleket import (
get_heleket_payment_by_id,
get_heleket_payment_by_order_id,
get_heleket_payment_by_uuid,
)
payment = None
if query.local_payment_id:
payment = await get_heleket_payment_by_id(db, query.local_payment_id)
if not payment and query.payment_id:
payment = await get_heleket_payment_by_uuid(db, query.payment_id)
if not payment and query.invoice_id:
payment = await get_heleket_payment_by_uuid(db, query.invoice_id)
if not payment and query.bill_id:
payment = await get_heleket_payment_by_order_id(db, query.bill_id)
if not payment or payment.user_id != user.id:
return MiniAppPaymentStatusResult(
method="heleket",
status="pending",
is_paid=False,
amount_kopeks=query.amount_kopeks,
message="Payment not found",
extra={
"local_payment_id": query.local_payment_id,
"uuid": query.payment_id or query.invoice_id,
"order_id": query.bill_id,
"payload": query.payload,
"started_at": query.started_at,
},
)
status_raw = payment.status
is_paid = bool(payment.is_paid)
status = _classify_status(status_raw, is_paid)
completed_at = payment.paid_at or payment.updated_at or payment.created_at
return MiniAppPaymentStatusResult(
method="heleket",
status=status,
is_paid=status == "paid",
amount_kopeks=payment.amount_kopeks,
currency=payment.currency,
completed_at=completed_at,
transaction_id=payment.transaction_id,
external_id=payment.uuid,
message=None,
extra={
"status": payment.status,
"local_payment_id": payment.id,
"uuid": payment.uuid,
"order_id": payment.order_id,
"payer_amount": payment.payer_amount,
"payer_currency": payment.payer_currency,
"discount_percent": payment.discount_percent,
"exchange_rate": payment.exchange_rate,
"payment_url": payment.payment_url,
"payload": query.payload,
"started_at": query.started_at,
},
)
async def _resolve_stars_payment_status(
db: AsyncSession,
user: User,
+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
View File
@@ -75,6 +75,10 @@ services:
- "${WEB_API_PORT:-8080}:8080"
- "${TRIBUTE_WEBHOOK_PORT:-8081}:8081"
- "${YOOKASSA_WEBHOOK_PORT:-8082}:8082"
- "${CRYPTOBOT_WEBHOOK_PORT:-8083}:8083"
- "${PAL24_WEBHOOK_PORT:-8084}:8084"
- "${WATA_WEBHOOK_PORT:-8085}:8085"
- "${HELEKET_WEBHOOK_PORT:-8086}:8086"
networks:
- bot_network
healthcheck:
+5 -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,10 @@ services:
- "${WEB_API_PORT:-8080}:8080"
- "${TRIBUTE_WEBHOOK_PORT:-8081}:8081"
- "${YOOKASSA_WEBHOOK_PORT:-8082}:8082"
- "${CRYPTOBOT_WEBHOOK_PORT:-8083}:8083"
- "${PAL24_WEBHOOK_PORT:-8084}:8084"
- "${WATA_WEBHOOK_PORT:-8085}:8085"
- "${HELEKET_WEBHOOK_PORT:-8086}:8086"
networks:
- bot_network
healthcheck:
+13 -1
View File
@@ -74,6 +74,9 @@
- `app/database/crud/cryptobot.py` — Python-модуль
Классы: нет
Функции: нет
- `app/database/crud/heleket.py` — Python-модуль
Классы: нет
Функции: нет
- `app/database/crud/discount_offer.py` — Python-модуль
Классы: нет
Функции: нет
@@ -158,6 +161,12 @@
- `app/external/cryptobot.py` — Python-модуль
Классы: `CryptoBotService` (2 методов)
Функции: нет
- `app/external/heleket.py` — Python-модуль
Классы: `HeleketService` (3 методов)
Функции: нет
- `app/external/heleket_webhook.py` — Python-модуль
Классы: `HeleketWebhookHandler` (3 методов)
Функции: `create_heleket_app`, `start_heleket_webhook_server`
- `app/external/pal24_client.py` — Async client for PayPalych (Pal24) API.
Классы: `Pal24APIError` — Base error for Pal24 API operations., `Pal24Response` (2 методов) — Wrapper for Pal24 API responses., `Pal24Client` (5 методов) — Async client implementing PayPalych API methods.
Функции: нет
@@ -323,7 +332,7 @@
Функции: `_t` — Helper for localized button labels with fallbacks., `get_admin_main_keyboard`, `get_admin_users_submenu_keyboard`, `get_admin_promo_submenu_keyboard`, `get_admin_communications_submenu_keyboard`, `get_admin_support_submenu_keyboard`, `get_admin_settings_submenu_keyboard`, `get_admin_system_submenu_keyboard`, `get_admin_reports_keyboard`, `get_admin_report_result_keyboard`, `get_admin_users_keyboard`, `get_admin_users_filters_keyboard`, `get_admin_subscriptions_keyboard`, `get_admin_promocodes_keyboard`, `get_admin_campaigns_keyboard`, `get_campaign_management_keyboard`, `get_campaign_edit_keyboard`, `get_campaign_bonus_type_keyboard`, `get_promocode_management_keyboard`, `get_admin_messages_keyboard`, `get_admin_monitoring_keyboard`, `get_admin_remnawave_keyboard`, `get_admin_statistics_keyboard`, `get_user_management_keyboard`, `get_user_promo_group_keyboard`, `get_confirmation_keyboard`, `get_promocode_type_keyboard`, `get_promocode_list_keyboard`, `get_broadcast_target_keyboard`, `get_custom_criteria_keyboard`, `get_broadcast_history_keyboard`, `get_sync_options_keyboard`, `get_sync_confirmation_keyboard`, `get_sync_result_keyboard`, `get_period_selection_keyboard`, `get_node_management_keyboard`, `get_squad_management_keyboard`, `get_squad_edit_keyboard`, `get_monitoring_keyboard`, `get_monitoring_logs_keyboard`, `get_monitoring_logs_navigation_keyboard`, `get_log_detail_keyboard`, `get_monitoring_clear_confirm_keyboard`, `get_monitoring_status_keyboard`, `get_monitoring_settings_keyboard`, `get_log_type_filter_keyboard`, `get_admin_servers_keyboard`, `get_server_edit_keyboard`, `get_admin_pagination_keyboard`, `get_maintenance_keyboard`, `get_sync_simplified_keyboard`, `get_welcome_text_keyboard`, `get_broadcast_button_config`, `get_broadcast_button_labels`, `get_message_buttons_selector_keyboard`, `get_broadcast_media_keyboard`, `get_media_confirm_keyboard`, `get_updated_message_buttons_selector_keyboard_with_media`
- `app/keyboards/inline.py` — Python-модуль
Классы: нет
Функции: `_get_localized_value`, `_build_additional_buttons`, `get_rules_keyboard`, `get_channel_sub_keyboard`, `get_post_registration_keyboard`, `get_language_selection_keyboard`, `_build_text_main_menu_keyboard`, `get_main_menu_keyboard`, `get_info_menu_keyboard`, `get_happ_download_button_row`, `get_happ_cryptolink_keyboard`, `get_happ_download_platform_keyboard`, `get_happ_download_link_keyboard`, `get_back_keyboard`, `get_server_status_keyboard`, `get_insufficient_balance_keyboard`, `get_subscription_keyboard`, `get_payment_methods_keyboard_with_cart`, `get_subscription_confirm_keyboard_with_cart`, `get_insufficient_balance_keyboard_with_cart`, `get_trial_keyboard`, `get_subscription_period_keyboard`, `get_traffic_packages_keyboard`, `get_countries_keyboard`, `get_devices_keyboard`, `_get_device_declension`, `get_subscription_confirm_keyboard`, `get_balance_keyboard`, `get_payment_methods_keyboard`, `get_yookassa_payment_keyboard`, `get_autopay_notification_keyboard`, `get_subscription_expiring_keyboard`, `get_referral_keyboard`, `get_support_keyboard`, `get_pagination_keyboard`, `get_confirmation_keyboard`, `get_autopay_keyboard`, `get_autopay_days_keyboard`, `_get_days_suffix`, `get_extend_subscription_keyboard`, `get_add_traffic_keyboard`, `get_change_devices_keyboard`, `get_confirm_change_devices_keyboard`, `get_reset_traffic_confirm_keyboard`, `get_manage_countries_keyboard`, `get_device_selection_keyboard`, `get_connection_guide_keyboard`, `get_app_selection_keyboard`, `get_specific_app_keyboard`, `get_extend_subscription_keyboard_with_prices`, `get_cryptobot_payment_keyboard`, `get_devices_management_keyboard`, `get_updated_subscription_settings_keyboard`, `get_device_reset_confirm_keyboard`, `get_device_management_help_keyboard`, `get_ticket_cancel_keyboard`, `get_my_tickets_keyboard`, `get_ticket_view_keyboard`, `get_ticket_reply_cancel_keyboard`, `get_admin_tickets_keyboard`, `get_admin_ticket_view_keyboard`, `get_admin_ticket_reply_cancel_keyboard`
Функции: `_get_localized_value`, `_build_additional_buttons`, `get_rules_keyboard`, `get_channel_sub_keyboard`, `get_post_registration_keyboard`, `get_language_selection_keyboard`, `_build_text_main_menu_keyboard`, `get_main_menu_keyboard`, `get_info_menu_keyboard`, `get_happ_download_button_row`, `get_happ_cryptolink_keyboard`, `get_happ_download_platform_keyboard`, `get_happ_download_link_keyboard`, `get_back_keyboard`, `get_server_status_keyboard`, `get_insufficient_balance_keyboard`, `get_subscription_keyboard`, `get_payment_methods_keyboard_with_cart`, `get_subscription_confirm_keyboard_with_cart`, `get_insufficient_balance_keyboard_with_cart`, `get_trial_keyboard`, `get_subscription_period_keyboard`, `get_traffic_packages_keyboard`, `get_countries_keyboard`, `get_devices_keyboard`, `_get_device_declension`, `get_subscription_confirm_keyboard`, `get_balance_keyboard`, `get_payment_methods_keyboard`, `get_yookassa_payment_keyboard`, `get_autopay_notification_keyboard`, `get_subscription_expiring_keyboard`, `get_referral_keyboard`, `get_support_keyboard`, `get_pagination_keyboard`, `get_confirmation_keyboard`, `get_autopay_keyboard`, `get_autopay_days_keyboard`, `_get_days_word`, `get_extend_subscription_keyboard`, `get_add_traffic_keyboard`, `get_change_devices_keyboard`, `get_confirm_change_devices_keyboard`, `get_reset_traffic_confirm_keyboard`, `get_manage_countries_keyboard`, `get_device_selection_keyboard`, `get_connection_guide_keyboard`, `get_app_selection_keyboard`, `get_specific_app_keyboard`, `get_extend_subscription_keyboard_with_prices`, `get_cryptobot_payment_keyboard`, `get_devices_management_keyboard`, `get_updated_subscription_settings_keyboard`, `get_device_reset_confirm_keyboard`, `get_device_management_help_keyboard`, `get_ticket_cancel_keyboard`, `get_my_tickets_keyboard`, `get_ticket_view_keyboard`, `get_ticket_reply_cancel_keyboard`, `get_admin_tickets_keyboard`, `get_admin_ticket_view_keyboard`, `get_admin_ticket_reply_cancel_keyboard`
- `app/keyboards/reply.py` — Python-модуль
Классы: нет
Функции: `get_main_reply_keyboard`, `get_admin_reply_keyboard`, `get_cancel_keyboard`, `get_confirmation_reply_keyboard`, `get_skip_keyboard`, `remove_keyboard`, `get_contact_keyboard`, `get_location_keyboard`
@@ -726,6 +735,9 @@
- `tests/services/test_payment_service_cryptobot.py` — Тесты сценариев CryptoBot в PaymentService.
Классы: `DummySession` (2 методов), `DummyLocalPayment` (1 методов), `StubCryptoBotService` (1 методов)
Функции: `anyio_backend`, `_make_service`
- `tests/services/test_payment_service_heleket.py` — Тесты сценариев Heleket в PaymentService.
Классы: `DummySession` (2 методов), `DummyLocalPayment` (1 методов), `StubHeleketService` (1 методов)
Функции: `anyio_backend`, `_make_service`
- `tests/services/test_payment_service_mulenpay.py` — Тесты для сценариев MulenPay в PaymentService.
Классы: `DummySession`, `DummyLocalPayment` (1 методов), `StubMulenPayService` (1 методов)
Функции: `anyio_backend`, `_make_service`
-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
+86 -5
View File
@@ -15,8 +15,10 @@ from app.services.maintenance_service import maintenance_service
from app.services.payment_service import PaymentService
from app.services.version_service import version_service
from app.external.webhook_server import WebhookServer
from app.external.heleket_webhook import start_heleket_webhook_server
from app.external.yookassa_webhook import start_yookassa_webhook_server
from app.external.pal24_webhook import start_pal24_webhook_server, Pal24WebhookServer
from app.external.wata_webhook import start_wata_webhook_server
from app.database.universal_migration import run_universal_migration
from app.services.backup_service import backup_service
from app.services.reporting_service import reporting_service
@@ -72,6 +74,8 @@ async def main():
webhook_server = None
yookassa_server_task = None
wata_server_task = None
heleket_server_task = None
pal24_server: Pal24WebhookServer | None = None
monitoring_task = None
maintenance_task = None
@@ -246,7 +250,7 @@ async def main():
if settings.TRIBUTE_ENABLED:
enabled_services.append("Tribute")
if settings.is_mulenpay_enabled():
enabled_services.append("Mulen Pay")
enabled_services.append(settings.get_mulenpay_display_name())
if settings.is_cryptobot_enabled():
enabled_services.append("CryptoBot")
@@ -255,7 +259,9 @@ async def main():
stage.log(f"Активированы: {', '.join(enabled_services)}")
stage.success("Webhook сервера запущены")
else:
stage.skip("Tribute, Mulen Pay и CryptoBot отключены")
stage.skip(
f"Tribute, {settings.get_mulenpay_display_name()} и CryptoBot отключены"
)
async with timeline.stage(
"YooKassa webhook",
@@ -285,6 +291,36 @@ async def main():
else:
stage.skip("PayPalych отключен настройками")
async with timeline.stage(
"WATA webhook",
"💳",
success_message="WATA webhook запущен",
) as stage:
if settings.is_wata_enabled():
wata_server_task = asyncio.create_task(
start_wata_webhook_server(payment_service)
)
stage.log(
f"Endpoint: {settings.WEBHOOK_URL}:{settings.WATA_WEBHOOK_PORT}{settings.WATA_WEBHOOK_PATH}"
)
else:
stage.skip("WATA отключен настройками")
async with timeline.stage(
"Heleket webhook",
"🪙",
success_message="Heleket webhook запущен",
) as stage:
if settings.is_heleket_enabled():
heleket_server_task = asyncio.create_task(
start_heleket_webhook_server(payment_service)
)
stage.log(
f"Endpoint: {settings.WEBHOOK_URL}:{settings.HELEKET_WEBHOOK_PORT}{settings.HELEKET_WEBHOOK_PATH}"
)
else:
stage.skip("Heleket отключен настройками")
async with timeline.stage(
"Служба мониторинга",
"📈",
@@ -355,7 +391,8 @@ async def main():
)
if settings.is_mulenpay_enabled():
webhook_lines.append(
f"Mulen Pay: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.MULENPAY_WEBHOOK_PATH}"
f"{settings.get_mulenpay_display_name()}: "
f"{settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.MULENPAY_WEBHOOK_PATH}"
)
if settings.is_cryptobot_enabled():
webhook_lines.append(
@@ -369,6 +406,10 @@ async def main():
webhook_lines.append(
f"PayPalych: {settings.WEBHOOK_URL}:{settings.PAL24_WEBHOOK_PORT}{settings.PAL24_WEBHOOK_PATH}"
)
if settings.is_wata_enabled():
webhook_lines.append(
f"WATA: {settings.WEBHOOK_URL}:{settings.WATA_WEBHOOK_PORT}{settings.WATA_WEBHOOK_PATH}"
)
timeline.log_section(
"Активные webhook endpoints",
@@ -399,7 +440,31 @@ async def main():
yookassa_server_task = asyncio.create_task(
start_yookassa_webhook_server(payment_service)
)
if wata_server_task and wata_server_task.done():
exception = wata_server_task.exception()
if exception:
logger.error(f"WATA webhook сервер завершился с ошибкой: {exception}")
logger.info("🔄 Перезапуск WATA webhook сервера...")
if settings.is_wata_enabled():
wata_server_task = asyncio.create_task(
start_wata_webhook_server(payment_service)
)
else:
wata_server_task = None
if heleket_server_task and heleket_server_task.done():
exception = heleket_server_task.exception()
if exception:
logger.error(f"Heleket webhook сервер завершился с ошибкой: {exception}")
logger.info("🔄 Перезапуск Heleket webhook сервера...")
if settings.is_heleket_enabled():
heleket_server_task = asyncio.create_task(
start_heleket_webhook_server(payment_service)
)
else:
heleket_server_task = None
if monitoring_task.done():
exception = monitoring_task.exception()
if exception:
@@ -446,7 +511,23 @@ async def main():
await yookassa_server_task
except asyncio.CancelledError:
pass
if wata_server_task and not wata_server_task.done():
logger.info("ℹ️ Остановка WATA webhook сервера...")
wata_server_task.cancel()
try:
await wata_server_task
except asyncio.CancelledError:
pass
if heleket_server_task and not heleket_server_task.done():
logger.info("ℹ️ Остановка Heleket webhook сервера...")
heleket_server_task.cancel()
try:
await heleket_server_task
except asyncio.CancelledError:
pass
if monitoring_task and not monitoring_task.done():
logger.info("ℹ️ Остановка службы мониторинга...")
monitoring_service.stop_monitoring()
+78 -7
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")
@@ -0,0 +1,158 @@
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
import pytest
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
from app.services.payment_service import PaymentService # noqa: E402
from app.database.crud import heleket as heleket_crud # noqa: E402
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
class DummySession:
def __init__(self) -> None:
self.added_objects: list[Any] = []
async def commit(self) -> None: # pragma: no cover - behaviour is mocked in tests
return None
async def refresh(self, obj: Any) -> None: # pragma: no cover
return None
def add(self, obj: Any) -> None: # pragma: no cover
self.added_objects.append(obj)
class DummyLocalPayment:
def __init__(self, payment_id: int = 123) -> None:
self.id = payment_id
self.created_at = datetime(2024, 1, 1, 12, 0, 0)
class StubHeleketService:
def __init__(self, response: Optional[Dict[str, Any]]) -> None:
self.response = response
self.calls: list[Dict[str, Any]] = []
async def create_payment(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
self.calls.append(payload)
return self.response
def _make_service(stub: Optional[StubHeleketService]) -> PaymentService:
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
service.bot = None
service.heleket_service = stub
service.yookassa_service = None
service.stars_service = None
service.cryptobot_service = None
service.mulenpay_service = None
service.pal24_service = None
service.wata_service = None
return service
@pytest.mark.anyio("asyncio")
async def test_create_heleket_payment_success(monkeypatch: pytest.MonkeyPatch) -> None:
response = {
"state": 0,
"result": {
"uuid": "heleket-uuid",
"order_id": "order-123",
"url": "https://heleket/pay",
"status": "check",
"payer_amount": "12.50",
"payer_currency": "USDT",
"discount_percent": -5,
"payer_amount_exchange_rate": "0.0125",
"expired_at": 1750000000,
},
}
stub = StubHeleketService(response)
service = _make_service(stub)
db = DummySession()
captured_args: Dict[str, Any] = {}
async def fake_create_heleket_payment(**kwargs: Any) -> DummyLocalPayment:
captured_args.update(kwargs)
return DummyLocalPayment(payment_id=555)
monkeypatch.setattr(
heleket_crud,
"create_heleket_payment",
fake_create_heleket_payment,
raising=False,
)
result = await service.create_heleket_payment(
db=db,
user_id=42,
amount_kopeks=15000,
description="Пополнение",
language="ru",
)
assert result is not None
assert result["local_payment_id"] == 555
assert result["uuid"] == "heleket-uuid"
assert result["order_id"] == "order-123"
assert result["payment_url"] == "https://heleket/pay"
assert stub.calls and stub.calls[0]["amount"] == "150.00"
assert captured_args["uuid"] == "heleket-uuid"
assert captured_args["user_id"] == 42
@pytest.mark.anyio("asyncio")
async def test_create_heleket_payment_returns_none_without_service() -> None:
service = _make_service(None)
db = DummySession()
result = await service.create_heleket_payment(
db=db,
user_id=1,
amount_kopeks=10000,
description="Пополнение",
)
assert result is None
@pytest.mark.anyio("asyncio")
async def test_create_heleket_payment_handles_empty_response(monkeypatch: pytest.MonkeyPatch) -> None:
stub = StubHeleketService(response=None)
service = _make_service(stub)
db = DummySession()
called = False
async def fake_create_heleket_payment(**kwargs: Any) -> DummyLocalPayment:
nonlocal called
called = True
return DummyLocalPayment()
monkeypatch.setattr(
heleket_crud,
"create_heleket_payment",
fake_create_heleket_payment,
raising=False,
)
result = await service.create_heleket_payment(
db=db,
user_id=1,
amount_kopeks=20000,
description="Пополнение",
)
assert result is None
assert called is False
@@ -11,6 +11,7 @@ if str(ROOT_DIR) not in sys.path:
from app.services.payment import ( # noqa: E402
CryptoBotPaymentMixin,
HeleketPaymentMixin,
MulenPayPaymentMixin,
Pal24PaymentMixin,
PaymentCommonMixin,
@@ -30,6 +31,7 @@ def test_payment_service_mro_contains_all_mixins() -> None:
YooKassaPaymentMixin,
TributePaymentMixin,
CryptoBotPaymentMixin,
HeleketPaymentMixin,
MulenPayPaymentMixin,
Pal24PaymentMixin,
WataPaymentMixin,
@@ -46,6 +48,7 @@ def test_payment_service_mro_contains_all_mixins() -> None:
"create_yookassa_payment",
"create_tribute_payment",
"create_cryptobot_payment",
"create_heleket_payment",
"create_mulenpay_payment",
"create_pal24_payment",
"create_wata_payment",
@@ -50,6 +50,7 @@ def _make_service(stub: Optional[StubMulenPayService]) -> PaymentService:
service.yookassa_service = None
service.stars_service = None
service.cryptobot_service = None
service.heleket_service = None
return service
+35 -1
View File
@@ -61,6 +61,7 @@ def _make_service(stub: Optional[StubPal24Service]) -> PaymentService:
service.yookassa_service = None
service.cryptobot_service = None
service.stars_service = None
service.heleket_service = None
return service
@@ -101,13 +102,46 @@ async def test_create_pal24_payment_success(monkeypatch: pytest.MonkeyPatch) ->
assert result is not None
assert result["local_payment_id"] == 321
assert result["bill_id"] == "BILL-1"
assert result["payment_method"] == "CARD"
assert result["payment_method"] == "card"
assert result["link_url"] == "https://pal24/sbp"
assert result["card_url"] == "https://pal24/card"
assert stub.calls and stub.calls[0]["payment_method"] == "BANK_CARD"
assert stub.calls and stub.calls[0]["amount_kopeks"] == 50000
assert "links" in captured_args["metadata"]
@pytest.mark.anyio("asyncio")
async def test_create_pal24_payment_default_method(monkeypatch: pytest.MonkeyPatch) -> None:
stub = StubPal24Service()
service = _make_service(stub)
db = DummySession()
async def fake_create_pal24_payment(*args: Any, **kwargs: Any) -> DummyLocalPayment:
return DummyLocalPayment(payment_id=111)
monkeypatch.setattr(
payment_service_module,
"create_pal24_payment",
fake_create_pal24_payment,
raising=False,
)
monkeypatch.setattr(settings, "PAL24_MIN_AMOUNT_KOPEKS", 1000, raising=False)
monkeypatch.setattr(settings, "PAL24_MAX_AMOUNT_KOPEKS", 1_000_000, raising=False)
result = await service.create_pal24_payment(
db=db,
user_id=42,
amount_kopeks=10_000,
description="Пополнение",
language="ru",
)
assert result is not None
assert stub.calls and stub.calls[0]["payment_method"] == "SBP"
assert result["payment_method"] == "sbp"
@pytest.mark.anyio("asyncio")
async def test_create_pal24_payment_limits_and_configuration(monkeypatch: pytest.MonkeyPatch) -> None:
stub = StubPal24Service()
@@ -28,6 +28,7 @@ def _make_service() -> PaymentService:
service.pal24_service = None
service.cryptobot_service = None
service.stars_service = None
service.heleket_service = None
return service
+230 -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
@@ -56,9 +74,26 @@ def _make_service(stub: Optional[StubWataService]) -> PaymentService:
service.yookassa_service = None
service.stars_service = None
service.cryptobot_service = None
service.heleket_service = None
return service
def test_wata_service_format_datetime_accepts_naive_utc() -> None:
value = datetime(2024, 5, 20, 12, 30, 0)
formatted = WataService._format_datetime(value)
assert formatted == "2024-05-20T12:30:00Z"
def test_wata_service_parse_datetime_returns_naive_utc() -> None:
parsed = WataService._parse_datetime("2024-05-20T12:30:00Z")
assert parsed == datetime(2024, 5, 20, 12, 30, 0)
assert parsed.tzinfo is None
parsed_with_offset = WataService._parse_datetime("2024-05-20T15:30:00+03:00")
assert parsed_with_offset == datetime(2024, 5, 20, 12, 30, 0)
assert parsed_with_offset.tzinfo is None
@pytest.mark.anyio("asyncio")
async def test_create_wata_payment_success(monkeypatch: pytest.MonkeyPatch) -> None:
response = {
@@ -142,3 +177,197 @@ async def test_create_wata_payment_returns_none_without_service() -> None:
description="Пополнение",
)
assert result is None
@pytest.mark.anyio("asyncio")
async def test_process_wata_webhook_updates_status(monkeypatch: pytest.MonkeyPatch) -> None:
service = _make_service(None)
db = DummySession()
payment = DummyWataPayment()
update_kwargs: Dict[str, Any] = {}
link_lookup_called = False
async def fake_get_by_order_id(db_arg: Any, order_id: str) -> DummyWataPayment:
assert db_arg is db
assert order_id == payment.order_id
return payment
async def fake_get_by_link_id(*_: Any, **__: Any) -> Optional[DummyWataPayment]:
nonlocal link_lookup_called
link_lookup_called = True
return None
async def fake_update_status(
db_arg: Any,
*,
payment: DummyWataPayment,
**kwargs: Any,
) -> DummyWataPayment:
assert db_arg is db
update_kwargs.update(kwargs)
if "status" in kwargs:
payment.status = kwargs["status"]
if "is_paid" in kwargs:
payment.is_paid = kwargs["is_paid"]
if "metadata" in kwargs:
payment.metadata_json = kwargs["metadata"]
if "callback_payload" in kwargs:
payment.callback_payload = kwargs["callback_payload"]
if "terminal_public_id" in kwargs:
payment.terminal_public_id = kwargs["terminal_public_id"]
return payment
monkeypatch.setattr(
payment_service_module,
"get_wata_payment_by_order_id",
fake_get_by_order_id,
raising=False,
)
monkeypatch.setattr(
payment_service_module,
"get_wata_payment_by_link_id",
fake_get_by_link_id,
raising=False,
)
monkeypatch.setattr(
payment_service_module,
"update_wata_payment_status",
fake_update_status,
raising=False,
)
payload = {
"orderId": payment.order_id,
"transactionStatus": "Declined",
"terminalPublicId": "terminal-001",
}
processed = await service.process_wata_webhook(db, payload)
assert processed is True
assert link_lookup_called is False
assert payment.status == "Declined"
assert payment.is_paid is False
assert payment.metadata_json.get("last_webhook") == payload
assert payment.callback_payload == payload
assert payment.terminal_public_id == "terminal-001"
assert update_kwargs["status"] == "Declined"
assert update_kwargs["is_paid"] is False
@pytest.mark.anyio("asyncio")
async def test_process_wata_webhook_finalizes_paid(monkeypatch: pytest.MonkeyPatch) -> None:
service = _make_service(None)
db = DummySession()
payment = DummyWataPayment()
finalize_called = False
async def fake_get_by_order_id(*_: Any, **__: Any) -> DummyWataPayment:
return payment
async def fake_update_status(
db_arg: Any,
*,
payment: DummyWataPayment,
**kwargs: Any,
) -> DummyWataPayment:
if "metadata" in kwargs:
payment.metadata_json = kwargs["metadata"]
if "callback_payload" in kwargs:
payment.callback_payload = kwargs["callback_payload"]
if "status" in kwargs:
payment.status = kwargs["status"]
return payment
async def fake_finalize(
db_arg: Any,
payment_arg: DummyWataPayment,
payload_arg: Dict[str, Any],
) -> DummyWataPayment:
nonlocal finalize_called
finalize_called = True
payment_arg.is_paid = True
return payment_arg
monkeypatch.setattr(
payment_service_module,
"get_wata_payment_by_order_id",
fake_get_by_order_id,
raising=False,
)
monkeypatch.setattr(
payment_service_module,
"get_wata_payment_by_link_id",
lambda *args, **kwargs: None,
raising=False,
)
monkeypatch.setattr(
payment_service_module,
"update_wata_payment_status",
fake_update_status,
raising=False,
)
monkeypatch.setattr(
service,
"_finalize_wata_payment",
fake_finalize,
raising=False,
)
payload = {
"orderId": payment.order_id,
"transactionStatus": "Paid",
"transactionId": "tx-001",
}
processed = await service.process_wata_webhook(db, payload)
assert processed is True
assert finalize_called is True
assert payment.is_paid is True
assert payment.metadata_json.get("last_webhook") == payload
@pytest.mark.anyio("asyncio")
async def test_process_wata_webhook_returns_false_when_payment_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = _make_service(None)
db = DummySession()
async def fake_get_by_order_id(*_: Any, **__: Any) -> None:
return None
async def fake_get_by_link_id(*_: Any, **__: Any) -> None:
return None
async def fail_update(*_: Any, **__: Any) -> None:
pytest.fail("update_wata_payment_status should not be called")
monkeypatch.setattr(
payment_service_module,
"get_wata_payment_by_order_id",
fake_get_by_order_id,
raising=False,
)
monkeypatch.setattr(
payment_service_module,
"get_wata_payment_by_link_id",
fake_get_by_link_id,
raising=False,
)
monkeypatch.setattr(
payment_service_module,
"update_wata_payment_status",
fail_update,
raising=False,
)
payload = {
"orderId": "missing-order",
"transactionStatus": "Paid",
}
processed = await service.process_wata_webhook(db, payload)
assert processed is False
+321 -3
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace, ModuleType
from typing import Any, Dict
@@ -16,6 +18,7 @@ if str(ROOT_DIR) not in sys.path:
import app.services.payment_service as payment_service_module # noqa: E402
from app.services.payment_service import PaymentService # noqa: E402
from app.database.models import PaymentMethod # noqa: E402
from app.config import settings # noqa: E402
@@ -54,6 +57,7 @@ def _make_service(bot: DummyBot) -> PaymentService:
service.mulenpay_service = None
service.pal24_service = None
service.cryptobot_service = None
service.heleket_service = None
return service
@@ -63,7 +67,10 @@ def anyio_backend() -> str:
@pytest.mark.anyio("asyncio")
async def test_process_mulenpay_callback_success(monkeypatch: pytest.MonkeyPatch) -> None:
@pytest.mark.parametrize("status_field", ["payment_status", "status", "paymentStatus"])
async def test_process_mulenpay_callback_success(
monkeypatch: pytest.MonkeyPatch, status_field: str
) -> None:
bot = DummyBot()
service = _make_service(bot)
fake_session = FakeSession()
@@ -142,10 +149,10 @@ async def test_process_mulenpay_callback_success(monkeypatch: pytest.MonkeyPatch
payload = {
"uuid": "mulen_uuid",
"payment_status": "success",
"id": 123,
"amount": "50.00",
}
payload[status_field] = "success"
result = await service.process_mulenpay_callback(fake_session, payload)
@@ -252,6 +259,146 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch
assert admin_calls
@pytest.mark.anyio("asyncio")
async def test_process_heleket_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None:
bot = DummyBot()
service = _make_service(bot)
fake_session = FakeSession()
payment = SimpleNamespace(
uuid="heleket-uuid",
order_id="heleket-order",
user_id=77,
amount="150.00",
amount_float=150.0,
amount_kopeks=15000,
status="check",
payer_amount=None,
payer_currency=None,
exchange_rate=None,
discount_percent=None,
payment_url=None,
transaction_id=None,
)
async def fake_get_by_uuid(db, uuid):
return payment if uuid == payment.uuid else None
async def fake_get_by_order(db, order_id):
return payment if order_id == payment.order_id else None
async def fake_update(
db,
uuid,
*,
status=None,
payer_amount=None,
payer_currency=None,
exchange_rate=None,
discount_percent=None,
paid_at=None,
payment_url=None,
metadata=None,
):
if status is not None:
payment.status = status
if payer_amount is not None:
payment.payer_amount = payer_amount
if payer_currency is not None:
payment.payer_currency = payer_currency
if exchange_rate is not None:
payment.exchange_rate = exchange_rate
if discount_percent is not None:
payment.discount_percent = discount_percent
if payment_url is not None:
payment.payment_url = payment_url
payment.paid_at = paid_at
if metadata:
payment.metadata_json = metadata
return payment
async def fake_link(db, uuid, transaction_id):
payment.transaction_id = transaction_id
return payment
heleket_module = ModuleType("app.database.crud.heleket")
heleket_module.get_heleket_payment_by_uuid = fake_get_by_uuid
heleket_module.get_heleket_payment_by_order_id = fake_get_by_order
heleket_module.update_heleket_payment = fake_update
heleket_module.link_heleket_payment_to_transaction = fake_link
monkeypatch.setitem(sys.modules, "app.database.crud.heleket", heleket_module)
transactions: list[Dict[str, Any]] = []
async def fake_create_transaction(db, **kwargs):
transactions.append(kwargs)
return SimpleNamespace(id=321, **kwargs)
monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction)
user = SimpleNamespace(
id=77,
telegram_id=7700,
balance_kopeks=0,
has_made_first_topup=False,
promo_group=None,
subscription=None,
referred_by_id=None,
referrer=None,
language="ru",
)
async def fake_get_user(db, user_id):
return user if user_id == user.id else None
monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user)
monkeypatch.setattr("app.services.payment.heleket.format_referrer_info", lambda u: "")
monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}", raising=False)
referral_stub = SimpleNamespace(process_referral_topup=AsyncMock())
monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_stub)
admin_calls: list[Any] = []
class DummyAdminService:
def __init__(self, bot):
self.bot = bot
async def send_balance_topup_notification(self, *args, **kwargs):
admin_calls.append((args, kwargs))
monkeypatch.setitem(
sys.modules,
"app.services.admin_notification_service",
SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)),
)
service.build_topup_success_keyboard = AsyncMock(return_value=None)
payload = {
"uuid": "heleket-uuid",
"status": "paid",
"payer_amount": "2.50",
"payer_currency": "USDT",
"discount_percent": -5,
"payer_amount_exchange_rate": "0.0166",
"paid_at": "2024-01-02T12:00:00Z",
"url": "https://pay.example",
}
result = await service.process_heleket_webhook(fake_session, payload)
assert result is True
assert transactions and transactions[0]["payment_method"] == PaymentMethod.HELEKET
assert payment.transaction_id == 321
assert user.balance_kopeks == 15000
assert user.has_made_first_topup is True
assert fake_session.commits >= 1
assert bot.sent_messages
assert admin_calls
referral_stub.process_referral_topup.assert_awaited_once()
@pytest.mark.anyio("asyncio")
async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None:
bot = DummyBot()
@@ -372,6 +519,9 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -
transaction_id=None,
is_paid=False,
status="NEW",
metadata_json={},
payment_method=None,
paid_at=None,
)
async def fake_get_by_order(db, order_id):
@@ -419,6 +569,7 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -
subscription=None,
referred_by_id=None,
referrer=None,
language="ru",
)
async def fake_get_user(db, user_id):
@@ -439,7 +590,35 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -
async def send_balance_topup_notification(self, *args, **kwargs):
admin_calls.append((args, kwargs))
monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminServicePal(bot)))
monkeypatch.setitem(
sys.modules,
"app.services.admin_notification_service",
SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminServicePal(bot)),
)
user_cart_stub = SimpleNamespace(
user_cart_service=SimpleNamespace(has_user_cart=AsyncMock(return_value=True))
)
monkeypatch.setitem(sys.modules, "app.services.user_cart_service", user_cart_stub)
class DummyTypes:
class InlineKeyboardMarkup:
def __init__(self, inline_keyboard=None, **kwargs):
self.inline_keyboard = inline_keyboard or []
self.kwargs = kwargs
class InlineKeyboardButton:
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
monkeypatch.setitem(sys.modules, "aiogram", SimpleNamespace(types=DummyTypes))
monkeypatch.setitem(
sys.modules,
"app.localization.texts",
SimpleNamespace(get_texts=lambda language: SimpleNamespace(t=lambda key, default=None: default)),
)
service.build_topup_success_keyboard = AsyncMock(return_value=None)
payload = {
@@ -455,9 +634,148 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -
assert payment.transaction_id == 654
assert user.balance_kopeks == 5000
assert bot.sent_messages
saved_cart_message = bot.sent_messages[-1]
reply_markup = saved_cart_message["kwargs"].get("reply_markup")
assert reply_markup is not None
assert reply_markup.inline_keyboard[0][0].kwargs["callback_data"] == "subscription_resume_checkout"
assert admin_calls
@pytest.mark.anyio("asyncio")
async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.MonkeyPatch) -> None:
bot = DummyBot()
service = _make_service(bot)
class DummyPal24Service:
BILL_SUCCESS_STATES = {"SUCCESS", "OVERPAID"}
BILL_FAILED_STATES = {"FAIL"}
BILL_PENDING_STATES = {"NEW", "PROCESS", "UNDERPAID"}
async def get_bill_status(self, bill_id: str) -> Dict[str, Any]:
return {
"status": "SUCCESS",
"bill": {
"status": "SUCCESS",
"payments": [
{
"id": "trs-auto-1",
"status": "SUCCESS",
"method": "SBP",
"balance_amount": "50.00",
"balance_currency": "RUB",
}
],
},
}
service.pal24_service = DummyPal24Service()
fake_session = FakeSession()
payment = SimpleNamespace(
id=77,
bill_id="BILL-AUTO",
order_id="order-auto",
amount_kopeks=5000,
user_id=91,
transaction_id=None,
is_paid=False,
status="NEW",
metadata_json={},
payment_id=None,
payment_method=None,
paid_at=None,
)
async def fake_get_payment_by_id(db, local_id):
return payment
async def fake_update_payment(db, payment_obj, **kwargs):
for key, value in kwargs.items():
setattr(payment, key, value)
return payment
async def fake_link_payment(db, payment_obj, transaction_id):
payment.transaction_id = transaction_id
return payment
monkeypatch.setattr(payment_service_module, "get_pal24_payment_by_id", fake_get_payment_by_id)
monkeypatch.setattr(payment_service_module, "update_pal24_payment_status", fake_update_payment)
monkeypatch.setattr(payment_service_module, "link_pal24_payment_to_transaction", fake_link_payment)
transactions: list[Dict[str, Any]] = []
async def fake_create_transaction(db, **kwargs):
transactions.append(kwargs)
payment.transaction_id = 999
return SimpleNamespace(id=999, **kwargs)
monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction)
user = SimpleNamespace(
id=91,
telegram_id=9100,
balance_kopeks=0,
has_made_first_topup=False,
promo_group=None,
subscription=None,
referred_by_id=None,
referrer=None,
language="ru",
)
async def fake_get_user(db, user_id):
return user
monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user)
monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}", raising=False)
referral_stub = SimpleNamespace(process_referral_topup=AsyncMock())
monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_stub)
admin_notifications: list[Any] = []
class DummyAdminService:
def __init__(self, bot):
self.bot = bot
async def send_balance_topup_notification(self, *args, **kwargs):
admin_notifications.append((args, kwargs))
monkeypatch.setitem(
sys.modules,
"app.services.admin_notification_service",
SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)),
)
user_cart_stub = SimpleNamespace(
user_cart_service=SimpleNamespace(has_user_cart=AsyncMock(return_value=False))
)
monkeypatch.setitem(sys.modules, "app.services.user_cart_service", user_cart_stub)
class DummyTypes:
class InlineKeyboardMarkup:
def __init__(self, inline_keyboard=None, **kwargs):
self.inline_keyboard = inline_keyboard or []
self.kwargs = kwargs
class InlineKeyboardButton:
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
monkeypatch.setitem(sys.modules, "aiogram", SimpleNamespace(types=DummyTypes))
service.build_topup_success_keyboard = AsyncMock(return_value=None)
result = await service.get_pal24_payment_status(fake_session, payment.id)
assert result is not None
assert payment.transaction_id == 999
assert user.balance_kopeks == 5000
assert bot.sent_messages
assert admin_notifications
assert transactions and transactions[0]["user_id"] == 91
@pytest.mark.anyio("asyncio")
async def test_process_pal24_postback_payment_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
bot = DummyBot()
@@ -68,6 +68,7 @@ def _make_service(yookassa_service: Optional[StubYooKassaService]) -> PaymentSer
service.pal24_service = None
service.mulenpay_service = None
service.cryptobot_service = None
service.heleket_service = None
return service
+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'