Compare commits
148 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df9985802b | |||
| c66849db10 | |||
| 8a1da85f3e | |||
| 0335f40b47 | |||
| 8ac1183670 | |||
| bcc761f9d3 | |||
| 1eb4e18c17 | |||
| 056c13bc23 | |||
| 2bdb7643f8 | |||
| 8b8f1b91f3 | |||
| 5ed2f0c958 | |||
| 454dc9321b | |||
| de91d3282f | |||
| e0bedc8e78 | |||
| 4a48818bc3 | |||
| ff7388766c | |||
| 6b36dc4df1 | |||
| fe847e35f0 | |||
| 4c2cb63cf9 | |||
| d3c994083e | |||
| cce3b0c13b | |||
| 6c208581d9 | |||
| c307278231 | |||
| 9eab802000 | |||
| 13ea3768b5 | |||
| ed5a92ab96 | |||
| 48265f1cd4 | |||
| 3b9568fcc1 | |||
| 79cfcbcece | |||
| 6d5aceb4ca | |||
| 905dbcc779 | |||
| f33dfdf031 | |||
| 59cd74d307 | |||
| 90209ebef1 | |||
| ab43e74ab7 | |||
| 4244962337 | |||
| ba79d03e38 | |||
| 8e4e2ddd1a | |||
| 38853cdd5a | |||
| f837c0c244 | |||
| 8a7b9cc651 | |||
| 3bf31055e7 | |||
| 3c5bf4fa22 | |||
| 4990ddf9e4 | |||
| de00612965 | |||
| 43f5629c8c | |||
| 448799a3ed | |||
| b2b5f104b5 | |||
| 5f71eaa926 | |||
| 54155b5649 | |||
| 8dc778654d | |||
| 5175cccab6 | |||
| 39ae095d9b | |||
| db3ac254ba | |||
| bff9ebf078 | |||
| 42ddadec5b | |||
| c6c1599e14 | |||
| 77b2d645c5 | |||
| 3875335cd7 | |||
| 0a53b85b8a | |||
| 82b6a8bf70 | |||
| 2a72deadd6 | |||
| afefcc9c07 | |||
| 82c79c1306 | |||
| 0e1296e0ea | |||
| 9dd6b54c6e | |||
| 8a5710aff3 | |||
| ce36fba54f | |||
| f601bedb48 | |||
| 801921ff74 | |||
| 67da390371 | |||
| d400cd7b49 | |||
| bf0ba22790 | |||
| f82a713110 | |||
| fedcf2569a | |||
| 1882909b3e | |||
| 226d3f2766 | |||
| 6982d27378 | |||
| 27ef75214e | |||
| 13dba5a303 | |||
| d7f91c8358 | |||
| ee9f0b7382 | |||
| e16eba10d9 | |||
| 1771cc4d13 | |||
| 3bda0a2001 | |||
| 877b1cde11 | |||
| 5faf7015ac | |||
| 55f386d7e8 | |||
| e6a310dc32 | |||
| ccd8f86e96 | |||
| b6d4373933 | |||
| 266620904d | |||
| 479af5741a | |||
| 79c110ff41 | |||
| 3d78974af7 | |||
| 57c5c679ee | |||
| 2781236011 | |||
| 4a002b7db1 | |||
| 8b2668087b | |||
| 3ec9e71de7 | |||
| da7a9cc3c5 | |||
| b5471b7720 | |||
| 6a4ce3dd38 | |||
| df086b09c7 | |||
| 01132a7bc7 | |||
| 182667ecb8 | |||
| c8f4cca340 | |||
| ac9fcd8d30 | |||
| c08c903e8f | |||
| 69bb399b63 | |||
| 463c5385d6 | |||
| 41dfe39518 | |||
| f44c9b6903 | |||
| 1f35d45dc6 | |||
| ef8f6625bf | |||
| 7101555da0 | |||
| e15b18fb41 | |||
| b80eeea089 | |||
| cb61014d9c | |||
| 5b3353433b | |||
| f1d45343e9 | |||
| b40a812f3a | |||
| ac00434645 | |||
| 964c33c772 | |||
| ec875837a4 | |||
| db0e169a41 | |||
| a6dcf26c20 | |||
| 5081debee7 | |||
| 0ceff44c30 | |||
| 136f29c1eb | |||
| d0eab3f7aa | |||
| d7ad9d7033 | |||
| 1a87d438fe | |||
| aec01ce0d4 | |||
| a33a893d1a | |||
| 37c9b931ca | |||
| 22e7f150b3 | |||
| 688882237f | |||
| c14d7ab0af | |||
| e12cc9f248 | |||
| 6ff0460607 | |||
| 8d5a002996 | |||
| 31bdf8a0ae | |||
| 1364158e6c | |||
| d7931a2afa | |||
| b032c8f354 | |||
| 1306c24fa3 | |||
| 38deb70f81 |
+9
-5
@@ -13,6 +13,11 @@ SUPPORT_USERNAME=@support
|
||||
# Имя пользователя бота (опционально, автоопределяется)
|
||||
# BOT_USERNAME=
|
||||
|
||||
# ===== SOCKS5 ПРОКСИ =====
|
||||
# URL SOCKS5 прокси-сервера для маршрутизации трафика бота к Telegram API
|
||||
# Формат: socks5://user:password@host:port или socks5://host:port
|
||||
# PROXY_URL=socks5://127.0.0.1:1080
|
||||
|
||||
# ===== СИСТЕМА ПОДДЕРЖКИ =====
|
||||
# Включить меню поддержки в интерфейсе
|
||||
SUPPORT_MENU_ENABLED=true
|
||||
@@ -194,6 +199,9 @@ REMNAWAVE_WEBHOOK_PATH=/remnawave-webhook
|
||||
# Сгенерируйте: openssl rand -hex 32
|
||||
# ВАЖНО: этот же секрет указывается в панели Remnawave при создании вебхука
|
||||
REMNAWAVE_WEBHOOK_SECRET=
|
||||
# Уведомления администраторам о потере/восстановлении связи с нодами
|
||||
# false = не отправлять события node.connection_lost / node.connection_restored
|
||||
REMNAWAVE_WEBHOOK_NOTIFY_NODE_CONNECTION_STATUS=true
|
||||
|
||||
# ===== УВЕДОМЛЕНИЯ ОТ ВЕБХУКОВ (что получают пользователи) =====
|
||||
# Глобальный переключатель уведомлений пользователям от вебхуков
|
||||
@@ -491,16 +499,11 @@ YOOKASSA_WEBHOOK_PORT=8082
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
|
||||
YOOKASSA_MAX_AMOUNT_KOPEKS=1000000
|
||||
|
||||
# Быстрый выбор суммы пополнения через YooKassa
|
||||
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
|
||||
|
||||
# Рекуррентные платежи YooKassa (автосохранение карты для автоплатежей)
|
||||
YOOKASSA_RECURRENT_ENABLED=false
|
||||
# true = карта сохраняется обязательно, false = пользователь решает (чекбокс на стороне YooKassa)
|
||||
YOOKASSA_RECURRENT_REQUIRED=true
|
||||
|
||||
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
|
||||
DISABLE_TOPUP_BUTTONS=false
|
||||
# Отключить пополнение баланса через поддержку
|
||||
SUPPORT_TOPUP_ENABLED=true
|
||||
|
||||
@@ -519,6 +522,7 @@ NALOGO_STORAGE_PATH=./nalogo_tokens.json # Путь к файлу с токен
|
||||
NALOGO_QUEUE_CHECK_INTERVAL=300 # Интервал проверки очереди чеков (секунды)
|
||||
NALOGO_QUEUE_RECEIPT_DELAY=3 # Задержка между отправкой чеков (секунды)
|
||||
NALOGO_QUEUE_MAX_ATTEMPTS=10 # Максимум попыток отправки одного чека
|
||||
# NALOGO_PROXY_URL=socks5://127.0.0.1:1080 # SOCKS прокси для nalog.ru (если не задан — используется PROXY_URL)
|
||||
|
||||
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 850 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.34.0"
|
||||
".": "3.41.0"
|
||||
}
|
||||
|
||||
+193
@@ -1,5 +1,198 @@
|
||||
# Changelog
|
||||
|
||||
## [3.41.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.40.0...v3.41.0) (2026-03-22)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add subscription status to referral network graph nodes ([de91d32](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/de91d3282ffa15c0cec60c0d62871d39e7ee4c05))
|
||||
* add total subscription revenue to referral network stats ([2bdb764](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2bdb7643f8fd142e99caee0fe989348161377348))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add abs() to all remaining subscription payment sum queries ([1eb4e18](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1eb4e18c1776b2265a48e0b923a0ca4ee057d912))
|
||||
* add missing total_subscription_revenue_kopeks in scoped graph early return ([bcc761f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bcc761f9d3f673bd2b404adf817762058d8e0df4))
|
||||
* consider subscription status field in network graph ([454dc93](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/454dc9321bb9405c5ff0ff559ae4ced15533f3af))
|
||||
* superadmin role managed exclusively via env config ([e0bedc8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e0bedc8e780a2f91509517110639773e90bb6125))
|
||||
* treat expired and limited subscription statuses as inactive in referral network graph ([5ed2f0c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5ed2f0c95842a43ab57220dc05ca346748bd6adb))
|
||||
* use abs() for subscription payment amounts in referral network ([056c13b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/056c13bc23e6737f44bbcb802a66b643349f75a9))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* extract _compute_subscription_status shared helper ([8b8f1b9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8b8f1b91f37f829528f785a40e3a9cb98c85e043))
|
||||
|
||||
## [3.40.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.39.0...v3.40.0) (2026-03-22)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* allow inactive tariffs for trial subscription activation ([cce3b0c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cce3b0c13bcbf0b567bd4dcf2670973382e7cab0))
|
||||
* custom broadcast buttons and fix home button to use bot menu ([13ea376](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/13ea3768b516337c4e0320120bc60a9acb27a16b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* accept stale Telegram initData to prevent MiniApp auth failures ([4c2cb63](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c2cb63cf9f71fb392c3723a99e88ca3d02b127d))
|
||||
* daily subscription pause not persisting in cabinet and miniapp ([d3c9940](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d3c994083e3b054d02d4911172968c914724d051))
|
||||
* handle spurious user.deleted webhooks — preserve active subscriptions and prevent orphaned panel users ([9eab802](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9eab80200006e576967204b52f90bf9866875917))
|
||||
* prevent MESSAGE_TOO_LONG in promo groups list ([c307278](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c30727823169159b4b6b61f54897b209ced8dfd2))
|
||||
* referral system — self-referral protection, race condition fix, deleted user re-registration ([ed5a92a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ed5a92ab966dac54c15217050eae87f4b05eed62))
|
||||
* sanitize email dots in RemnaWave username generation ([6c20858](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6c208581d936f5ab7d6b978baafd50881b8ce9f1))
|
||||
* send DISABLED instead of EXPIRED status to RemnaWave API ([79cfcbc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79cfcbcece3938f2daa83206f96ec1bffd0857e0))
|
||||
|
||||
## [3.39.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.38.0...v3.39.0) (2026-03-21)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add NaloGO fiscal receipts for code-only gift purchases ([90209eb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/90209ebef1a872665e622124a1898d52eff398e7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add NaloGO fiscal receipt creation for landing page purchases ([4244962](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/424496233773b4cee4e389a1172e95208b3afeaf))
|
||||
* manual admin top-ups missing from sales statistics ([ab43e74](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ab43e74ab7484f8d3517f91e366ea395e1944b99))
|
||||
* skip non-JSON payload rows in cryptobot payment index and query ([ba79d03](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba79d03e389afed972296fe2bc05104aa6b883f3))
|
||||
|
||||
## [3.38.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.37.0...v3.38.0) (2026-03-21)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add SOCKS proxy support for nalogo (tax service) module ([3c5bf4f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3c5bf4fa22d1cdf144269f4e6ab32a4523c8f1f3))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add diagnostic payload logging in create_user error path ([4990ddf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4990ddf9e46495b65fc3638ea8d6bed0cbe6b857))
|
||||
* retry Remnawave API calls without externalSquadUuid on A039 FK violation ([de00612](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/de006129657ce3dac2b1f2fc0ab1b91e23e44241))
|
||||
* sanitize proxy credentials in all nalogo error paths ([3bf3105](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3bf31055e71ff64e6a6d94486bb7f7775ac7dc91))
|
||||
|
||||
## [3.37.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.36.1...v3.37.0) (2026-03-21)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add SOCKS5 proxy support for Telegram API traffic ([82b6a8b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/82b6a8bf707736541b58637b0fc9a84b0c403a6c))
|
||||
* broadcast caption validation + landing daily created stats ([d400cd7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d400cd7b49cb18edf8545a5af54009561610218a))
|
||||
* expose cabinet_email/password vars in subscription delivered template admin UI ([f82a713](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f82a713110c90494e41f2de9910f5dc60a06962e))
|
||||
* include cabinet credentials in subscription delivered email ([fedcf25](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fedcf2569a153ac40699dd5b402208ac86db0fc3))
|
||||
* show both bot and cabinet referral links everywhere ([67da390](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/67da3903715e97c0d84d0018efdd74b085ee8720))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* default payment_method to BALANCE for bot subscription payments ([226d3f2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/226d3f2766bb0842e5a4aefb1458a9e40389108e))
|
||||
* derive income_today from revenue_chart to ensure consistency ([1882909](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1882909b3e60f4959921ae085e909bc91c0756b3))
|
||||
* include landing page revenue in dashboard statistics ([c6c1599](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c6c1599e14a8c7033dfa399ff3f2c3ee6d156598))
|
||||
* include SUBSCRIPTION_PAYMENT in dashboard revenue ([5f71eaa](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f71eaa92604faa2c6928994bd32a9c6c8ae7ae5))
|
||||
* include SUBSCRIPTION_PAYMENT in dashboard revenue calculations ([13dba5a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/13dba5a303604f8d39e85d1382a4d36225f957ac))
|
||||
* include SUBSCRIPTION_PAYMENT in dashboard revenue calculations ([d7f91c8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d7f91c83584384ef5d0fc90a28debc722b2db79f))
|
||||
* include SUBSCRIPTION_PAYMENT in recent payments today/week totals ([6982d27](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6982d27378d0dbe5c7b37d1491e6c9d0461dffc8))
|
||||
* include SUBSCRIPTION_PAYMENT in sales summary and deposits stats ([27ef752](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/27ef75214e73211a68d541f5375ca14275ea86e9))
|
||||
* increase landing purchase rate limit from 5 to 30 req/min ([801921f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/801921ff74107daccc17e49a0335c7b133268280))
|
||||
* narrow exception handling and fix session leak in gift.py ([3875335](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3875335cd7083115043d22f8726f0d33a225cd10))
|
||||
* prevent bootstrap from reactivating revoked superadmin roles ([9dd6b54](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9dd6b54c6e963d2a842d4ac48c9a463014306ed7))
|
||||
* prevent double balance credit on concurrent Platega webhooks ([0e1296e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0e1296e0ea291ee661aa82508d5eb8049ce23cac))
|
||||
* prevent double-payment TOCTOU race in all payment providers ([82c79c1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/82c79c130601736cd149350f761dcceacfb7c2db))
|
||||
* resolve EmailService stale SMTP config causing NoneType crash on from_email ([2a72dea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2a72deadd6ac3baea15a8d4ee414d763c16690b0))
|
||||
* resolve remaining TOCTOU issues in RioPay, SeverPay and restore paid_at ([afefcc9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/afefcc9c07a80ecf65871bc2d0fbcfe13ffc24fd))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* centralize Bot instantiation via create_bot() factory ([0a53b85](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0a53b85b8a3193f67e148c0f2bde246cc92f010c))
|
||||
|
||||
## [3.36.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.36.0...v3.36.1) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* handle duplicate admin roles in RBAC bootstrap ([877b1cd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/877b1cde11c462c0cf5692119ca09944476a2fb6))
|
||||
* make migration 0042 idempotent for retry_count column ([5faf701](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5faf7015ac043f44366fd2a5e00be19eab76b945))
|
||||
|
||||
## [3.36.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.35.0...v3.36.0) (2026-03-20)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add media attachment support for admin ticket replies ([69bb399](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/69bb399b63d6e1d761cc5518e6272917fc5a6ae7))
|
||||
* add multi-provider recovery, retry_count, amount verification, and indexes ([3d78974](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3d78974af70b360449d9cf634e09a79821cdc7c0))
|
||||
* add partner → campaign edges to referral network graph ([01132a7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/01132a7bc77b07eaaaf876c05d639bfed83e5324))
|
||||
* add referral network graph visualization admin API ([c08c903](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c08c903e8f94f3730872b19de904ce166ba35b98))
|
||||
* add scoped referral network graph with scope selector API ([df086b0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/df086b09c75a9157cdc558fb610b617a2d49deaf))
|
||||
* multi-select scope for referral network graph API ([6a4ce3d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6a4ce3dd38dc3cf2e9db08322093bfd0b84f1e1c))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address review findings for guest purchase recovery ([57c5c67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/57c5c679eef987e9bacee1a9d420ac3c0ff69ff7))
|
||||
* address review findings for multi-provider recovery ([79c110f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79c110ff41659ff225164c13690bafc859212d05))
|
||||
* allow repeated auto-assignment of promo groups on each purchase ([4a002b7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4a002b7db1bfa8fd1149b0e3dfa469e8912a0e3b))
|
||||
* correct revenue calculations in referral network ([c8f4cca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c8f4cca34053713eb2793bbb82e74cbb9a6f893c))
|
||||
* improve referral network query correctness and cleanup ([ac9fcd8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ac9fcd8d30dd64fdc7363e689e9ffaf03700976e))
|
||||
* prevent duplicate promo groups during auto-assignment after purchase ([da7a9cc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da7a9cc3c5fd771b932a2bfd154400f6f923a4d1))
|
||||
* prevent guest purchases from getting stuck in PENDING/FAILED status ([2781236](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2781236011942e794949544b9c5422aa8679e5eb))
|
||||
* prevent premature commits in promocode promo group operations ([8b26680](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8b2668087b4831c08474846b20591b2158d607fc))
|
||||
* propagate exceptions from get_primary_user_promo_group ([3ec9e71](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3ec9e71de7d8d40e969f9ea738455445d04d983a))
|
||||
* use 'kassa_ai' base model name for guest metadata patch ([182667e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/182667ecb86f9bbbe87d640865dcbcaadf9e72f6))
|
||||
* use base model name for KassaAI guest metadata patch ([b6d4373](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b6d43739337cd2e2cfd61ac33398eb6def5b28b6))
|
||||
|
||||
|
||||
### Performance
|
||||
|
||||
* add covering indexes for referral network queries ([b5471b7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b5471b7720213c217fc452dc1234a7d3c53447d5))
|
||||
|
||||
## [3.35.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.34.1...v3.35.0) (2026-03-18)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add user_email to admin payments API response ([7101555](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7101555da0722d1eacd97f40b6b8c8c3a2327a0c))
|
||||
* include manual admin top-ups in sales statistics revenue ([b80eeea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b80eeea089568c60c20b1ae165b8dbe887bbe378))
|
||||
* раздельные топики для админских уведомлений ([e15b18f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e15b18fb41b180e7dd3d65f2f058667be321fe85))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fix Platega and CryptoBot webhook verification ([b40a812](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b40a812f3aa0596bf6c5105008451dd8a17b103f))
|
||||
* handle None autopay_days_before in autopayment processing ([f1d4534](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f1d45343e941594d69f71e822ecc9b3a7062f4bf))
|
||||
* remove forced white background from custom email template overrides ([cb61014](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cb61014d9c5a89e3aeafb191f1b9826ca1cbf338))
|
||||
* undefined currency variable in RioPay payment creation ([5b33534](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5b3353433bc524e3e51f2ae87d26bd162bd9f97b))
|
||||
|
||||
## [3.34.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.34.0...v3.34.1) (2026-03-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add null check for subscription in execute_change_devices ([5081deb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5081debee7625954bd7b82f66e09dd58e08e8822))
|
||||
* correct CryptoBot webhook signature verification and auto-fill topup amount from cart ([d7ad9d7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d7ad9d70330b6ef5599f7a9409cdd16657d61b85))
|
||||
* correct RioPay API header case and remove undocumented fields ([1a87d43](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1a87d438fe127a0b62bd6ee59887212869a3cb17))
|
||||
* disable quick amount buttons in balance topup ([d0eab3f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d0eab3f7aacf0249ca244f168c96044347c918e3))
|
||||
* reset device limit to new tariff base on tariff switch ([aec01ce](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aec01ce0d4a36da5ddd07b56ca4bd5de04735a0b))
|
||||
* sync crypto link from happ.cryptoLink in webhook handlers ([0ceff44](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ceff44c30cf11466c4cbe51b520558c51c4af4a))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* remove quick amount buttons feature entirely ([136f29c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/136f29c1eb63778b2b329ed5bf72ab06a4531d0b))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add bot preview screenshot to README ([d7931a2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d7931a2afaf272aae74453f2bb6d493593895f67))
|
||||
* add cabinet preview screenshot to README ([b032c8f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b032c8f35435ddd592df04d6352097580f5e9837))
|
||||
* add icons and list all 14+1 payment providers ([1306c24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1306c24fa36fd3e812c5bec551a0aca450ca7d2c))
|
||||
* add Redis to tech stack ([c14d7ab](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c14d7ab0af2e5dd4f35213b323a97eedcc99af0e))
|
||||
* add Redis to tech stack ([e12cc9f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e12cc9f248764104d538db1daaede9ddd8b77b2d))
|
||||
* add WATA partnership block to payments section ([31bdf8a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/31bdf8a0aeba53fcec40358d0a38b8480130a346))
|
||||
* increase logo size to 800px ([22e7f15](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/22e7f150b30c35b0419e7923e94c5ebc92c4b61a))
|
||||
* redesign README — concise feature showcase, link to docs ([38deb70](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/38deb70f8118d371e36ac92a4417bdb543635fc9))
|
||||
* replace header logo with new artwork ([6888822](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/688882237fe3019bb78d5e1d7ad54faf4cd69c09))
|
||||
* WATA partnership block with logo and table card ([8d5a002](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8d5a0029964ba52b40e03ebfe8ab6f4146d3aca9))
|
||||
|
||||
## [3.34.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.33.0...v3.34.0) (2026-03-18)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.34.0" # x-release-please-version
|
||||
ARG VERSION="v3.41.0" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
+14
-3
@@ -96,10 +96,21 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
except Exception as e:
|
||||
logger.warning('Кеш не инициализирован', error=e)
|
||||
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
bot = create_bot()
|
||||
|
||||
proxy_url = settings.get_proxy_url()
|
||||
nalogo_proxy_url = settings.get_nalogo_proxy_url()
|
||||
|
||||
if proxy_url or nalogo_proxy_url:
|
||||
from app.utils.proxy import mask_proxy_url
|
||||
|
||||
if proxy_url:
|
||||
logger.info('Proxy configured', proxy_url=mask_proxy_url(proxy_url))
|
||||
if nalogo_proxy_url:
|
||||
source = 'NALOGO_PROXY_URL' if settings.NALOGO_PROXY_URL else 'PROXY_URL (fallback)'
|
||||
logger.info('Nalogo proxy configured', proxy_url=mask_proxy_url(nalogo_proxy_url), source=source)
|
||||
|
||||
maintenance_service.set_bot(bot)
|
||||
logger.info('Бот установлен в maintenance_service')
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Factory for creating Bot instances with proxy support."""
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def create_bot(token: str | None = None, **kwargs) -> Bot:
|
||||
"""Create a Bot instance with SOCKS5 proxy session if PROXY_URL is configured."""
|
||||
proxy_url = settings.get_proxy_url()
|
||||
session = None
|
||||
if proxy_url:
|
||||
from aiogram.client.session.aiohttp import AiohttpSession
|
||||
|
||||
session = AiohttpSession(proxy=proxy_url)
|
||||
|
||||
kwargs.setdefault('default', DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
return Bot(token=token or settings.BOT_TOKEN, session=session, **kwargs)
|
||||
@@ -49,7 +49,17 @@ def validate_telegram_login_widget(data: dict[str, Any], max_age_seconds: int =
|
||||
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
|
||||
age = (datetime.now(UTC) - auth_time).total_seconds()
|
||||
if age > max_age_seconds or age < -_MAX_CLOCK_SKEW_SECONDS:
|
||||
logger.warning(
|
||||
'Telegram widget auth rejected: too old',
|
||||
age_hours=round(age / 3600, 1),
|
||||
max_age_hours=round(max_age_seconds / 3600, 1),
|
||||
)
|
||||
return False
|
||||
if age > 86400:
|
||||
logger.info(
|
||||
'Telegram widget auth accepted with stale auth_date',
|
||||
age_hours=round(age / 3600, 1),
|
||||
)
|
||||
except (ValueError, TypeError, OSError):
|
||||
return False
|
||||
|
||||
@@ -96,7 +106,17 @@ def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) ->
|
||||
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
|
||||
age = (datetime.now(UTC) - auth_time).total_seconds()
|
||||
if age > max_age_seconds or age < -_MAX_CLOCK_SKEW_SECONDS:
|
||||
logger.warning(
|
||||
'Telegram initData rejected: too old',
|
||||
age_hours=round(age / 3600, 1),
|
||||
max_age_hours=round(max_age_seconds / 3600, 1),
|
||||
)
|
||||
return None
|
||||
if age > 86400:
|
||||
logger.info(
|
||||
'Telegram initData accepted with stale auth_date (Telegram caching bug)',
|
||||
age_hours=round(age / 3600, 1),
|
||||
)
|
||||
except (ValueError, TypeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from .admin_pinned_messages import router as admin_pinned_messages_router
|
||||
from .admin_policies import router as admin_policies_router
|
||||
from .admin_promo_offers import router as admin_promo_offers_router
|
||||
from .admin_promocodes import promo_groups_router as admin_promo_groups_router, router as admin_promocodes_router
|
||||
from .admin_referral_network import router as admin_referral_network_router
|
||||
from .admin_remnawave import router as admin_remnawave_router
|
||||
from .admin_roles import router as admin_roles_router
|
||||
from .admin_sales_stats import router as admin_sales_stats_router
|
||||
@@ -99,6 +100,7 @@ router.include_router(admin_wheel_router)
|
||||
router.include_router(admin_tariffs_router)
|
||||
router.include_router(admin_servers_router)
|
||||
router.include_router(admin_stats_router)
|
||||
router.include_router(admin_referral_network_router)
|
||||
router.include_router(admin_sales_stats_router)
|
||||
router.include_router(admin_ban_system_router)
|
||||
router.include_router(admin_broadcasts_router)
|
||||
|
||||
@@ -487,7 +487,8 @@ async def link_telegram(
|
||||
|
||||
if request.init_data:
|
||||
# Mini App flow: validate initData
|
||||
user_data = validate_telegram_init_data(request.init_data)
|
||||
# Generous max_age: Telegram Desktop/iOS cache initData with stale auth_date
|
||||
user_data = validate_telegram_init_data(request.init_data, max_age_seconds=86400 * 30)
|
||||
if not user_data or not user_data.get('id'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -560,7 +561,8 @@ async def link_telegram(
|
||||
if request.photo_url is not None:
|
||||
widget_data['photo_url'] = request.photo_url
|
||||
|
||||
if not validate_telegram_login_widget(widget_data):
|
||||
# Generous max_age: Telegram caches auth data with stale auth_date
|
||||
if not validate_telegram_login_widget(widget_data, max_age_seconds=86400 * 30):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid or expired Telegram Login Widget data',
|
||||
|
||||
@@ -411,6 +411,13 @@ async def create_broadcast(
|
||||
|
||||
media_payload = request.media
|
||||
|
||||
# Validate caption length for media messages (Telegram limit: 1024 chars)
|
||||
if media_payload and len(message_text) > 1024:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Текст слишком длинный для сообщения с медиа. Максимум 1024 символов, сейчас {len(message_text)}. Сократите текст или уберите медиафайл.',
|
||||
)
|
||||
|
||||
# Create broadcast record
|
||||
broadcast = BroadcastHistory(
|
||||
target_type=request.target,
|
||||
@@ -446,6 +453,7 @@ async def create_broadcast(
|
||||
selected_buttons=request.selected_buttons,
|
||||
media=media_config,
|
||||
initiator_name=admin.username or f'Admin #{admin.id}',
|
||||
custom_buttons=[btn.model_dump() for btn in request.custom_buttons] if request.custom_buttons else None,
|
||||
)
|
||||
|
||||
# Start broadcast
|
||||
@@ -644,6 +652,7 @@ async def create_combined_broadcast(
|
||||
selected_buttons=request.selected_buttons,
|
||||
media=media_config,
|
||||
initiator_name=admin_name,
|
||||
custom_buttons=[btn.model_dump() for btn in request.custom_buttons] if request.custom_buttons else None,
|
||||
)
|
||||
|
||||
await broadcast_service.start_broadcast(broadcast.id, telegram_config)
|
||||
|
||||
@@ -312,7 +312,7 @@ TEMPLATE_TYPES = [
|
||||
'zh': '通过落地页成功付款后发送给买家的邮件',
|
||||
'ua': 'Лист покупцю після успішної оплати через лендінг',
|
||||
},
|
||||
'context_vars': ['tariff_name', 'period_days', 'cabinet_url'],
|
||||
'context_vars': ['tariff_name', 'period_days', 'cabinet_url', 'cabinet_email', 'cabinet_password'],
|
||||
},
|
||||
{
|
||||
'type': 'guest_activation_required',
|
||||
@@ -425,6 +425,8 @@ SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = {
|
||||
'tariff_name': 'Premium',
|
||||
'period_days': 30,
|
||||
'cabinet_url': 'https://example.com/cabinet',
|
||||
'cabinet_email': 'user@example.com',
|
||||
'cabinet_password': 'SecurePass123',
|
||||
},
|
||||
'guest_activation_required': {
|
||||
'tariff_name': 'Premium',
|
||||
@@ -670,8 +672,8 @@ async def preview_template(
|
||||
language = data.language if data.language in AVAILABLE_LANGUAGES else 'ru'
|
||||
|
||||
if data.body_html:
|
||||
# Preview custom content wrapped in base template
|
||||
rendered_html = templates_instance._get_base_template(data.body_html, language)
|
||||
# Preview custom content — auto-detects styled vs simple HTML
|
||||
rendered_html = templates_instance._wrap_override_template(data.body_html, language)
|
||||
subject = data.subject or notification_type
|
||||
else:
|
||||
# Preview default template
|
||||
|
||||
@@ -483,6 +483,7 @@ class OrderRequest(BaseModel):
|
||||
|
||||
class LandingDailyStat(BaseModel):
|
||||
date: str # YYYY-MM-DD
|
||||
created: int = 0
|
||||
purchases: int
|
||||
revenue_kopeks: int
|
||||
gifts: int
|
||||
@@ -844,17 +845,35 @@ async def get_landing_stats(
|
||||
)
|
||||
daily_rows = {str(r.day): r for r in daily_result.all()}
|
||||
|
||||
# Created per day (all statuses, by created_at)
|
||||
day_created_utc = func.date(func.timezone('UTC', GuestPurchase.created_at))
|
||||
created_result = await db.execute(
|
||||
select(
|
||||
day_created_utc.label('day'),
|
||||
func.count(GuestPurchase.id).label('created'),
|
||||
)
|
||||
.where(
|
||||
GuestPurchase.landing_id == landing_id,
|
||||
GuestPurchase.created_at >= cutoff,
|
||||
)
|
||||
.group_by(day_created_utc)
|
||||
.order_by(day_created_utc)
|
||||
)
|
||||
created_rows = {str(r.day): r.created for r in created_result.all()}
|
||||
|
||||
# Fill missing days with zeros
|
||||
today = now.date()
|
||||
daily_stats: list[LandingDailyStat] = []
|
||||
for i in range(_STATS_PERIOD_DAYS, -1, -1):
|
||||
day = today - timedelta(days=i)
|
||||
day_str = day.isoformat()
|
||||
day_created = created_rows.get(day_str, 0)
|
||||
if day_str in daily_rows:
|
||||
r = daily_rows[day_str]
|
||||
daily_stats.append(
|
||||
LandingDailyStat(
|
||||
date=day_str,
|
||||
created=day_created,
|
||||
purchases=r.purchases,
|
||||
revenue_kopeks=r.revenue_kopeks,
|
||||
gifts=r.gifts,
|
||||
@@ -864,6 +883,7 @@ async def get_landing_stats(
|
||||
daily_stats.append(
|
||||
LandingDailyStat(
|
||||
date=day_str,
|
||||
created=day_created,
|
||||
purchases=0,
|
||||
revenue_kopeks=0,
|
||||
gifts=0,
|
||||
@@ -897,7 +917,7 @@ async def get_landing_stats(
|
||||
]
|
||||
|
||||
return LandingStatsResponse(
|
||||
total_purchases=total_successful,
|
||||
total_purchases=total_created,
|
||||
total_revenue_kopeks=total_revenue_kopeks,
|
||||
total_gifts=total_gifts,
|
||||
total_regular=total_regular,
|
||||
|
||||
@@ -227,8 +227,7 @@ async def approve_application(
|
||||
|
||||
# Notify user about approval
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.config import settings
|
||||
from app.services.notification_delivery_service import notification_delivery_service
|
||||
|
||||
@@ -240,7 +239,7 @@ async def approve_application(
|
||||
tg_message = (
|
||||
f'✅ Ваша заявка на партнёрство одобрена!\nКомиссия: {request.commission_percent}%{comment_text}'
|
||||
)
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
await notification_delivery_service.notify_partner_approved(
|
||||
user=user,
|
||||
@@ -280,8 +279,7 @@ async def reject_application(
|
||||
|
||||
# Notify user about rejection
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.config import settings
|
||||
from app.services.notification_delivery_service import notification_delivery_service
|
||||
|
||||
@@ -291,7 +289,7 @@ async def reject_application(
|
||||
if user:
|
||||
comment_text = f'\nПричина: {request.comment}' if request.comment else ''
|
||||
tg_message = f'❌ Ваша заявка на партнёрство отклонена.{comment_text}'
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
await notification_delivery_service.notify_partner_rejected(
|
||||
user=user,
|
||||
|
||||
@@ -4,14 +4,11 @@ import math
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.bot_factory import create_bot
|
||||
from app.database.models import PaymentMethod, User
|
||||
from app.services.payment_search_service import (
|
||||
MAX_ALL_TIME_DAYS,
|
||||
@@ -62,6 +59,7 @@ class PendingPaymentResponse(BaseModel):
|
||||
user_id: int | None = None
|
||||
user_telegram_id: int | None = None
|
||||
user_username: str | None = None
|
||||
user_email: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -285,6 +283,7 @@ def _record_to_response(record: PendingPayment) -> PendingPaymentResponse:
|
||||
user_id=record.user.id if record.user else None,
|
||||
user_telegram_id=record.user.telegram_id if record.user else None,
|
||||
user_username=record.user.username if record.user else None,
|
||||
user_email=record.user.email if record.user else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -548,7 +547,7 @@ async def check_payment_status(
|
||||
old_is_paid = record.is_paid
|
||||
|
||||
# Run manual check
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
bot = create_bot()
|
||||
try:
|
||||
payment_service = PaymentService(bot=bot)
|
||||
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
|
||||
|
||||
@@ -5,13 +5,11 @@ from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.bot_factory import create_bot
|
||||
from app.database.models import PinnedMessage, User
|
||||
from app.services.pinned_message_service import (
|
||||
broadcast_pinned_message,
|
||||
@@ -77,10 +75,7 @@ _cached_bot: Bot | None = None
|
||||
def _get_bot() -> Bot:
|
||||
global _cached_bot
|
||||
if _cached_bot is None:
|
||||
_cached_bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
_cached_bot = create_bot()
|
||||
return _cached_bot
|
||||
|
||||
|
||||
|
||||
@@ -8,15 +8,13 @@ from typing import Any
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.bot_factory import create_bot
|
||||
from app.database.crud.discount_offer import (
|
||||
count_discount_offers,
|
||||
list_discount_offers,
|
||||
@@ -369,10 +367,7 @@ async def list_offers(
|
||||
|
||||
def _get_bot() -> Bot:
|
||||
"""Create bot instance for sending notifications."""
|
||||
return Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
return create_bot()
|
||||
|
||||
|
||||
def _build_default_promo_message(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,12 +4,13 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.rbac import AdminRoleCRUD, UserRoleCRUD
|
||||
from app.database.crud.rbac import SUPERADMIN_LEVEL, AdminRoleCRUD, UserRoleCRUD
|
||||
from app.database.models import User
|
||||
from app.services.permission_service import PERMISSION_REGISTRY, get_all_permissions
|
||||
|
||||
@@ -129,21 +130,26 @@ async def _role_to_response(db: AsyncSession, role) -> RoleResponse:
|
||||
|
||||
|
||||
async def _get_admin_level(db: AsyncSession, admin: User) -> int:
|
||||
"""Get the maximum role level of the current admin.
|
||||
"""Get the effective management level of the current admin.
|
||||
|
||||
Legacy config-based admins (ADMIN_IDS) get superadmin level (999+1=1000)
|
||||
so they can manage all roles including level 999.
|
||||
Superadmin-tier users (DB level 999 or legacy ADMIN_IDS) are promoted to
|
||||
level 1000 so they can manage peer Superadmins. Without this, the ``>=``
|
||||
hierarchy guard would block 999-vs-999 operations.
|
||||
"""
|
||||
from app.config import settings
|
||||
|
||||
_perms, _names, max_level = await UserRoleCRUD.get_user_permissions(db, admin.id)
|
||||
|
||||
# DB-assigned Superadmins can manage peers
|
||||
if max_level >= SUPERADMIN_LEVEL:
|
||||
max_level = SUPERADMIN_LEVEL + 1
|
||||
|
||||
# Legacy config-based admins always get the highest level
|
||||
if settings.is_admin(
|
||||
telegram_id=admin.telegram_id,
|
||||
email=admin.email if admin.email_verified else None,
|
||||
):
|
||||
max_level = max(max_level, 1000)
|
||||
max_level = max(max_level, SUPERADMIN_LEVEL + 1)
|
||||
|
||||
return max_level
|
||||
|
||||
@@ -339,6 +345,15 @@ async def update_role(
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
|
||||
# System roles: only permissions can be extended, block is_active/level changes
|
||||
if role.is_system:
|
||||
blocked = {'is_active', 'level'} & update_data.keys()
|
||||
if blocked:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f'Cannot change {", ".join(sorted(blocked))} on a system role',
|
||||
)
|
||||
|
||||
# Validate level change
|
||||
if 'level' in update_data and update_data['level'] >= admin_level:
|
||||
raise HTTPException(
|
||||
@@ -426,6 +441,14 @@ async def assign_role(
|
||||
detail='Role not found',
|
||||
)
|
||||
|
||||
# Superadmin role is managed exclusively via ADMIN_IDS/ADMIN_EMAILS env config
|
||||
if role.level >= SUPERADMIN_LEVEL:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Superadmin role is managed via ADMIN_IDS/ADMIN_EMAILS environment variables. '
|
||||
'Add the user there and restart the bot.',
|
||||
)
|
||||
|
||||
admin_level = await _get_admin_level(db, admin)
|
||||
|
||||
# Cannot assign a role with level >= own level
|
||||
@@ -483,13 +506,11 @@ async def revoke_role(
|
||||
admin: User = Depends(require_permission('roles:assign')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Revoke a role assignment. Cannot remove the last superadmin."""
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
"""Revoke a role assignment. Superadmin roles are managed via env config."""
|
||||
from app.database.models import UserRole
|
||||
|
||||
# Load the assignment to check hierarchy
|
||||
result = await db.execute(sa_select(UserRole).where(UserRole.id == assignment_id))
|
||||
# Lock the assignment row (FOR UPDATE held until commit)
|
||||
result = await db.execute(sa.select(UserRole).where(UserRole.id == assignment_id).with_for_update())
|
||||
user_role = result.scalar_one_or_none()
|
||||
if not user_role:
|
||||
raise HTTPException(
|
||||
@@ -504,6 +525,14 @@ async def revoke_role(
|
||||
detail='Associated role not found',
|
||||
)
|
||||
|
||||
# Superadmin role is managed exclusively via env config
|
||||
if role.level >= SUPERADMIN_LEVEL:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Superadmin role is managed via ADMIN_IDS/ADMIN_EMAILS environment variables. '
|
||||
'Remove the user from env and restart the bot.',
|
||||
)
|
||||
|
||||
admin_level = await _get_admin_level(db, admin)
|
||||
|
||||
# Cannot revoke a role at or above own level
|
||||
@@ -513,23 +542,9 @@ async def revoke_role(
|
||||
detail='Cannot revoke a role at or above your own level',
|
||||
)
|
||||
|
||||
# Protect last superadmin (level 999)
|
||||
superadmin_level = 999
|
||||
if role.level == superadmin_level:
|
||||
superadmin_count = await UserRoleCRUD.get_superadmin_count(db)
|
||||
if superadmin_count <= 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot remove the last superadmin',
|
||||
)
|
||||
|
||||
revoked = await UserRoleCRUD.revoke_role(db, assignment_id)
|
||||
if not revoked:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to revoke role',
|
||||
)
|
||||
|
||||
# Revoke directly on the locked object (avoid CRUD re-fetch without FOR UPDATE)
|
||||
user_role.is_active = False
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
@@ -539,4 +554,5 @@ async def revoke_role(
|
||||
target_user_id=user_role.user_id,
|
||||
role_name=role.name,
|
||||
)
|
||||
|
||||
return {'message': 'Role revoked', 'assignment_id': assignment_id}
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.transaction import REAL_PAYMENT_METHODS
|
||||
from app.database.models import (
|
||||
PaymentMethod,
|
||||
Subscription,
|
||||
SubscriptionConversion,
|
||||
SubscriptionStatus,
|
||||
@@ -87,6 +88,7 @@ class SalesSummary(BaseModel):
|
||||
"""Summary stats for the top cards."""
|
||||
|
||||
total_revenue_kopeks: int
|
||||
manual_topup_kopeks: int
|
||||
active_subscriptions: int
|
||||
active_trials: int
|
||||
new_trials: int
|
||||
@@ -110,11 +112,11 @@ async def get_sales_summary(
|
||||
try:
|
||||
period_start, period_end = _parse_period(days, start_date, end_date)
|
||||
|
||||
# Total revenue (deposits with real payment methods)
|
||||
# Total revenue (deposits + direct subscription payments with real payment methods)
|
||||
revenue_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
|
||||
Transaction.is_completed == True,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
Transaction.created_at >= period_start,
|
||||
@@ -124,6 +126,20 @@ async def get_sales_summary(
|
||||
)
|
||||
total_revenue = revenue_result.scalar() or 0
|
||||
|
||||
# Manual top-ups by admins
|
||||
manual_topup_result = await db.execute(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.payment_method == PaymentMethod.MANUAL.value,
|
||||
Transaction.created_at >= period_start,
|
||||
Transaction.created_at <= period_end,
|
||||
)
|
||||
)
|
||||
)
|
||||
manual_topup = manual_topup_result.scalar() or 0
|
||||
|
||||
# Consolidated subscription counts: active paid, active trial, new trials in period
|
||||
sub_counts_result = await db.execute(
|
||||
select(
|
||||
@@ -230,7 +246,7 @@ async def get_sales_summary(
|
||||
|
||||
# Add-on revenue
|
||||
addon_revenue_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.is_completed == True,
|
||||
@@ -240,10 +256,11 @@ async def get_sales_summary(
|
||||
)
|
||||
)
|
||||
)
|
||||
addon_revenue = abs(addon_revenue_result.scalar() or 0)
|
||||
addon_revenue = addon_revenue_result.scalar() or 0
|
||||
|
||||
return SalesSummary(
|
||||
total_revenue_kopeks=total_revenue,
|
||||
total_revenue_kopeks=total_revenue + manual_topup,
|
||||
manual_topup_kopeks=manual_topup,
|
||||
active_subscriptions=active_subs,
|
||||
active_trials=active_trials,
|
||||
new_trials=new_trials,
|
||||
@@ -1060,10 +1077,11 @@ async def get_deposits_stats(
|
||||
try:
|
||||
period_start, period_end = _parse_period(days, start_date, end_date)
|
||||
|
||||
methods_with_manual = [*REAL_PAYMENT_METHODS, PaymentMethod.MANUAL.value]
|
||||
base_filter = and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
|
||||
Transaction.is_completed == True,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
Transaction.payment_method.in_(methods_with_manual),
|
||||
Transaction.created_at >= period_start,
|
||||
Transaction.created_at <= period_end,
|
||||
)
|
||||
@@ -1071,7 +1089,7 @@ async def get_deposits_stats(
|
||||
totals_result = await db.execute(
|
||||
select(
|
||||
func.count(Transaction.id).label('count'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
|
||||
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
|
||||
).where(base_filter)
|
||||
)
|
||||
totals = totals_result.one()
|
||||
@@ -1083,11 +1101,11 @@ async def get_deposits_stats(
|
||||
select(
|
||||
Transaction.payment_method.label('method'),
|
||||
func.count(Transaction.id).label('count'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
|
||||
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
|
||||
)
|
||||
.where(base_filter)
|
||||
.group_by(Transaction.payment_method)
|
||||
.order_by(func.sum(Transaction.amount_kopeks).desc())
|
||||
.order_by(func.sum(func.abs(Transaction.amount_kopeks)).desc())
|
||||
)
|
||||
by_method = [
|
||||
DepositByMethodItem(method=row.method or 'unknown', count=row.count, amount_kopeks=row.amount)
|
||||
@@ -1098,7 +1116,7 @@ async def get_deposits_stats(
|
||||
select(
|
||||
func.date(Transaction.created_at).label('date'),
|
||||
func.count(Transaction.id).label('count'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
|
||||
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
|
||||
)
|
||||
.where(base_filter)
|
||||
.group_by(func.date(Transaction.created_at))
|
||||
@@ -1114,12 +1132,12 @@ async def get_deposits_stats(
|
||||
]
|
||||
|
||||
# Daily deposits grouped by payment method
|
||||
# base_filter already excludes NULLs via .in_(REAL_PAYMENT_METHODS), no coalesce needed
|
||||
# base_filter already excludes NULLs via .in_(methods_with_manual), no coalesce needed
|
||||
daily_by_method_query = await db.execute(
|
||||
select(
|
||||
func.date(Transaction.created_at).label('date'),
|
||||
Transaction.payment_method.label('method'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
|
||||
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
|
||||
)
|
||||
.where(base_filter)
|
||||
.group_by(func.date(Transaction.created_at), Transaction.payment_method)
|
||||
|
||||
@@ -275,6 +275,14 @@ async def get_dashboard_stats(
|
||||
# Get tariff statistics
|
||||
tariff_stats = await _get_tariff_stats(db)
|
||||
|
||||
# Derive income_today from revenue_chart to ensure consistency with chart
|
||||
today_str = now.date().isoformat()
|
||||
income_today_from_chart = sum(
|
||||
item.get('amount_kopeks', 0) for item in revenue_data if str(item.get('date', '')) == today_str
|
||||
)
|
||||
# Use chart-derived value if available, otherwise fall back to trans_stats
|
||||
income_today_kopeks = income_today_from_chart or trans_stats.get('today', {}).get('income_kopeks', 0)
|
||||
|
||||
# Build response
|
||||
return DashboardStats(
|
||||
nodes=nodes_data,
|
||||
@@ -290,8 +298,8 @@ async def get_dashboard_stats(
|
||||
trial_to_paid_conversion=sub_stats.get('trial_to_paid_conversion', 0.0),
|
||||
),
|
||||
financial=FinancialStats(
|
||||
income_today_kopeks=trans_stats.get('today', {}).get('income_kopeks', 0),
|
||||
income_today_rubles=trans_stats.get('today', {}).get('income_kopeks', 0) / 100,
|
||||
income_today_kopeks=income_today_kopeks,
|
||||
income_today_rubles=income_today_kopeks / 100,
|
||||
income_month_kopeks=trans_stats.get('totals', {}).get('income_kopeks', 0),
|
||||
income_month_rubles=trans_stats.get('totals', {}).get('income_kopeks', 0) / 100,
|
||||
income_total_kopeks=all_time_stats.get('totals', {}).get('income_kopeks', 0),
|
||||
@@ -926,9 +934,9 @@ async def get_recent_payments(
|
||||
total_count = total_count_result.scalar() or 0
|
||||
|
||||
today_total_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= today_start,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
@@ -938,9 +946,9 @@ async def get_recent_payments(
|
||||
total_today = today_total_result.scalar() or 0
|
||||
|
||||
week_total_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= week_ago,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -90,6 +90,19 @@ class AdminReplyRequest(BaseModel):
|
||||
"""Admin reply to ticket."""
|
||||
|
||||
message: str = Field(..., min_length=1, max_length=4000, description='Reply message')
|
||||
media_type: str | None = Field(None, description='Media type: photo, video, or document')
|
||||
media_file_id: str | None = Field(None, max_length=255, description='Telegram file_id from media upload')
|
||||
media_caption: str | None = Field(None, max_length=1000, description='Caption for media')
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_media_fields(self) -> 'AdminReplyRequest':
|
||||
if self.media_file_id and not self.media_type:
|
||||
raise ValueError('media_type is required when media_file_id is provided')
|
||||
if self.media_type and not self.media_file_id:
|
||||
raise ValueError('media_file_id is required when media_type is provided')
|
||||
if self.media_type and self.media_type not in {'photo', 'video', 'document'}:
|
||||
raise ValueError('media_type must be one of: photo, video, document')
|
||||
return self
|
||||
|
||||
|
||||
class AdminStatusUpdateRequest(BaseModel):
|
||||
@@ -443,11 +456,16 @@ async def reply_to_ticket(
|
||||
)
|
||||
|
||||
# Create admin message
|
||||
has_media = bool(request.media_file_id)
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket.id,
|
||||
user_id=ticket.user_id,
|
||||
message_text=request.message,
|
||||
is_from_admin=True,
|
||||
has_media=has_media,
|
||||
media_type=request.media_type if has_media else None,
|
||||
media_file_id=request.media_file_id if has_media else None,
|
||||
media_caption=request.media_caption if has_media else None,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(message)
|
||||
@@ -461,14 +479,9 @@ async def reply_to_ticket(
|
||||
|
||||
# Try to notify user via Telegram
|
||||
try:
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
bot = create_bot()
|
||||
try:
|
||||
from app.handlers.admin.tickets import notify_user_about_ticket_reply
|
||||
|
||||
|
||||
@@ -7,16 +7,13 @@ import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.types import BufferedInputFile
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.bot_factory import create_bot
|
||||
from app.database.models import Subscription, Transaction, TransactionType, User
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
@@ -680,10 +677,7 @@ async def export_traffic_csv(
|
||||
filename = f'traffic_usage_{period_label}_{timestamp}.csv'
|
||||
|
||||
try:
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
bot = create_bot()
|
||||
async with bot:
|
||||
await bot.send_document(
|
||||
chat_id=admin.telegram_id,
|
||||
|
||||
@@ -29,6 +29,7 @@ from app.database.crud.user import (
|
||||
from app.database.crud.user_promo_group import sync_user_primary_promo_group
|
||||
from app.database.models import (
|
||||
GuestPurchase,
|
||||
PaymentMethod,
|
||||
PromoGroup,
|
||||
ReferralEarning,
|
||||
Subscription,
|
||||
@@ -897,6 +898,7 @@ async def update_user_balance(
|
||||
description=request.description,
|
||||
create_transaction=request.create_transaction,
|
||||
transaction_type=TransactionType.DEPOSIT,
|
||||
payment_method=PaymentMethod.MANUAL,
|
||||
)
|
||||
else:
|
||||
# Subtract balance
|
||||
@@ -912,6 +914,7 @@ async def update_user_balance(
|
||||
amount_kopeks=amount_to_subtract,
|
||||
description=request.description,
|
||||
create_transaction=request.create_transaction,
|
||||
payment_method=PaymentMethod.MANUAL,
|
||||
)
|
||||
|
||||
if not success:
|
||||
|
||||
@@ -199,8 +199,7 @@ async def approve_withdrawal(
|
||||
|
||||
# Notify user about approval
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.config import settings
|
||||
from app.services.notification_delivery_service import notification_delivery_service
|
||||
|
||||
@@ -211,7 +210,7 @@ async def approve_withdrawal(
|
||||
formatted_amount = settings.format_price(withdrawal.amount_kopeks)
|
||||
comment_text = f'\n{request.comment}' if request.comment else ''
|
||||
tg_message = f'✅ Ваш запрос на вывод {formatted_amount} одобрен.{comment_text}'
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
await notification_delivery_service.notify_withdrawal_approved(
|
||||
user=user,
|
||||
@@ -251,8 +250,7 @@ async def reject_withdrawal(
|
||||
|
||||
# Notify user about rejection
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.config import settings
|
||||
from app.services.notification_delivery_service import notification_delivery_service
|
||||
|
||||
@@ -263,7 +261,7 @@ async def reject_withdrawal(
|
||||
formatted_amount = settings.format_price(withdrawal.amount_kopeks)
|
||||
comment_text = f'\nПричина: {request.comment}' if request.comment else ''
|
||||
tg_message = f'❌ Ваш запрос на вывод {formatted_amount} отклонён.{comment_text}'
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
await notification_delivery_service.notify_withdrawal_rejected(
|
||||
user=user,
|
||||
|
||||
+91
-29
@@ -196,12 +196,10 @@ async def _process_campaign_bonus(
|
||||
user.referred_by_id = campaign.partner_user_id
|
||||
await db.flush()
|
||||
try:
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot)
|
||||
async with create_bot() as bot:
|
||||
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot)
|
||||
logger.info(
|
||||
'Referral set from campaign partner',
|
||||
user_id=user.id,
|
||||
@@ -241,11 +239,39 @@ async def _process_referral_code(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
referral_code: str | None,
|
||||
*,
|
||||
is_new_user: bool = False,
|
||||
) -> None:
|
||||
"""Set referred_by_id for user if referral_code is valid. Never raises."""
|
||||
if not referral_code or user.referred_by_id:
|
||||
"""Process referral for a newly created user. Never raises.
|
||||
|
||||
Only applies to new users (is_new_user=True). Existing users cannot be
|
||||
assigned a referrer — same logic as the bot /start handler.
|
||||
|
||||
Handles two cases:
|
||||
- referred_by_id already set by create_user() → fire registration event
|
||||
- referred_by_id not set (resolution failed earlier) → resolve, set, fire
|
||||
"""
|
||||
if not referral_code or not is_new_user:
|
||||
return
|
||||
try:
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
# Lock user row to prevent concurrent referral application (TOCTOU race)
|
||||
await db.execute(select(User).where(User.id == user.id).with_for_update())
|
||||
await db.refresh(user)
|
||||
|
||||
# Case 1: referred_by_id already set by create_user() — just fire the event
|
||||
if user.referred_by_id:
|
||||
async with create_bot() as bot:
|
||||
await process_referral_registration(db, user.id, user.referred_by_id, bot=bot)
|
||||
logger.info(
|
||||
'Referral registration processed for pre-set referrer',
|
||||
user_id=user.id,
|
||||
referrer_id=user.referred_by_id,
|
||||
)
|
||||
return
|
||||
|
||||
# Case 2: referred_by_id not set — resolve referral code and set it
|
||||
referrer = await get_user_by_referral_code(db, referral_code)
|
||||
if not referrer:
|
||||
return
|
||||
@@ -255,12 +281,9 @@ async def _process_referral_code(
|
||||
return
|
||||
user.referred_by_id = referrer.id
|
||||
await db.flush()
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
await process_referral_registration(db, user.id, referrer.id, bot=bot)
|
||||
async with create_bot() as bot:
|
||||
await process_referral_registration(db, user.id, referrer.id, bot=bot)
|
||||
logger.info('Referral applied from code', user_id=user.id, referrer_id=referrer.id, referral_code=referral_code)
|
||||
except Exception as e:
|
||||
logger.error('Failed to process referral code', error=e, referral_code=referral_code)
|
||||
@@ -408,7 +431,11 @@ async def auth_telegram(
|
||||
detail='Too many requests',
|
||||
headers={'Retry-After': '60'},
|
||||
)
|
||||
user_data = validate_telegram_init_data(request.init_data)
|
||||
# Telegram Desktop/iOS cache initData with stale auth_date (known Telegram bug:
|
||||
# https://github.com/telegramdesktop/tdesktop/issues/28303).
|
||||
# Use generous max_age: HMAC signature proves authenticity,
|
||||
# JWT tokens handle actual session expiration after login.
|
||||
user_data = validate_telegram_init_data(request.init_data, max_age_seconds=86400 * 30)
|
||||
|
||||
if not user_data:
|
||||
raise HTTPException(
|
||||
@@ -437,10 +464,19 @@ async def auth_telegram(
|
||||
try:
|
||||
referrer = await get_user_by_referral_code(db, request.referral_code)
|
||||
if referrer:
|
||||
referrer_id = referrer.id
|
||||
# Self-referral protection by telegram_id (user doesn't exist yet, can't compare user.id)
|
||||
if referrer.telegram_id and referrer.telegram_id == telegram_id:
|
||||
logger.warning(
|
||||
'Self-referral attempt blocked via telegram_id',
|
||||
telegram_id=telegram_id,
|
||||
referral_code=request.referral_code,
|
||||
)
|
||||
else:
|
||||
referrer_id = referrer.id
|
||||
except Exception as e:
|
||||
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
|
||||
|
||||
is_new_user = not user
|
||||
if not user:
|
||||
# Create new user from Telegram initData
|
||||
logger.info('Creating new user from cabinet (initData): telegram_id', telegram_id=telegram_id)
|
||||
@@ -484,8 +520,8 @@ async def auth_telegram(
|
||||
# Store refresh token
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
|
||||
# Process referral code (before campaign bonus, which may also set referrer)
|
||||
await _process_referral_code(db, user, request.referral_code)
|
||||
# Process referral code (only for new users — existing users cannot be assigned a referrer)
|
||||
await _process_referral_code(db, user, request.referral_code, is_new_user=is_new_user)
|
||||
|
||||
# Process campaign bonus
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
@@ -518,7 +554,8 @@ async def auth_telegram_widget(
|
||||
|
||||
widget_data = request.model_dump(exclude={'campaign_slug', 'referral_code'})
|
||||
|
||||
if not validate_telegram_login_widget(widget_data):
|
||||
# Generous max_age: Telegram caches auth data with stale auth_date
|
||||
if not validate_telegram_login_widget(widget_data, max_age_seconds=86400 * 30):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Invalid or expired Telegram authentication data',
|
||||
@@ -532,10 +569,19 @@ async def auth_telegram_widget(
|
||||
try:
|
||||
referrer = await get_user_by_referral_code(db, request.referral_code)
|
||||
if referrer:
|
||||
referrer_id = referrer.id
|
||||
# Self-referral protection by telegram_id (user doesn't exist yet, can't compare user.id)
|
||||
if referrer.telegram_id and referrer.telegram_id == request.id:
|
||||
logger.warning(
|
||||
'Self-referral attempt blocked via telegram_id',
|
||||
telegram_id=request.id,
|
||||
referral_code=request.referral_code,
|
||||
)
|
||||
else:
|
||||
referrer_id = referrer.id
|
||||
except Exception as e:
|
||||
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
|
||||
|
||||
is_new_user = not user
|
||||
if not user:
|
||||
# Create new user from Telegram data
|
||||
logger.info(
|
||||
@@ -572,8 +618,8 @@ async def auth_telegram_widget(
|
||||
response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
|
||||
# Process referral code (before campaign bonus, which may also set referrer)
|
||||
await _process_referral_code(db, user, request.referral_code)
|
||||
# Process referral code (only for new users — existing users cannot be assigned a referrer)
|
||||
await _process_referral_code(db, user, request.referral_code, is_new_user=is_new_user)
|
||||
|
||||
# Process campaign bonus
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
@@ -664,10 +710,19 @@ async def auth_telegram_oidc(
|
||||
try:
|
||||
referrer = await get_user_by_referral_code(db, request.referral_code)
|
||||
if referrer:
|
||||
referrer_id = referrer.id
|
||||
except (ValueError, LookupError) as e:
|
||||
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=str(e))
|
||||
# Self-referral protection by telegram_id (user doesn't exist yet, can't compare user.id)
|
||||
if referrer.telegram_id and referrer.telegram_id == telegram_id:
|
||||
logger.warning(
|
||||
'Self-referral attempt blocked via telegram_id',
|
||||
telegram_id=telegram_id,
|
||||
referral_code=request.referral_code,
|
||||
)
|
||||
else:
|
||||
referrer_id = referrer.id
|
||||
except Exception as e:
|
||||
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
|
||||
|
||||
is_new_user = not user
|
||||
if not user:
|
||||
logger.info('Creating new user from cabinet OIDC', telegram_id=telegram_id, username=username)
|
||||
user = await create_user(
|
||||
@@ -701,7 +756,8 @@ async def auth_telegram_oidc(
|
||||
response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
|
||||
await _process_referral_code(db, user, request.referral_code)
|
||||
# Process referral code (only for new users — existing users cannot be assigned a referrer)
|
||||
await _process_referral_code(db, user, request.referral_code, is_new_user=is_new_user)
|
||||
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
if response.campaign_bonus:
|
||||
@@ -937,12 +993,10 @@ async def register_email_standalone(
|
||||
# Обработать реферальную регистрацию (если есть реферер)
|
||||
if referrer:
|
||||
try:
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
await process_referral_registration(db, user.id, referrer.id, bot=bot)
|
||||
async with create_bot() as bot:
|
||||
await process_referral_registration(db, user.id, referrer.id, bot=bot)
|
||||
logger.info(
|
||||
'Processed referral registration: user_id=, referrer_id', user_id=user.id, referrer_id=referrer.id
|
||||
)
|
||||
@@ -1798,6 +1852,14 @@ async def poll_deep_link_token(
|
||||
response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, response.refresh_token, device_info='deep_link')
|
||||
|
||||
# Deep link auth is always for existing users — referral code not applicable
|
||||
# (kept for campaign bonus processing only)
|
||||
|
||||
# Process campaign bonus
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
if response.campaign_bonus:
|
||||
response.user = _user_to_response(user)
|
||||
|
||||
logger.info('Deep link auth successful', user_id=user.id, telegram_id=user.telegram_id)
|
||||
|
||||
return response
|
||||
|
||||
@@ -4,15 +4,12 @@ import math
|
||||
import time
|
||||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.config import settings
|
||||
from app.database.crud.saved_payment_method import (
|
||||
deactivate_payment_method,
|
||||
@@ -272,50 +269,37 @@ async def create_stars_invoice(
|
||||
|
||||
# Create invoice through Telegram Bot API
|
||||
try:
|
||||
bot_token = settings.BOT_TOKEN
|
||||
api_url = f'https://api.telegram.org/bot{bot_token}/createInvoiceLink'
|
||||
from aiogram.exceptions import TelegramAPIError
|
||||
from aiogram.types import LabeledPrice
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
api_url,
|
||||
json={
|
||||
'title': 'Пополнение баланса VPN',
|
||||
'description': f'Пополнение баланса на {normalized_kopeks / 100:.2f} ₽ ({stars_amount} ⭐)',
|
||||
'payload': payload,
|
||||
'provider_token': '', # Empty for Stars
|
||||
'currency': 'XTR',
|
||||
'prices': [{'label': 'Пополнение баланса', 'amount': stars_amount}],
|
||||
},
|
||||
async with create_bot() as bot:
|
||||
invoice_url = await bot.create_invoice_link(
|
||||
title='Пополнение баланса VPN',
|
||||
description=f'Пополнение баланса на {normalized_kopeks / 100:.2f} ₽ ({stars_amount} ⭐)',
|
||||
payload=payload,
|
||||
provider_token='',
|
||||
currency='XTR',
|
||||
prices=[LabeledPrice(label='Пополнение баланса', amount=stars_amount)],
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
logger.info(
|
||||
'Created Stars invoice for balance top-up: user=, amount= kopeks, stars',
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
stars_amount=stars_amount,
|
||||
)
|
||||
|
||||
if not result.get('ok'):
|
||||
logger.error('Telegram API error', result=result)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create Stars invoice',
|
||||
)
|
||||
return StarsInvoiceResponse(
|
||||
invoice_url=invoice_url,
|
||||
stars_amount=stars_amount,
|
||||
amount_kopeks=normalized_kopeks,
|
||||
)
|
||||
|
||||
invoice_url = result['result']
|
||||
logger.info(
|
||||
'Created Stars invoice for balance top-up: user=, amount= kopeks, stars',
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
stars_amount=stars_amount,
|
||||
)
|
||||
|
||||
return StarsInvoiceResponse(
|
||||
invoice_url=invoice_url,
|
||||
stars_amount=stars_amount,
|
||||
amount_kopeks=normalized_kopeks,
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error('HTTP error creating Stars invoice', error=e)
|
||||
except TelegramAPIError as e:
|
||||
logger.error('Error creating Stars invoice', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to connect to Telegram API',
|
||||
detail='Failed to create Stars invoice',
|
||||
)
|
||||
|
||||
|
||||
@@ -1202,7 +1186,7 @@ async def check_payment_status(
|
||||
old_is_paid = record.is_paid
|
||||
|
||||
# Run manual check
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
bot = create_bot()
|
||||
try:
|
||||
payment_service = PaymentService(bot=bot)
|
||||
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
|
||||
|
||||
+17
-13
@@ -306,9 +306,9 @@ async def create_gift_purchase(
|
||||
else:
|
||||
# 2) Fall back to Bot API (works for public usernames the bot has seen)
|
||||
try:
|
||||
from aiogram import Bot
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
async with Bot(token=settings.BOT_TOKEN) as bot:
|
||||
async with create_bot() as bot:
|
||||
chat = await asyncio.wait_for(bot.get_chat(chat_id=f'@{tg_username}'), timeout=5.0)
|
||||
pre_resolved_telegram_id = chat.id
|
||||
except Exception:
|
||||
@@ -371,19 +371,23 @@ async def create_gift_purchase(
|
||||
# Stars payments need a Bot instance to create invoice links
|
||||
bot = None
|
||||
if body.payment_method == 'telegram_stars':
|
||||
from aiogram import Bot
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
|
||||
payment_service = PaymentService(bot=bot)
|
||||
payment_result = await payment_service.create_guest_payment(
|
||||
db=db,
|
||||
amount_kopeks=price_kopeks,
|
||||
payment_method=body.payment_method,
|
||||
description=f'Gift: {tariff.name} ({body.period_days}d)',
|
||||
purchase_token=purchase.token,
|
||||
return_url=return_url,
|
||||
)
|
||||
try:
|
||||
payment_service = PaymentService(bot=bot)
|
||||
payment_result = await payment_service.create_guest_payment(
|
||||
db=db,
|
||||
amount_kopeks=price_kopeks,
|
||||
payment_method=body.payment_method,
|
||||
description=f'Gift: {tariff.name} ({body.period_days}d)',
|
||||
purchase_token=purchase.token,
|
||||
return_url=return_url,
|
||||
)
|
||||
finally:
|
||||
if bot:
|
||||
await bot.session.close()
|
||||
|
||||
if payment_result is None:
|
||||
await db.rollback()
|
||||
|
||||
@@ -550,7 +550,7 @@ async def create_landing_purchase(
|
||||
No authentication required.
|
||||
"""
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'landing_purchase', limit=5, window=60, fail_closed=True):
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'landing_purchase', limit=30, window=60, fail_closed=True):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail='Too many purchase attempts, please try again later',
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
import mimetypes
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.types import BufferedInputFile
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
|
||||
@@ -98,10 +96,7 @@ async def upload_media(
|
||||
target_chat_id = _resolve_target_chat_id()
|
||||
upload = BufferedInputFile(file_bytes, filename=file.filename or 'upload')
|
||||
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
bot = create_bot()
|
||||
|
||||
try:
|
||||
if media_type_normalized == 'photo':
|
||||
@@ -158,10 +153,7 @@ async def download_media(
|
||||
Download media file by file_id.
|
||||
Used to display images/documents in ticket messages.
|
||||
"""
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
bot = create_bot()
|
||||
|
||||
try:
|
||||
file = await bot.get_file(file_id)
|
||||
|
||||
@@ -40,6 +40,8 @@ async def _finalize_oauth_login(
|
||||
provider: str,
|
||||
campaign_slug: str | None = None,
|
||||
referral_code: str | None = None,
|
||||
*,
|
||||
is_new_user: bool = False,
|
||||
) -> AuthResponse:
|
||||
"""Update last login, create tokens, store refresh token."""
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
@@ -47,10 +49,10 @@ async def _finalize_oauth_login(
|
||||
auth_response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}')
|
||||
|
||||
# Process referral code (before campaign bonus, which may also set referrer)
|
||||
# Process referral code (only for new users — existing users cannot be assigned a referrer)
|
||||
from .auth import _process_referral_code, _user_to_response
|
||||
|
||||
await _process_referral_code(db, user, referral_code)
|
||||
await _process_referral_code(db, user, referral_code, is_new_user=is_new_user)
|
||||
|
||||
auth_response.campaign_bonus = await _process_campaign_bonus(db, user, campaign_slug)
|
||||
if auth_response.campaign_bonus:
|
||||
@@ -232,4 +234,6 @@ async def oauth_callback(
|
||||
referred_by_id=referrer_id,
|
||||
)
|
||||
logger.info('New OAuth user created', provider=provider, user_id=user.id)
|
||||
return await _finalize_oauth_login(db, user, provider, request.campaign_slug, request.referral_code)
|
||||
return await _finalize_oauth_login(
|
||||
db, user, provider, request.campaign_slug, request.referral_code, is_new_user=True
|
||||
)
|
||||
|
||||
@@ -178,12 +178,11 @@ async def apply_for_partner(
|
||||
|
||||
# Уведомляем админов о новой заявке
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_partner_application_notification(
|
||||
|
||||
@@ -92,14 +92,8 @@ async def get_referral_info(
|
||||
available_balance = min(user.balance_kopeks, referral_entitlement)
|
||||
|
||||
# Build referral links
|
||||
referral_link = settings.get_referral_link(user.referral_code) if user.referral_code else ''
|
||||
bot_username = settings.get_bot_username()
|
||||
bot_referral_link = ''
|
||||
if user.referral_code and bot_username:
|
||||
from urllib.parse import quote
|
||||
|
||||
safe_code = quote(user.referral_code, safe='')
|
||||
bot_referral_link = f'https://t.me/{bot_username}?start={safe_code}'
|
||||
referral_link = (settings.get_cabinet_referral_link(user.referral_code) or '') if user.referral_code else ''
|
||||
bot_referral_link = settings.get_bot_referral_link(user.referral_code) if user.referral_code else ''
|
||||
|
||||
return ReferralInfoResponse(
|
||||
referral_code=user.referral_code or '',
|
||||
|
||||
@@ -815,12 +815,11 @@ async def purchase_traffic(
|
||||
|
||||
# Отправляем уведомление админам
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
old_traffic = subscription.traffic_limit_gb - request.gb
|
||||
@@ -1043,12 +1042,11 @@ async def purchase_devices_legacy(
|
||||
|
||||
# Отправляем уведомление админам
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
@@ -1147,6 +1145,7 @@ async def get_trial_info(
|
||||
price_kopeks = settings.TRIAL_ACTIVATION_PRICE if requires_payment else 0
|
||||
|
||||
# Get trial parameters from tariff if configured (same logic as activate_trial)
|
||||
# Триальный тариф может быть неактивным — используется для отдельных лимитов
|
||||
try:
|
||||
from app.database.crud.tariff import get_tariff_by_id, get_trial_tariff
|
||||
|
||||
@@ -1156,8 +1155,6 @@ async def get_trial_info(
|
||||
trial_tariff_id = settings.get_trial_tariff_id()
|
||||
if trial_tariff_id > 0:
|
||||
trial_tariff = await get_tariff_by_id(db, trial_tariff_id)
|
||||
if trial_tariff and not trial_tariff.is_active:
|
||||
trial_tariff = None
|
||||
|
||||
if trial_tariff:
|
||||
traffic_limit_gb = trial_tariff.traffic_limit_gb
|
||||
@@ -1290,6 +1287,7 @@ async def activate_trial(
|
||||
|
||||
# First check for tariff with is_trial_available flag in DB (set via admin panel)
|
||||
# Then fallback to TRIAL_TARIFF_ID from settings
|
||||
# Триальный тариф может быть неактивным — используется для отдельных лимитов
|
||||
trial_tariff = None
|
||||
try:
|
||||
from app.database.crud.tariff import get_tariff_by_id, get_trial_tariff
|
||||
@@ -1300,8 +1298,6 @@ async def activate_trial(
|
||||
trial_tariff_id = settings.get_trial_tariff_id()
|
||||
if trial_tariff_id > 0:
|
||||
trial_tariff = await get_tariff_by_id(db, trial_tariff_id)
|
||||
if trial_tariff and not trial_tariff.is_active:
|
||||
trial_tariff = None
|
||||
|
||||
if trial_tariff:
|
||||
trial_traffic_limit = trial_tariff.traffic_limit_gb
|
||||
@@ -1344,12 +1340,11 @@ async def activate_trial(
|
||||
|
||||
# Send admin notification about trial activation
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
charged_amount = settings.TRIAL_ACTIVATION_PRICE if requires_payment else None
|
||||
@@ -1763,12 +1758,11 @@ async def submit_purchase(
|
||||
|
||||
# Отправляем уведомление админам о покупке подписки
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
is_new_subscription = result.get('was_trial_conversion') or not context.subscription
|
||||
@@ -2161,12 +2155,11 @@ async def purchase_tariff(
|
||||
|
||||
# Отправляем уведомление админам о покупке/продлении тарифа
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
# Определяем тип покупки: новая подписка или продление
|
||||
@@ -2418,12 +2411,11 @@ async def purchase_devices(
|
||||
|
||||
# Отправляем уведомление админам
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
@@ -4107,7 +4099,7 @@ async def switch_tariff(
|
||||
# Update subscription
|
||||
old_tariff_name = current_tariff.name if current_tariff else 'Unknown'
|
||||
|
||||
# Preserve extra purchased devices above the old tariff's base limit
|
||||
# Reset device limit to new tariff base (extra purchased devices are not carried over)
|
||||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||||
|
||||
# Re-load subscription to avoid MissingGreenlet from expired lazy relationship
|
||||
@@ -4204,12 +4196,11 @@ async def switch_tariff(
|
||||
|
||||
# Отправляем уведомление админам о смене тарифа
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
@@ -4262,6 +4253,7 @@ async def toggle_subscription_pause(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, Any]:
|
||||
"""Toggle pause/resume for daily subscription."""
|
||||
logger.debug('toggle_subscription_pause called', user_id=user.id)
|
||||
await db.refresh(user, ['subscription'])
|
||||
|
||||
if not user.subscription:
|
||||
@@ -4284,7 +4276,15 @@ async def toggle_subscription_pause(
|
||||
detail='Pause is only available for daily tariffs',
|
||||
)
|
||||
|
||||
# Determine current state
|
||||
raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
|
||||
# Lock user BEFORE reading state and mutating to prevent TOCTOU on promo group
|
||||
# and to ensure is_daily_paused mutation is not overwritten by populate_existing
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Determine current state from the LOCKED instance
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
is_currently_paused = getattr(user.subscription, 'is_daily_paused', False)
|
||||
@@ -4302,13 +4302,6 @@ async def toggle_subscription_pause(
|
||||
new_paused_state = not is_currently_paused
|
||||
user.subscription.is_daily_paused = new_paused_state
|
||||
|
||||
raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
|
||||
# Lock user BEFORE discount computation to prevent TOCTOU on promo group
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Apply group discount to daily price (consistent with DailySubscriptionService and miniapp resume)
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
@@ -4318,6 +4311,8 @@ async def toggle_subscription_pause(
|
||||
PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price
|
||||
)
|
||||
|
||||
resume_transaction = None
|
||||
|
||||
# If resuming, check balance and charge
|
||||
if not new_paused_state:
|
||||
if daily_price > 0 and user.balance_kopeks < daily_price:
|
||||
@@ -4342,6 +4337,7 @@ async def toggle_subscription_pause(
|
||||
daily_price,
|
||||
f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
|
||||
mark_as_paid_subscription=True,
|
||||
commit=False,
|
||||
)
|
||||
if not deducted:
|
||||
raise HTTPException(
|
||||
@@ -4357,26 +4353,45 @@ async def toggle_subscription_pause(
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.models import TransactionType
|
||||
|
||||
try:
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=daily_price,
|
||||
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning('Failed to create resume transaction', error=exc)
|
||||
resume_transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=daily_price,
|
||||
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
|
||||
commit=False,
|
||||
)
|
||||
|
||||
# Balance deducted successfully — now activate
|
||||
now = datetime.now(UTC)
|
||||
user.subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
user.subscription.last_daily_charge_at = datetime.now(UTC)
|
||||
user.subscription.end_date = datetime.now(UTC) + timedelta(days=1)
|
||||
user.subscription.last_daily_charge_at = now
|
||||
user.subscription.end_date = now + timedelta(days=1)
|
||||
|
||||
# Re-apply is_daily_paused on the current identity-mapped instance
|
||||
# (subtract_user_balance with populate_existing=True may have reloaded it from DB)
|
||||
user.subscription.is_daily_paused = new_paused_state
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user.subscription)
|
||||
await db.refresh(user)
|
||||
|
||||
# Emit deferred transaction side effects after commit
|
||||
if not new_paused_state and was_disabled and daily_price > 0 and resume_transaction is not None:
|
||||
try:
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
await emit_transaction_side_effects(
|
||||
db=db,
|
||||
transaction=resume_transaction,
|
||||
amount_kopeks=daily_price,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning('Failed to emit resume transaction side effects', error=exc)
|
||||
|
||||
# Sync with RemnaWave only when resuming from DISABLED state
|
||||
if not new_paused_state and was_disabled:
|
||||
try:
|
||||
|
||||
+20
-35
@@ -5,7 +5,6 @@ API роуты колеса удачи для пользователей.
|
||||
import math
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
@@ -21,7 +20,6 @@ from app.cabinet.schemas.wheel import (
|
||||
WheelConfigResponse,
|
||||
WheelPrizeDisplay,
|
||||
)
|
||||
from app.config import settings
|
||||
from app.database.crud.wheel import (
|
||||
get_or_create_wheel_config,
|
||||
get_user_spin_history,
|
||||
@@ -251,44 +249,31 @@ async def create_stars_invoice(
|
||||
|
||||
# Создаем invoice через Telegram Bot API
|
||||
try:
|
||||
bot_token = settings.BOT_TOKEN
|
||||
api_url = f'https://api.telegram.org/bot{bot_token}/createInvoiceLink'
|
||||
from aiogram.exceptions import TelegramAPIError
|
||||
from aiogram.types import LabeledPrice
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
api_url,
|
||||
json={
|
||||
'title': 'Колесо удачи',
|
||||
'description': f'Спин колеса удачи ({stars_amount} ⭐)',
|
||||
'payload': payload,
|
||||
'provider_token': '', # Пустой для Stars
|
||||
'currency': 'XTR',
|
||||
'prices': [{'label': 'Спин колеса', 'amount': stars_amount}],
|
||||
},
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
async with create_bot() as bot:
|
||||
invoice_url = await bot.create_invoice_link(
|
||||
title='Колесо удачи',
|
||||
description=f'Спин колеса удачи ({stars_amount} ⭐)',
|
||||
payload=payload,
|
||||
provider_token='',
|
||||
currency='XTR',
|
||||
prices=[LabeledPrice(label='Спин колеса', amount=stars_amount)],
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
logger.info('Created Stars invoice for wheel spin: user=, stars', user_id=user.id, stars_amount=stars_amount)
|
||||
|
||||
if not result.get('ok'):
|
||||
logger.error('Telegram API error', result=result)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Ошибка создания инвойса',
|
||||
)
|
||||
return StarsInvoiceResponse(
|
||||
invoice_url=invoice_url,
|
||||
stars_amount=stars_amount,
|
||||
)
|
||||
|
||||
invoice_url = result['result']
|
||||
logger.info(
|
||||
'Created Stars invoice for wheel spin: user=, stars', user_id=user.id, stars_amount=stars_amount
|
||||
)
|
||||
|
||||
return StarsInvoiceResponse(
|
||||
invoice_url=invoice_url,
|
||||
stars_amount=stars_amount,
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error('HTTP error creating invoice', error=e)
|
||||
except TelegramAPIError as e:
|
||||
logger.error('Error creating invoice', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Ошибка соединения с Telegram',
|
||||
detail='Ошибка создания инвойса',
|
||||
)
|
||||
|
||||
@@ -70,12 +70,11 @@ async def create_withdrawal(
|
||||
|
||||
# Уведомляем админов о запросе на вывод
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_withdrawal_request_notification(
|
||||
|
||||
@@ -198,6 +198,17 @@ class DeepLinkTokenResponse(BaseModel):
|
||||
|
||||
|
||||
class DeepLinkPollRequest(BaseModel):
|
||||
"""Request to poll deep link auth status."""
|
||||
"""Request to poll deep link auth status.
|
||||
|
||||
Deep link auth is always for existing bot users — referral codes are not applicable here.
|
||||
Only campaign_slug is supported (campaign bonus can apply to existing users).
|
||||
"""
|
||||
|
||||
token: str = Field(..., min_length=16, max_length=128, description='Deep link auth token')
|
||||
campaign_slug: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=64,
|
||||
pattern=r'^[a-zA-Z0-9_-]+$',
|
||||
description='Campaign slug captured from cabinet URL',
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
# ============ Channel Types ============
|
||||
@@ -75,6 +75,27 @@ class BroadcastButtonsResponse(BaseModel):
|
||||
buttons: list[BroadcastButton]
|
||||
|
||||
|
||||
class CustomBroadcastButton(BaseModel):
|
||||
"""Custom button for broadcast message."""
|
||||
|
||||
label: str = Field(..., min_length=1, max_length=64)
|
||||
action_type: Literal['callback', 'url'] = 'callback'
|
||||
action_value: str = Field(..., min_length=1, max_length=256)
|
||||
|
||||
@field_validator('action_value')
|
||||
@classmethod
|
||||
def validate_action_value(cls, v: str, info) -> str:
|
||||
action_type = info.data.get('action_type', 'callback')
|
||||
if action_type == 'url':
|
||||
if not v.startswith(('https://', 'tg://')):
|
||||
raise ValueError('URL must start with https:// or tg://')
|
||||
elif action_type == 'callback':
|
||||
# Telegram API limits callback_data to 64 bytes
|
||||
if len(v.encode('utf-8')) > 64:
|
||||
raise ValueError('Callback data must be at most 64 bytes')
|
||||
return v
|
||||
|
||||
|
||||
# ============ Media ============
|
||||
|
||||
|
||||
@@ -95,6 +116,7 @@ class BroadcastCreateRequest(BaseModel):
|
||||
target: str
|
||||
message_text: str = Field(..., min_length=1, max_length=4000)
|
||||
selected_buttons: list[str] = Field(default_factory=lambda: ['home'])
|
||||
custom_buttons: list[CustomBroadcastButton] = Field(default_factory=list, max_length=10)
|
||||
media: BroadcastMediaRequest | None = None
|
||||
|
||||
|
||||
@@ -187,6 +209,7 @@ class CombinedBroadcastCreateRequest(BaseModel):
|
||||
# Telegram-specific fields
|
||||
message_text: str | None = Field(default=None, max_length=4000)
|
||||
selected_buttons: list[str] = Field(default_factory=lambda: ['home'])
|
||||
custom_buttons: list[CustomBroadcastButton] = Field(default_factory=list, max_length=10)
|
||||
media: BroadcastMediaRequest | None = None
|
||||
|
||||
# Email-specific fields
|
||||
|
||||
@@ -17,14 +17,33 @@ logger = structlog.get_logger(__name__)
|
||||
class EmailService:
|
||||
"""Service for sending emails via SMTP."""
|
||||
|
||||
def __init__(self):
|
||||
self.host = settings.SMTP_HOST
|
||||
self.port = settings.SMTP_PORT
|
||||
self.user = settings.SMTP_USER
|
||||
self.password = settings.SMTP_PASSWORD
|
||||
self.from_email = settings.get_smtp_from_email()
|
||||
self.from_name = settings.SMTP_FROM_NAME
|
||||
self.use_tls = settings.SMTP_USE_TLS
|
||||
@property
|
||||
def host(self) -> str | None:
|
||||
return settings.SMTP_HOST
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
return settings.SMTP_PORT
|
||||
|
||||
@property
|
||||
def user(self) -> str | None:
|
||||
return settings.SMTP_USER
|
||||
|
||||
@property
|
||||
def password(self) -> str | None:
|
||||
return settings.SMTP_PASSWORD
|
||||
|
||||
@property
|
||||
def from_email(self) -> str | None:
|
||||
return settings.get_smtp_from_email()
|
||||
|
||||
@property
|
||||
def from_name(self) -> str:
|
||||
return settings.SMTP_FROM_NAME
|
||||
|
||||
@property
|
||||
def use_tls(self) -> bool:
|
||||
return settings.SMTP_USE_TLS
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""Check if SMTP is properly configured."""
|
||||
@@ -71,6 +90,11 @@ class EmailService:
|
||||
logger.warning('SMTP is not configured, cannot send email')
|
||||
return False
|
||||
|
||||
sender_email = self.from_email
|
||||
if not sender_email or '@' not in sender_email:
|
||||
logger.error('Invalid or missing SMTP from_email, cannot send email', from_email=sender_email)
|
||||
return False
|
||||
|
||||
# Defensive: strip newlines to prevent header injection
|
||||
to_email = to_email.strip().replace('\n', '').replace('\r', '')
|
||||
subject = subject.replace('\n', '').replace('\r', '')
|
||||
@@ -79,11 +103,11 @@ class EmailService:
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['Subject'] = subject
|
||||
safe_from_name = self.from_name.replace('\n', '').replace('\r', '') if self.from_name else ''
|
||||
safe_from_email = self.from_email.replace('\n', '').replace('\r', '') if self.from_email else ''
|
||||
safe_from_email = sender_email.replace('\n', '').replace('\r', '')
|
||||
msg['From'] = f'{safe_from_name} <{safe_from_email}>'
|
||||
msg['To'] = to_email
|
||||
msg['Date'] = formatdate(localtime=False)
|
||||
msg['Message-ID'] = make_msgid(domain=self.from_email.split('@')[-1])
|
||||
msg['Message-ID'] = make_msgid(domain=safe_from_email.split('@')[-1])
|
||||
|
||||
# Plain text version
|
||||
if body_text is None:
|
||||
@@ -103,7 +127,7 @@ class EmailService:
|
||||
msg.attach(part2)
|
||||
|
||||
with self._get_smtp_connection() as smtp:
|
||||
smtp.sendmail(self.from_email, to_email, msg.as_string())
|
||||
smtp.sendmail(safe_from_email, to_email, msg.as_string())
|
||||
|
||||
logger.info('Email sent successfully to', to_email=to_email)
|
||||
return True
|
||||
|
||||
@@ -198,7 +198,7 @@ async def get_rendered_override(
|
||||
for key, value in context.items():
|
||||
body_html = body_html.replace(f'{{{key}}}', html.escape(str(value)))
|
||||
|
||||
rendered = templates._get_base_template(body_html, language)
|
||||
rendered = templates._wrap_override_template(body_html, language)
|
||||
subject = override['subject']
|
||||
|
||||
# Also substitute in subject
|
||||
|
||||
@@ -74,6 +74,39 @@ class EmailNotificationTemplates:
|
||||
|
||||
return template_func(language, context)
|
||||
|
||||
def _wrap_override_template(self, content: str, language: str = 'ru') -> str:
|
||||
"""Wrap override template content appropriately based on its structure.
|
||||
|
||||
Three-tier detection:
|
||||
1. Full HTML document (<!DOCTYPE or <html>) — return as-is, no wrapping
|
||||
2. Styled content (has <style> tag or background CSS) — minimal HTML wrapper
|
||||
without forced colors, headers, or footers
|
||||
3. Simple HTML fragment — wrap with base template (header, footer, white bg)
|
||||
for backward compatibility
|
||||
"""
|
||||
content_stripped = content.strip()
|
||||
content_lower = content_stripped.lower()
|
||||
|
||||
# Tier 1: Full HTML document — return as-is
|
||||
if content_lower.startswith('<!doctype') or content_lower.startswith('<html'):
|
||||
return content_stripped
|
||||
|
||||
# Tier 2: Styled content — minimal wrapper without forced styling
|
||||
if '<style' in content_lower or 'background' in content_lower:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0;">
|
||||
{content}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
# Tier 3: Simple HTML fragment — use base template for structure
|
||||
return self._get_base_template(content, language)
|
||||
|
||||
def _get_base_template(self, content: str, language: str = 'ru') -> str:
|
||||
"""Wrap content in base HTML template."""
|
||||
footer_texts = {
|
||||
@@ -1340,6 +1373,8 @@ class EmailNotificationTemplates:
|
||||
tariff_name = html.escape(context.get('tariff_name', ''))
|
||||
period_days = context.get('period_days', 0)
|
||||
cabinet_url = html.escape(context.get('cabinet_url', ''))
|
||||
cabinet_email = html.escape(context.get('cabinet_email', ''))
|
||||
cabinet_password = context.get('cabinet_password', '')
|
||||
|
||||
subjects = {
|
||||
'ru': 'Ваша VPN подписка готова',
|
||||
@@ -1349,6 +1384,66 @@ class EmailNotificationTemplates:
|
||||
'fa': 'اشتراک VPN شما آماده است',
|
||||
}
|
||||
|
||||
creds_block_ru = (
|
||||
f"""
|
||||
<div class="highlight">
|
||||
<p><strong>Данные для входа в личный кабинет:</strong></p>
|
||||
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
|
||||
<p><strong>Пароль:</strong> <code>{cabinet_password}</code></p>
|
||||
</div>
|
||||
"""
|
||||
if cabinet_password
|
||||
else ''
|
||||
)
|
||||
|
||||
creds_block_en = (
|
||||
f"""
|
||||
<div class="highlight">
|
||||
<p><strong>Your cabinet login credentials:</strong></p>
|
||||
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
|
||||
<p><strong>Password:</strong> <code>{cabinet_password}</code></p>
|
||||
</div>
|
||||
"""
|
||||
if cabinet_password
|
||||
else ''
|
||||
)
|
||||
|
||||
creds_block_zh = (
|
||||
f"""
|
||||
<div class="highlight">
|
||||
<p><strong>个人中心登录信息:</strong></p>
|
||||
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
|
||||
<p><strong>密码:</strong> <code>{cabinet_password}</code></p>
|
||||
</div>
|
||||
"""
|
||||
if cabinet_password
|
||||
else ''
|
||||
)
|
||||
|
||||
creds_block_ua = (
|
||||
f"""
|
||||
<div class="highlight">
|
||||
<p><strong>Дані для входу в особистий кабінет:</strong></p>
|
||||
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
|
||||
<p><strong>Пароль:</strong> <code>{cabinet_password}</code></p>
|
||||
</div>
|
||||
"""
|
||||
if cabinet_password
|
||||
else ''
|
||||
)
|
||||
|
||||
creds_block_fa = (
|
||||
f"""
|
||||
<div class="highlight">
|
||||
<p><strong>اطلاعات ورود به پنل کاربری:</strong></p>
|
||||
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
|
||||
<p><strong>رمز عبور:</strong> <code>{cabinet_password}</code></p>
|
||||
</div>
|
||||
"""
|
||||
if cabinet_password
|
||||
else ''
|
||||
)
|
||||
|
||||
bodies = {
|
||||
'ru': f"""
|
||||
<h2>Ваша VPN подписка готова!</h2>
|
||||
@@ -1356,6 +1451,7 @@ class EmailNotificationTemplates:
|
||||
<p>Тариф: <strong>{tariff_name}</strong></p>
|
||||
<p>Период: <strong>{period_days} дней</strong></p>
|
||||
</div>
|
||||
{creds_block_ru}
|
||||
<p>Подписка активирована в вашем личном кабинете.</p>
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти в личный кабинет</a></p>
|
||||
""",
|
||||
@@ -1365,6 +1461,7 @@ class EmailNotificationTemplates:
|
||||
<p>Plan: <strong>{tariff_name}</strong></p>
|
||||
<p>Period: <strong>{period_days} days</strong></p>
|
||||
</div>
|
||||
{creds_block_en}
|
||||
<p>Your subscription has been activated in your cabinet.</p>
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Go to Cabinet</a></p>
|
||||
""",
|
||||
@@ -1374,6 +1471,7 @@ class EmailNotificationTemplates:
|
||||
<p>套餐: <strong>{tariff_name}</strong></p>
|
||||
<p>期限: <strong>{period_days} 天</strong></p>
|
||||
</div>
|
||||
{creds_block_zh}
|
||||
<p>订阅已在您的个人中心激活。</p>
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">前往个人中心</a></p>
|
||||
""",
|
||||
@@ -1383,6 +1481,7 @@ class EmailNotificationTemplates:
|
||||
<p>Тариф: <strong>{tariff_name}</strong></p>
|
||||
<p>Період: <strong>{period_days} днів</strong></p>
|
||||
</div>
|
||||
{creds_block_ua}
|
||||
<p>Підписка активована у вашому особистому кабінеті.</p>
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти до кабінету</a></p>
|
||||
""",
|
||||
@@ -1392,6 +1491,7 @@ class EmailNotificationTemplates:
|
||||
<p>طرح: <strong>{tariff_name}</strong></p>
|
||||
<p>مدت: <strong>{period_days} روز</strong></p>
|
||||
</div>
|
||||
{creds_block_fa}
|
||||
<p>اشتراک شما در پنل کاربری فعال شده است.</p>
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">رفتن به پنل کاربری</a></p>
|
||||
""",
|
||||
|
||||
+86
-24
@@ -7,7 +7,7 @@ from collections import defaultdict
|
||||
from datetime import time
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import quote as _url_quote, urlparse
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import structlog
|
||||
@@ -56,6 +56,17 @@ class Settings(BaseSettings):
|
||||
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID: int | None = None
|
||||
ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID: int | None = None
|
||||
|
||||
# Раздельные топики для уведомлений (если не задано — fallback на ADMIN_NOTIFICATIONS_TOPIC_ID)
|
||||
ADMIN_NOTIFICATIONS_PURCHASES_TOPIC_ID: int | None = None # Покупки подписок
|
||||
ADMIN_NOTIFICATIONS_RENEWALS_TOPIC_ID: int | None = None # Продления
|
||||
ADMIN_NOTIFICATIONS_TRIALS_TOPIC_ID: int | None = None # Триалы
|
||||
ADMIN_NOTIFICATIONS_BALANCE_TOPIC_ID: int | None = None # Пополнение баланса
|
||||
ADMIN_NOTIFICATIONS_ADDONS_TOPIC_ID: int | None = None # Докупка трафика/устройств/серверов
|
||||
ADMIN_NOTIFICATIONS_INFRASTRUCTURE_TOPIC_ID: int | None = None # Ноды, техработы, статус панели
|
||||
ADMIN_NOTIFICATIONS_ERRORS_TOPIC_ID: int | None = None # Ошибки бота
|
||||
ADMIN_NOTIFICATIONS_PROMO_TOPIC_ID: int | None = None # Промокоды, кампании, промогруппы
|
||||
ADMIN_NOTIFICATIONS_PARTNERS_TOPIC_ID: int | None = None # Партнёрки, выводы, админ-действия
|
||||
|
||||
# Настройки очереди чеков NaloGO
|
||||
NALOGO_QUEUE_CHECK_INTERVAL: int = 300 # Интервал проверки очереди (секунды)
|
||||
NALOGO_QUEUE_RECEIPT_DELAY: int = 3 # Задержка между отправкой чеков (секунды)
|
||||
@@ -107,6 +118,7 @@ class Settings(BaseSettings):
|
||||
REMNAWAVE_WEBHOOK_ENABLED: bool = False
|
||||
REMNAWAVE_WEBHOOK_PATH: str = '/remnawave-webhook'
|
||||
REMNAWAVE_WEBHOOK_SECRET: str | None = None # HMAC-SHA256 shared secret (min 32 chars)
|
||||
REMNAWAVE_WEBHOOK_NOTIFY_NODE_CONNECTION_STATUS: bool = True
|
||||
|
||||
# Webhook user notification toggles (what Telegram messages users receive from webhook events)
|
||||
WEBHOOK_NOTIFY_USER_ENABLED: bool = True
|
||||
@@ -353,10 +365,8 @@ class Settings(BaseSettings):
|
||||
YOOKASSA_TRUSTED_PROXY_NETWORKS: str = ''
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
|
||||
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
|
||||
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
|
||||
YOOKASSA_RECURRENT_ENABLED: bool = False
|
||||
YOOKASSA_RECURRENT_REQUIRED: bool = False
|
||||
DISABLE_TOPUP_BUTTONS: bool = False
|
||||
SUPPORT_TOPUP_ENABLED: bool = True
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10
|
||||
@@ -366,6 +376,7 @@ class Settings(BaseSettings):
|
||||
NALOGO_PASSWORD: str | None = None
|
||||
NALOGO_DEVICE_ID: str | None = None
|
||||
NALOGO_STORAGE_PATH: str = './nalogo_tokens.json'
|
||||
NALOGO_PROXY_URL: str | None = None # SOCKS proxy for nalog.ru; falls back to PROXY_URL if not set
|
||||
|
||||
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
|
||||
|
||||
@@ -793,6 +804,27 @@ class Settings(BaseSettings):
|
||||
BAN_SYSTEM_API_TOKEN: str | None = None
|
||||
BAN_SYSTEM_REQUEST_TIMEOUT: int = 30
|
||||
|
||||
# SOCKS5 proxy for routing bot traffic to Telegram API
|
||||
# Format: socks5://user:password@host:port or socks5://host:port
|
||||
PROXY_URL: str | None = None
|
||||
|
||||
@field_validator('PROXY_URL', 'NALOGO_PROXY_URL', mode='before')
|
||||
@classmethod
|
||||
def validate_proxy_url(cls, value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(value)
|
||||
if parsed.scheme not in ('socks5', 'socks5h', 'socks4'):
|
||||
raise ValueError(
|
||||
f'Proxy URL must use socks5://, socks5h://, or socks4:// scheme, got: {parsed.scheme!r}. '
|
||||
'HTTP proxies are not supported for security reasons.'
|
||||
)
|
||||
if not parsed.hostname:
|
||||
raise ValueError('Proxy URL must contain a hostname')
|
||||
return value
|
||||
|
||||
@field_validator('MAIN_MENU_MODE', mode='before')
|
||||
@classmethod
|
||||
def normalize_main_menu_mode(cls, value: str | None) -> str:
|
||||
@@ -919,6 +951,17 @@ class Settings(BaseSettings):
|
||||
"""Проверяет, используется ли SQLite"""
|
||||
return 'sqlite' in self.get_database_url()
|
||||
|
||||
def get_proxy_url(self) -> str | None:
|
||||
"""Return SOCKS5 proxy URL or None."""
|
||||
return self.PROXY_URL if self.PROXY_URL else None
|
||||
|
||||
def get_nalogo_proxy_url(self) -> str | None:
|
||||
"""Return SOCKS proxy URL for nalogo or None.
|
||||
|
||||
Uses NALOGO_PROXY_URL if set, otherwise falls back to PROXY_URL.
|
||||
"""
|
||||
return self.NALOGO_PROXY_URL or self.PROXY_URL
|
||||
|
||||
def is_admin(self, telegram_id: int | None = None, email: str | None = None) -> bool:
|
||||
"""
|
||||
Check if user is admin by telegram_id or email.
|
||||
@@ -1090,12 +1133,17 @@ class Settings(BaseSettings):
|
||||
username_clean = (username or '').lstrip('@')
|
||||
full_name_value = full_name or ''
|
||||
|
||||
# Remnawave разрешает только буквы, цифры, подчёркивания и дефисы
|
||||
def _sanitize(value: str) -> str:
|
||||
result = re.sub(r'[^0-9A-Za-z_-]+', '_', value)
|
||||
return re.sub(r'_+', '_', result).strip('_-')
|
||||
|
||||
# Для email-пользователей формируем уникальный identifier
|
||||
if telegram_id:
|
||||
identifier = str(telegram_id)
|
||||
elif email:
|
||||
email_prefix = email.split('@')[0][:10]
|
||||
identifier = f'email_{email_prefix}_{user_id}' if user_id else f'email_{email_prefix}'
|
||||
email_prefix = _sanitize(email.split('@')[0][:10])
|
||||
identifier = _sanitize(f'email_{email_prefix}_{user_id}' if user_id else f'email_{email_prefix}')
|
||||
elif user_id:
|
||||
identifier = f'id_{user_id}'
|
||||
else:
|
||||
@@ -1109,20 +1157,18 @@ class Settings(BaseSettings):
|
||||
'username_clean': username_clean,
|
||||
'telegram_id': str(telegram_id) if telegram_id else identifier,
|
||||
'identifier': identifier,
|
||||
'email': email.split('@')[0] if email else '',
|
||||
'email': _sanitize(email.split('@')[0]) if email else '',
|
||||
'user_id': str(user_id) if user_id else '',
|
||||
},
|
||||
)
|
||||
|
||||
raw_username = template.format_map(values).strip()
|
||||
# Remnawave разрешает только буквы, цифры, подчёркивания и дефисы
|
||||
sanitized_username = re.sub(r'[^0-9A-Za-z_-]+', '_', raw_username)
|
||||
sanitized_username = re.sub(r'_+', '_', sanitized_username).strip('_-')
|
||||
sanitized_username = _sanitize(raw_username)
|
||||
|
||||
if not sanitized_username:
|
||||
sanitized_username = f'user_{identifier}'
|
||||
sanitized_username = _sanitize(f'user_{identifier}')
|
||||
|
||||
return sanitized_username[:36]
|
||||
return sanitized_username[:36].strip('_-') or 'user'
|
||||
|
||||
@staticmethod
|
||||
def parse_daily_time_list(raw_value: str | None) -> list[time]:
|
||||
@@ -1254,10 +1300,6 @@ class Settings(BaseSettings):
|
||||
|
||||
return bool(value)
|
||||
|
||||
def is_quick_amount_buttons_enabled(self) -> bool:
|
||||
"""Показывать ли кнопки быстрого выбора суммы пополнения."""
|
||||
return self.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not self.DISABLE_TOPUP_BUTTONS
|
||||
|
||||
def get_available_languages(self) -> list[str]:
|
||||
defaults = ['ru', 'en', 'ua', 'zh', 'fa']
|
||||
|
||||
@@ -1434,24 +1476,44 @@ class Settings(BaseSettings):
|
||||
|
||||
_CABINET_URL_DEFAULT = 'https://example.com/cabinet'
|
||||
|
||||
def _encode_referral_code(self, referral_code: str) -> str:
|
||||
"""Validate and URL-encode a referral code."""
|
||||
if not referral_code:
|
||||
raise ValueError('referral_code must not be empty or None')
|
||||
return _url_quote(referral_code, safe='')
|
||||
|
||||
def _normalized_cabinet_url(self) -> str | None:
|
||||
"""Return normalized cabinet URL, or None if not configured."""
|
||||
cabinet_url = (self.CABINET_URL or '').strip().rstrip('/')
|
||||
if not cabinet_url or cabinet_url == self._CABINET_URL_DEFAULT:
|
||||
return None
|
||||
return cabinet_url
|
||||
|
||||
def get_referral_link(self, referral_code: str, bot_username: str | None = None) -> str:
|
||||
"""Build a referral link pointing to the web cabinet.
|
||||
|
||||
Falls back to a Telegram bot deep link when CABINET_URL is not configured.
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
cabinet_link = self.get_cabinet_referral_link(referral_code)
|
||||
if cabinet_link:
|
||||
return cabinet_link
|
||||
return self.get_bot_referral_link(referral_code, bot_username)
|
||||
|
||||
if not referral_code:
|
||||
raise ValueError('referral_code must not be empty or None')
|
||||
|
||||
safe_code = quote(referral_code, safe='')
|
||||
cabinet_url = (self.CABINET_URL or '').strip().rstrip('/')
|
||||
if cabinet_url and cabinet_url != self._CABINET_URL_DEFAULT:
|
||||
sep = '&' if '?' in cabinet_url else '?'
|
||||
return f'{cabinet_url}{sep}ref={safe_code}'
|
||||
def get_bot_referral_link(self, referral_code: str, bot_username: str | None = None) -> str:
|
||||
"""Always return the Telegram bot deep link for a referral code."""
|
||||
safe_code = self._encode_referral_code(referral_code)
|
||||
username = bot_username or self.get_bot_username() or 'bot'
|
||||
return f'https://t.me/{username}?start={safe_code}'
|
||||
|
||||
def get_cabinet_referral_link(self, referral_code: str) -> str | None:
|
||||
"""Return the cabinet referral link, or None if cabinet is not configured."""
|
||||
cabinet_url = self._normalized_cabinet_url()
|
||||
if not cabinet_url:
|
||||
return None
|
||||
safe_code = self._encode_referral_code(referral_code)
|
||||
sep = '&' if '?' in cabinet_url else '?'
|
||||
return f'{cabinet_url}{sep}ref={safe_code}'
|
||||
|
||||
def is_deep_links_enabled(self) -> bool:
|
||||
return self.ENABLE_DEEP_LINKS
|
||||
|
||||
|
||||
@@ -67,8 +67,24 @@ async def get_cryptobot_payment_by_id(db: AsyncSession, payment_id: int) -> Cryp
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_cryptobot_payment_by_invoice_id_for_update(db: AsyncSession, invoice_id: str) -> CryptoBotPayment | None:
|
||||
result = await db.execute(
|
||||
select(CryptoBotPayment)
|
||||
.options(selectinload(CryptoBotPayment.user))
|
||||
.where(CryptoBotPayment.invoice_id == invoice_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_cryptobot_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> CryptoBotPayment | None:
|
||||
result = await db.execute(select(CryptoBotPayment).where(CryptoBotPayment.id == payment_id).with_for_update())
|
||||
result = await db.execute(
|
||||
select(CryptoBotPayment)
|
||||
.where(CryptoBotPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
|
||||
@@ -63,7 +63,12 @@ async def get_freekassa_payment_by_id(db: AsyncSession, payment_id: int) -> Free
|
||||
|
||||
|
||||
async def get_freekassa_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> FreekassaPayment | None:
|
||||
result = await db.execute(select(FreekassaPayment).where(FreekassaPayment.id == payment_id).with_for_update())
|
||||
result = await db.execute(
|
||||
select(FreekassaPayment)
|
||||
.where(FreekassaPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
|
||||
@@ -65,7 +65,12 @@ async def get_kassa_ai_payment_by_id(db: AsyncSession, payment_id: int) -> Kassa
|
||||
|
||||
|
||||
async def get_kassa_ai_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> KassaAiPayment | None:
|
||||
result = await db.execute(select(KassaAiPayment).where(KassaAiPayment.id == payment_id).with_for_update())
|
||||
result = await db.execute(
|
||||
select(KassaAiPayment)
|
||||
.where(KassaAiPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
|
||||
@@ -58,7 +58,12 @@ async def get_mulenpay_payment_by_local_id(db: AsyncSession, payment_id: int) ->
|
||||
|
||||
|
||||
async def get_mulenpay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> MulenPayPayment | None:
|
||||
result = await db.execute(select(MulenPayPayment).where(MulenPayPayment.id == payment_id).with_for_update())
|
||||
result = await db.execute(
|
||||
select(MulenPayPayment)
|
||||
.where(MulenPayPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
|
||||
@@ -67,7 +67,12 @@ async def get_pal24_payment_by_id(db: AsyncSession, payment_id: int) -> Pal24Pay
|
||||
|
||||
|
||||
async def get_pal24_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> Pal24Payment | None:
|
||||
result = await db.execute(select(Pal24Payment).where(Pal24Payment.id == payment_id).with_for_update())
|
||||
result = await db.execute(
|
||||
select(Pal24Payment)
|
||||
.where(Pal24Payment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
|
||||
@@ -71,7 +71,12 @@ async def get_platega_payment_by_id(db: AsyncSession, payment_id: int) -> Plateg
|
||||
|
||||
|
||||
async def get_platega_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> PlategaPayment | None:
|
||||
result = await db.execute(select(PlategaPayment).where(PlategaPayment.id == payment_id).with_for_update())
|
||||
result = await db.execute(
|
||||
select(PlategaPayment)
|
||||
.where(PlategaPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
|
||||
@@ -38,8 +38,8 @@ _POLICY_UPDATABLE_FIELDS = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
# Superadmin level constant
|
||||
_SUPERADMIN_LEVEL = 999
|
||||
# Superadmin level constant — single source of truth, imported by admin_roles and bootstrap
|
||||
SUPERADMIN_LEVEL = 999
|
||||
|
||||
|
||||
class AdminRoleCRUD:
|
||||
@@ -240,21 +240,6 @@ class UserRoleCRUD:
|
||||
logger.info('Assigned role to user', user_role_id=user_role.id, user_id=user_id, role_id=role_id)
|
||||
return user_role
|
||||
|
||||
@staticmethod
|
||||
async def revoke_role(db: AsyncSession, user_role_id: int) -> bool:
|
||||
"""Soft-revoke: set is_active=False. Returns False if not found."""
|
||||
result = await db.execute(select(UserRole).where(UserRole.id == user_role_id))
|
||||
user_role = result.scalar_one_or_none()
|
||||
if not user_role:
|
||||
return False
|
||||
|
||||
user_role.is_active = False
|
||||
await db.flush()
|
||||
logger.info(
|
||||
'Revoked user role', user_role_id=user_role_id, user_id=user_role.user_id, role_id=user_role.role_id
|
||||
)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def get_all_admins(
|
||||
db: AsyncSession,
|
||||
@@ -296,14 +281,16 @@ class UserRoleCRUD:
|
||||
|
||||
@staticmethod
|
||||
async def get_superadmin_count(db: AsyncSession) -> int:
|
||||
"""Count users with an active role at superadmin level (999)."""
|
||||
"""Count users with an active, non-expired role at superadmin level (999)."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(func.count(func.distinct(UserRole.user_id)))
|
||||
.join(AdminRole, UserRole.role_id == AdminRole.id)
|
||||
.where(
|
||||
UserRole.is_active.is_(True),
|
||||
AdminRole.is_active.is_(True),
|
||||
AdminRole.level == _SUPERADMIN_LEVEL,
|
||||
AdminRole.level == SUPERADMIN_LEVEL,
|
||||
or_(UserRole.expires_at.is_(None), UserRole.expires_at > now),
|
||||
)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
@@ -66,6 +66,17 @@ async def get_riopay_payment_by_id(db: AsyncSession, payment_id: int) -> RioPayP
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_riopay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> RioPayPayment | None:
|
||||
"""Получает платеж по ID с блокировкой FOR UPDATE (для защиты от TOCTOU race)."""
|
||||
result = await db.execute(
|
||||
select(RioPayPayment)
|
||||
.where(RioPayPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_riopay_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: RioPayPayment,
|
||||
|
||||
@@ -70,7 +70,12 @@ async def get_severpay_payment_by_id(db: AsyncSession, payment_id: int) -> Sever
|
||||
|
||||
async def get_severpay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> SeverPayPayment | None:
|
||||
"""Получает платеж по ID с блокировкой FOR UPDATE."""
|
||||
result = await db.execute(select(SeverPayPayment).where(SeverPayPayment.id == payment_id).with_for_update())
|
||||
result = await db.execute(
|
||||
select(SeverPayPayment)
|
||||
.where(SeverPayPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
|
||||
@@ -40,23 +40,18 @@ def calc_device_limit_on_tariff_switch(
|
||||
new_tariff_device_limit: int | None,
|
||||
max_device_limit: int | None = None,
|
||||
) -> int:
|
||||
"""Calculate device_limit preserving extra purchased devices when switching tariffs.
|
||||
"""Calculate device_limit when switching tariffs.
|
||||
|
||||
Extra devices = current_device_limit - old_tariff_device_limit (clamped to 0).
|
||||
Result = new_tariff_device_limit + extra_devices, capped at max_device_limit.
|
||||
Resets to new tariff base device limit — previously purchased
|
||||
extra devices are NOT carried over. Capped at max_device_limit.
|
||||
"""
|
||||
old_base = old_tariff_device_limit if old_tariff_device_limit is not None else 0
|
||||
current = current_device_limit if current_device_limit is not None else old_base
|
||||
extra = max(0, current - old_base)
|
||||
|
||||
new_base = new_tariff_device_limit if new_tariff_device_limit is not None else 1
|
||||
total = new_base + extra
|
||||
|
||||
effective_max = max_device_limit or (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
|
||||
if effective_max and total > effective_max:
|
||||
total = effective_max
|
||||
if effective_max and new_base > effective_max:
|
||||
new_base = effective_max
|
||||
|
||||
return total
|
||||
return new_base
|
||||
|
||||
|
||||
def is_active_paid_subscription(subscription: Subscription | None) -> bool:
|
||||
@@ -1447,6 +1442,7 @@ async def create_pending_subscription(
|
||||
payment_method: str = 'pending',
|
||||
total_price_kopeks: int = 0,
|
||||
is_trial: bool = False,
|
||||
tariff_id: int | None = None,
|
||||
) -> Subscription:
|
||||
"""Creates a pending subscription that will be activated after payment.
|
||||
|
||||
@@ -1480,6 +1476,8 @@ async def create_pending_subscription(
|
||||
existing_subscription.connected_squads = connected_squads or []
|
||||
existing_subscription.traffic_used_gb = 0.0
|
||||
existing_subscription.updated_at = current_time
|
||||
if tariff_id is not None:
|
||||
existing_subscription.tariff_id = tariff_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_subscription)
|
||||
@@ -1502,6 +1500,7 @@ async def create_pending_subscription(
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads or [],
|
||||
tariff_id=tariff_id,
|
||||
autopay_enabled=settings.is_autopay_enabled_by_default(),
|
||||
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
|
||||
)
|
||||
@@ -1531,6 +1530,7 @@ async def create_pending_trial_subscription(
|
||||
connected_squads: list[str] = None,
|
||||
payment_method: str = 'pending',
|
||||
total_price_kopeks: int = 0,
|
||||
tariff_id: int | None = None,
|
||||
) -> Subscription:
|
||||
"""Creates a pending trial subscription. Wrapper for create_pending_subscription with is_trial=True."""
|
||||
return await create_pending_subscription(
|
||||
@@ -1543,6 +1543,7 @@ async def create_pending_trial_subscription(
|
||||
payment_method=payment_method,
|
||||
total_price_kopeks=total_price_kopeks,
|
||||
is_trial=True,
|
||||
tariff_id=tariff_id,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -83,13 +83,16 @@ async def count_tariffs(db: AsyncSession, *, include_inactive: bool = False) ->
|
||||
async def get_trial_tariff(db: AsyncSession) -> Tariff | None:
|
||||
"""Получает тариф, доступный для триала (is_trial_available=True).
|
||||
|
||||
Триальный тариф может быть неактивным — это сделано специально,
|
||||
чтобы он не отображался в списке покупки, но использовался для триала
|
||||
со своими лимитами (трафик, устройства, серверы).
|
||||
|
||||
Сортируется по updated_at DESC, чтобы вернуть последний установленный
|
||||
триальный тариф (на случай если их несколько).
|
||||
"""
|
||||
query = (
|
||||
select(Tariff)
|
||||
.where(Tariff.is_trial_available.is_(True))
|
||||
.where(Tariff.is_active.is_(True))
|
||||
.options(selectinload(Tariff.allowed_promo_groups))
|
||||
.order_by(Tariff.updated_at.desc().nullslast(), Tariff.id.desc())
|
||||
.limit(1)
|
||||
|
||||
@@ -51,6 +51,11 @@ async def create_transaction(
|
||||
else amount_kopeks
|
||||
)
|
||||
|
||||
# Default payment_method to BALANCE for subscription/gift payments from bot (not landing)
|
||||
# to avoid double-counting with DEPOSIT in revenue calculations
|
||||
if payment_method is None and type in (TransactionType.SUBSCRIPTION_PAYMENT, TransactionType.GIFT_PAYMENT):
|
||||
payment_method = PaymentMethod.BALANCE
|
||||
|
||||
transaction = Transaction(
|
||||
user_id=user_id,
|
||||
type=type.value,
|
||||
@@ -108,7 +113,7 @@ async def create_transaction(
|
||||
|
||||
await maybe_assign_promo_group_by_total_spent(db, user_id)
|
||||
except Exception as exc:
|
||||
logger.debug('Не удалось проверить автовыдачу промогруппы для пользователя', user_id=user_id, exc=exc)
|
||||
logger.warning('Не удалось проверить автовыдачу промогруппы для пользователя', user_id=user_id, exc=exc)
|
||||
if type == TransactionType.SUBSCRIPTION_PAYMENT and is_completed:
|
||||
try:
|
||||
from app.services.referral_contest_service import referral_contest_service
|
||||
@@ -168,7 +173,7 @@ async def emit_transaction_side_effects(
|
||||
|
||||
await maybe_assign_promo_group_by_total_spent(db, user_id)
|
||||
except Exception as exc:
|
||||
logger.debug('Не удалось проверить автовыдачу промогруппы для пользователя', user_id=user_id, exc=exc)
|
||||
logger.warning('Не удалось проверить автовыдачу промогруппы для пользователя', user_id=user_id, exc=exc)
|
||||
|
||||
if type == TransactionType.SUBSCRIPTION_PAYMENT and is_completed:
|
||||
try:
|
||||
@@ -253,7 +258,7 @@ async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Tr
|
||||
|
||||
await maybe_assign_promo_group_by_total_spent(db, transaction.user_id)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
logger.warning(
|
||||
'Не удалось проверить автовыдачу промогруппы для пользователя', user_id=transaction.user_id, exc=exc
|
||||
)
|
||||
|
||||
@@ -278,11 +283,11 @@ async def get_transactions_statistics(
|
||||
if not end_date:
|
||||
end_date = datetime.now(UTC)
|
||||
|
||||
# Доход считаем только по реальным платежам (исключаем колесо, промокоды, админские пополнения)
|
||||
# Доход считаем по реальным платежам + прямые покупки подписок (лендинги)
|
||||
income_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= start_date,
|
||||
Transaction.created_at <= end_date,
|
||||
@@ -339,11 +344,11 @@ async def get_transactions_statistics(
|
||||
select(
|
||||
Transaction.payment_method,
|
||||
func.count(Transaction.id).label('count'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('total_amount'),
|
||||
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('total_amount'),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= start_date,
|
||||
Transaction.created_at <= end_date,
|
||||
@@ -363,11 +368,11 @@ async def get_transactions_statistics(
|
||||
)
|
||||
transactions_today = today_result.scalar()
|
||||
|
||||
# Доход за сегодня - только реальные платежи
|
||||
# Доход за сегодня — реальные платежи + прямые покупки подписок (лендинги)
|
||||
today_income_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= today,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
@@ -391,17 +396,17 @@ async def get_transactions_statistics(
|
||||
|
||||
|
||||
async def get_revenue_by_period(db: AsyncSession, days: int = 30) -> list[dict]:
|
||||
"""Доход по дням - только реальные платежи."""
|
||||
"""Доход по дням — реальные платежи + прямые покупки подписок (лендинги)."""
|
||||
start_date = datetime.now(UTC) - timedelta(days=days)
|
||||
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.date(Transaction.created_at).label('date'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
|
||||
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= start_date,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, desc, select
|
||||
from sqlalchemy import and_, desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -24,7 +24,7 @@ async def _sync_user_primary_promo_group(
|
||||
select(UserPromoGroup.promo_group_id)
|
||||
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
|
||||
.where(UserPromoGroup.user_id == user_id)
|
||||
.order_by(desc(PromoGroup.priority), PromoGroup.id)
|
||||
.order_by(desc(PromoGroup.priority), desc(PromoGroup.id))
|
||||
)
|
||||
|
||||
first = result.first()
|
||||
@@ -53,7 +53,12 @@ async def sync_user_primary_promo_group(
|
||||
|
||||
|
||||
async def add_user_to_promo_group(
|
||||
db: AsyncSession, user_id: int, promo_group_id: int, assigned_by: str = 'admin'
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promo_group_id: int,
|
||||
assigned_by: str = 'admin',
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> UserPromoGroup | None:
|
||||
"""
|
||||
Добавляет пользователю промогруппу.
|
||||
@@ -63,6 +68,7 @@ async def add_user_to_promo_group(
|
||||
user_id: ID пользователя
|
||||
promo_group_id: ID промогруппы
|
||||
assigned_by: Кто назначил ('admin', 'system', 'auto', 'promocode')
|
||||
commit: Коммитить транзакцию (False для батчевых операций)
|
||||
|
||||
Returns:
|
||||
UserPromoGroup или None если уже существует
|
||||
@@ -85,8 +91,9 @@ async def add_user_to_promo_group(
|
||||
|
||||
await _sync_user_primary_promo_group(db, user_id)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user_promo_group)
|
||||
if commit:
|
||||
await db.commit()
|
||||
await db.refresh(user_promo_group)
|
||||
|
||||
logger.info(
|
||||
'Пользователю добавлена промогруппа',
|
||||
@@ -98,11 +105,19 @@ async def add_user_to_promo_group(
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Ошибка добавления промогруппы пользователю', error=error)
|
||||
await db.rollback()
|
||||
return None
|
||||
if commit:
|
||||
await db.rollback()
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
async def remove_user_from_promo_group(db: AsyncSession, user_id: int, promo_group_id: int) -> bool:
|
||||
async def remove_user_from_promo_group(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promo_group_id: int,
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> bool:
|
||||
"""
|
||||
Удаляет промогруппу у пользователя.
|
||||
|
||||
@@ -110,6 +125,7 @@ async def remove_user_from_promo_group(db: AsyncSession, user_id: int, promo_gro
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
promo_group_id: ID промогруппы
|
||||
commit: Коммитить транзакцию (False для батчевых операций)
|
||||
|
||||
Returns:
|
||||
True если удалено, False если связи не было
|
||||
@@ -133,15 +149,18 @@ async def remove_user_from_promo_group(db: AsyncSession, user_id: int, promo_gro
|
||||
|
||||
await _sync_user_primary_promo_group(db, user_id)
|
||||
|
||||
await db.commit()
|
||||
if commit:
|
||||
await db.commit()
|
||||
|
||||
logger.info('У пользователя удалена промогруппа', user_id=user_id, promo_group_id=promo_group_id)
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Ошибка удаления промогруппы у пользователя', error=error)
|
||||
await db.rollback()
|
||||
return False
|
||||
if commit:
|
||||
await db.rollback()
|
||||
return False
|
||||
raise
|
||||
|
||||
|
||||
async def get_user_promo_groups(db: AsyncSession, user_id: int) -> list[UserPromoGroup]:
|
||||
@@ -155,19 +174,14 @@ async def get_user_promo_groups(db: AsyncSession, user_id: int) -> list[UserProm
|
||||
Returns:
|
||||
Список UserPromoGroup с загруженными PromoGroup, отсортированный по приоритету DESC
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup)
|
||||
.options(selectinload(UserPromoGroup.promo_group))
|
||||
.where(UserPromoGroup.user_id == user_id)
|
||||
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
|
||||
.order_by(desc(PromoGroup.priority), PromoGroup.id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Ошибка получения промогрупп пользователя', user_id=user_id, error=error)
|
||||
return []
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup)
|
||||
.options(selectinload(UserPromoGroup.promo_group))
|
||||
.where(UserPromoGroup.user_id == user_id)
|
||||
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
|
||||
.order_by(desc(PromoGroup.priority), desc(PromoGroup.id))
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_primary_user_promo_group(db: AsyncSession, user_id: int) -> PromoGroup | None:
|
||||
@@ -181,19 +195,14 @@ async def get_primary_user_promo_group(db: AsyncSession, user_id: int) -> PromoG
|
||||
Returns:
|
||||
PromoGroup с максимальным приоритетом или None
|
||||
"""
|
||||
try:
|
||||
user_promo_groups = await get_user_promo_groups(db, user_id)
|
||||
user_promo_groups = await get_user_promo_groups(db, user_id)
|
||||
|
||||
if not user_promo_groups:
|
||||
return None
|
||||
|
||||
# Первая в списке имеет максимальный приоритет (список уже отсортирован)
|
||||
return user_promo_groups[0].promo_group or None
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Ошибка получения primary промогруппы пользователя', user_id=user_id, error=error)
|
||||
if not user_promo_groups:
|
||||
return None
|
||||
|
||||
# Первая в списке имеет максимальный приоритет (список уже отсортирован)
|
||||
return user_promo_groups[0].promo_group or None
|
||||
|
||||
|
||||
async def has_user_promo_group(db: AsyncSession, user_id: int, promo_group_id: int) -> bool:
|
||||
"""
|
||||
@@ -207,17 +216,12 @@ async def has_user_promo_group(db: AsyncSession, user_id: int, promo_group_id: i
|
||||
Returns:
|
||||
True если пользователь уже имеет эту промогруппу
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup).where(
|
||||
and_(UserPromoGroup.user_id == user_id, UserPromoGroup.promo_group_id == promo_group_id)
|
||||
)
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup).where(
|
||||
and_(UserPromoGroup.user_id == user_id, UserPromoGroup.promo_group_id == promo_group_id)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Ошибка проверки промогруппы пользователя', error=error)
|
||||
return False
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def count_user_promo_groups(db: AsyncSession, user_id: int) -> int:
|
||||
@@ -232,8 +236,10 @@ async def count_user_promo_groups(db: AsyncSession, user_id: int) -> int:
|
||||
Количество промогрупп
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(select(UserPromoGroup).where(UserPromoGroup.user_id == user_id))
|
||||
return len(list(result.scalars().all()))
|
||||
result = await db.execute(
|
||||
select(func.count()).select_from(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Ошибка подсчета промогрупп пользователя', error=error)
|
||||
@@ -257,15 +263,18 @@ async def replace_user_promo_groups(
|
||||
"""
|
||||
try:
|
||||
# Удаляем все текущие промогруппы
|
||||
await db.execute(select(UserPromoGroup).where(UserPromoGroup.user_id == user_id))
|
||||
result = await db.execute(select(UserPromoGroup).where(UserPromoGroup.user_id == user_id))
|
||||
for upg in result.scalars().all():
|
||||
await db.delete(upg)
|
||||
await db.flush()
|
||||
|
||||
# Добавляем новые
|
||||
for promo_group_id in promo_group_ids:
|
||||
user_promo_group = UserPromoGroup(user_id=user_id, promo_group_id=promo_group_id, assigned_by=assigned_by)
|
||||
db.add(user_promo_group)
|
||||
await db.flush()
|
||||
|
||||
await _sync_user_primary_promo_group(db, user_id)
|
||||
|
||||
await db.commit()
|
||||
logger.info('Промогруппы пользователя заменены на', user_id=user_id, promo_group_ids=promo_group_ids)
|
||||
|
||||
@@ -72,7 +72,12 @@ async def get_wata_payment_by_id(
|
||||
|
||||
|
||||
async def get_wata_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> WataPayment | None:
|
||||
result = await db.execute(select(WataPayment).where(WataPayment.id == payment_id).with_for_update())
|
||||
result = await db.execute(
|
||||
select(WataPayment)
|
||||
.where(WataPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
|
||||
@@ -1574,6 +1574,7 @@ class Transaction(Base):
|
||||
Index('ix_transactions_type_created_completed', 'type', 'created_at', 'is_completed'),
|
||||
Index('ix_transactions_user_created', 'user_id', 'created_at'),
|
||||
Index('ix_transactions_type_method_created', 'type', 'payment_method', 'created_at'),
|
||||
Index('ix_transactions_user_type_completed_amount', 'user_id', 'type', 'is_completed', 'amount_kopeks'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
@@ -2490,7 +2491,10 @@ class AdvertisingCampaign(Base):
|
||||
|
||||
class AdvertisingCampaignRegistration(Base):
|
||||
__tablename__ = 'advertising_campaign_registrations'
|
||||
__table_args__ = (UniqueConstraint('campaign_id', 'user_id', name='uq_campaign_user'),)
|
||||
__table_args__ = (
|
||||
UniqueConstraint('campaign_id', 'user_id', name='uq_campaign_user'),
|
||||
Index('ix_campaign_reg_user_created', 'user_id', 'created_at'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
campaign_id = Column(Integer, ForeignKey('advertising_campaigns.id', ondelete='CASCADE'), nullable=False)
|
||||
@@ -3283,6 +3287,9 @@ class GuestPurchase(Base):
|
||||
cabinet_password = Column(Text, nullable=True)
|
||||
auto_login_token = Column(Text, nullable=True)
|
||||
recipient_warning = Column(String(50), nullable=True)
|
||||
retry_count = Column(Integer, nullable=False, default=0, server_default='0')
|
||||
receipt_uuid = Column(String(255), nullable=True, index=True)
|
||||
receipt_created_at = Column(AwareDateTime(), nullable=True)
|
||||
|
||||
landing = relationship('LandingPage', back_populates='guest_purchases', lazy='selectin')
|
||||
tariff = relationship('Tariff', lazy='selectin')
|
||||
|
||||
Vendored
+35
-11
@@ -1,5 +1,6 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
@@ -15,7 +16,6 @@ class CryptoBotService:
|
||||
def __init__(self):
|
||||
self.api_token = settings.CRYPTOBOT_API_TOKEN
|
||||
self.base_url = settings.get_cryptobot_base_url()
|
||||
self.webhook_secret = settings.CRYPTOBOT_WEBHOOK_SECRET
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
@@ -122,22 +122,46 @@ class CryptoBotService:
|
||||
return await self._make_request('GET', 'getExchangeRates')
|
||||
|
||||
def verify_webhook_signature(self, body: str, signature: str) -> bool:
|
||||
if not self.webhook_secret:
|
||||
logger.warning('CryptoBot webhook secret не настроен')
|
||||
# По документации CryptoBot, ключ ВСЕГДА SHA256 от API токена
|
||||
token = self.api_token
|
||||
if not token:
|
||||
logger.warning('CryptoBot API token не настроен, пропуск проверки подписи')
|
||||
return True
|
||||
|
||||
try:
|
||||
secret_hash = hashlib.sha256(self.webhook_secret.encode()).digest()
|
||||
expected_signature = hmac.new(secret_hash, body.encode(), hashlib.sha256).hexdigest()
|
||||
secret_hash = hashlib.sha256(token.encode()).digest()
|
||||
|
||||
is_valid = hmac.compare_digest(signature, expected_signature)
|
||||
# 1. Raw body — CryptoBot шлёт compact JSON
|
||||
expected = hmac.new(secret_hash, body.encode('utf-8'), hashlib.sha256).hexdigest()
|
||||
if hmac.compare_digest(signature, expected):
|
||||
logger.info('CryptoBot webhook подпись валидна (raw body)')
|
||||
return True
|
||||
|
||||
if is_valid:
|
||||
logger.info('✅ CryptoBot webhook подпись валидна')
|
||||
else:
|
||||
logger.error('❌ Неверная подпись CryptoBot webhook')
|
||||
# 2. Fallback: re-serialize compact JSON
|
||||
parsed = json.loads(body)
|
||||
check_string = json.dumps(parsed, separators=(',', ':'), ensure_ascii=False)
|
||||
expected_reserialized = hmac.new(secret_hash, check_string.encode('utf-8'), hashlib.sha256).hexdigest()
|
||||
if hmac.compare_digest(signature, expected_reserialized):
|
||||
logger.info('CryptoBot webhook подпись валидна (re-serialized)')
|
||||
return True
|
||||
|
||||
return is_valid
|
||||
# 3. Fallback: ensure_ascii=True
|
||||
check_string_ascii = json.dumps(parsed, separators=(',', ':'), ensure_ascii=True)
|
||||
expected_ascii = hmac.new(secret_hash, check_string_ascii.encode('utf-8'), hashlib.sha256).hexdigest()
|
||||
if hmac.compare_digest(signature, expected_ascii):
|
||||
logger.info('CryptoBot webhook подпись валидна (ascii-escaped)')
|
||||
return True
|
||||
|
||||
logger.error(
|
||||
'Неверная подпись CryptoBot webhook',
|
||||
received_signature=signature,
|
||||
expected_raw=expected,
|
||||
expected_reserialized=expected_reserialized,
|
||||
body_length=len(body),
|
||||
token_length=len(token),
|
||||
token_prefix=token[:4] + '...',
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка проверки подписи CryptoBot webhook', error=e)
|
||||
|
||||
Vendored
+30
-5
@@ -469,7 +469,22 @@ class RemnaWaveAPI:
|
||||
hwidDeviceLimit=data.get('hwidDeviceLimit'),
|
||||
status=data.get('status'),
|
||||
)
|
||||
response = await self._make_request('POST', '/api/users', data)
|
||||
try:
|
||||
response = await self._make_request('POST', '/api/users', data)
|
||||
except RemnaWaveAPIError as e:
|
||||
# A039 = FK violation on externalSquadUuid — retry without it
|
||||
error_code = (e.response_data or {}).get('errorCode', '')
|
||||
if error_code == 'A039' and 'externalSquadUuid' in data:
|
||||
stale_uuid = data.pop('externalSquadUuid')
|
||||
logger.warning(
|
||||
'A039 FK violation on externalSquadUuid, retrying without it',
|
||||
stale_uuid=stale_uuid,
|
||||
username=data.get('username'),
|
||||
)
|
||||
response = await self._make_request('POST', '/api/users', data)
|
||||
else:
|
||||
logger.error('POST /api/users FAILED — full payload', payload=data)
|
||||
raise
|
||||
user = self._parse_user(response['response'])
|
||||
logger.info(
|
||||
'POST /api/users response',
|
||||
@@ -570,10 +585,20 @@ class RemnaWaveAPI:
|
||||
|
||||
try:
|
||||
response = await self._make_request('PATCH', '/api/users', data)
|
||||
except Exception:
|
||||
# Логируем полный payload при ошибке для диагностики A039
|
||||
logger.error('PATCH /api/users FAILED — full payload', payload=data)
|
||||
raise
|
||||
except RemnaWaveAPIError as e:
|
||||
# A039 = FK violation on externalSquadUuid — retry without it
|
||||
error_code = (e.response_data or {}).get('errorCode', '')
|
||||
if error_code == 'A039' and 'externalSquadUuid' in data:
|
||||
stale_uuid = data.pop('externalSquadUuid')
|
||||
logger.warning(
|
||||
'A039 FK violation on externalSquadUuid, retrying without it',
|
||||
stale_uuid=stale_uuid,
|
||||
uuid=uuid,
|
||||
)
|
||||
response = await self._make_request('PATCH', '/api/users', data)
|
||||
else:
|
||||
logger.error('PATCH /api/users FAILED — full payload', payload=data)
|
||||
raise
|
||||
user = self._parse_user(response['response'])
|
||||
logger.info(
|
||||
'PATCH /api/users response',
|
||||
|
||||
Vendored
+4
-1
@@ -377,7 +377,10 @@ class WebhookServer:
|
||||
signature = request.headers.get('Crypto-Pay-API-Signature')
|
||||
logger.info('CryptoBot Signature', signature=signature)
|
||||
|
||||
if signature and settings.CRYPTOBOT_WEBHOOK_SECRET:
|
||||
if settings.CRYPTOBOT_API_TOKEN:
|
||||
if not signature:
|
||||
logger.error('CryptoBot webhook без подписи')
|
||||
return web.json_response({'status': 'error', 'reason': 'missing_signature'}, status=401)
|
||||
from app.external.cryptobot import CryptoBotService
|
||||
|
||||
cryptobot_service = CryptoBotService()
|
||||
|
||||
@@ -83,7 +83,6 @@ CABINET_MINIAPP_BUTTON_KEYS = {
|
||||
'connect',
|
||||
'subscription',
|
||||
'support',
|
||||
'home',
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +96,11 @@ def get_updated_message_buttons_selector_keyboard(
|
||||
return get_updated_message_buttons_selector_keyboard_with_media(selected_buttons, False, language)
|
||||
|
||||
|
||||
def create_broadcast_keyboard(selected_buttons: list, language: str = 'ru') -> types.InlineKeyboardMarkup | None:
|
||||
def create_broadcast_keyboard(
|
||||
selected_buttons: list,
|
||||
language: str = 'ru',
|
||||
custom_buttons: list[dict] | None = None,
|
||||
) -> types.InlineKeyboardMarkup | None:
|
||||
selected_buttons = selected_buttons or []
|
||||
keyboard: list[list[types.InlineKeyboardButton]] = []
|
||||
button_config_map = get_broadcast_button_config(language)
|
||||
@@ -123,6 +126,20 @@ def create_broadcast_keyboard(selected_buttons: list, language: str = 'ru') -> t
|
||||
if row_buttons:
|
||||
keyboard.append(row_buttons)
|
||||
|
||||
# Append custom buttons (each on its own row)
|
||||
if custom_buttons:
|
||||
for btn in custom_buttons:
|
||||
label = btn.get('label', '')
|
||||
action_type = btn.get('action_type', 'callback')
|
||||
action_value = btn.get('action_value', '')
|
||||
if not label or not action_value:
|
||||
continue
|
||||
if action_type == 'url':
|
||||
keyboard.append([types.InlineKeyboardButton(text=label, url=action_value)])
|
||||
else:
|
||||
# callback type
|
||||
keyboard.append([types.InlineKeyboardButton(text=label, callback_data=action_value)])
|
||||
|
||||
if not keyboard:
|
||||
return None
|
||||
|
||||
|
||||
@@ -462,28 +462,17 @@ async def show_promo_groups_menu(
|
||||
|
||||
keyboard_rows = []
|
||||
for group, member_count in groups:
|
||||
icon = '⭐' if group.is_default else '🎯'
|
||||
default_suffix = texts.t('ADMIN_PROMO_GROUPS_DEFAULT_LABEL', ' (базовая)') if group.is_default else ''
|
||||
group_lines = [
|
||||
f'{"⭐" if group.is_default else "🎯"} <b>{group.name}</b>{default_suffix}',
|
||||
]
|
||||
group_lines.extend(_format_discount_lines(texts, group))
|
||||
group_lines.append(_format_auto_assign_line(texts, group))
|
||||
group_lines.append(
|
||||
texts.t(
|
||||
'ADMIN_PROMO_GROUPS_MEMBERS_COUNT',
|
||||
'Участников: {count}',
|
||||
).format(count=member_count)
|
||||
)
|
||||
|
||||
period_lines = _format_period_discounts_lines(texts, group, db_user.language)
|
||||
group_lines.extend(period_lines)
|
||||
group_lines.append('')
|
||||
|
||||
lines.extend(group_lines)
|
||||
members_label = texts.t(
|
||||
'ADMIN_PROMO_GROUPS_MEMBERS_COUNT',
|
||||
'Участников: {count}',
|
||||
).format(count=member_count)
|
||||
lines.append(f'{icon} <b>{group.name}</b>{default_suffix} — {members_label}')
|
||||
keyboard_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f'{"⭐" if group.is_default else "🎯"} {group.name}',
|
||||
text=f'{icon} {group.name}',
|
||||
callback_data=f'promo_group_manage_{group.id}',
|
||||
)
|
||||
]
|
||||
|
||||
@@ -1019,7 +1019,7 @@ async def delete_user_account(callback: types.CallbackQuery, db_user: User, db:
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
user_service = UserService()
|
||||
delete_result = await user_service.delete_user_account(db, user_id, db_user.id)
|
||||
delete_result = await user_service.delete_user_account(db, user_id, db_user.id, force_panel_delete=True)
|
||||
|
||||
if delete_result.bot_deleted:
|
||||
await callback.message.edit_text(
|
||||
@@ -4571,7 +4571,7 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
update_kwargs = dict(
|
||||
uuid=target_user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
|
||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.DISABLED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3)
|
||||
if subscription.traffic_limit_gb > 0
|
||||
@@ -4608,7 +4608,7 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
|
||||
create_kwargs = dict(
|
||||
username=username,
|
||||
expire_at=subscription.end_date,
|
||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
|
||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.DISABLED,
|
||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3)
|
||||
if subscription.traffic_limit_gb > 0
|
||||
else 0,
|
||||
|
||||
@@ -129,9 +129,9 @@ async def process_cloudpayments_payment_amount(
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly (called from quick_amount handlers).
|
||||
Process payment amount directly.
|
||||
|
||||
Similar to process_heleket_payment_amount and other payment handlers.
|
||||
Similar to other payment amount handlers.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -197,7 +197,7 @@ async def start_cloudpayments_payment(
|
||||
"""
|
||||
Start CloudPayments payment flow.
|
||||
|
||||
Shows amount input prompt or quick amount buttons.
|
||||
Shows amount input prompt.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -375,123 +375,3 @@ async def process_cloudpayments_amount(
|
||||
)
|
||||
|
||||
logger.info('CloudPayments payment created: user amount=₽', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_cloudpayments_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Handle quick amount selection for CloudPayments.
|
||||
|
||||
Called when user clicks a predefined amount button.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_cloudpayments_enabled():
|
||||
await callback.answer(
|
||||
texts.t('CLOUDPAYMENTS_NOT_AVAILABLE', 'CloudPayments временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Extract amount from callback data: topup_amount|cloudpayments|{amount_kopeks}
|
||||
try:
|
||||
parts = callback.data.split('|')
|
||||
if len(parts) >= 3:
|
||||
amount_kopeks = int(parts[2])
|
||||
else:
|
||||
await callback.answer('Invalid callback data', show_alert=True)
|
||||
return
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer('Invalid amount', show_alert=True)
|
||||
return
|
||||
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
# Validate amount
|
||||
if amount_kopeks < settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS:
|
||||
await callback.answer(
|
||||
texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS:
|
||||
await callback.answer(
|
||||
texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
# Create payment
|
||||
payment_service = PaymentService()
|
||||
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description=settings.CLOUDPAYMENTS_DESCRIPTION,
|
||||
)
|
||||
|
||||
result = await payment_service.create_cloudpayments_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
telegram_id=db_user.telegram_id,
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not result:
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'PAYMENT_CREATE_ERROR',
|
||||
'Не удалось создать платёж. Попробуйте позже.',
|
||||
),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
|
||||
# Create keyboard with payment button
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
'PAY_BUTTON',
|
||||
'💳 Оплатить {amount}₽',
|
||||
).format(amount=f'{amount_rub:.0f}'),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '◀️ Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'CLOUDPAYMENTS_PAYMENT_CREATED',
|
||||
'💳 <b>Оплата банковской картой</b>\n\n'
|
||||
'Сумма: <b>{amount}₽</b>\n\n'
|
||||
'Нажмите кнопку ниже для оплаты.\n'
|
||||
'После успешной оплаты баланс будет пополнен автоматически.',
|
||||
).format(amount=f'{amount_rub:.2f}'),
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'CloudPayments payment created (quick): user amount=₽', telegram_id=db_user.telegram_id, amount_rub=amount_rub
|
||||
)
|
||||
|
||||
@@ -53,41 +53,18 @@ async def start_cryptobot_payment(callback: types.CallbackQuery, db_user: User,
|
||||
available_assets = settings.get_cryptobot_assets()
|
||||
assets_text = ', '.join(available_assets)
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
message_text = (
|
||||
f'🪙 <b>Пополнение криптовалютой</b>\n\n'
|
||||
f'Выберите сумму пополнения или введите вручную сумму '
|
||||
f'от 100 до 100,000 ₽:\n\n'
|
||||
f'💰 Доступные активы: {assets_text}\n'
|
||||
f'⚡ Мгновенное зачисление на баланс\n'
|
||||
f'🔒 Безопасная оплата через CryptoBot\n\n'
|
||||
f'{rate_text}\n'
|
||||
f'Сумма будет автоматически конвертирована в USD для оплаты.'
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f'🪙 <b>Пополнение криптовалютой</b>\n\n'
|
||||
f'Введите сумму для пополнения от 100 до 100,000 ₽:\n\n'
|
||||
f'💰 Доступные активы: {assets_text}\n'
|
||||
f'⚡ Мгновенное зачисление на баланс\n'
|
||||
f'🔒 Безопасная оплата через CryptoBot\n\n'
|
||||
f'{rate_text}\n'
|
||||
f'Сумма будет автоматически конвертирована в USD для оплаты.'
|
||||
)
|
||||
message_text = (
|
||||
f'🪙 <b>Пополнение криптовалютой</b>\n\n'
|
||||
f'Введите сумму для пополнения от 100 до 100,000 ₽:\n\n'
|
||||
f'💰 Доступные активы: {assets_text}\n'
|
||||
f'⚡ Мгновенное зачисление на баланс\n'
|
||||
f'🔒 Безопасная оплата через CryptoBot\n\n'
|
||||
f'{rate_text}\n'
|
||||
f'Сумма будет автоматически конвертирована в USD для оплаты.'
|
||||
)
|
||||
|
||||
# Создаем клавиатуру
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
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)
|
||||
|
||||
@@ -154,7 +154,7 @@ async def process_freekassa_payment_amount(
|
||||
payment_method: str | None = None,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly (called from quick_amount handlers).
|
||||
Process payment amount directly.
|
||||
payment_method: 'freekassa', 'freekassa_sbp', 'freekassa_card'
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
@@ -370,127 +370,3 @@ async def process_freekassa_custom_amount(
|
||||
state=state,
|
||||
payment_method=data.get('payment_method'),
|
||||
)
|
||||
|
||||
|
||||
async def _process_freekassa_quick_amount_impl(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
payment_method: str,
|
||||
):
|
||||
"""
|
||||
Process quick amount selection for Freekassa payment.
|
||||
Called when user clicks a predefined amount button.
|
||||
payment_method: 'freekassa', 'freekassa_sbp', 'freekassa_card'
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_freekassa_enabled():
|
||||
await callback.answer(
|
||||
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if payment_method == 'freekassa_sbp' and not settings.is_freekassa_sbp_enabled():
|
||||
await callback.answer(
|
||||
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if payment_method == 'freekassa_card' and not settings.is_freekassa_card_enabled():
|
||||
await callback.answer(
|
||||
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Extract amount from callback data: topup_amount|{method}|{amount_kopeks}
|
||||
try:
|
||||
parts = callback.data.split('|')
|
||||
if len(parts) >= 3:
|
||||
amount_kopeks = int(parts[2])
|
||||
else:
|
||||
await callback.answer('Invalid callback data', show_alert=True)
|
||||
return
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer('Invalid amount', show_alert=True)
|
||||
return
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.FREEKASSA_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.FREEKASSA_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await callback.answer(
|
||||
texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await callback.answer(
|
||||
texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
await state.clear()
|
||||
|
||||
await _create_freekassa_payment_and_respond(
|
||||
message_or_callback=callback.message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=True,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_freekassa_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_freekassa_sbp_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa_sbp')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_freekassa_card_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa_card')
|
||||
|
||||
@@ -72,13 +72,6 @@ async def start_heleket_payment(
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
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,
|
||||
|
||||
@@ -168,7 +168,7 @@ async def process_kassa_ai_payment_amount(
|
||||
state: FSMContext,
|
||||
payment_method: str = 'kassa_ai',
|
||||
):
|
||||
"""Process payment amount directly (called from custom_amount and quick_amount handlers)."""
|
||||
"""Process payment amount directly (called from custom_amount handlers)."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
@@ -275,54 +275,6 @@ async def _start_kassa_ai_sub_topup(
|
||||
)
|
||||
|
||||
|
||||
async def _process_kassa_ai_sub_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
payment_method: str,
|
||||
):
|
||||
"""Generic quick amount handler for any KassaAI sub-method."""
|
||||
cfg = _KASSA_AI_METHOD_CONFIG[payment_method]
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not cfg['is_enabled']():
|
||||
await callback.answer(texts.t('KASSA_AI_NOT_AVAILABLE', cfg['unavailable_text']), show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
parts = callback.data.split('|')
|
||||
amount_kopeks = int(parts[2]) if len(parts) >= 3 else None
|
||||
if amount_kopeks is None:
|
||||
raise ValueError
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer('Invalid amount', show_alert=True)
|
||||
return
|
||||
|
||||
if await _check_topup_restriction(callback, db_user):
|
||||
return
|
||||
|
||||
min_amount = settings.KASSA_AI_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.KASSA_AI_MAX_AMOUNT_KOPEKS
|
||||
if amount_kopeks < min_amount:
|
||||
await callback.answer(texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'), show_alert=True)
|
||||
return
|
||||
if amount_kopeks > max_amount:
|
||||
await callback.answer(texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'), show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
await state.clear()
|
||||
await _create_kassa_ai_payment_and_respond(
|
||||
message_or_callback=callback.message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=True,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
|
||||
# --- Public handler functions (registered in main.py) ---
|
||||
|
||||
|
||||
@@ -376,17 +328,6 @@ async def process_kassa_ai_custom_amount(
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_kassa_ai_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Process quick amount selection for KassaAI payment."""
|
||||
await _process_kassa_ai_sub_quick_amount(callback, db_user, db, state, 'kassa_ai')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_kassa_ai_sbp_topup(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -398,17 +339,6 @@ async def start_kassa_ai_sbp_topup(
|
||||
await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai_sbp')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_kassa_ai_sbp_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Process quick amount for KassaAI SBP."""
|
||||
await _process_kassa_ai_sub_quick_amount(callback, db_user, db, state, 'kassa_ai_sbp')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_kassa_ai_card_topup(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -418,14 +348,3 @@ async def start_kassa_ai_card_topup(
|
||||
):
|
||||
"""Start KassaAI Card top-up process."""
|
||||
await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai_card')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_kassa_ai_card_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Process quick amount for KassaAI Card."""
|
||||
await _process_kassa_ai_sub_quick_amount(callback, db_user, db, state, 'kassa_ai_card')
|
||||
|
||||
+17
-149
@@ -157,102 +157,6 @@ async def route_payment_by_method(
|
||||
return False
|
||||
|
||||
|
||||
async def get_quick_amount_buttons(language: str, user: User) -> list:
|
||||
"""
|
||||
Generate quick amount buttons with user-specific pricing and discounts.
|
||||
|
||||
Uses PricingEngine as the single source of truth for all price calculations,
|
||||
including base period price, devices, servers, traffic, and per-category discounts.
|
||||
|
||||
Args:
|
||||
language: User's language for formatting
|
||||
user: User object to calculate personalized discounts
|
||||
|
||||
Returns:
|
||||
List of button rows for inline keyboard
|
||||
"""
|
||||
if not settings.is_quick_amount_buttons_enabled():
|
||||
return []
|
||||
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
texts = get_texts(language)
|
||||
|
||||
buttons = []
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
tariff = None
|
||||
tariff_periods = None
|
||||
if settings.is_tariffs_mode() and subscription and subscription.tariff_id:
|
||||
tariff = subscription.tariff
|
||||
if tariff and tariff.period_prices:
|
||||
tariff_periods = sorted(int(k) for k in tariff.period_prices.keys())
|
||||
|
||||
if tariff_periods:
|
||||
periods = tariff_periods[:6]
|
||||
else:
|
||||
periods = settings.get_available_subscription_periods()[:6]
|
||||
|
||||
for period in periods:
|
||||
try:
|
||||
if tariff and tariff_periods and period in tariff_periods:
|
||||
result = await pricing_engine.calculate_tariff_purchase_price(
|
||||
tariff,
|
||||
period,
|
||||
device_limit=subscription.device_limit if subscription else None,
|
||||
user=user,
|
||||
)
|
||||
elif subscription:
|
||||
result = await pricing_engine.calculate_renewal_price(db, subscription, period, user=user)
|
||||
else:
|
||||
result = await pricing_engine.calculate_classic_new_subscription_price(
|
||||
db,
|
||||
period,
|
||||
[],
|
||||
0,
|
||||
settings.DEFAULT_DEVICE_LIMIT,
|
||||
user=user,
|
||||
)
|
||||
|
||||
total_price = result.final_total
|
||||
original_total = result.original_total
|
||||
|
||||
if total_price <= 0:
|
||||
continue
|
||||
|
||||
callback_data = f'quick_amount_{total_price}'
|
||||
period_label = f'{period} дней'
|
||||
|
||||
has_discount = original_total > total_price and original_total > 0
|
||||
if has_discount:
|
||||
discount_pct = round((original_total - total_price) * 100 / original_total)
|
||||
if discount_pct > 0:
|
||||
button_text = (
|
||||
f'{texts.format_price(original_total)} ➜ '
|
||||
f'{texts.format_price(total_price)} '
|
||||
f'(-{discount_pct}%) • {period_label}'
|
||||
)
|
||||
else:
|
||||
button_text = f'{texts.format_price(total_price)} • {period_label}'
|
||||
else:
|
||||
button_text = f'{texts.format_price(total_price)} • {period_label}'
|
||||
|
||||
buttons.append(types.InlineKeyboardButton(text=button_text, callback_data=callback_data))
|
||||
except Exception:
|
||||
logger.warning('Failed to calculate price for period', period=period)
|
||||
continue
|
||||
|
||||
keyboard_rows = []
|
||||
for i in range(0, len(buttons), 2):
|
||||
keyboard_rows.append(buttons[i : i + 2])
|
||||
|
||||
return keyboard_rows
|
||||
|
||||
|
||||
@error_handler
|
||||
async def show_balance_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
# Проверяем, доступно ли сообщение
|
||||
@@ -379,9 +283,22 @@ async def show_payment_methods(callback: types.CallbackQuery, db_user: User, db:
|
||||
|
||||
payment_text = get_payment_methods_text(db_user.language)
|
||||
|
||||
# Проверяем сохранённую корзину для автоподстановки суммы пополнения
|
||||
amount_kopeks = 0
|
||||
try:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
|
||||
cart_data = await user_cart_service.get_user_cart(db_user.id)
|
||||
if cart_data and cart_data.get('saved_cart'):
|
||||
missing = cart_data.get('missing_amount', 0)
|
||||
if missing > 0:
|
||||
amount_kopeks = missing
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
full_text = payment_text
|
||||
|
||||
keyboard = get_payment_methods_keyboard(0, db_user.language)
|
||||
keyboard = get_payment_methods_keyboard(amount_kopeks, db_user.language)
|
||||
|
||||
# Если сообщение недоступно, отправляем новое
|
||||
if isinstance(callback.message, InaccessibleMessage):
|
||||
@@ -627,37 +544,6 @@ async def handle_sbp_payment(callback: types.CallbackQuery, db: AsyncSession):
|
||||
await callback.answer('❌ Ошибка обработки платежа', show_alert=True)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_quick_amount_selection(callback: types.CallbackQuery, db_user: User, state: FSMContext):
|
||||
"""
|
||||
Обработчик выбора суммы через кнопки быстрого выбора
|
||||
"""
|
||||
# Проверяем, что пользователь в правильном состоянии FSM
|
||||
current_state = await state.get_state()
|
||||
if current_state != BalanceStates.waiting_for_amount:
|
||||
await callback.answer('❌ Сначала выберите способ оплаты', show_alert=True)
|
||||
return
|
||||
|
||||
# Извлекаем сумму из callback_data
|
||||
try:
|
||||
amount_kopeks = int(callback.data.split('_')[-1])
|
||||
|
||||
# Получаем метод оплаты из состояния
|
||||
data = await state.get_data()
|
||||
payment_method = data.get('payment_method', 'yookassa')
|
||||
|
||||
# Роутим платеж на соответствующий обработчик
|
||||
if not await route_payment_by_method(callback.message, db_user, amount_kopeks, state, payment_method):
|
||||
await callback.answer('❌ Неизвестный способ оплаты', show_alert=True)
|
||||
return
|
||||
|
||||
except ValueError:
|
||||
await callback.answer('❌ Ошибка обработки суммы', show_alert=True)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка обработки быстрого выбора суммы', error=e)
|
||||
await callback.answer('❌ Ошибка обработки запроса', show_alert=True)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_topup_amount_callback(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -784,52 +670,37 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
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 .cloudpayments import handle_cloudpayments_quick_amount, start_cloudpayments_payment
|
||||
from .cloudpayments import start_cloudpayments_payment
|
||||
|
||||
dp.callback_query.register(start_cloudpayments_payment, F.data == 'topup_cloudpayments')
|
||||
dp.callback_query.register(handle_cloudpayments_quick_amount, F.data.startswith('topup_amount|cloudpayments|'))
|
||||
|
||||
from .freekassa import (
|
||||
process_freekassa_card_quick_amount,
|
||||
process_freekassa_quick_amount,
|
||||
process_freekassa_sbp_quick_amount,
|
||||
start_freekassa_card_topup,
|
||||
start_freekassa_sbp_topup,
|
||||
start_freekassa_topup,
|
||||
)
|
||||
|
||||
dp.callback_query.register(start_freekassa_topup, F.data == 'topup_freekassa')
|
||||
dp.callback_query.register(process_freekassa_quick_amount, F.data.startswith('topup_amount|freekassa|'))
|
||||
dp.callback_query.register(start_freekassa_sbp_topup, F.data == 'topup_freekassa_sbp')
|
||||
dp.callback_query.register(process_freekassa_sbp_quick_amount, F.data.startswith('topup_amount|freekassa_sbp|'))
|
||||
dp.callback_query.register(start_freekassa_card_topup, F.data == 'topup_freekassa_card')
|
||||
dp.callback_query.register(process_freekassa_card_quick_amount, F.data.startswith('topup_amount|freekassa_card|'))
|
||||
|
||||
from .kassa_ai import (
|
||||
process_kassa_ai_card_quick_amount,
|
||||
process_kassa_ai_quick_amount,
|
||||
process_kassa_ai_sbp_quick_amount,
|
||||
start_kassa_ai_card_topup,
|
||||
start_kassa_ai_sbp_topup,
|
||||
start_kassa_ai_topup,
|
||||
)
|
||||
|
||||
dp.callback_query.register(start_kassa_ai_topup, F.data == 'topup_kassa_ai')
|
||||
dp.callback_query.register(process_kassa_ai_quick_amount, F.data.startswith('topup_amount|kassa_ai|'))
|
||||
dp.callback_query.register(start_kassa_ai_sbp_topup, F.data == 'topup_kassa_ai_sbp')
|
||||
dp.callback_query.register(process_kassa_ai_sbp_quick_amount, F.data.startswith('topup_amount|kassa_ai_sbp|'))
|
||||
dp.callback_query.register(start_kassa_ai_card_topup, F.data == 'topup_kassa_ai_card')
|
||||
dp.callback_query.register(process_kassa_ai_card_quick_amount, F.data.startswith('topup_amount|kassa_ai_card|'))
|
||||
|
||||
from .riopay import process_riopay_quick_amount, start_riopay_topup
|
||||
from .riopay import start_riopay_topup
|
||||
|
||||
dp.callback_query.register(start_riopay_topup, F.data == 'topup_riopay')
|
||||
dp.callback_query.register(process_riopay_quick_amount, F.data.startswith('topup_amount|riopay|'))
|
||||
|
||||
from .severpay import process_severpay_quick_amount, start_severpay_topup
|
||||
from .severpay import start_severpay_topup
|
||||
|
||||
dp.callback_query.register(start_severpay_topup, F.data == 'topup_severpay')
|
||||
dp.callback_query.register(process_severpay_quick_amount, F.data.startswith('topup_amount|severpay|'))
|
||||
|
||||
from .mulenpay import check_mulenpay_payment_status
|
||||
|
||||
@@ -849,9 +720,6 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(handle_payment_methods_unavailable, F.data == 'payment_methods_unavailable')
|
||||
|
||||
# Регистрируем обработчик для кнопок быстрого выбора суммы
|
||||
dp.callback_query.register(handle_quick_amount_selection, F.data.startswith('quick_amount_'))
|
||||
|
||||
dp.callback_query.register(handle_topup_amount_callback, F.data.startswith('topup_amount|'))
|
||||
|
||||
dp.callback_query.register(handle_saved_cards_list, F.data == 'saved_cards_list')
|
||||
|
||||
@@ -65,13 +65,6 @@ async def start_mulenpay_payment(
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
|
||||
@@ -303,13 +303,6 @@ async def start_pal24_payment(
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
|
||||
@@ -71,13 +71,6 @@ async def _prompt_amount(
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await message.edit_text(
|
||||
prompt_template.format(
|
||||
method_name=method_name,
|
||||
|
||||
@@ -136,7 +136,7 @@ async def process_riopay_payment_amount(
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly (called from quick_amount handlers).
|
||||
Process payment amount directly.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -282,75 +282,3 @@ async def process_riopay_custom_amount(
|
||||
amount_kopeks=amount_kopeks,
|
||||
state=state,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_riopay_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process quick amount selection for RioPay payment.
|
||||
Called when user clicks a predefined amount button.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_riopay_enabled():
|
||||
await callback.answer(
|
||||
texts.t('RIOPAY_NOT_AVAILABLE', 'RioPay временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Extract amount from callback data: topup_amount|riopay|{amount_kopeks}
|
||||
try:
|
||||
parts = callback.data.split('|')
|
||||
if len(parts) >= 3:
|
||||
amount_kopeks = int(parts[2])
|
||||
else:
|
||||
await callback.answer('Invalid callback data', show_alert=True)
|
||||
return
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer('Invalid amount', show_alert=True)
|
||||
return
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
await callback.message.edit_text(
|
||||
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.RIOPAY_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.RIOPAY_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await callback.answer(
|
||||
texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await callback.answer(
|
||||
texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
await state.clear()
|
||||
|
||||
await _create_riopay_payment_and_respond(
|
||||
message_or_callback=callback.message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=True,
|
||||
)
|
||||
|
||||
@@ -136,7 +136,7 @@ async def process_severpay_payment_amount(
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly (called from quick_amount handlers).
|
||||
Process payment amount directly.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -243,75 +243,3 @@ async def start_severpay_topup(
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_severpay_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process quick amount selection for SeverPay payment.
|
||||
Called when user clicks a predefined amount button.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_severpay_enabled():
|
||||
await callback.answer(
|
||||
texts.t('SEVERPAY_NOT_AVAILABLE', 'SeverPay временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Extract amount from callback data: topup_amount|severpay|{amount_kopeks}
|
||||
try:
|
||||
parts = callback.data.split('|')
|
||||
if len(parts) >= 3:
|
||||
amount_kopeks = int(parts[2])
|
||||
else:
|
||||
await callback.answer('Invalid callback data', show_alert=True)
|
||||
return
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer('Invalid amount', show_alert=True)
|
||||
return
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
await callback.message.edit_text(
|
||||
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.SEVERPAY_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.SEVERPAY_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await callback.answer(
|
||||
texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await callback.answer(
|
||||
texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
await state.clear()
|
||||
|
||||
await _create_severpay_payment_and_respond(
|
||||
message_or_callback=callback.message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=True,
|
||||
)
|
||||
|
||||
@@ -40,24 +40,10 @@ async def start_stars_payment(callback: types.CallbackQuery, db_user: User, stat
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
message_text = '⭐ <b>Пополнение через Telegram Stars</b>\n\nВыберите сумму пополнения или введите вручную:'
|
||||
else:
|
||||
message_text = texts.TOP_UP_AMOUNT
|
||||
message_text = texts.TOP_UP_AMOUNT
|
||||
|
||||
# Создаем клавиатуру
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(message_text, reply_markup=keyboard)
|
||||
|
||||
await state.update_data(
|
||||
|
||||
@@ -61,13 +61,6 @@ async def start_wata_payment(
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
|
||||
@@ -46,31 +46,13 @@ async def start_yookassa_payment(callback: types.CallbackQuery, db_user: User, s
|
||||
min_amount_rub = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
||||
max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
message_text = (
|
||||
f'💳 <b>Оплата банковской картой</b>\n\n'
|
||||
f'Выберите сумму пополнения или введите вручную сумму '
|
||||
f'от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f'💳 <b>Оплата банковской картой</b>\n\n'
|
||||
f'Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
|
||||
)
|
||||
message_text = (
|
||||
f'💳 <b>Оплата банковской картой</b>\n\n'
|
||||
f'Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
|
||||
)
|
||||
|
||||
# Создаем клавиатуру
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
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)
|
||||
@@ -110,31 +92,13 @@ async def start_yookassa_sbp_payment(callback: types.CallbackQuery, db_user: Use
|
||||
min_amount_rub = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
||||
max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
message_text = (
|
||||
f'🏦 <b>Оплата через СБП</b>\n\n'
|
||||
f'Выберите сумму пополнения или введите вручную сумму '
|
||||
f'от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f'🏦 <b>Оплата через СБП</b>\n\n'
|
||||
f'Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
|
||||
)
|
||||
message_text = (
|
||||
f'🏦 <b>Оплата через СБП</b>\n\n'
|
||||
f'Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
|
||||
)
|
||||
|
||||
# Создаем клавиатуру
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
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)
|
||||
|
||||
+73
-34
@@ -1,5 +1,6 @@
|
||||
import hashlib
|
||||
import json
|
||||
from html import escape as html_escape
|
||||
from pathlib import Path
|
||||
|
||||
import qrcode
|
||||
@@ -14,7 +15,7 @@ from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_referral_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.admin_notification_service import AdminNotificationService, NotificationCategory
|
||||
from app.services.referral_withdrawal_service import referral_withdrawal_service
|
||||
from app.states import ReferralWithdrawalStates
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
@@ -45,7 +46,8 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
|
||||
summary = await get_user_referral_summary(db, db_user.id)
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
|
||||
bot_referral_link = settings.get_bot_referral_link(db_user.referral_code, bot_username)
|
||||
cabinet_referral_link = settings.get_cabinet_referral_link(db_user.referral_code)
|
||||
|
||||
referral_text = (
|
||||
texts.t('REFERRAL_PROGRAM_TITLE', '👥 <b>Реферальная программа</b>')
|
||||
@@ -114,13 +116,27 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
|
||||
'• Комиссия с каждого пополнения реферала: <b>{percent}%</b>',
|
||||
).format(percent=get_effective_referral_commission_percent(db_user))
|
||||
|
||||
referral_text += '\n' + commission_line + '\n\n'
|
||||
|
||||
# Show bot link
|
||||
referral_text += (
|
||||
texts.t('REFERRAL_BOT_LINK_TITLE', '🤖 <b>Ссылка на бота:</b>')
|
||||
+ f'\n<code>{html_escape(bot_referral_link)}</code>\n'
|
||||
)
|
||||
|
||||
# Show cabinet link if configured
|
||||
if cabinet_referral_link:
|
||||
referral_text += (
|
||||
'\n'
|
||||
+ texts.t('REFERRAL_CABINET_LINK_TITLE', '🌐 <b>Ссылка на кабинет:</b>')
|
||||
+ f'\n<code>{html_escape(cabinet_referral_link)}</code>\n'
|
||||
)
|
||||
|
||||
referral_text += (
|
||||
'\n'
|
||||
+ commission_line
|
||||
+ '\n\n'
|
||||
+ texts.t('REFERRAL_LINK_TITLE', '🔗 <b>Ваша реферальная ссылка:</b>')
|
||||
+ f'\n<code>{referral_link}</code>\n\n'
|
||||
+ texts.t('REFERRAL_CODE_TITLE', '🆔 <b>Ваш код:</b> <code>{code}</code>').format(code=db_user.referral_code)
|
||||
+ texts.t('REFERRAL_CODE_TITLE', '🆔 <b>Ваш код:</b> <code>{code}</code>').format(
|
||||
code=html_escape(str(db_user.referral_code or ''))
|
||||
)
|
||||
+ '\n\n'
|
||||
)
|
||||
|
||||
@@ -158,7 +174,7 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
|
||||
).format(
|
||||
reason=reason_text,
|
||||
amount=texts.format_price(earning['amount_kopeks']),
|
||||
referral_name=earning['referral_name'],
|
||||
referral_name=html_escape(str(earning['referral_name'] or '')),
|
||||
)
|
||||
+ '\n'
|
||||
)
|
||||
@@ -243,15 +259,15 @@ async def show_referral_qr(
|
||||
await callback.answer()
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
|
||||
bot_referral_link = settings.get_bot_referral_link(db_user.referral_code, bot_username)
|
||||
|
||||
qr_dir = Path('data') / 'referral_qr'
|
||||
qr_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
link_hash = hashlib.md5(referral_link.encode()).hexdigest()[:8]
|
||||
link_hash = hashlib.md5(bot_referral_link.encode()).hexdigest()[:8]
|
||||
file_path = qr_dir / f'{db_user.id}_{link_hash}.png'
|
||||
if not file_path.exists():
|
||||
img = qrcode.make(referral_link)
|
||||
img = qrcode.make(bot_referral_link)
|
||||
img.save(file_path)
|
||||
|
||||
photo = FSInputFile(file_path)
|
||||
@@ -259,25 +275,28 @@ async def show_referral_qr(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_referrals')]]
|
||||
)
|
||||
|
||||
caption = texts.t(
|
||||
'REFERRAL_QR_BOT_LINK',
|
||||
'🤖 Ссылка на бота:\n{link}',
|
||||
).format(link=bot_referral_link)
|
||||
|
||||
cabinet_referral_link = settings.get_cabinet_referral_link(db_user.referral_code)
|
||||
if cabinet_referral_link:
|
||||
caption += '\n\n' + texts.t(
|
||||
'REFERRAL_QR_CABINET_LINK',
|
||||
'🌐 Ссылка на кабинет:\n{link}',
|
||||
).format(link=cabinet_referral_link)
|
||||
|
||||
try:
|
||||
await callback.message.edit_media(
|
||||
types.InputMediaPhoto(
|
||||
media=photo,
|
||||
caption=texts.t(
|
||||
'REFERRAL_LINK_CAPTION',
|
||||
'🔗 Ваша реферальная ссылка:\n{link}',
|
||||
).format(link=referral_link),
|
||||
),
|
||||
types.InputMediaPhoto(media=photo, caption=caption),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
await callback.message.delete()
|
||||
await callback.message.answer_photo(
|
||||
photo,
|
||||
caption=texts.t(
|
||||
'REFERRAL_LINK_CAPTION',
|
||||
'🔗 Ваша реферальная ссылка:\n{link}',
|
||||
).format(link=referral_link),
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
@@ -322,7 +341,7 @@ async def show_detailed_referral_list(callback: types.CallbackQuery, db_user: Us
|
||||
texts.t(
|
||||
'REFERRAL_LIST_ITEM_HEADER',
|
||||
'{index}. {status} <b>{name}</b>',
|
||||
).format(index=i, status=status_emoji, name=referral['full_name'])
|
||||
).format(index=i, status=status_emoji, name=html_escape(str(referral['full_name'] or '')))
|
||||
+ '\n'
|
||||
)
|
||||
text += (
|
||||
@@ -454,7 +473,7 @@ async def show_referral_analytics(callback: types.CallbackQuery, db_user: User,
|
||||
'{index}. {name}: {amount} ({count} начислений)',
|
||||
).format(
|
||||
index=i,
|
||||
name=ref['referral_name'],
|
||||
name=html_escape(str(ref['referral_name'] or '')),
|
||||
amount=texts.format_price(ref['total_earned_kopeks']),
|
||||
count=ref['earnings_count'],
|
||||
)
|
||||
@@ -485,7 +504,8 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User):
|
||||
return
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
|
||||
bot_referral_link = settings.get_bot_referral_link(db_user.referral_code, bot_username)
|
||||
cabinet_referral_link = settings.get_cabinet_referral_link(db_user.referral_code)
|
||||
|
||||
invite_text = texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!')
|
||||
|
||||
@@ -507,14 +527,29 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User):
|
||||
+ texts.t('REFERRAL_INVITE_FEATURE_SECURE', '🔒 Надежная защита')
|
||||
+ '\n\n'
|
||||
+ texts.t('REFERRAL_INVITE_LINK_PROMPT', '👇 Переходи по ссылке:')
|
||||
+ f'\n{referral_link}'
|
||||
+ f'\n{bot_referral_link}'
|
||||
)
|
||||
|
||||
if cabinet_referral_link:
|
||||
invite_text += (
|
||||
'\n\n'
|
||||
+ texts.t('REFERRAL_INVITE_CABINET_LINK', '🌐 Или через личный кабинет:')
|
||||
+ f'\n{cabinet_referral_link}'
|
||||
)
|
||||
|
||||
# Compact share text for switch_inline_query (256-char limit)
|
||||
share_text = invite_text
|
||||
if len(share_text) > 256:
|
||||
share_text = texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!') + f'\n\n👇 {bot_referral_link}'
|
||||
if cabinet_referral_link and len(share_text) + len(cabinet_referral_link) + 5 <= 256:
|
||||
share_text += f'\n🌐 {cabinet_referral_link}'
|
||||
share_text = share_text[:256]
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t('REFERRAL_SHARE_BUTTON', '📤 Поделиться'), switch_inline_query=invite_text
|
||||
text=texts.t('REFERRAL_SHARE_BUTTON', '📤 Поделиться'), switch_inline_query=share_text
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_referrals')],
|
||||
@@ -531,7 +566,7 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User):
|
||||
'Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:',
|
||||
)
|
||||
+ '\n\n'
|
||||
f'<code>{invite_text}</code>'
|
||||
f'<code>{html_escape(invite_text)}</code>'
|
||||
),
|
||||
keyboard,
|
||||
)
|
||||
@@ -584,7 +619,7 @@ async def show_withdrawal_info(callback: types.CallbackQuery, db_user: User, db:
|
||||
]
|
||||
)
|
||||
else:
|
||||
text += f'❌ {reason}\n'
|
||||
text += f'❌ {html_escape(str(reason))}\n'
|
||||
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_referrals')])
|
||||
|
||||
@@ -746,7 +781,7 @@ async def process_payment_details(message: types.Message, db_user: User, db: Asy
|
||||
)
|
||||
text += (
|
||||
texts.t('REFERRAL_WITHDRAWAL_CONFIRM_DETAILS', '💳 Реквизиты:\n<code>{details}</code>').format(
|
||||
details=payment_details
|
||||
details=html_escape(payment_details)
|
||||
)
|
||||
+ '\n\n'
|
||||
)
|
||||
@@ -792,16 +827,18 @@ async def confirm_withdrawal_request(callback: types.CallbackQuery, db_user: Use
|
||||
# Отправляем уведомление админам
|
||||
analysis = json.loads(request.risk_analysis) if request.risk_analysis else {}
|
||||
|
||||
user_id_display = db_user.telegram_id or db_user.email or f'#{db_user.id}'
|
||||
user_id_display = html_escape(str(db_user.telegram_id or db_user.email or f'#{db_user.id}'))
|
||||
safe_name = html_escape(db_user.full_name or 'Без имени')
|
||||
safe_details = html_escape(payment_details)
|
||||
admin_text = f"""
|
||||
🔔 <b>Новая заявка на вывод #{request.id}</b>
|
||||
|
||||
👤 Пользователь: {db_user.full_name or 'Без имени'}
|
||||
👤 Пользователь: {safe_name}
|
||||
🆔 ID: <code>{user_id_display}</code>
|
||||
💰 Сумма: <b>{amount_kopeks / 100:.0f}₽</b>
|
||||
|
||||
💳 Реквизиты:
|
||||
<code>{payment_details}</code>
|
||||
<code>{safe_details}</code>
|
||||
|
||||
{referral_withdrawal_service.format_analysis_for_admin(analysis)}
|
||||
"""
|
||||
@@ -825,7 +862,9 @@ async def confirm_withdrawal_request(callback: types.CallbackQuery, db_user: Use
|
||||
|
||||
try:
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_admin_notification(admin_text, reply_markup=admin_keyboard)
|
||||
await notification_service.send_admin_notification(
|
||||
admin_text, reply_markup=admin_keyboard, category=NotificationCategory.PARTNERS
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления админам о заявке на вывод', error=e)
|
||||
|
||||
|
||||
@@ -354,10 +354,6 @@ async def _handle_guest_purchase_payment(
|
||||
stars_amount=stars_amount,
|
||||
purchase_token_prefix=purchase_token[:5],
|
||||
)
|
||||
elif result is False:
|
||||
await message.answer(
|
||||
'❌ Произошла ошибка при обработке подарочной подписки. Обратитесь в поддержку.',
|
||||
)
|
||||
else:
|
||||
logger.error('try_fulfill_guest_purchase returned None for Stars gift', payload=payload)
|
||||
await message.answer('❌ Ошибка обработки платежа. Обратитесь в поддержку.')
|
||||
|
||||
@@ -888,7 +888,8 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
)
|
||||
|
||||
user.status = UserStatus.ACTIVE.value
|
||||
# Keep status=DELETED so complete_registration properly handles
|
||||
# referral assignment and status change (not the "already active" branch)
|
||||
user.balance_kopeks = 0
|
||||
user.remnawave_uuid = None
|
||||
user.has_had_paid_subscription = False
|
||||
@@ -1191,7 +1192,7 @@ async def process_rules_accept(callback: types.CallbackQuery, state: FSMContext,
|
||||
reply_markup=get_rules_keyboard(language),
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -1302,7 +1303,7 @@ async def process_privacy_policy_accept(callback: types.CallbackQuery, state: FS
|
||||
reply_markup=get_privacy_policy_keyboard(language),
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept)
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -1392,7 +1393,7 @@ async def process_referral_code_skip(callback: types.CallbackQuery, state: FSMCo
|
||||
await callback.message.edit_text(
|
||||
texts.t('REGISTRATION_COMPLETING', '✅ Завершаем регистрацию...'), reply_markup=None
|
||||
)
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
|
||||
@@ -497,6 +497,12 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
if not subscription:
|
||||
await callback.answer(
|
||||
texts.t('NO_ACTIVE_SUBSCRIPTION', '⚠️ У вас нет активной подписки'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
current_devices = subscription.device_limit
|
||||
|
||||
# Проверяем тариф подписки
|
||||
|
||||
@@ -619,8 +619,6 @@ async def show_trial_offer(callback: types.CallbackQuery, db_user: User, db: Asy
|
||||
trial_tariff_id = settings.get_trial_tariff_id()
|
||||
if trial_tariff_id > 0:
|
||||
trial_tariff = await get_tariff(db, trial_tariff_id)
|
||||
if trial_tariff and not trial_tariff.is_active:
|
||||
trial_tariff = None
|
||||
|
||||
if trial_tariff:
|
||||
trial_traffic = trial_tariff.traffic_limit_gb
|
||||
@@ -811,14 +809,36 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
|
||||
user_balance_kopeks = getattr(db_user, 'balance_kopeks', 0) or 0
|
||||
can_pay_from_balance = user_balance_kopeks >= trial_price_kopeks
|
||||
|
||||
traffic_label = 'Безлимит' if settings.TRIAL_TRAFFIC_LIMIT_GB == 0 else f'{settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ'
|
||||
# Берём параметры из триального тарифа если доступен
|
||||
paid_trial_days = settings.TRIAL_DURATION_DAYS
|
||||
paid_trial_traffic = settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
paid_trial_devices = settings.TRIAL_DEVICE_LIMIT
|
||||
if settings.is_tariffs_mode():
|
||||
try:
|
||||
from app.database.crud.tariff import get_tariff_by_id as get_tariff, get_trial_tariff
|
||||
|
||||
paid_trial_tariff = await get_trial_tariff(db)
|
||||
if not paid_trial_tariff:
|
||||
trial_tariff_id = settings.get_trial_tariff_id()
|
||||
if trial_tariff_id > 0:
|
||||
paid_trial_tariff = await get_tariff(db, trial_tariff_id)
|
||||
if paid_trial_tariff:
|
||||
paid_trial_traffic = paid_trial_tariff.traffic_limit_gb
|
||||
paid_trial_devices = paid_trial_tariff.device_limit
|
||||
tariff_trial_days = getattr(paid_trial_tariff, 'trial_duration_days', None)
|
||||
if tariff_trial_days:
|
||||
paid_trial_days = tariff_trial_days
|
||||
except Exception as e:
|
||||
logger.error('Ошибка получения триального тарифа для платного триала', error=e)
|
||||
|
||||
traffic_label = 'Безлимит' if paid_trial_traffic == 0 else f'{paid_trial_traffic} ГБ'
|
||||
|
||||
message_lines = [
|
||||
texts.t('PAID_TRIAL_HEADER', '⚡ <b>Пробная подписка</b>'),
|
||||
'',
|
||||
f'📅 {texts.t("PERIOD", "Период")}: {settings.TRIAL_DURATION_DAYS} {texts.t("DAYS", "дней")}',
|
||||
f'📅 {texts.t("PERIOD", "Период")}: {paid_trial_days} {texts.t("DAYS", "дней")}',
|
||||
f'📊 {texts.t("TRAFFIC", "Трафик")}: {traffic_label}',
|
||||
f'📱 {texts.t("DEVICES", "Устройства")}: {settings.TRIAL_DEVICE_LIMIT}',
|
||||
f'📱 {texts.t("DEVICES", "Устройства")}: {paid_trial_devices}',
|
||||
'',
|
||||
f'💰 {texts.t("PRICE", "Стоимость")}: {settings.format_price(trial_price_kopeks)}',
|
||||
f'💳 {texts.t("YOUR_BALANCE", "Ваш баланс")}: {settings.format_price(user_balance_kopeks)}',
|
||||
@@ -865,6 +885,7 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
|
||||
from app.database.crud.tariff import get_tariff_by_id, get_trial_tariff
|
||||
|
||||
# Сначала проверяем тариф из БД с флагом is_trial_available
|
||||
# Триальный тариф может быть неактивным — используется для отдельных лимитов
|
||||
trial_tariff = await get_trial_tariff(db)
|
||||
|
||||
# Если не найден в БД, проверяем настройку TRIAL_TARIFF_ID
|
||||
@@ -872,8 +893,6 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
|
||||
trial_tariff_id = settings.get_trial_tariff_id()
|
||||
if trial_tariff_id > 0:
|
||||
trial_tariff = await get_tariff_by_id(db, trial_tariff_id)
|
||||
if trial_tariff and not trial_tariff.is_active:
|
||||
trial_tariff = None
|
||||
|
||||
if trial_tariff:
|
||||
trial_traffic_limit = trial_tariff.traffic_limit_gb
|
||||
@@ -3044,10 +3063,47 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user:
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_devices = settings.get_disabled_mode_device_limit()
|
||||
|
||||
# Получаем параметры из триального тарифа (аналогично бесплатному триалу)
|
||||
trial_tariff = None
|
||||
trial_traffic_limit = None
|
||||
trial_device_limit = forced_devices
|
||||
trial_squads = None
|
||||
tariff_id_for_trial = None
|
||||
trial_duration = None
|
||||
|
||||
if settings.is_tariffs_mode():
|
||||
try:
|
||||
from app.database.crud.tariff import get_tariff_by_id as _get_tariff, get_trial_tariff
|
||||
|
||||
trial_tariff = await get_trial_tariff(db)
|
||||
if not trial_tariff:
|
||||
trial_tariff_id = settings.get_trial_tariff_id()
|
||||
if trial_tariff_id > 0:
|
||||
trial_tariff = await _get_tariff(db, trial_tariff_id)
|
||||
if trial_tariff:
|
||||
trial_traffic_limit = trial_tariff.traffic_limit_gb
|
||||
trial_device_limit = trial_tariff.device_limit
|
||||
trial_squads = trial_tariff.allowed_squads or []
|
||||
tariff_id_for_trial = trial_tariff.id
|
||||
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
|
||||
if tariff_trial_days:
|
||||
trial_duration = tariff_trial_days
|
||||
logger.info(
|
||||
'Платный триал с баланса: используем тариф',
|
||||
trial_tariff_name=trial_tariff.name,
|
||||
trial_tariff_id=trial_tariff.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка получения триального тарифа для платного триала', error=e)
|
||||
|
||||
subscription = await create_trial_subscription(
|
||||
db,
|
||||
db_user.id,
|
||||
device_limit=forced_devices,
|
||||
duration_days=trial_duration,
|
||||
device_limit=trial_device_limit,
|
||||
traffic_limit_gb=trial_traffic_limit,
|
||||
connected_squads=trial_squads,
|
||||
tariff_id=tariff_id_for_trial,
|
||||
)
|
||||
|
||||
await db.refresh(db_user)
|
||||
@@ -3365,28 +3421,63 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
try:
|
||||
payment_service = PaymentService(callback.bot)
|
||||
|
||||
# Получаем случайный сквад для триала
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
# Получаем параметры из триального тарифа
|
||||
trial_duration = settings.TRIAL_DURATION_DAYS
|
||||
trial_traffic = settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
trial_devices = settings.TRIAL_DEVICE_LIMIT
|
||||
trial_squads_list = []
|
||||
tariff_id_for_trial = None
|
||||
|
||||
trial_squad_uuid = await get_random_trial_squad_uuid(db)
|
||||
if settings.is_tariffs_mode():
|
||||
try:
|
||||
from app.database.crud.tariff import get_tariff_by_id as _get_tariff, get_trial_tariff
|
||||
|
||||
trial_tariff = await get_trial_tariff(db)
|
||||
if not trial_tariff:
|
||||
trial_tariff_id = settings.get_trial_tariff_id()
|
||||
if trial_tariff_id > 0:
|
||||
trial_tariff = await _get_tariff(db, trial_tariff_id)
|
||||
if trial_tariff:
|
||||
trial_traffic = trial_tariff.traffic_limit_gb
|
||||
trial_devices = trial_tariff.device_limit
|
||||
trial_squads_list = trial_tariff.allowed_squads or []
|
||||
tariff_id_for_trial = trial_tariff.id
|
||||
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
|
||||
if tariff_trial_days:
|
||||
trial_duration = tariff_trial_days
|
||||
logger.info(
|
||||
'Платный триал через платёжку: используем тариф',
|
||||
trial_tariff_name=trial_tariff.name,
|
||||
trial_tariff_id=trial_tariff.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка получения триального тарифа для платного триала', error=e)
|
||||
|
||||
# Если тариф не задал серверы, получаем случайный сквад
|
||||
if not trial_squads_list:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
trial_squad_uuid = await get_random_trial_squad_uuid(db)
|
||||
trial_squads_list = [trial_squad_uuid] if trial_squad_uuid else []
|
||||
|
||||
# Создаем pending триальную подписку
|
||||
pending_subscription = await create_pending_trial_subscription(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
duration_days=settings.TRIAL_DURATION_DAYS,
|
||||
traffic_limit_gb=settings.TRIAL_TRAFFIC_LIMIT_GB,
|
||||
device_limit=settings.TRIAL_DEVICE_LIMIT,
|
||||
connected_squads=[trial_squad_uuid] if trial_squad_uuid else [],
|
||||
duration_days=trial_duration,
|
||||
traffic_limit_gb=trial_traffic,
|
||||
device_limit=trial_devices,
|
||||
connected_squads=trial_squads_list,
|
||||
payment_method=f'trial_{payment_method}',
|
||||
total_price_kopeks=trial_price_kopeks,
|
||||
tariff_id=tariff_id_for_trial,
|
||||
)
|
||||
|
||||
if not pending_subscription:
|
||||
await callback.answer('❌ Не удалось подготовить заказ. Попробуйте позже.', show_alert=True)
|
||||
return
|
||||
|
||||
traffic_label = 'Безлимит' if settings.TRIAL_TRAFFIC_LIMIT_GB == 0 else f'{settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ'
|
||||
traffic_label = 'Безлимит' if trial_traffic == 0 else f'{trial_traffic} ГБ'
|
||||
|
||||
if payment_method == 'stars':
|
||||
# Оплата через Telegram Stars
|
||||
@@ -3395,11 +3486,11 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
await callback.bot.send_invoice(
|
||||
chat_id=callback.from_user.id,
|
||||
title=texts.t('PAID_TRIAL_INVOICE_TITLE', 'Пробная подписка на {days} дней').format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
days=trial_duration
|
||||
),
|
||||
description=(
|
||||
f'{texts.t("PERIOD", "Период")}: {settings.TRIAL_DURATION_DAYS} {texts.t("DAYS", "дней")}\n'
|
||||
f'{texts.t("DEVICES", "Устройства")}: {settings.TRIAL_DEVICE_LIMIT}\n'
|
||||
f'{texts.t("PERIOD", "Период")}: {trial_duration} {texts.t("DAYS", "дней")}\n'
|
||||
f'{texts.t("DEVICES", "Устройства")}: {trial_devices}\n'
|
||||
f'{texts.t("TRAFFIC", "Трафик")}: {traffic_label}'
|
||||
),
|
||||
payload=f'trial_{pending_subscription.id}',
|
||||
@@ -3426,7 +3517,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
db=db,
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
days=trial_duration
|
||||
),
|
||||
user_id=db_user.id,
|
||||
metadata={
|
||||
@@ -3465,7 +3556,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
days=trial_duration
|
||||
),
|
||||
metadata={
|
||||
'type': 'trial',
|
||||
@@ -3514,7 +3605,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
amount_usd=amount_usd,
|
||||
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
|
||||
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
days=trial_duration
|
||||
),
|
||||
payload=f'trial_{pending_subscription.id}_{db_user.id}',
|
||||
)
|
||||
@@ -3562,7 +3653,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
days=trial_duration
|
||||
),
|
||||
language=db_user.language,
|
||||
)
|
||||
@@ -3600,7 +3691,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
days=trial_duration
|
||||
),
|
||||
language=db_user.language,
|
||||
)
|
||||
@@ -3637,7 +3728,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
days=trial_duration
|
||||
),
|
||||
language=db_user.language,
|
||||
)
|
||||
@@ -3675,7 +3766,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
days=trial_duration
|
||||
),
|
||||
language=db_user.language,
|
||||
)
|
||||
@@ -3719,7 +3810,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
days=trial_duration
|
||||
),
|
||||
language=db_user.language,
|
||||
payment_method_code=method_code,
|
||||
|
||||
@@ -1469,7 +1469,7 @@ async def confirm_daily_tariff_purchase(
|
||||
try:
|
||||
if existing_subscription:
|
||||
# Обновляем существующую подписку на суточный тариф
|
||||
# Сохраняем докупленные устройства при смене тарифа
|
||||
# Сбрасываем лимит устройств на базу нового тарифа (докупленные не переносятся)
|
||||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||||
|
||||
old_tariff = (
|
||||
@@ -2697,7 +2697,7 @@ async def confirm_daily_tariff_switch(
|
||||
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
|
||||
|
||||
# Обновляем подписку на суточный тариф
|
||||
# Сохраняем докупленные устройства при смене тарифа
|
||||
# Сбрасываем лимит устройств на базу нового тарифа (докупленные не переносятся)
|
||||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||||
|
||||
old_tariff = await get_tariff_by_id(db, subscription.tariff_id) if subscription.tariff_id else None
|
||||
@@ -3288,7 +3288,7 @@ async def confirm_instant_switch(
|
||||
is_new_daily = getattr(new_tariff, 'is_daily', False)
|
||||
|
||||
# Обновляем подписку с новыми параметрами тарифа
|
||||
# Сохраняем докупленные устройства при смене тарифа
|
||||
# Сбрасываем лимит устройств на базу нового тарифа (докупленные не переносятся)
|
||||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||||
|
||||
old_tariff = await get_tariff_by_id(db, subscription.tariff_id) if subscription.tariff_id else None
|
||||
|
||||
@@ -41,11 +41,13 @@ class AsyncHTTPClient:
|
||||
auth_provider: AuthProvider,
|
||||
default_headers: dict[str, str] | None = None,
|
||||
timeout: float = 10.0,
|
||||
proxy_url: str | None = None,
|
||||
):
|
||||
self.base_url = base_url
|
||||
self.auth_provider = auth_provider
|
||||
self.default_headers = default_headers or {}
|
||||
self.timeout = timeout
|
||||
self.proxy_url = proxy_url
|
||||
self._refresh_lock = asyncio.Lock()
|
||||
self.max_retries = 2 # Same as PHP AuthenticationPlugin::RETRY_LIMIT
|
||||
|
||||
@@ -124,7 +126,7 @@ class AsyncHTTPClient:
|
||||
if json_data is not None:
|
||||
request_kwargs['json'] = json_data
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx.AsyncClient(proxy=self.proxy_url) as client:
|
||||
# Initial request
|
||||
response = await client.request(**request_kwargs)
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ class AuthProviderImpl(AuthProvider):
|
||||
base_url: str = 'https://lknpd.nalog.ru/api',
|
||||
storage_path: str | None = None,
|
||||
device_id: str | None = None,
|
||||
proxy_url: str | None = None,
|
||||
):
|
||||
self.base_url_v1 = f'{base_url}/v1'
|
||||
self.base_url_v2 = f'{base_url}/v2'
|
||||
@@ -47,6 +48,7 @@ class AuthProviderImpl(AuthProvider):
|
||||
self.device_id = device_id or generate_device_id()
|
||||
self.device_info = DeviceInfo(sourceDeviceId=self.device_id)
|
||||
self._token_data: dict[str, Any] | None = None
|
||||
self.proxy_url = proxy_url
|
||||
|
||||
# Default headers similar to PHP Authenticator
|
||||
self.default_headers = {
|
||||
@@ -130,7 +132,7 @@ class AuthProviderImpl(AuthProvider):
|
||||
'deviceInfo': self.device_info.model_dump(),
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx.AsyncClient(proxy=self.proxy_url) as client:
|
||||
response = await client.post(
|
||||
f'{self.base_url_v1}/auth/lkfl',
|
||||
json=request_data,
|
||||
@@ -165,7 +167,7 @@ class AuthProviderImpl(AuthProvider):
|
||||
'requireTpToBeActive': True,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx.AsyncClient(proxy=self.proxy_url) as client:
|
||||
response = await client.post(
|
||||
f'{self.base_url_v2}/auth/challenge/sms/start',
|
||||
json=request_data,
|
||||
@@ -200,7 +202,7 @@ class AuthProviderImpl(AuthProvider):
|
||||
'deviceInfo': self.device_info.model_dump(),
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx.AsyncClient(proxy=self.proxy_url) as client:
|
||||
response = await client.post(
|
||||
f'{self.base_url_v1}/auth/challenge/sms/verify',
|
||||
json=request_data,
|
||||
@@ -233,7 +235,7 @@ class AuthProviderImpl(AuthProvider):
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx.AsyncClient(proxy=self.proxy_url) as client:
|
||||
response = await client.post(
|
||||
f'{self.base_url_v1}/auth/token',
|
||||
json=request_data,
|
||||
|
||||
@@ -36,6 +36,7 @@ class Client:
|
||||
storage_path: str | None = None,
|
||||
device_id: str | None = None,
|
||||
timeout: float = 10.0,
|
||||
proxy_url: str | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize Moy Nalog API client.
|
||||
@@ -45,6 +46,7 @@ class Client:
|
||||
storage_path: Optional file path for token storage
|
||||
device_id: Optional device ID (auto-generated if not provided)
|
||||
timeout: HTTP request timeout in seconds
|
||||
proxy_url: Optional SOCKS proxy URL for routing traffic
|
||||
"""
|
||||
self.base_url = base_url
|
||||
self.timeout = timeout
|
||||
@@ -54,6 +56,7 @@ class Client:
|
||||
base_url=base_url,
|
||||
storage_path=storage_path,
|
||||
device_id=device_id,
|
||||
proxy_url=proxy_url,
|
||||
)
|
||||
|
||||
# Initialize HTTP client with auth middleware
|
||||
@@ -67,6 +70,7 @@ class Client:
|
||||
'Referrer': 'https://lknpd.nalog.ru/auth/login',
|
||||
},
|
||||
timeout=timeout,
|
||||
proxy_url=proxy_url,
|
||||
)
|
||||
|
||||
# User profile data (for receipt operations)
|
||||
|
||||
@@ -1317,6 +1317,11 @@
|
||||
"REFERRAL_INVITE_TITLE": "🎉 Join the VPN service!",
|
||||
"REFERRAL_LINK_CAPTION": "🔗 Your referral link:\n{link}",
|
||||
"REFERRAL_LINK_TITLE": "🔗 <b>Your referral link:</b>",
|
||||
"REFERRAL_BOT_LINK_TITLE": "🤖 <b>Bot link:</b>",
|
||||
"REFERRAL_CABINET_LINK_TITLE": "🌐 <b>Cabinet link:</b>",
|
||||
"REFERRAL_QR_BOT_LINK": "🤖 Bot link:\n{link}",
|
||||
"REFERRAL_QR_CABINET_LINK": "🌐 Cabinet link:\n{link}",
|
||||
"REFERRAL_INVITE_CABINET_LINK": "🌐 Or via personal cabinet:",
|
||||
"REFERRAL_LIST_BUTTON": "👥 Referral list",
|
||||
"REFERRAL_LIST_EMPTY": "📋 You have no referrals yet.\n\nShare your referral link to start earning!",
|
||||
"REFERRAL_LIST_HEADER": "👥 <b>Your referrals</b> (page {current}/{total})",
|
||||
|
||||
@@ -1338,6 +1338,11 @@
|
||||
"REFERRAL_INVITE_TITLE": "🎉 به سرویس VPN بپیوند!",
|
||||
"REFERRAL_LINK_CAPTION": "🔗 لینک دعوت شما:\n{link}",
|
||||
"REFERRAL_LINK_TITLE": "🔗 <b>لینک دعوت شما:</b>",
|
||||
"REFERRAL_BOT_LINK_TITLE": "🤖 <b>لینک ربات:</b>",
|
||||
"REFERRAL_CABINET_LINK_TITLE": "🌐 <b>لینک کابینت:</b>",
|
||||
"REFERRAL_QR_BOT_LINK": "🤖 لینک ربات:\n{link}",
|
||||
"REFERRAL_QR_CABINET_LINK": "🌐 لینک کابینت:\n{link}",
|
||||
"REFERRAL_INVITE_CABINET_LINK": "🌐 یا از طریق کابینت شخصی:",
|
||||
"REFERRAL_LIST_BUTTON": "👥 لیست دعوتشدگان",
|
||||
"REFERRAL_LIST_EMPTY": "📋 هنوز دعوتشدهای ندارید.\n\nلینک دعوت خود را به اشتراک بگذارید!",
|
||||
"REFERRAL_LIST_HEADER": "👥 <b>دعوتشدگان شما</b> (صفحه {current}/{total})",
|
||||
|
||||
@@ -1338,6 +1338,11 @@
|
||||
"REFERRAL_INVITE_TITLE": "🎉 Присоединяйся к VPN сервису!",
|
||||
"REFERRAL_LINK_CAPTION": "🔗 Ваша реферальная ссылка:\n{link}",
|
||||
"REFERRAL_LINK_TITLE": "🔗 <b>Ваша реферальная ссылка:</b>",
|
||||
"REFERRAL_BOT_LINK_TITLE": "🤖 <b>Ссылка на бота:</b>",
|
||||
"REFERRAL_CABINET_LINK_TITLE": "🌐 <b>Ссылка на кабинет:</b>",
|
||||
"REFERRAL_QR_BOT_LINK": "🤖 Ссылка на бота:\n{link}",
|
||||
"REFERRAL_QR_CABINET_LINK": "🌐 Ссылка на кабинет:\n{link}",
|
||||
"REFERRAL_INVITE_CABINET_LINK": "🌐 Или через личный кабинет:",
|
||||
"REFERRAL_LIST_BUTTON": "👥 Список рефералов",
|
||||
"REFERRAL_LIST_EMPTY": "📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!",
|
||||
"REFERRAL_LIST_HEADER": "👥 <b>Ваши рефералы</b> (стр. {current}/{total})",
|
||||
|
||||
@@ -1254,6 +1254,11 @@
|
||||
"REFERRAL_INVITE_TITLE": "🎉 Приєднуйся до VPN сервісу!",
|
||||
"REFERRAL_LINK_CAPTION": "🔗 Ваше реферальне посилання:\n{link}",
|
||||
"REFERRAL_LINK_TITLE": "🔗 <b>Ваше реферальне посилання:</b>",
|
||||
"REFERRAL_BOT_LINK_TITLE": "🤖 <b>Посилання на бота:</b>",
|
||||
"REFERRAL_CABINET_LINK_TITLE": "🌐 <b>Посилання на кабінет:</b>",
|
||||
"REFERRAL_QR_BOT_LINK": "🤖 Посилання на бота:\n{link}",
|
||||
"REFERRAL_QR_CABINET_LINK": "🌐 Посилання на кабінет:\n{link}",
|
||||
"REFERRAL_INVITE_CABINET_LINK": "🌐 Або через особистий кабінет:",
|
||||
"REFERRAL_LIST_BUTTON": "👥 Список рефералів",
|
||||
"REFERRAL_LIST_EMPTY": "📋 У вас поки немає рефералів.\n\nПоділіться своїм реферальним посиланням, щоб почати заробляти!",
|
||||
"REFERRAL_LIST_HEADER": "👥 <b>Ваші реферали</b> (стор. {current}/{total})",
|
||||
|
||||
@@ -1252,6 +1252,11 @@
|
||||
"REFERRAL_INVITE_TITLE": "🎉加入VPN服务!",
|
||||
"REFERRAL_LINK_CAPTION": "🔗您的推荐链接:\n{link}",
|
||||
"REFERRAL_LINK_TITLE": "🔗<b>您的推荐链接:</b>",
|
||||
"REFERRAL_BOT_LINK_TITLE": "🤖<b>机器人链接:</b>",
|
||||
"REFERRAL_CABINET_LINK_TITLE": "🌐<b>控制面板链接:</b>",
|
||||
"REFERRAL_QR_BOT_LINK": "🤖机器人链接:\n{link}",
|
||||
"REFERRAL_QR_CABINET_LINK": "🌐控制面板链接:\n{link}",
|
||||
"REFERRAL_INVITE_CABINET_LINK": "🌐或通过个人面板:",
|
||||
"REFERRAL_LIST_BUTTON": "👥推荐列表",
|
||||
"REFERRAL_LIST_EMPTY": "📋您目前没有推荐。\n\n分享您的推荐链接开始赚钱吧!",
|
||||
"REFERRAL_LIST_HEADER": "👥<b>您的推荐</b>(第{current}/{total}页)",
|
||||
|
||||
@@ -219,7 +219,10 @@ async def send_error_to_admin_chat(
|
||||
global _last_error_notification
|
||||
|
||||
chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None)
|
||||
topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None)
|
||||
# Используем топик для ошибок, если настроен, иначе общий
|
||||
topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_ERRORS_TOPIC_ID', None) or getattr(
|
||||
settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None
|
||||
)
|
||||
enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
|
||||
|
||||
if not enabled or not chat_id:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import html
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
@@ -26,6 +27,21 @@ from app.utils.message_patch import caption_exceeds_telegram_limit
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
|
||||
class NotificationCategory(StrEnum):
|
||||
"""Категории уведомлений для маршрутизации по топикам."""
|
||||
|
||||
PURCHASES = 'purchases' # Покупки подписок, покупки с лендинга
|
||||
RENEWALS = 'renewals' # Продления
|
||||
TRIALS = 'trials' # Триалы
|
||||
BALANCE = 'balance' # Пополнение баланса
|
||||
ADDONS = 'addons' # Докупка трафика/устройств/серверов
|
||||
INFRASTRUCTURE = 'infrastructure' # Ноды, техработы, статус панели, вебхуки
|
||||
ERRORS = 'errors' # Ошибки бота, краши
|
||||
PROMO = 'promo' # Промокоды, кампании, промогруппы
|
||||
PARTNERS = 'partners' # Партнёрки, выводы, админ-действия
|
||||
TICKETS = 'tickets' # Тикеты (уже существует)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@@ -37,6 +53,20 @@ class AdminNotificationService:
|
||||
self.ticket_topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID', None)
|
||||
self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
|
||||
|
||||
# Маппинг категорий на topic_id (None = fallback на self.topic_id)
|
||||
self.category_topics: dict[NotificationCategory, int | None] = {
|
||||
NotificationCategory.PURCHASES: getattr(settings, 'ADMIN_NOTIFICATIONS_PURCHASES_TOPIC_ID', None),
|
||||
NotificationCategory.RENEWALS: getattr(settings, 'ADMIN_NOTIFICATIONS_RENEWALS_TOPIC_ID', None),
|
||||
NotificationCategory.TRIALS: getattr(settings, 'ADMIN_NOTIFICATIONS_TRIALS_TOPIC_ID', None),
|
||||
NotificationCategory.BALANCE: getattr(settings, 'ADMIN_NOTIFICATIONS_BALANCE_TOPIC_ID', None),
|
||||
NotificationCategory.ADDONS: getattr(settings, 'ADMIN_NOTIFICATIONS_ADDONS_TOPIC_ID', None),
|
||||
NotificationCategory.INFRASTRUCTURE: getattr(settings, 'ADMIN_NOTIFICATIONS_INFRASTRUCTURE_TOPIC_ID', None),
|
||||
NotificationCategory.ERRORS: getattr(settings, 'ADMIN_NOTIFICATIONS_ERRORS_TOPIC_ID', None),
|
||||
NotificationCategory.PROMO: getattr(settings, 'ADMIN_NOTIFICATIONS_PROMO_TOPIC_ID', None),
|
||||
NotificationCategory.PARTNERS: getattr(settings, 'ADMIN_NOTIFICATIONS_PARTNERS_TOPIC_ID', None),
|
||||
NotificationCategory.TICKETS: self.ticket_topic_id,
|
||||
}
|
||||
|
||||
async def _get_referrer_info(self, db: AsyncSession, referred_by_id: int | None) -> str:
|
||||
if not referred_by_id:
|
||||
return 'Нет'
|
||||
@@ -371,7 +401,7 @@ class AdminNotificationService:
|
||||
message_lines.append('')
|
||||
message_lines.append(f'⏰ <i>{format_local_datetime(datetime.now(UTC), "%d.%m.%Y %H:%M:%S")}</i>')
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.TRIALS)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о триале', error=e)
|
||||
@@ -502,7 +532,15 @@ class AdminNotificationService:
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
# Маршрутизация по категориям (зеркалит логику заголовков выше)
|
||||
if purchase_type == 'renewal' or (
|
||||
not was_trial_conversion and purchase_type is None and user.has_had_paid_subscription
|
||||
):
|
||||
cat = NotificationCategory.RENEWALS
|
||||
else:
|
||||
cat = NotificationCategory.PURCHASES
|
||||
|
||||
return await self._send_message('\n'.join(message_lines), category=cat)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о покупке', error=e)
|
||||
@@ -565,7 +603,7 @@ class AdminNotificationService:
|
||||
else:
|
||||
message = f'{message_prefix}{message_suffix}'
|
||||
|
||||
return await self._send_message(message)
|
||||
return await self._send_message(message, category=NotificationCategory.INFRASTRUCTURE)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления об обновлении', error=e)
|
||||
@@ -586,7 +624,7 @@ class AdminNotificationService:
|
||||
|
||||
⚙️ <i>Система автоматических обновлений • {format_local_datetime(datetime.now(UTC), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
return await self._send_message(message, category=NotificationCategory.ERRORS)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления об ошибке проверки версий', error=e)
|
||||
@@ -824,7 +862,7 @@ class AdminNotificationService:
|
||||
return False
|
||||
|
||||
try:
|
||||
return await self._send_message(message)
|
||||
return await self._send_message(message, category=NotificationCategory.BALANCE)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о пополнении', error=e, exc_info=True)
|
||||
return False
|
||||
@@ -901,7 +939,7 @@ class AdminNotificationService:
|
||||
|
||||
⏰ <i>{format_local_datetime(datetime.now(UTC), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
return await self._send_message(message, category=NotificationCategory.RENEWALS)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о продлении', error=e)
|
||||
@@ -1008,7 +1046,7 @@ class AdminNotificationService:
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PROMO)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления об активации промокода', error=e)
|
||||
@@ -1097,7 +1135,7 @@ class AdminNotificationService:
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PROMO)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о переходе по кампании', error=e)
|
||||
@@ -1187,14 +1225,30 @@ class AdminNotificationService:
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PROMO)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о смене промогруппы', error=e)
|
||||
return False
|
||||
|
||||
def _resolve_topic_id(self, category: NotificationCategory | None = None) -> int | None:
|
||||
"""Определяет topic_id для сообщения.
|
||||
|
||||
Если указана category и для неё настроен топик — возвращает его.
|
||||
Иначе — fallback на self.topic_id (общий топик).
|
||||
"""
|
||||
if category:
|
||||
topic = self.category_topics.get(category)
|
||||
if topic is not None:
|
||||
return topic
|
||||
return self.topic_id
|
||||
|
||||
async def _send_message(
|
||||
self, text: str, reply_markup: types.InlineKeyboardMarkup | None = None, *, ticket_event: bool = False
|
||||
self,
|
||||
text: str,
|
||||
reply_markup: types.InlineKeyboardMarkup | None = None,
|
||||
*,
|
||||
category: NotificationCategory | None = None,
|
||||
) -> bool:
|
||||
if not self.chat_id:
|
||||
logger.warning('ADMIN_NOTIFICATIONS_CHAT_ID не настроен')
|
||||
@@ -1208,19 +1262,14 @@ class AdminNotificationService:
|
||||
'disable_web_page_preview': True,
|
||||
}
|
||||
|
||||
# route to ticket-specific topic if provided
|
||||
thread_id = None
|
||||
if ticket_event and self.ticket_topic_id:
|
||||
thread_id = self.ticket_topic_id
|
||||
elif self.topic_id:
|
||||
thread_id = self.topic_id
|
||||
thread_id = self._resolve_topic_id(category)
|
||||
if thread_id:
|
||||
message_kwargs['message_thread_id'] = thread_id
|
||||
if reply_markup is not None:
|
||||
message_kwargs['reply_markup'] = reply_markup
|
||||
|
||||
await self.bot.send_message(**message_kwargs)
|
||||
logger.info('Уведомление отправлено в чат', chat_id=self.chat_id)
|
||||
logger.info('Уведомление отправлено в чат', chat_id=self.chat_id, category=category)
|
||||
return True
|
||||
|
||||
except TelegramForbiddenError:
|
||||
@@ -1241,11 +1290,17 @@ class AdminNotificationService:
|
||||
"""Public check for whether admin notifications are configured and active."""
|
||||
return self._is_enabled()
|
||||
|
||||
async def send_admin_notification(self, text: str, reply_markup: types.InlineKeyboardMarkup | None = None) -> bool:
|
||||
async def send_admin_notification(
|
||||
self,
|
||||
text: str,
|
||||
reply_markup: types.InlineKeyboardMarkup | None = None,
|
||||
*,
|
||||
category: NotificationCategory | None = None,
|
||||
) -> bool:
|
||||
"""Send a generic notification to admin chat with optional inline keyboard."""
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
return await self._send_message(text, reply_markup=reply_markup)
|
||||
return await self._send_message(text, reply_markup=reply_markup, category=category)
|
||||
|
||||
async def send_guest_purchase_notification(
|
||||
self,
|
||||
@@ -1316,7 +1371,7 @@ class AdminNotificationService:
|
||||
|
||||
message_lines.append(f'<i>{format_local_datetime(datetime.now(UTC), "%d.%m.%Y %H:%M")}</i>')
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PURCHASES)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о гостевой покупке', error=e)
|
||||
@@ -1330,7 +1385,7 @@ class AdminNotificationService:
|
||||
"""
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
return await self._send_message(text)
|
||||
return await self._send_message(text, category=NotificationCategory.INFRASTRUCTURE)
|
||||
|
||||
def _get_payment_method_display(self, payment_method: str | None) -> str:
|
||||
if not payment_method:
|
||||
@@ -1516,7 +1571,7 @@ class AdminNotificationService:
|
||||
|
||||
message = '\n'.join(message_parts)
|
||||
|
||||
return await self._send_message(message)
|
||||
return await self._send_message(message, category=NotificationCategory.INFRASTRUCTURE)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о техработах', error=e)
|
||||
@@ -1601,7 +1656,7 @@ class AdminNotificationService:
|
||||
|
||||
message = '\n'.join(message_parts)
|
||||
|
||||
return await self._send_message(message)
|
||||
return await self._send_message(message, category=NotificationCategory.INFRASTRUCTURE)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о статусе панели Remnawave', error=e)
|
||||
@@ -1694,7 +1749,7 @@ class AdminNotificationService:
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.ADDONS)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления об изменении подписки', error=e)
|
||||
@@ -1778,7 +1833,7 @@ class AdminNotificationService:
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PARTNERS)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о заявке на партнёрку', error=e)
|
||||
@@ -1829,7 +1884,7 @@ class AdminNotificationService:
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PARTNERS)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о запросе на вывод', error=e)
|
||||
@@ -1873,7 +1928,7 @@ class AdminNotificationService:
|
||||
)
|
||||
|
||||
message = '\n'.join(message_lines)
|
||||
return await self._send_message(message)
|
||||
return await self._send_message(message, category=NotificationCategory.PARTNERS)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о массовой блокировке', error=e)
|
||||
@@ -1910,7 +1965,7 @@ class AdminNotificationService:
|
||||
if media_file_id and media_type == 'photo':
|
||||
return await self._send_ticket_photo_notification(text, media_file_id, keyboard)
|
||||
|
||||
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
|
||||
return await self._send_message(text, reply_markup=keyboard, category=NotificationCategory.TICKETS)
|
||||
|
||||
async def _send_ticket_photo_notification(
|
||||
self,
|
||||
@@ -1925,7 +1980,7 @@ class AdminNotificationService:
|
||||
if not self.chat_id:
|
||||
return False
|
||||
|
||||
thread_id = self.ticket_topic_id or self.topic_id
|
||||
thread_id = self._resolve_topic_id(category=NotificationCategory.TICKETS)
|
||||
|
||||
try:
|
||||
if not caption_exceeds_telegram_limit(text):
|
||||
@@ -1943,7 +1998,7 @@ class AdminNotificationService:
|
||||
await self.bot.send_photo(**photo_kwargs)
|
||||
else:
|
||||
# Текст отдельно, фото следом в тот же топик
|
||||
await self._send_message(text, reply_markup=keyboard, ticket_event=True)
|
||||
await self._send_message(text, reply_markup=keyboard, category=NotificationCategory.TICKETS)
|
||||
photo_kwargs = {
|
||||
'chat_id': self.chat_id,
|
||||
'photo': photo_file_id,
|
||||
@@ -1956,7 +2011,7 @@ class AdminNotificationService:
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки фото-уведомления тикета', error=e)
|
||||
# Fallback: отправляем хотя бы текст
|
||||
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
|
||||
return await self._send_message(text, reply_markup=keyboard, category=NotificationCategory.TICKETS)
|
||||
|
||||
async def send_suspicious_traffic_notification(self, message: str, bot: Bot, topic_id: int | None = None) -> bool:
|
||||
"""
|
||||
|
||||
@@ -1810,10 +1810,12 @@ class BackupService:
|
||||
notification_text += f'\n\n⏰ <i>{datetime.now(UTC).strftime("%d.%m.%Y %H:%M:%S")}</i>'
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.admin_notification_service import AdminNotificationService, NotificationCategory
|
||||
|
||||
admin_service = AdminNotificationService(self.bot)
|
||||
await admin_service._send_message(notification_text)
|
||||
await admin_service.send_admin_notification(
|
||||
notification_text, category=NotificationCategory.INFRASTRUCTURE
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления через AdminNotificationService', error=e)
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ class BroadcastConfig:
|
||||
selected_buttons: list[str]
|
||||
media: BroadcastMediaConfig | None = None
|
||||
initiator_name: str | None = None
|
||||
custom_buttons: list[dict] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -179,7 +180,7 @@ class BroadcastService:
|
||||
await self._mark_finished(broadcast_id, sent_count, failed_count, blocked_count, cancelled=False)
|
||||
return
|
||||
|
||||
keyboard = self._build_keyboard(config.selected_buttons)
|
||||
keyboard = self._build_keyboard(config.selected_buttons, config.custom_buttons)
|
||||
|
||||
logger.info(
|
||||
'Рассылка : начинаем отправку получателям (batch delay=s)',
|
||||
@@ -358,10 +359,14 @@ class BroadcastService:
|
||||
|
||||
return sent_count, failed_count, blocked_count, False
|
||||
|
||||
def _build_keyboard(self, selected_buttons: list[str] | None) -> InlineKeyboardMarkup | None:
|
||||
def _build_keyboard(
|
||||
self,
|
||||
selected_buttons: list[str] | None,
|
||||
custom_buttons: list[dict] | None = None,
|
||||
) -> InlineKeyboardMarkup | None:
|
||||
if selected_buttons is None:
|
||||
selected_buttons = []
|
||||
return create_broadcast_keyboard(selected_buttons)
|
||||
return create_broadcast_keyboard(selected_buttons, custom_buttons=custom_buttons)
|
||||
|
||||
async def _deliver_message(
|
||||
self,
|
||||
|
||||
@@ -7,7 +7,7 @@ from datetime import UTC, datetime, timedelta
|
||||
from typing import Literal
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy import func, or_, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -25,6 +25,7 @@ from app.database.models import (
|
||||
LandingPage,
|
||||
PaymentMethod,
|
||||
Tariff,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
@@ -46,11 +47,10 @@ async def _send_admin_notification(
|
||||
if not getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) or not settings.BOT_TOKEN:
|
||||
return
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
async with Bot(token=settings.BOT_TOKEN) as bot:
|
||||
async with create_bot() as bot:
|
||||
service = AdminNotificationService(bot)
|
||||
await service.send_guest_purchase_notification(
|
||||
purchase,
|
||||
@@ -177,6 +177,97 @@ async def create_purchase(
|
||||
return purchase
|
||||
|
||||
|
||||
async def _create_nalogo_receipt_for_purchase(
|
||||
db: AsyncSession,
|
||||
purchase: GuestPurchase,
|
||||
user: User,
|
||||
transaction: Transaction | None = None,
|
||||
) -> None:
|
||||
"""Create NaloGO fiscal receipt for a guest purchase (best-effort)."""
|
||||
if not settings.is_nalogo_enabled():
|
||||
return
|
||||
|
||||
# Без payment_id нет dedup-ключа в Redis — нельзя гарантировать идемпотентность
|
||||
if not purchase.payment_id:
|
||||
logger.warning(
|
||||
'Cannot create NaloGO receipt: purchase has no payment_id',
|
||||
purchase_id=purchase.id,
|
||||
)
|
||||
return
|
||||
|
||||
# Нулевые/отрицательные суммы не фискализируем
|
||||
if purchase.amount_kopeks <= 0:
|
||||
return
|
||||
|
||||
# Защита от дублей: если у транзакции или покупки уже есть чек — не создаём новый
|
||||
if transaction and transaction.receipt_uuid:
|
||||
logger.info(
|
||||
'NaloGO receipt already exists for guest purchase (transaction)',
|
||||
purchase_id=purchase.id,
|
||||
receipt_uuid=transaction.receipt_uuid,
|
||||
)
|
||||
return
|
||||
|
||||
if purchase.receipt_uuid:
|
||||
logger.info(
|
||||
'NaloGO receipt already exists for guest purchase (purchase)',
|
||||
purchase_id=purchase.id,
|
||||
receipt_uuid=purchase.receipt_uuid,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
from app.services.nalogo_service import NaloGoService
|
||||
|
||||
nalogo_service = NaloGoService()
|
||||
if not nalogo_service.configured:
|
||||
return
|
||||
|
||||
amount_rubles = purchase.amount_kopeks / 100
|
||||
# Не передаём telegram_user_id в описание чека — privacy (VPN-сервис)
|
||||
receipt_name = settings.get_balance_payment_description(purchase.amount_kopeks)
|
||||
|
||||
receipt_uuid = await nalogo_service.create_receipt(
|
||||
name=receipt_name,
|
||||
amount=amount_rubles,
|
||||
quantity=1,
|
||||
payment_id=purchase.payment_id,
|
||||
telegram_user_id=user.telegram_id,
|
||||
amount_kopeks=purchase.amount_kopeks,
|
||||
)
|
||||
|
||||
if receipt_uuid:
|
||||
logger.info(
|
||||
'NaloGO receipt created for guest purchase',
|
||||
purchase_id=purchase.id,
|
||||
receipt_uuid=receipt_uuid,
|
||||
saved_to_transaction=transaction is not None,
|
||||
)
|
||||
# Всегда сохраняем receipt_uuid на purchase (persistent dedup)
|
||||
try:
|
||||
purchase.receipt_uuid = receipt_uuid
|
||||
purchase.receipt_created_at = datetime.now(UTC)
|
||||
if transaction:
|
||||
transaction.receipt_uuid = receipt_uuid
|
||||
transaction.receipt_created_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
logger.warning(
|
||||
'Failed to save receipt_uuid to purchase/transaction',
|
||||
purchase_id=purchase.id,
|
||||
receipt_uuid=receipt_uuid,
|
||||
)
|
||||
except Exception as exc:
|
||||
from app.utils.proxy import sanitize_proxy_error
|
||||
|
||||
logger.error(
|
||||
'Failed to create nalogo receipt for guest purchase',
|
||||
purchase_id=purchase.id,
|
||||
error=sanitize_proxy_error(exc),
|
||||
)
|
||||
|
||||
|
||||
async def fulfill_purchase(
|
||||
db: AsyncSession,
|
||||
purchase_token: str,
|
||||
@@ -272,6 +363,10 @@ async def fulfill_purchase(
|
||||
|
||||
await _send_admin_notification(purchase, notification_tariff_name, is_pending_activation=True)
|
||||
|
||||
# Создаем чек через NaloGO (деньги получены, чек нужен)
|
||||
await _create_nalogo_receipt_for_purchase(db, purchase, user)
|
||||
await db.refresh(purchase) # guard: inner rollback may expire the object
|
||||
|
||||
# Clear plaintext password after email delivery
|
||||
if purchase.cabinet_password:
|
||||
purchase.cabinet_password = None
|
||||
@@ -336,9 +431,10 @@ async def fulfill_purchase(
|
||||
await db.refresh(purchase, attribute_names=['landing', 'user'])
|
||||
|
||||
# Create transaction so promo group auto-assignment and contest tracking work
|
||||
transaction = None
|
||||
try:
|
||||
payment_method_enum = _resolve_payment_method(purchase.payment_method)
|
||||
await create_transaction(
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
@@ -364,6 +460,12 @@ async def fulfill_purchase(
|
||||
|
||||
await _send_admin_notification(purchase, notification_tariff_name, is_pending_activation=False)
|
||||
|
||||
# Создаем чек через NaloGO
|
||||
await _create_nalogo_receipt_for_purchase(db, purchase, user, transaction)
|
||||
|
||||
# Refresh purchase: если внутри nalogo helper был rollback, объект expired
|
||||
await db.refresh(purchase)
|
||||
|
||||
# Clear plaintext password after email delivery — no longer needed in DB
|
||||
if purchase.cabinet_password:
|
||||
purchase.cabinet_password = None
|
||||
@@ -392,24 +494,41 @@ async def fulfill_purchase(
|
||||
return purchase
|
||||
|
||||
|
||||
def _resolve_base_payment_method(method_str: str | None) -> str:
|
||||
"""Resolve base payment method string by stripping sub-option suffixes.
|
||||
|
||||
'yookassa_sbp' → 'yookassa', 'kassa_ai' → 'kassa_ai' (enum match keeps it),
|
||||
'platega_2' → 'platega'.
|
||||
"""
|
||||
if not method_str:
|
||||
return ''
|
||||
# If exact enum match, return as-is (handles 'telegram_stars', 'kassa_ai', etc.)
|
||||
try:
|
||||
PaymentMethod(method_str)
|
||||
return method_str
|
||||
except ValueError:
|
||||
pass
|
||||
# Strip sub-option suffix
|
||||
if '_' in method_str:
|
||||
base = method_str.rsplit('_', 1)[0]
|
||||
try:
|
||||
PaymentMethod(base)
|
||||
return base
|
||||
except ValueError:
|
||||
pass
|
||||
return method_str
|
||||
|
||||
|
||||
def _resolve_payment_method(method_str: str | None) -> PaymentMethod | None:
|
||||
"""Convert payment method string from GuestPurchase to PaymentMethod enum."""
|
||||
if not method_str:
|
||||
return None
|
||||
# Try exact match first (handles 'telegram_stars', 'kassa_ai', 'yookassa', etc.)
|
||||
base = _resolve_base_payment_method(method_str)
|
||||
try:
|
||||
return PaymentMethod(method_str)
|
||||
return PaymentMethod(base)
|
||||
except ValueError:
|
||||
pass
|
||||
# Strip sub-option suffix ('yookassa_sbp' → 'yookassa', 'platega_2' → 'platega')
|
||||
if '_' in method_str:
|
||||
base_method = method_str.split('_')[0]
|
||||
try:
|
||||
return PaymentMethod(base_method)
|
||||
except ValueError:
|
||||
pass
|
||||
logger.debug('Unknown payment method for transaction', method=method_str)
|
||||
return None
|
||||
logger.debug('Unknown payment method for transaction', method=method_str)
|
||||
return None
|
||||
|
||||
|
||||
def _mask_email(email: str) -> str:
|
||||
@@ -529,9 +648,9 @@ async def _find_or_create_user(
|
||||
resolved_telegram_id: int | None = pre_resolved_telegram_id
|
||||
if resolved_telegram_id is None:
|
||||
try:
|
||||
from aiogram import Bot
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
async with Bot(token=settings.BOT_TOKEN) as bot:
|
||||
async with create_bot() as bot:
|
||||
chat = await asyncio.wait_for(
|
||||
bot.get_chat(chat_id=f'@{username}'),
|
||||
timeout=5.0,
|
||||
@@ -639,11 +758,10 @@ async def _send_telegram_gift_notification(
|
||||
try:
|
||||
import html as html_mod
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
gift_from = ''
|
||||
if purchase.contact_value:
|
||||
safe_name = html_mod.escape(purchase.contact_value)
|
||||
@@ -674,10 +792,7 @@ async def _send_telegram_gift_notification(
|
||||
]
|
||||
)
|
||||
|
||||
async with Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
) as bot:
|
||||
async with create_bot() as bot:
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=text,
|
||||
@@ -1002,14 +1117,16 @@ async def retry_stuck_paid_purchases(
|
||||
stale_minutes: int = 5,
|
||||
limit: int = 10,
|
||||
max_age_hours: int = 24,
|
||||
max_retries: int = 20,
|
||||
) -> int:
|
||||
"""Retry fulfillment for purchases stuck in PAID status.
|
||||
|
||||
Finds purchases that have been in PAID status for longer than stale_minutes
|
||||
(but not older than max_age_hours) and attempts to fulfill them in isolated
|
||||
sessions. Returns the number of successfully retried purchases.
|
||||
(but not older than max_age_hours, and with retry_count < max_retries) and
|
||||
attempts to fulfill them in isolated sessions.
|
||||
|
||||
Purchases older than max_age_hours are left for manual investigation.
|
||||
Purchases exceeding max_retries are marked FAILED and an admin alert is sent.
|
||||
Returns the number of successfully retried purchases.
|
||||
"""
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
@@ -1018,10 +1135,12 @@ async def retry_stuck_paid_purchases(
|
||||
|
||||
# Collect tokens only — each retry gets its own session.
|
||||
# NULL paid_at is included via or_() as a safety net for data anomalies.
|
||||
# Filter retry_count < max_retries in SQL to avoid wasting LIMIT slots.
|
||||
result = await db.execute(
|
||||
select(GuestPurchase.token)
|
||||
.where(
|
||||
GuestPurchase.status == GuestPurchaseStatus.PAID.value,
|
||||
GuestPurchase.retry_count < max_retries,
|
||||
or_(GuestPurchase.paid_at < cutoff, GuestPurchase.paid_at.is_(None)),
|
||||
or_(GuestPurchase.paid_at > max_age, GuestPurchase.paid_at.is_(None)),
|
||||
# Exclude code-only gifts — they stay PAID intentionally until activated
|
||||
@@ -1032,6 +1151,9 @@ async def retry_stuck_paid_purchases(
|
||||
)
|
||||
tokens = result.scalars().all()
|
||||
|
||||
# Separately fail exhausted purchases (retry_count >= max_retries)
|
||||
await _fail_exhausted_purchases_batch(db, GuestPurchaseStatus.PAID, max_retries, max_age)
|
||||
|
||||
if not tokens:
|
||||
return 0
|
||||
|
||||
@@ -1039,6 +1161,7 @@ async def retry_stuck_paid_purchases(
|
||||
for token in tokens:
|
||||
try:
|
||||
async with AsyncSessionLocal() as retry_db:
|
||||
await _increment_retry_count(retry_db, token)
|
||||
await fulfill_purchase(retry_db, token)
|
||||
retried += 1
|
||||
logger.info('Retried stuck purchase successfully', token_prefix=token[:5])
|
||||
@@ -1053,12 +1176,15 @@ async def retry_stuck_pending_activation(
|
||||
stale_minutes: int = 10,
|
||||
limit: int = 10,
|
||||
max_age_hours: int = 24,
|
||||
max_retries: int = 20,
|
||||
) -> int:
|
||||
"""Retry activation for purchases stuck in PENDING_ACTIVATION status.
|
||||
|
||||
This handles the case where activate_purchase() failed after the status
|
||||
was already transitioned to PENDING_ACTIVATION (e.g., Remnawave panel was
|
||||
temporarily down). Each retry runs in an isolated session.
|
||||
|
||||
Purchases exceeding max_retries are marked FAILED and an admin alert is sent.
|
||||
"""
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
@@ -1069,6 +1195,7 @@ async def retry_stuck_pending_activation(
|
||||
select(GuestPurchase.token)
|
||||
.where(
|
||||
GuestPurchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value,
|
||||
GuestPurchase.retry_count < max_retries,
|
||||
or_(GuestPurchase.paid_at < cutoff, GuestPurchase.paid_at.is_(None)),
|
||||
or_(GuestPurchase.paid_at > max_age, GuestPurchase.paid_at.is_(None)),
|
||||
GuestPurchase.user_id.isnot(None),
|
||||
@@ -1078,6 +1205,9 @@ async def retry_stuck_pending_activation(
|
||||
)
|
||||
tokens = result.scalars().all()
|
||||
|
||||
# Separately fail exhausted purchases (retry_count >= max_retries)
|
||||
await _fail_exhausted_purchases_batch(db, GuestPurchaseStatus.PENDING_ACTIVATION, max_retries, max_age)
|
||||
|
||||
if not tokens:
|
||||
return 0
|
||||
|
||||
@@ -1085,6 +1215,7 @@ async def retry_stuck_pending_activation(
|
||||
for token in tokens:
|
||||
try:
|
||||
async with AsyncSessionLocal() as retry_db:
|
||||
await _increment_retry_count(retry_db, token)
|
||||
await activate_purchase(retry_db, token)
|
||||
retried += 1
|
||||
logger.info('Retried stuck pending_activation successfully', token_prefix=token[:5])
|
||||
@@ -1092,3 +1223,368 @@ async def retry_stuck_pending_activation(
|
||||
logger.exception('Failed to retry stuck pending_activation', token_prefix=token[:5])
|
||||
|
||||
return retried
|
||||
|
||||
|
||||
async def _increment_retry_count(db: AsyncSession, purchase_token: str) -> None:
|
||||
"""Atomically increment retry_count via UPDATE statement (no SELECT, no identity map pollution)."""
|
||||
await db.execute(
|
||||
update(GuestPurchase)
|
||||
.where(GuestPurchase.token == purchase_token)
|
||||
.values(retry_count=GuestPurchase.retry_count + 1)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _fail_exhausted_purchases_batch(
|
||||
db: AsyncSession,
|
||||
status: GuestPurchaseStatus,
|
||||
max_retries: int,
|
||||
max_age: datetime,
|
||||
) -> None:
|
||||
"""Find and mark exhausted purchases as FAILED, then send admin alerts."""
|
||||
from app.database.crud.landing import update_purchase_status
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
result = await db.execute(
|
||||
select(GuestPurchase.token, GuestPurchase.retry_count)
|
||||
.where(
|
||||
GuestPurchase.status == status.value,
|
||||
GuestPurchase.retry_count >= max_retries,
|
||||
or_(GuestPurchase.paid_at > max_age, GuestPurchase.paid_at.is_(None)),
|
||||
)
|
||||
.limit(10)
|
||||
)
|
||||
exhausted = result.all()
|
||||
|
||||
for token, retry_count in exhausted:
|
||||
# Collect alert data before closing the session
|
||||
alert_data: dict | None = None
|
||||
try:
|
||||
async with AsyncSessionLocal() as fail_db:
|
||||
row = await fail_db.execute(select(GuestPurchase).where(GuestPurchase.token == token).with_for_update())
|
||||
purchase = row.scalars().first()
|
||||
if purchase and purchase.status not in (
|
||||
GuestPurchaseStatus.DELIVERED.value,
|
||||
GuestPurchaseStatus.FAILED.value,
|
||||
):
|
||||
# Capture alert data before commit expires attributes
|
||||
alert_data = {
|
||||
'id': purchase.id,
|
||||
'token': purchase.token,
|
||||
'amount_kopeks': purchase.amount_kopeks,
|
||||
'payment_method': purchase.payment_method,
|
||||
'payment_id': purchase.payment_id,
|
||||
'contact_type': purchase.contact_type,
|
||||
'contact_value': purchase.contact_value,
|
||||
'created_at': purchase.created_at,
|
||||
}
|
||||
await update_purchase_status(fail_db, token, GuestPurchaseStatus.FAILED)
|
||||
logger.error(
|
||||
'Purchase exceeded max retries — marked FAILED',
|
||||
token_prefix=token[:5],
|
||||
retry_count=retry_count,
|
||||
phase=status.value,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to mark exhausted purchase as FAILED', token_prefix=token[:5])
|
||||
|
||||
# Send alert OUTSIDE the session (no row lock held)
|
||||
if alert_data:
|
||||
await _send_stuck_purchase_alert(alert_data, retry_count, status.value)
|
||||
|
||||
|
||||
async def _send_stuck_purchase_alert(data: dict, retry_count: int, phase: str) -> None:
|
||||
"""Send admin notification about a purchase that exhausted all retries.
|
||||
|
||||
Accepts a plain dict (not ORM object) so it can be called after the session is closed.
|
||||
"""
|
||||
if not getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) or not settings.BOT_TOKEN:
|
||||
return
|
||||
try:
|
||||
import html as html_mod
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService, NotificationCategory
|
||||
|
||||
amount_rub = data['amount_kopeks'] / 100
|
||||
contact_value = html_mod.escape(str(data.get('contact_value', '?')))
|
||||
contact_type = html_mod.escape(str(data.get('contact_type', '?')))
|
||||
text = (
|
||||
f'<b>STUCK PURCHASE — retries exhausted</b>\n\n'
|
||||
f'Token: <code>{data["token"][:8]}...</code>\n'
|
||||
f'Status: <code>{phase}</code> → <code>FAILED</code>\n'
|
||||
f'Retries: <b>{retry_count}</b>\n'
|
||||
f'Amount: <b>{amount_rub:.0f} ₽</b>\n'
|
||||
f'Payment: <code>{html_mod.escape(str(data.get("payment_method") or "?"))}</code>\n'
|
||||
f'Payment ID: <code>{html_mod.escape(str(data.get("payment_id") or "?"))}</code>\n'
|
||||
f'Contact: {contact_type}: <code>{contact_value}</code>\n'
|
||||
f'Created: {data["created_at"]:%Y-%m-%d %H:%M UTC}\n\n'
|
||||
f'Requires manual investigation.'
|
||||
)
|
||||
|
||||
async with create_bot() as bot:
|
||||
service = AdminNotificationService(bot)
|
||||
await service.send_admin_notification(text, category=NotificationCategory.ERRORS)
|
||||
except Exception:
|
||||
logger.warning('Failed to send stuck purchase admin alert', purchase_id=data.get('id'), exc_info=True)
|
||||
|
||||
|
||||
async def _send_amount_mismatch_alert(
|
||||
purchase: GuestPurchase,
|
||||
provider_amount_kopeks: int,
|
||||
provider_payment_id: str,
|
||||
payment_method: str | None,
|
||||
) -> None:
|
||||
"""Send admin alert when recovery detects an amount mismatch (possible fraud or bug)."""
|
||||
if not getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) or not settings.BOT_TOKEN:
|
||||
return
|
||||
try:
|
||||
import html as html_mod
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService, NotificationCategory
|
||||
|
||||
text = (
|
||||
f'<b>AMOUNT MISMATCH — purchase marked FAILED</b>\n\n'
|
||||
f'Token: <code>{purchase.token[:8]}...</code>\n'
|
||||
f'Expected: <b>{purchase.amount_kopeks / 100:.0f} ₽</b>\n'
|
||||
f'Provider: <b>{provider_amount_kopeks / 100:.0f} ₽</b>\n'
|
||||
f'Payment: <code>{html_mod.escape(str(payment_method or "?"))}</code>\n'
|
||||
f'Payment ID: <code>{html_mod.escape(str(provider_payment_id))}</code>\n'
|
||||
f'Contact: {html_mod.escape(str(purchase.contact_type))}: '
|
||||
f'<code>{html_mod.escape(str(purchase.contact_value))}</code>\n\n'
|
||||
f'Requires manual investigation.'
|
||||
)
|
||||
|
||||
async with create_bot() as bot:
|
||||
service = AdminNotificationService(bot)
|
||||
await service.send_admin_notification(text, category=NotificationCategory.ERRORS)
|
||||
except Exception:
|
||||
logger.warning('Failed to send amount mismatch alert', purchase_id=purchase.id, exc_info=True)
|
||||
|
||||
|
||||
async def recover_stuck_pending_purchases(
|
||||
db: AsyncSession,
|
||||
stale_minutes: int = 10,
|
||||
limit: int = 10,
|
||||
max_age_hours: int = 24,
|
||||
) -> int:
|
||||
"""Recover purchases stuck in PENDING by checking provider payment status.
|
||||
|
||||
Queries all payment provider tables (YooKassa, Heleket, CryptoBot, etc.)
|
||||
for succeeded payments matching the purchase_token. If a provider payment
|
||||
is confirmed but the GuestPurchase is still PENDING (webhook was lost or
|
||||
processing failed), marks the purchase as PAID so retry_stuck_paid_purchases
|
||||
can fulfill it. Includes amount verification.
|
||||
|
||||
Returns the number of recovered purchases.
|
||||
"""
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
cutoff = datetime.now(UTC) - timedelta(minutes=stale_minutes)
|
||||
max_age = datetime.now(UTC) - timedelta(hours=max_age_hours)
|
||||
|
||||
# Find PENDING purchases older than stale_minutes but younger than max_age_hours
|
||||
result = await db.execute(
|
||||
select(GuestPurchase.token, GuestPurchase.payment_method)
|
||||
.where(
|
||||
GuestPurchase.status == GuestPurchaseStatus.PENDING.value,
|
||||
GuestPurchase.created_at < cutoff,
|
||||
GuestPurchase.created_at > max_age,
|
||||
)
|
||||
.order_by(GuestPurchase.created_at.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
pending_purchases = result.all()
|
||||
|
||||
if not pending_purchases:
|
||||
return 0
|
||||
|
||||
recovered = 0
|
||||
for token, payment_method in pending_purchases:
|
||||
try:
|
||||
async with AsyncSessionLocal() as recover_db:
|
||||
paid = await _check_and_recover_pending_purchase(recover_db, token, payment_method)
|
||||
if paid:
|
||||
recovered += 1
|
||||
except Exception:
|
||||
logger.exception('Failed to recover pending purchase', token_prefix=token[:5])
|
||||
|
||||
return recovered
|
||||
|
||||
|
||||
async def _find_succeeded_provider_payment(
|
||||
db: AsyncSession,
|
||||
base_method: str,
|
||||
purchase_token: str,
|
||||
) -> tuple[str, int | None] | None:
|
||||
"""Query provider payment tables for a succeeded payment matching purchase_token.
|
||||
|
||||
Returns ``(provider_payment_id, amount_kopeks)`` or ``None``.
|
||||
``amount_kopeks`` is ``None`` when the amount check should be skipped
|
||||
(e.g., CryptoBot where USD→RUB conversion introduces imprecision).
|
||||
"""
|
||||
from sqlalchemy import cast
|
||||
from sqlalchemy.types import JSON as SA_JSON
|
||||
|
||||
from app.database.models import (
|
||||
CloudPaymentsPayment,
|
||||
CryptoBotPayment,
|
||||
FreekassaPayment,
|
||||
HeleketPayment,
|
||||
KassaAiPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PlategaPayment,
|
||||
RioPayPayment,
|
||||
SeverPayPayment,
|
||||
WataPayment,
|
||||
YooKassaPayment,
|
||||
)
|
||||
|
||||
# --- CryptoBot: special case — payload field (text JSON), skip amount check ---
|
||||
if base_method == 'cryptobot':
|
||||
result = await db.execute(
|
||||
select(CryptoBotPayment).where(
|
||||
CryptoBotPayment.status == 'paid',
|
||||
CryptoBotPayment.payload.like('{%'),
|
||||
cast(CryptoBotPayment.payload, SA_JSON)['purchase_token'].as_string() == purchase_token,
|
||||
)
|
||||
)
|
||||
p = result.scalars().first()
|
||||
return (p.invoice_id, None) if p else None
|
||||
|
||||
# --- All other providers: metadata_json['purchase_token'] + is_paid/status filters ---
|
||||
model = None
|
||||
payment_id_attr: str = ''
|
||||
extra_conditions: list = []
|
||||
|
||||
if base_method.startswith('yookassa'):
|
||||
model = YooKassaPayment
|
||||
payment_id_attr = 'yookassa_payment_id'
|
||||
extra_conditions = [YooKassaPayment.status == 'succeeded', YooKassaPayment.is_paid.is_(True)]
|
||||
elif base_method == 'heleket':
|
||||
model = HeleketPayment
|
||||
payment_id_attr = 'uuid'
|
||||
extra_conditions = [HeleketPayment.status.in_(['paid', 'paid_over'])]
|
||||
elif base_method == 'mulenpay':
|
||||
model = MulenPayPayment
|
||||
payment_id_attr = 'uuid'
|
||||
extra_conditions = [MulenPayPayment.is_paid.is_(True)]
|
||||
elif base_method == 'pal24':
|
||||
model = Pal24Payment
|
||||
payment_id_attr = 'bill_id'
|
||||
extra_conditions = [Pal24Payment.is_paid.is_(True)]
|
||||
elif base_method == 'wata':
|
||||
model = WataPayment
|
||||
payment_id_attr = 'payment_link_id'
|
||||
extra_conditions = [WataPayment.is_paid.is_(True)]
|
||||
elif base_method == 'platega':
|
||||
model = PlategaPayment
|
||||
payment_id_attr = 'platega_transaction_id'
|
||||
extra_conditions = [PlategaPayment.is_paid.is_(True)]
|
||||
elif base_method == 'cloudpayments':
|
||||
model = CloudPaymentsPayment
|
||||
payment_id_attr = 'invoice_id'
|
||||
extra_conditions = [CloudPaymentsPayment.status == 'completed', CloudPaymentsPayment.is_paid.is_(True)]
|
||||
elif base_method == 'freekassa':
|
||||
model = FreekassaPayment
|
||||
payment_id_attr = 'order_id'
|
||||
extra_conditions = [FreekassaPayment.status == 'success', FreekassaPayment.is_paid.is_(True)]
|
||||
elif base_method == 'kassa_ai':
|
||||
model = KassaAiPayment
|
||||
payment_id_attr = 'order_id'
|
||||
extra_conditions = [KassaAiPayment.status == 'success', KassaAiPayment.is_paid.is_(True)]
|
||||
elif base_method == 'riopay':
|
||||
model = RioPayPayment
|
||||
payment_id_attr = 'order_id'
|
||||
extra_conditions = [RioPayPayment.status == 'success', RioPayPayment.is_paid.is_(True)]
|
||||
elif base_method == 'severpay':
|
||||
model = SeverPayPayment
|
||||
payment_id_attr = 'order_id'
|
||||
extra_conditions = [SeverPayPayment.status == 'success', SeverPayPayment.is_paid.is_(True)]
|
||||
|
||||
if model is None:
|
||||
return None
|
||||
|
||||
result = await db.execute(
|
||||
select(model).where(
|
||||
model.metadata_json['purchase_token'].as_string() == purchase_token,
|
||||
*extra_conditions,
|
||||
)
|
||||
)
|
||||
p = result.scalars().first()
|
||||
if p is None:
|
||||
return None
|
||||
|
||||
payment_id = str(getattr(p, payment_id_attr))
|
||||
# amount_kopeks: Integer column for most providers, @property for Heleket
|
||||
amount = getattr(p, 'amount_kopeks', None)
|
||||
return (payment_id, amount)
|
||||
|
||||
|
||||
async def _check_and_recover_pending_purchase(
|
||||
db: AsyncSession,
|
||||
purchase_token: str,
|
||||
payment_method: str | None,
|
||||
) -> bool:
|
||||
"""Check if a PENDING purchase has a succeeded payment and transition to PAID.
|
||||
|
||||
Uses SELECT ... FOR UPDATE on the GuestPurchase row to prevent concurrent
|
||||
webhook processing from racing with the recovery.
|
||||
Verifies amount match between provider payment and guest purchase.
|
||||
"""
|
||||
from app.database.crud.landing import update_purchase_status
|
||||
|
||||
# Lock the row to prevent TOCTOU race with concurrent webhook processing
|
||||
result = await db.execute(select(GuestPurchase).where(GuestPurchase.token == purchase_token).with_for_update())
|
||||
purchase = result.scalars().first()
|
||||
if purchase is None or purchase.status != GuestPurchaseStatus.PENDING.value:
|
||||
return False
|
||||
|
||||
# Resolve base method: 'yookassa_sbp' → 'yookassa', 'kassa_ai' stays 'kassa_ai'
|
||||
base_method = _resolve_base_payment_method(payment_method)
|
||||
|
||||
match = await _find_succeeded_provider_payment(db, base_method, purchase_token)
|
||||
if match is None:
|
||||
if base_method:
|
||||
logger.debug(
|
||||
'No succeeded provider payment found for PENDING purchase',
|
||||
token_prefix=purchase_token[:5],
|
||||
payment_method=payment_method,
|
||||
)
|
||||
return False
|
||||
|
||||
provider_payment_id, provider_amount_kopeks = match
|
||||
|
||||
# Amount verification (skip when provider_amount_kopeks is None, e.g., crypto)
|
||||
if provider_amount_kopeks is not None and provider_amount_kopeks != purchase.amount_kopeks:
|
||||
logger.error(
|
||||
'Amount mismatch during PENDING recovery — skipping',
|
||||
token_prefix=purchase_token[:5],
|
||||
provider_amount=provider_amount_kopeks,
|
||||
purchase_amount=purchase.amount_kopeks,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
# Mark FAILED to prevent repeated mismatch logs every cycle
|
||||
from app.database.crud.landing import update_purchase_status as _update_status
|
||||
|
||||
await _update_status(db, purchase_token, GuestPurchaseStatus.FAILED)
|
||||
await _send_amount_mismatch_alert(purchase, provider_amount_kopeks, provider_payment_id, payment_method)
|
||||
return False
|
||||
|
||||
# Transition PENDING → PAID for retry_stuck_paid_purchases to handle
|
||||
await update_purchase_status(
|
||||
db,
|
||||
purchase_token,
|
||||
GuestPurchaseStatus.PAID,
|
||||
payment_id=provider_payment_id,
|
||||
paid_at=datetime.now(UTC),
|
||||
)
|
||||
logger.info(
|
||||
'Recovered stuck PENDING purchase → PAID',
|
||||
token_prefix=purchase_token[:5],
|
||||
payment_method=payment_method,
|
||||
provider_payment_id=provider_payment_id,
|
||||
)
|
||||
return True
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user