Compare commits
260 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| c1e015fb6e | |||
| 0730173e5b | |||
| 968f18b6e4 | |||
| 7eea35f111 | |||
| 6920e3a0fb | |||
| fddf8ef5eb | |||
| ad268329be | |||
| 1804c28f05 | |||
| f967c29bd7 | |||
| 06a00e367c | |||
| abaf279533 | |||
| 6d4430c639 | |||
| 911df7a05c | |||
| f106ce8216 | |||
| dcff6947dd | |||
| 4966e39eb9 | |||
| 4abb8cb1a3 | |||
| 04f4e6bf6e | |||
| 3d1fbc70f8 | |||
| 3089c1704b | |||
| 20eff6170f | |||
| 038c34e52a | |||
| 77f1a764d5 | |||
| 641da949a9 | |||
| 3f0b24c1ec | |||
| c34fdd10a0 | |||
| 72b5305b87 | |||
| 099391eb5f | |||
| 322d457652 | |||
| 5b722c5210 | |||
| a80a85c2a4 | |||
| f84885cc8a | |||
| 12898b7eab | |||
| 20a6fa1bcf | |||
| 94199413c2 | |||
| 826accba51 | |||
| 1cc687ac15 | |||
| e4bb0430fb | |||
| 603b9a1f46 | |||
| 557af5994d | |||
| 808818ca2b | |||
| b563796091 | |||
| 6a3e9d92b5 | |||
| cda2392411 | |||
| 04419fdff7 | |||
| 713146dd6b | |||
| 7d41ab44be | |||
| 98f6f93487 | |||
| 3752b7b067 | |||
| 2f33e55144 | |||
| c0b282a189 | |||
| e1bcb1ba91 | |||
| 3d68db0a51 | |||
| 8d7f0eea0f | |||
| 4aaf0ddd25 | |||
| db2f0c93f2 | |||
| 3f8e8993b2 | |||
| e453521098 | |||
| 8d3cd50098 | |||
| f80912e444 | |||
| 484d2f7e34 | |||
| 842fb697e6 | |||
| 3ac3a92e26 | |||
| 7648707ca2 | |||
| 7e466ef464 | |||
| 28321df4d2 | |||
| 6adf70b2da | |||
| 2d204275da | |||
| ebee8348ca | |||
| 06954c1711 | |||
| 5e04e2a020 | |||
| 08d69fb47f | |||
| 3306e02902 | |||
| 14dceaa39f | |||
| 5442f288d4 | |||
| 5bf4aeb31e | |||
| 7356921eeb | |||
| f24337fb41 | |||
| 69a38dad25 | |||
| aa3459b846 | |||
| 4d695be7d5 | |||
| b8fcbc7661 | |||
| 96042782d9 | |||
| a625eaae4f | |||
| 869fe06831 | |||
| a5fbd7400f | |||
| 995d66483b | |||
| 5c77bd7a0f | |||
| 04697fd4cb | |||
| c9f2dffabf | |||
| fe4e6acb53 | |||
| e24b911283 | |||
| b551def340 | |||
| 5e9a462261 | |||
| 3a3bd9d499 | |||
| 75dbd2b4fc | |||
| b4ef52caa4 | |||
| ae99358ae9 | |||
| 08bea704de | |||
| 18e2e7841a | |||
| 652b6dabde | |||
| c9a9816daa | |||
| 49c0f3fc10 | |||
| cb43acab31 | |||
| f59b215645 | |||
| 3efa24bab3 | |||
| bd2e93a6a5 | |||
| 978f68e7be | |||
| 28fc36dca4 | |||
| 1660b24f98 | |||
| acf27a1023 | |||
| ce82c2c009 | |||
| e6ebc6722d | |||
| 02e5401327 | |||
| c3bb63ffed | |||
| 88369eec50 | |||
| 83ca51cd5b | |||
| f9dad615ee | |||
| ba049ca017 | |||
| 585baaf63c | |||
| 04197817fe | |||
| b2ee6c766a | |||
| d35ee58aa6 | |||
| 815a1d9136 | |||
| b7775b72dc | |||
| ba54819f9c | |||
| 266340aad1 | |||
| 8f434525eb | |||
| efa1b11db5 | |||
| d0ce193edb | |||
| 92d872236f | |||
| a11f492801 | |||
| c8162505ed | |||
| 076290e0c1 | |||
| 2f5674fcd7 | |||
| b9058e115a | |||
| 673afccb8c | |||
| 1badb39c49 | |||
| 23ff40cd2c | |||
| bf72f241d8 | |||
| 12ae871653 | |||
| 8a362db783 | |||
| b1e2146254 | |||
| 68bc8eb57c | |||
| 9957259881 | |||
| b3f3eba575 | |||
| a798f1143e | |||
| cb5126aff8 | |||
| 8b35428055 | |||
| 5424d8c314 |
+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.30.0"
|
||||
".": "3.38.0"
|
||||
}
|
||||
|
||||
+321
@@ -1,5 +1,326 @@
|
||||
# Changelog
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* добавлен SeverPay в админ-панель и настройки кабинета ([06a00e3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/06a00e367c303b5426008f38a20393ec4f2e07cd))
|
||||
* добавлена интеграция SeverPay для пополнения баланса ([abaf279](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/abaf279533d31994a8a70346627c962b43000c64))
|
||||
* поиск платежей в админ-панели с фильтрами и статистикой ([1804c28](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1804c28f0551fbe52883fc36fc4cfcd60d2d6bd6))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove contains_eager conflicting with selectinload on user relationship ([fddf8ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fddf8ef5ebc8e5176a75a10e92d59165bdabf2e1))
|
||||
* добавлен импорт MAX_ALL_TIME_DAYS в admin_payments routes ([ad26832](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad268329be45bdd665ce4a6f142e9762a78e55b5))
|
||||
* добавлены RioPay и SeverPay в REAL_PAYMENT_METHODS ([f967c29](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f967c29bd7cfe8a66e7e0f492573bb7521fcda22))
|
||||
|
||||
## [3.33.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.4...v3.33.0) (2026-03-17)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add SBP and Card sub-options for KassaAI payment method ([5b722c5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5b722c521036befcbbaf6192215f651c2ec9c4fb))
|
||||
* add SBP and Card sub-options to kassa_ai payment method ([04419fd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/04419fdff7dc244eb2c9553ea1501e2de454010b))
|
||||
* deep link авторизация в кабинете при блокировке oauth.telegram.org ([322d457](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/322d45765220c854e36ae0b4b862a96d36ae3be8))
|
||||
* добавлена поддержка RioPay в кабинете ([3d1fbc7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3d1fbc70f8add81d4a3561d63dcd46f385bcc6f1))
|
||||
* добавлена поддержка RioPay для лендингов и подарков ([04f4e6b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/04f4e6bf6e9031ef8512a07ab36355111c524319))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add back button to payment amount validation errors ([20eff61](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/20eff6170fb752051022a14fa9d927d59ff1d602))
|
||||
* add sync_squads=True to admin tariff change handler ([3f0b24c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3f0b24c1ec82ebfb6fdbc801ed6b493ea09c9a2f))
|
||||
* deep link auth security and reliability fixes ([099391e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/099391eb5f24319703f12ae2cb72d93b26164d99))
|
||||
* enforce promo group authorization on country/server selection ([641da94](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/641da949a907ade7b870e1a5385fdb71f9e524af))
|
||||
* merge phantom users into active accounts on /start ([77f1a76](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/77f1a764d59d68236899ec59c884907d3666d3dd))
|
||||
* MissingGreenlet crash after subscription purchase in cabinet ([a80a85c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a80a85c2a489f397efb4ffb5bd77655420a49adb))
|
||||
* MissingGreenlet crash after subscription purchase in cabinet ([1cc687a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1cc687ac15ecdf12928e5ce448514c09b6628f65))
|
||||
* MissingGreenlet при изменении количества устройств на CLASSIC подписках ([826accb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/826accba519f23a687fcc3d387f727fd31d2a88c))
|
||||
* protect external squads from deletion during server sync ([b563796](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b563796091e83edcc8dfbd6222648b5346f39a9c))
|
||||
* review findings — db.commit, isinstance guard, constants, ACTIVE check ([72b5305](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/72b5305b870ae9ecdb2672549b9c153dd8b3f7bc))
|
||||
* sub-method enabled check, guest payment provider, silent FSM return ([603b9a1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/603b9a1f4610a5b288d6fba78c98474c439529aa))
|
||||
* **subscription:** remove stale extend promo state fields causing NameError ([20a6fa1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/20a6fa1bcf362455623f43838f4ffaaf98b33e76))
|
||||
* swap Caddy auth headers — api_key to Authorization, caddy_token to X-Api-Key ([038c34e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/038c34e52a863d0c5c6993ea785587ba7e0bc61d))
|
||||
* sync squads to Remnawave panel on tariff purchase/switch ([c34fdd1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c34fdd10a0a22a85a4e29cbf44da2ac5d4a643b3))
|
||||
* защита внешних сквадов от удаления при синхронизации серверов ([f84885c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f84885cc8aa0c9a70f916076e987284f1f9c3479))
|
||||
* исправлен расчёт конверсии в статистике продаж ([3089c17](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3089c1704b54323b4c2a151d40a5b395a427c658))
|
||||
* исправлены проблемы RioPay интеграции после ревью ([4abb8cb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4abb8cb1a3f21089697199261bcc37e6f6a5c623))
|
||||
* миграция Tribute webhook с deprecated user_id на trb_user_id ([9419941](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/94199413c283167edd829b337cf9bec0a5414a54))
|
||||
* скрыть плашку верификации email при выключенной верификации ([4966e39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4966e39eb9b92967ef92c92ae434ca6cdea80c84))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* deduplicate KassaAI handlers with config dict and shared helpers ([e4bb043](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e4bb0430fb9cc4f55013767ceda6d1c214bd80a6))
|
||||
* move KASSA_AI_SUB_METHODS to service layer, add early enabled checks ([cda2392](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cda239241122ae1dd02a252b3eadc47453c0c48b))
|
||||
|
||||
## [3.32.4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.3...v3.32.4) (2026-03-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* лог полного payload при ошибке PATCH /api/users для диагностики A039 ([8d7f0ee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8d7f0eea0fecd9e66bf199bd2c288e073f1354c0))
|
||||
* не пересылать activeInternalSquads в рутинных обновлениях RemnaWave (A039) ([4aaf0dd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4aaf0ddd25527ec23fa6a479ac3826d6b6266761))
|
||||
* не пересылать externalSquadUuid в рутинных обновлениях RemnaWave ([3d68db0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3d68db0a51fac55640d44be784c832875ca2da17))
|
||||
* расширен лог PATCH /api/users payload для диагностики A039 ([db2f0c9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/db2f0c93f2974410e744411fb9111c6de1f0f0be))
|
||||
* режим «Контакт и тикеты» возвращает support_type='both' вместо 'tickets' ([2f33e55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f33e5514469f2686c4b35e2105f4188a41d4145))
|
||||
* реферальный бонус инвайтера — сумма вместо максимума, защита флага первого пополнения ([e1bcb1b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e1bcb1ba910ef3a79dec5fa974ae8e6c09494aa7))
|
||||
* сохранение user_id до rollback чтобы избежать MissingGreenlet при lazy load ([3f8e899](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3f8e8993b2949b5a5e04b1d8a468ef7dc1170e08))
|
||||
* убрана отправка externalSquadUuid=null в RemnaWave API и исправлен ложный лог синхронизации рулетки ([f80912e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f80912e444ab5706c809e689ca5ed2a38da118d0))
|
||||
* уведомление об истечении подписки теперь учитывает autopay_enabled пользователя ([c0b282a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c0b282a189a2b761c68fc70886edd91d9c807ff6))
|
||||
* устранена отправка externalSquadUuid=null в RemnaWave API (A039) и исправлен reduce_devices ([e453521](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e4535210982351413cb82483000fe441e7b7300a))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* централизация всех расчётов цен в PricingEngine ([8d3cd50](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8d3cd500980f4f640cb1ba493150f1f20e8bd58c))
|
||||
|
||||
## [3.32.3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.2...v3.32.3) (2026-03-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* campaign registration, revenue calculation, backup restore, autopay errors, referral links ([7648707](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7648707ca26d6cd2703b50b0fe8c4697e6155784))
|
||||
* implement case-insensitive email checks in authentication and user retrieval ([7e466ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7e466ef464ce918d885bd6297d1e605a633fd43e))
|
||||
* implement case-insensitive email checks in authentication and user retrieval ([ebee834](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebee8348ca338b9be5f044537e5e2b4740dc6441))
|
||||
* **payment:** prioritize saved cart after topup over expired auto-extend ([28321df](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/28321df4d274269536efebcf3da870f2e7d07d90))
|
||||
* refresh CLASSIC_PERIOD_PRICES when admin changes PRICE_*_DAYS or SALES_MODE ([6adf70b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6adf70b2da6e2250cc8e909dbb497b355302e72f))
|
||||
|
||||
## [3.32.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.1...v3.32.2) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add nested selectinload and referrer eager loading to prevent MissingGreenlet ([3306e02](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3306e029021c396e13774a205225beece4fbbcfb))
|
||||
* add selectinload to user lock queries to prevent MissingGreenlet ([5442f28](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5442f288d4c6c3973dd92ac141172a9f0e53a28f))
|
||||
* silence PARTICIPANT_ID_INVALID error in channel subscription check ([14dceaa](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/14dceaa39ff9faa1c9205483653014a1c5ac73fb))
|
||||
|
||||
## [3.32.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.0...v3.32.1) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* invalid ISO date format in node usage stats API call ([69a38da](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/69a38dad259bd05f4658e1014ce0bd73fc2e2ac5))
|
||||
* platega webhook ID fallback for SBP and card payments ([aa3459b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aa3459b8463ce0a54b7709aa3547b2337064fa26))
|
||||
* resolve MissingGreenlet in switch_tariff endpoint ([4d695be](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4d695be7d51adda40fa72c00c349fb0e1ec4acd2))
|
||||
|
||||
## [3.32.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.31.0...v3.32.0) (2026-03-13)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add _calculate_servers_price (fixed fallback) and _calculate_traffic_price ([88369ee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/88369eec5047e733d26d2450a74abd0d600b2e1b))
|
||||
* add CLASSIC_PERIOD_PRICES to config ([c3bb63f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c3bb63ffed6e0b684c322aa51d70ab7e71c8eb6b))
|
||||
* add LIMITED subscription status and preserve extra devices on tariff switch ([8f43452](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8f434525eb14618e3c3e26261d443b1632c111bb))
|
||||
* add RenewalPricing dataclass and PricingEngine discount methods ([83ca51c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/83ca51cd5b040e747c6db904dde0f3a5c59f480f))
|
||||
* implement calculate_renewal_price with tariff and classic modes ([02e5401](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02e5401327786c9dfe5ae7d4c89624c9455aa53e))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing settings import in admin_users tariff switch ([b2ee6c7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b2ee6c766a1fb0c9a701684a6349b970d12f5e2e))
|
||||
* add per-category discounts and months multiplier to classic mode ([1660b24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1660b24f9844374bbd156f9202a8e1550a6beb49))
|
||||
* add period_days whitelist validation and type annotations ([18e2e78](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/18e2e7841a6d614263e7c87db5964916ec869a9d))
|
||||
* address 6-agent review findings for PricingEngine ([c9f2dff](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c9f2dffabf6369df360c5f9ad7a12c0415026310))
|
||||
* address review findings from 5-agent audit ([08bea70](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/08bea704ded78102dce29deac8da95c4e4b9d815))
|
||||
* atomicity refactor, review fixes, and DELETED recovery logging ([ba54819](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba54819f9cd7f60914dd472b68885683f435db4e))
|
||||
* change None assignment to [] + add "or []" guards at all 5 call sites. ([a5fbd74](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a5fbd7400f828824c5baa520bdaf06023b4caf70))
|
||||
* downgrade known-harmless RemnaWave 400s to warning level ([0419781](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/04197817fede058dc4688dce2f9877f0fc2a7f7f))
|
||||
* guard rollback on commit flag, add flush to promo_offer_log ([b7775b7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b7775b72dc7a1b3d18f179c2f247fe9f47023347))
|
||||
* handle legacy telegram_id in YooKassa webhook recovery metadata ([815a1d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/815a1d9136f39b932d4b369aec9d67034d6785d9))
|
||||
* harden remnawave API error handling and YooKassa user cross-validation ([585baaf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/585baaf63c9f535e5311a32085d0187d8c854001))
|
||||
* harden YooKassa webhook recovery user lookup ([d35ee58](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d35ee58aa6f3edc6a9e8ab43025569262acf64a2))
|
||||
* payment providers — lock_user_for_update + commit=False atomicity ([b4ef52c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b4ef52caa4b324eded8e6c6cb715a09ad59140c1))
|
||||
* prevent balance loss on auto-purchase for DISABLED subscriptions and fix WATA expiration ([266340a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/266340aad195995f208ed82fc11e0909d34898f4))
|
||||
* pricing audit — display/charge parity, race conditions, balance locks ([ae99358](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ae99358ae9f35a25370ab127d98a0b630a08e3f2))
|
||||
* resolve merge conflict with dev (accept calc_device_limit_on_tariff_switch) ([ba049ca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba049ca017e004c25f8738f01b2d5f329a35bb5e))
|
||||
* user deletion FK error + connected_squads None TypeError ([a5fbd74](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a5fbd7400f828824c5baa520bdaf06023b4caf70))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* add typed breakdowns + module-level singleton to PricingEngine ([b551def](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b551def3402e2bf762406fb0b374360958231bb3))
|
||||
* extract shared formatting helpers into app/utils/formatting.py ([5e9a462](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5e9a462261e46ee649de481266a821fd6793bf2e))
|
||||
* make finalize() accept both old and new pricing types ([3efa24b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3efa24bab3a2bd1d31103b134e502c10af8e41e1))
|
||||
* migrate admin user price calculation to PricingEngine ([49c0f3f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/49c0f3fc10d27092961601cf7f6a780fb56885fa))
|
||||
* migrate all callers to pricing_engine singleton + fix miniapp discount ([e24b911](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e24b911283bf4cbee7b18d3e49c935217e4a2863))
|
||||
* migrate bot renewal display to PricingEngine ([ce82c2c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ce82c2c00988542ac73dc9d2e811711ea9cefebe))
|
||||
* migrate bot renewal execute to PricingEngine ([acf27a1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/acf27a102308d38676b2ccaef78016b56a80935d))
|
||||
* migrate cabinet renewal display + execute to PricingEngine ([28fc36d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/28fc36dca41b626430baf823274561269023ac59))
|
||||
* migrate cart auto-purchase to PricingEngine (fresh calc) ([bd2e93a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bd2e93a6a5076341b104f7dba2b7fc5fdb587e66))
|
||||
* migrate menu.py renewal pricing to PricingEngine ([652b6da](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/652b6dabde014d19075f13198dd06e5fb8bef380))
|
||||
* migrate miniapp renewal display + execute to PricingEngine ([cb43aca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cb43acab3194bbf9b6e2c04ca0254ba5b2571b2d))
|
||||
* migrate recurrent and monitoring services to PricingEngine ([978f68e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/978f68e7be42b0faf92d9ac5bc0bbaa2022ac95b))
|
||||
* migrate remaining callers to PricingEngine + cleanup dead CRUD ([75dbd2b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/75dbd2b4fcc8ab14ac44d915bf55b10406544bb1))
|
||||
* migrate try_auto_extend_expired to PricingEngine ([e6ebc67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e6ebc6722d826d291156e6bff3bf86000b32b783))
|
||||
* remove dead pricing code and fix miniapp classic mode ([c9a9816](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c9a9816daa15a4534a3990822543eeefe1a1631b))
|
||||
* unify first-purchase discount algorithm with PricingEngine ([fe4e6ac](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe4e6acb5391d0797ea01281eeb2e2ea59a0070f))
|
||||
|
||||
## [3.31.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.30.0...v3.31.0) (2026-03-12)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add show_in_gift toggle for tariffs in admin panel ([cb5126a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cb5126aff8c15938a59ea9c4f8e605b250b05dbc))
|
||||
* add sync-squads endpoint for bulk updating subscription squads in Remnawave ([b1e2146](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b1e2146254255586b5be9bd894ac4d113a0a8cf5))
|
||||
* auto-sync squads to Remnawave when admin updates tariff ([076290e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/076290e0c1d81b610a7653d6b64ed218e0f124b4))
|
||||
* referral links now point to web cabinet instead of bot ([12ae871](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/12ae871653399bc4ccd23b6394878e814ce9cd75))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add post_update=True to User.referrals self-referential relationship ([9957259](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/995725988150f31d193631120a4692e88fa4dd57))
|
||||
* add Telegram Stars payment support for gift subscriptions ([5424d8c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5424d8c31484873b0adc0bc980abdc51ee81325b))
|
||||
* correct skipped_count in sync-squads circuit breaker and simplify ternary ([8a362db](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8a362db7833b5b7793b5b52345d227cb84cbc39e))
|
||||
* preserve purchased devices when admin changes user tariff ([bf72f24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bf72f241d81e4432f50a61ec3bb829d18c92955d))
|
||||
* prevent account takeover via auto_login_token, ensure promo group on all purchase paths ([b3f3eba](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b3f3eba5756404df9ed0f12d8048244ca536f7d3))
|
||||
* reactivate subscription after traffic top-up when status is EXPIRED ([8b35428](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8b354280558a5f28d1b99eae55ccd21a4af6a07b))
|
||||
* update promo group via M2M table so admin changes persist ([68bc8eb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/68bc8eb57c792059d2be8a8fff6bba3254d3773d))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* remove estimated price from balance, simplify server sync, fix HTML injection ([a798f11](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a798f1143eebf52e18254bddd610f7f14a0c4056))
|
||||
|
||||
## [3.30.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.29.0...v3.30.0) (2026-03-11)
|
||||
|
||||
|
||||
|
||||
+19
-32
@@ -197,28 +197,17 @@ async def create_subscription(
|
||||
### Документация кода
|
||||
|
||||
```python
|
||||
async def calculate_subscription_price(
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
devices_count: int,
|
||||
servers_count: int
|
||||
) -> int:
|
||||
"""
|
||||
Рассчитывает стоимость подписки.
|
||||
|
||||
Args:
|
||||
period_days: Период подписки в днях
|
||||
traffic_gb: Лимит трафика в ГБ (0 = безлимит)
|
||||
devices_count: Количество устройств
|
||||
servers_count: Количество серверов
|
||||
|
||||
Returns:
|
||||
Стоимость в копейках
|
||||
|
||||
Raises:
|
||||
ValueError: Если переданы некорректные параметры
|
||||
"""
|
||||
# implementation
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
pricing = PricingEngine.calculate_renewal_price(
|
||||
subscription=subscription,
|
||||
period_days=30,
|
||||
user=user,
|
||||
)
|
||||
# pricing.final_total — стоимость в копейках
|
||||
# pricing.original_total — цена до скидок
|
||||
# pricing.promo_group_discount — скидка промогруппы
|
||||
# pricing.promo_offer_discount — скидка промо-оффера
|
||||
```
|
||||
|
||||
### Обработка ошибок
|
||||
@@ -341,20 +330,18 @@ python main.py
|
||||
### Тестирование компонентов
|
||||
|
||||
```python
|
||||
# tests/test_subscription_service.py
|
||||
# tests/services/test_pricing_engine.py
|
||||
import pytest
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calculate_price():
|
||||
price = await SubscriptionService.calculate_subscription_price(
|
||||
def test_calculate_renewal_price():
|
||||
pricing = PricingEngine.calculate_renewal_price(
|
||||
subscription=mock_subscription,
|
||||
period_days=30,
|
||||
traffic_gb=100,
|
||||
devices_count=3,
|
||||
servers_count=1
|
||||
user=mock_user,
|
||||
)
|
||||
assert price > 0
|
||||
assert isinstance(price, int)
|
||||
assert pricing.final_total > 0
|
||||
assert isinstance(pricing.final_total, int)
|
||||
```
|
||||
|
||||
### Integration тесты
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.30.0" # x-release-please-version
|
||||
ARG VERSION="v3.38.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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
"""Admin routes for payment verification in cabinet."""
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
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,
|
||||
PeriodPreset,
|
||||
SearchParams,
|
||||
StatusFilter,
|
||||
search_payments,
|
||||
search_payments_stats,
|
||||
)
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.payment_verification_service import (
|
||||
SUPPORTED_MANUAL_CHECK_METHODS,
|
||||
@@ -54,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
|
||||
@@ -87,6 +93,16 @@ class PaymentsStatsResponse(BaseModel):
|
||||
by_method: dict
|
||||
|
||||
|
||||
class SearchStatsResponse(BaseModel):
|
||||
"""Statistics for payment search results."""
|
||||
|
||||
total: int
|
||||
pending: int
|
||||
paid: int
|
||||
cancelled: int
|
||||
by_method: dict
|
||||
|
||||
|
||||
# ============ Helper functions ============
|
||||
|
||||
|
||||
@@ -241,6 +257,8 @@ def _get_payment_url(record: PendingPayment) -> str | None:
|
||||
elif record.method == PaymentMethod.CLOUDPAYMENTS or record.method == PaymentMethod.FREEKASSA:
|
||||
payment_url = getattr(payment, 'payment_url', None) or payment_url
|
||||
|
||||
if payment_url and not payment_url.startswith(('https://', 'http://')):
|
||||
return None
|
||||
return payment_url
|
||||
|
||||
|
||||
@@ -265,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,
|
||||
)
|
||||
|
||||
|
||||
@@ -329,6 +348,140 @@ async def get_payments_stats(
|
||||
)
|
||||
|
||||
|
||||
@router.get('/search', response_model=PendingPaymentListResponse)
|
||||
async def search_payments_endpoint(
|
||||
search: str | None = Query(
|
||||
None, max_length=256, description='Search query (invoice, @username, telegram_id, email)'
|
||||
),
|
||||
status_filter: str = Query('all', description='Status filter: all, pending, paid, cancelled'),
|
||||
method_filter: str | None = Query(None, description='Filter by payment method'),
|
||||
period: str = Query('24h', description='Period preset: 24h, 7d, 30d, all'),
|
||||
date_from: datetime | None = Query(None, description='Custom range start (ISO 8601)'),
|
||||
date_to: datetime | None = Query(None, description='Custom range end (ISO 8601)'),
|
||||
page: int = Query(1, ge=1, description='Page number'),
|
||||
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
|
||||
admin: User = Depends(require_permission('payments:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Search payments across all providers with filters."""
|
||||
try:
|
||||
parsed_status = StatusFilter(status_filter)
|
||||
except ValueError:
|
||||
parsed_status = StatusFilter.ALL
|
||||
|
||||
try:
|
||||
parsed_period = PeriodPreset(period)
|
||||
except ValueError:
|
||||
parsed_period = PeriodPreset.H24
|
||||
|
||||
parsed_method: PaymentMethod | None = None
|
||||
if method_filter:
|
||||
try:
|
||||
parsed_method = PaymentMethod(method_filter)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Ensure custom dates are timezone-aware
|
||||
if date_from is not None and date_from.tzinfo is None:
|
||||
date_from = date_from.replace(tzinfo=UTC)
|
||||
if date_to is not None and date_to.tzinfo is None:
|
||||
date_to = date_to.replace(tzinfo=UTC)
|
||||
|
||||
# Clamp custom dates to safety limit
|
||||
min_allowed = datetime.now(UTC) - timedelta(days=MAX_ALL_TIME_DAYS)
|
||||
if date_from is not None and date_from < min_allowed:
|
||||
date_from = min_allowed
|
||||
if date_from is not None and date_to is not None and date_from > date_to:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='date_from must be before date_to')
|
||||
|
||||
params = SearchParams(
|
||||
search=search.strip() if search else None,
|
||||
status_filter=parsed_status,
|
||||
method_filter=parsed_method,
|
||||
period=parsed_period,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
)
|
||||
|
||||
page_items, total = await search_payments(db, params)
|
||||
pages = math.ceil(total / per_page) if total > 0 else 1
|
||||
items = [_record_to_response(p) for p in page_items]
|
||||
|
||||
return PendingPaymentListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/search/stats', response_model=SearchStatsResponse)
|
||||
async def search_payments_stats_endpoint(
|
||||
search: str | None = Query(
|
||||
None, max_length=256, description='Search query (invoice, @username, telegram_id, email)'
|
||||
),
|
||||
status_filter: str = Query('all', description='Status filter: all, pending, paid, cancelled'),
|
||||
method_filter: str | None = Query(None, description='Filter by payment method'),
|
||||
period: str = Query('24h', description='Period preset: 24h, 7d, 30d, all'),
|
||||
date_from: datetime | None = Query(None, description='Custom range start (ISO 8601)'),
|
||||
date_to: datetime | None = Query(None, description='Custom range end (ISO 8601)'),
|
||||
admin: User = Depends(require_permission('payments:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get aggregated statistics for payment search results."""
|
||||
try:
|
||||
parsed_status = StatusFilter(status_filter)
|
||||
except ValueError:
|
||||
parsed_status = StatusFilter.ALL
|
||||
|
||||
try:
|
||||
parsed_period = PeriodPreset(period)
|
||||
except ValueError:
|
||||
parsed_period = PeriodPreset.H24
|
||||
|
||||
parsed_method: PaymentMethod | None = None
|
||||
if method_filter:
|
||||
try:
|
||||
parsed_method = PaymentMethod(method_filter)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Ensure custom dates are timezone-aware
|
||||
if date_from is not None and date_from.tzinfo is None:
|
||||
date_from = date_from.replace(tzinfo=UTC)
|
||||
if date_to is not None and date_to.tzinfo is None:
|
||||
date_to = date_to.replace(tzinfo=UTC)
|
||||
|
||||
# Clamp custom dates to safety limit
|
||||
min_allowed = datetime.now(UTC) - timedelta(days=MAX_ALL_TIME_DAYS)
|
||||
if date_from is not None and date_from < min_allowed:
|
||||
date_from = min_allowed
|
||||
if date_from is not None and date_to is not None and date_from > date_to:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='date_from must be before date_to')
|
||||
|
||||
params = SearchParams(
|
||||
search=search.strip() if search else None,
|
||||
status_filter=parsed_status,
|
||||
method_filter=parsed_method,
|
||||
period=parsed_period,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
|
||||
stats = await search_payments_stats(db, params)
|
||||
|
||||
return SearchStatsResponse(
|
||||
total=stats.total,
|
||||
pending=stats.pending,
|
||||
paid=stats.paid,
|
||||
cancelled=stats.cancelled,
|
||||
by_method=stats.by_method or {},
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{method}/{payment_id}', response_model=PendingPaymentResponse)
|
||||
async def get_pending_payment_details(
|
||||
method: str,
|
||||
@@ -342,7 +495,7 @@ async def get_pending_payment_details(
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid payment method: {method}',
|
||||
detail='Invalid payment method',
|
||||
)
|
||||
|
||||
record = await get_payment_record(db, payment_method, payment_id)
|
||||
@@ -369,7 +522,7 @@ async def check_payment_status(
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid payment method: {method}',
|
||||
detail='Invalid payment method',
|
||||
)
|
||||
|
||||
# Get current record
|
||||
@@ -394,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(
|
||||
@@ -435,6 +450,13 @@ async def assign_role(
|
||||
detail='Cannot assign a role with level >= your own role level',
|
||||
)
|
||||
|
||||
# Superadmin assignments must be permanent — expiry would cause silent lockout
|
||||
if role.level == SUPERADMIN_LEVEL and payload.expires_at is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Superadmin role assignments cannot be time-limited',
|
||||
)
|
||||
|
||||
# Verify target user exists
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
@@ -484,12 +506,12 @@ async def revoke_role(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Revoke a role assignment. Cannot remove the last superadmin."""
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import get_user_by_id
|
||||
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(
|
||||
@@ -513,9 +535,19 @@ 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:
|
||||
# Block self-revocation of superadmin role
|
||||
if role.level == SUPERADMIN_LEVEL and user_role.user_id == admin.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot revoke your own superadmin role',
|
||||
)
|
||||
|
||||
# Protect last superadmin (level 999).
|
||||
# Advisory lock serializes concurrent superadmin revocations so two requests
|
||||
# cannot both read count=2 and then both proceed to revoke.
|
||||
if role.level == SUPERADMIN_LEVEL:
|
||||
if not settings.is_sqlite():
|
||||
await db.execute(sa.text('SELECT pg_advisory_xact_lock(736453)'))
|
||||
superadmin_count = await UserRoleCRUD.get_superadmin_count(db)
|
||||
if superadmin_count <= 1:
|
||||
raise HTTPException(
|
||||
@@ -523,13 +555,16 @@ async def revoke_role(
|
||||
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',
|
||||
)
|
||||
# Warn if target user is a legacy admin — RBAC revocation won't actually block access
|
||||
target_user = await get_user_by_id(db, user_role.user_id)
|
||||
is_target_legacy = target_user and settings.is_admin(
|
||||
telegram_id=target_user.telegram_id,
|
||||
email=target_user.email if target_user.email_verified else None,
|
||||
)
|
||||
|
||||
# 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 +574,11 @@ async def revoke_role(
|
||||
target_user_id=user_role.user_id,
|
||||
role_name=role.name,
|
||||
)
|
||||
return {'message': 'Role revoked', 'assignment_id': assignment_id}
|
||||
|
||||
result_msg = {'message': 'Role revoked', 'assignment_id': assignment_id}
|
||||
if is_target_legacy:
|
||||
result_msg['warning'] = (
|
||||
'This user is still listed in ADMIN_IDS/ADMIN_EMAILS env config. '
|
||||
'They retain full access until removed from those settings and the bot is restarted.'
|
||||
)
|
||||
return result_msg
|
||||
|
||||
@@ -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(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(
|
||||
@@ -170,6 +186,7 @@ async def get_sales_summary(
|
||||
new_trials = row.new_trials or 0
|
||||
|
||||
# Trial-to-paid conversion in period
|
||||
# Method 1: SubscriptionConversion records (only created by some purchase flows)
|
||||
conversions_result = await db.execute(
|
||||
select(func.count(SubscriptionConversion.id)).where(
|
||||
and_(
|
||||
@@ -178,9 +195,29 @@ async def get_sales_summary(
|
||||
)
|
||||
)
|
||||
)
|
||||
conversions = conversions_result.scalar() or 0
|
||||
# Cap at 100%: conversions from previous periods can exceed current new_trials
|
||||
conversion_rate = min(round((conversions / new_trials * 100), 1), 100.0) if new_trials > 0 else 0.0
|
||||
conversion_records = conversions_result.scalar() or 0
|
||||
|
||||
# Method 2: Users registered in period who have paid (catches all purchase flows)
|
||||
converted_users_result = await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
and_(
|
||||
User.created_at >= period_start,
|
||||
User.created_at <= period_end,
|
||||
User.has_had_paid_subscription.is_(True),
|
||||
)
|
||||
)
|
||||
)
|
||||
converted_users = converted_users_result.scalar() or 0
|
||||
|
||||
# Use the higher count to catch conversions from all purchase flows
|
||||
conversions = max(conversion_records, converted_users)
|
||||
|
||||
# new_trials only counts REMAINING trials (is_trial=True), but converted users
|
||||
# had is_trial flipped to False. Add conversions back to get total trial starters.
|
||||
total_trial_starters = new_trials + conversions
|
||||
conversion_rate = (
|
||||
min(round((conversions / total_trial_starters * 100), 1), 100.0) if total_trial_starters > 0 else 0.0
|
||||
)
|
||||
|
||||
# Renewals count
|
||||
renewals_subquery = (
|
||||
@@ -222,7 +259,8 @@ async def get_sales_summary(
|
||||
addon_revenue = abs(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,
|
||||
@@ -290,6 +328,7 @@ async def get_trials_stats(
|
||||
)
|
||||
total_trials = total_result.scalar() or 0
|
||||
|
||||
# Conversion: SubscriptionConversion records + fallback to has_had_paid_subscription
|
||||
conversions_result = await db.execute(
|
||||
select(func.count(SubscriptionConversion.id)).where(
|
||||
and_(
|
||||
@@ -298,9 +337,25 @@ async def get_trials_stats(
|
||||
)
|
||||
)
|
||||
)
|
||||
conversions = conversions_result.scalar() or 0
|
||||
# Cap at 100%: conversions from previous periods can exceed current period trials
|
||||
conversion_rate = min(round((conversions / total_trials * 100), 1), 100.0) if total_trials > 0 else 0.0
|
||||
conversion_records = conversions_result.scalar() or 0
|
||||
|
||||
converted_users_result = await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
and_(
|
||||
User.created_at >= period_start,
|
||||
User.created_at <= period_end,
|
||||
User.has_had_paid_subscription.is_(True),
|
||||
)
|
||||
)
|
||||
)
|
||||
converted_users = converted_users_result.scalar() or 0
|
||||
conversions = max(conversion_records, converted_users)
|
||||
|
||||
# total_trials only counts remaining is_trial=True; add conversions for total starters
|
||||
total_trial_starters = total_trials + conversions
|
||||
conversion_rate = (
|
||||
min(round((conversions / total_trial_starters * 100), 1), 100.0) if total_trial_starters > 0 else 0.0
|
||||
)
|
||||
|
||||
avg_duration_result = await db.execute(
|
||||
select(func.avg(SubscriptionConversion.trial_duration_days)).where(
|
||||
@@ -1022,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,
|
||||
)
|
||||
@@ -1033,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()
|
||||
@@ -1076,7 +1132,7 @@ 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'),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Admin routes for managing tariffs in cabinet."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.database.crud.server_squad import get_all_server_squads
|
||||
from app.database.crud.tariff import (
|
||||
@@ -17,7 +20,7 @@ from app.database.crud.tariff import (
|
||||
set_tariff_promo_groups,
|
||||
update_tariff,
|
||||
)
|
||||
from app.database.models import PromoGroup, Subscription, Tariff, Transaction, TransactionType, User
|
||||
from app.database.models import PromoGroup, Subscription, SubscriptionStatus, Tariff, Transaction, TransactionType, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.tariffs import (
|
||||
@@ -26,6 +29,7 @@ from ..schemas.tariffs import (
|
||||
PromoGroupInfo,
|
||||
ServerInfo,
|
||||
ServerTrafficLimit,
|
||||
SyncSquadsResponse,
|
||||
TariffCreateRequest,
|
||||
TariffDetailResponse,
|
||||
TariffListItem,
|
||||
@@ -127,6 +131,7 @@ async def list_tariffs(
|
||||
is_daily=tariff.is_daily,
|
||||
daily_price_kopeks=tariff.daily_price_kopeks,
|
||||
allow_traffic_topup=tariff.allow_traffic_topup,
|
||||
show_in_gift=tariff.show_in_gift,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
tier_level=tariff.tier_level,
|
||||
@@ -265,6 +270,8 @@ async def get_tariff(
|
||||
traffic_reset_mode=tariff.traffic_reset_mode,
|
||||
# Внешний сквад
|
||||
external_squad_uuid=tariff.external_squad_uuid,
|
||||
# Показывать в подарках
|
||||
show_in_gift=tariff.show_in_gift,
|
||||
created_at=tariff.created_at,
|
||||
updated_at=tariff.updated_at,
|
||||
)
|
||||
@@ -321,6 +328,8 @@ async def create_new_tariff(
|
||||
traffic_reset_mode=request.traffic_reset_mode,
|
||||
# Внешний сквад
|
||||
external_squad_uuid=request.external_squad_uuid,
|
||||
# Показывать в подарках
|
||||
show_in_gift=request.show_in_gift,
|
||||
)
|
||||
|
||||
logger.info('Admin created tariff', admin_id=admin.id, tariff_id=tariff.id, tariff_name=tariff.name)
|
||||
@@ -347,6 +356,10 @@ async def update_existing_tariff(
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Capture old values for change detection
|
||||
old_squads = list(tariff.allowed_squads) if tariff.allowed_squads else []
|
||||
old_external_squad = tariff.external_squad_uuid
|
||||
|
||||
# Build updates dict
|
||||
updates = {}
|
||||
if request.name is not None:
|
||||
@@ -413,6 +426,9 @@ async def update_existing_tariff(
|
||||
# Внешний сквад (None допускается для сброса)
|
||||
if 'external_squad_uuid' in request.model_fields_set:
|
||||
updates['external_squad_uuid'] = request.external_squad_uuid
|
||||
# Показывать в подарках
|
||||
if request.show_in_gift is not None:
|
||||
updates['show_in_gift'] = request.show_in_gift
|
||||
|
||||
if updates:
|
||||
await update_tariff(db, tariff, **updates)
|
||||
@@ -426,6 +442,18 @@ async def update_existing_tariff(
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
|
||||
# Auto-sync squads to active subscriptions in Remnawave when squads changed
|
||||
new_squads = tariff.allowed_squads or []
|
||||
squads_changed = request.allowed_squads is not None and sorted(old_squads) != sorted(new_squads)
|
||||
ext_squad_changed = (
|
||||
'external_squad_uuid' in request.model_fields_set and tariff.external_squad_uuid != old_external_squad
|
||||
)
|
||||
if squads_changed or ext_squad_changed:
|
||||
asyncio.create_task(
|
||||
_background_sync_squads(tariff_id, admin.id),
|
||||
name=f'sync-squads-tariff-{tariff_id}',
|
||||
)
|
||||
|
||||
return await get_tariff(tariff_id, admin, db)
|
||||
|
||||
|
||||
@@ -586,3 +614,217 @@ async def get_tariff_stats(
|
||||
revenue_kopeks=revenue_kopeks,
|
||||
revenue_rubles=revenue_kopeks / 100,
|
||||
)
|
||||
|
||||
|
||||
async def _background_sync_squads(tariff_id: int, admin_id: int) -> None:
|
||||
"""Run squad sync in background with its own DB session (fire-and-forget)."""
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff:
|
||||
return
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.join(User, Subscription.user_id == User.id)
|
||||
.options(joinedload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.tariff_id == tariff_id,
|
||||
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
|
||||
User.remnawave_uuid.isnot(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
subscriptions = list(result.unique().scalars().all())
|
||||
|
||||
if not subscriptions:
|
||||
return
|
||||
|
||||
new_squads = tariff.allowed_squads or []
|
||||
ext_squad_uuid = tariff.external_squad_uuid
|
||||
|
||||
service = RemnaWaveService()
|
||||
updated = 0
|
||||
failed = 0
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
semaphore = asyncio.Semaphore(5)
|
||||
|
||||
async def _sync_one(sub: Subscription) -> None:
|
||||
nonlocal updated, failed
|
||||
remnawave_uuid = sub.user.remnawave_uuid if sub.user else None
|
||||
if not remnawave_uuid:
|
||||
return
|
||||
async with semaphore:
|
||||
try:
|
||||
await api.update_user(
|
||||
uuid=remnawave_uuid,
|
||||
active_internal_squads=new_squads,
|
||||
external_squad_uuid=ext_squad_uuid,
|
||||
)
|
||||
sub.connected_squads = new_squads
|
||||
updated += 1
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
logger.warning(
|
||||
'Background sync: failed to sync squads for user',
|
||||
user_id=sub.user_id,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
await asyncio.gather(*[_sync_one(sub) for sub in subscriptions])
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
'Background squad sync completed after tariff update',
|
||||
admin_id=admin_id,
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff.name,
|
||||
total=len(subscriptions),
|
||||
updated=updated,
|
||||
failed=failed,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Background squad sync failed', tariff_id=tariff_id)
|
||||
|
||||
|
||||
_SYNC_SQUADS_CONCURRENCY = 5
|
||||
_SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES = 10
|
||||
|
||||
|
||||
@router.post('/{tariff_id}/sync-squads', response_model=SyncSquadsResponse)
|
||||
async def sync_tariff_squads(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(require_permission('tariffs:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Sync squads from tariff to all active/trial subscriptions in Remnawave panel.
|
||||
|
||||
Updates connected_squads and external_squad_uuid for every active or trial
|
||||
subscription linked to this tariff. Only users that have a remnawave_uuid
|
||||
(i.e. already exist in the panel) are touched.
|
||||
"""
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Fetch active + trial subscriptions for this tariff whose users exist in Remnawave
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.join(User, Subscription.user_id == User.id)
|
||||
.options(joinedload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.tariff_id == tariff_id,
|
||||
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
|
||||
User.remnawave_uuid.isnot(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
subscriptions = list(result.unique().scalars().all())
|
||||
|
||||
if not subscriptions:
|
||||
return SyncSquadsResponse(
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff.name,
|
||||
total_subscriptions=0,
|
||||
updated_count=0,
|
||||
failed_count=0,
|
||||
skipped_count=0,
|
||||
)
|
||||
|
||||
new_squads = tariff.allowed_squads or []
|
||||
# None means "clear external squad" — intentional when tariff has none
|
||||
ext_squad_uuid = tariff.external_squad_uuid
|
||||
|
||||
# Sync to Remnawave panel with concurrency limit and circuit breaker
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
updated_count = 0
|
||||
failed_count = 0
|
||||
skipped_count = 0
|
||||
consecutive_failures = 0
|
||||
errors: list[str] = []
|
||||
aborted = False
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
semaphore = asyncio.Semaphore(_SYNC_SQUADS_CONCURRENCY)
|
||||
|
||||
async def _sync_one(sub: Subscription) -> str:
|
||||
# Counter mutations are safe: no `await` between read-modify-write
|
||||
# and the check within each branch (single-threaded asyncio event loop).
|
||||
nonlocal updated_count, failed_count, skipped_count, consecutive_failures, aborted
|
||||
|
||||
if aborted:
|
||||
skipped_count += 1
|
||||
return 'skipped'
|
||||
|
||||
remnawave_uuid = sub.user.remnawave_uuid if sub.user else None
|
||||
if not remnawave_uuid:
|
||||
skipped_count += 1
|
||||
return 'skipped'
|
||||
|
||||
async with semaphore:
|
||||
if aborted:
|
||||
skipped_count += 1
|
||||
return 'skipped'
|
||||
|
||||
try:
|
||||
await api.update_user(
|
||||
uuid=remnawave_uuid,
|
||||
active_internal_squads=new_squads,
|
||||
external_squad_uuid=ext_squad_uuid,
|
||||
)
|
||||
# Update local DB only on successful API call
|
||||
sub.connected_squads = new_squads
|
||||
updated_count += 1
|
||||
consecutive_failures = 0
|
||||
return 'ok'
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
consecutive_failures += 1
|
||||
errors.append(f'user_id={sub.user_id}: sync failed')
|
||||
logger.warning(
|
||||
'Failed to sync squads for user in Remnawave',
|
||||
user_id=sub.user_id,
|
||||
remnawave_uuid=remnawave_uuid,
|
||||
error=str(e),
|
||||
)
|
||||
if consecutive_failures >= _SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES:
|
||||
aborted = True
|
||||
errors.append(f'Aborted after {_SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES} consecutive failures')
|
||||
return 'error'
|
||||
|
||||
await asyncio.gather(*[_sync_one(sub) for sub in subscriptions])
|
||||
|
||||
# Commit local DB changes only for successfully synced subscriptions
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Admin synced squads for tariff',
|
||||
admin_id=admin.id,
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff.name,
|
||||
total=len(subscriptions),
|
||||
updated=updated_count,
|
||||
failed=failed_count,
|
||||
skipped=skipped_count,
|
||||
)
|
||||
|
||||
return SyncSquadsResponse(
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff.name,
|
||||
total_subscriptions=len(subscriptions),
|
||||
updated_count=updated_count,
|
||||
failed_count=failed_count,
|
||||
skipped_count=skipped_count,
|
||||
errors=errors[:20],
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,10 +4,11 @@ from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import Integer, and_, func, or_, select
|
||||
from sqlalchemy import Integer, and_, delete as sa_delete, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.campaign import get_campaign_registration_by_user
|
||||
from app.database.crud.subscription import (
|
||||
extend_subscription,
|
||||
@@ -25,6 +26,7 @@ from app.database.crud.user import (
|
||||
get_users_statistics,
|
||||
subtract_user_balance,
|
||||
)
|
||||
from app.database.crud.user_promo_group import sync_user_primary_promo_group
|
||||
from app.database.models import (
|
||||
GuestPurchase,
|
||||
PromoGroup,
|
||||
@@ -36,6 +38,7 @@ from app.database.models import (
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
UserPromoGroup,
|
||||
UserStatus,
|
||||
)
|
||||
from app.utils.timezone import panel_datetime_to_utc
|
||||
@@ -315,11 +318,10 @@ async def _sync_subscription_to_panel(
|
||||
if hwid_limit is not None:
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
# Внешний сквад: синхронизируем из тарифа или сбрасываем
|
||||
# Внешний сквад: синхронизируем из тарифа (если задан)
|
||||
# Не отправляем null — RemnaWave API не принимает null для externalSquadUuid (A039)
|
||||
if ext_squad_uuid is not None:
|
||||
update_kwargs['external_squad_uuid'] = ext_squad_uuid
|
||||
else:
|
||||
update_kwargs['external_squad_uuid'] = None
|
||||
|
||||
try:
|
||||
updated_panel_user = await api.update_user(**update_kwargs)
|
||||
@@ -1119,9 +1121,19 @@ async def update_user_subscription(
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Preserve extra purchased devices above the old tariff's base limit
|
||||
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
|
||||
|
||||
subscription.tariff_id = request.tariff_id
|
||||
subscription.traffic_limit_gb = tariff.traffic_limit_gb
|
||||
subscription.device_limit = tariff.device_limit
|
||||
subscription.device_limit = calc_device_limit_on_tariff_switch(
|
||||
current_device_limit=subscription.device_limit,
|
||||
old_tariff_device_limit=old_tariff.device_limit if old_tariff else None,
|
||||
new_tariff_device_limit=tariff.device_limit,
|
||||
max_device_limit=tariff.max_device_limit,
|
||||
)
|
||||
# Set squads from tariff
|
||||
if tariff.allowed_squads:
|
||||
subscription.connected_squads = tariff.allowed_squads
|
||||
@@ -1140,8 +1152,6 @@ async def update_user_subscription(
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.traffic_reset_at = None
|
||||
|
||||
from app.config import settings
|
||||
|
||||
if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH:
|
||||
subscription.traffic_used_gb = 0.0
|
||||
|
||||
@@ -1268,7 +1278,7 @@ async def update_user_subscription(
|
||||
|
||||
await add_subscription_traffic(db, subscription, request.traffic_gb)
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
await reactivate_subscription(db, subscription)
|
||||
|
||||
await db.refresh(subscription)
|
||||
@@ -1276,6 +1286,13 @@ async def update_user_subscription(
|
||||
# Sync to Remnawave panel
|
||||
await _sync_subscription_to_panel(db, user, subscription)
|
||||
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if getattr(user, 'remnawave_uuid', None) and subscription.status == 'active':
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
|
||||
|
||||
logger.info('Admin added traffic for user', admin_id=admin.id, traffic_gb=request.traffic_gb, user_id=user_id)
|
||||
|
||||
return UpdateSubscriptionResponse(
|
||||
@@ -1629,8 +1646,22 @@ async def update_user_promo_group(
|
||||
)
|
||||
promo_group_name = promo_group.name
|
||||
|
||||
user.promo_group_id = new_promo_group_id
|
||||
user.updated_at = datetime.now(UTC)
|
||||
# Update M2M table (authoritative source) — not just the legacy FK column.
|
||||
# Without this, sync_user_primary_promo_group overwrites the admin change
|
||||
# on the next transaction.
|
||||
await db.execute(sa_delete(UserPromoGroup).where(UserPromoGroup.user_id == user_id))
|
||||
|
||||
if new_promo_group_id is not None:
|
||||
db.add(
|
||||
UserPromoGroup(
|
||||
user_id=user_id,
|
||||
promo_group_id=new_promo_group_id,
|
||||
assigned_by='admin',
|
||||
)
|
||||
)
|
||||
|
||||
await db.flush()
|
||||
await sync_user_primary_promo_group(db, user_id)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
@@ -2484,8 +2515,25 @@ async def sync_user_from_panel(
|
||||
if panel_user.expire_at:
|
||||
panel_expire_utc = panel_datetime_to_utc(panel_user.expire_at)
|
||||
|
||||
sub_end_utc = sub.end_date if sub.end_date and sub.end_date.tzinfo else sub.end_date
|
||||
sub_end_utc = sub.end_date
|
||||
if sub_end_utc is not None and sub_end_utc.tzinfo is None:
|
||||
sub_end_utc = sub_end_utc.replace(tzinfo=UTC)
|
||||
if sub_end_utc != panel_expire_utc:
|
||||
# Предупреждаем если локальная дата новее панельной
|
||||
# (например, автопокупка уже продлила подписку)
|
||||
if sub_end_utc and panel_expire_utc and sub_end_utc > panel_expire_utc:
|
||||
logger.warning(
|
||||
'Sync: локальная end_date новее панельной, перезаписываем. '
|
||||
'Возможно автопокупка уже продлила подписку.',
|
||||
user_id=user_id,
|
||||
local_end_date=sub_end_utc.isoformat(),
|
||||
panel_expire_at=panel_expire_utc.isoformat(),
|
||||
)
|
||||
errors.append(
|
||||
f'Warning: local end_date ({sub_end_utc.isoformat()}) is newer than '
|
||||
f'panel expire_at ({panel_expire_utc.isoformat()}). '
|
||||
f'Panel value applied — check if auto-purchase extended subscription.'
|
||||
)
|
||||
changes['end_date'] = {
|
||||
'old': sub.end_date.isoformat() if sub.end_date else None,
|
||||
'new': panel_expire_utc.isoformat(),
|
||||
@@ -2728,11 +2776,10 @@ async def sync_user_to_panel(
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
changes['device_limit'] = hwid_limit
|
||||
|
||||
# Внешний сквад: синхронизируем из тарифа или сбрасываем
|
||||
# Внешний сквад: синхронизируем из тарифа (если задан)
|
||||
# Не отправляем null — RemnaWave API не принимает null для externalSquadUuid (A039)
|
||||
if ext_squad_uuid is not None:
|
||||
update_kwargs['external_squad_uuid'] = ext_squad_uuid
|
||||
else:
|
||||
update_kwargs['external_squad_uuid'] = None
|
||||
|
||||
try:
|
||||
await api.update_user(**update_kwargs)
|
||||
|
||||
@@ -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,
|
||||
|
||||
+155
-29
@@ -6,7 +6,7 @@ from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -28,10 +28,16 @@ from app.database.crud.user import (
|
||||
set_email_change_pending,
|
||||
verify_and_apply_email_change,
|
||||
)
|
||||
from app.database.models import CabinetRefreshToken, User
|
||||
from app.database.models import CabinetRefreshToken, User, UserStatus
|
||||
from app.services.campaign_service import AdvertisingCampaignService
|
||||
from app.services.disposable_email_service import disposable_email_service
|
||||
from app.services.referral_service import process_referral_registration
|
||||
from app.services.web_auth_service import (
|
||||
WEB_AUTH_TOKEN_TTL,
|
||||
consume_web_auth_token,
|
||||
create_web_auth_token,
|
||||
poll_web_auth_token,
|
||||
)
|
||||
from app.utils.cache import RateLimitCache, TokenReplayCache
|
||||
from app.utils.timezone import panel_datetime_to_utc
|
||||
|
||||
@@ -61,6 +67,8 @@ from ..schemas.auth import (
|
||||
AuthResponse,
|
||||
AutoLoginRequest,
|
||||
CampaignBonusInfo,
|
||||
DeepLinkPollRequest,
|
||||
DeepLinkTokenResponse,
|
||||
EmailChangeRequest,
|
||||
EmailChangeResponse,
|
||||
EmailChangeVerifyRequest,
|
||||
@@ -188,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,
|
||||
@@ -247,12 +253,11 @@ 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)
|
||||
from app.bot_factory import create_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)
|
||||
@@ -461,7 +466,7 @@ async def auth_telegram(
|
||||
if updated:
|
||||
logger.info('User profile updated from initData', user_id=user.id)
|
||||
|
||||
if user.status != 'active':
|
||||
if user.status != UserStatus.ACTIVE.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='User account is not active',
|
||||
@@ -544,7 +549,7 @@ async def auth_telegram_widget(
|
||||
)
|
||||
logger.info('User created successfully: id=, telegram_id', user_id=user.id, telegram_id=user.telegram_id)
|
||||
|
||||
if user.status != 'active':
|
||||
if user.status != UserStatus.ACTIVE.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='User account is not active',
|
||||
@@ -673,7 +678,7 @@ async def auth_telegram_oidc(
|
||||
)
|
||||
logger.info('User created successfully', user_id=user.id, telegram_id=user.telegram_id)
|
||||
|
||||
if user.status != 'active':
|
||||
if user.status != UserStatus.ACTIVE.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='User account is not active',
|
||||
@@ -721,8 +726,9 @@ async def register_email(
|
||||
detail='Disposable email addresses are not allowed',
|
||||
)
|
||||
|
||||
# Check if email already exists
|
||||
existing_user = await db.execute(select(User).where(User.email == request.email))
|
||||
# Check if email already exists (case-insensitive)
|
||||
email_lower = (request.email or '').strip().lower()
|
||||
existing_user = await db.execute(select(User).where(func.lower(User.email) == email_lower))
|
||||
if existing_user.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -837,8 +843,9 @@ async def register_email_standalone(
|
||||
detail='Disposable email addresses are not allowed',
|
||||
)
|
||||
|
||||
# Проверить что email не занят
|
||||
existing = await db.execute(select(User).where(User.email == request.email))
|
||||
# Проверить что email не занят (без учёта регистра)
|
||||
email_lower = (request.email or '').strip().lower()
|
||||
existing = await db.execute(select(User).where(func.lower(User.email) == email_lower))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -927,12 +934,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
|
||||
)
|
||||
@@ -1096,8 +1101,9 @@ async def login_email(
|
||||
# Check if this is a test email login
|
||||
is_test_email = settings.is_test_email(request.email)
|
||||
|
||||
# Find user by email
|
||||
result = await db.execute(select(User).where(User.email == request.email))
|
||||
# Find user by email (case-insensitive)
|
||||
email_lower = (request.email or '').strip().lower()
|
||||
result = await db.execute(select(User).where(func.lower(User.email) == email_lower))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
@@ -1140,7 +1146,7 @@ async def login_email(
|
||||
detail='Please verify your email first',
|
||||
)
|
||||
|
||||
if user.status != 'active':
|
||||
if user.status != UserStatus.ACTIVE.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='User account is not active',
|
||||
@@ -1289,7 +1295,7 @@ async def auto_login(
|
||||
detail='User not found',
|
||||
)
|
||||
|
||||
if user.status != 'active':
|
||||
if user.status != UserStatus.ACTIVE.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Account is deactivated',
|
||||
@@ -1317,7 +1323,8 @@ async def forgot_password(
|
||||
detail='Too many requests',
|
||||
headers={'Retry-After': '60'},
|
||||
)
|
||||
result = await db.execute(select(User).where(User.email == request.email))
|
||||
email_lower = (request.email or '').strip().lower()
|
||||
result = await db.execute(select(User).where(func.lower(User.email) == email_lower))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
# Always return success to prevent email enumeration
|
||||
@@ -1670,3 +1677,122 @@ async def get_email_change_status(
|
||||
'new_email': user.email_change_new,
|
||||
'expires_at': user.email_change_expires.isoformat() if user.email_change_expires else None,
|
||||
}
|
||||
|
||||
|
||||
# --- Deep link auth (fallback when oauth.telegram.org is blocked) ---
|
||||
|
||||
|
||||
@router.post('/deeplink/request', response_model=DeepLinkTokenResponse)
|
||||
async def request_deep_link_token(
|
||||
raw_request: Request,
|
||||
):
|
||||
"""Generate a one-time deep link auth token.
|
||||
|
||||
Frontend shows t.me/{bot}?start=webauth_{token} to the user.
|
||||
No auth required (user is not logged in yet).
|
||||
"""
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'deeplink_request', limit=10, window=60, fail_closed=True):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail='Too many requests',
|
||||
headers={'Retry-After': '60'},
|
||||
)
|
||||
|
||||
try:
|
||||
token = await create_web_auth_token()
|
||||
except RuntimeError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail='Service temporarily unavailable',
|
||||
)
|
||||
|
||||
bot_username = settings.get_bot_username()
|
||||
if not bot_username:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail='Bot not configured',
|
||||
)
|
||||
|
||||
return DeepLinkTokenResponse(
|
||||
token=token,
|
||||
bot_username=bot_username,
|
||||
expires_in=WEB_AUTH_TOKEN_TTL,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/deeplink/poll', response_model=AuthResponse)
|
||||
async def poll_deep_link_token(
|
||||
request: DeepLinkPollRequest,
|
||||
raw_request: Request,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Poll for deep link auth completion.
|
||||
|
||||
Returns 202 if still pending, AuthResponse if completed, 410 if expired.
|
||||
"""
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'deeplink_poll', limit=60, window=60, fail_closed=True):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail='Too many requests',
|
||||
headers={'Retry-After': '60'},
|
||||
)
|
||||
|
||||
data = await poll_web_auth_token(request.token)
|
||||
|
||||
if data is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_410_GONE,
|
||||
detail='Token expired or not found',
|
||||
)
|
||||
|
||||
if data.get('status') == 'pending':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
detail='Waiting for confirmation',
|
||||
)
|
||||
|
||||
if data.get('status') != 'linked':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_410_GONE,
|
||||
detail='Invalid token state',
|
||||
)
|
||||
|
||||
# Token is linked - consume it atomically
|
||||
consumed = await consume_web_auth_token(request.token)
|
||||
if not consumed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_410_GONE,
|
||||
detail='Token already consumed',
|
||||
)
|
||||
|
||||
user_id = consumed.get('user_id')
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Invalid token data',
|
||||
)
|
||||
|
||||
user = await get_user_by_id(db, int(user_id))
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='User not found',
|
||||
)
|
||||
|
||||
if user.status != UserStatus.ACTIVE.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Account is deactivated',
|
||||
)
|
||||
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, response.refresh_token, device_info='deep_link')
|
||||
|
||||
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',
|
||||
)
|
||||
|
||||
|
||||
@@ -701,6 +685,11 @@ async def create_topup(
|
||||
detail='KassaAI payment method is unavailable',
|
||||
)
|
||||
|
||||
# Use payment_option to select sbp or card
|
||||
KASSA_AI_OPTION_MAP = {'sbp': 44, 'card': 36}
|
||||
option = (request.payment_option or '').strip().lower()
|
||||
ps_id = KASSA_AI_OPTION_MAP.get(option) # None = use env default
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_kassa_ai_payment(
|
||||
db=db,
|
||||
@@ -709,6 +698,7 @@ async def create_topup(
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_system_id=ps_id,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
@@ -720,6 +710,33 @@ async def create_topup(
|
||||
detail='Failed to create KassaAI payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'riopay':
|
||||
if not settings.is_riopay_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='RioPay payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_riopay_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
success_url=cabinet_success_url,
|
||||
fail_url=cabinet_failed_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('riopay_order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create RioPay payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'tribute':
|
||||
if not settings.TRIBUTE_ENABLED or not settings.TRIBUTE_DONATE_LINK:
|
||||
raise HTTPException(
|
||||
@@ -871,6 +888,17 @@ def _get_status_info(record: PendingPayment) -> tuple[str, str]:
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.RIOPAY:
|
||||
mapping = {
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'failed': ('❌', 'Ошибка'),
|
||||
'canceled': ('❌', 'Отменено'),
|
||||
'expired': ('⌛', 'Истёк'),
|
||||
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
return '❓', 'Неизвестно'
|
||||
|
||||
|
||||
@@ -901,6 +929,8 @@ def _is_checkable(record: PendingPayment) -> bool:
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
if record.method == PaymentMethod.KASSA_AI:
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
if record.method == PaymentMethod.RIOPAY:
|
||||
return status in {'pending'}
|
||||
return False
|
||||
|
||||
|
||||
@@ -924,7 +954,12 @@ def _get_payment_url(record: PendingPayment) -> str | None:
|
||||
)
|
||||
elif record.method == PaymentMethod.PLATEGA:
|
||||
payment_url = getattr(payment, 'redirect_url', None) or payment_url
|
||||
elif record.method in (PaymentMethod.CLOUDPAYMENTS, PaymentMethod.FREEKASSA, PaymentMethod.KASSA_AI):
|
||||
elif record.method in (
|
||||
PaymentMethod.CLOUDPAYMENTS,
|
||||
PaymentMethod.FREEKASSA,
|
||||
PaymentMethod.KASSA_AI,
|
||||
PaymentMethod.RIOPAY,
|
||||
):
|
||||
payment_url = getattr(payment, 'payment_url', None) or payment_url
|
||||
|
||||
return payment_url
|
||||
@@ -1013,6 +1048,7 @@ async def get_latest_payment_by_method(
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PlategaPayment,
|
||||
RioPayPayment,
|
||||
WataPayment,
|
||||
YooKassaPayment,
|
||||
)
|
||||
@@ -1028,6 +1064,7 @@ async def get_latest_payment_by_method(
|
||||
PaymentMethod.CLOUDPAYMENTS: CloudPaymentsPayment,
|
||||
PaymentMethod.FREEKASSA: FreekassaPayment,
|
||||
PaymentMethod.KASSA_AI: KassaAiPayment,
|
||||
PaymentMethod.RIOPAY: RioPayPayment,
|
||||
}
|
||||
|
||||
model = model_map.get(payment_method)
|
||||
@@ -1149,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)
|
||||
|
||||
@@ -244,6 +244,7 @@ class EmailAuthEnabledResponse(BaseModel):
|
||||
"""Email auth enabled setting."""
|
||||
|
||||
enabled: bool = True
|
||||
verification_enabled: bool = True
|
||||
|
||||
|
||||
class EmailAuthEnabledUpdate(BaseModel):
|
||||
@@ -838,10 +839,16 @@ async def get_email_auth_enabled(
|
||||
|
||||
if email_auth_value is not None:
|
||||
enabled = email_auth_value.lower() == 'true'
|
||||
return EmailAuthEnabledResponse(enabled=enabled)
|
||||
return EmailAuthEnabledResponse(
|
||||
enabled=enabled,
|
||||
verification_enabled=settings.is_cabinet_email_verification_enabled(),
|
||||
)
|
||||
|
||||
# Default: check config setting
|
||||
return EmailAuthEnabledResponse(enabled=settings.is_cabinet_email_auth_enabled())
|
||||
return EmailAuthEnabledResponse(
|
||||
enabled=settings.is_cabinet_email_auth_enabled(),
|
||||
verification_enabled=settings.is_cabinet_email_verification_enabled(),
|
||||
)
|
||||
|
||||
|
||||
@router.patch('/email-auth', response_model=EmailAuthEnabledResponse)
|
||||
@@ -855,7 +862,10 @@ async def update_email_auth_enabled(
|
||||
|
||||
logger.info('Admin set email auth enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
|
||||
|
||||
return EmailAuthEnabledResponse(enabled=payload.enabled)
|
||||
return EmailAuthEnabledResponse(
|
||||
enabled=payload.enabled,
|
||||
verification_enabled=settings.is_cabinet_email_verification_enabled(),
|
||||
)
|
||||
|
||||
|
||||
# ============ Telegram Widget Config Routes ============
|
||||
|
||||
@@ -86,6 +86,7 @@ def _user_allowed(subscription) -> bool:
|
||||
return subscription.status in {
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +122,9 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_va
|
||||
if not user:
|
||||
return 'Error: user not found'
|
||||
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
user.balance_kopeks += int(round(amount * 100))
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
+49
-50
@@ -22,7 +22,6 @@ from app.database.models import (
|
||||
Tariff,
|
||||
TransactionType,
|
||||
User,
|
||||
UserPromoGroup,
|
||||
)
|
||||
from app.services.guest_purchase_service import (
|
||||
GuestPurchaseError,
|
||||
@@ -82,9 +81,11 @@ async def get_gift_config(
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
)
|
||||
|
||||
# Load active tariffs
|
||||
# Load active tariffs visible in gift section
|
||||
result = await db.execute(
|
||||
select(Tariff).where(Tariff.is_active.is_(True)).order_by(Tariff.display_order, Tariff.id)
|
||||
select(Tariff)
|
||||
.where(Tariff.is_active.is_(True), Tariff.show_in_gift.is_(True))
|
||||
.order_by(Tariff.display_order, Tariff.id)
|
||||
)
|
||||
tariffs_db = result.scalars().all()
|
||||
|
||||
@@ -110,15 +111,17 @@ async def get_gift_config(
|
||||
price = base_price
|
||||
|
||||
# Apply promo group discount
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
promo_group_discount = 0
|
||||
if promo_group:
|
||||
promo_group_discount = promo_group.get_discount_percent('period', days)
|
||||
if promo_group_discount > 0:
|
||||
price = int(price * (100 - promo_group_discount) / 100)
|
||||
price = PricingEngine.apply_discount(price, promo_group_discount)
|
||||
|
||||
# Apply active promo offer discount (stacks on top)
|
||||
if promo_offer_discount_percent > 0:
|
||||
price = price - price * promo_offer_discount_percent // 100
|
||||
price = PricingEngine.apply_discount(price, promo_offer_discount_percent)
|
||||
|
||||
# Ensure minimum price of 1 kopek after all discounts
|
||||
price = max(1, price)
|
||||
@@ -241,49 +244,34 @@ async def create_gift_purchase(
|
||||
|
||||
# Find tariff and validate period
|
||||
tariff = await get_tariff_by_id(db, body.tariff_id)
|
||||
if tariff is None or not tariff.is_active:
|
||||
if tariff is None or not tariff.is_active or not tariff.show_in_gift:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tariff not found or inactive',
|
||||
)
|
||||
|
||||
price_kopeks = tariff.get_price_for_period(body.period_days)
|
||||
if price_kopeks is None:
|
||||
# Validate that period has a configured price before locking
|
||||
if tariff.get_price_for_period(body.period_days) is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Price is not configured for this period',
|
||||
)
|
||||
|
||||
# Lock user row to prevent concurrent promo offer double-spend
|
||||
locked_result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.id == user.id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
# Lock user BEFORE price computation to prevent TOCTOU on promo offer
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
pricing_result = await pricing_engine.calculate_tariff_purchase_price(
|
||||
tariff,
|
||||
body.period_days,
|
||||
device_limit=tariff.device_limit,
|
||||
user=user,
|
||||
)
|
||||
user = locked_result.scalar_one()
|
||||
|
||||
# Apply promo group discount
|
||||
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
|
||||
if promo_group is None:
|
||||
promo_group = getattr(user, 'promo_group', None)
|
||||
|
||||
if promo_group:
|
||||
discount_percent = promo_group.get_discount_percent('period', body.period_days)
|
||||
if discount_percent > 0:
|
||||
price_kopeks = int(price_kopeks * (100 - discount_percent) / 100)
|
||||
|
||||
# Apply active promo offer discount (stacks)
|
||||
promo_offer_discount_percent = get_user_active_promo_discount_percent(user)
|
||||
if promo_offer_discount_percent > 0:
|
||||
price_kopeks = price_kopeks - price_kopeks * promo_offer_discount_percent // 100
|
||||
|
||||
# Ensure minimum price of 1 kopek after all discounts
|
||||
price_kopeks = max(1, price_kopeks)
|
||||
price_kopeks = max(1, pricing_result.final_total)
|
||||
consume_promo = pricing_result.promo_offer_discount > 0
|
||||
|
||||
# Determine buyer contact info
|
||||
if user.email:
|
||||
@@ -318,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:
|
||||
@@ -380,15 +368,26 @@ async def create_gift_purchase(
|
||||
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
payment_service = PaymentService()
|
||||
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,
|
||||
)
|
||||
# Stars payments need a Bot instance to create invoice links
|
||||
bot = None
|
||||
if body.payment_method == 'telegram_stars':
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
bot = create_bot()
|
||||
|
||||
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()
|
||||
@@ -411,7 +410,7 @@ async def create_gift_purchase(
|
||||
)
|
||||
|
||||
# Consume promo offer discount before committing gateway purchase
|
||||
if promo_offer_discount_percent > 0 and getattr(user, 'promo_offer_discount_percent', 0):
|
||||
if consume_promo and getattr(user, 'promo_offer_discount_percent', 0):
|
||||
user.promo_offer_discount_percent = 0
|
||||
user.promo_offer_discount_source = None
|
||||
user.promo_offer_discount_expires_at = None
|
||||
@@ -476,7 +475,7 @@ async def create_gift_purchase(
|
||||
price_kopeks,
|
||||
description=f'Gift: {tariff.name} ({body.period_days}d)',
|
||||
create_transaction=False,
|
||||
consume_promo_offer=promo_offer_discount_percent > 0,
|
||||
consume_promo_offer=consume_promo,
|
||||
)
|
||||
if not balance_ok:
|
||||
await db.rollback()
|
||||
|
||||
@@ -91,7 +91,7 @@ class SupportConfigResponse(BaseModel):
|
||||
"""Support/tickets configuration for miniapp."""
|
||||
|
||||
tickets_enabled: bool
|
||||
support_type: str # "tickets", "profile", "url"
|
||||
support_type: str # "tickets", "profile", "url", "both"
|
||||
support_url: str | None = None
|
||||
support_username: str | None = None
|
||||
|
||||
@@ -299,7 +299,7 @@ async def get_support_config():
|
||||
support_type = 'profile'
|
||||
else: # both
|
||||
tickets_enabled = True
|
||||
support_type = 'tickets'
|
||||
support_type = 'both'
|
||||
|
||||
return SupportConfigResponse(
|
||||
tickets_enabled=tickets_enabled,
|
||||
|
||||
@@ -342,7 +342,9 @@ async def _load_landing_tariffs(
|
||||
effective_discount = tariff_override if tariff_override is not None else discount.percent
|
||||
original_price_kopeks = price
|
||||
original_price_label = settings.format_price(price)
|
||||
price = max(1, price - (price * effective_discount // 100))
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
price = max(1, PricingEngine.apply_discount(price, effective_discount))
|
||||
|
||||
periods.append(
|
||||
LandingTariffPeriod(
|
||||
@@ -548,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',
|
||||
@@ -607,6 +609,13 @@ async def create_landing_purchase(
|
||||
except GuestPurchaseError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
|
||||
|
||||
# Gift purchases require the tariff to be visible in the gift section
|
||||
if body.is_gift and not tariff.show_in_gift:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This tariff is not available for gift purchases',
|
||||
)
|
||||
|
||||
# Validate amount against per-method min/max limits (before creating purchase record)
|
||||
min_amount = method_config.get('min_amount_kopeks')
|
||||
max_amount = method_config.get('max_amount_kopeks')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -91,13 +91,14 @@ async def get_referral_info(
|
||||
referral_entitlement = max(0, total_earnings - withdrawn - pending)
|
||||
available_balance = min(user.balance_kopeks, referral_entitlement)
|
||||
|
||||
# Build referral link
|
||||
bot_username = settings.get_bot_username() or 'bot'
|
||||
referral_link = f'https://t.me/{bot_username}?start={user.referral_code}'
|
||||
# Build referral links
|
||||
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 '',
|
||||
referral_link=referral_link,
|
||||
bot_referral_link=bot_referral_link,
|
||||
total_referrals=total_referrals,
|
||||
active_referrals=active_referrals,
|
||||
total_earnings_kopeks=total_earnings,
|
||||
|
||||
+370
-590
File diff suppressed because it is too large
Load Diff
+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(
|
||||
|
||||
@@ -187,3 +187,17 @@ class EmailChangeResponse(BaseModel):
|
||||
message: str = Field(..., description='Success message')
|
||||
new_email: str = Field(..., description='New email address pending verification')
|
||||
expires_in_minutes: int = Field(..., description='Code expiration time in minutes')
|
||||
|
||||
|
||||
class DeepLinkTokenResponse(BaseModel):
|
||||
"""Response with deep link auth token."""
|
||||
|
||||
token: str = Field(..., description='One-time auth token')
|
||||
bot_username: str = Field(..., description='Bot username for deep link')
|
||||
expires_in: int = Field(..., description='Token TTL in seconds')
|
||||
|
||||
|
||||
class DeepLinkPollRequest(BaseModel):
|
||||
"""Request to poll deep link auth status."""
|
||||
|
||||
token: str = Field(..., min_length=16, max_length=128, description='Deep link auth token')
|
||||
|
||||
@@ -10,6 +10,7 @@ class ReferralInfoResponse(BaseModel):
|
||||
|
||||
referral_code: str
|
||||
referral_link: str
|
||||
bot_referral_link: str = ''
|
||||
total_referrals: int
|
||||
active_referrals: int
|
||||
total_earnings_kopeks: int
|
||||
|
||||
@@ -48,6 +48,7 @@ class SubscriptionData(BaseModel):
|
||||
hide_subscription_link: bool = False # Скрывать ли отображение ссылки (но кнопки работают)
|
||||
is_active: bool
|
||||
is_expired: bool
|
||||
is_limited: bool = False
|
||||
traffic_purchases: list[TrafficPurchaseInfo] = []
|
||||
# Daily tariff fields
|
||||
is_daily: bool = False
|
||||
|
||||
@@ -54,6 +54,7 @@ class TariffListItem(BaseModel):
|
||||
is_daily: bool = False
|
||||
daily_price_kopeks: int = 0
|
||||
allow_traffic_topup: bool = True
|
||||
show_in_gift: bool = True
|
||||
traffic_limit_gb: int
|
||||
device_limit: int
|
||||
tier_level: int
|
||||
@@ -114,6 +115,8 @@ class TariffDetailResponse(BaseModel):
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
# Внешний сквад RemnaWave
|
||||
external_squad_uuid: str | None = None
|
||||
# Показывать в подарках
|
||||
show_in_gift: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
@@ -170,6 +173,8 @@ class TariffCreateRequest(BaseModel):
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
# Внешний сквад RemnaWave
|
||||
external_squad_uuid: str | None = Field(None, pattern=UUID_PATTERN)
|
||||
# Показывать в подарках
|
||||
show_in_gift: bool = True
|
||||
|
||||
|
||||
class TariffUpdateRequest(BaseModel):
|
||||
@@ -209,6 +214,8 @@ class TariffUpdateRequest(BaseModel):
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
# Внешний сквад RemnaWave
|
||||
external_squad_uuid: str | None = Field(None, pattern=UUID_PATTERN)
|
||||
# Показывать в подарках
|
||||
show_in_gift: bool | None = None
|
||||
|
||||
|
||||
class TariffSortOrderRequest(BaseModel):
|
||||
@@ -243,3 +250,15 @@ class TariffStatsResponse(BaseModel):
|
||||
trial_subscriptions: int
|
||||
revenue_kopeks: int
|
||||
revenue_rubles: float
|
||||
|
||||
|
||||
class SyncSquadsResponse(BaseModel):
|
||||
"""Response after syncing squads for tariff subscriptions."""
|
||||
|
||||
tariff_id: int
|
||||
tariff_name: str
|
||||
total_subscriptions: int
|
||||
updated_count: int
|
||||
failed_count: int
|
||||
skipped_count: int
|
||||
errors: list[str] = Field(default_factory=list)
|
||||
|
||||
@@ -22,6 +22,7 @@ class SubscriptionStatusEnum(StrEnum):
|
||||
ACTIVE = 'active'
|
||||
EXPIRED = 'expired'
|
||||
DISABLED = 'disabled'
|
||||
LIMITED = 'limited'
|
||||
PENDING = 'pending'
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
""",
|
||||
|
||||
+152
-7
@@ -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
|
||||
|
||||
@@ -535,6 +546,11 @@ class Settings(BaseSettings):
|
||||
KASSA_AI_WEBHOOK_PORT: int = 8089
|
||||
# Способ оплаты: 44 = СБП (QR код), 36 = Карты РФ, 43 = SberPay
|
||||
KASSA_AI_PAYMENT_SYSTEM_ID: int = 44
|
||||
# Раздельные методы оплаты KassaAI (отображаются как отдельные кнопки)
|
||||
KASSA_AI_SBP_ENABLED: bool = False # СБП — payment_system_id=44
|
||||
KASSA_AI_SBP_DISPLAY_NAME: str = 'СБП (KassaAI)'
|
||||
KASSA_AI_CARD_ENABLED: bool = False # Карты РФ — payment_system_id=36
|
||||
KASSA_AI_CARD_DISPLAY_NAME: str = 'Карта (KassaAI)'
|
||||
|
||||
# RioPay (api.riopay.online) v2.0.1
|
||||
RIOPAY_ENABLED: bool = False
|
||||
@@ -548,6 +564,18 @@ class Settings(BaseSettings):
|
||||
RIOPAY_SUCCESS_URL: str | None = None
|
||||
RIOPAY_FAIL_URL: str | None = None
|
||||
|
||||
# SeverPay (severpay.io)
|
||||
SEVERPAY_ENABLED: bool = False
|
||||
SEVERPAY_MID: int | None = None # Merchant ID
|
||||
SEVERPAY_TOKEN: str | None = None # Secret token for HMAC-SHA256
|
||||
SEVERPAY_DISPLAY_NAME: str = 'SeverPay'
|
||||
SEVERPAY_CURRENCY: str = 'RUB'
|
||||
SEVERPAY_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
SEVERPAY_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
|
||||
SEVERPAY_WEBHOOK_PATH: str = '/severpay-webhook'
|
||||
SEVERPAY_RETURN_URL: str | None = None
|
||||
SEVERPAY_LIFETIME: int = 1440 # minutes, 30-4320
|
||||
|
||||
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
|
||||
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
|
||||
CABINET_BUTTON_STYLE: str = ''
|
||||
@@ -776,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:
|
||||
@@ -902,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.
|
||||
@@ -1237,10 +1297,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']
|
||||
|
||||
@@ -1415,6 +1471,46 @@ class Settings(BaseSettings):
|
||||
return value
|
||||
return None
|
||||
|
||||
_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.
|
||||
"""
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
@@ -1830,6 +1926,36 @@ class Settings(BaseSettings):
|
||||
def get_riopay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_riopay_display_name())
|
||||
|
||||
def is_severpay_enabled(self) -> bool:
|
||||
return self.SEVERPAY_ENABLED and self.SEVERPAY_MID is not None and self.SEVERPAY_TOKEN is not None
|
||||
|
||||
def get_severpay_display_name(self) -> str:
|
||||
name = (self.SEVERPAY_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'SeverPay'
|
||||
|
||||
def get_severpay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_severpay_display_name())
|
||||
|
||||
def is_kassa_ai_sbp_enabled(self) -> bool:
|
||||
return self.KASSA_AI_SBP_ENABLED and self.is_kassa_ai_enabled()
|
||||
|
||||
def get_kassa_ai_sbp_display_name(self) -> str:
|
||||
name = (self.KASSA_AI_SBP_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'СБП (KassaAI)'
|
||||
|
||||
def get_kassa_ai_sbp_display_name_html(self) -> str:
|
||||
return html.escape(self.get_kassa_ai_sbp_display_name())
|
||||
|
||||
def is_kassa_ai_card_enabled(self) -> bool:
|
||||
return self.KASSA_AI_CARD_ENABLED and self.is_kassa_ai_enabled()
|
||||
|
||||
def get_kassa_ai_card_display_name(self) -> str:
|
||||
name = (self.KASSA_AI_CARD_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Карта (KassaAI)'
|
||||
|
||||
def get_kassa_ai_card_display_name_html(self) -> str:
|
||||
return html.escape(self.get_kassa_ai_card_display_name())
|
||||
|
||||
def is_payment_verification_auto_check_enabled(self) -> bool:
|
||||
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
|
||||
|
||||
@@ -2680,6 +2806,25 @@ PERIOD_PRICES: dict[int, int] = {}
|
||||
refresh_period_prices()
|
||||
|
||||
|
||||
def _build_classic_period_prices() -> dict[int, int]:
|
||||
"""Build classic-mode period prices directly from PRICE_*_DAYS settings.
|
||||
|
||||
Unlike PERIOD_PRICES (which may use DB tariff prices in tariffs mode),
|
||||
this always reflects the env/settings values — the canonical prices for
|
||||
classic (non-tariff) subscriptions.
|
||||
"""
|
||||
return {days: getattr(settings, field_name, 0) for days, field_name in _PERIOD_PRICE_FIELDS.items()}
|
||||
|
||||
|
||||
CLASSIC_PERIOD_PRICES: dict[int, int] = _build_classic_period_prices()
|
||||
|
||||
|
||||
def refresh_classic_period_prices() -> None:
|
||||
"""Rebuild CLASSIC_PERIOD_PRICES from current settings."""
|
||||
CLASSIC_PERIOD_PRICES.clear()
|
||||
CLASSIC_PERIOD_PRICES.update(_build_classic_period_prices())
|
||||
|
||||
|
||||
def get_traffic_prices() -> dict[int, int]:
|
||||
packages = settings.get_traffic_packages()
|
||||
return {package['gb']: package['price'] for package in packages}
|
||||
|
||||
@@ -366,7 +366,8 @@ async def get_campaign_statistics(
|
||||
first_payment_amount_by_user[user_id] = amount_value
|
||||
first_payment_time_by_user[user_id] = created_at
|
||||
|
||||
total_revenue = deposits_total + subscription_payments_total
|
||||
# Revenue = only real deposits (exclude bonus-funded subscription spending)
|
||||
total_revenue = deposits_total
|
||||
|
||||
paid_user_ids = set(paid_users_from_transactions)
|
||||
paid_user_ids.update(conversion_user_ids)
|
||||
|
||||
@@ -67,13 +67,34 @@ 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()
|
||||
|
||||
|
||||
async def update_cryptobot_payment_status(
|
||||
db: AsyncSession, invoice_id: str, status: str, paid_at: datetime | None = None
|
||||
db: AsyncSession,
|
||||
invoice_id: str,
|
||||
status: str,
|
||||
paid_at: datetime | None = None,
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> CryptoBotPayment | None:
|
||||
payment = await get_cryptobot_payment_by_invoice_id(db, invoice_id)
|
||||
|
||||
@@ -86,8 +107,11 @@ async def update_cryptobot_payment_status(
|
||||
if status == 'paid' and paid_at:
|
||||
payment.paid_at = paid_at
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
if commit:
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
else:
|
||||
await db.flush()
|
||||
|
||||
logger.info('Обновлен статус CryptoBot платежа', invoice_id=invoice_id, status=status)
|
||||
return payment
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ async def log_promo_offer_action(
|
||||
except Exception:
|
||||
logger.exception('Failed to commit promo offer log entry')
|
||||
raise
|
||||
else:
|
||||
await db.flush()
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,7 +15,7 @@ logger = structlog.get_logger(__name__)
|
||||
async def create_riopay_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
user_id: int | None,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = 'RUB',
|
||||
@@ -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,
|
||||
|
||||
@@ -141,8 +141,12 @@ async def get_available_server_squads(
|
||||
.order_by(ServerSquad.sort_order, ServerSquad.display_name)
|
||||
)
|
||||
|
||||
if exclude_trial_only:
|
||||
query = query.where(ServerSquad.is_trial_eligible.is_(False))
|
||||
# НЕ фильтруем по is_trial_eligible — это поле означает "доступен для триала",
|
||||
# а НЕ "только для триала". Сквад может быть одновременно триальным и платным.
|
||||
# Фильтр exclude_trial_only убирал единственный доступный сквад, из-за чего
|
||||
# пользователи без триала получали пустой connected_squads при покупке.
|
||||
# Параметр exclude_trial_only сохранён для обратной совместимости, но не используется.
|
||||
# TODO: если нужна логика "только для триала", добавить отдельное поле is_trial_only
|
||||
|
||||
if promo_group_id is not None:
|
||||
query = query.join(ServerSquad.allowed_promo_groups).where(PromoGroup.id == promo_group_id)
|
||||
@@ -306,15 +310,24 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
|
||||
await create_server_squad(
|
||||
db=db,
|
||||
squad_uuid=squad_uuid,
|
||||
display_name=_generate_display_name(original_name),
|
||||
display_name=original_name,
|
||||
original_name=original_name,
|
||||
country_code=_extract_country_code(original_name),
|
||||
price_kopeks=1000,
|
||||
is_available=False,
|
||||
)
|
||||
created += 1
|
||||
|
||||
removed_servers = [server for uuid, server in existing_servers.items() if uuid not in remnawave_uuids]
|
||||
# Protect external squads referenced by tariffs from being removed during sync
|
||||
tariff_ext_uuids_result = await db.execute(
|
||||
select(Tariff.external_squad_uuid).where(Tariff.external_squad_uuid.isnot(None))
|
||||
)
|
||||
protected_uuids = {row[0] for row in tariff_ext_uuids_result.fetchall()}
|
||||
|
||||
removed_servers = [
|
||||
server
|
||||
for uuid, server in existing_servers.items()
|
||||
if uuid not in remnawave_uuids and uuid not in protected_uuids
|
||||
]
|
||||
|
||||
if removed_servers:
|
||||
removed_ids = [server.id for server in removed_servers]
|
||||
@@ -482,219 +495,6 @@ async def get_random_trial_squad_uuid(
|
||||
return None
|
||||
|
||||
|
||||
def _generate_display_name(original_name: str) -> str:
|
||||
"""Генерирует отображаемое название сервера на основе оригинального имени."""
|
||||
|
||||
country_names = {
|
||||
# Европа
|
||||
'NL': '🇳🇱 Нидерланды',
|
||||
'DE': '🇩🇪 Германия',
|
||||
'FR': '🇫🇷 Франция',
|
||||
'GB': '🇬🇧 Великобритания',
|
||||
'UK': '🇬🇧 Великобритания',
|
||||
'IT': '🇮🇹 Италия',
|
||||
'ES': '🇪🇸 Испания',
|
||||
'PT': '🇵🇹 Португалия',
|
||||
'PL': '🇵🇱 Польша',
|
||||
'CZ': '🇨🇿 Чехия',
|
||||
'AT': '🇦🇹 Австрия',
|
||||
'CH': '🇨🇭 Швейцария',
|
||||
'SE': '🇸🇪 Швеция',
|
||||
'NO': '🇳🇴 Норвегия',
|
||||
'FI': '🇫🇮 Финляндия',
|
||||
'DK': '🇩🇰 Дания',
|
||||
'BE': '🇧🇪 Бельгия',
|
||||
'IE': '🇮🇪 Ирландия',
|
||||
'RO': '🇷🇴 Румыния',
|
||||
'BG': '🇧🇬 Болгария',
|
||||
'HU': '🇭🇺 Венгрия',
|
||||
'GR': '🇬🇷 Греция',
|
||||
'LV': '🇱🇻 Латвия',
|
||||
'LT': '🇱🇹 Литва',
|
||||
'EE': '🇪🇪 Эстония',
|
||||
'SK': '🇸🇰 Словакия',
|
||||
'SI': '🇸🇮 Словения',
|
||||
'HR': '🇭🇷 Хорватия',
|
||||
'RS': '🇷🇸 Сербия',
|
||||
'UA': '🇺🇦 Украина',
|
||||
'MD': '🇲🇩 Молдова',
|
||||
'BY': '🇧🇾 Беларусь',
|
||||
'LU': '🇱🇺 Люксембург',
|
||||
# СНГ и Азия
|
||||
'RU': '🇷🇺 Россия',
|
||||
'KZ': '🇰🇿 Казахстан',
|
||||
'UZ': '🇺🇿 Узбекистан',
|
||||
'GE': '🇬🇪 Грузия',
|
||||
'AM': '🇦🇲 Армения',
|
||||
'AZ': '🇦🇿 Азербайджан',
|
||||
# Америка
|
||||
'US': '🇺🇸 США',
|
||||
'CA': '🇨🇦 Канада',
|
||||
'MX': '🇲🇽 Мексика',
|
||||
'BR': '🇧🇷 Бразилия',
|
||||
'AR': '🇦🇷 Аргентина',
|
||||
'CL': '🇨🇱 Чили',
|
||||
'CO': '🇨🇴 Колумбия',
|
||||
# Азия
|
||||
'JP': '🇯🇵 Япония',
|
||||
'KR': '🇰🇷 Южная Корея',
|
||||
'CN': '🇨🇳 Китай',
|
||||
'HK': '🇭🇰 Гонконг',
|
||||
'TW': '🇹🇼 Тайвань',
|
||||
'SG': '🇸🇬 Сингапур',
|
||||
'TH': '🇹🇭 Таиланд',
|
||||
'VN': '🇻🇳 Вьетнам',
|
||||
'MY': '🇲🇾 Малайзия',
|
||||
'ID': '🇮🇩 Индонезия',
|
||||
'PH': '🇵🇭 Филиппины',
|
||||
'IN': '🇮🇳 Индия',
|
||||
'PK': '🇵🇰 Пакистан',
|
||||
# Ближний Восток
|
||||
'IL': '🇮🇱 Израиль',
|
||||
'TR': '🇹🇷 Турция',
|
||||
'AE': '🇦🇪 ОАЭ',
|
||||
'SA': '🇸🇦 Саудовская Аравия',
|
||||
'QA': '🇶🇦 Катар',
|
||||
'BH': '🇧🇭 Бахрейн',
|
||||
'KW': '🇰🇼 Кувейт',
|
||||
# Океания
|
||||
'AU': '🇦🇺 Австралия',
|
||||
'NZ': '🇳🇿 Новая Зеландия',
|
||||
# Африка
|
||||
'ZA': '🇿🇦 ЮАР',
|
||||
'EG': '🇪🇬 Египет',
|
||||
'NG': '🇳🇬 Нигерия',
|
||||
'KE': '🇰🇪 Кения',
|
||||
}
|
||||
|
||||
name_upper = original_name.upper()
|
||||
|
||||
# Сначала ищем код как отдельный элемент (через - или _)
|
||||
for code, display_name in country_names.items():
|
||||
if f'-{code}' in name_upper or f'_{code}' in name_upper:
|
||||
return display_name
|
||||
if name_upper.startswith(code + '-') or name_upper.startswith(code + '_'):
|
||||
return display_name
|
||||
if name_upper.endswith('-' + code) or name_upper.endswith('_' + code):
|
||||
return display_name
|
||||
if name_upper == code:
|
||||
return display_name
|
||||
|
||||
# Потом ищем просто вхождение кода
|
||||
for code, display_name in country_names.items():
|
||||
if code in name_upper:
|
||||
return display_name
|
||||
|
||||
return f'🌍 {original_name}'
|
||||
|
||||
|
||||
def _extract_country_code(original_name: str) -> str | None:
|
||||
"""Извлекает код страны из оригинального названия."""
|
||||
|
||||
# Полный список кодов стран
|
||||
codes = [
|
||||
# Европа
|
||||
'NL',
|
||||
'DE',
|
||||
'FR',
|
||||
'GB',
|
||||
'UK',
|
||||
'IT',
|
||||
'ES',
|
||||
'PT',
|
||||
'PL',
|
||||
'CZ',
|
||||
'AT',
|
||||
'CH',
|
||||
'SE',
|
||||
'NO',
|
||||
'FI',
|
||||
'DK',
|
||||
'BE',
|
||||
'IE',
|
||||
'RO',
|
||||
'BG',
|
||||
'HU',
|
||||
'GR',
|
||||
'LV',
|
||||
'LT',
|
||||
'EE',
|
||||
'SK',
|
||||
'SI',
|
||||
'HR',
|
||||
'RS',
|
||||
'UA',
|
||||
'MD',
|
||||
'BY',
|
||||
'LU',
|
||||
# СНГ
|
||||
'RU',
|
||||
'KZ',
|
||||
'UZ',
|
||||
'GE',
|
||||
'AM',
|
||||
'AZ',
|
||||
# Америка
|
||||
'US',
|
||||
'CA',
|
||||
'MX',
|
||||
'BR',
|
||||
'AR',
|
||||
'CL',
|
||||
'CO',
|
||||
# Азия
|
||||
'JP',
|
||||
'KR',
|
||||
'CN',
|
||||
'HK',
|
||||
'TW',
|
||||
'SG',
|
||||
'TH',
|
||||
'VN',
|
||||
'MY',
|
||||
'ID',
|
||||
'PH',
|
||||
'IN',
|
||||
'PK',
|
||||
# Ближний Восток
|
||||
'IL',
|
||||
'TR',
|
||||
'AE',
|
||||
'SA',
|
||||
'QA',
|
||||
'BH',
|
||||
'KW',
|
||||
# Океания
|
||||
'AU',
|
||||
'NZ',
|
||||
# Африка
|
||||
'ZA',
|
||||
'EG',
|
||||
'NG',
|
||||
'KE',
|
||||
]
|
||||
|
||||
name_upper = original_name.upper()
|
||||
|
||||
# Сначала ищем код как отдельный элемент
|
||||
for code in codes:
|
||||
if f'-{code}' in name_upper or f'_{code}' in name_upper:
|
||||
return code
|
||||
if name_upper.startswith(code + '-') or name_upper.startswith(code + '_'):
|
||||
return code
|
||||
if name_upper.endswith('-' + code) or name_upper.endswith('_' + code):
|
||||
return code
|
||||
if name_upper == code:
|
||||
return code
|
||||
|
||||
# Потом просто ищем вхождение
|
||||
for code in codes:
|
||||
if code in name_upper:
|
||||
return code
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_server_statistics(db: AsyncSession) -> dict:
|
||||
total_result = await db.execute(select(func.count(ServerSquad.id)))
|
||||
total_servers = total_result.scalar()
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""CRUD операции для платежей SeverPay."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import SeverPayPayment
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_severpay_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = 'RUB',
|
||||
description: str | None = None,
|
||||
payment_url: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
severpay_id: str | None = None,
|
||||
severpay_uid: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: dict | None = None,
|
||||
) -> SeverPayPayment:
|
||||
"""Создает запись о платеже SeverPay."""
|
||||
payment = SeverPayPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method,
|
||||
severpay_id=severpay_id,
|
||||
severpay_uid=severpay_uid,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata_json,
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
)
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info('Создан платеж SeverPay', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_severpay_payment_by_order_id(db: AsyncSession, order_id: str) -> SeverPayPayment | None:
|
||||
"""Получает платеж по order_id (internal)."""
|
||||
result = await db.execute(select(SeverPayPayment).where(SeverPayPayment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_severpay_payment_by_severpay_id(db: AsyncSession, severpay_id: str) -> SeverPayPayment | None:
|
||||
"""Получает платеж по ID от SeverPay."""
|
||||
result = await db.execute(select(SeverPayPayment).where(SeverPayPayment.severpay_id == severpay_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_severpay_payment_by_id(db: AsyncSession, payment_id: int) -> SeverPayPayment | None:
|
||||
"""Получает платеж по ID."""
|
||||
result = await db.execute(select(SeverPayPayment).where(SeverPayPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
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()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_severpay_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: SeverPayPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool | None = None,
|
||||
severpay_id: str | None = None,
|
||||
severpay_uid: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
transaction_id: int | None = None,
|
||||
) -> SeverPayPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
payment.status = status
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if severpay_id is not None:
|
||||
payment.severpay_id = severpay_id
|
||||
if severpay_uid is not None:
|
||||
payment.severpay_uid = severpay_uid
|
||||
if payment_method is not None:
|
||||
payment.payment_method = payment_method
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if transaction_id is not None:
|
||||
payment.transaction_id = transaction_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
'Обновлен статус платежа SeverPay',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=payment.is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_severpay_payments(db: AsyncSession, user_id: int) -> list[SeverPayPayment]:
|
||||
"""Получает незавершенные платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(SeverPayPayment).where(
|
||||
SeverPayPayment.user_id == user_id,
|
||||
SeverPayPayment.status == 'pending',
|
||||
SeverPayPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_severpay_payments(
|
||||
db: AsyncSession,
|
||||
) -> list[SeverPayPayment]:
|
||||
"""Получает просроченные платежи в статусе pending."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(SeverPayPayment).where(
|
||||
SeverPayPayment.status == 'pending',
|
||||
SeverPayPayment.is_paid == False,
|
||||
SeverPayPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def link_severpay_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: SeverPayPayment,
|
||||
transaction_id: int,
|
||||
) -> SeverPayPayment:
|
||||
"""Связывает платеж с транзакцией."""
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
+109
-482
@@ -1,6 +1,5 @@
|
||||
from collections.abc import Iterable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, delete, func, select
|
||||
@@ -11,17 +10,14 @@ from sqlalchemy.orm.exc import StaleDataError
|
||||
from app.config import settings
|
||||
from app.database.crud.notification import clear_notifications
|
||||
from app.database.models import (
|
||||
PromoGroup,
|
||||
Subscription,
|
||||
SubscriptionServer,
|
||||
SubscriptionStatus,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
UserPromoGroup,
|
||||
UserStatus,
|
||||
)
|
||||
from app.utils.pricing_utils import calculate_months_from_days
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
|
||||
@@ -38,6 +34,26 @@ def is_recently_updated_by_webhook(subscription: Subscription) -> bool:
|
||||
return elapsed < _WEBHOOK_GUARD_SECONDS
|
||||
|
||||
|
||||
def calc_device_limit_on_tariff_switch(
|
||||
current_device_limit: int | None,
|
||||
old_tariff_device_limit: int | None,
|
||||
new_tariff_device_limit: int | None,
|
||||
max_device_limit: int | None = None,
|
||||
) -> int:
|
||||
"""Calculate device_limit when switching tariffs.
|
||||
|
||||
Resets to new tariff base device limit — previously purchased
|
||||
extra devices are NOT carried over. Capped at max_device_limit.
|
||||
"""
|
||||
new_base = new_tariff_device_limit if new_tariff_device_limit is not None else 1
|
||||
|
||||
effective_max = max_device_limit or (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
|
||||
if effective_max and new_base > effective_max:
|
||||
new_base = effective_max
|
||||
|
||||
return new_base
|
||||
|
||||
|
||||
def is_active_paid_subscription(subscription: Subscription | None) -> bool:
|
||||
"""Return True if subscription is active, paid (non-trial), and not expired."""
|
||||
if not subscription:
|
||||
@@ -197,6 +213,23 @@ async def create_paid_subscription(
|
||||
if device_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
# Fallback: если connected_squads пустой — берём первый доступный сквад
|
||||
final_squads = list(connected_squads or [])
|
||||
if not final_squads:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_available_server_squads
|
||||
|
||||
available = await get_available_server_squads(db)
|
||||
if available:
|
||||
final_squads = [available[0].squad_uuid]
|
||||
logger.warning(
|
||||
'⚠️ connected_squads пустой при создании подписки, используем fallback сквад',
|
||||
user_id=user_id,
|
||||
fallback_squad=final_squads[0],
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('❌ Не удалось получить fallback сквад', user_id=user_id, error=error)
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
@@ -205,7 +238,7 @@ async def create_paid_subscription(
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads or [],
|
||||
connected_squads=final_squads,
|
||||
autopay_enabled=settings.is_autopay_enabled_by_default(),
|
||||
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
|
||||
tariff_id=tariff_id,
|
||||
@@ -225,7 +258,7 @@ async def create_paid_subscription(
|
||||
status=subscription.status,
|
||||
)
|
||||
|
||||
squad_uuids = list(connected_squads or [])
|
||||
squad_uuids = list(final_squads)
|
||||
if update_server_counters and squad_uuids:
|
||||
try:
|
||||
from app.database.crud.server_squad import (
|
||||
@@ -275,7 +308,25 @@ async def replace_subscription(
|
||||
|
||||
current_time = datetime.now(UTC)
|
||||
old_squads = set(subscription.connected_squads or [])
|
||||
new_squads = set(connected_squads or [])
|
||||
|
||||
# Fallback: если connected_squads пустой — берём первый доступный сквад
|
||||
final_connected = list(connected_squads or [])
|
||||
if not final_connected:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_available_server_squads
|
||||
|
||||
available = await get_available_server_squads(db)
|
||||
if available:
|
||||
final_connected = [available[0].squad_uuid]
|
||||
logger.warning(
|
||||
'⚠️ connected_squads пустой при замене подписки, используем fallback сквад',
|
||||
subscription_id=subscription.id,
|
||||
fallback_squad=final_connected[0],
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('❌ Не удалось получить fallback сквад', subscription_id=subscription.id, error=error)
|
||||
|
||||
new_squads = set(final_connected)
|
||||
|
||||
new_autopay_enabled = subscription.autopay_enabled if autopay_enabled is None else autopay_enabled
|
||||
new_autopay_days_before = subscription.autopay_days_before if autopay_days_before is None else autopay_days_before
|
||||
@@ -357,6 +408,7 @@ async def extend_subscription(
|
||||
traffic_limit_gb: int | None = None,
|
||||
device_limit: int | None = None,
|
||||
connected_squads: list[str] | None = None,
|
||||
commit: bool = True,
|
||||
) -> Subscription:
|
||||
"""Продлевает подписку на указанное количество дней.
|
||||
|
||||
@@ -389,6 +441,7 @@ async def extend_subscription(
|
||||
was_expired = subscription.status in (
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
) or (subscription.end_date is not None and subscription.end_date <= current_time)
|
||||
|
||||
if is_tariff_change:
|
||||
@@ -445,6 +498,7 @@ async def extend_subscription(
|
||||
if days > 0 and subscription.status in (
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
):
|
||||
previous_status = subscription.status
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
@@ -522,9 +576,17 @@ async def extend_subscription(
|
||||
logger.info('📱 Обновлен лимит устройств: →', old_devices=old_devices, device_limit=device_limit)
|
||||
|
||||
if connected_squads is not None:
|
||||
old_squads = subscription.connected_squads
|
||||
subscription.connected_squads = connected_squads
|
||||
logger.info('🌍 Обновлены сквады: →', old_squads=old_squads, connected_squads=connected_squads)
|
||||
# Не перезаписываем существующие сквады пустым списком
|
||||
if connected_squads or not subscription.connected_squads:
|
||||
old_squads = subscription.connected_squads
|
||||
subscription.connected_squads = connected_squads
|
||||
logger.info('🌍 Обновлены сквады: →', old_squads=old_squads, connected_squads=connected_squads)
|
||||
else:
|
||||
logger.warning(
|
||||
'⚠️ Попытка перезаписать сквады пустым списком, сохраняем текущие',
|
||||
subscription_id=subscription.id,
|
||||
current_squads=subscription.connected_squads,
|
||||
)
|
||||
|
||||
# Обработка daily полей при смене тарифа
|
||||
if is_tariff_change and tariff_id is not None:
|
||||
@@ -567,9 +629,13 @@ async def extend_subscription(
|
||||
|
||||
subscription.updated_at = current_time
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription, ['tariff'])
|
||||
await clear_notifications(db, subscription.id)
|
||||
if commit:
|
||||
await db.commit()
|
||||
await db.refresh(subscription, ['tariff'])
|
||||
else:
|
||||
await db.flush()
|
||||
|
||||
await clear_notifications(db, subscription.id, commit=commit)
|
||||
|
||||
logger.info('✅ Подписка продлена до', end_date=subscription.end_date)
|
||||
logger.info('📊 Новые параметры: статус=, окончание', status=subscription.status, end_date=subscription.end_date)
|
||||
@@ -772,26 +838,39 @@ async def deactivate_subscription(db: AsyncSession, subscription: Subscription)
|
||||
|
||||
|
||||
async def reactivate_subscription(db: AsyncSession, subscription: Subscription) -> Subscription:
|
||||
"""Реактивация подписки (например, после повторной подписки на канал).
|
||||
"""Реактивация подписки (например, после повторной подписки на канал или докупки трафика).
|
||||
|
||||
Активирует только если подписка была DISABLED и ещё не истекла.
|
||||
Активирует если подписка была DISABLED или EXPIRED и ещё не истекла по времени.
|
||||
Не логирует если реактивация не требуется.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Тихо выходим если реактивация не нужна
|
||||
if subscription.status != SubscriptionStatus.DISABLED.value:
|
||||
# Тихо выходим если реактивация не нужна (уже активна или другой статус)
|
||||
reactivatable_statuses = {
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
}
|
||||
if subscription.status not in reactivatable_statuses:
|
||||
return subscription
|
||||
|
||||
if subscription.end_date and subscription.end_date <= now:
|
||||
if not subscription.end_date or subscription.end_date <= now:
|
||||
return subscription
|
||||
|
||||
old_status = subscription.status
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = now
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(
|
||||
'✅ Подписка реактивирована',
|
||||
subscription_id=subscription.id,
|
||||
user_id=subscription.user_id,
|
||||
old_status=old_status,
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
@@ -1154,212 +1233,6 @@ async def add_subscription_servers(
|
||||
return subscription
|
||||
|
||||
|
||||
async def get_server_monthly_price(db: AsyncSession, server_squad_id: int) -> int:
|
||||
from app.database.models import ServerSquad
|
||||
|
||||
result = await db.execute(select(ServerSquad.price_kopeks).where(ServerSquad.id == server_squad_id))
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def get_servers_monthly_prices(
|
||||
db: AsyncSession,
|
||||
server_squad_ids: list[int],
|
||||
*,
|
||||
user: Optional['User'] = None,
|
||||
) -> list[int]:
|
||||
"""Получает месячные цены серверов с проверкой доступности для промогруппы пользователя."""
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import ServerSquad
|
||||
|
||||
prices = []
|
||||
|
||||
# Загружаем промогруппы пользователя если нужно
|
||||
user_promo_group = None
|
||||
user_promo_group_id = None
|
||||
if user:
|
||||
try:
|
||||
# Пробуем загрузить промогруппы если ещё не загружены
|
||||
await db.refresh(user, ['user_promo_groups', 'promo_group'])
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
user_promo_group = user.get_primary_promo_group()
|
||||
user_promo_group_id = user_promo_group.id if user_promo_group else None
|
||||
except Exception as e:
|
||||
logger.warning('Не удалось получить промогруппу пользователя', error=e)
|
||||
|
||||
for server_id in server_squad_ids:
|
||||
# Загружаем сервер с промогруппами
|
||||
result = await db.execute(
|
||||
select(ServerSquad)
|
||||
.options(selectinload(ServerSquad.allowed_promo_groups))
|
||||
.where(ServerSquad.id == server_id)
|
||||
)
|
||||
server = result.scalar_one_or_none()
|
||||
|
||||
if not server:
|
||||
prices.append(0)
|
||||
continue
|
||||
|
||||
# Проверяем доступность сервера для промогруппы пользователя
|
||||
is_allowed = True
|
||||
if user_promo_group_id is not None and server.allowed_promo_groups:
|
||||
allowed_ids = {pg.id for pg in server.allowed_promo_groups}
|
||||
is_allowed = user_promo_group_id in allowed_ids
|
||||
|
||||
if server.is_available and is_allowed:
|
||||
prices.append(server.price_kopeks)
|
||||
else:
|
||||
# Сервер недоступен для промогруппы пользователя
|
||||
logger.warning(
|
||||
'⚠️ Сервер (id=) недоступен для промогруппы пользователя (promo_group_id=), allowed_promo_groups',
|
||||
display_name=server.display_name,
|
||||
server_id=server_id,
|
||||
user_promo_group_id=user_promo_group_id,
|
||||
value=[pg.id for pg in server.allowed_promo_groups] if server.allowed_promo_groups else [],
|
||||
)
|
||||
prices.append(server.price_kopeks) # Всё равно берём реальную цену
|
||||
|
||||
return prices
|
||||
|
||||
|
||||
def _get_discount_percent(
|
||||
user: User | None,
|
||||
promo_group: PromoGroup | None,
|
||||
category: str,
|
||||
*,
|
||||
period_days: int | None = None,
|
||||
) -> int:
|
||||
if user is not None:
|
||||
try:
|
||||
return user.get_promo_discount(category, period_days)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
if promo_group is not None:
|
||||
return promo_group.get_discount_percent(category, period_days)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
async def calculate_subscription_total_cost(
|
||||
db: AsyncSession,
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
server_squad_ids: list[int],
|
||||
devices: int,
|
||||
*,
|
||||
user: User | None = None,
|
||||
promo_group: PromoGroup | None = None,
|
||||
) -> tuple[int, dict]:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
base_price_original = PERIOD_PRICES.get(period_days, 0)
|
||||
period_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'period',
|
||||
period_days=period_days,
|
||||
)
|
||||
base_discount_total = base_price_original * period_discount_percent // 100
|
||||
base_price = base_price_original - base_discount_total
|
||||
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
|
||||
traffic_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'traffic',
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_price = discounted_traffic_per_month * months_in_period
|
||||
total_traffic_discount = traffic_discount_per_month * months_in_period
|
||||
|
||||
servers_prices = await get_servers_monthly_prices(db, server_squad_ids, user=user)
|
||||
servers_price_per_month = sum(servers_prices)
|
||||
servers_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'servers',
|
||||
period_days=period_days,
|
||||
)
|
||||
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
|
||||
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
|
||||
total_servers_price = discounted_servers_per_month * months_in_period
|
||||
total_servers_discount = servers_discount_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'devices',
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
total_devices_price = discounted_devices_per_month * months_in_period
|
||||
total_devices_discount = devices_discount_per_month * months_in_period
|
||||
|
||||
total_cost = base_price + total_traffic_price + total_servers_price + total_devices_price
|
||||
|
||||
details = {
|
||||
'base_price': base_price,
|
||||
'base_price_original': base_price_original,
|
||||
'base_discount_percent': period_discount_percent,
|
||||
'base_discount_total': base_discount_total,
|
||||
'traffic_price_per_month': traffic_price_per_month,
|
||||
'traffic_discount_percent': traffic_discount_percent,
|
||||
'traffic_discount_total': total_traffic_discount,
|
||||
'total_traffic_price': total_traffic_price,
|
||||
'servers_price_per_month': servers_price_per_month,
|
||||
'servers_discount_percent': servers_discount_percent,
|
||||
'servers_discount_total': total_servers_discount,
|
||||
'total_servers_price': total_servers_price,
|
||||
'devices_price_per_month': devices_price_per_month,
|
||||
'devices_discount_percent': devices_discount_percent,
|
||||
'devices_discount_total': total_devices_discount,
|
||||
'total_devices_price': total_devices_price,
|
||||
'months_in_period': months_in_period,
|
||||
'servers_individual_prices': [
|
||||
(price - (price * servers_discount_percent // 100)) * months_in_period for price in servers_prices
|
||||
],
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
'📊 Расчет стоимости подписки на дней ( мес)', period_days=period_days, months_in_period=months_in_period
|
||||
)
|
||||
logger.debug('Базовый период: ₽', base_price=base_price / 100)
|
||||
if total_traffic_price > 0:
|
||||
message = f' Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period} = {total_traffic_price / 100}₽'
|
||||
if total_traffic_discount > 0:
|
||||
message += f' (скидка {traffic_discount_percent}%: -{total_traffic_discount / 100}₽)'
|
||||
logger.debug(message)
|
||||
if total_servers_price > 0:
|
||||
message = (
|
||||
f' Серверы: {servers_price_per_month / 100}₽/мес × {months_in_period} = {total_servers_price / 100}₽'
|
||||
)
|
||||
if total_servers_discount > 0:
|
||||
message += f' (скидка {servers_discount_percent}%: -{total_servers_discount / 100}₽)'
|
||||
logger.debug(message)
|
||||
if total_devices_price > 0:
|
||||
message = (
|
||||
f' Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period} = {total_devices_price / 100}₽'
|
||||
)
|
||||
if total_devices_discount > 0:
|
||||
message += f' (скидка {devices_discount_percent}%: -{total_devices_discount / 100}₽)'
|
||||
logger.debug(message)
|
||||
logger.debug('ИТОГО: ₽', total_cost=total_cost / 100)
|
||||
|
||||
return total_cost, details
|
||||
|
||||
|
||||
async def get_subscription_server_ids(db: AsyncSession, subscription_id: int) -> list[int]:
|
||||
result = await db.execute(
|
||||
select(SubscriptionServer.server_squad_id).where(SubscriptionServer.subscription_id == subscription_id)
|
||||
@@ -1367,32 +1240,6 @@ async def get_subscription_server_ids(db: AsyncSession, subscription_id: int) ->
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
|
||||
async def get_subscription_servers(db: AsyncSession, subscription_id: int) -> list[dict]:
|
||||
from app.database.models import ServerSquad
|
||||
|
||||
result = await db.execute(
|
||||
select(SubscriptionServer, ServerSquad)
|
||||
.join(ServerSquad, SubscriptionServer.server_squad_id == ServerSquad.id)
|
||||
.where(SubscriptionServer.subscription_id == subscription_id)
|
||||
)
|
||||
|
||||
servers_info = []
|
||||
for sub_server, server_squad in result.fetchall():
|
||||
servers_info.append(
|
||||
{
|
||||
'server_id': server_squad.id,
|
||||
'squad_uuid': server_squad.squad_uuid,
|
||||
'display_name': server_squad.display_name,
|
||||
'country_code': server_squad.country_code,
|
||||
'paid_price_kopeks': sub_server.paid_price_kopeks,
|
||||
'connected_at': sub_server.connected_at,
|
||||
'is_available': server_squad.is_available,
|
||||
}
|
||||
)
|
||||
|
||||
return servers_info
|
||||
|
||||
|
||||
async def remove_subscription_servers(db: AsyncSession, subscription_id: int, server_squad_ids: list[int]) -> bool:
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
@@ -1416,232 +1263,6 @@ async def remove_subscription_servers(db: AsyncSession, subscription_id: int, se
|
||||
return False
|
||||
|
||||
|
||||
async def get_subscription_renewal_cost(
|
||||
db: AsyncSession,
|
||||
subscription_id: int,
|
||||
period_days: int,
|
||||
*,
|
||||
user: User | None = None,
|
||||
promo_group: PromoGroup | None = None,
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
selectinload(Subscription.user)
|
||||
.selectinload(User.user_promo_groups)
|
||||
.selectinload(UserPromoGroup.promo_group),
|
||||
)
|
||||
.where(Subscription.id == subscription_id)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
if not subscription:
|
||||
return base_price
|
||||
|
||||
if user is None:
|
||||
user = subscription.user
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
servers_info = await get_subscription_servers(db, subscription_id)
|
||||
servers_price_per_month = 0
|
||||
for server_info in servers_info:
|
||||
from app.database.models import ServerSquad
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad.price_kopeks).where(ServerSquad.id == server_info['server_id'])
|
||||
)
|
||||
current_server_price = result.scalar() or 0
|
||||
servers_price_per_month += current_server_price
|
||||
|
||||
servers_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'servers',
|
||||
period_days=period_days,
|
||||
)
|
||||
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
|
||||
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
|
||||
total_servers_cost = discounted_servers_per_month * months_in_period
|
||||
total_servers_discount = servers_discount_per_month * months_in_period
|
||||
|
||||
# В режиме fixed_with_topup при продлении используем фиксированный лимит
|
||||
purchased_traffic = subscription.purchased_traffic_gb or 0
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_price_per_month = settings.get_traffic_price(settings.get_fixed_traffic_limit())
|
||||
# Separate base traffic from purchased to avoid wrong tier lookup
|
||||
elif purchased_traffic > 0:
|
||||
base_traffic_gb = (subscription.traffic_limit_gb or 0) - purchased_traffic
|
||||
if base_traffic_gb <= 0:
|
||||
logger.warning(
|
||||
'Purchased traffic >= total limit, pricing purchased portion only',
|
||||
subscription_id=subscription.id,
|
||||
traffic_limit_gb=subscription.traffic_limit_gb,
|
||||
purchased_traffic_gb=purchased_traffic,
|
||||
)
|
||||
traffic_price_per_month = settings.get_traffic_price(purchased_traffic)
|
||||
else:
|
||||
traffic_price_per_month = settings.get_traffic_price(base_traffic_gb) + settings.get_traffic_price(
|
||||
purchased_traffic
|
||||
)
|
||||
else:
|
||||
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
traffic_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'traffic',
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_cost = discounted_traffic_per_month * months_in_period
|
||||
total_traffic_discount = traffic_discount_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'devices',
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
total_devices_cost = discounted_devices_per_month * months_in_period
|
||||
total_devices_discount = devices_discount_per_month * months_in_period
|
||||
|
||||
total_cost = base_price + total_servers_cost + total_traffic_cost + total_devices_cost
|
||||
|
||||
logger.info(
|
||||
'💰 Расчет продления подписки на дней ( мес)',
|
||||
subscription_id=subscription_id,
|
||||
period_days=period_days,
|
||||
months_in_period=months_in_period,
|
||||
)
|
||||
logger.info('📅 Период: ₽', base_price=base_price / 100)
|
||||
if total_servers_cost > 0:
|
||||
message = f' 🌍 Серверы: {servers_price_per_month / 100}₽/мес × {months_in_period} = {total_servers_cost / 100}₽'
|
||||
if total_servers_discount > 0:
|
||||
message += f' (скидка {servers_discount_percent}%: -{total_servers_discount / 100}₽)'
|
||||
logger.info(message)
|
||||
if total_traffic_cost > 0:
|
||||
message = (
|
||||
f' 📊 Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period} = {total_traffic_cost / 100}₽'
|
||||
)
|
||||
if total_traffic_discount > 0:
|
||||
message += f' (скидка {traffic_discount_percent}%: -{total_traffic_discount / 100}₽)'
|
||||
logger.info(message)
|
||||
if total_devices_cost > 0:
|
||||
message = f' 📱 Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period} = {total_devices_cost / 100}₽'
|
||||
if total_devices_discount > 0:
|
||||
message += f' (скидка {devices_discount_percent}%: -{total_devices_discount / 100}₽)'
|
||||
logger.info(message)
|
||||
logger.info('💎 ИТОГО: ₽', total_cost=total_cost / 100)
|
||||
|
||||
return total_cost
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка расчета стоимости продления', error=e)
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
return PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
|
||||
async def calculate_addon_cost_for_remaining_period(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
additional_traffic_gb: int = 0,
|
||||
additional_devices: int = 0,
|
||||
additional_server_ids: list[int] = None,
|
||||
*,
|
||||
user: User | None = None,
|
||||
promo_group: PromoGroup | None = None,
|
||||
) -> int:
|
||||
if additional_server_ids is None:
|
||||
additional_server_ids = []
|
||||
|
||||
now = datetime.now(UTC)
|
||||
days_to_pay = max(1, (subscription.end_date - now).days)
|
||||
period_hint_days = days_to_pay
|
||||
|
||||
total_cost = 0
|
||||
|
||||
if user is None:
|
||||
user = getattr(subscription, 'user', None)
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
if additional_traffic_gb > 0:
|
||||
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
|
||||
traffic_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'traffic',
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
traffic_total_cost = int(discounted_traffic_per_month * days_to_pay / 30)
|
||||
total_cost += traffic_total_cost
|
||||
message = f'Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month / 100}₽/мес × {days_to_pay} дн. = {traffic_total_cost / 100}₽'
|
||||
if traffic_discount_per_month > 0:
|
||||
message += (
|
||||
f' (скидка {traffic_discount_percent}%: -{int(traffic_discount_per_month * days_to_pay / 30) / 100}₽)'
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
if additional_devices > 0:
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'devices',
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
devices_total_cost = int(discounted_devices_per_month * days_to_pay / 30)
|
||||
total_cost += devices_total_cost
|
||||
message = f'Устройства +{additional_devices}: {devices_price_per_month / 100}₽/мес × {days_to_pay} дн. = {devices_total_cost / 100}₽'
|
||||
if devices_discount_per_month > 0:
|
||||
message += (
|
||||
f' (скидка {devices_discount_percent}%: -{int(devices_discount_per_month * days_to_pay / 30) / 100}₽)'
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
if additional_server_ids:
|
||||
from app.database.models import ServerSquad
|
||||
|
||||
for server_id in additional_server_ids:
|
||||
result = await db.execute(
|
||||
select(ServerSquad.price_kopeks, ServerSquad.display_name).where(ServerSquad.id == server_id)
|
||||
)
|
||||
server_data = result.first()
|
||||
if server_data:
|
||||
server_price_per_month, server_name = server_data
|
||||
servers_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'servers',
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
server_discount_per_month = server_price_per_month * servers_discount_percent // 100
|
||||
discounted_server_per_month = server_price_per_month - server_discount_per_month
|
||||
server_total_cost = int(discounted_server_per_month * days_to_pay / 30)
|
||||
total_cost += server_total_cost
|
||||
message = f'Сервер {server_name}: {server_price_per_month / 100}₽/мес × {days_to_pay} дн. = {server_total_cost / 100}₽'
|
||||
if server_discount_per_month > 0:
|
||||
message += f' (скидка {servers_discount_percent}%: -{int(server_discount_per_month * days_to_pay / 30) / 100}₽)'
|
||||
logger.info(message)
|
||||
|
||||
logger.info('💰 Итого доплата за дн.: ₽', days_to_pay=days_to_pay, total_cost=total_cost / 100)
|
||||
return total_cost
|
||||
|
||||
|
||||
async def expire_subscription(db: AsyncSession, subscription: Subscription) -> Subscription:
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
@@ -2109,8 +1730,9 @@ async def get_disabled_daily_subscriptions_for_resume(
|
||||
# Не возобновляем подписки, приостановленные пользователем вручную
|
||||
# is_(False) не ловит NULL, поэтому добавляем OR is_(None)
|
||||
(Subscription.is_daily_paused.is_(False) | Subscription.is_daily_paused.is_(None)),
|
||||
# Баланс пользователя >= суточной цены тарифа
|
||||
User.balance_kopeks >= Tariff.daily_price_kopeks,
|
||||
# Баланс пользователя > 0 (permissive pre-filter;
|
||||
# actual discounted price check happens in _process_single_charge)
|
||||
User.balance_kopeks > 0,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -2155,8 +1777,9 @@ async def get_expired_daily_subscriptions_for_recovery(db: AsyncSession) -> list
|
||||
Subscription.is_trial.is_(False),
|
||||
# Только недавно экспайренные
|
||||
Subscription.updated_at >= recovery_threshold,
|
||||
# Баланс достаточен для списания
|
||||
User.balance_kopeks >= Tariff.daily_price_kopeks,
|
||||
# Баланс > 0 (permissive pre-filter;
|
||||
# actual discounted price check happens in _process_single_charge)
|
||||
User.balance_kopeks > 0,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -2206,8 +1829,12 @@ async def resume_daily_subscription(
|
||||
|
||||
subscription.is_daily_paused = False
|
||||
|
||||
# Восстанавливаем статус ACTIVE если подписка была DISABLED/EXPIRED
|
||||
if subscription.status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value):
|
||||
# Восстанавливаем статус ACTIVE если подписка была DISABLED/EXPIRED/LIMITED
|
||||
if subscription.status in (
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
):
|
||||
previous_status = subscription.status
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
# Обновляем время последнего списания для корректного расчёта следующего
|
||||
|
||||
@@ -61,16 +61,17 @@ async def get_conversion_statistics(db: AsyncSession) -> dict:
|
||||
total_conversions = total_conversions_result.scalar() or 0
|
||||
|
||||
# Подсчитываем пользователей с платными подписками
|
||||
users_with_paid_result = await db.execute(select(func.count(User.id)).where(User.has_had_paid_subscription == True))
|
||||
users_with_paid_result = await db.execute(
|
||||
select(func.count(User.id)).where(User.has_had_paid_subscription.is_(True))
|
||||
)
|
||||
users_with_paid = users_with_paid_result.scalar() or 0
|
||||
|
||||
# Подсчитываем всех пользователей с подписками (использовавших триал)
|
||||
# Считаем что все новые пользователи начинают с триала
|
||||
total_users_with_subscriptions_result = await db.execute(select(func.count(func.distinct(Subscription.user_id))))
|
||||
total_users_with_subscriptions = total_users_with_subscriptions_result.scalar() or 0
|
||||
|
||||
# Расчёт конверсии: (оплатившие) / (всего с подписками) * 100
|
||||
# Это показывает какой % пользователей, получивших подписку, в итоге оплатили
|
||||
# Знаменатель = все юзеры с подписками (включая уже конвертированных)
|
||||
if total_users_with_subscriptions > 0:
|
||||
conversion_rate = round((users_with_paid / total_users_with_subscriptions) * 100, 1)
|
||||
else:
|
||||
|
||||
@@ -185,6 +185,8 @@ async def create_tariff(
|
||||
traffic_price_per_gb_kopeks: int = 0,
|
||||
min_traffic_gb: int = 1,
|
||||
max_traffic_gb: int = 1000,
|
||||
# Видимость в разделе подарков
|
||||
show_in_gift: bool = True,
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode: str | None = None, # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
# Внешний сквад RemnaWave
|
||||
@@ -223,6 +225,8 @@ async def create_tariff(
|
||||
traffic_price_per_gb_kopeks=max(0, traffic_price_per_gb_kopeks),
|
||||
min_traffic_gb=max(1, min_traffic_gb),
|
||||
max_traffic_gb=max(1, max_traffic_gb),
|
||||
# Видимость в разделе подарков
|
||||
show_in_gift=show_in_gift,
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode=traffic_reset_mode,
|
||||
# Внешний сквад
|
||||
@@ -290,6 +294,8 @@ async def update_tariff(
|
||||
traffic_price_per_gb_kopeks: int | None = None,
|
||||
min_traffic_gb: int | None = None,
|
||||
max_traffic_gb: int | None = None,
|
||||
# Видимость в разделе подарков
|
||||
show_in_gift: bool | None = None,
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode: str | None = ..., # ... = не передан, None = сбросить к глобальной настройке
|
||||
# Внешний сквад RemnaWave
|
||||
@@ -354,6 +360,9 @@ async def update_tariff(
|
||||
tariff.min_traffic_gb = max(1, min_traffic_gb)
|
||||
if max_traffic_gb is not None:
|
||||
tariff.max_traffic_gb = max(1, max_traffic_gb)
|
||||
# Видимость в разделе подарков
|
||||
if show_in_gift is not None:
|
||||
tariff.show_in_gift = show_in_gift
|
||||
# Режим сброса трафика
|
||||
if traffic_reset_mode is not ...:
|
||||
tariff.traffic_reset_mode = traffic_reset_mode
|
||||
|
||||
@@ -25,6 +25,8 @@ REAL_PAYMENT_METHODS = [
|
||||
PaymentMethod.CLOUDPAYMENTS.value,
|
||||
PaymentMethod.FREEKASSA.value,
|
||||
PaymentMethod.KASSA_AI.value,
|
||||
PaymentMethod.RIOPAY.value,
|
||||
PaymentMethod.SEVERPAY.value,
|
||||
]
|
||||
|
||||
|
||||
@@ -49,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,
|
||||
@@ -106,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
|
||||
@@ -166,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:
|
||||
@@ -251,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
|
||||
)
|
||||
|
||||
@@ -276,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,
|
||||
@@ -341,7 +348,7 @@ async def get_transactions_statistics(
|
||||
)
|
||||
.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,
|
||||
@@ -361,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),
|
||||
@@ -389,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),
|
||||
|
||||
+110
-23
@@ -410,6 +410,28 @@ async def update_user(db: AsyncSession, user: User, **kwargs) -> User:
|
||||
return user
|
||||
|
||||
|
||||
async def lock_user_for_update(db: AsyncSession, user: User) -> User:
|
||||
"""Lock user row with SELECT FOR UPDATE to prevent concurrent balance modifications.
|
||||
|
||||
Returns the refreshed user object with current DB values.
|
||||
Must be called within an active transaction before modifying balance_kopeks.
|
||||
Eagerly loads key relationships to avoid MissingGreenlet in async context.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.id == user.id)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def add_user_balance(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
@@ -421,6 +443,22 @@ async def add_user_balance(
|
||||
payment_method: PaymentMethod | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
# Lock the user row to prevent concurrent balance race conditions
|
||||
# Eagerly load key relationships to avoid MissingGreenlet in async context
|
||||
locked_result = await db.execute(
|
||||
select(User)
|
||||
.where(User.id == user.id)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
user = locked_result.scalar_one()
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.now(UTC)
|
||||
@@ -490,6 +528,27 @@ async def add_user_balance_by_id(
|
||||
return False
|
||||
|
||||
|
||||
async def lock_user_for_pricing(db: AsyncSession, user_id: int) -> User:
|
||||
"""Lock user row with FOR UPDATE and return refreshed instance.
|
||||
|
||||
Call BEFORE computing prices that depend on promo offer state
|
||||
to prevent TOCTOU race conditions where two concurrent requests
|
||||
both read the same promo offer discount and charge a discounted price.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.id == user_id)
|
||||
.options(
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.subscription).selectinload(Subscription.tariff),
|
||||
)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def subtract_user_balance(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
@@ -501,17 +560,33 @@ async def subtract_user_balance(
|
||||
transaction_type: TransactionType = TransactionType.WITHDRAWAL,
|
||||
consume_promo_offer: bool = False,
|
||||
mark_as_paid_subscription: bool = False,
|
||||
commit: bool = True,
|
||||
) -> bool:
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
logger.info('💸 ОТЛАДКА subtract_user_balance:')
|
||||
logger.info('👤 User ID: (ID: )', user_id=user.id, user_id_display=user_id_display)
|
||||
logger.info('💰 Баланс до списания: копеек', balance_kopeks=user.balance_kopeks)
|
||||
logger.info('💸 Сумма к списанию: копеек', amount_kopeks=amount_kopeks)
|
||||
logger.info('📝 Описание', description=description)
|
||||
if amount_kopeks < 0:
|
||||
logger.error('subtract_user_balance called with negative amount', amount_kopeks=amount_kopeks, user_id=user.id)
|
||||
return False
|
||||
|
||||
logger.debug(
|
||||
'subtract_user_balance called',
|
||||
user_id=user.id,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
)
|
||||
|
||||
# Lock the user row to prevent concurrent balance race conditions
|
||||
# Eagerly load key relationships to avoid MissingGreenlet in async context
|
||||
locked_result = await db.execute(
|
||||
select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True)
|
||||
select(User)
|
||||
.where(User.id == user.id)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
user = locked_result.scalar_one()
|
||||
|
||||
@@ -572,8 +647,6 @@ async def subtract_user_balance(
|
||||
create_transaction as create_trans,
|
||||
)
|
||||
|
||||
# create_trans commits the session, atomically persisting
|
||||
# both the balance change and the transaction record
|
||||
await create_trans(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
@@ -581,11 +654,15 @@ async def subtract_user_balance(
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
payment_method=payment_method,
|
||||
commit=commit,
|
||||
)
|
||||
else:
|
||||
elif commit:
|
||||
await db.commit()
|
||||
else:
|
||||
await db.flush()
|
||||
|
||||
await db.refresh(user)
|
||||
if commit:
|
||||
await db.refresh(user)
|
||||
|
||||
if consume_promo_offer and log_context:
|
||||
try:
|
||||
@@ -598,26 +675,30 @@ async def subtract_user_balance(
|
||||
percent=log_context.get('percent'),
|
||||
effect_type=log_context.get('effect_type'),
|
||||
details=log_context.get('details'),
|
||||
commit=commit,
|
||||
)
|
||||
except Exception as log_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to record promo offer consumption log for user', user_id=user.id, log_error=log_error
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to rollback session after promo offer consumption log failure',
|
||||
rollback_error=rollback_error,
|
||||
)
|
||||
if commit:
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to rollback session after promo offer consumption log failure',
|
||||
rollback_error=rollback_error,
|
||||
)
|
||||
|
||||
logger.info('✅ Средства списаны: →', old_balance=old_balance, balance_kopeks=user.balance_kopeks)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error('❌ ОШИБКА СПИСАНИЯ', error=e)
|
||||
await db.rollback()
|
||||
return False
|
||||
if commit:
|
||||
await db.rollback()
|
||||
return False
|
||||
raise
|
||||
|
||||
|
||||
async def cleanup_expired_promo_offer_discounts(db: AsyncSession) -> int:
|
||||
@@ -1131,8 +1212,11 @@ async def create_user_by_email(
|
||||
|
||||
|
||||
async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
|
||||
"""Get user by email address."""
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
"""Get user by email address (case-insensitive)."""
|
||||
if not email or not email.strip():
|
||||
return None
|
||||
email_lower = email.strip().lower()
|
||||
result = await db.execute(select(User).where(func.lower(User.email) == email_lower))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
@@ -1148,7 +1232,10 @@ async def is_email_taken(db: AsyncSession, email: str, exclude_user_id: int | No
|
||||
Returns:
|
||||
True if email is taken, False otherwise
|
||||
"""
|
||||
query = select(User.id).where(User.email == email)
|
||||
if not email or not email.strip():
|
||||
return False
|
||||
email_lower = email.strip().lower()
|
||||
query = select(User.id).where(func.lower(User.email) == email_lower)
|
||||
if exclude_user_id:
|
||||
query = query.where(User.id != exclude_user_id)
|
||||
result = await db.execute(query)
|
||||
|
||||
@@ -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()
|
||||
@@ -40,6 +40,7 @@ async def _sync_user_primary_promo_group(
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Ошибка синхронизации primary промогруппы пользователя', user_id=user_id, error=error)
|
||||
raise
|
||||
|
||||
|
||||
async def sync_user_primary_promo_group(
|
||||
@@ -52,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:
|
||||
"""
|
||||
Добавляет пользователю промогруппу.
|
||||
@@ -62,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 если уже существует
|
||||
@@ -84,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(
|
||||
'Пользователю добавлена промогруппа',
|
||||
@@ -97,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:
|
||||
"""
|
||||
Удаляет промогруппу у пользователя.
|
||||
|
||||
@@ -109,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 если связи не было
|
||||
@@ -132,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]:
|
||||
@@ -154,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:
|
||||
@@ -180,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:
|
||||
"""
|
||||
@@ -206,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:
|
||||
@@ -231,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)
|
||||
@@ -256,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()
|
||||
|
||||
|
||||
|
||||
+86
-4
@@ -123,6 +123,7 @@ class SubscriptionStatus(Enum):
|
||||
ACTIVE = 'active'
|
||||
EXPIRED = 'expired'
|
||||
DISABLED = 'disabled'
|
||||
LIMITED = 'limited'
|
||||
PENDING = 'pending'
|
||||
|
||||
|
||||
@@ -158,6 +159,7 @@ class PaymentMethod(Enum):
|
||||
FREEKASSA = 'freekassa'
|
||||
KASSA_AI = 'kassa_ai'
|
||||
RIOPAY = 'riopay'
|
||||
SEVERPAY = 'severpay'
|
||||
MANUAL = 'manual'
|
||||
BALANCE = 'balance'
|
||||
|
||||
@@ -755,7 +757,7 @@ class RioPayPayment(Base):
|
||||
__tablename__ = 'riopay_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
|
||||
# Идентификаторы
|
||||
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
|
||||
@@ -811,6 +813,69 @@ class RioPayPayment(Base):
|
||||
return f'<RioPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class SeverPayPayment(Base):
|
||||
"""Платежи через SeverPay (severpay.io)."""
|
||||
|
||||
__tablename__ = 'severpay_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
|
||||
# Идентификаторы
|
||||
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
|
||||
severpay_id = Column(String(64), unique=True, nullable=True, index=True) # ID от SeverPay
|
||||
severpay_uid = Column(String(64), unique=True, nullable=True, index=True) # UID от SeverPay
|
||||
|
||||
# Суммы
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default='RUB')
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Статусы
|
||||
status = Column(String(32), nullable=False, default='pending')
|
||||
is_paid = Column(Boolean, default=False)
|
||||
|
||||
# Данные платежа
|
||||
payment_url = Column(Text, nullable=True)
|
||||
payment_method = Column(String(32), nullable=True)
|
||||
|
||||
# Метаданные
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
# Временные метки
|
||||
paid_at = Column(AwareDateTime(), nullable=True)
|
||||
expires_at = Column(AwareDateTime(), nullable=True)
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
# Связь с транзакцией
|
||||
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', backref='severpay_payments')
|
||||
transaction = relationship('Transaction', backref='severpay_payment')
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == 'pending'
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return self.status == 'success' and self.is_paid
|
||||
|
||||
@property
|
||||
def is_failed(self) -> bool:
|
||||
return self.status in ['failed', 'expired', 'declined', 'amount_mismatch']
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f'<SeverPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class PromoGroup(Base):
|
||||
__tablename__ = 'promo_groups'
|
||||
|
||||
@@ -974,6 +1039,9 @@ class Tariff(Base):
|
||||
min_traffic_gb = Column(Integer, default=1, nullable=False) # Минимальный трафик в ГБ
|
||||
max_traffic_gb = Column(Integer, default=1000, nullable=False) # Максимальный трафик в ГБ
|
||||
|
||||
# Видимость в разделе подарков
|
||||
show_in_gift = Column(Boolean, default=True, server_default='true', nullable=False)
|
||||
|
||||
# Режим сброса трафика: DAY, WEEK, MONTH, NO_RESET (по умолчанию берётся из конфига)
|
||||
traffic_reset_mode = Column(String(20), nullable=True, default=None) # None = использовать глобальную настройку
|
||||
|
||||
@@ -1144,7 +1212,9 @@ class User(Base):
|
||||
discord_id = Column(String(255), unique=True, nullable=True, index=True)
|
||||
vk_id = Column(BigInteger, unique=True, nullable=True, index=True)
|
||||
broadcasts = relationship('BroadcastHistory', back_populates='admin')
|
||||
referrals = relationship('User', backref='referrer', remote_side=[id], foreign_keys='User.referred_by_id')
|
||||
referrals = relationship(
|
||||
'User', backref='referrer', remote_side=[id], foreign_keys='User.referred_by_id', post_update=True
|
||||
)
|
||||
subscription = relationship('Subscription', back_populates='user', uselist=False)
|
||||
transactions = relationship('Transaction', back_populates='user')
|
||||
referral_earnings = relationship('ReferralEarning', foreign_keys='ReferralEarning.user_id', back_populates='user')
|
||||
@@ -1345,6 +1415,9 @@ class Subscription(Base):
|
||||
if self.status == SubscriptionStatus.DISABLED.value:
|
||||
return 'disabled'
|
||||
|
||||
if self.status == SubscriptionStatus.LIMITED.value:
|
||||
return 'limited'
|
||||
|
||||
if self.status == SubscriptionStatus.ACTIVE.value:
|
||||
if end is None or end <= current_time:
|
||||
return 'expired'
|
||||
@@ -1369,6 +1442,8 @@ class Subscription(Base):
|
||||
return '🟢 Активна'
|
||||
if actual_status == 'disabled':
|
||||
return '⚫ Отключена'
|
||||
if actual_status == 'limited':
|
||||
return '⚠️ Трафик исчерпан'
|
||||
if actual_status == 'trial':
|
||||
return '🎯 Тестовая'
|
||||
|
||||
@@ -1386,6 +1461,8 @@ class Subscription(Base):
|
||||
return '💎'
|
||||
if actual_status == 'disabled':
|
||||
return '⚫'
|
||||
if actual_status == 'limited':
|
||||
return '⚠️'
|
||||
if actual_status == 'trial':
|
||||
return '🎁'
|
||||
|
||||
@@ -1434,7 +1511,7 @@ class Subscription(Base):
|
||||
else:
|
||||
self.end_date = datetime.now(UTC) + timedelta(days=days)
|
||||
|
||||
if self.status == SubscriptionStatus.EXPIRED.value:
|
||||
if self.status in (SubscriptionStatus.EXPIRED.value, SubscriptionStatus.LIMITED.value):
|
||||
self.status = SubscriptionStatus.ACTIVE.value
|
||||
|
||||
def add_traffic(self, gb: int):
|
||||
@@ -1497,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)
|
||||
@@ -2413,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)
|
||||
@@ -3206,6 +3287,7 @@ 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')
|
||||
|
||||
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
+44
-21
@@ -280,12 +280,6 @@ class RemnaWaveAPI:
|
||||
'X-Real-IP': '127.0.0.1',
|
||||
}
|
||||
|
||||
# Caddy авторизация — добавляется поверх основной
|
||||
if self.caddy_token:
|
||||
# Caddy Security: готовый base64 токен используется как есть
|
||||
headers['Authorization'] = f'Basic {self.caddy_token}'
|
||||
logger.debug('Используем Caddy Basic Auth')
|
||||
|
||||
# Основная авторизация RemnaWave API
|
||||
if self.auth_type == 'basic' and self.username and self.password:
|
||||
credentials = f'{self.username}:{self.password}'
|
||||
@@ -293,16 +287,16 @@ class RemnaWaveAPI:
|
||||
headers['X-Api-Key'] = f'Basic {encoded_credentials}'
|
||||
logger.debug('Используем Basic Auth в X-Api-Key заголовке')
|
||||
elif self.auth_type == 'caddy':
|
||||
# Для caddy auth_type основная авторизация уже в Authorization header
|
||||
# Но API ключ всё равно нужен для RemnaWave
|
||||
# Caddy Security: caddy_token → X-Api-Key, api_key → Authorization: Bearer
|
||||
if self.api_key:
|
||||
headers['X-Api-Key'] = self.api_key
|
||||
logger.debug('Используем API ключ для RemnaWave + Caddy авторизацию')
|
||||
headers['Authorization'] = f'Bearer {self.api_key}'
|
||||
if self.caddy_token:
|
||||
headers['X-Api-Key'] = self.caddy_token
|
||||
logger.debug('Используем Caddy авторизацию')
|
||||
else:
|
||||
# api_key или bearer — стандартный режим
|
||||
headers['X-Api-Key'] = self.api_key
|
||||
if not self.caddy_token:
|
||||
headers['Authorization'] = f'Bearer {self.api_key}'
|
||||
headers['Authorization'] = f'Bearer {self.api_key}'
|
||||
logger.debug('Используем API ключ в X-Api-Key заголовке')
|
||||
|
||||
return headers
|
||||
@@ -400,7 +394,12 @@ class RemnaWaveAPI:
|
||||
|
||||
if response.status >= 400:
|
||||
error_message = response_data.get('message', f'HTTP {response.status}')
|
||||
log = logger.warning if response.status in (502, 503, 504) else logger.error
|
||||
# Downgrade known-harmless 400s to warning (caller handles them as success)
|
||||
error_lower = str(error_message).lower()
|
||||
is_harmless = response.status == 400 and (
|
||||
'already enabled' in error_lower or 'already disabled' in error_lower
|
||||
)
|
||||
log = logger.warning if response.status in (502, 503, 504) or is_harmless else logger.error
|
||||
log('API Error %s: %s', response.status, error_message)
|
||||
log('Response: %s', response_text[:500])
|
||||
raise RemnaWaveAPIError(error_message, response.status, response_data)
|
||||
@@ -470,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',
|
||||
@@ -569,13 +583,22 @@ class RemnaWaveAPI:
|
||||
if external_squad_uuid is not ...:
|
||||
data['externalSquadUuid'] = external_squad_uuid
|
||||
|
||||
logger.info(
|
||||
'PATCH /api/users payload',
|
||||
uuid=uuid,
|
||||
hwidDeviceLimit=data.get('hwidDeviceLimit'),
|
||||
status=data.get('status'),
|
||||
)
|
||||
response = await self._make_request('PATCH', '/api/users', data)
|
||||
try:
|
||||
response = await self._make_request('PATCH', '/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,
|
||||
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
+13
-4
@@ -72,18 +72,21 @@ class TributeService:
|
||||
status = None
|
||||
amount_kopeks = 0
|
||||
telegram_user_id = None
|
||||
trb_user_id = None
|
||||
|
||||
payment_id = webhook_data.get('id') or webhook_data.get('payment_id')
|
||||
status = webhook_data.get('status')
|
||||
amount_kopeks = webhook_data.get('amount', 0)
|
||||
telegram_user_id = webhook_data.get('telegram_user_id') or webhook_data.get('user_id')
|
||||
telegram_user_id = webhook_data.get('telegram_user_id')
|
||||
trb_user_id = webhook_data.get('trb_user_id')
|
||||
|
||||
if not payment_id and 'payload' in webhook_data:
|
||||
data = webhook_data['payload']
|
||||
payment_id = data.get('id') or data.get('payment_id')
|
||||
status = data.get('status')
|
||||
amount_kopeks = data.get('amount', 0)
|
||||
telegram_user_id = data.get('telegram_user_id') or data.get('user_id')
|
||||
telegram_user_id = data.get('telegram_user_id')
|
||||
trb_user_id = data.get('trb_user_id')
|
||||
|
||||
if not payment_id and 'name' in webhook_data:
|
||||
event_name = webhook_data.get('name')
|
||||
@@ -91,6 +94,7 @@ class TributeService:
|
||||
payment_id = str(data.get('donation_request_id'))
|
||||
amount_kopeks = data.get('amount', 0)
|
||||
telegram_user_id = data.get('telegram_user_id')
|
||||
trb_user_id = data.get('trb_user_id')
|
||||
|
||||
if event_name in ('new_donation', 'recurrent_donation'):
|
||||
status = 'paid'
|
||||
@@ -100,15 +104,19 @@ class TributeService:
|
||||
status = 'unknown'
|
||||
|
||||
logger.info(
|
||||
'📝 Извлеченные данные: payment_id=, status=, amount_kopeks=, user_id',
|
||||
'📝 Извлеченные данные: payment_id=, status=, amount_kopeks=, telegram_user_id=, trb_user_id=',
|
||||
payment_id=payment_id,
|
||||
status=status,
|
||||
amount_kopeks=amount_kopeks,
|
||||
telegram_user_id=telegram_user_id,
|
||||
trb_user_id=trb_user_id,
|
||||
)
|
||||
|
||||
if not telegram_user_id:
|
||||
logger.error('❌ Не найден telegram_user_id в webhook данных')
|
||||
logger.error(
|
||||
'❌ Не найден telegram_user_id в webhook данных',
|
||||
trb_user_id=trb_user_id,
|
||||
)
|
||||
logger.error(
|
||||
'🔍 Полные данные для отладки', dumps=json.dumps(webhook_data, ensure_ascii=False, indent=2)
|
||||
)
|
||||
@@ -124,6 +132,7 @@ class TributeService:
|
||||
'event_type': 'payment',
|
||||
'payment_id': payment_id or f'tribute_{telegram_user_id}_{amount_kopeks}',
|
||||
'user_id': telegram_user_id,
|
||||
'trb_user_id': trb_user_id,
|
||||
'amount_kopeks': int(amount_kopeks) if amount_kopeks else 0,
|
||||
'status': status or 'paid',
|
||||
'external_id': f'donation_{payment_id or "unknown"}',
|
||||
|
||||
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()
|
||||
|
||||
@@ -63,7 +63,7 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
|
||||
},
|
||||
'payments': {
|
||||
'title': '💳 Платежные системы',
|
||||
'description': 'YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute, Kassa AI, RioPay и Telegram Stars.',
|
||||
'description': 'YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute, Kassa AI, RioPay, SeverPay и Telegram Stars.',
|
||||
'icon': '💳',
|
||||
'categories': (
|
||||
'PAYMENT',
|
||||
@@ -75,6 +75,7 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
|
||||
'FREEKASSA',
|
||||
'KASSA_AI',
|
||||
'RIOPAY',
|
||||
'SEVERPAY',
|
||||
'MULENPAY',
|
||||
'PAL24',
|
||||
'WATA',
|
||||
@@ -1256,6 +1257,9 @@ def _build_settings_keyboard(
|
||||
elif category_key == 'RIOPAY':
|
||||
label = texts.t('PAYMENT_RIOPAY', f'💳 {settings.get_riopay_display_name()}')
|
||||
test_payment_buttons.append([_test_button(f'{label} · тест', 'riopay')])
|
||||
elif category_key == 'SEVERPAY':
|
||||
label = texts.t('PAYMENT_SEVERPAY', f'💳 {settings.get_severpay_display_name()}')
|
||||
test_payment_buttons.append([_test_button(f'{label} · тест', 'severpay')])
|
||||
|
||||
if test_payment_buttons:
|
||||
rows.extend(test_payment_buttons)
|
||||
|
||||
@@ -1103,13 +1103,27 @@ async def confirm_button_selection(callback: types.CallbackQuery, db_user: User,
|
||||
await callback.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
await callback.bot.send_photo(
|
||||
chat_id=callback.message.chat.id,
|
||||
photo=media_file_id,
|
||||
caption=preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
# Telegram ограничивает caption до 1024 символов
|
||||
if len(preview_text) <= 1024:
|
||||
await callback.bot.send_photo(
|
||||
chat_id=callback.message.chat.id,
|
||||
photo=media_file_id,
|
||||
caption=preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
# Фото без caption + текст отдельным сообщением
|
||||
await callback.bot.send_photo(
|
||||
chat_id=callback.message.chat.id,
|
||||
photo=media_file_id,
|
||||
)
|
||||
await callback.bot.send_message(
|
||||
chat_id=callback.message.chat.id,
|
||||
text=preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
# Если нет file_id, используем safe редактирование
|
||||
await safe_edit_or_send_text(
|
||||
@@ -1244,13 +1258,27 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
|
||||
'video': 'video',
|
||||
'document': 'document',
|
||||
}[media_type]
|
||||
await send_method(
|
||||
chat_id=telegram_id,
|
||||
**{media_kwarg: media_file_id},
|
||||
caption=message_text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=broadcast_keyboard,
|
||||
)
|
||||
# Telegram ограничивает caption до 1024 символов
|
||||
if len(message_text) <= 1024:
|
||||
await send_method(
|
||||
chat_id=telegram_id,
|
||||
**{media_kwarg: media_file_id},
|
||||
caption=message_text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=broadcast_keyboard,
|
||||
)
|
||||
else:
|
||||
# Медиа без caption + текст отдельным сообщением
|
||||
await send_method(
|
||||
chat_id=telegram_id,
|
||||
**{media_kwarg: media_file_id},
|
||||
)
|
||||
await callback.bot.send_message(
|
||||
chat_id=telegram_id,
|
||||
text=message_text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=broadcast_keyboard,
|
||||
)
|
||||
else:
|
||||
# Неизвестный media_type — отправляем как текст
|
||||
await callback.bot.send_message(
|
||||
@@ -1557,7 +1585,11 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
if target == 'expired':
|
||||
# Истекшие подписки
|
||||
now = datetime.now(UTC)
|
||||
expired_statuses = [SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value]
|
||||
expired_statuses = [
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
]
|
||||
query = (
|
||||
select(sql_func.count(distinct(User.id)))
|
||||
.outerjoin(Subscription, User.id == Subscription.user_id)
|
||||
@@ -1576,7 +1608,11 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
if target == 'expired_subscribers':
|
||||
# То же что и expired
|
||||
now = datetime.now(UTC)
|
||||
expired_statuses = [SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value]
|
||||
expired_statuses = [
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
]
|
||||
query = (
|
||||
select(sql_func.count(distinct(User.id)))
|
||||
.outerjoin(Subscription, User.id == Subscription.user_id)
|
||||
|
||||
@@ -977,7 +977,7 @@ async def _render_squad_selection(
|
||||
if not selected_server:
|
||||
selected_server = await get_server_squad_by_uuid(db, selected_uuid)
|
||||
if selected_server:
|
||||
selected_server_name = selected_server.display_name
|
||||
selected_server_name = html.escape(selected_server.display_name)
|
||||
|
||||
header = texts.t('ADMIN_PROMO_OFFER_SELECT_SQUAD_TITLE', '🌍 <b>Выберите сквад</b>')
|
||||
if selected_server_name:
|
||||
|
||||
@@ -629,6 +629,9 @@ async def process_test_referral_earning(message: types.Message, db_user: User, d
|
||||
db.add(earning)
|
||||
|
||||
# Добавляем на баланс пользователя
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
target_user = await lock_user_for_update(db, target_user)
|
||||
target_user.balance_kopeks += amount_kopeks
|
||||
|
||||
await db.commit()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
import math
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
@@ -175,7 +176,7 @@ def _format_migration_server_label(texts, server) -> str:
|
||||
return texts.t(
|
||||
'ADMIN_SQUAD_MIGRATION_SERVER_LABEL',
|
||||
'{name} — 👥 {users} ({status})',
|
||||
).format(name=server.display_name, users=server.current_users, status=status)
|
||||
).format(name=html.escape(server.display_name), users=server.current_users, status=status)
|
||||
|
||||
|
||||
def _build_migration_keyboard(
|
||||
|
||||
@@ -44,8 +44,8 @@ def _build_server_edit_view(server):
|
||||
<b>Информация:</b>
|
||||
• ID: {server.id}
|
||||
• UUID: <code>{server.squad_uuid}</code>
|
||||
• Название: {server.display_name}
|
||||
• Оригинальное: {server.original_name or 'Не указано'}
|
||||
• Название: {html.escape(server.display_name)}
|
||||
• Оригинальное: {html.escape(server.original_name) if server.original_name else 'Не указано'}
|
||||
• Статус: {status_emoji}
|
||||
|
||||
<b>Настройки:</b>
|
||||
@@ -172,7 +172,7 @@ async def show_servers_list(callback: types.CallbackQuery, db_user: User, db: As
|
||||
status_emoji = '✅' if server.is_available else '❌'
|
||||
price_text = f'{int(server.price_rubles)} ₽' if server.price_kopeks > 0 else 'Бесплатно'
|
||||
|
||||
text += f'{i}. {status_emoji} {server.display_name}\n'
|
||||
text += f'{i}. {status_emoji} {html.escape(server.display_name)}\n'
|
||||
text += f' 💰 Цена: {price_text}'
|
||||
|
||||
if server.max_users:
|
||||
@@ -559,7 +559,7 @@ async def start_server_edit_name(callback: types.CallbackQuery, state: FSMContex
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'✏️ <b>Редактирование названия</b>\n\n'
|
||||
f'Текущее название: <b>{server.display_name}</b>\n\n'
|
||||
f'Текущее название: <b>{html.escape(server.display_name)}</b>\n\n'
|
||||
f'Отправьте новое название для сервера:',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
@@ -621,7 +621,7 @@ async def delete_server_confirm(callback: types.CallbackQuery, db_user: User, db
|
||||
🗑️ <b>Удаление сервера</b>
|
||||
|
||||
Вы действительно хотите удалить сервер:
|
||||
<b>{server.display_name}</b>
|
||||
<b>{html.escape(server.display_name)}</b>
|
||||
|
||||
⚠️ <b>Внимание!</b>
|
||||
Сервер можно удалить только если к нему нет активных подключений.
|
||||
@@ -658,7 +658,7 @@ async def delete_server_execute(callback: types.CallbackQuery, db_user: User, db
|
||||
await cache.delete_pattern('available_countries*')
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'✅ Сервер <b>{server.display_name}</b> успешно удален!',
|
||||
f'✅ Сервер <b>{html.escape(server.display_name)}</b> успешно удален!',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='📋 К списку серверов', callback_data='admin_servers_list')]
|
||||
@@ -668,7 +668,7 @@ async def delete_server_execute(callback: types.CallbackQuery, db_user: User, db
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
f'❌ Не удалось удалить сервер <b>{server.display_name}</b>\n\nВозможно, к нему есть активные подключения.',
|
||||
f'❌ Не удалось удалить сервер <b>{html.escape(server.display_name)}</b>\n\nВозможно, к нему есть активные подключения.',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='🔙 К серверу', callback_data=f'admin_server_edit_{server_id}')]
|
||||
@@ -706,7 +706,7 @@ async def show_server_detailed_stats(callback: types.CallbackQuery, db_user: Use
|
||||
|
||||
for i, server in enumerate(sorted_servers[:5], 1):
|
||||
price_text = f'{int(server.price_rubles)} ₽' if server.price_kopeks > 0 else 'Бесплатно'
|
||||
text += f'{i}. {server.display_name} - {price_text}\n'
|
||||
text += f'{i}. {html.escape(server.display_name)} - {price_text}\n'
|
||||
|
||||
if not sorted_servers:
|
||||
text += 'Нет доступных серверов\n'
|
||||
@@ -968,7 +968,7 @@ async def start_server_edit_promo_groups(
|
||||
|
||||
text = (
|
||||
'🎯 <b>Настройка промогрупп</b>\n\n'
|
||||
f'Сервер: <b>{server.display_name}</b>\n\n'
|
||||
f'Сервер: <b>{html.escape(server.display_name)}</b>\n\n'
|
||||
'Выберите промогруппы, которым будет доступен этот сервер.\n'
|
||||
'Должна быть выбрана минимум одна промогруппа.'
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.database.models import Tariff, User
|
||||
from app.localization.texts import get_texts
|
||||
from app.states import AdminStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatting import format_period, format_price_kopeks, format_traffic
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -29,34 +30,6 @@ logger = structlog.get_logger(__name__)
|
||||
ITEMS_PER_PAGE = 10
|
||||
|
||||
|
||||
def _format_traffic(gb: int) -> str:
|
||||
"""Форматирует трафик."""
|
||||
if gb == 0:
|
||||
return 'Безлимит'
|
||||
return f'{gb} ГБ'
|
||||
|
||||
|
||||
def _format_price_kopeks(kopeks: int) -> str:
|
||||
"""Форматирует цену из копеек в рубли."""
|
||||
rubles = kopeks / 100
|
||||
if rubles == int(rubles):
|
||||
return f'{int(rubles)} ₽'
|
||||
return f'{rubles:.2f} ₽'
|
||||
|
||||
|
||||
def _format_period(days: int) -> str:
|
||||
"""Форматирует период."""
|
||||
if days == 1:
|
||||
return '1 день'
|
||||
if days < 5:
|
||||
return f'{days} дня'
|
||||
if days < 21 or days % 10 >= 5 or days % 10 == 0:
|
||||
return f'{days} дней'
|
||||
if days % 10 == 1:
|
||||
return f'{days} день'
|
||||
return f'{days} дня'
|
||||
|
||||
|
||||
def _parse_period_prices(text: str) -> dict[str, int]:
|
||||
"""
|
||||
Парсит строку с ценами периодов.
|
||||
@@ -94,7 +67,7 @@ def _format_period_prices_display(prices: dict[str, int]) -> str:
|
||||
for period_str in sorted(prices.keys(), key=int):
|
||||
period = int(period_str)
|
||||
price = prices[period_str]
|
||||
lines.append(f' • {_format_period(period)}: {_format_price_kopeks(price)}')
|
||||
lines.append(f' • {format_period(period)}: {format_price_kopeks(price)}')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
@@ -278,7 +251,7 @@ def _format_traffic_topup_packages(tariff: Tariff) -> str:
|
||||
lines = ['✅ Включено']
|
||||
for gb in sorted(packages.keys()):
|
||||
price = packages[gb]
|
||||
lines.append(f' • {gb} ГБ: {_format_price_kopeks(price)}')
|
||||
lines.append(f' • {gb} ГБ: {format_price_kopeks(price)}')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
@@ -288,7 +261,7 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st
|
||||
get_texts(language)
|
||||
|
||||
status = '✅ Активен' if tariff.is_active else '❌ Неактивен'
|
||||
traffic = _format_traffic(tariff.traffic_limit_gb)
|
||||
traffic = format_traffic(tariff.traffic_limit_gb)
|
||||
prices_display = _format_period_prices_display(tariff.period_prices or {})
|
||||
|
||||
# Форматируем список серверов
|
||||
@@ -314,7 +287,7 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st
|
||||
# Форматируем цену за устройство
|
||||
device_price = getattr(tariff, 'device_price_kopeks', None)
|
||||
if device_price is not None and device_price > 0:
|
||||
device_price_display = _format_price_kopeks(device_price) + '/мес'
|
||||
device_price_display = format_price_kopeks(device_price) + '/мес'
|
||||
else:
|
||||
device_price_display = 'Недоступно'
|
||||
|
||||
@@ -338,7 +311,7 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st
|
||||
|
||||
# Формируем блок цен в зависимости от типа тарифа
|
||||
if is_daily:
|
||||
price_block = f'<b>💰 Суточная цена:</b> {_format_price_kopeks(daily_price_kopeks)}/день'
|
||||
price_block = f'<b>💰 Суточная цена:</b> {format_price_kopeks(daily_price_kopeks)}/день'
|
||||
tariff_type = '🔄 Суточный'
|
||||
else:
|
||||
price_block = f'<b>Цены:</b>\n{prices_display}'
|
||||
@@ -619,7 +592,7 @@ async def start_edit_daily_price(
|
||||
await callback.message.edit_text(
|
||||
f'💰 <b>Редактирование суточной цены</b>\n\n'
|
||||
f'Тариф: {tariff.name}\n'
|
||||
f'Текущая цена: {_format_price_kopeks(current_price)}/день\n\n'
|
||||
f'Текущая цена: {format_price_kopeks(current_price)}/день\n\n'
|
||||
'Введите новую цену за день в рублях.\n'
|
||||
'Пример: <code>50</code> или <code>99.90</code>',
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
@@ -698,7 +671,7 @@ async def process_daily_price_input(
|
||||
subs_count = await get_tariff_subscriptions_count(db, tariff_id)
|
||||
|
||||
await message.answer(
|
||||
f'✅ Суточная цена установлена: {_format_price_kopeks(price_kopeks)}/день\n\n'
|
||||
f'✅ Суточная цена установлена: {format_price_kopeks(price_kopeks)}/день\n\n'
|
||||
+ format_tariff_info(tariff, db_user.language, subs_count),
|
||||
reply_markup=get_tariff_view_keyboard(tariff, db_user.language),
|
||||
parse_mode='HTML',
|
||||
@@ -793,7 +766,7 @@ async def process_tariff_traffic(
|
||||
await state.update_data(tariff_traffic=traffic)
|
||||
await state.set_state(AdminStates.creating_tariff_devices)
|
||||
|
||||
traffic_display = _format_traffic(traffic)
|
||||
traffic_display = format_traffic(traffic)
|
||||
|
||||
await message.answer(
|
||||
'📦 <b>Создание тарифа</b>\n\n'
|
||||
@@ -831,7 +804,7 @@ async def process_tariff_devices(
|
||||
await state.update_data(tariff_devices=devices)
|
||||
await state.set_state(AdminStates.creating_tariff_tier)
|
||||
|
||||
traffic_display = _format_traffic(data['tariff_traffic'])
|
||||
traffic_display = format_traffic(data['tariff_traffic'])
|
||||
|
||||
await message.answer(
|
||||
'📦 <b>Создание тарифа</b>\n\n'
|
||||
@@ -871,7 +844,7 @@ async def process_tariff_tier(
|
||||
data = await state.get_data()
|
||||
await state.update_data(tariff_tier=tier)
|
||||
|
||||
traffic_display = _format_traffic(data['tariff_traffic'])
|
||||
traffic_display = format_traffic(data['tariff_traffic'])
|
||||
|
||||
# Шаг 5/6: Выбор типа тарифа
|
||||
await message.answer(
|
||||
@@ -907,7 +880,7 @@ async def select_tariff_type_periodic(
|
||||
await state.update_data(tariff_is_daily=False)
|
||||
await state.set_state(AdminStates.creating_tariff_prices)
|
||||
|
||||
traffic_display = _format_traffic(data['tariff_traffic'])
|
||||
traffic_display = format_traffic(data['tariff_traffic'])
|
||||
|
||||
await callback.message.edit_text(
|
||||
'📦 <b>Создание тарифа</b>\n\n'
|
||||
@@ -945,7 +918,7 @@ async def select_tariff_type_daily(
|
||||
await state.update_data(tariff_is_daily=True)
|
||||
await state.set_state(AdminStates.editing_tariff_daily_price)
|
||||
|
||||
traffic_display = _format_traffic(data['tariff_traffic'])
|
||||
traffic_display = format_traffic(data['tariff_traffic'])
|
||||
|
||||
await callback.message.edit_text(
|
||||
'📦 <b>Создание суточного тарифа</b>\n\n'
|
||||
@@ -989,7 +962,7 @@ async def process_tariff_prices(
|
||||
data = await state.get_data()
|
||||
await state.update_data(tariff_prices=prices)
|
||||
|
||||
_format_traffic(data['tariff_traffic'])
|
||||
format_traffic(data['tariff_traffic'])
|
||||
_format_period_prices_display(prices)
|
||||
|
||||
# Создаем тариф
|
||||
@@ -1170,7 +1143,7 @@ async def start_edit_tariff_traffic(
|
||||
await state.set_state(AdminStates.editing_tariff_traffic)
|
||||
await state.update_data(tariff_id=tariff_id, language=db_user.language)
|
||||
|
||||
current_traffic = _format_traffic(tariff.traffic_limit_gb)
|
||||
current_traffic = format_traffic(tariff.traffic_limit_gb)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'📊 <b>Редактирование трафика</b>\n\n'
|
||||
@@ -1462,7 +1435,7 @@ async def start_edit_tariff_device_price(
|
||||
|
||||
device_price = getattr(tariff, 'device_price_kopeks', None)
|
||||
if device_price is not None and device_price > 0:
|
||||
current_price = _format_price_kopeks(device_price) + '/мес'
|
||||
current_price = format_price_kopeks(device_price) + '/мес'
|
||||
else:
|
||||
current_price = 'Недоступно (докупка устройств запрещена)'
|
||||
|
||||
@@ -1782,7 +1755,7 @@ async def start_edit_tariff_traffic_topup(
|
||||
status = '✅ Включено'
|
||||
if packages:
|
||||
packages_display = '\n'.join(
|
||||
f' • {gb} ГБ: {_format_price_kopeks(price)}' for gb, price in sorted(packages.items())
|
||||
f' • {gb} ГБ: {format_price_kopeks(price)}' for gb, price in sorted(packages.items())
|
||||
)
|
||||
else:
|
||||
packages_display = ' Пакеты не настроены'
|
||||
@@ -1871,7 +1844,7 @@ async def toggle_tariff_traffic_topup(
|
||||
status = '✅ Включено'
|
||||
if packages:
|
||||
packages_display = '\n'.join(
|
||||
f' • {gb} ГБ: {_format_price_kopeks(price)}' for gb, price in sorted(packages.items())
|
||||
f' • {gb} ГБ: {format_price_kopeks(price)}' for gb, price in sorted(packages.items())
|
||||
)
|
||||
else:
|
||||
packages_display = ' Пакеты не настроены'
|
||||
@@ -1951,7 +1924,7 @@ async def start_edit_traffic_topup_packages(
|
||||
|
||||
if packages:
|
||||
packages_display = '\n'.join(
|
||||
f' • {gb} ГБ: {_format_price_kopeks(price)}' for gb, price in sorted(packages.items())
|
||||
f' • {gb} ГБ: {format_price_kopeks(price)}' for gb, price in sorted(packages.items())
|
||||
)
|
||||
else:
|
||||
packages_display = ' Не настроены'
|
||||
@@ -2020,9 +1993,7 @@ async def process_edit_traffic_topup_packages(
|
||||
|
||||
# Показываем обновленное меню
|
||||
texts = get_texts(db_user.language)
|
||||
packages_display = '\n'.join(
|
||||
f' • {gb} ГБ: {_format_price_kopeks(price)}' for gb, price in sorted(packages.items())
|
||||
)
|
||||
packages_display = '\n'.join(f' • {gb} ГБ: {format_price_kopeks(price)}' for gb, price in sorted(packages.items()))
|
||||
max_topup_traffic = getattr(tariff, 'max_topup_traffic_gb', 0) or 0
|
||||
max_limit_display = f'{max_topup_traffic} ГБ' if max_topup_traffic > 0 else 'Без ограничений'
|
||||
|
||||
@@ -2136,7 +2107,7 @@ async def process_edit_max_topup_traffic(
|
||||
packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}
|
||||
if packages:
|
||||
packages_display = '\n'.join(
|
||||
f' • {gb} ГБ: {_format_price_kopeks(price)}' for gb, price in sorted(packages.items())
|
||||
f' • {gb} ГБ: {format_price_kopeks(price)}' for gb, price in sorted(packages.items())
|
||||
)
|
||||
else:
|
||||
packages_display = ' Пакеты не настроены'
|
||||
|
||||
+88
-20
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
@@ -868,7 +869,7 @@ async def _render_user_subscription_overview(callback: types.CallbackQuery, db:
|
||||
try:
|
||||
server = await get_server_squad_by_uuid(db, squad_uuid)
|
||||
if server:
|
||||
text += f'• {server.display_name}\n'
|
||||
text += f'• {html.escape(server.display_name)}\n'
|
||||
else:
|
||||
text += f'• {squad_uuid[:8]}... (неизвестный)\n'
|
||||
except Exception as e:
|
||||
@@ -1209,7 +1210,7 @@ async def show_user_management(callback: types.CallbackQuery, db_user: User, db:
|
||||
end_date=format_datetime(subscription.end_date),
|
||||
traffic=traffic_usage,
|
||||
devices=subscription.device_limit,
|
||||
countries=len(subscription.connected_squads),
|
||||
countries=len(subscription.connected_squads or []),
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -2711,7 +2712,7 @@ async def show_user_statistics(callback: types.CallbackQuery, db_user: User, db:
|
||||
text += f'• Статус: {sub_status}{sub_type}\n'
|
||||
text += f'• Трафик: {subscription.traffic_used_gb:.1f}/{subscription.traffic_limit_gb} ГБ\n'
|
||||
text += f'• Устройства: {subscription.device_limit}\n'
|
||||
text += f'• Стран: {len(subscription.connected_squads)}\n'
|
||||
text += f'• Стран: {len(subscription.connected_squads or [])}\n'
|
||||
else:
|
||||
text += '• Отсутствует\n'
|
||||
|
||||
@@ -4002,12 +4003,20 @@ async def _add_subscription_traffic(db: AsyncSession, user_id: int, gb: int, adm
|
||||
else:
|
||||
await add_subscription_traffic(db, subscription, gb)
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
await reactivate_subscription(db, subscription)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if subscription.status == 'active':
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if user and user.remnawave_uuid:
|
||||
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
|
||||
|
||||
traffic_text = 'безлимитный' if gb == 0 else f'{gb} ГБ'
|
||||
logger.info('Админ добавил трафик пользователю', admin_id=admin_id, traffic_text=traffic_text, user_id=user_id)
|
||||
return True
|
||||
@@ -4164,8 +4173,7 @@ async def _calculate_subscription_period_price(
|
||||
subscription_service: SubscriptionService | None = None,
|
||||
) -> int:
|
||||
"""Рассчитывает стоимость подписки для администратора с учётом всех параметров."""
|
||||
|
||||
service = subscription_service or SubscriptionService()
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
# Загружаем тариф для корректного расчёта в тарифном режиме
|
||||
if subscription.tariff_id:
|
||||
@@ -4174,13 +4182,13 @@ async def _calculate_subscription_period_price(
|
||||
except Exception as e:
|
||||
logger.warning('Не удалось загрузить тариф для расчёта цены', error=e)
|
||||
|
||||
return await service.calculate_renewal_price(
|
||||
subscription=subscription,
|
||||
period_days=period_days,
|
||||
db=db,
|
||||
pricing = await pricing_engine.calculate_renewal_price(
|
||||
db,
|
||||
subscription,
|
||||
period_days,
|
||||
user=target_user,
|
||||
promo_group=getattr(target_user, 'promo_group', None),
|
||||
)
|
||||
return pricing.final_total
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -4449,6 +4457,11 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
# TOCTOU protection: lock user row before pricing to prevent concurrent balance modifications
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
target_user = await lock_user_for_pricing(db, target_user.id)
|
||||
|
||||
try:
|
||||
price_kopeks = await _calculate_subscription_period_price(
|
||||
db,
|
||||
@@ -4577,11 +4590,10 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
|
||||
if hwid_limit is not None:
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
# Внешний сквад: синхронизируем из тарифа или сбрасываем
|
||||
# Внешний сквад: синхронизируем из тарифа (если задан)
|
||||
# Не отправляем null — RemnaWave API не принимает null для externalSquadUuid (A039)
|
||||
if ext_squad_uuid is not None:
|
||||
update_kwargs['external_squad_uuid'] = ext_squad_uuid
|
||||
else:
|
||||
update_kwargs['external_squad_uuid'] = None
|
||||
|
||||
remnawave_user = await api.update_user(**update_kwargs)
|
||||
else:
|
||||
@@ -4906,7 +4918,7 @@ async def admin_buy_tariff_execute(callback: types.CallbackQuery, db_user: User,
|
||||
user_id = int(parts[4])
|
||||
tariff_id = int(parts[5])
|
||||
period = int(parts[6])
|
||||
price_kopeks = int(parts[7])
|
||||
price_kopeks_from_callback = int(parts[7])
|
||||
|
||||
user_service = UserService()
|
||||
profile = await user_service.get_user_profile(db, user_id)
|
||||
@@ -4925,7 +4937,48 @@ async def admin_buy_tariff_execute(callback: types.CallbackQuery, db_user: User,
|
||||
await callback.answer('❌ Тариф недоступен', show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем баланс ещё раз
|
||||
# TOCTOU protection: lock user row before pricing to prevent concurrent balance modifications
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
target_user = await lock_user_for_pricing(db, target_user.id)
|
||||
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
|
||||
existing_subscription = await get_subscription_by_user_id(db, target_user.id)
|
||||
|
||||
# Recalculate price from locked state (callback data may be stale)
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
pricing_engine = PricingEngine()
|
||||
device_limit = None
|
||||
if existing_subscription and existing_subscription.tariff_id == tariff_id:
|
||||
device_limit = existing_subscription.device_limit
|
||||
|
||||
try:
|
||||
result = await pricing_engine.calculate_tariff_purchase_price(
|
||||
tariff,
|
||||
period,
|
||||
device_limit=device_limit,
|
||||
user=target_user,
|
||||
)
|
||||
price_kopeks = result.final_total
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка расчёта стоимости тарифа при списании средств админом для пользователя',
|
||||
telegram_id=target_user.telegram_id,
|
||||
e=e,
|
||||
)
|
||||
await callback.answer('❌ Не удалось рассчитать стоимость тарифа', show_alert=True)
|
||||
return
|
||||
|
||||
if price_kopeks_from_callback != price_kopeks:
|
||||
logger.info(
|
||||
'Стоимость тарифа для пользователя изменилась перед списанием',
|
||||
telegram_id=target_user.telegram_id,
|
||||
price_kopeks_from_callback=price_kopeks_from_callback,
|
||||
price_kopeks=price_kopeks,
|
||||
)
|
||||
|
||||
if target_user.balance_kopeks < price_kopeks:
|
||||
await callback.answer('❌ Недостаточно средств на балансе', show_alert=True)
|
||||
return
|
||||
@@ -4934,7 +4987,6 @@ async def admin_buy_tariff_execute(callback: types.CallbackQuery, db_user: User,
|
||||
from app.database.crud.subscription import (
|
||||
create_paid_subscription,
|
||||
extend_subscription,
|
||||
get_subscription_by_user_id,
|
||||
)
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
@@ -5309,9 +5361,24 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us
|
||||
try:
|
||||
old_tariff_id = subscription.tariff_id
|
||||
|
||||
# Обновляем параметры подписки в соответствии с тарифом
|
||||
# Preserve extra purchased devices above the old tariff's base limit
|
||||
extra_devices = 0
|
||||
if subscription.tariff_id:
|
||||
old_tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if old_tariff and old_tariff.device_limit:
|
||||
extra_devices = max(0, (subscription.device_limit or old_tariff.device_limit) - old_tariff.device_limit)
|
||||
|
||||
subscription.tariff_id = tariff.id
|
||||
subscription.device_limit = tariff.device_limit
|
||||
|
||||
new_base = tariff.device_limit or 1
|
||||
new_total = new_base + extra_devices
|
||||
effective_max = tariff.max_device_limit or (
|
||||
settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
|
||||
)
|
||||
if effective_max and new_total > effective_max:
|
||||
new_total = effective_max
|
||||
subscription.device_limit = new_total
|
||||
|
||||
subscription.traffic_limit_gb = tariff.traffic_limit_gb
|
||||
subscription.connected_squads = tariff.allowed_squads or []
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
@@ -5350,6 +5417,7 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
|
||||
reset_reason='смена тарифа (админ)',
|
||||
sync_squads=True,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -5364,7 +5432,7 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us
|
||||
await callback.message.edit_text(
|
||||
f'✅ <b>Тариф успешно изменен</b>\n\n'
|
||||
f'Новый тариф: <b>{tariff.name}</b>\n'
|
||||
f'• Устройства: {tariff.device_limit}\n'
|
||||
f'• Устройства: {subscription.device_limit}\n'
|
||||
f'• Трафик: {"♾️" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ"}\n'
|
||||
f'• Серверы: {len(tariff.allowed_squads) if tariff.allowed_squads else 0}',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -167,6 +167,7 @@ async def process_cloudpayments_payment_amount(
|
||||
'AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount:.0f}₽',
|
||||
).format(min_amount=min_rub),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -177,6 +178,7 @@ async def process_cloudpayments_payment_amount(
|
||||
'AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount:,.0f}₽',
|
||||
).format(max_amount=max_rub),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -195,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)
|
||||
|
||||
@@ -290,6 +292,7 @@ async def process_cloudpayments_amount(
|
||||
'AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount:.0f}₽',
|
||||
).format(min_amount=min_rub),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
@@ -301,6 +304,7 @@ async def process_cloudpayments_amount(
|
||||
'AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount:,.0f}₽',
|
||||
).format(max_amount=max_rub),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
@@ -371,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)
|
||||
@@ -133,11 +110,13 @@ async def process_cryptobot_payment_amount(
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
if amount_rubles < 100:
|
||||
await message.answer('Минимальная сумма пополнения: 100 ₽')
|
||||
await message.answer('Минимальная сумма пополнения: 100 ₽', reply_markup=get_back_keyboard(db_user.language))
|
||||
return
|
||||
|
||||
if amount_rubles > 100000:
|
||||
await message.answer('Максимальная сумма пополнения: 100,000 ₽')
|
||||
await message.answer(
|
||||
'Максимальная сумма пополнения: 100,000 ₽', reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -154,11 +133,15 @@ async def process_cryptobot_payment_amount(
|
||||
amount_usd = round(amount_usd, 2)
|
||||
|
||||
if amount_usd < 1:
|
||||
await message.answer('❌ Минимальная сумма для оплаты в USD: 1.00 USD')
|
||||
await message.answer(
|
||||
'❌ Минимальная сумма для оплаты в USD: 1.00 USD', reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
if amount_usd > 1000:
|
||||
await message.answer('❌ Максимальная сумма для оплаты в USD: 1,000 USD')
|
||||
await message.answer(
|
||||
'❌ Максимальная сумма для оплаты в USD: 1,000 USD', reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
|
||||
@@ -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)
|
||||
@@ -186,6 +186,7 @@ async def process_freekassa_payment_amount(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}₽',
|
||||
).format(min_amount=min_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
@@ -196,6 +197,7 @@ async def process_freekassa_payment_amount(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}₽',
|
||||
).format(max_amount=max_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
@@ -368,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,
|
||||
@@ -129,11 +122,13 @@ async def process_heleket_payment_amount(
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
if amount_rubles < 100:
|
||||
await message.answer('Минимальная сумма пополнения: 100 ₽')
|
||||
await message.answer('Минимальная сумма пополнения: 100 ₽', reply_markup=get_back_keyboard(db_user.language))
|
||||
return
|
||||
|
||||
if amount_rubles > 100000:
|
||||
await message.answer('Максимальная сумма пополнения: 100,000 ₽')
|
||||
await message.answer(
|
||||
'Максимальная сумма пополнения: 100,000 ₽', reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.kassa_ai_service import KASSA_AI_SUB_METHODS
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
@@ -18,23 +19,55 @@ from app.utils.decorators import error_handler
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# --- Enabled check + display name lookup by payment method ---
|
||||
|
||||
_KASSA_AI_METHOD_CONFIG = {
|
||||
'kassa_ai': {
|
||||
'is_enabled': settings.is_kassa_ai_enabled,
|
||||
'display_name': settings.get_kassa_ai_display_name,
|
||||
'unavailable_text': 'KassaAI временно недоступен',
|
||||
},
|
||||
'kassa_ai_sbp': {
|
||||
'is_enabled': settings.is_kassa_ai_sbp_enabled,
|
||||
'display_name': settings.get_kassa_ai_sbp_display_name,
|
||||
'unavailable_text': 'KassaAI СБП временно недоступен',
|
||||
},
|
||||
'kassa_ai_card': {
|
||||
'is_enabled': settings.is_kassa_ai_card_enabled,
|
||||
'display_name': settings.get_kassa_ai_card_display_name,
|
||||
'unavailable_text': 'KassaAI Карта временно недоступна',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _check_topup_restriction(callback: types.CallbackQuery, db_user: User) -> bool:
|
||||
"""Check if user has topup restriction. Returns True if restricted (handler should abort)."""
|
||||
if not getattr(db_user, 'restriction_topup', False):
|
||||
return False
|
||||
texts = get_texts(db_user.language)
|
||||
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 True
|
||||
|
||||
|
||||
async def _create_kassa_ai_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
payment_method: str = 'kassa_ai',
|
||||
):
|
||||
"""
|
||||
Common logic for creating KassaAI payment and sending response.
|
||||
|
||||
Args:
|
||||
message_or_callback: Either a Message or CallbackQuery object
|
||||
db_user: User object
|
||||
db: Database session
|
||||
amount_kopeks: Amount in kopeks
|
||||
edit_message: Whether to edit existing message or send new one
|
||||
"""
|
||||
"""Common logic for creating KassaAI payment and sending response."""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
@@ -46,6 +79,9 @@ async def _create_kassa_ai_payment_and_respond(
|
||||
description='Пополнение баланса',
|
||||
)
|
||||
|
||||
sub = KASSA_AI_SUB_METHODS.get(payment_method)
|
||||
payment_system_id = sub['payment_system_id'] if sub else settings.KASSA_AI_PAYMENT_SYSTEM_ID
|
||||
|
||||
result = await payment_service.create_kassa_ai_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
@@ -53,6 +89,7 @@ async def _create_kassa_ai_payment_and_respond(
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
payment_system_id=payment_system_id,
|
||||
)
|
||||
|
||||
if not result:
|
||||
@@ -74,7 +111,8 @@ async def _create_kassa_ai_payment_and_respond(
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
display_name = settings.get_kassa_ai_display_name()
|
||||
cfg = _KASSA_AI_METHOD_CONFIG.get(payment_method, _KASSA_AI_METHOD_CONFIG['kassa_ai'])
|
||||
display_name = cfg['display_name']()
|
||||
|
||||
# Create keyboard with payment button
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
@@ -128,10 +166,9 @@ async def process_kassa_ai_payment_amount(
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
payment_method: str = 'kassa_ai',
|
||||
):
|
||||
"""
|
||||
Process payment amount directly (called from quick_amount handlers).
|
||||
"""
|
||||
"""Process payment amount directly (called from custom_amount handlers)."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
@@ -161,6 +198,7 @@ async def process_kassa_ai_payment_amount(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}₽',
|
||||
).format(min_amount=min_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
@@ -171,6 +209,7 @@ async def process_kassa_ai_payment_amount(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}₽',
|
||||
).format(max_amount=max_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
@@ -183,53 +222,40 @@ async def process_kassa_ai_payment_amount(
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_kassa_ai_topup(
|
||||
# --- Generic start/quick-amount implementations ---
|
||||
|
||||
|
||||
async def _start_kassa_ai_sub_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
payment_method: str,
|
||||
):
|
||||
"""
|
||||
Start KassaAI top-up process - ask for amount.
|
||||
"""
|
||||
"""Generic start topup handler for any KassaAI sub-method."""
|
||||
cfg = _KASSA_AI_METHOD_CONFIG[payment_method]
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
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')])
|
||||
if not cfg['is_enabled']():
|
||||
await callback.answer(texts.t('KASSA_AI_NOT_AVAILABLE', cfg['unavailable_text']), show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
if await _check_topup_restriction(callback, db_user):
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method='kassa_ai')
|
||||
await state.update_data(payment_method=payment_method)
|
||||
|
||||
min_amount = settings.KASSA_AI_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.KASSA_AI_MAX_AMOUNT_KOPEKS // 100
|
||||
display_name = settings.get_kassa_ai_display_name()
|
||||
display_name = cfg['display_name']()
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '◀️ Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
]
|
||||
inline_keyboard=[[InlineKeyboardButton(text=texts.t('BACK_BUTTON', '◀️ Назад'), callback_data='menu_balance')]]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -249,6 +275,20 @@ async def start_kassa_ai_topup(
|
||||
)
|
||||
|
||||
|
||||
# --- Public handler functions (registered in main.py) ---
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_kassa_ai_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Start KassaAI top-up process - ask for amount."""
|
||||
await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_kassa_ai_custom_amount(
|
||||
message: types.Message,
|
||||
@@ -256,11 +296,10 @@ async def process_kassa_ai_custom_amount(
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process custom amount input for KassaAI payment.
|
||||
"""
|
||||
"""Process custom amount input for KassaAI payment."""
|
||||
data = await state.get_data()
|
||||
if data.get('payment_method') != 'kassa_ai':
|
||||
pm = data.get('payment_method', 'kassa_ai')
|
||||
if pm not in _KASSA_AI_METHOD_CONFIG:
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
@@ -285,82 +324,27 @@ async def process_kassa_ai_custom_amount(
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
state=state,
|
||||
payment_method=pm,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_kassa_ai_quick_amount(
|
||||
async def start_kassa_ai_sbp_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process quick amount selection for KassaAI payment.
|
||||
Called when user clicks a predefined amount button.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
"""Start KassaAI SBP top-up process."""
|
||||
await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai_sbp')
|
||||
|
||||
if not settings.is_kassa_ai_enabled():
|
||||
await callback.answer(
|
||||
texts.t('KASSA_AI_NOT_AVAILABLE', 'KassaAI временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Extract amount from callback data: topup_amount|kassa_ai|{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.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,
|
||||
)
|
||||
@error_handler
|
||||
async def start_kassa_ai_card_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Start KassaAI Card top-up process."""
|
||||
await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai_card')
|
||||
|
||||
+52
-341
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.crud.transaction import get_user_transactions
|
||||
from app.database.models import TransactionType, User
|
||||
from app.handlers.subscription.autopay import handle_confirm_unlink, handle_saved_cards_list, handle_unlink_card
|
||||
from app.keyboards.inline import (
|
||||
get_back_keyboard,
|
||||
get_balance_keyboard,
|
||||
@@ -17,7 +18,6 @@ from app.keyboards.inline import (
|
||||
from app.localization.texts import get_texts
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
from app.utils.price_display import calculate_user_price
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -131,11 +131,20 @@ async def route_payment_by_method(
|
||||
)
|
||||
return True
|
||||
|
||||
if payment_method == 'kassa_ai':
|
||||
if payment_method in ('kassa_ai', 'kassa_ai_sbp', 'kassa_ai_card'):
|
||||
from .kassa_ai import process_kassa_ai_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_kassa_ai_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
await process_kassa_ai_payment_amount(
|
||||
message, db_user, db, amount_kopeks, state, payment_method=payment_method
|
||||
)
|
||||
return True
|
||||
|
||||
if payment_method == 'severpay':
|
||||
from .severpay import process_severpay_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_severpay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == 'riopay':
|
||||
@@ -148,159 +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.
|
||||
|
||||
Includes full subscription cost: base period price + devices + servers + traffic.
|
||||
|
||||
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.config import PERIOD_PRICES
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.utils.pricing_utils import apply_percentage_discount, calculate_months_from_days
|
||||
|
||||
texts = get_texts(language)
|
||||
|
||||
tariff = None
|
||||
tariff_prices = None
|
||||
tariff_periods = None
|
||||
devices_price_per_month = 0
|
||||
servers_per_month_prices: list[int] = []
|
||||
traffic_price_per_month = 0
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
# В режиме тарифов получаем цены из тарифа пользователя
|
||||
if settings.is_tariffs_mode() and subscription and subscription.tariff_id:
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff and tariff.period_prices:
|
||||
tariff_prices = {int(k): v for k, v in tariff.period_prices.items()}
|
||||
tariff_periods = sorted(tariff_prices.keys())
|
||||
|
||||
# Получаем стоимость устройств, серверов и трафика из подписки
|
||||
if subscription and not subscription.is_trial:
|
||||
# Устройства: в режиме тарифов используем цену и базовый лимит из тарифа
|
||||
if settings.is_tariffs_mode() and tariff and tariff_prices:
|
||||
tariff_device_price = getattr(tariff, 'device_price_kopeks', None)
|
||||
if tariff_device_price and tariff_device_price > 0:
|
||||
device_unit_price = tariff_device_price
|
||||
base_device_limit = tariff.device_limit or 0
|
||||
else:
|
||||
device_unit_price = settings.PRICE_PER_DEVICE
|
||||
base_device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
device_unit_price = settings.PRICE_PER_DEVICE
|
||||
base_device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
device_limit = subscription.device_limit or base_device_limit
|
||||
additional_devices = max(0, device_limit - base_device_limit)
|
||||
if additional_devices > 0:
|
||||
devices_price_per_month = additional_devices * device_unit_price
|
||||
|
||||
# Серверы
|
||||
connected_squads = subscription.connected_squads or []
|
||||
if connected_squads:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
_, servers_per_month_prices = await subscription_service.get_countries_price_by_uuids(
|
||||
connected_squads, db, promo_group_id=user.promo_group_id
|
||||
)
|
||||
|
||||
# Трафик
|
||||
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
|
||||
buttons = []
|
||||
|
||||
# Используем периоды тарифа в режиме тарифов, иначе стандартные
|
||||
if tariff_periods:
|
||||
periods = tariff_periods[:6]
|
||||
else:
|
||||
periods = settings.get_available_subscription_periods()[:6]
|
||||
|
||||
for period in periods:
|
||||
# Получаем цену из тарифа или из PERIOD_PRICES
|
||||
if tariff_prices and period in tariff_prices:
|
||||
base_price_kopeks = tariff_prices[period]
|
||||
else:
|
||||
base_price_kopeks = PERIOD_PRICES.get(period, 0)
|
||||
|
||||
if base_price_kopeks > 0:
|
||||
# Базовая цена периода с промо-скидками
|
||||
price_info = calculate_user_price(user, base_price_kopeks, period, 'period')
|
||||
|
||||
months = calculate_months_from_days(period)
|
||||
|
||||
# Стоимость устройств со скидкой
|
||||
devices_addon = 0
|
||||
if devices_price_per_month > 0:
|
||||
devices_discount = user.get_promo_discount('devices', period)
|
||||
devices_discounted, _ = apply_percentage_discount(devices_price_per_month, devices_discount)
|
||||
devices_addon = devices_discounted * months
|
||||
|
||||
# Стоимость серверов со скидкой
|
||||
servers_addon = 0
|
||||
if servers_per_month_prices:
|
||||
servers_discount = user.get_promo_discount('servers', period)
|
||||
for server_price in servers_per_month_prices:
|
||||
discounted, _ = apply_percentage_discount(server_price, servers_discount)
|
||||
servers_addon += discounted
|
||||
servers_addon *= months
|
||||
|
||||
# Стоимость трафика со скидкой
|
||||
traffic_addon = 0
|
||||
if traffic_price_per_month > 0:
|
||||
traffic_discount = user.get_promo_discount('traffic', period)
|
||||
traffic_discounted, _ = apply_percentage_discount(traffic_price_per_month, traffic_discount)
|
||||
traffic_addon = traffic_discounted * months
|
||||
|
||||
total_price = price_info.final_price + devices_addon + servers_addon + traffic_addon
|
||||
callback_data = f'quick_amount_{total_price}'
|
||||
|
||||
period_label = f'{period} дней'
|
||||
|
||||
# Скидка считается от полной базовой стоимости (период + аддоны без скидок)
|
||||
total_base = (
|
||||
base_price_kopeks
|
||||
+ (devices_price_per_month + sum(servers_per_month_prices) + traffic_price_per_month) * months
|
||||
)
|
||||
has_discount = total_base > total_price and total_base > 0
|
||||
|
||||
if has_discount:
|
||||
discount_pct = round((total_base - total_price) * 100 / total_base)
|
||||
if discount_pct > 0:
|
||||
button_text = (
|
||||
f'{texts.format_price(total_base)} ➜ '
|
||||
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))
|
||||
|
||||
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):
|
||||
# Проверяем, доступно ли сообщение
|
||||
@@ -404,10 +260,7 @@ async def handle_balance_history_pagination(callback: types.CallbackQuery, db_us
|
||||
@error_handler
|
||||
async def show_payment_methods(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.payment_utils import get_payment_methods_text
|
||||
from app.utils.pricing_utils import apply_percentage_discount, calculate_months_from_days
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -430,141 +283,22 @@ async def show_payment_methods(callback: types.CallbackQuery, db_user: User, db:
|
||||
|
||||
payment_text = get_payment_methods_text(db_user.language)
|
||||
|
||||
# Добавляем информацию о текущем тарифе пользователя
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
tariff_info = ''
|
||||
if subscription and not subscription.is_trial:
|
||||
# Рассчитываем приблизительную стоимость продления на 30 дней
|
||||
duration_days = 30 # Берем для примера 30 дней
|
||||
current_traffic = subscription.traffic_limit_gb
|
||||
current_connected_squads = subscription.connected_squads or []
|
||||
current_device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
# Проверяем сохранённую корзину для автоподстановки суммы пополнения
|
||||
amount_kopeks = 0
|
||||
try:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
|
||||
try:
|
||||
# Получаем цены для текущих параметров
|
||||
from app.config import PERIOD_PRICES
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
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
|
||||
|
||||
# В режиме тарифов берём цену из тарифа пользователя
|
||||
tariff = None
|
||||
tariff_price_found = False
|
||||
base_price_original = 0
|
||||
if settings.is_tariffs_mode() and subscription.tariff_id:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff and tariff.period_prices:
|
||||
base_price_original = tariff.period_prices.get(str(duration_days), 0)
|
||||
if base_price_original > 0:
|
||||
tariff_price_found = True
|
||||
full_text = payment_text
|
||||
|
||||
# Если не нашли в тарифе - используем PERIOD_PRICES
|
||||
if base_price_original <= 0:
|
||||
base_price_original = PERIOD_PRICES.get(duration_days, 0)
|
||||
|
||||
if tariff_price_found:
|
||||
# Тарифный режим: серверы и трафик включены в цену тарифа.
|
||||
# Порядок: база + устройства → скидка на полную сумму (как в calculate_renewal_price).
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
original_price = base_price_original
|
||||
|
||||
tariff_device_limit = tariff.device_limit if tariff.device_limit is not None else 0
|
||||
device_limit = (
|
||||
subscription.device_limit if subscription.device_limit is not None else tariff_device_limit
|
||||
)
|
||||
extra_devices = max(0, device_limit - tariff_device_limit)
|
||||
device_price_per_unit = (
|
||||
tariff.device_price_kopeks
|
||||
if tariff and tariff.device_price_kopeks is not None
|
||||
else settings.PRICE_PER_DEVICE
|
||||
)
|
||||
months_in_period = calculate_months_from_days(duration_days)
|
||||
devices_price = extra_devices * device_price_per_unit * months_in_period
|
||||
original_price += devices_price
|
||||
|
||||
# Скидка промогруппы на полную сумму (база + устройства)
|
||||
period_discount_percent = db_user.get_promo_discount('period', duration_days)
|
||||
discount_total = original_price * period_discount_percent // 100
|
||||
total_price = original_price - discount_total
|
||||
|
||||
# Promo-offer скидка (временная)
|
||||
promo_offer_percent = get_user_active_promo_discount_percent(db_user)
|
||||
if promo_offer_percent > 0:
|
||||
promo_offer_discount = total_price * promo_offer_percent // 100
|
||||
total_price = total_price - promo_offer_discount
|
||||
else:
|
||||
# Классический режим: серверы + трафик + устройства считаются отдельно
|
||||
period_discount_percent = db_user.get_promo_discount('period', duration_days)
|
||||
base_price, base_discount_total = apply_percentage_discount(
|
||||
base_price_original,
|
||||
period_discount_percent,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
(
|
||||
servers_price_per_month,
|
||||
per_server_monthly_prices,
|
||||
) = await subscription_service.get_countries_price_by_uuids(
|
||||
current_connected_squads,
|
||||
db,
|
||||
promo_group_id=db_user.promo_group_id,
|
||||
)
|
||||
servers_discount_percent = db_user.get_promo_discount('servers', duration_days)
|
||||
total_servers_price = 0
|
||||
for server_price in per_server_monthly_prices:
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
server_price,
|
||||
servers_discount_percent,
|
||||
)
|
||||
total_servers_price += discounted_per_month
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(current_traffic)
|
||||
traffic_discount_percent = db_user.get_promo_discount('traffic', duration_days)
|
||||
traffic_discounted_per_month, traffic_discount_per_month = apply_percentage_discount(
|
||||
traffic_price_per_month,
|
||||
traffic_discount_percent,
|
||||
)
|
||||
|
||||
additional_devices = max(0, (current_device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = db_user.get_promo_discount('devices', duration_days)
|
||||
devices_discounted_per_month, devices_discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
|
||||
months_in_period = calculate_months_from_days(duration_days)
|
||||
total_price = (
|
||||
base_price
|
||||
+ total_servers_price * months_in_period
|
||||
+ traffic_discounted_per_month * months_in_period
|
||||
+ devices_discounted_per_month * months_in_period
|
||||
)
|
||||
|
||||
traffic_value = current_traffic or 0
|
||||
if traffic_value <= 0:
|
||||
traffic_display = texts.t('TRAFFIC_UNLIMITED_SHORT', 'Безлимит')
|
||||
else:
|
||||
traffic_display = texts.format_traffic(traffic_value)
|
||||
|
||||
current_tariff_desc = (
|
||||
f'📱 Подписка: {len(current_connected_squads)} серверов, '
|
||||
f'{traffic_display}, {current_device_limit} устр.'
|
||||
)
|
||||
estimated_price_info = (
|
||||
f'💰 Стоимость продления (примерно): {texts.format_price(total_price)} за {duration_days} дней'
|
||||
)
|
||||
|
||||
tariff_info = f'\n\n📋 <b>Ваш текущий тариф:</b>\n{current_tariff_desc}\n{estimated_price_info}'
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
'Не удалось рассчитать стоимость текущей подписки для пользователя', db_user_id=db_user.id, error=e
|
||||
)
|
||||
tariff_info = ''
|
||||
|
||||
full_text = payment_text + tariff_info
|
||||
|
||||
keyboard = get_payment_methods_keyboard(0, db_user.language)
|
||||
keyboard = get_payment_methods_keyboard(amount_kopeks, db_user.language)
|
||||
|
||||
# Если сообщение недоступно, отправляем новое
|
||||
if isinstance(callback.message, InaccessibleMessage):
|
||||
@@ -734,11 +468,13 @@ async def process_topup_amount(message: types.Message, db_user: User, state: FSM
|
||||
amount_rubles = float(amount_text.replace(',', '.'))
|
||||
|
||||
if amount_rubles < 1:
|
||||
await message.answer('Минимальная сумма пополнения: 1 ₽')
|
||||
await message.answer('Минимальная сумма пополнения: 1 ₽', reply_markup=get_back_keyboard(db_user.language))
|
||||
return
|
||||
|
||||
if amount_rubles > 50000:
|
||||
await message.answer('Максимальная сумма пополнения: 50,000 ₽')
|
||||
await message.answer(
|
||||
'Максимальная сумма пополнения: 50,000 ₽', reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
amount_kopeks = int(amount_rubles * 100)
|
||||
@@ -748,13 +484,17 @@ async def process_topup_amount(message: types.Message, db_user: User, state: FSM
|
||||
if payment_method in ['yookassa', 'yookassa_sbp']:
|
||||
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
|
||||
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f'❌ Минимальная сумма для оплаты через YooKassa: {min_rubles:.0f} ₽')
|
||||
await message.answer(
|
||||
f'❌ Минимальная сумма для оплаты через YooKassa: {min_rubles:.0f} ₽',
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
|
||||
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(
|
||||
f'❌ Максимальная сумма для оплаты через YooKassa: {max_rubles:,.0f} ₽'.replace(',', ' ')
|
||||
f'❌ Максимальная сумма для оплаты через YooKassa: {max_rubles:,.0f} ₽'.replace(',', ' '),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -804,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,
|
||||
@@ -961,36 +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_quick_amount, start_kassa_ai_topup
|
||||
from .kassa_ai import (
|
||||
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(start_kassa_ai_card_topup, F.data == 'topup_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 start_severpay_topup
|
||||
|
||||
dp.callback_query.register(start_severpay_topup, F.data == 'topup_severpay')
|
||||
|
||||
from .mulenpay import check_mulenpay_payment_status
|
||||
|
||||
@@ -1010,7 +720,8 @@ 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')
|
||||
dp.callback_query.register(handle_unlink_card, F.data.startswith('unlink_card_'))
|
||||
dp.callback_query.register(handle_confirm_unlink, F.data.startswith('confirm_unlink_'))
|
||||
|
||||
@@ -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,
|
||||
@@ -124,13 +117,15 @@ async def process_mulenpay_payment_amount(
|
||||
|
||||
if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
f'Минимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MIN_AMOUNT_KOPEKS)}'
|
||||
f'Минимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MIN_AMOUNT_KOPEKS)}',
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
f'Максимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MAX_AMOUNT_KOPEKS)}'
|
||||
f'Максимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MAX_AMOUNT_KOPEKS)}',
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -359,12 +352,18 @@ async def process_pal24_payment_amount(
|
||||
|
||||
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
|
||||
min_rubles = settings.PAL24_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f'❌ Минимальная сумма для оплаты через PayPalych: {min_rubles:.0f} ₽')
|
||||
await message.answer(
|
||||
f'❌ Минимальная сумма для оплаты через PayPalych: {min_rubles:.0f} ₽',
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
|
||||
max_rubles = settings.PAL24_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f'❌ Максимальная сумма для оплаты через PayPalych: {max_rubles:,.0f} ₽'.replace(',', ' '))
|
||||
await message.answer(
|
||||
f'❌ Максимальная сумма для оплаты через PayPalych: {max_rubles:,.0f} ₽'.replace(',', ' '),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
available_methods = _get_available_pal24_methods()
|
||||
|
||||
@@ -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,
|
||||
@@ -250,7 +243,8 @@ async def process_platega_payment_amount(
|
||||
texts.t(
|
||||
'PLATEGA_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма для оплаты через Platega: {amount}',
|
||||
).format(amount=settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS))
|
||||
).format(amount=settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS)),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -259,7 +253,8 @@ async def process_platega_payment_amount(
|
||||
texts.t(
|
||||
'PLATEGA_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма для оплаты через Platega: {amount}',
|
||||
).format(amount=settings.format_price(settings.PLATEGA_MAX_AMOUNT_KOPEKS))
|
||||
).format(amount=settings.format_price(settings.PLATEGA_MAX_AMOUNT_KOPEKS)),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -161,6 +161,7 @@ async def process_riopay_payment_amount(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}₽',
|
||||
).format(min_amount=min_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
@@ -171,6 +172,7 @@ async def process_riopay_payment_amount(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}₽',
|
||||
).format(max_amount=max_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
@@ -280,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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Handler for SeverPay balance top-up."""
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
|
||||
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
|
||||
if not getattr(db_user, 'restriction_topup', False):
|
||||
return None
|
||||
|
||||
keyboard = []
|
||||
support_url = settings.get_support_contact_url()
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
async def _create_severpay_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
):
|
||||
"""
|
||||
Common logic for creating SeverPay payment and sending response.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
# Create payment
|
||||
payment_service = PaymentService()
|
||||
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description='Пополнение баланса',
|
||||
)
|
||||
|
||||
result = await payment_service.create_severpay_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not result:
|
||||
error_text = texts.t(
|
||||
'PAYMENT_CREATE_ERROR',
|
||||
'Не удалось создать платёж. Попробуйте позже.',
|
||||
)
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
error_text,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
display_name = settings.get_severpay_display_name()
|
||||
|
||||
# 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',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
response_text = texts.t(
|
||||
'SEVERPAY_PAYMENT_CREATED',
|
||||
'💳 <b>Оплата через {name}</b>\n\n'
|
||||
'Сумма: <b>{amount}₽</b>\n\n'
|
||||
'Нажмите кнопку ниже для оплаты.\n'
|
||||
'После успешной оплаты баланс будет пополнен автоматически.',
|
||||
).format(name=display_name, amount=f'{amount_rub:.2f}')
|
||||
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
logger.info('SeverPay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_severpay_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
await message.answer(
|
||||
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.SEVERPAY_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.SEVERPAY_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}₽',
|
||||
).format(min_amount=min_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}₽',
|
||||
).format(max_amount=max_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
await _create_severpay_payment_and_respond(
|
||||
message_or_callback=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_severpay_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Start SeverPay top-up process - ask for amount.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
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
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method='severpay')
|
||||
|
||||
min_amount = settings.SEVERPAY_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.SEVERPAY_MAX_AMOUNT_KOPEKS // 100
|
||||
display_name = settings.get_severpay_display_name()
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '◀️ Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'SEVERPAY_ENTER_AMOUNT',
|
||||
'💳 <b>Пополнение через {name}</b>\n\n'
|
||||
'Введите сумму пополнения в рублях.\n\n'
|
||||
'Минимум: {min_amount}₽\n'
|
||||
'Максимум: {max_amount}₽',
|
||||
).format(
|
||||
name=display_name,
|
||||
min_amount=min_amount,
|
||||
max_amount=f'{max_amount:,}'.replace(',', ' '),
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
@@ -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,
|
||||
@@ -120,7 +113,8 @@ async def process_wata_payment_amount(
|
||||
texts.t(
|
||||
'WATA_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {amount}',
|
||||
).format(amount=settings.format_price(settings.WATA_MIN_AMOUNT_KOPEKS))
|
||||
).format(amount=settings.format_price(settings.WATA_MIN_AMOUNT_KOPEKS)),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -129,7 +123,8 @@ async def process_wata_payment_amount(
|
||||
texts.t(
|
||||
'WATA_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {amount}',
|
||||
).format(amount=settings.format_price(settings.WATA_MAX_AMOUNT_KOPEKS))
|
||||
).format(amount=settings.format_price(settings.WATA_MAX_AMOUNT_KOPEKS)),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -178,12 +142,18 @@ async def process_yookassa_payment_amount(
|
||||
|
||||
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
|
||||
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f'❌ Минимальная сумма для оплаты картой: {min_rubles:.0f} ₽')
|
||||
await message.answer(
|
||||
f'❌ Минимальная сумма для оплаты картой: {min_rubles:.0f} ₽',
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
|
||||
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f'❌ Максимальная сумма для оплаты картой: {max_rubles:,.0f} ₽'.replace(',', ' '))
|
||||
await message.answer(
|
||||
f'❌ Максимальная сумма для оплаты картой: {max_rubles:,.0f} ₽'.replace(',', ' '),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -327,12 +297,18 @@ async def process_yookassa_sbp_payment_amount(
|
||||
|
||||
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
|
||||
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f'❌ Минимальная сумма для оплаты через СБП: {min_rubles:.0f} ₽')
|
||||
await message.answer(
|
||||
f'❌ Минимальная сумма для оплаты через СБП: {min_rubles:.0f} ₽',
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
|
||||
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f'❌ Максимальная сумма для оплаты через СБП: {max_rubles:,.0f} ₽'.replace(',', ' '))
|
||||
await message.answer(
|
||||
f'❌ Максимальная сумма для оплаты через СБП: {max_rubles:,.0f} ₽'.replace(',', ' '),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
|
||||
+40
-16
@@ -1089,6 +1089,9 @@ def _get_subscription_status(user: User, texts, is_daily_tariff: bool = False) -
|
||||
if actual_status == 'disabled':
|
||||
return texts.t('SUB_STATUS_DISABLED', '⚫ Отключена')
|
||||
|
||||
if actual_status == 'limited':
|
||||
return texts.t('SUB_STATUS_LIMITED', '⚠️ Трафик исчерпан')
|
||||
|
||||
if actual_status == 'expired':
|
||||
return texts.t(
|
||||
'SUB_STATUS_EXPIRED',
|
||||
@@ -1246,7 +1249,7 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
from app.database.crud.server_squad import get_available_server_squads, get_server_ids_by_uuids
|
||||
from app.database.crud.server_squad import get_available_server_squads
|
||||
from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
@@ -1284,7 +1287,9 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
|
||||
if not connected_squads and available_servers:
|
||||
connected_squads = [available_servers[0].squad_uuid]
|
||||
|
||||
server_ids = await get_server_ids_by_uuids(db, connected_squads) if connected_squads else []
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||||
|
||||
balance = db_user.balance_kopeks
|
||||
available_periods = sorted(settings.get_available_subscription_periods(), reverse=True)
|
||||
@@ -1294,35 +1299,50 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
|
||||
# Найти максимальный период <= баланса
|
||||
best_period = None
|
||||
best_price = 0
|
||||
best_pricing = None # Cache pricing result for reuse in finalize()
|
||||
|
||||
# PricingEngine — единый расчёт для всех поверхностей (и продление, и новая подписка).
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
# Для продления используем тот же сервис, что и при реальном списании,
|
||||
# чтобы сумма проверки совпадала с суммой списания.
|
||||
renewal_service = SubscriptionRenewalService() if subscription else None
|
||||
|
||||
try:
|
||||
for period in available_periods:
|
||||
if subscription and renewal_service:
|
||||
pricing = await renewal_service.calculate_pricing(db, db_user, subscription, period)
|
||||
price = pricing.final_total
|
||||
if subscription:
|
||||
pricing_result = await pricing_engine.calculate_renewal_price(db, subscription, period, user=db_user)
|
||||
price = pricing_result.final_total
|
||||
else:
|
||||
price, _ = await subscription_service.calculate_subscription_price_with_months(
|
||||
period, traffic_limit_gb, server_ids, device_limit, db, user=db_user
|
||||
new_pricing = await pricing_engine.calculate_classic_new_subscription_price(
|
||||
db,
|
||||
period,
|
||||
connected_squads,
|
||||
traffic_limit_gb,
|
||||
device_limit,
|
||||
user=db_user,
|
||||
)
|
||||
price = new_pricing.final_total
|
||||
if price <= balance:
|
||||
best_period = period
|
||||
best_price = price
|
||||
best_pricing = pricing_result if subscription else None
|
||||
break
|
||||
|
||||
if not best_period:
|
||||
# Показать сколько не хватает для минимального периода
|
||||
min_period = min(available_periods) if available_periods else 30
|
||||
if subscription and renewal_service:
|
||||
pricing = await renewal_service.calculate_pricing(db, db_user, subscription, min_period)
|
||||
min_price = pricing.final_total
|
||||
if subscription:
|
||||
min_pricing = await pricing_engine.calculate_renewal_price(db, subscription, min_period, user=db_user)
|
||||
min_price = min_pricing.final_total
|
||||
else:
|
||||
min_price, _ = await subscription_service.calculate_subscription_price_with_months(
|
||||
min_period, traffic_limit_gb, server_ids, device_limit, db, user=db_user
|
||||
min_new_pricing = await pricing_engine.calculate_classic_new_subscription_price(
|
||||
db,
|
||||
min_period,
|
||||
connected_squads,
|
||||
traffic_limit_gb,
|
||||
device_limit,
|
||||
user=db_user,
|
||||
)
|
||||
min_price = min_new_pricing.final_total
|
||||
missing = min_price - balance
|
||||
await callback.answer(
|
||||
texts.t('INSUFFICIENT_FUNDS_DETAILED', f'❌ Недостаточно средств. Не хватает {missing // 100} ₽'),
|
||||
@@ -1336,8 +1356,10 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
|
||||
|
||||
try:
|
||||
if subscription:
|
||||
# Продление существующей подписки
|
||||
pricing = await renewal_service.calculate_pricing(db, db_user, subscription, best_period)
|
||||
# Продление существующей подписки (reuse cached pricing from loop above)
|
||||
if best_pricing is None:
|
||||
raise ValueError('best_pricing is None despite best_period being set')
|
||||
pricing = best_pricing
|
||||
|
||||
await renewal_service.finalize(
|
||||
db,
|
||||
@@ -1357,12 +1379,14 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
|
||||
)
|
||||
else:
|
||||
# Списать баланс ДО создания подписки (чтобы не было orphaned subscription при неудаче)
|
||||
consume_promo = get_user_active_promo_discount_percent(db_user) > 0
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
db_user,
|
||||
best_price,
|
||||
f'Активация подписки на {best_period} дней',
|
||||
mark_as_paid_subscription=True,
|
||||
consume_promo_offer=consume_promo,
|
||||
)
|
||||
if not success:
|
||||
await callback.answer('❌ Недостаточно средств', show_alert=True)
|
||||
|
||||
+89
-36
@@ -1,4 +1,6 @@
|
||||
import hashlib
|
||||
import json
|
||||
from html import escape as html_escape
|
||||
from pathlib import Path
|
||||
|
||||
import qrcode
|
||||
@@ -13,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
|
||||
@@ -37,10 +39,15 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not db_user.referral_code:
|
||||
await callback.answer(texts.t('REFERRAL_CODE_NOT_ASSIGNED', 'Реферальный код не назначен'), show_alert=True)
|
||||
return
|
||||
|
||||
summary = await get_user_referral_summary(db, db_user.id)
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = f'https://t.me/{bot_username}?start={db_user.referral_code}'
|
||||
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>')
|
||||
@@ -109,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'
|
||||
)
|
||||
|
||||
@@ -153,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'
|
||||
)
|
||||
@@ -229,19 +250,24 @@ async def show_referral_qr(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
):
|
||||
await callback.answer()
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not db_user.referral_code:
|
||||
await callback.answer(texts.t('REFERRAL_CODE_NOT_ASSIGNED', 'Реферальный код не назначен'), show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = f'https://t.me/{bot_username}?start={db_user.referral_code}'
|
||||
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)
|
||||
|
||||
file_path = qr_dir / f'{db_user.id}.png'
|
||||
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)
|
||||
@@ -249,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,
|
||||
)
|
||||
|
||||
@@ -312,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 += (
|
||||
@@ -444,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'],
|
||||
)
|
||||
@@ -470,8 +499,13 @@ async def show_referral_analytics(callback: types.CallbackQuery, db_user: User,
|
||||
async def create_invite_message(callback: types.CallbackQuery, db_user: User):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not db_user.referral_code:
|
||||
await callback.answer(texts.t('REFERRAL_CODE_NOT_ASSIGNED', 'Реферальный код не назначен'), show_alert=True)
|
||||
return
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = f'https://t.me/{bot_username}?start={db_user.referral_code}'
|
||||
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 сервису!')
|
||||
|
||||
@@ -493,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')],
|
||||
@@ -517,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,
|
||||
)
|
||||
@@ -570,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')])
|
||||
|
||||
@@ -732,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'
|
||||
)
|
||||
@@ -778,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)}
|
||||
"""
|
||||
@@ -811,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)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
@@ -189,7 +190,7 @@ def _format_server_lines(
|
||||
else:
|
||||
latency_text = texts.t('SERVER_STATUS_OFFLINE', 'нет ответа')
|
||||
|
||||
name = server.display_name or server.name
|
||||
name = html.escape(server.display_name or server.name)
|
||||
flag_prefix = f'{server.flag} ' if server.flag else ''
|
||||
server_line = f'{flag_prefix}{name} — {latency_text}'
|
||||
lines.append(f'<blockquote>{server_line}</blockquote>')
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user