Compare commits

..

1352 Commits

Author SHA1 Message Date
Egor d7aab2c3da Update Dockerfile 2025-10-15 06:48:18 +03:00
Egor f59e04b231 Update docker-registry.yml 2025-10-15 06:48:05 +03:00
Egor a2ac04a57a Update docker-hub.yml 2025-10-15 06:47:51 +03:00
Egor 4526df2342 Merge pull request #1352 from Fr1ngg/dev4
Dev4
2025-10-15 06:34:29 +03:00
Egor d0c65f201f Merge pull request #1351 from Fr1ngg/37rzsp-bedolaga/fix-top-up-balance-functionality
Fix Telegram Stars payment state and add missing YooKassa SBP locales
2025-10-15 06:30:26 +03:00
Egor 4fd736ecbf Fix Telegram Stars state import and add YooKassa SBP locale keys 2025-10-15 06:30:01 +03:00
Egor 351700f886 Merge pull request #1350 from Fr1ngg/dev4
Автосинк с ремной при старте + по таймеру
2025-10-15 06:21:50 +03:00
Egor 453d1dbf92 Merge pull request #1349 from Fr1ngg/lnnftq-bedolaga/add-auto-sync-feature-for-remnawave
Persist RemnaWave auto-sync schedule updates
2025-10-15 06:15:55 +03:00
Egor 0f358a9691 Persist RemnaWave auto-sync schedule updates 2025-10-15 06:10:44 +03:00
Egor ab890ea958 Merge pull request #1347 from Fr1ngg/revert-1346-r66y4z-bedolaga/add-auto-sync-feature-for-remnawave
Revert "Fix RemnaWave auto-sync callback handling"
2025-10-15 05:57:45 +03:00
Egor e0b0498f81 Revert "Fix RemnaWave auto-sync callback handling" 2025-10-15 05:57:34 +03:00
Egor af8dc119a5 Merge pull request #1346 from Fr1ngg/r66y4z-bedolaga/add-auto-sync-feature-for-remnawave
Fix RemnaWave auto-sync callback handling
2025-10-15 05:47:19 +03:00
Egor 7ad98ceebb Fix manual auto-sync callback timing 2025-10-15 05:42:27 +03:00
Egor 004aad4f06 Merge pull request #1345 from Fr1ngg/revert-1344-f5x8i3-bedolaga/add-auto-sync-feature-for-remnawave
Revert "Add coverage for RemnaWave auto-sync schedule helpers"
2025-10-15 05:41:27 +03:00
Egor ec5524d74f Revert "Add coverage for RemnaWave auto-sync schedule helpers" 2025-10-15 05:41:18 +03:00
Egor c1211ee025 Merge pull request #1344 from Fr1ngg/f5x8i3-bedolaga/add-auto-sync-feature-for-remnawave
Add coverage for RemnaWave auto-sync schedule helpers
2025-10-15 05:35:07 +03:00
Egor bf6528128f Add tests for RemnaWave auto-sync scheduling helpers 2025-10-15 05:34:02 +03:00
Egor 34c22b8955 Merge pull request #1343 from Fr1ngg/lpha75-bedolaga/-wata
Add localized labels for Wata payment method in mini app
2025-10-15 05:09:17 +03:00
Egor d48488338e Add localization for Wata top-up method 2025-10-15 05:09:01 +03:00
Egor a37fd89d8b Merge pull request #1342 from Fr1ngg/dev4
Wata
2025-10-15 04:58:19 +03:00
Egor 4553b1e038 Merge pull request #1341 from Fr1ngg/lej6x5-bedolaga/fix-wata-migration-error
Deduplicate Wata payment links before enforcing unique index
2025-10-15 04:51:34 +03:00
Egor 8c39f5aecf Deduplicate Wata payment links before enforcing unique index 2025-10-15 04:51:05 +03:00
Egor ce5ceb2f89 Merge pull request #1340 from Fr1ngg/revert-1339-32ftif-bedolaga/fix-wata-migration-error
Revert "Harden Wata payment link constraints"
2025-10-15 04:50:07 +03:00
Egor 8ca7750d25 Revert "Harden Wata payment link constraints" 2025-10-15 04:49:58 +03:00
Egor 1045cf42f9 Merge pull request #1339 from Fr1ngg/32ftif-bedolaga/fix-wata-migration-error
Harden Wata payment link constraints
2025-10-15 04:34:25 +03:00
Egor 427a8594d5 Harden Wata payment link constraints 2025-10-15 04:32:56 +03:00
Egor e007e8142a Merge pull request #1338 from Fr1ngg/revert-1337-c2jl2y-bedolaga/fix-wata-migration-error
Revert "Fix Wata auto-migration by ensuring required columns exist"
2025-10-15 04:32:31 +03:00
Egor 55e47e8e00 Revert "Fix Wata auto-migration by ensuring required columns exist" 2025-10-15 04:31:09 +03:00
Egor f84c54da03 Merge pull request #1337 from Fr1ngg/c2jl2y-bedolaga/fix-wata-migration-error
Fix Wata auto-migration by ensuring required columns exist
2025-10-15 04:21:05 +03:00
Egor 9e5279454c Fix Wata schema migration to add missing columns 2025-10-15 04:20:28 +03:00
Egor e935d13ccd Merge pull request #1336 from Fr1ngg/revert-1335-gr6o9l-bedolaga/modularize-miniapp-routes-by-functionality
Revert "Restore miniapp helpers and fix mulenpay status"
2025-10-15 04:15:33 +03:00
Egor 2c17ce5c3a Revert "Restore miniapp helpers and fix mulenpay status" 2025-10-15 04:15:24 +03:00
Egor 947ad48acd Merge pull request #1335 from Fr1ngg/gr6o9l-bedolaga/modularize-miniapp-routes-by-functionality
Restore miniapp helpers and fix mulenpay status
2025-10-15 04:13:48 +03:00
Egor aefae5818e Restore miniapp helpers and fix mulenpay status 2025-10-15 04:13:27 +03:00
Egor 47191342f6 Merge pull request #1334 from Fr1ngg/revert-1333-a5pqeb-bedolaga/modularize-miniapp-routes-by-functionality
Revert "Refine miniapp module exports"
2025-10-15 04:12:47 +03:00
Egor 0c841cbf86 Revert "Refine miniapp module exports" 2025-10-15 04:12:39 +03:00
Egor 8ef4c74b4a Merge pull request #1333 from Fr1ngg/a5pqeb-bedolaga/modularize-miniapp-routes-by-functionality
Refine miniapp module exports
2025-10-15 03:55:47 +03:00
Egor feb5e92fc2 Refine miniapp module exports 2025-10-15 03:54:04 +03:00
Egor 27338809c1 Merge pull request #1331 from Fr1ngg/uzp44y-bedolaga/refactor-bot-configuration-settings
Reassign miscellaneous bot settings to proper categories
2025-10-15 02:52:59 +03:00
Egor 53231ca074 Organize other settings into dedicated menu groups 2025-10-15 02:52:35 +03:00
Egor 1c177660c0 Merge pull request #1330 from Fr1ngg/bl2afh-bedolaga/update-bot-menu-configuration
Revamp bot configuration menu structure
2025-10-15 02:40:04 +03:00
Egor 041f1d09d7 Revamp bot configuration menu structure 2025-10-15 02:39:13 +03:00
Egor 8e13f644b0 Merge pull request #1329 from Fr1ngg/revert-1328-6k8mnf-bedolaga/update-bot-menu-configuration
Revert "refactor: regroup config categories"
2025-10-15 02:38:50 +03:00
Egor fc0b7591f9 Revert "refactor: regroup config categories" 2025-10-15 02:38:40 +03:00
Egor 308c645096 Merge pull request #1328 from Fr1ngg/6k8mnf-bedolaga/update-bot-menu-configuration
refactor: regroup config categories
2025-10-15 02:37:00 +03:00
Egor fd41d22322 refactor: regroup config categories 2025-10-15 02:36:11 +03:00
Egor 694d85ec10 Merge pull request #1327 from Fr1ngg/revert-1326-ogoewn-bedolaga/update-bot-menu-configuration
Revert "fix: group WATA payments in the admin menu"
2025-10-15 02:35:45 +03:00
Egor 790ffecade Revert "fix: group WATA payments in the admin menu" 2025-10-15 02:35:37 +03:00
Egor 5c5cc42af7 Merge pull request #1326 from Fr1ngg/ogoewn-bedolaga/update-bot-menu-configuration
fix: group WATA payments in the admin menu
2025-10-15 02:33:46 +03:00
Egor 0784f56f7c fix: organize payment settings and add wata test 2025-10-15 02:33:17 +03:00
Egor 775da62e00 Merge pull request #1325 from Fr1ngg/x280im-bedolaga/verify-wata-payment-method-integration
Add WATA payment support to miniapp API
2025-10-15 01:59:21 +03:00
Egor 436efcdd3e Add WATA payment support to miniapp API and tests 2025-10-15 01:50:34 +03:00
Egor 086660ebef Merge pull request #1324 from Fr1ngg/du2spj-bedolaga/fix-wata-payment-finalization-logic
Handle WATA Paid status during polling
2025-10-15 01:29:49 +03:00
Egor c546d10d93 Handle WATA Paid status during polling 2025-10-15 01:23:50 +03:00
Egor 169bdae0d1 Merge pull request #1323 from Fr1ngg/q1598f-bedolaga/add-new-balance-topping-method-to-bot
Fix missing logging import in WATA CRUD helpers
2025-10-15 01:21:28 +03:00
Egor 56e0ec4cd2 Fix logging import in WATA CRUD module 2025-10-15 01:14:43 +03:00
Egor f62e2794b2 Merge pull request #1322 from Fr1ngg/main
w
2025-10-15 00:43:41 +03:00
Egor f19db8f184 Merge pull request #1320 from Gy9vin/main
Фиксы ошибок запуска
2025-10-15 00:36:24 +03:00
Egor 41d4c9b507 Merge pull request #1321 from Fr1ngg/revert-1319-revert-1318-main
Revert "Revert "да все до кучи""
2025-10-15 00:36:11 +03:00
Egor 73e4abb29f Revert "Revert "да все до кучи"" 2025-10-15 00:35:58 +03:00
gy9vin f7cd8c9fc3 Фиксы ошибок запуска 2025-10-14 23:03:21 +03:00
Egor 6497f52a11 Merge pull request #1319 from Fr1ngg/revert-1318-main
Revert "да все до кучи"
2025-10-14 21:39:30 +03:00
Egor a4de6a8330 Revert "да все до кучи" 2025-10-14 21:39:20 +03:00
Egor d27e625ee6 Merge pull request #1318 from Gy9vin/main
да все до кучи
2025-10-14 21:35:11 +03:00
gy9vin e7a34152fc удаление бэкапов 2025-10-14 21:30:29 +03:00
Egor e332fc41fc Delete app/handlers/balance.py.backup 2025-10-14 21:20:29 +03:00
Egor 6bf0ac7fbf Delete app/handlers/balance.py.backup2 2025-10-14 21:19:27 +03:00
Egor 0effaa5240 Merge pull request #1317 from Fr1ngg/revert-1315-revert-1314-revert-1313-nv8l52-bedolaga/add-new-balance-top-up-method-to-bot
Revert "Revert "Revert "Complete Wata Pay integration"""
2025-10-14 21:18:03 +03:00
Egor 89b0544f86 Revert "Revert "Revert "Complete Wata Pay integration""" 2025-10-14 21:17:54 +03:00
Egor b1626d32cd Merge pull request #1315 from Fr1ngg/revert-1314-revert-1313-nv8l52-bedolaga/add-new-balance-top-up-method-to-bot
Revert "Revert "Complete Wata Pay integration""
2025-10-14 21:00:16 +03:00
gy9vin 8cdb4289c9 Модульность balance 2025-10-14 21:00:08 +03:00
gy9vin d4e3adddfc pal24 фиксы с кнопками СБП и карта 2025-10-14 20:21:52 +03:00
gy9vin 1baeb50322 кнопка назад при ручной проверки статуса платежа на палыче 2025-10-14 19:55:24 +03:00
Egor 7ed635600e Revert "Revert "Complete Wata Pay integration"" 2025-10-14 19:14:28 +03:00
Egor cb81fae3d8 Merge pull request #1311 from Fr1ngg/main
w
2025-10-14 19:13:49 +03:00
Egor 93cb99d50c Merge pull request #1314 from Fr1ngg/revert-1313-nv8l52-bedolaga/add-new-balance-top-up-method-to-bot
Revert "Complete Wata Pay integration"
2025-10-14 19:13:23 +03:00
Egor 5a4f59dfb2 Revert "Complete Wata Pay integration" 2025-10-14 19:13:11 +03:00
Egor 59f39e18a0 Merge pull request #1313 from Fr1ngg/nv8l52-bedolaga/add-new-balance-top-up-method-to-bot
Complete Wata Pay integration
2025-10-14 19:12:19 +03:00
Egor d3dd423042 Complete Wata Pay integration 2025-10-14 16:46:00 +03:00
Egor b90b270a9e Merge pull request #1306 from Gy9vin/main
Партнерка активация по env + убрано упоминание о vpn в русской локализации
2025-10-14 16:08:41 +03:00
gy9vin 7846b54a6f вернул докер 2025-10-14 11:16:19 +03:00
gy9vin 77083e8349 Фикс 2025-10-14 11:14:55 +03:00
gy9vin f121a5733b Прячем кнопки пополнения через env. оставляя только введения суммы вручную 2025-10-14 09:45:14 +03:00
Mikhail ad6bb95df7 Merge branch 'Fr1ngg:main' into main 2025-10-14 09:03:02 +03:00
gy9vin 58588e37d0 Реализация корзины пользователя! запоминает настройки подписки после пополнения баланса. 2025-10-14 09:02:16 +03:00
Egor 9717876fe7 Merge pull request #1307 from Fr1ngg/main
w
2025-10-14 00:55:29 +03:00
gy9vin badf44a9e7 Выпиливание упоминание про VPN из русской локализации 2025-10-13 23:02:25 +03:00
gy9vin a320de1cbb Скрытие кнопки партнерки через env 2025-10-13 23:00:06 +03:00
Egor aebfd8933a Update Dockerfile 2025-10-13 22:11:55 +03:00
Egor 482fb26d48 Update docker-registry.yml 2025-10-13 22:11:47 +03:00
Egor 8542473c6a Update docker-hub.yml 2025-10-13 22:11:33 +03:00
Egor 66e56c68cb Merge pull request #1305 from Fr1ngg/dev4
Рефактор subscription разбите на модули
2025-10-13 21:33:44 +03:00
Egor 135f3ccb92 Merge pull request #1304 from Fr1ngg/pfxfpr-bedolaga/refactor-subscription.py-for-modularity
Refactor subscription handlers into modular package
2025-10-13 21:24:58 +03:00
Egor d082e8a61c Refactor subscription handlers into modular package 2025-10-13 21:21:26 +03:00
Egor 23ad622fcd Merge pull request #1303 from Fr1ngg/dev4
фиксы + модульность платежек
2025-10-13 21:01:30 +03:00
Egor 9b639c3e1d Merge pull request #1302 from Fr1ngg/bedolaga-koykgg
Preserve advertising payload when subscription check blocks /start
2025-10-13 20:56:10 +03:00
Egor 75e9b8e27d Fix campaign tracking for users subscribing before /start 2025-10-13 20:51:25 +03:00
Egor 893736b240 Merge pull request #1301 from Fr1ngg/ozlu2i-bedolaga/fix-notification-for-balance-top-up
Handle MissingGreenlet when sending payment success notifications
2025-10-13 17:53:51 +03:00
Egor f5403802cc Fix payment success notification missing greenlet error 2025-10-13 17:26:51 +03:00
Egor 1c2325ca70 Merge pull request #1300 from Fr1ngg/isg6i0-bedolaga/fix-subscription-activation-after-promo-code
Fix reactivation of subscriptions extended via promo codes
2025-10-13 16:53:21 +03:00
Egor e032369110 Reactivate subscriptions when extending from disabled 2025-10-13 16:52:58 +03:00
Egor 9435a82add Merge pull request #1298 from Fr1ngg/bedolaga-8s2c1e
Handle miniapp YooKassa SBP payment status
2025-10-13 16:28:26 +03:00
Egor 7478167065 Handle miniapp YooKassa SBP payment status 2025-10-13 16:28:04 +03:00
Egor de5206ccd0 Merge pull request #1294 from Gy9vin/main
Покрытие кода тестами!
2025-10-13 16:14:18 +03:00
Egor 9d24522fab Merge pull request #1297 from Fr1ngg/main
w
2025-10-13 16:13:48 +03:00
gy9vin dec3aefdec Модульная структура платежки 2025-10-12 16:14:00 +03:00
gy9vin 30645d633e Исправление уведомления пользователя о поступлении денег на счет 2025-10-12 14:20:54 +03:00
gy9vin 2dcf3ef087 Покрытие кода тестами! 2025-10-12 10:13:32 +03:00
Egor 6c4aa8246c Update Dockerfile 2025-10-12 07:33:07 +03:00
Egor b08be14c6a Update docker-registry.yml 2025-10-12 07:32:48 +03:00
Egor 14d4166ac9 Update docker-hub.yml 2025-10-12 07:32:32 +03:00
Egor c2e91265b8 Merge pull request #1293 from Fr1ngg/dev4
Фикс кнопок в рассылке для режима MAIN_MENU_MODE=text
2025-10-12 07:19:27 +03:00
Egor edd2809f73 Merge pull request #1292 from Fr1ngg/8xskxx-bedolaga/fix-miniapp-access-in-main_menu_mode=text
Fix broadcast buttons opening miniapp in text menu mode
2025-10-12 07:15:58 +03:00
Egor b657b9e95c Fix broadcast buttons opening miniapp in text menu mode 2025-10-12 07:15:37 +03:00
Egor d112d769b7 Merge pull request #1291 from Fr1ngg/95i1mf-bedolaga/update-miniapp/index.html-for-accurate-traffic-data
Ensure mini app shows fresh subscription usage
2025-10-12 07:07:01 +03:00
Egor 28d26cee17 Sync subscription traffic when opening mini app 2025-10-12 07:06:33 +03:00
Egor e7115461db Merge pull request #1290 from Fr1ngg/dev4
Новый режим работы бота через ЛК MiniApp
2025-10-12 06:54:04 +03:00
Egor c8205bd882 Update README.md 2025-10-12 06:53:02 +03:00
Egor 1314d1c5cb Update .env.example 2025-10-12 06:52:25 +03:00
Egor 138a8920d9 Merge pull request #1289 from Fr1ngg/sdanmv-bedolaga/add-text-only-menu-mode
Route subscription reminders to miniapp in text mode
2025-10-12 06:41:26 +03:00
Egor ac3890598f Route subscription reminders to miniapp in text mode 2025-10-12 06:40:31 +03:00
Egor 2784920fbb Merge pull request #1288 from Fr1ngg/revert-1287-2jiako-bedolaga/add-text-only-menu-mode
Revert "Route promo offer CTA buttons to miniapp in text mode"
2025-10-12 06:20:01 +03:00
Egor 81e5731f34 Revert "Route promo offer CTA buttons to miniapp in text mode" 2025-10-12 06:19:53 +03:00
Egor d637f461f9 Merge pull request #1287 from Fr1ngg/2jiako-bedolaga/add-text-only-menu-mode
Route promo offer CTA buttons to miniapp in text mode
2025-10-12 05:49:54 +03:00
Egor 7fbe5a2bd8 Route promo offer CTAs to miniapp in text mode 2025-10-12 05:47:28 +03:00
Egor 9819897dbb Merge pull request #1286 from Fr1ngg/revert-1285-kluzxc-bedolaga/add-text-only-menu-mode
Revert "Route notification CTAs to miniapp in text mode"
2025-10-12 05:47:16 +03:00
Egor d20f264b59 Revert "Route notification CTAs to miniapp in text mode" 2025-10-12 05:47:08 +03:00
Egor 3d4e8b4d1b Merge pull request #1285 from Fr1ngg/kluzxc-bedolaga/add-text-only-menu-mode
Route notification CTAs to miniapp in text mode
2025-10-12 05:32:33 +03:00
Egor 253df6a60a Route notification CTAs to miniapp in text mode 2025-10-12 05:32:15 +03:00
Egor fae50b2155 Merge pull request #1284 from Fr1ngg/revert-1283-fwlbh2-bedolaga/add-text-only-menu-mode
Revert "Add text-only main menu mode with miniapp profile shortcut"
2025-10-12 05:31:56 +03:00
Egor 0df5fe9844 Revert "Add text-only main menu mode with miniapp profile shortcut" 2025-10-12 05:31:40 +03:00
Egor 522146c054 Merge pull request #1283 from Fr1ngg/fwlbh2-bedolaga/add-text-only-menu-mode
Add text-only main menu mode with miniapp profile shortcut
2025-10-12 05:05:08 +03:00
Egor 9a3301bc38 Add text-only main menu mode with miniapp profile button 2025-10-12 05:02:47 +03:00
Egor c169b8e69a Merge pull request #1282 from Fr1ngg/dev4
Обновление миниапки под актуальный шаблон app-config из ремны + режима guide в боте
2025-10-12 04:47:37 +03:00
Egor dbc9cbbb39 Merge pull request #1281 from Fr1ngg/main
w
2025-10-12 04:45:35 +03:00
Egor ec768d68ef Merge pull request #1280 from Fr1ngg/fozp9n-bedolaga/update-guide-mode-for-app-config.json
Add app selection list to connection guide
2025-10-12 04:32:03 +03:00
Egor 254255fe94 Add Apple TV option to connection guide 2025-10-12 04:31:49 +03:00
Egor c1aaa3acb0 Merge pull request #1279 from Fr1ngg/revert-1277-rsxc5b-bedolaga/update-guide-mode-for-app-config.json
Revert "Add app selection list to connection guide"
2025-10-12 04:30:44 +03:00
Egor 4c2262e0b0 Revert "Add app selection list to connection guide" 2025-10-12 04:30:36 +03:00
Egor 9c4445505b Merge pull request #1278 from Fr1ngg/i89vp3-bedolaga/move-subscription-button-to-instructions
Move connect button into subscription step
2025-10-12 04:28:54 +03:00
Egor b47944ef7b Move connect button into subscription step 2025-10-12 04:28:39 +03:00
Egor 2b268a0e06 Merge pull request #1277 from Fr1ngg/rsxc5b-bedolaga/update-guide-mode-for-app-config.json
Add app selection list to connection guide
2025-10-12 04:23:04 +03:00
Egor 3ba9bddfc5 Add multi-app selection to connection guide 2025-10-12 04:22:47 +03:00
Egor e19f18931f Merge pull request #1276 from Fr1ngg/revert-1275-ht68uj-bedolaga/update-guide-mode-for-app-config.json
Revert "Adapt guide mode to new app config and connect links"
2025-10-12 04:22:37 +03:00
Egor b8cbfc6065 Revert "Adapt guide mode to new app config and connect links" 2025-10-12 04:22:29 +03:00
Egor e7714dc33d Merge pull request #1275 from Fr1ngg/ht68uj-bedolaga/update-guide-mode-for-app-config.json
Adapt guide mode to new app config and connect links
2025-10-12 04:11:03 +03:00
Egor 847d04fa42 Adapt guide mode to new app config and connect links 2025-10-12 04:10:46 +03:00
Egor 164b4f4e33 Merge pull request #1274 from Fr1ngg/owmhgs-bedolaga/update-redirect-link-in-miniapp/index.html
Use configured Happ redirect template in miniapp
2025-10-12 03:09:40 +03:00
Egor e1b7af2990 Use configured Happ redirect template in miniapp 2025-10-12 03:09:25 +03:00
Egor da74c7841d Merge pull request #1273 from Fr1ngg/revert-1272-rkb5qf-bedolaga/update-redirect-link-in-miniapp/index.html
Revert "Use configured Happ redirect template fallback"
2025-10-12 03:08:35 +03:00
Egor 68d83be87a Revert "Use configured Happ redirect template fallback" 2025-10-12 03:08:28 +03:00
Egor 57f820dc35 Merge pull request #1272 from Fr1ngg/rkb5qf-bedolaga/update-redirect-link-in-miniapp/index.html
Use configured Happ redirect template fallback
2025-10-12 03:05:30 +03:00
Egor cd0aba5a6c Use configured Happ redirect template fallback 2025-10-12 03:05:11 +03:00
Egor a43e68846a Update app-config.json 2025-10-12 02:47:56 +03:00
Egor adadeb4b2a Update app-config.json 2025-10-12 02:47:46 +03:00
Egor ad81cff59a Update index.html 2025-10-12 02:45:16 +03:00
Egor bf0c1ce711 Update and rename bedolaga_app2.svg to bedolaga_app3.svg 2025-10-12 02:41:45 +03:00
Egor c71401cad4 Rename bedolaga_app.svg to bedolaga_app2.svg 2025-10-12 02:36:39 +03:00
Egor 81744d3de8 Update bedolaga_app.svg 2025-10-12 02:36:05 +03:00
Egor f49dbeaf3a Update bedolaga_app.svg 2025-10-12 02:35:21 +03:00
Egor 649bd32af3 Create bedolaga_app.svg 2025-10-12 02:18:37 +03:00
Egor 55c37d8860 Merge pull request #1271 from Fr1ngg/2dsocc-bedolaga/fix-scroll-issue-in-modal-instructions
Add platform filter controls to installation modal
2025-10-12 02:07:25 +03:00
Egor c953cc150d Add platform filter controls to installation modal 2025-10-12 02:07:06 +03:00
Egor 59a78f0823 Merge pull request #1270 from Fr1ngg/revert-1269-mbe8rg-bedolaga/fix-scroll-issue-in-modal-instructions
Revert "Fix installation modal truncation for PC app list"
2025-10-12 02:00:49 +03:00
Egor 8ac3d5e826 Revert "Fix installation modal truncation for PC app list" 2025-10-12 02:00:41 +03:00
Egor 744b7d532c Merge pull request #1269 from Fr1ngg/mbe8rg-bedolaga/fix-scroll-issue-in-modal-instructions
Fix installation modal truncation for PC app list
2025-10-12 01:59:05 +03:00
Egor 72ec8d565c Allow installation modal to show full PC app list 2025-10-12 01:58:31 +03:00
Egor 9350868830 Merge pull request #1268 from Fr1ngg/revert-1267-2fc8ey-bedolaga/fix-scroll-issue-in-modal-instructions
Revert "Improve installation modal scroll handling"
2025-10-12 01:58:20 +03:00
Egor effbede310 Revert "Improve installation modal scroll handling" 2025-10-12 01:58:12 +03:00
Egor 4b3be5725c Merge pull request #1267 from Fr1ngg/2fc8ey-bedolaga/fix-scroll-issue-in-modal-instructions
Improve installation modal scroll handling
2025-10-12 01:48:07 +03:00
Egor 22ed539b3a Fix installation modal scrolling 2025-10-12 01:47:50 +03:00
Egor fa46a5b96c Merge pull request #1265 from Fr1ngg/rz4sct-bedolaga/update-miniapp/index.html-for-new-json-format
Fix desktop redirect to use scheme subscription URLs
2025-10-12 01:32:21 +03:00
Egor 63402474d2 Fix PC connect redirect handling 2025-10-12 01:32:06 +03:00
Egor 93db300dc7 Merge pull request #1264 from Fr1ngg/revert-1263-kdbq2j-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Ensure PC connect links use redirect template scheme payloads"
2025-10-12 01:31:47 +03:00
Egor 76e29314b2 Revert "Ensure PC connect links use redirect template scheme payloads" 2025-10-12 01:31:37 +03:00
Egor 7e5c1d388e Merge pull request #1263 from Fr1ngg/kdbq2j-bedolaga/update-miniapp/index.html-for-new-json-format
Ensure PC connect links use redirect template scheme payloads
2025-10-12 01:28:12 +03:00
Egor 6b14ab70c6 Fix PC connect links to honor selected platform 2025-10-12 01:27:54 +03:00
Egor 5e3cff20b1 Merge pull request #1262 from Fr1ngg/revert-1261-z7k7de-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Fix desktop redirect to use scheme subscription URLs"
2025-10-12 01:27:21 +03:00
Egor 34c1c0b806 Revert "Fix desktop redirect to use scheme subscription URLs" 2025-10-12 01:27:13 +03:00
Egor e69dca013c Merge pull request #1261 from Fr1ngg/z7k7de-bedolaga/update-miniapp/index.html-for-new-json-format
Fix desktop redirect to use scheme subscription URLs
2025-10-12 01:06:23 +03:00
Egor d201e07385 Fix desktop redirect to use scheme subscription URLs 2025-10-12 01:06:07 +03:00
Egor 613e239691 Merge pull request #1260 from Fr1ngg/revert-1258-j5y403-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Ensure desktop redirect targets prefer scheme links"
2025-10-12 01:05:51 +03:00
Egor b72e00de9d Revert "Ensure desktop redirect targets prefer scheme links" 2025-10-12 01:05:43 +03:00
Egor 0830461398 Merge pull request #1258 from Fr1ngg/j5y403-bedolaga/update-miniapp/index.html-for-new-json-format
Ensure desktop redirect targets prefer scheme links
2025-10-12 01:01:28 +03:00
Egor 9abf0c1da9 Merge pull request #1259 from Fr1ngg/revert-1257-26yx6h-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Ensure desktop redirects use scheme subscription URLs"
2025-10-12 01:01:11 +03:00
Egor 74148d4e7d Revert "Ensure desktop redirects use scheme subscription URLs" 2025-10-12 01:01:03 +03:00
Egor 8901cad885 Ensure desktop redirect targets prefer scheme links 2025-10-12 01:00:44 +03:00
Egor a800bd4e3c Merge pull request #1257 from Fr1ngg/26yx6h-bedolaga/update-miniapp/index.html-for-new-json-format
Ensure desktop redirects use scheme subscription URLs
2025-10-12 00:50:50 +03:00
Egor 5c3c438fb7 Fix desktop redirect scheme target 2025-10-12 00:50:34 +03:00
Egor 65df7ff58a Merge pull request #1256 from Fr1ngg/revert-1255-qabbzf-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Fix desktop connect redirect target selection"
2025-10-12 00:50:14 +03:00
Egor 46855439e2 Revert "Fix desktop connect redirect target selection" 2025-10-12 00:49:37 +03:00
Egor 7b3b4a8fb3 Merge pull request #1255 from Fr1ngg/qabbzf-bedolaga/update-miniapp/index.html-for-new-json-format
Fix desktop connect redirect target selection
2025-10-12 00:45:54 +03:00
Egor 8a90893b40 Fix desktop connect redirect target selection 2025-10-12 00:45:32 +03:00
Egor edf1d3dc29 Merge pull request #1254 from Fr1ngg/revert-1253-qt32vw-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Adjust desktop connect redirects to wrap scheme links"
2025-10-12 00:44:41 +03:00
Egor d5bf7f2749 Revert "Adjust desktop connect redirects to wrap scheme links" 2025-10-12 00:44:32 +03:00
Egor 5939d2d69a Merge pull request #1253 from Fr1ngg/qt32vw-bedolaga/update-miniapp/index.html-for-new-json-format
Adjust desktop connect redirects to wrap scheme links
2025-10-12 00:42:56 +03:00
Egor 90a2fd9fdc Fix PC redirect scheme targeting 2025-10-12 00:42:35 +03:00
Egor 841b183a59 Merge pull request #1252 from Fr1ngg/revert-1251-revert-1248-revert-1247-4egmuh-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Revert "Revert "Fix desktop connect redirects for scheme subscriptions"""
2025-10-12 00:39:38 +03:00
Egor 9a73621ff4 Revert "Revert "Revert "Fix desktop connect redirects for scheme subscriptions""" 2025-10-12 00:39:29 +03:00
Egor 71e8ceace9 Merge pull request #1251 from Fr1ngg/revert-1248-revert-1247-4egmuh-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Revert "Fix desktop connect redirects for scheme subscriptions""
2025-10-12 00:27:47 +03:00
Egor 4e8e11fc7b Revert "Revert "Fix desktop connect redirects for scheme subscriptions"" 2025-10-12 00:27:38 +03:00
Egor b3a1a8ddb8 Merge pull request #1250 from Fr1ngg/revert-1249-lhhq28-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Prioritize scheme-based redirect targets on desktop"
2025-10-12 00:27:18 +03:00
Egor 029d162e44 Revert "Prioritize scheme-based redirect targets on desktop" 2025-10-12 00:27:10 +03:00
Egor 86ab3e544d Merge pull request #1249 from Fr1ngg/lhhq28-bedolaga/update-miniapp/index.html-for-new-json-format
Prioritize scheme-based redirect targets on desktop
2025-10-12 00:25:37 +03:00
Egor 587ca35049 Fix desktop redirect target selection 2025-10-12 00:25:20 +03:00
Egor 02c68e5983 Merge pull request #1248 from Fr1ngg/revert-1247-4egmuh-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Fix desktop connect redirects for scheme subscriptions"
2025-10-12 00:24:39 +03:00
Egor 9ddc6b8f0c Revert "Fix desktop connect redirects for scheme subscriptions" 2025-10-12 00:24:29 +03:00
Egor eb69b166fe Merge pull request #1247 from Fr1ngg/4egmuh-bedolaga/update-miniapp/index.html-for-new-json-format
Fix desktop connect redirects for scheme subscriptions
2025-10-12 00:22:45 +03:00
Egor 0ea6476834 Fix desktop connect redirects for non-Happ apps 2025-10-12 00:22:28 +03:00
Egor 6994735b0c Merge pull request #1246 from Fr1ngg/revert-1245-wklyjo-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Adjust desktop connect redirects to wrap scheme links"
2025-10-12 00:22:09 +03:00
Egor f504322afd Revert "Adjust desktop connect redirects to wrap scheme links" 2025-10-12 00:22:02 +03:00
Egor a60ff3bdb1 Merge pull request #1245 from Fr1ngg/wklyjo-bedolaga/update-miniapp/index.html-for-new-json-format
Adjust desktop connect redirects to wrap scheme links
2025-10-12 00:20:25 +03:00
Egor 1eec3b71f6 Adjust PC redirect targets for connect links 2025-10-12 00:20:04 +03:00
Egor 31945067c9 Merge pull request #1244 from Fr1ngg/revert-1243-gh1a1w-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Refine desktop connect redirects to use subscription URLs"
2025-10-12 00:19:29 +03:00
Egor fe0da51909 Revert "Refine desktop connect redirects to use subscription URLs" 2025-10-12 00:19:19 +03:00
Egor 9ddf7891aa Merge pull request #1243 from Fr1ngg/gh1a1w-bedolaga/update-miniapp/index.html-for-new-json-format
Refine desktop connect redirects to use subscription URLs
2025-10-12 00:03:50 +03:00
Egor ce4eb3341d Adjust connect link priorities for desktop 2025-10-12 00:03:32 +03:00
Egor f8e68de2bb Merge pull request #1242 from Fr1ngg/revert-1241-4g4jc1-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Adjust connect button links for PC redirect handling"
2025-10-12 00:03:07 +03:00
Egor 6613de548c Revert "Adjust connect button links for PC redirect handling" 2025-10-12 00:02:58 +03:00
Egor 1d3f02d26a Merge pull request #1241 from Fr1ngg/4g4jc1-bedolaga/update-miniapp/index.html-for-new-json-format
Adjust connect button links for PC redirect handling
2025-10-11 23:46:46 +03:00
Egor 77d571980f Refine connect link routing for device-specific flows 2025-10-11 23:46:30 +03:00
Egor db79c538f0 Merge pull request #1240 from Fr1ngg/revert-1239-yooawl-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Ensure desktop connect buttons use Happ redirect"
2025-10-11 23:46:10 +03:00
Egor c066b9929d Revert "Ensure desktop connect buttons use Happ redirect" 2025-10-11 23:46:01 +03:00
Egor ac39a22f61 Merge pull request #1239 from Fr1ngg/yooawl-bedolaga/update-miniapp/index.html-for-new-json-format
Ensure desktop connect buttons use Happ redirect
2025-10-11 23:42:48 +03:00
Egor 814617bd64 Adjust miniapp connect links for device redirects 2025-10-11 23:42:21 +03:00
Egor ba57848b60 Merge pull request #1238 from Fr1ngg/revert-1237-qau9xs-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Enhance installation modal for new config format"
2025-10-11 23:31:27 +03:00
Egor 269cbe6b2c Revert "Enhance installation modal for new config format" 2025-10-11 23:31:19 +03:00
Egor 07d3eff9a0 Merge pull request #1237 from Fr1ngg/qau9xs-bedolaga/update-miniapp/index.html-for-new-json-format
Enhance installation modal for new config format
2025-10-11 23:28:05 +03:00
Egor 4e63a99197 Fix connect links for Happ and platform schemes 2025-10-11 23:27:49 +03:00
Egor fe36737406 Merge pull request #1236 from Fr1ngg/revert-1235-kq2y5m-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Fix Happ connect link handling"
2025-10-11 23:27:12 +03:00
Egor 48917e47db Revert "Fix Happ connect link handling" 2025-10-11 23:27:04 +03:00
Egor 6f348b00d3 Merge pull request #1235 from Fr1ngg/kq2y5m-bedolaga/update-miniapp/index.html-for-new-json-format
Fix Happ connect link handling
2025-10-11 23:21:14 +03:00
Egor ea9da58e35 Fix Happ connect link handling 2025-10-11 23:20:53 +03:00
Egor 036e4d275d Merge pull request #1234 from Fr1ngg/revert-1233-8a5ts1-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Enhance installation modal for new config format"
2025-10-11 23:20:38 +03:00
Egor cff14989ae Revert "Enhance installation modal for new config format" 2025-10-11 23:20:29 +03:00
Egor 10da004e5d Merge pull request #1233 from Fr1ngg/8a5ts1-bedolaga/update-miniapp/index.html-for-new-json-format
Enhance installation modal for new config format
2025-10-11 21:13:36 +03:00
Egor ed0f62542f Enhance installation modal for new config format 2025-10-11 21:13:17 +03:00
Egor cc2b7c2036 Update app-config.json 2025-10-11 20:34:26 +03:00
Egor d3a87d62b4 Update app-config.json 2025-10-11 20:34:14 +03:00
Egor ef70f176dd Update Dockerfile 2025-10-11 09:06:35 +03:00
Egor 7a111bae6c Update docker-registry.yml 2025-10-11 09:06:18 +03:00
Egor 3132b32545 Update docker-hub.yml 2025-10-11 09:06:07 +03:00
Egor 10da3595b9 Merge pull request #1231 from Fr1ngg/dev4
Личный кабинет в миниапке
2025-10-11 08:47:20 +03:00
Egor f0f3f3508f Merge pull request #1230 from Fr1ngg/jb1s1w-bedolaga/fix-admin-notifications-for-miniapp-index
Add admin notifications to miniapp subscription flows
2025-10-11 08:39:43 +03:00
Egor 60b85911e1 Add admin notifications to miniapp subscription flows 2025-10-11 08:39:19 +03:00
Egor e78d75a440 Merge pull request #1229 from Fr1ngg/r8bqjd-bedolaga/fix-admin-notification-error
Fix admin topup notifications without DB session
2025-10-11 08:25:27 +03:00
Egor de57db14ea Delay first-topup flag until after referral processing 2025-10-11 08:25:11 +03:00
Egor b85d5083a9 Merge pull request #1228 from Fr1ngg/revert-1227-50t4gr-bedolaga/fix-admin-notification-error
Revert "Fix admin topup notifications without DB session"
2025-10-11 08:24:58 +03:00
Egor 862fcc53c4 Revert "Fix admin topup notifications without DB session" 2025-10-11 08:24:50 +03:00
Egor 42f1757508 Merge pull request #1227 from Fr1ngg/50t4gr-bedolaga/fix-admin-notification-error
Fix admin topup notifications without DB session
2025-10-11 08:02:43 +03:00
Egor 50279979ce Fix admin topup notifications without DB session 2025-10-11 08:02:25 +03:00
Egor ec00fe12a0 Merge pull request #1226 from Fr1ngg/0y4863-bedolaga/fix-mobile-modal-to-cover-full-screen
Make installation modal full screen on mobile
2025-10-11 07:46:06 +03:00
Egor 0806ed9f1e Make installation modal full screen on mobile 2025-10-11 07:45:53 +03:00
Egor 11b886d6ca Merge pull request #1225 from Fr1ngg/revert-1224-g7l4tv-bedolaga/fix-mobile-modal-to-cover-full-screen
Revert "Make subscription modal full-screen on mobile"
2025-10-11 07:45:23 +03:00
Egor 291695395f Revert "Make subscription modal full-screen on mobile" 2025-10-11 07:45:15 +03:00
Egor f787dfef04 Merge pull request #1224 from Fr1ngg/g7l4tv-bedolaga/fix-mobile-modal-to-cover-full-screen
Make subscription modal full-screen on mobile
2025-10-11 07:38:17 +03:00
Egor edc6b766cb Adjust mobile modal to full-screen 2025-10-11 07:38:03 +03:00
Egor da2d2c826a Merge pull request #1223 from Fr1ngg/revert-1222-ar94nr-bedolaga/fix-mobile-modal-to-cover-full-screen
Revert "Adjust subscription modal layout on mobile"
2025-10-11 07:37:44 +03:00
Egor 0ad1f58fd4 Revert "Adjust subscription modal layout on mobile" 2025-10-11 07:37:37 +03:00
Egor 9f398dbbe5 Merge pull request #1222 from Fr1ngg/ar94nr-bedolaga/fix-mobile-modal-to-cover-full-screen
Adjust subscription modal layout on mobile
2025-10-11 07:34:21 +03:00
Egor 3a33928c9e Adjust subscription modal layout on mobile 2025-10-11 07:31:34 +03:00
Egor cf77b38fe7 Merge pull request #1221 from Fr1ngg/ql23rs-bedolaga/fix-subscription-block-height-for-mobile
Adjust mini app subscription card height for promo discounts
2025-10-11 07:12:47 +03:00
Egor 3ab2dc0484 Adjust purchase card layout for promo discounts 2025-10-11 07:12:34 +03:00
Egor 27b5f70712 Merge pull request #1220 from Fr1ngg/revert-1219-dhoqjs-bedolaga/fix-layout-for-subscription-block-on-mobile
Revert "Adjust mobile spacing in mini app subscription purchase section"
2025-10-11 07:02:07 +03:00
Egor 05e47e3a1c Revert "Adjust mobile spacing in mini app subscription purchase section" 2025-10-11 07:01:59 +03:00
Egor e85a6dd9e0 Merge pull request #1219 from Fr1ngg/dhoqjs-bedolaga/fix-layout-for-subscription-block-on-mobile
Adjust mobile spacing in mini app subscription purchase section
2025-10-11 06:59:43 +03:00
Egor 595c7ce5bb Adjust mobile spacing for subscription actions 2025-10-11 06:59:27 +03:00
Egor 46e3433b55 Merge pull request #1218 from Fr1ngg/revert-1217-fm92lt-bedolaga/fix-layout-for-subscription-block-on-mobile
Revert "Fix subscription action spacing on narrow screens"
2025-10-11 06:58:55 +03:00
Egor f7577c1acc Revert "Fix subscription action spacing on narrow screens" 2025-10-11 06:58:46 +03:00
Egor 2e57522a58 Merge pull request #1217 from Fr1ngg/fm92lt-bedolaga/fix-layout-for-subscription-block-on-mobile
Fix subscription action spacing on narrow screens
2025-10-11 06:57:08 +03:00
Egor a3ca5725a6 Adjust purchase actions spacing on narrow screens 2025-10-11 06:56:53 +03:00
Egor 3a09240019 Merge pull request #1216 from Fr1ngg/revert-1215-uv8nkp-bedolaga/fix-layout-for-subscription-block-on-mobile
Revert "Adjust mobile spacing for subscription purchase actions"
2025-10-11 06:56:03 +03:00
Egor b3ad09f0a1 Revert "Adjust mobile spacing for subscription purchase actions" 2025-10-11 06:55:54 +03:00
Egor de515862f4 Merge pull request #1215 from Fr1ngg/uv8nkp-bedolaga/fix-layout-for-subscription-block-on-mobile
Adjust mobile spacing for subscription purchase actions
2025-10-11 06:54:18 +03:00
Egor c49e8d6c18 Adjust purchase summary spacing on small screens 2025-10-11 06:53:57 +03:00
Egor decafb1d0e Merge pull request #1214 from Fr1ngg/uw7hjl-bedolaga/update-subscription-block-design-on-miniapp/index.html
Align subscription period buttons with traffic cards
2025-10-11 06:44:09 +03:00
Egor 8f19a5e205 Align subscription period buttons with traffic cards 2025-10-11 06:43:52 +03:00
Egor fc75009e9b Merge pull request #1213 from Fr1ngg/aug6ak-bedolaga/redesign-subscription-section-for-mobile
Improve mobile subscription period layout and move copy link button
2025-10-11 06:20:15 +03:00
Egor c753f2f67e Improve subscription purchase mobile layout 2025-10-11 06:19:49 +03:00
Egor 5a260c00d0 Merge pull request #1212 from Fr1ngg/revert-1211-ddsp1p-bedolaga/redesign-subscription-section-for-mobile
Revert "Improve mobile subscription layout and relocate copy button"
2025-10-11 06:19:20 +03:00
Egor 6f690a672e Revert "Improve mobile subscription layout and relocate copy button" 2025-10-11 06:19:11 +03:00
Egor 3b2c560947 Merge pull request #1211 from Fr1ngg/ddsp1p-bedolaga/redesign-subscription-section-for-mobile
Improve mobile subscription layout and relocate copy button
2025-10-11 06:11:04 +03:00
Egor fb1641c5ba Improve mobile subscription layout and relocate copy button 2025-10-11 06:10:20 +03:00
Egor 91615ec4b9 Merge pull request #1210 from Fr1ngg/yh3i06-bedolaga/modify-auto-payment-feature-in-miniapp
Honor mini app autopay default day
2025-10-11 05:48:27 +03:00
Egor fe98d4391f Allow same-day autopay normalization 2025-10-11 05:48:04 +03:00
Egor 76f8ccdd85 Merge pull request #1209 from Fr1ngg/revert-1208-iyctz5-bedolaga/modify-auto-payment-feature-in-miniapp
Revert "Honor mini app autopay default day"
2025-10-11 05:47:44 +03:00
Egor 45bc3bc15c Revert "Honor mini app autopay default day" 2025-10-11 05:47:35 +03:00
Egor 0975fdea4d Merge pull request #1208 from Fr1ngg/iyctz5-bedolaga/modify-auto-payment-feature-in-miniapp
Honor mini app autopay default day
2025-10-11 05:34:45 +03:00
Egor 61bf48b7fa Honor mini app autopay default day 2025-10-11 05:34:05 +03:00
Egor 0ff4dd4321 Merge pull request #1207 from Fr1ngg/revert-1206-y71m00-bedolaga/modify-auto-payment-feature-in-miniapp
Revert "Ensure mini app autopay shows all day options"
2025-10-11 05:33:50 +03:00
Egor e0edc6bcdc Revert "Ensure mini app autopay shows all day options" 2025-10-11 05:33:42 +03:00
Egor 14e58421e4 Merge pull request #1206 from Fr1ngg/y71m00-bedolaga/modify-auto-payment-feature-in-miniapp
Ensure mini app autopay shows all day options
2025-10-11 05:19:16 +03:00
Egor b24ba2e75f Ensure mini app autopay shows all day options 2025-10-11 05:19:02 +03:00
Egor d9be872581 Merge pull request #1203 from Fr1ngg/revert-1202-1ji1v8-bedolaga/modify-auto-payment-feature-in-miniapp
Revert "Connect mini app autopay UI to backend"
2025-10-11 05:18:29 +03:00
Egor 30ab648784 Merge pull request #1205 from Fr1ngg/revert-1204-y7k7f9-bedolaga/modify-auto-payment-feature-in-miniapp
Revert "Fix autopay day parsing in mini app"
2025-10-11 05:18:09 +03:00
Egor b730de4175 Revert "Fix autopay day parsing in mini app" 2025-10-11 05:16:39 +03:00
Egor 6bfb484a9b Merge pull request #1204 from Fr1ngg/y7k7f9-bedolaga/modify-auto-payment-feature-in-miniapp
Fix autopay day parsing in mini app
2025-10-11 05:15:16 +03:00
Egor 0e6f6d0908 Handle autopay day labels when parsing options 2025-10-11 05:14:54 +03:00
Egor 90a495e942 Revert "Connect mini app autopay UI to backend" 2025-10-11 05:14:25 +03:00
Egor 0182e190a8 Merge pull request #1202 from Fr1ngg/1ji1v8-bedolaga/modify-auto-payment-feature-in-miniapp
Connect mini app autopay UI to backend
2025-10-11 05:05:57 +03:00
Egor 30274dce20 Connect mini app autopay UI to backend 2025-10-11 05:05:36 +03:00
Egor 0098cc724f Merge pull request #1201 from Fr1ngg/revert-1200-mt5flx-bedolaga/modify-auto-payment-feature-in-miniapp
Revert "Enable mini app autopay controls"
2025-10-11 05:05:23 +03:00
Egor d9cf4008fe Revert "Enable mini app autopay controls" 2025-10-11 05:05:15 +03:00
Egor ca83cf5349 Merge pull request #1200 from Fr1ngg/mt5flx-bedolaga/modify-auto-payment-feature-in-miniapp
Enable mini app autopay controls
2025-10-11 05:02:11 +03:00
Egor bf5e267822 Add mini app autopay endpoints and payloads 2025-10-11 05:01:55 +03:00
Egor b51a0c04bc Merge pull request #1199 from Fr1ngg/revert-1198-c5f5ag-bedolaga/modify-auto-payment-feature-in-miniapp
Revert "Revamp autopay settings UI in mini app"
2025-10-11 05:01:18 +03:00
Egor 42e19de19c Revert "Revamp autopay settings UI in mini app" 2025-10-11 05:01:09 +03:00
Egor 56c5e04c76 Merge pull request #1198 from Fr1ngg/c5f5ag-bedolaga/modify-auto-payment-feature-in-miniapp
Revamp autopay settings UI in mini app
2025-10-11 04:44:56 +03:00
Egor 556f9dbd45 Revamp mini-app autopay controls 2025-10-11 04:44:41 +03:00
Egor 562fe9dccb Merge pull request #1197 from Fr1ngg/revert-1196-qbc98z-bedolaga/redesign-autopayment-feature-in-miniapp/index.html
Revert "Add autopay toggle controls to miniapp UI"
2025-10-11 04:44:29 +03:00
Egor 960063fdca Revert "Add autopay toggle controls to miniapp UI" 2025-10-11 04:44:04 +03:00
Egor 4a4e7406c3 Merge pull request #1196 from Fr1ngg/qbc98z-bedolaga/redesign-autopayment-feature-in-miniapp/index.html
Add autopay toggle controls to miniapp UI
2025-10-11 04:39:33 +03:00
Egor fca88c5e08 feat: redesign autopay controls in miniapp 2025-10-11 04:39:13 +03:00
Egor 55dcb61798 Merge pull request #1195 from Fr1ngg/k66ujv-bedolaga/refactor-subscribe-button-in-miniapp/index.html
fix: restore connect fallback when instructions missing
2025-10-11 04:08:28 +03:00
Egor 3d9cc65336 fix: keep connect action without instructions 2025-10-11 04:08:08 +03:00
Egor 61c69e6827 Merge pull request #1194 from Fr1ngg/revert-1193-2s0o2x-bedolaga/refactor-subscribe-button-in-miniapp/index.html
Revert "fix: hide connect button for inactive users"
2025-10-11 04:07:30 +03:00
Egor 246c9d3935 Revert "fix: hide connect button for inactive users" 2025-10-11 04:07:21 +03:00
Egor 7f6b0f4190 Merge pull request #1193 from Fr1ngg/2s0o2x-bedolaga/refactor-subscribe-button-in-miniapp/index.html
fix: hide connect button for inactive users
2025-10-11 03:53:31 +03:00
Egor 9e495e9f64 fix: hide connect button for inactive users 2025-10-11 03:53:15 +03:00
Egor 58bc8e97ee Merge pull request #1192 from Fr1ngg/revert-1191-revert-1186-revert-1185-tl4a1p-bedolaga/refactor-subscribe-button-in-miniapp/index.html
Revert "Revert "Revert "feat: redesign miniapp connect flow"""
2025-10-11 03:52:43 +03:00
Egor f491a6bf00 Revert "Revert "Revert "feat: redesign miniapp connect flow""" 2025-10-11 03:51:48 +03:00
Egor 8abcd0707d Merge pull request #1191 from Fr1ngg/revert-1186-revert-1185-tl4a1p-bedolaga/refactor-subscribe-button-in-miniapp/index.html
Revert "Revert "feat: redesign miniapp connect flow""
2025-10-11 03:30:25 +03:00
Egor b4ac9cd2bc Revert "Revert "feat: redesign miniapp connect flow"" 2025-10-11 03:30:15 +03:00
Egor b18759b69f Merge pull request #1190 from Fr1ngg/revert-1187-revert-1184-ulw2na-bedolaga/update-miniapp/index.html-logic
Revert "Revert "Hide connection lists until subscription is active""
2025-10-11 03:29:57 +03:00
Egor d327ad9379 Revert "Revert "Hide connection lists until subscription is active"" 2025-10-11 03:29:49 +03:00
Egor cfe86bd8d3 Merge pull request #1189 from Fr1ngg/revert-1188-84cuhz-bedolaga/update-miniapp/index.html-logic
Revert "Add floating connect button for installation guide"
2025-10-11 03:29:42 +03:00
Egor 5cb4479a70 Revert "Add floating connect button for installation guide" 2025-10-11 03:29:35 +03:00
Egor 64664fa30f Merge pull request #1188 from Fr1ngg/84cuhz-bedolaga/update-miniapp/index.html-logic
Add floating connect button for installation guide
2025-10-11 03:27:20 +03:00
Egor 15ed087142 Refine mini app connect entry point 2025-10-11 03:27:06 +03:00
Egor be6d358ed0 Merge pull request #1187 from Fr1ngg/revert-1184-ulw2na-bedolaga/update-miniapp/index.html-logic
Revert "Hide connection lists until subscription is active"
2025-10-11 03:26:20 +03:00
Egor dd77ecae9d Revert "Hide connection lists until subscription is active" 2025-10-11 03:26:12 +03:00
Egor edfb325ccd Merge pull request #1186 from Fr1ngg/revert-1185-tl4a1p-bedolaga/refactor-subscribe-button-in-miniapp/index.html
Revert "feat: redesign miniapp connect flow"
2025-10-11 03:25:40 +03:00
Egor a4a6685dbb Revert "feat: redesign miniapp connect flow" 2025-10-11 03:25:32 +03:00
Egor ae9d32ceb4 Merge pull request #1185 from Fr1ngg/tl4a1p-bedolaga/refactor-subscribe-button-in-miniapp/index.html
feat: redesign miniapp connect flow
2025-10-11 03:22:14 +03:00
Egor fa12448068 feat: redesign connect button and installation guide 2025-10-11 03:21:58 +03:00
Egor a72b7ce3c8 Merge pull request #1184 from Fr1ngg/ulw2na-bedolaga/update-miniapp/index.html-logic
Hide connection lists until subscription is active
2025-10-11 03:07:02 +03:00
Egor 1bf150f45a Hide connected resources without subscription 2025-10-11 03:06:43 +03:00
Egor e0633c409d Merge pull request #1183 from Fr1ngg/revert-1182-8soheo-bedolaga/update-miniapp/index.html-logic
Revert "Remove purchase action from missing subscription card"
2025-10-11 03:06:30 +03:00
Egor 2621b2f8be Revert "Remove purchase action from missing subscription card" 2025-10-11 03:06:22 +03:00
Egor ba1394c35f Merge pull request #1182 from Fr1ngg/8soheo-bedolaga/update-miniapp/index.html-logic
Remove purchase action from missing subscription card
2025-10-11 02:32:33 +03:00
Egor 1072b64af8 Remove duplicate purchase button from missing subscription card 2025-10-11 02:32:18 +03:00
Egor d3584d52e8 Merge pull request #1181 from Fr1ngg/revert-1180-m51uu7-bedolaga/update-miniapp/index.html-logic
Revert "Restore mini app trial activation and purchase flow"
2025-10-11 02:31:20 +03:00
Egor f32b9242ba Revert "Restore mini app trial activation and purchase flow" 2025-10-11 02:31:12 +03:00
Egor 516a1f3e52 Merge pull request #1180 from Fr1ngg/m51uu7-bedolaga/update-miniapp/index.html-logic
Restore mini app trial activation and purchase flow
2025-10-11 02:21:38 +03:00
Egor 57906b1df9 Fix mini app trial activation and purchase flow 2025-10-11 02:21:22 +03:00
Egor 83865dd0b9 Merge pull request #1179 from Fr1ngg/revert-1178-s7vtov-bedolaga/update-miniapp/index.html-logic
Revert "Fix mini app trial activation and purchase options"
2025-10-11 02:21:04 +03:00
Egor 69553d2551 Revert "Fix mini app trial activation and purchase options" 2025-10-11 02:20:55 +03:00
Egor 02a8b932a3 Merge pull request #1178 from Fr1ngg/s7vtov-bedolaga/update-miniapp/index.html-logic
Fix mini app trial activation and purchase options
2025-10-11 02:14:05 +03:00
Egor b27624021f Fix mini app trial activation and purchase options 2025-10-11 02:13:48 +03:00
Egor 4fde44eefd Merge pull request #1177 from Fr1ngg/revert-1176-revert-1171-revert-1170-2xx781-bedolaga/update-miniapp/index.html-logic
Revert "Revert "Revert "Polish mini app subscription state handling"""
2025-10-11 02:13:20 +03:00
Egor 37f5977efd Revert "Revert "Revert "Polish mini app subscription state handling""" 2025-10-11 02:13:13 +03:00
Egor a9cfbb4a4d Merge pull request #1176 from Fr1ngg/revert-1171-revert-1170-2xx781-bedolaga/update-miniapp/index.html-logic
Revert "Revert "Polish mini app subscription state handling""
2025-10-11 02:00:50 +03:00
Egor c7beec1df0 Revert "Revert "Polish mini app subscription state handling"" 2025-10-11 02:00:39 +03:00
Egor 172c3426b9 Merge pull request #1175 from Fr1ngg/revert-1174-cvpz16-bedolaga/update-miniapp/index.html-logic
Revert "Improve miniapp onboarding for unsubscribed users"
2025-10-11 01:58:32 +03:00
Egor a616987eab Revert "Improve miniapp onboarding for unsubscribed users" 2025-10-11 01:58:25 +03:00
Egor 0742d7cdc9 Merge pull request #1174 from Fr1ngg/cvpz16-bedolaga/update-miniapp/index.html-logic
Improve miniapp onboarding for unsubscribed users
2025-10-11 01:57:16 +03:00
Egor f10a84399b Improve miniapp onboarding for unsubscribed users 2025-10-11 01:56:56 +03:00
Egor 6d2eb30704 Merge pull request #1173 from Fr1ngg/revert-1172-3gh1f0-bedolaga/update-miniapp/index.html-logic
Revert "Handle miniapp flows for unregistered and inactive users"
2025-10-11 01:56:37 +03:00
Egor 618a5153f6 Revert "Handle miniapp flows for unregistered and inactive users" 2025-10-11 01:56:28 +03:00
Egor 292378ebbc Merge pull request #1172 from Fr1ngg/3gh1f0-bedolaga/update-miniapp/index.html-logic
Handle miniapp flows for unregistered and inactive users
2025-10-11 01:55:12 +03:00
Egor 645cca088a Handle miniapp flows for unregistered and inactive users 2025-10-11 01:54:46 +03:00
Egor 4159689a4e Merge pull request #1171 from Fr1ngg/revert-1170-2xx781-bedolaga/update-miniapp/index.html-logic
Revert "Polish mini app subscription state handling"
2025-10-11 01:53:55 +03:00
Egor dd2ec37bec Revert "Polish mini app subscription state handling" 2025-10-11 01:53:47 +03:00
Egor 125131c8e6 Merge pull request #1170 from Fr1ngg/2xx781-bedolaga/update-miniapp/index.html-logic
Polish mini app subscription state handling
2025-10-11 01:52:14 +03:00
Egor 3b895b73f9 Polish mini app subscription state handling 2025-10-11 01:51:57 +03:00
Egor 39c915acc4 Merge pull request #1169 from Fr1ngg/revert-1168-74stxg-bedolaga/update-miniapp/index.html-logic
Revert "Update mini app onboarding for unregistered and inactive users"
2025-10-11 01:10:29 +03:00
Egor 8f08489939 Revert "Update mini app onboarding for unregistered and inactive users" 2025-10-11 01:10:22 +03:00
Egor baf69495d6 Merge pull request #1168 from Fr1ngg/74stxg-bedolaga/update-miniapp/index.html-logic
Update mini app onboarding for unregistered and inactive users
2025-10-11 01:08:29 +03:00
Egor 7e9c1bd784 Update mini app states for unregistered and inactive users 2025-10-11 01:08:11 +03:00
Egor 509e7ffe14 Merge pull request #1167 from Fr1ngg/revert-1166-revert-1157-revert-1156-t3tlcj-bedolaga/update-miniapp/index.html-logic
Revert "Revert "Revert "Handle registration and missing subscription states in mini app"""
2025-10-11 01:07:39 +03:00
Egor e4547b3ca0 Revert "Revert "Revert "Handle registration and missing subscription states in mini app""" 2025-10-11 01:07:31 +03:00
Egor 5d638dbba9 Merge pull request #1166 from Fr1ngg/revert-1157-revert-1156-t3tlcj-bedolaga/update-miniapp/index.html-logic
Revert "Revert "Handle registration and missing subscription states in mini app""
2025-10-11 01:06:12 +03:00
Egor d0390bb432 Revert "Revert "Handle registration and missing subscription states in mini app"" 2025-10-11 01:06:00 +03:00
Egor 66eaadd628 Merge pull request #1165 from Fr1ngg/revert-1164-revert-1153-revert-1152-7gq87l-bedolaga/update-miniapp/index.html-logic
Revert "Revert "Revert "Improve miniapp onboarding states for unregistered users"""
2025-10-11 01:05:02 +03:00
Egor 8a5b5599cc Revert "Revert "Revert "Improve miniapp onboarding states for unregistered users""" 2025-10-11 01:04:48 +03:00
Egor a7935f3e88 Merge pull request #1164 from Fr1ngg/revert-1153-revert-1152-7gq87l-bedolaga/update-miniapp/index.html-logic
Revert "Revert "Improve miniapp onboarding states for unregistered users""
2025-10-11 01:02:37 +03:00
Egor bbb89633c3 Revert "Revert "Improve miniapp onboarding states for unregistered users"" 2025-10-11 01:02:29 +03:00
Egor 40a690b4bd Merge pull request #1163 from Fr1ngg/revert-1162-revert-1155-revert-1154-c3rvdj-bedolaga/update-miniapp/index.html-logic
Revert "Revert "Revert "Handle registration and empty subscription states in mini app"""
2025-10-11 01:01:55 +03:00
Egor 4f06a183be Revert "Revert "Revert "Handle registration and empty subscription states in mini app""" 2025-10-11 01:01:47 +03:00
Egor 6701a63a0a Merge pull request #1162 from Fr1ngg/revert-1155-revert-1154-c3rvdj-bedolaga/update-miniapp/index.html-logic
Revert "Revert "Handle registration and empty subscription states in mini app""
2025-10-11 01:00:52 +03:00
Egor 905e1f1861 Revert "Revert "Handle registration and empty subscription states in mini app"" 2025-10-11 01:00:44 +03:00
Egor fe91fc1c29 Merge pull request #1161 from Fr1ngg/revert-1160-u5iv6j-bedolaga/update-miniapp/index.html-logic
Revert "Handle registration and empty subscription states in miniapp"
2025-10-11 00:59:41 +03:00
Egor df8b85a179 Revert "Handle registration and empty subscription states in miniapp" 2025-10-11 00:59:34 +03:00
Egor 8d08b23d0f Merge pull request #1160 from Fr1ngg/u5iv6j-bedolaga/update-miniapp/index.html-logic
Handle registration and empty subscription states in miniapp
2025-10-11 00:57:30 +03:00
Egor ce087beea6 Add tests for miniapp subscription responses 2025-10-11 00:57:12 +03:00
Egor e63d2158c3 Merge pull request #1159 from Fr1ngg/revert-1158-adv2s5-bedolaga/update-miniapp/index.html-logic
Revert "feat: improve miniapp onboarding states"
2025-10-11 00:56:46 +03:00
Egor eb64d8ed3a Revert "feat: improve miniapp onboarding states" 2025-10-11 00:56:37 +03:00
Egor fc4e17d995 Merge pull request #1158 from Fr1ngg/adv2s5-bedolaga/update-miniapp/index.html-logic
feat: improve miniapp onboarding states
2025-10-11 00:53:54 +03:00
Egor f22fd67b44 feat: improve miniapp onboarding states 2025-10-11 00:53:37 +03:00
Egor e9ba19011a Merge pull request #1157 from Fr1ngg/revert-1156-t3tlcj-bedolaga/update-miniapp/index.html-logic
Revert "Handle registration and missing subscription states in mini app"
2025-10-11 00:53:03 +03:00
Egor 6569e45d9c Revert "Handle registration and missing subscription states in mini app" 2025-10-11 00:52:55 +03:00
Egor bc12bf47d6 Merge pull request #1156 from Fr1ngg/t3tlcj-bedolaga/update-miniapp/index.html-logic
Handle registration and missing subscription states in mini app
2025-10-11 00:51:54 +03:00
Egor e731ccc055 Add mini app states for registration and subscription purchase 2025-10-11 00:51:36 +03:00
Egor c648e78e34 Merge pull request #1155 from Fr1ngg/revert-1154-c3rvdj-bedolaga/update-miniapp/index.html-logic
Revert "Handle registration and empty subscription states in mini app"
2025-10-11 00:51:17 +03:00
Egor ef073bd307 Revert "Handle registration and empty subscription states in mini app" 2025-10-11 00:51:09 +03:00
Egor dde44a8afd Merge pull request #1154 from Fr1ngg/c3rvdj-bedolaga/update-miniapp/index.html-logic
Handle registration and empty subscription states in mini app
2025-10-11 00:48:58 +03:00
Egor 0981aa6efa Adjust mini app states for unregistered and inactive users 2025-10-11 00:47:22 +03:00
Egor 2b71e1701c Merge pull request #1153 from Fr1ngg/revert-1152-7gq87l-bedolaga/update-miniapp/index.html-logic
Revert "Improve miniapp onboarding states for unregistered users"
2025-10-11 00:47:04 +03:00
Egor 2bd5c3e061 Revert "Improve miniapp onboarding states for unregistered users" 2025-10-11 00:46:56 +03:00
Egor 8f39c69f22 Merge pull request #1152 from Fr1ngg/7gq87l-bedolaga/update-miniapp/index.html-logic
Improve miniapp onboarding states for unregistered users
2025-10-11 00:45:42 +03:00
Egor b4301265ec Improve miniapp states for unregistered users 2025-10-11 00:45:24 +03:00
Egor 7d793eff6a Merge pull request #1151 from Fr1ngg/revert-1150-k49afg-bedolaga/update-miniapp/index.html-logic
Revert "feat: improve miniapp onboarding states"
2025-10-11 00:44:49 +03:00
Egor 48e1485cac Revert "feat: improve miniapp onboarding states" 2025-10-11 00:44:41 +03:00
Egor f548f186de Merge pull request #1150 from Fr1ngg/k49afg-bedolaga/update-miniapp/index.html-logic
feat: improve miniapp onboarding states
2025-10-11 00:42:36 +03:00
Egor 97ae7b6b1b feat: improve miniapp onboarding states 2025-10-11 00:42:21 +03:00
Egor ac06b039a9 Merge pull request #1149 from Fr1ngg/9kilqp-bedolaga/fix-layout-for-subscription-button
Stop clipping top-up button in subscription purchase card
2025-10-11 00:24:16 +03:00
Egor 8514f8604a Ensure top-up button stays visible in subscription card 2025-10-11 00:23:59 +03:00
Egor a22b04a91e Merge pull request #1148 from Fr1ngg/revert-1147-hehyjo-bedolaga/fix-layout-for-subscription-button
Revert "Ensure subscription top-up button remains visible"
2025-10-11 00:23:47 +03:00
Egor 11f5289f2d Revert "Ensure subscription top-up button remains visible" 2025-10-11 00:23:29 +03:00
Egor c72886d45a Merge pull request #1147 from Fr1ngg/hehyjo-bedolaga/fix-layout-for-subscription-button
Ensure subscription top-up button remains visible
2025-10-11 00:20:21 +03:00
Egor fbfd59ca83 Prevent purchase top-up button clipping 2025-10-11 00:20:03 +03:00
Egor 1737791659 Merge pull request #1146 from Fr1ngg/revert-1143-t4unz2-bedolaga/fix-layout-for-subscription-button
Revert "Refine subscription period option layout"
2025-10-11 00:19:04 +03:00
Egor 3a8112b4ec Revert "Refine subscription period option layout" 2025-10-11 00:18:56 +03:00
Egor cfdb7b6bdd Merge pull request #1145 from Fr1ngg/revert-1144-q2di6m-bedolaga/fix-button-cropping-in-subscription-section
Revert "Fix subscription top-up button clipping"
2025-10-11 00:18:49 +03:00
Egor 223a112d88 Revert "Fix subscription top-up button clipping" 2025-10-11 00:18:42 +03:00
Egor b72ad3e090 Merge pull request #1144 from Fr1ngg/q2di6m-bedolaga/fix-button-cropping-in-subscription-section
Fix subscription top-up button clipping
2025-10-11 00:16:22 +03:00
Egor e81b555e43 Fix subscription top-up button clipping 2025-10-11 00:16:03 +03:00
Egor 8214775ded Merge pull request #1143 from Fr1ngg/t4unz2-bedolaga/fix-layout-for-subscription-button
Refine subscription period option layout
2025-10-11 00:03:04 +03:00
Egor 3bb5727089 Improve subscription period card layout 2025-10-11 00:02:35 +03:00
Egor 39677eb82e Merge pull request #1141 from Fr1ngg/revert-1140-7tajud-bedolaga/fix-layout-for-subscription-button
Revert "Revamp subscription purchase traffic and device UI"
2025-10-11 00:01:58 +03:00
Egor c56cdf2383 Revert "Revamp subscription purchase traffic and device UI" 2025-10-11 00:01:48 +03:00
Egor a40c4dd7e9 Merge pull request #1140 from Fr1ngg/7tajud-bedolaga/fix-layout-for-subscription-button
Revamp subscription purchase traffic and device UI
2025-10-10 23:48:55 +03:00
Egor a09fd825bf Refine purchase selections layout 2025-10-10 23:48:39 +03:00
Egor 03cf74656e Merge pull request #1139 from Fr1ngg/revert-1137-8hdvnv-bedolaga/fix-layout-for-subscription-button
Revert "Redesign subscription purchase miniapp layout"
2025-10-10 23:48:20 +03:00
Egor 98a301c1b5 Revert "Redesign subscription purchase miniapp layout" 2025-10-10 23:48:12 +03:00
Egor 1c865e3ae8 Merge pull request #1137 from Fr1ngg/8hdvnv-bedolaga/fix-layout-for-subscription-button
Redesign subscription purchase miniapp layout
2025-10-10 23:39:56 +03:00
Egor 01388442b7 Merge pull request #1138 from Fr1ngg/revert-1134-hccafi-bedolaga/fix-layout-for-subscription-button
Revert "Improve subscription purchase CTA spacing on narrow viewports"
2025-10-10 23:39:43 +03:00
Egor 251a940be2 Revamp subscription purchase flow layout 2025-10-10 23:39:36 +03:00
Egor fc725ba9e1 Revert "Improve subscription purchase CTA spacing on narrow viewports" 2025-10-10 23:39:36 +03:00
Egor 9f7e73ae2e Merge pull request #1136 from Fr1ngg/revert-1135-2rmc9c-bedolaga/improve-subscription-purchase-block-design
Revert "Improve subscription purchase UI styling"
2025-10-10 23:38:51 +03:00
Egor d576801676 Revert "Improve subscription purchase UI styling" 2025-10-10 23:38:43 +03:00
Egor 8b82d56635 Merge pull request #1135 from Fr1ngg/2rmc9c-bedolaga/improve-subscription-purchase-block-design
Improve subscription purchase UI styling
2025-10-10 23:37:04 +03:00
Egor b165fafc4d Refine subscription purchase UI 2025-10-10 23:36:47 +03:00
Egor bb9a0b305c Merge pull request #1134 from Fr1ngg/hccafi-bedolaga/fix-layout-for-subscription-button
Improve subscription purchase CTA spacing on narrow viewports
2025-10-10 21:56:05 +03:00
Egor 99b219a307 Improve narrow layout for purchase card 2025-10-10 21:55:49 +03:00
Egor ad2a9b9d14 Merge pull request #1133 from Fr1ngg/revert-1132-mxndh2-bedolaga/fix-layout-for-subscription-button
Revert "Adjust subscription purchase card spacing"
2025-10-10 21:55:27 +03:00
Egor e03bf2a393 Revert "Adjust subscription purchase card spacing" 2025-10-10 21:55:19 +03:00
Egor b6ead34e6c Merge pull request #1132 from Fr1ngg/mxndh2-bedolaga/fix-layout-for-subscription-button
Adjust subscription purchase card spacing
2025-10-10 21:48:42 +03:00
Egor cb180d9c0e Adjust subscription purchase card spacing 2025-10-10 21:48:27 +03:00
Egor 791e26f56a Merge pull request #1131 from Fr1ngg/revert-1130-9a3g3t-bedolaga/fix-layout-for-subscription-button
Revert "Fix spacing around subscription purchase actions"
2025-10-10 21:47:46 +03:00
Egor b0403dab83 Revert "Fix spacing around subscription purchase actions" 2025-10-10 21:47:38 +03:00
Egor e6c3d45991 Merge pull request #1130 from Fr1ngg/9a3g3t-bedolaga/fix-layout-for-subscription-button
Fix spacing around subscription purchase actions
2025-10-10 21:45:13 +03:00
Egor d43597ab2a Improve subscription purchase spacing 2025-10-10 21:44:58 +03:00
Egor b2bf5024a0 Merge pull request #1129 from Fr1ngg/dd0l4x-bedolaga/add-subscription-renewal-section
Ensure subscription purchase actions remain visible
2025-10-10 21:36:59 +03:00
Egor 001f35ed89 Adjust subscription purchase card spacing 2025-10-10 21:36:44 +03:00
Egor 9def85e8e2 Merge pull request #1128 from Fr1ngg/revert-1127-obl5mk-bedolaga/add-subscription-renewal-section
Revert "Improve subscription purchase CTA spacing"
2025-10-10 21:36:27 +03:00
Egor ed92e0cd8e Revert "Improve subscription purchase CTA spacing" 2025-10-10 21:36:19 +03:00
Egor 336cfe440a Merge pull request #1127 from Fr1ngg/obl5mk-bedolaga/add-subscription-renewal-section
Improve subscription purchase CTA spacing
2025-10-10 21:32:36 +03:00
Egor 3c312e089b Improve subscription purchase CTA spacing 2025-10-10 21:32:22 +03:00
Egor d54e42612e Merge pull request #1126 from Fr1ngg/revert-1125-vuqd4o-bedolaga/add-subscription-renewal-section
Revert "Adjust subscription purchase CTA spacing on mobile"
2025-10-10 21:31:50 +03:00
Egor 233520a656 Revert "Adjust subscription purchase CTA spacing on mobile" 2025-10-10 21:31:41 +03:00
Egor aeff150feb Merge pull request #1125 from Fr1ngg/vuqd4o-bedolaga/add-subscription-renewal-section
Adjust subscription purchase CTA spacing on mobile
2025-10-10 21:24:59 +03:00
Egor f0d67e2428 Increase purchase CTA spacing for mobile 2025-10-10 21:24:43 +03:00
Egor 72df1d91d5 Merge pull request #1124 from Fr1ngg/revert-1123-jub1oo-bedolaga/add-subscription-renewal-section
Revert "Handle zero-cost renewals and adjust purchase CTA spacing"
2025-10-10 21:24:22 +03:00
Egor 26811cdcff Revert "Handle zero-cost renewals and adjust purchase CTA spacing" 2025-10-10 21:24:14 +03:00
Egor bc26d8352b Merge pull request #1123 from Fr1ngg/jub1oo-bedolaga/add-subscription-renewal-section
Handle zero-cost renewals and adjust purchase CTA spacing
2025-10-10 21:23:03 +03:00
Egor 4fe2e029bc Tighten subscription purchase CTA spacing 2025-10-10 21:22:48 +03:00
Egor f54bf16ef4 Merge pull request #1122 from Fr1ngg/revert-1121-gy8gds-bedolaga/add-subscription-renewal-section
Revert "Adjust purchase CTA spacing on mobile"
2025-10-10 21:22:14 +03:00
Egor 7dfa5d5a6d Revert "Adjust purchase CTA spacing on mobile" 2025-10-10 21:22:05 +03:00
Egor ac1f6120ab Merge pull request #1121 from Fr1ngg/gy8gds-bedolaga/add-subscription-renewal-section
Adjust purchase CTA spacing on mobile
2025-10-10 21:18:45 +03:00
Egor 26c9896b51 Adjust purchase CTA spacing on mobile 2025-10-10 21:18:22 +03:00
Egor 3b7323c6f4 Merge pull request #1120 from Fr1ngg/revert-1119-byeh3b-bedolaga/add-subscription-renewal-section
Revert "Handle zero-cost renewals and adjust purchase CTA spacing"
2025-10-10 21:18:06 +03:00
Egor 956936185a Revert "Handle zero-cost renewals and adjust purchase CTA spacing" 2025-10-10 21:17:57 +03:00
Egor 435c2d98f1 Merge pull request #1119 from Fr1ngg/byeh3b-bedolaga/add-subscription-renewal-section
Handle zero-cost renewals and adjust purchase CTA spacing
2025-10-10 21:06:22 +03:00
Egor 7f24bd7c8c Tighten renewal promo consumption and polish purchase CTA 2025-10-10 21:06:08 +03:00
Egor b15781ad3b Merge pull request #1118 from Fr1ngg/revert-1117-revert-1114-revert-1113-52037z-bedolaga/add-subscription-renewal-section
Revert "Revert "Revert "Handle zero-cost renewals and polish purchase button layout"""
2025-10-10 21:05:31 +03:00
Egor c598d66ecc Revert "Revert "Revert "Handle zero-cost renewals and polish purchase button layout""" 2025-10-10 21:05:23 +03:00
Egor ffc969daaa Merge pull request #1117 from Fr1ngg/revert-1114-revert-1113-52037z-bedolaga/add-subscription-renewal-section
Revert "Revert "Handle zero-cost renewals and polish purchase button layout""
2025-10-10 21:05:00 +03:00
Egor 116201cca3 Revert "Revert "Handle zero-cost renewals and polish purchase button layout"" 2025-10-10 21:04:52 +03:00
Egor a7592836da Merge pull request #1116 from Fr1ngg/revert-1115-hhoosa-bedolaga/add-subscription-renewal-section
Revert "Ensure free renewal promos are consumed and tweak mobile CTA"
2025-10-10 21:04:44 +03:00
Egor 734c3679ce Revert "Ensure free renewal promos are consumed and tweak mobile CTA" 2025-10-10 21:04:35 +03:00
Egor d350b627fc Merge pull request #1115 from Fr1ngg/hhoosa-bedolaga/add-subscription-renewal-section
Ensure free renewal promos are consumed and tweak mobile CTA
2025-10-10 21:02:55 +03:00
Egor 4fe483da56 Fix free renewal promo consumption and mobile submit layout 2025-10-10 21:02:29 +03:00
Egor 8b4e1c830b Merge pull request #1114 from Fr1ngg/revert-1113-52037z-bedolaga/add-subscription-renewal-section
Revert "Handle zero-cost renewals and polish purchase button layout"
2025-10-10 21:02:13 +03:00
Egor ebfb653d8b Revert "Handle zero-cost renewals and polish purchase button layout" 2025-10-10 21:02:05 +03:00
Egor 8b4f1672dd Merge pull request #1113 from Fr1ngg/52037z-bedolaga/add-subscription-renewal-section
Handle zero-cost renewals and polish purchase button layout
2025-10-10 20:58:47 +03:00
Egor e48a3f178e Fix promo offer consumption and mobile purchase button 2025-10-10 20:56:23 +03:00
Egor a72ed1c2a1 Merge pull request #1112 from Fr1ngg/revert-1111-wti6kr-bedolaga/add-subscription-renewal-section
Revert "Polish subscription purchase configurator"
2025-10-10 20:55:26 +03:00
Egor 15db2d975f Revert "Polish subscription purchase configurator" 2025-10-10 20:55:17 +03:00
Egor 99ff5f8a9d Merge pull request #1111 from Fr1ngg/wti6kr-bedolaga/add-subscription-renewal-section
Polish subscription purchase configurator
2025-10-10 20:07:44 +03:00
Egor 742093e68c Polish subscription purchase configurator 2025-10-10 20:07:27 +03:00
Egor a72f6caf80 Merge pull request #1110 from Fr1ngg/revert-1109-1q0t4b-bedolaga/add-subscription-renewal-section
Revert "Refine subscription purchase configurator UX"
2025-10-10 20:07:12 +03:00
Egor 605d33053d Revert "Refine subscription purchase configurator UX" 2025-10-10 20:07:03 +03:00
Egor 7a2e83f3d1 Merge pull request #1109 from Fr1ngg/1q0t4b-bedolaga/add-subscription-renewal-section
Refine subscription purchase configurator UX
2025-10-10 20:04:57 +03:00
Egor e7fc1b8402 Polish subscription purchase flow 2025-10-10 20:04:42 +03:00
Egor 5fcdcc5878 Merge pull request #1108 from Fr1ngg/revert-1107-ppvgbo-bedolaga/add-subscription-renewal-section
Revert "Refine subscription purchase presentation"
2025-10-10 20:04:28 +03:00
Egor 4212fc0be3 Revert "Refine subscription purchase presentation" 2025-10-10 20:04:19 +03:00
Egor df4ce40739 Merge pull request #1107 from Fr1ngg/ppvgbo-bedolaga/add-subscription-renewal-section
Refine subscription purchase presentation
2025-10-10 20:02:18 +03:00
Egor b761630505 Improve subscription purchase layout and summary 2025-10-10 20:02:02 +03:00
Egor d1c3358dbf Merge pull request #1106 from Fr1ngg/revert-1105-63c4w3-bedolaga/add-subscription-renewal-section
Revert "Improve subscription purchase layout and discount summary"
2025-10-10 20:01:33 +03:00
Egor 1d1fc7dbfb Revert "Improve subscription purchase layout and discount summary" 2025-10-10 20:01:23 +03:00
Egor 25aaa703a6 Merge pull request #1105 from Fr1ngg/63c4w3-bedolaga/add-subscription-renewal-section
Improve subscription purchase layout and discount summary
2025-10-10 19:59:23 +03:00
Egor 46f3ef23ed Refine subscription purchase configurator 2025-10-10 19:59:08 +03:00
Egor 80517f0c53 Merge pull request #1104 from Fr1ngg/revert-1103-u6qc6m-bedolaga/add-subscription-renewal-section
Revert "Update renewal balance warning per selected period"
2025-10-10 19:58:40 +03:00
Egor 66d1f96e32 Revert "Update renewal balance warning per selected period" 2025-10-10 19:58:31 +03:00
Egor a93c5808c0 Merge pull request #1103 from Fr1ngg/u6qc6m-bedolaga/add-subscription-renewal-section
Update renewal balance warning per selected period
2025-10-10 19:40:56 +03:00
Egor f80818c18a Fix renewal balance warning for period switches 2025-10-10 19:40:38 +03:00
Egor 6199716c09 Merge pull request #1102 from Fr1ngg/revert-1101-revert-1097-revert-1096-h2ud8b-bedolaga/add-subscription-renewal-section
Revert "Revert "Revert "Prevent renewal options from showing phantom discounts"""
2025-10-10 19:39:46 +03:00
Egor ab131ed399 Revert "Revert "Revert "Prevent renewal options from showing phantom discounts""" 2025-10-10 19:39:34 +03:00
Egor 43203bfeb3 Merge pull request #1101 from Fr1ngg/revert-1097-revert-1096-h2ud8b-bedolaga/add-subscription-renewal-section
Revert "Revert "Prevent renewal options from showing phantom discounts""
2025-10-10 12:47:12 +03:00
Egor 4873a26880 Revert "Revert "Prevent renewal options from showing phantom discounts"" 2025-10-10 12:47:03 +03:00
Egor 974b049ee0 Merge pull request #1100 from Fr1ngg/revert-1099-mtpzes-bedolaga/add-subscription-renewal-section
Revert "Prevent renewal prices from showing strike-through without discounts"
2025-10-10 12:46:45 +03:00
Egor 3656ec5df5 Revert "Prevent renewal prices from showing strike-through without discounts" 2025-10-10 12:46:37 +03:00
Egor ef4d343e16 Merge pull request #1099 from Fr1ngg/mtpzes-bedolaga/add-subscription-renewal-section
Prevent renewal prices from showing strike-through without discounts
2025-10-10 12:43:07 +03:00
Egor 71171b24b3 Fix renewal option original price display when no discount 2025-10-10 12:42:46 +03:00
Egor 5ce9fa9fac Merge pull request #1098 from Fr1ngg/revert-1095-e369z3-bedolaga/add-subscription-renewal-section
Revert "Fix renewal original price display without discounts"
2025-10-10 12:42:19 +03:00
Egor 85a702b00a Revert "Fix renewal original price display without discounts" 2025-10-10 12:42:10 +03:00
Egor aab8727de7 Merge pull request #1095 from Fr1ngg/e369z3-bedolaga/add-subscription-renewal-section
Fix renewal original price display without discounts
2025-10-10 12:41:02 +03:00
Egor 813bb45cf9 Merge pull request #1097 from Fr1ngg/revert-1096-h2ud8b-bedolaga/add-subscription-renewal-section
Revert "Prevent renewal options from showing phantom discounts"
2025-10-10 12:40:50 +03:00
Egor 3d731e6b06 Revert "Prevent renewal options from showing phantom discounts" 2025-10-10 12:40:41 +03:00
Egor 8e6174ea13 Merge pull request #1096 from Fr1ngg/h2ud8b-bedolaga/add-subscription-renewal-section
Prevent renewal options from showing phantom discounts
2025-10-10 12:37:53 +03:00
Egor 7a5a1fa8b4 Normalize renewal price comparison when no discount 2025-10-10 12:37:28 +03:00
Egor 1a4c8eff19 Fix renewal price display when no discount 2025-10-10 12:36:39 +03:00
Egor b345a4ce25 Merge pull request #1094 from Fr1ngg/revert-1093-ifww7g-bedolaga/add-subscription-renewal-section
Revert "Fix renewal mini app spacing and discount presentation"
2025-10-10 12:36:04 +03:00
Egor d8c37349ca Revert "Fix renewal mini app spacing and discount presentation" 2025-10-10 12:35:53 +03:00
Egor 4df3b2ea8f Merge pull request #1093 from Fr1ngg/ifww7g-bedolaga/add-subscription-renewal-section
Fix renewal mini app spacing and discount presentation
2025-10-10 12:28:19 +03:00
Egor 8bc683e03a Fix renewal discount display in mini app 2025-10-10 12:27:59 +03:00
Egor c176a9abf2 Merge pull request #1092 from Fr1ngg/revert-1091-revert-1088-revert-1087-bfz03a-bedolaga/add-subscription-renewal-section
Revert "Revert "Revert "Add renewal API for mini app subscription extensions"""
2025-10-10 12:27:32 +03:00
Egor 72553e5f8b Revert "Revert "Revert "Add renewal API for mini app subscription extensions""" 2025-10-10 12:27:22 +03:00
Egor de09965876 Merge pull request #1091 from Fr1ngg/revert-1088-revert-1087-bfz03a-bedolaga/add-subscription-renewal-section
Revert "Revert "Add renewal API for mini app subscription extensions""
2025-10-10 11:58:39 +03:00
Egor 7eb14ad036 Revert "Revert "Add renewal API for mini app subscription extensions"" 2025-10-10 11:58:29 +03:00
Egor 0e0c917fdd Merge pull request #1090 from Fr1ngg/revert-1089-64afzg-bedolaga/fix-subscription-renewal-section-in-miniapp
Revert "Implement mini app subscription renewal flow"
2025-10-10 11:58:25 +03:00
Egor 7eb58d1add Revert "Implement mini app subscription renewal flow" 2025-10-10 11:58:05 +03:00
Egor 405cba645a Merge pull request #1089 from Fr1ngg/64afzg-bedolaga/fix-subscription-renewal-section-in-miniapp
Implement mini app subscription renewal flow
2025-10-10 11:57:18 +03:00
Egor 359b7c807c Implement mini app subscription renewal API 2025-10-10 11:56:51 +03:00
Egor dbed5d9e5d Merge pull request #1088 from Fr1ngg/revert-1087-bfz03a-bedolaga/add-subscription-renewal-section
Revert "Add renewal API for mini app subscription extensions"
2025-10-10 11:56:39 +03:00
Egor f999a31664 Revert "Add renewal API for mini app subscription extensions" 2025-10-10 11:56:29 +03:00
Egor 96c795c789 Merge pull request #1087 from Fr1ngg/bfz03a-bedolaga/add-subscription-renewal-section
Add renewal API for mini app subscription extensions
2025-10-10 11:52:08 +03:00
Egor 874c5d8017 Add renewal API for mini app subscription extensions 2025-10-10 11:51:48 +03:00
Egor 2c76751c63 Merge pull request #1086 from Fr1ngg/revert-1083-8qdgqk-bedolaga/add-subscription-renewal-section
Revert "Add subscription renewal UI to mini app"
2025-10-10 11:51:19 +03:00
Egor 483b99e557 Revert "Add subscription renewal UI to mini app" 2025-10-10 11:51:08 +03:00
Egor b653c45320 Merge pull request #1085 from Fr1ngg/revert-1084-nqyfy2-bedolaga/fix-subscription-renewal-section-in-miniapp
Revert "Implement miniapp subscription renewal API"
2025-10-10 11:51:02 +03:00
Egor 3f0f9c3f4b Revert "Implement miniapp subscription renewal API" 2025-10-10 11:50:52 +03:00
Egor d899dc7d86 Merge pull request #1084 from Fr1ngg/nqyfy2-bedolaga/fix-subscription-renewal-section-in-miniapp
Implement miniapp subscription renewal API
2025-10-10 11:49:03 +03:00
Egor 21567088a1 Add miniapp subscription renewal endpoints 2025-10-10 11:48:47 +03:00
Egor 5425210cb0 Merge pull request #1083 from Fr1ngg/8qdgqk-bedolaga/add-subscription-renewal-section
Add subscription renewal UI to mini app
2025-10-10 11:27:28 +03:00
Egor 0484d69538 Guard renewal summary elements when data is missing 2025-10-10 11:27:10 +03:00
Egor ac5c764daa Merge pull request #1082 from Fr1ngg/revert-1081-uiwlsk-bedolaga/add-subscription-renewal-section
Revert "Add subscription renewal section to miniapp"
2025-10-10 11:26:57 +03:00
Egor 15df6b802c Revert "Add subscription renewal section to miniapp" 2025-10-10 11:26:48 +03:00
Egor a1e1267239 Merge pull request #1081 from Fr1ngg/uiwlsk-bedolaga/add-subscription-renewal-section
Add subscription renewal section to miniapp
2025-10-10 11:20:15 +03:00
Egor 5fcaa73d20 Add subscription renewal section to miniapp 2025-10-10 11:19:58 +03:00
Egor c57d273c7e Merge pull request #1080 from Fr1ngg/zros0m-bedolaga/-api
Update miniapp purchase UI to reflect promo pricing and hide inactive servers
2025-10-10 10:52:31 +03:00
Egor 51a1db0680 Hide inactive miniapp servers during purchase 2025-10-10 10:52:11 +03:00
Egor a3d46cd293 Merge pull request #1079 from Fr1ngg/revert-1078-19ztpa-bedolaga/-api
Revert "Update miniapp purchase UI to reflect promo pricing"
2025-10-10 10:50:29 +03:00
Egor 18ec1c1f5f Revert "Update miniapp purchase UI to reflect promo pricing" 2025-10-10 10:50:20 +03:00
Egor a9d4bea9de Merge pull request #1078 from Fr1ngg/19ztpa-bedolaga/-api
Update miniapp purchase UI to reflect promo pricing
2025-10-10 10:26:18 +03:00
Egor 10b05f95ab Enhance miniapp purchase UI for new pricing data 2025-10-10 10:26:00 +03:00
Egor b5be8e5237 Merge pull request #1077 from Fr1ngg/revert-1076-9jagyo-bedolaga/expand-api-for-subscription-purchases
Revert "Add subscription purchase flow for the mini app"
2025-10-10 10:25:04 +03:00
Egor 4117dcb97f Revert "Add subscription purchase flow for the mini app" 2025-10-10 10:24:55 +03:00
Egor 0cb422d33e Merge pull request #1076 from Fr1ngg/9jagyo-bedolaga/expand-api-for-subscription-purchases
Add subscription purchase flow for the mini app
2025-10-10 10:23:20 +03:00
Egor da1ed55fd7 Add subscription purchase flow for mini app 2025-10-10 10:22:50 +03:00
Egor bb17f1c456 Merge pull request #1075 from Fr1ngg/revert-1074-vty20t-bedolaga/expand-api-for-subscription-purchase
Revert "Harden miniapp purchase period resolution"
2025-10-10 09:59:31 +03:00
Egor b2f88e75d5 Revert "Harden miniapp purchase period resolution" 2025-10-10 09:59:22 +03:00
Egor 767e7b653e Merge pull request #1074 from Fr1ngg/vty20t-bedolaga/expand-api-for-subscription-purchase
Harden miniapp purchase period resolution
2025-10-10 09:57:56 +03:00
Egor 2a7969716a Harden miniapp purchase period resolution 2025-10-10 09:57:37 +03:00
Egor 3e10bceb43 Merge pull request #1073 from Fr1ngg/revert-1072-026xmf-bedolaga/expand-api-for-subscription-purchase
Revert "Handle missing subscription period metadata in preview"
2025-10-10 09:57:14 +03:00
Egor 6f293615bb Revert "Handle missing subscription period metadata in preview" 2025-10-10 09:57:06 +03:00
Egor 69ce111281 Merge pull request #1072 from Fr1ngg/026xmf-bedolaga/expand-api-for-subscription-purchase
Handle missing subscription period metadata in preview
2025-10-10 09:55:38 +03:00
Egor 85c8ad018a Handle missing period selections in preview 2025-10-10 09:55:20 +03:00
Egor 385ebd1d39 Merge pull request #1070 from Fr1ngg/revert-1069-h3hzso-bedolaga/expand-api-for-subscription-purchase
Revert "Improve subscription purchase period handling"
2025-10-10 09:54:23 +03:00
Egor 4227a8613e Revert "Improve subscription purchase period handling" 2025-10-10 09:54:15 +03:00
Egor 2dc8947b00 Merge pull request #1069 from Fr1ngg/h3hzso-bedolaga/expand-api-for-subscription-purchase
Improve subscription purchase period handling
2025-10-10 09:53:13 +03:00
Egor 851f4dc58c Handle missing period selections in miniapp preview 2025-10-10 09:52:50 +03:00
Egor 366fc28ec5 Merge pull request #1068 from Fr1ngg/revert-1067-qor8f9-bedolaga/expand-api-for-subscription-purchase
Revert "Fix subscription purchase selection handling"
2025-10-10 09:52:22 +03:00
Egor 277d690686 Revert "Fix subscription purchase selection handling" 2025-10-10 09:52:13 +03:00
Egor 077584fb57 Merge pull request #1067 from Fr1ngg/qor8f9-bedolaga/expand-api-for-subscription-purchase
Fix subscription purchase selection handling
2025-10-10 09:40:17 +03:00
Egor 1bd05de773 Fix subscription purchase selection handling 2025-10-10 09:39:45 +03:00
Egor 1ae84a73dc Merge pull request #1066 from Fr1ngg/revert-1065-a0y28m-bedolaga/expand-api-for-subscription-purchase
Revert "Improve miniapp subscription configurator responsiveness"
2025-10-10 09:39:19 +03:00
Egor f5dad62ab0 Revert "Improve miniapp subscription configurator responsiveness" 2025-10-10 09:39:10 +03:00
Egor 2d90b9783a Merge pull request #1065 from Fr1ngg/a0y28m-bedolaga/expand-api-for-subscription-purchase
Improve miniapp subscription configurator responsiveness
2025-10-10 09:37:21 +03:00
Egor dd174bf6a5 Fix miniapp subscription configurator interactions 2025-10-10 09:37:04 +03:00
Egor fcd20b3aaa Merge pull request #1064 from Fr1ngg/revert-1063-ny2u3l-bedolaga/expand-api-for-subscription-purchase
Revert "Improve miniapp subscription purchase configurator"
2025-10-10 09:36:41 +03:00
Egor d97a71d96a Revert "Improve miniapp subscription purchase configurator" 2025-10-10 09:36:32 +03:00
Egor e7b631a991 Merge pull request #1063 from Fr1ngg/ny2u3l-bedolaga/expand-api-for-subscription-purchase
Improve miniapp subscription purchase configurator
2025-10-10 09:35:12 +03:00
Egor 948e36478b Improve miniapp subscription purchase configurator 2025-10-10 09:34:56 +03:00
Egor ec52741cf8 Merge pull request #1062 from Fr1ngg/revert-1061-revert-1056-revert-1053-9vdvf5-bedolaga/expand-api-for-subscription-purchase
Revert "Revert "Revert "Add mini app subscription purchase endpoints and schemas"""
2025-10-10 09:34:38 +03:00
Egor 08c3a54e6d Revert "Revert "Revert "Add mini app subscription purchase endpoints and schemas""" 2025-10-10 09:34:27 +03:00
Egor 75af19ab83 Merge pull request #1061 from Fr1ngg/revert-1056-revert-1053-9vdvf5-bedolaga/expand-api-for-subscription-purchase
Revert "Revert "Add mini app subscription purchase endpoints and schemas""
2025-10-10 09:13:05 +03:00
Egor 13c89c1d6f Revert "Revert "Add mini app subscription purchase endpoints and schemas"" 2025-10-10 09:12:57 +03:00
Egor c091cb95c8 Merge pull request #1060 from Fr1ngg/revert-1059-0idrid-bedolaga/expand-api-for-subscription-purchase
Revert "Add mini-app subscription purchase endpoints and UI handling"
2025-10-10 09:12:12 +03:00
Egor 65aa0a7c08 Revert "Add mini-app subscription purchase endpoints and UI handling" 2025-10-10 09:12:03 +03:00
Egor 080c019684 Merge pull request #1059 from Fr1ngg/0idrid-bedolaga/expand-api-for-subscription-purchase
Add mini-app subscription purchase endpoints and UI handling
2025-10-10 09:11:03 +03:00
Egor 0e1962c291 Add mini-app subscription purchase endpoints and UI handling 2025-10-10 09:10:44 +03:00
Egor e0aea226a4 Merge pull request #1058 from Fr1ngg/revert-1057-80ize0-bedolaga/extend-api-for-subscription-purchasing
Revert "Add miniapp subscription purchase flow"
2025-10-10 09:09:51 +03:00
Egor 687ea7d1d6 Revert "Add miniapp subscription purchase flow" 2025-10-10 09:09:41 +03:00
Egor b7146268c2 Merge pull request #1057 from Fr1ngg/80ize0-bedolaga/extend-api-for-subscription-purchasing
Add miniapp subscription purchase flow
2025-10-10 09:08:37 +03:00
Egor 0952059c60 Add miniapp subscription purchase flow 2025-10-10 09:08:18 +03:00
Egor bbf5575b49 Merge pull request #1056 from Fr1ngg/revert-1053-9vdvf5-bedolaga/expand-api-for-subscription-purchase
Revert "Add mini app subscription purchase endpoints and schemas"
2025-10-10 09:07:29 +03:00
Egor 6b8e913f91 Revert "Add mini app subscription purchase endpoints and schemas" 2025-10-10 09:07:18 +03:00
Egor 51e5f1c715 Merge pull request #1053 from Fr1ngg/9vdvf5-bedolaga/expand-api-for-subscription-purchase
Add mini app subscription purchase endpoints and schemas
2025-10-10 09:04:08 +03:00
Egor 910f48bff0 Merge pull request #1055 from Fr1ngg/revert-1054-fze3ek-bedolaga/expand-api-for-subscription-purchase
Revert "Add mini app subscription purchase flow"
2025-10-10 09:03:27 +03:00
Egor 62325d7a21 Revert "Add mini app subscription purchase flow" 2025-10-10 09:03:19 +03:00
Egor dc07de9957 Merge pull request #1054 from Fr1ngg/fze3ek-bedolaga/expand-api-for-subscription-purchase
Add mini app subscription purchase flow
2025-10-10 09:02:02 +03:00
Egor d65524d349 Add mini app subscription purchase flow 2025-10-10 09:01:37 +03:00
Egor fd989d0186 Add subscription purchase API for mini app 2025-10-10 09:00:27 +03:00
Egor 390874565e Merge pull request #1052 from Fr1ngg/oz7h91-bedolaga/add-subscription-purchase-configurator-to-miniapp
Implement subscription purchase configurator
2025-10-10 08:21:56 +03:00
Egor ae64dc48d2 Implement subscription purchase configurator 2025-10-10 08:21:31 +03:00
Egor 8a68a0d4b1 Merge pull request #1051 from Fr1ngg/revert-1050-hpiari-bedolaga/add-subscription-purchase-configurator-to-miniapp
Revert "Add mini-app subscription purchase configurator"
2025-10-10 08:21:17 +03:00
Egor 82cdd4442e Revert "Add mini-app subscription purchase configurator" 2025-10-10 08:21:09 +03:00
Egor 112ce528d9 Merge pull request #1050 from Fr1ngg/hpiari-bedolaga/add-subscription-purchase-configurator-to-miniapp
Add mini-app subscription purchase configurator
2025-10-10 08:13:32 +03:00
Egor 950f84d012 Add subscription purchase configurator logic 2025-10-10 08:13:10 +03:00
Egor 025a57a325 Merge pull request #1049 from Fr1ngg/revert-1048-84983m-bedolaga/add-subscription-purchase-feature-to-miniapp
Revert "Add interactive purchase configurator flow to mini app"
2025-10-10 08:12:18 +03:00
Egor 9d374f3ba1 Revert "Add interactive purchase configurator flow to mini app" 2025-10-10 08:12:02 +03:00
Egor d4aec3b8aa Merge pull request #1048 from Fr1ngg/84983m-bedolaga/add-subscription-purchase-feature-to-miniapp
Add interactive purchase configurator flow to mini app
2025-10-10 08:10:59 +03:00
Egor ffe8f70946 Add purchase configurator actions for mini app 2025-10-10 08:10:43 +03:00
Egor febf9ec669 Merge pull request #1047 from Fr1ngg/revert-1046-7hfdjj-bedolaga/add-subscription-purchase-configurator-to-miniapp
Revert "Wire subscription purchase configurator actions"
2025-10-10 08:10:03 +03:00
Egor 6bff990d59 Revert "Wire subscription purchase configurator actions" 2025-10-10 08:09:53 +03:00
Egor d50afd94eb Merge pull request #1046 from Fr1ngg/7hfdjj-bedolaga/add-subscription-purchase-configurator-to-miniapp
Wire subscription purchase configurator actions
2025-10-10 08:06:23 +03:00
Egor d43bab9c88 Wire subscription purchase configurator actions 2025-10-10 08:06:02 +03:00
Egor b6b63cd82d Merge pull request #1045 from Fr1ngg/revert-1044-17u0o4-bedolaga/add-subscription-purchase-feature-to-miniapp
Revert "Add subscription configurator to mini app"
2025-10-10 08:05:25 +03:00
Egor d7534e098c Revert "Add subscription configurator to mini app" 2025-10-10 08:05:17 +03:00
Egor 1af55ec86e Merge pull request #1044 from Fr1ngg/17u0o4-bedolaga/add-subscription-purchase-feature-to-miniapp
Add subscription configurator to mini app
2025-10-10 08:02:30 +03:00
Egor 3b72707fd9 Safely render subscription configurator summary 2025-10-10 08:02:14 +03:00
Egor 55ee06402e Merge pull request #1043 from Fr1ngg/revert-1042-pt7wcl-bedolaga/add-subscription-purchase-feature-to-miniapp
Revert "feat: add subscription purchase configurator to miniapp"
2025-10-10 07:59:04 +03:00
Egor bd19b570e5 Revert "feat: add subscription purchase configurator to miniapp" 2025-10-10 07:58:53 +03:00
Egor d0735aad31 Merge pull request #1042 from Fr1ngg/pt7wcl-bedolaga/add-subscription-purchase-feature-to-miniapp
feat: add subscription purchase configurator to miniapp
2025-10-10 07:57:09 +03:00
Egor 73594b9088 feat: add subscription purchase configurator to miniapp 2025-10-10 07:56:55 +03:00
Egor 6075ed5410 Merge pull request #1041 from Fr1ngg/dev4
Редактирование платной подписки в миниапке
2025-10-10 07:46:30 +03:00
Egor 07ff14c6b1 Merge pull request #1040 from Fr1ngg/5ulvyc-bedolaga/add-subscription-settings-endpoints
Handle Telegram popup confirmations and validate traffic selections
2025-10-10 07:19:56 +03:00
Egor 36ac94468a Fix miniapp confirmations and guard traffic updates 2025-10-10 07:19:38 +03:00
Egor 3c3bd139e3 Merge pull request #1039 from Fr1ngg/revert-1038-bblv1f-bedolaga/add-subscription-settings-endpoints
Revert "Require active subscription and confirm add-on charges"
2025-10-10 07:19:04 +03:00
Egor 82b2127e55 Revert "Require active subscription and confirm add-on charges" 2025-10-10 07:18:41 +03:00
Egor 5f2bb415de Merge pull request #1038 from Fr1ngg/bblv1f-bedolaga/add-subscription-settings-endpoints
Require active subscription and confirm add-on charges
2025-10-10 07:03:46 +03:00
Egor c2a6dfb3b4 Improve subscription settings updates 2025-10-10 07:03:31 +03:00
Egor 04bcc53012 Merge pull request #1037 from Fr1ngg/revert-1036-kn2p4i-bedolaga/add-subscription-settings-endpoints
Revert "Require active subscription for miniapp changes and add confirmations"
2025-10-10 07:03:14 +03:00
Egor d9330bfb7a Revert "Require active subscription for miniapp changes and add confirmations" 2025-10-10 07:03:06 +03:00
Egor 938f96bef8 Merge pull request #1036 from Fr1ngg/kn2p4i-bedolaga/add-subscription-settings-endpoints
Require active subscription for miniapp changes and add confirmations
2025-10-10 06:59:25 +03:00
Egor 4ea0868f6a Ensure active subscription updates and add confirmations 2025-10-10 06:59:07 +03:00
Egor d032b9fcb1 Merge pull request #1035 from Fr1ngg/revert-1034-nlxsql-bedolaga/add-subscription-settings-endpoints
Revert "Require confirmation before paid miniapp subscription updates"
2025-10-10 06:58:53 +03:00
Egor a0efbb105e Revert "Require confirmation before paid miniapp subscription updates" 2025-10-10 06:58:45 +03:00
Egor 6cfd239217 Merge pull request #1034 from Fr1ngg/nlxsql-bedolaga/add-subscription-settings-endpoints
Require confirmation before paid miniapp subscription updates
2025-10-10 06:55:10 +03:00
Egor 8378a2c555 Require active subscription and confirm paid miniapp updates 2025-10-10 06:54:54 +03:00
Egor fd8ced4f1a Merge pull request #1033 from Fr1ngg/revert-1032-keclgm-bedolaga/add-subscription-settings-endpoints
Revert "Add miniapp subscription settings management endpoints"
2025-10-10 06:54:33 +03:00
Egor 261b5b8d14 Revert "Add miniapp subscription settings management endpoints" 2025-10-10 06:54:24 +03:00
Egor 0fb684544f Merge pull request #1032 from Fr1ngg/keclgm-bedolaga/add-subscription-settings-endpoints
Add miniapp subscription settings management endpoints
2025-10-10 06:32:30 +03:00
Egor 22f9c9a48d Add miniapp subscription settings management endpoints 2025-10-10 06:32:12 +03:00
Egor b6c3acfa13 Merge pull request #1031 from Fr1ngg/revert-1030-cy8m6c-bedolaga/add-subscription-settings-endpoints
Revert "Add miniapp subscription settings management endpoints"
2025-10-10 06:31:53 +03:00
Egor 8b94ac7e1d Revert "Add miniapp subscription settings management endpoints" 2025-10-10 06:31:45 +03:00
Egor a676e2818a Merge pull request #1030 from Fr1ngg/cy8m6c-bedolaga/add-subscription-settings-endpoints
Add miniapp subscription settings management endpoints
2025-10-10 06:30:54 +03:00
Egor b5c2ad4c07 Add miniapp subscription settings management endpoints 2025-10-10 06:30:38 +03:00
Egor d3100f6996 Merge pull request #1029 from Fr1ngg/revert-1028-7c9pa7-bedolaga/add-subscription-settings-endpoints
Revert "Add miniapp subscription settings management endpoints"
2025-10-10 06:29:31 +03:00
Egor 3a13c5225b Revert "Add miniapp subscription settings management endpoints" 2025-10-10 06:29:23 +03:00
Egor eab3626848 Merge pull request #1028 from Fr1ngg/7c9pa7-bedolaga/add-subscription-settings-endpoints
Add miniapp subscription settings management endpoints
2025-10-10 06:27:44 +03:00
Egor 7a34a9d370 Add miniapp subscription settings management endpoints 2025-10-10 06:27:27 +03:00
Egor 7045412fe6 Merge pull request #1027 from Fr1ngg/revert-1026-n4b27b-bedolaga/fix-subscription-settings-endpoints-issues
Revert "Add miniapp subscription settings API endpoints"
2025-10-10 06:12:57 +03:00
Egor 7027fa91c3 Revert "Add miniapp subscription settings API endpoints" 2025-10-10 06:12:48 +03:00
Egor 8e9f7f91ef Merge pull request #1026 from Fr1ngg/n4b27b-bedolaga/fix-subscription-settings-endpoints-issues
Add miniapp subscription settings API endpoints
2025-10-10 06:08:34 +03:00
Egor e6923a0d0a Add miniapp subscription settings API endpoints 2025-10-10 06:07:53 +03:00
Egor 3250976709 Merge pull request #1025 from Fr1ngg/revert-1024-h9chhq-bedolaga/fix-subscription-settings-on-page
Revert "Implement miniapp subscription settings endpoints"
2025-10-10 06:07:40 +03:00
Egor cb5b731e92 Revert "Implement miniapp subscription settings endpoints" 2025-10-10 06:07:32 +03:00
Egor 34a7c0dfc6 Merge pull request #1024 from Fr1ngg/h9chhq-bedolaga/fix-subscription-settings-on-page
Implement miniapp subscription settings endpoints
2025-10-10 06:06:33 +03:00
Egor 009b6bf71d Add miniapp subscription settings endpoints 2025-10-10 06:06:17 +03:00
Egor d01da00809 Merge pull request #1023 from Fr1ngg/da8kxk-bedolaga/add-subscription-settings-panel
Add subscription settings management to miniapp
2025-10-10 05:24:10 +03:00
Egor a76ca27f22 feat: add subscription subscription settings management 2025-10-10 05:23:53 +03:00
Egor 1a73b13d50 Merge pull request #1022 from Fr1ngg/dev4
Пополнение баланса на странице миниапки
2025-10-10 04:55:28 +03:00
Egor 073af762c5 Merge pull request #1021 from Fr1ngg/bedolaga-yhudb1
Normalize Stars invoices to matched amounts
2025-10-10 04:49:07 +03:00
Egor b5c8b4218c Preserve normalized Stars invoice amounts 2025-10-10 04:48:50 +03:00
Egor 00605a37ab Merge pull request #1020 from Fr1ngg/revert-1019-bedolaga-8f5lx7
Revert "Normalize Stars invoices to matched amounts"
2025-10-10 04:48:11 +03:00
Egor 0f61df80e2 Revert "Normalize Stars invoices to matched amounts" 2025-10-10 04:48:01 +03:00
Egor d1777b48ea Merge pull request #1019 from Fr1ngg/bedolaga-8f5lx7
Normalize Stars invoices to matched amounts
2025-10-10 04:31:39 +03:00
Egor fae4036670 Normalize Stars invoices to matched amounts 2025-10-10 04:31:21 +03:00
Egor 57a32ea52e Merge pull request #1018 from Fr1ngg/revert-1017-bedolaga-6dyjgo
Revert "Fix miniapp stars status reuse"
2025-10-10 04:30:13 +03:00
Egor 5412ad4202 Revert "Fix miniapp stars status reuse" 2025-10-10 04:30:05 +03:00
Egor ab9bc8b9e1 Merge pull request #1017 from Fr1ngg/bedolaga-6dyjgo
Fix miniapp stars status reuse
2025-10-10 04:06:36 +03:00
Egor 2c4090b63e Fix miniapp stars status reuse 2025-10-10 04:06:17 +03:00
Egor 61287d5dbc Merge pull request #1016 from Fr1ngg/revert-1015-bedolaga-22m19x
Revert "Prevent duplicate miniapp stars payment confirmations"
2025-10-10 04:05:57 +03:00
Egor 0cc37c1a9e Revert "Prevent duplicate miniapp stars payment confirmations" 2025-10-10 04:05:48 +03:00
Egor c877b327a5 Merge pull request #1015 from Fr1ngg/bedolaga-22m19x
Prevent duplicate miniapp stars payment confirmations
2025-10-10 04:04:01 +03:00
Egor a27c9a7463 Ensure stars payments use unique payloads 2025-10-10 04:03:44 +03:00
Egor 16c99bed06 Merge pull request #1014 from Fr1ngg/revert-1013-revert-1010-revert-1009-bedolaga-y96yta
Revert "Revert "Revert "Handle miniapp payment status refresh"""
2025-10-10 04:01:13 +03:00
Egor 045bc32798 Revert "Revert "Revert "Handle miniapp payment status refresh""" 2025-10-10 04:01:03 +03:00
Egor f1dbd7c784 Merge pull request #1013 from Fr1ngg/revert-1010-revert-1009-bedolaga-y96yta
Revert "Revert "Handle miniapp payment status refresh""
2025-10-10 03:46:22 +03:00
Egor cb9c673af1 Revert "Revert "Handle miniapp payment status refresh"" 2025-10-10 03:46:14 +03:00
Egor 53ed64afa3 Merge pull request #1012 from Fr1ngg/revert-1011-revert-1006-revert-1005-bedolaga-gg86kp
Revert "Revert "Revert "Align miniapp payment limits and status UX"""
2025-10-10 03:45:59 +03:00
Egor 23d313e12d Revert "Revert "Revert "Align miniapp payment limits and status UX""" 2025-10-10 03:45:50 +03:00
Egor 02313fc924 Merge pull request #1011 from Fr1ngg/revert-1006-revert-1005-bedolaga-gg86kp
Revert "Revert "Align miniapp payment limits and status UX""
2025-10-10 03:42:08 +03:00
Egor 860937591d Revert "Revert "Align miniapp payment limits and status UX"" 2025-10-10 03:41:59 +03:00
Egor 9388f1c81a Merge pull request #1010 from Fr1ngg/revert-1009-bedolaga-y96yta
Revert "Handle miniapp payment status refresh"
2025-10-10 03:41:34 +03:00
Egor d7c4f439b9 Revert "Handle miniapp payment status refresh" 2025-10-10 03:41:26 +03:00
Egor 4273d2354c Merge pull request #1009 from Fr1ngg/bedolaga-y96yta
Handle miniapp payment status refresh
2025-10-10 03:37:47 +03:00
Egor ee1c215278 Enhance miniapp payment flows 2025-10-10 03:37:16 +03:00
Egor da2260f55c Merge pull request #1008 from Fr1ngg/revert-1007-bedolaga-kj65b7
Revert "Improve miniapp payment polling for Stars"
2025-10-10 03:36:57 +03:00
Egor bdeff1dbad Revert "Improve miniapp payment polling for Stars" 2025-10-10 03:36:48 +03:00
Egor bd421f23da Merge pull request #1007 from Fr1ngg/bedolaga-kj65b7
Improve miniapp payment polling for Stars
2025-10-10 03:32:00 +03:00
Egor c63b439722 Improve miniapp payment polling for Stars 2025-10-10 03:31:39 +03:00
Egor b501e937de Merge pull request #1006 from Fr1ngg/revert-1005-bedolaga-gg86kp
Revert "Align miniapp payment limits and status UX"
2025-10-10 03:31:15 +03:00
Egor afa65e0fbd Revert "Align miniapp payment limits and status UX" 2025-10-10 03:31:06 +03:00
Egor 16945492e8 Merge pull request #1005 from Fr1ngg/bedolaga-gg86kp
Align miniapp payment limits and status UX
2025-10-10 03:28:15 +03:00
Egor 3c879914ca Align miniapp payment limits and status UI 2025-10-10 03:27:54 +03:00
Egor d4f8d9b94d Merge pull request #1004 from Fr1ngg/revert-1003-bedolaga-d7n0dr
Revert "Implement mini app balance top-up flow"
2025-10-10 02:56:31 +03:00
Egor f44e6aca6f Revert "Implement mini app balance top-up flow" 2025-10-10 02:56:21 +03:00
Egor af273c5762 Merge pull request #1003 from Fr1ngg/bedolaga-d7n0dr
Implement mini app balance top-up flow
2025-10-10 02:54:56 +03:00
Egor c24ba07751 Add balance top-up flow to mini app 2025-10-10 02:54:33 +03:00
Egor 1fb1dadd9d Merge pull request #1002 from Fr1ngg/revert-1001-bedolaga-qilzdq
Revert "Add miniapp balance top up flow"
2025-10-10 02:54:07 +03:00
Egor 83536d7f3d Revert "Add miniapp balance top up flow" 2025-10-10 02:53:56 +03:00
Egor c2be32783f Merge pull request #1001 from Fr1ngg/bedolaga-qilzdq
Add miniapp balance top up flow
2025-10-10 02:39:26 +03:00
Egor 6654608aba feat: add miniapp balance top up flow 2025-10-10 02:39:10 +03:00
Egor 6f29265913 Merge pull request #1000 from Fr1ngg/revert-999-bedolaga-r7wllt
Revert "feat: enable balance top up flow in miniapp"
2025-10-10 02:38:31 +03:00
Egor 95b42d6e9b Revert "feat: enable balance top up flow in miniapp" 2025-10-10 02:38:23 +03:00
Egor e76975388e Merge pull request #999 from Fr1ngg/bedolaga-r7wllt
feat: enable balance top up flow in miniapp
2025-10-10 02:36:16 +03:00
Egor 43ebb11793 feat: enable balance top up flow in miniapp 2025-10-10 02:35:46 +03:00
Egor e6d373d9bd Merge pull request #998 from Fr1ngg/ywf16j-bedolaga/add-squad-migration-feature-to-bot
Add RemnaWave squad migration API endpoints
2025-10-10 02:10:50 +03:00
Egor bea83a3635 Add RemnaWave squad migration API endpoints 2025-10-10 02:10:34 +03:00
Egor ba73236ca1 Merge pull request #997 from Fr1ngg/revert-996-9r5j29-bedolaga/add-squad-migration-feature-to-bot
Revert "Add Remnawave squad migration admin workflow"
2025-10-10 02:09:43 +03:00
Egor 4120d516b4 Revert "Add Remnawave squad migration admin workflow" 2025-10-10 02:09:35 +03:00
Egor 09a46fccad Merge pull request #996 from Fr1ngg/9r5j29-bedolaga/add-squad-migration-feature-to-bot
Add Remnawave squad migration admin workflow
2025-10-10 01:41:41 +03:00
Egor 7b27f39f74 feat: add remnawave squad migration flow 2025-10-10 01:41:20 +03:00
Egor e4b1754e8b Merge pull request #995 from Fr1ngg/dev4
Dev4
2025-10-10 00:47:48 +03:00
Egor 3114bf59de Update config.py 2025-10-10 00:46:57 +03:00
Egor 30b87e13c6 Merge pull request #994 from Fr1ngg/7oguuk-bedolaga/extend-ban-words-filter-in-nicknames
feat: make display name banned keywords configurable
2025-10-10 00:42:37 +03:00
Egor 45d2ca6e25 feat: make display name banned keywords configurable 2025-10-10 00:42:17 +03:00
Egor 42e3fae980 Merge pull request #993 from Fr1ngg/dev4
Dev4
2025-10-09 19:30:54 +03:00
Egor 07c4ab264c Merge pull request #992 from Fr1ngg/b4gp89-bedolaga/fix-rounding-error-for-star-conversion
Fix Telegram Stars rounding and amount formatting
2025-10-09 19:21:28 +03:00
Egor bdaa3b5875 Fix Telegram Stars rounding and formatting 2025-10-09 19:20:44 +03:00
Egor 42696d0564 Merge pull request #991 from Fr1ngg/u9tr9v-bedolaga/fix-balance-top-up-error
Fix handling of Telegram Stars payment callbacks without message text
2025-10-09 19:10:50 +03:00
Egor 3c0c5a3494 Fix topup amount handler for non-text messages 2025-10-09 19:10:33 +03:00
Egor 743c3dabee Merge pull request #990 from Fr1ngg/dev4
w
2025-10-09 19:02:00 +03:00
Egor 3b8708f8e6 Merge pull request #989 from Fr1ngg/v59gno-bedolaga/update-ticket-system-to-use-tg-id
Fix ticket reply notifications to use Telegram IDs
2025-10-09 18:57:08 +03:00
Egor 977042e66c Fix ticket reply notifications to use Telegram IDs 2025-10-09 18:56:43 +03:00
Egor a91f42e9f0 Merge pull request #988 from Fr1ngg/dev4
Dev4
2025-10-09 18:49:42 +03:00
Egor c4d5388081 Merge pull request #987 from Fr1ngg/5ygzlg-bedolaga/update-bot-middleware-to-restrict-users
Ensure display name middleware catches obfuscated keywords
2025-10-09 18:41:55 +03:00
Egor f73a0fcfe8 Tighten keyword detection in display name middleware 2025-10-09 18:41:35 +03:00
Egor 7ff6e42f5b Merge pull request #986 from Fr1ngg/main
w
2025-10-09 18:32:06 +03:00
Egor cdd1e3f107 Update Dockerfile 2025-10-09 08:18:59 +03:00
Egor 6c6ade4f7f Update docker-registry.yml 2025-10-09 08:18:48 +03:00
Egor 30ad692bc2 Update docker-hub.yml 2025-10-09 08:18:37 +03:00
Egor 24b3ea4881 Merge pull request #984 from Fr1ngg/dev4
Апгрейд miniapp с подпиской
2025-10-09 08:13:28 +03:00
Egor 560689d9ee Merge pull request #983 from Fr1ngg/nx6oya-bedolaga/add-device-removal-feature-to-miniapp
Add device removal controls to miniapp
2025-10-09 08:01:30 +03:00
Egor aa763403c6 Add device removal support to miniapp 2025-10-09 08:01:13 +03:00
Egor 96c0c3562d Update index.html 2025-10-09 07:43:37 +03:00
Egor e48eaf584c Merge pull request #982 from Fr1ngg/ybcgxa-bedolaga/add-affiliate-section-to-miniapp
Update referral terms copy
2025-10-09 07:41:13 +03:00
Egor 790cd6bab3 Update referral terms labels 2025-10-09 07:40:57 +03:00
Egor 55ae2bd91c Merge pull request #981 from Fr1ngg/revert-980-jb9c12-bedolaga/add-affiliate-section-to-miniapp
Revert "Normalize referral rewards and adjust mini app spacing"
2025-10-09 07:39:50 +03:00
Egor d431931501 Revert "Normalize referral rewards and adjust mini app spacing" 2025-10-09 07:39:42 +03:00
Egor 425d15c850 Merge pull request #980 from Fr1ngg/jb9c12-bedolaga/add-affiliate-section-to-miniapp
Normalize referral rewards and adjust mini app spacing
2025-10-09 07:28:45 +03:00
Egor 58affda938 Fix referral reward normalization and spacing 2025-10-09 07:28:29 +03:00
Egor c4eb2a60de Merge pull request #979 from Fr1ngg/revert-978-ec880l-bedolaga/add-affiliate-section-to-miniapp
Revert "Fix referral bonus display and layout spacing"
2025-10-09 07:28:05 +03:00
Egor 46dca8c095 Revert "Fix referral bonus display and layout spacing" 2025-10-09 07:27:57 +03:00
Egor b752278398 Merge pull request #978 from Fr1ngg/ec880l-bedolaga/add-affiliate-section-to-miniapp
Fix referral bonus display and layout spacing
2025-10-09 07:24:40 +03:00
Egor 6a659a2131 Fix referral terms display and spacing 2025-10-09 07:24:20 +03:00
Egor 019397b0ea Merge pull request #977 from Fr1ngg/revert-976-tvd4k7-bedolaga/add-affiliate-section-to-miniapp
Revert "Enhance referral mini app card summary"
2025-10-09 07:23:37 +03:00
Egor 947a400909 Revert "Enhance referral mini app card summary" 2025-10-09 07:23:29 +03:00
Egor 7f52beb2a2 Merge pull request #976 from Fr1ngg/tvd4k7-bedolaga/add-affiliate-section-to-miniapp
Enhance referral mini app card summary
2025-10-09 07:06:47 +03:00
Egor 283d42af86 Enhance referral summary in mini app 2025-10-09 07:06:29 +03:00
Egor d36d1e3041 Merge pull request #975 from Fr1ngg/5o8uht-bedolaga/add-promo-code-activation-field
Improve promo code button layout on mini app
2025-10-09 06:32:51 +03:00
Egor a83bc25bd0 Improve promo code button layout on mini app 2025-10-09 06:32:34 +03:00
Egor 3555ada3b5 Merge pull request #974 from Fr1ngg/revert-973-ek2x9x-bedolaga/add-promo-code-activation-field
Revert "Add promo code activation UI to mini app"
2025-10-09 06:27:52 +03:00
Egor 1cb93d9688 Revert "Add promo code activation UI to mini app" 2025-10-09 06:27:44 +03:00
Egor 6f9fc5e071 Merge pull request #973 from Fr1ngg/ek2x9x-bedolaga/add-promo-code-activation-field
Add promo code activation UI to mini app
2025-10-09 06:26:16 +03:00
Egor 96d466328c Add promo code activation flow to mini app 2025-10-09 06:25:47 +03:00
Egor 846e7844db Merge pull request #972 from Fr1ngg/revert-971-f0jtfw-bedolaga/add-promo-code-activation-field
Revert "Add promo code activation support in mini app"
2025-10-09 06:25:19 +03:00
Egor 4e0b0837d9 Revert "Add promo code activation support in mini app" 2025-10-09 06:25:11 +03:00
Egor 8ba5cda095 Merge pull request #971 from Fr1ngg/f0jtfw-bedolaga/add-promo-code-activation-field
Add promo code activation support in mini app
2025-10-09 06:23:08 +03:00
Egor 7ac33bce28 Add promo code activation flow to mini app 2025-10-09 06:22:51 +03:00
Egor d8a0c166ac Merge pull request #970 from Fr1ngg/revert-969-x3v2no-bedolaga/add-promo-code-activation-field
Revert "Add promo code activation UI to mini app"
2025-10-09 06:22:14 +03:00
Egor c1858fe925 Revert "Add promo code activation UI to mini app" 2025-10-09 06:22:06 +03:00
Egor dae8e4c34c Merge pull request #969 from Fr1ngg/x3v2no-bedolaga/add-promo-code-activation-field
Add promo code activation UI to mini app
2025-10-09 06:19:52 +03:00
Egor 3d7c219fd1 feat: add promo code activation to miniapp 2025-10-09 06:19:37 +03:00
Egor 03781463e4 Merge pull request #968 from Fr1ngg/nnkucu-bedolaga/add-faq-section-to-/miniapp/index.html
Expose legal documents in mini app subscription view
2025-10-09 06:02:54 +03:00
Egor dcd9074136 Expose legal documents in mini app 2025-10-09 06:02:37 +03:00
Egor 7fbd26c8c3 Merge pull request #967 from Fr1ngg/revert-966-wq02f5-bedolaga/add-faq-section-to-/miniapp/index.html
Revert "Add FAQ section to mini app"
2025-10-09 06:02:05 +03:00
Egor 9677a55375 Revert "Add FAQ section to mini app" 2025-10-09 06:01:50 +03:00
Egor eb51cda617 Merge pull request #966 from Fr1ngg/wq02f5-bedolaga/add-faq-section-to-/miniapp/index.html
Add FAQ section to mini app
2025-10-09 05:49:20 +03:00
Egor 09bdde6f91 Add FAQ section to mini app 2025-10-09 05:49:01 +03:00
Egor 784b2ed996 Merge pull request #965 from Fr1ngg/revert-964-cg1tpe-bedolaga/add-faq-section-to-/miniapp/index.html
Revert "Add FAQ support to mini app subscription page"
2025-10-09 05:48:47 +03:00
Egor 9bee4a2365 Revert "Add FAQ support to mini app subscription page" 2025-10-09 05:48:39 +03:00
Egor f8946b99f3 Merge pull request #964 from Fr1ngg/cg1tpe-bedolaga/add-faq-section-to-/miniapp/index.html
Add FAQ support to mini app subscription page
2025-10-09 05:47:44 +03:00
Egor a17cbf4448 Add FAQ section to miniapp and expose data 2025-10-09 05:47:27 +03:00
Egor b0422e0c0f Merge pull request #963 from Fr1ngg/revert-962-io42y3-bedolaga/add-faq-section-to-/miniapp/index.html
Revert "feat: show FAQ in mini app"
2025-10-09 05:47:09 +03:00
Egor 9d0bf8b3cf Revert "feat: show FAQ in mini app" 2025-10-09 05:47:00 +03:00
Egor 8c5e8ca019 Merge pull request #962 from Fr1ngg/io42y3-bedolaga/add-faq-section-to-/miniapp/index.html
feat: show FAQ in mini app
2025-10-09 05:45:55 +03:00
Egor 0327adb8da feat: show FAQ in mini app 2025-10-09 05:45:40 +03:00
Egor 5769fa63a5 Merge pull request #961 from Fr1ngg/rkljpj-bedolaga/add-promo-offer-display-on-subscription-page
Display active test access promo offers
2025-10-09 05:20:01 +03:00
Egor 1d09006b7c Display active test access promo offers 2025-10-09 05:19:44 +03:00
Egor e194ab9729 Merge pull request #960 from Fr1ngg/revert-959-36674g-bedolaga/add-promo-offer-display-on-subscription-page
Revert "Fix promo offer timers by normalizing date parsing"
2025-10-09 05:18:46 +03:00
Egor 45a6173916 Revert "Fix promo offer timers by normalizing date parsing" 2025-10-09 05:18:38 +03:00
Egor b21baba006 Merge pull request #959 from Fr1ngg/36674g-bedolaga/add-promo-offer-display-on-subscription-page
Fix promo offer timers by normalizing date parsing
2025-10-09 05:09:51 +03:00
Egor 45c1b4695c Fix promo offer timer timezone handling 2025-10-09 05:09:31 +03:00
Egor 9b2b28423d Merge pull request #958 from Fr1ngg/revert-957-xzisus-bedolaga/add-promo-offer-display-on-subscription-page
Revert "Add promo offer display to subscription miniapp"
2025-10-09 05:07:44 +03:00
Egor 9d00c2ddc6 Revert "Add promo offer display to subscription miniapp" 2025-10-09 05:07:35 +03:00
Egor dc66676663 Merge pull request #957 from Fr1ngg/xzisus-bedolaga/add-promo-offer-display-on-subscription-page
Add promo offer display to subscription miniapp
2025-10-09 04:54:21 +03:00
Egor 1d600e2c63 Add promo offer display to subscription miniapp 2025-10-09 04:54:05 +03:00
Egor 5ce177a7b5 Merge pull request #956 from Fr1ngg/revert-955-4ttjow-bedolaga/add-promo-offer-display-on-subscription-page
Revert "Add promo offer banners to mini app"
2025-10-09 04:53:29 +03:00
Egor d181a333d7 Revert "Add promo offer banners to mini app" 2025-10-09 04:53:20 +03:00
Egor 3694cb4ed7 Merge pull request #955 from Fr1ngg/4ttjow-bedolaga/add-promo-offer-display-on-subscription-page
Add promo offer banners to mini app
2025-10-09 04:51:18 +03:00
Egor 051048e0ae Add promo offer banners to mini app 2025-10-09 04:50:46 +03:00
Egor 61ee6e42cf Merge pull request #954 from Fr1ngg/revert-953-x57z5w-bedolaga/add-promo-offer-display-on-subscription-page
Revert "feat: surface promo offers in mini app"
2025-10-09 04:50:35 +03:00
Egor 5bd618a799 Revert "feat: surface promo offers in mini app" 2025-10-09 04:50:27 +03:00
Egor 7794a4cc7b Merge pull request #953 from Fr1ngg/x57z5w-bedolaga/add-promo-offer-display-on-subscription-page
feat: surface promo offers in mini app
2025-10-09 04:43:18 +03:00
Egor 6d01d32dc2 feat: show promo offers in miniapp 2025-10-09 04:42:58 +03:00
Egor be71def203 Update index.html 2025-10-09 04:22:01 +03:00
Egor 7a2de410bd Update index.html 2025-10-09 04:15:51 +03:00
Egor b019e8b32d Update index.html 2025-10-09 04:11:57 +03:00
Egor cb4f773eb1 Update index.html 2025-10-09 04:09:28 +03:00
Egor 29fe9f4943 Merge pull request #952 from Fr1ngg/hycxed-bedolaga/add-user-promotion-group-display
Refine mini app promo section layout and discount display
2025-10-09 04:03:51 +03:00
Egor 63edfa852d Improve promo section presentation and discounts 2025-10-09 04:03:37 +03:00
Egor 66fabb6836 Merge pull request #951 from Fr1ngg/revert-950-5ob0h8-bedolaga/add-user-promotion-group-display
Revert "Improve promo group UI and period discounts"
2025-10-09 04:03:19 +03:00
Egor f29ea49185 Revert "Improve promo group UI and period discounts" 2025-10-09 04:03:10 +03:00
Egor 7620a3df92 Merge pull request #950 from Fr1ngg/5ob0h8-bedolaga/add-user-promotion-group-display
Improve promo group UI and period discounts
2025-10-09 04:02:20 +03:00
Egor 577bcfdc88 Improve promo group UI and period discounts 2025-10-09 04:02:00 +03:00
Egor 33ec09edd0 Merge pull request #949 from Fr1ngg/revert-948-ess9tg-bedolaga/add-user-promotion-group-display
Revert "Improve promo group UI with collapsible discounts"
2025-10-09 04:01:47 +03:00
Egor 7b3c463fa2 Revert "Improve promo group UI with collapsible discounts" 2025-10-09 04:01:40 +03:00
Egor 0e541af5e1 Merge pull request #948 from Fr1ngg/ess9tg-bedolaga/add-user-promotion-group-display
Improve promo group UI with collapsible discounts
2025-10-09 04:00:32 +03:00
Egor e39822e732 Improve promo group UI with collapsible discounts 2025-10-09 04:00:18 +03:00
Egor c7973e7b58 Merge pull request #947 from Fr1ngg/revert-946-0zyc5p-bedolaga/add-user-promotion-group-display
Revert "Display promo group discounts in mini app"
2025-10-09 03:59:47 +03:00
Egor 4941052d57 Revert "Display promo group discounts in mini app" 2025-10-09 03:59:40 +03:00
Egor 0022abc6e0 Merge pull request #946 from Fr1ngg/0zyc5p-bedolaga/add-user-promotion-group-display
Display promo group discounts in mini app
2025-10-09 03:45:11 +03:00
Egor acef77b0e8 Display promo group discounts in mini app 2025-10-09 03:44:54 +03:00
Egor 4ddd580347 Merge pull request #945 from Fr1ngg/revert-944-nafa9w-bedolaga/add-user-promotion-group-display
Revert "Display promo level discounts in the mini app"
2025-10-09 03:44:44 +03:00
Egor 42cc4db077 Revert "Display promo level discounts in the mini app" 2025-10-09 03:44:35 +03:00
Egor 8e3f976441 Merge pull request #944 from Fr1ngg/nafa9w-bedolaga/add-user-promotion-group-display
Display promo level discounts in the mini app
2025-10-09 03:40:28 +03:00
Egor 190d9ae13e Show promo level discounts when available 2025-10-09 03:39:59 +03:00
Egor d6deec1a29 Merge pull request #943 from Fr1ngg/revert-942-38969u-bedolaga/add-user-promotion-group-display
Revert "Display promo level discounts in mini app"
2025-10-09 03:39:44 +03:00
Egor e8dcbd09ff Revert "Display promo level discounts in mini app" 2025-10-09 03:39:33 +03:00
Egor 382f1eb6f7 Merge pull request #942 from Fr1ngg/38969u-bedolaga/add-user-promotion-group-display
Display promo level discounts in mini app
2025-10-09 03:38:30 +03:00
Egor 2853410ebc Show discount details for auto promo groups 2025-10-09 03:38:14 +03:00
Egor 2bde6a83ed Merge pull request #941 from Fr1ngg/revert-940-gbzjeq-bedolaga/add-user-promotion-group-display
Revert "Show promo group levels in mini app subscription view"
2025-10-09 03:36:29 +03:00
Egor 83b1fa0f0f Revert "Show promo group levels in mini app subscription view" 2025-10-09 03:36:21 +03:00
Egor 3a2a05d307 Merge pull request #940 from Fr1ngg/gbzjeq-bedolaga/add-user-promotion-group-display
Show promo group levels in mini app subscription view
2025-10-09 03:25:40 +03:00
Egor 2460766e30 Show promo group info and auto levels in miniapp 2025-10-09 03:25:25 +03:00
Egor 3eb01eaabc Merge pull request #939 from Fr1ngg/revert-938-pwo411-bedolaga/add-user-promotion-group-display
Revert "Show promo group and auto levels in mini app"
2025-10-09 03:24:36 +03:00
Egor 35bca4166d Revert "Show promo group and auto levels in mini app" 2025-10-09 03:24:28 +03:00
Egor f2db938fdc Merge pull request #938 from Fr1ngg/pwo411-bedolaga/add-user-promotion-group-display
Show promo group and auto levels in mini app
2025-10-09 03:22:18 +03:00
Egor ff0c68afc6 Show promo group and auto levels in mini app 2025-10-09 03:21:40 +03:00
Egor 67babf7566 Update README.md 2025-10-08 20:21:06 +03:00
Egor f71788d2d4 Update README.md 2025-10-08 20:11:24 +03:00
Egor 5eebdcc85c Update README.md 2025-10-08 20:02:57 +03:00
Egor 4b89de9634 Update README.md 2025-10-08 19:57:45 +03:00
Egor b81d5b199b Update README.md 2025-10-08 19:55:33 +03:00
Egor a21559cd0d Merge pull request #931 from Fr1ngg/jehc2b-bedolaga/-readme
docs: expand README functional overview
2025-10-08 19:46:23 +03:00
Egor 6cd3f87318 docs: expand feature overview in README 2025-10-08 19:46:04 +03:00
Egor f9e416341f Update Dockerfile 2025-10-08 08:08:25 +03:00
Egor 8a2149d2f8 Update docker-registry.yml 2025-10-08 08:08:15 +03:00
Egor e0f6e3b1ef Update docker-hub.yml 2025-10-08 08:08:01 +03:00
Egor 8948f4d302 Merge pull request #916 from Fr1ngg/b20uxt-bedolaga/fix-trial-server-counter-updates-for-subscriptions
Prevent double counting server counters on paid subscriptions
2025-10-08 07:54:53 +03:00
Egor d13ff6830b Gate server counter updates for paid subscriptions 2025-10-08 07:54:31 +03:00
Egor b8aee33e88 Merge pull request #915 from Fr1ngg/revert-914-1cynlm-bedolaga/fix-trial-server-counter-updates-for-subscriptions
Revert "Update paid subscription creation to bump server counters"
2025-10-08 07:51:42 +03:00
Egor 17639f3a67 Revert "Update paid subscription creation to bump server counters" 2025-10-08 07:51:32 +03:00
Egor cb77b5a21c Merge pull request #914 from Fr1ngg/1cynlm-bedolaga/fix-trial-server-counter-updates-for-subscriptions
Update paid subscription creation to bump server counters
2025-10-08 07:48:01 +03:00
Egor 012ba2049e Update paid subscription creation to bump server counters 2025-10-08 07:47:40 +03:00
Egor 6acfcc9966 Merge pull request #913 from Fr1ngg/dev2
Рандомные сквады для триала, конфигурация доступных пакетов трафика через админку
2025-10-08 07:36:14 +03:00
Egor f6839c4184 Merge pull request #912 from Fr1ngg/pa8726-bedolaga/add-traffic-package-management-in-admin-panel
Clear admin price input messages after saving
2025-10-08 07:31:53 +03:00
Egor 1fbdcb7391 Remove success message when updating pricing 2025-10-08 07:31:36 +03:00
Egor 50a65d06d4 Merge pull request #911 from Fr1ngg/revert-910-owno31-bedolaga/add-traffic-package-management-in-admin-panel
Revert "Add admin controls for traffic package visibility"
2025-10-08 07:31:10 +03:00
Egor 20855e5bfc Revert "Add admin controls for traffic package visibility" 2025-10-08 07:30:56 +03:00
Egor 2e4317e457 Merge pull request #910 from Fr1ngg/owno31-bedolaga/add-traffic-package-management-in-admin-panel
Add admin controls for traffic package visibility
2025-10-08 07:26:18 +03:00
Egor 89dd41ef43 Add admin controls for traffic package visibility 2025-10-08 07:26:04 +03:00
Egor 7674b0325b Merge pull request #909 from Fr1ngg/revert-908-pn3n44-bedolaga/add-traffic-package-management-in-admin-panel
Revert "Add admin controls for traffic package visibility"
2025-10-08 07:25:45 +03:00
Egor 8a5141b58c Revert "Add admin controls for traffic package visibility" 2025-10-08 07:25:36 +03:00
Egor a819be0b5a Merge pull request #908 from Fr1ngg/pn3n44-bedolaga/add-traffic-package-management-in-admin-panel
Add admin controls for traffic package visibility
2025-10-08 07:23:52 +03:00
Egor 7de4730822 Add admin toggles for traffic packages 2025-10-08 07:23:36 +03:00
Egor b4815b291e Merge pull request #907 from Fr1ngg/revert-906-9sm5by-bedolaga/add-traffic-package-management-in-admin-panel
Revert "Add admin UI toggles for traffic packages"
2025-10-08 07:23:23 +03:00
Egor 68da3d7d30 Revert "Add admin UI toggles for traffic packages" 2025-10-08 07:23:14 +03:00
Egor ca13385dee Merge pull request #906 from Fr1ngg/9sm5by-bedolaga/add-traffic-package-management-in-admin-panel
Add admin UI toggles for traffic packages
2025-10-08 07:20:38 +03:00
Egor 9216128eb6 Add traffic package toggle controls in admin pricing 2025-10-08 07:20:23 +03:00
Egor 3d1fe09540 Merge pull request #905 from Fr1ngg/vkeste-bedolaga/fix-trial-server-availability-issue
Allow trial squads to be issued when inactive
2025-10-08 07:07:21 +03:00
Egor 07b8e04afe Allow trial squad selection when inactive 2025-10-08 07:07:06 +03:00
Egor 01039a969a Merge pull request #904 from Fr1ngg/8pyo0r-bedolaga/fix-user-counter-update-on-servers
Fix server user counts when subscriptions are removed
2025-10-08 06:58:10 +03:00
Egor a020db5bc6 Fix server counters when subscriptions are removed 2025-10-08 06:57:53 +03:00
Egor a91baf2acd Merge pull request #903 from Fr1ngg/revert-902-njiwqq-bedolaga/fix-user-counter-update-on-servers
Revert "Update trial subscription server counts automatically"
2025-10-08 06:57:31 +03:00
Egor 1caa2b2ba2 Revert "Update trial subscription server counts automatically" 2025-10-08 06:57:22 +03:00
Egor a7e9f732a2 Merge pull request #902 from Fr1ngg/njiwqq-bedolaga/fix-user-counter-update-on-servers
Update trial subscription server counts automatically
2025-10-08 06:44:43 +03:00
Egor c8794722a2 Update trial subscription server counts automatically 2025-10-08 06:44:28 +03:00
Egor bbdb3e454e Merge pull request #901 from Fr1ngg/revert-900-zf3oh7-bedolaga/fix-user-counter-update-issue
Revert "Fix server user counters for nullable values"
2025-10-08 06:36:07 +03:00
Egor fa693494d0 Revert "Fix server user counters for nullable values" 2025-10-08 06:35:45 +03:00
Egor 02c38a25ea Merge pull request #900 from Fr1ngg/zf3oh7-bedolaga/fix-user-counter-update-issue
Fix server user counters for nullable values
2025-10-08 06:34:03 +03:00
Egor d5d60a8c7a Fix server user counters for nullable values 2025-10-08 06:33:44 +03:00
Egor 864aaae017 Merge pull request #899 from Fr1ngg/revert-898-kkzy84-bedolaga/fix-user-counter-update-issue
Revert "Expire server cache after counter updates"
2025-10-08 06:33:17 +03:00
Egor 413173270d Revert "Expire server cache after counter updates" 2025-10-08 06:33:09 +03:00
Egor b8eb6606f1 Merge pull request #898 from Fr1ngg/kkzy84-bedolaga/fix-user-counter-update-issue
Expire server cache after counter updates
2025-10-08 06:31:40 +03:00
Egor 83226c8f3d Expire server cache after counter updates 2025-10-08 06:31:16 +03:00
Egor 8a96cddb4f Merge pull request #897 from Fr1ngg/revert-896-z6lcfu-bedolaga/fix-user-counter-update-issue
Revert "Fix server squad user counters on removal"
2025-10-08 06:30:14 +03:00
Egor e32f3191e4 Revert "Fix server squad user counters on removal" 2025-10-08 06:30:05 +03:00
Egor acb5c3c1d7 Merge pull request #896 from Fr1ngg/z6lcfu-bedolaga/fix-user-counter-update-issue
Fix server squad user counters on removal
2025-10-08 06:27:18 +03:00
Egor cf13b49b3c Fix server squad user counters on removal 2025-10-08 06:26:41 +03:00
Egor 2049765969 Merge pull request #895 from Fr1ngg/tkq4hh-bedolaga/refactor-trial-squad-selection-in-bot
feat: randomize trial squad distribution
2025-10-08 06:07:56 +03:00
Egor 6b2af1f4a3 feat: randomize trial squad distribution 2025-10-08 06:07:31 +03:00
Egor 00d080cbfb Merge pull request #894 from Fr1ngg/j4qq3i-bedolaga/add-admin-panel-for-menu-button-management
Add admin tools for configurable main menu buttons
2025-10-08 06:00:00 +03:00
Egor d0ce5438a8 Add admin API and service for dynamic main menu buttons 2025-10-08 05:59:37 +03:00
Egor 05914c4e14 Merge pull request #893 from Fr1ngg/dev2
Включение автоплатежа по дефолту
2025-10-08 05:21:12 +03:00
Egor a040d03df0 Merge pull request #892 from Fr1ngg/bj3dv4-bedolaga/add-default-auto-payment-setting-for-users
Enable configurable default autopay
2025-10-08 05:17:30 +03:00
Egor a29e0d5fb4 Update config.py 2025-10-08 05:17:12 +03:00
Egor ea7697fd66 Enable configurable default autopay 2025-10-08 05:16:11 +03:00
Egor ab86296256 Merge pull request #891 from Fr1ngg/dev2
Возможность уменьшать кол-во дней подписки юзерам
2025-10-08 05:00:57 +03:00
Egor 9bb384788f Merge pull request #890 from Fr1ngg/c9cmzh-bedolaga/add-ability-to-decrease-subscription-days
Allow admins to decrease user subscription days
2025-10-08 04:56:52 +03:00
Egor 6fc4eb033b Allow admins to decrease subscription days 2025-10-08 04:56:33 +03:00
Egor 8a8abf5b96 Merge pull request #889 from Fr1ngg/revert-888-fggclp-bedolaga/adapt-connection-page-for-app-schemes
Revert "feat: support dynamic miniapp app selection"
2025-10-08 04:50:32 +03:00
Egor 01e68bec68 Revert "feat: support dynamic miniapp app selection" 2025-10-08 04:50:22 +03:00
Egor 7b6488d166 Merge pull request #888 from Fr1ngg/fggclp-bedolaga/adapt-connection-page-for-app-schemes
feat: support dynamic miniapp app selection
2025-10-08 04:46:21 +03:00
Egor 6c4e4b366a feat: support dynamic miniapp app selection 2025-10-08 04:46:03 +03:00
Egor 1defa938ba Merge pull request #887 from Fr1ngg/revert-886-4mz7rm-bedolaga/adapt-connection-page-for-app-schemes
Revert "Adapt miniapp connection UX for per-app schemes"
2025-10-08 04:45:23 +03:00
Egor efaac8ff76 Revert "Adapt miniapp connection UX for per-app schemes" 2025-10-08 04:45:13 +03:00
Egor 9246312c68 Merge pull request #886 from Fr1ngg/4mz7rm-bedolaga/adapt-connection-page-for-app-schemes
Adapt miniapp connection UX for per-app schemes
2025-10-08 04:41:21 +03:00
Egor d4bbea616f feat: update miniapp connection flows 2025-10-08 04:41:01 +03:00
Egor 1be8e1a60f Merge pull request #885 from Fr1ngg/dev2
Вывод перечеркнутой исходной цены и скидочной вместе при продлении
2025-10-08 04:33:57 +03:00
Egor 0f1147a226 Merge pull request #884 from Fr1ngg/q6yun8-bedolaga/show-crossed-out-and-discounted-prices
Show strikethrough renewal prices when discounts apply
2025-10-08 04:30:22 +03:00
Egor dc7d0fa8d6 Show original prices when subscription renewal discounts apply 2025-10-08 04:30:05 +03:00
Egor 1fc4686490 Merge pull request #883 from Fr1ngg/revert-882-wu6jus-bedolaga/adapt-connection-page-for-app-schemes
Revert "Update miniapp app selection and connection flows"
2025-10-08 04:29:31 +03:00
Egor 8378020a3e Revert "Update miniapp app selection and connection flows" 2025-10-08 04:29:21 +03:00
Egor bce31ea9a4 Merge pull request #882 from Fr1ngg/wu6jus-bedolaga/adapt-connection-page-for-app-schemes
Update miniapp app selection and connection flows
2025-10-08 04:27:48 +03:00
Egor 5b4337a436 Update miniapp connection flows per app selection 2025-10-08 04:26:16 +03:00
Egor ac0953a221 Merge pull request #881 from Fr1ngg/dev2
Потерянные локали и обновление механизма синхронизации с remnawave (синк со временем из докера)
2025-10-08 04:15:27 +03:00
Egor bfdb17d653 Merge pull request #880 from Fr1ngg/iocwov-bedolaga/fix-subscription-datetime-handling
Fix disabled subscription end date timezone
2025-10-08 03:57:37 +03:00
Egor 3159e80897 Fix disabled subscription end date timezone 2025-10-08 03:57:00 +03:00
Egor 35d3fb1916 Merge pull request #879 from Fr1ngg/revert-878-xg90tu-bedolaga/fix-subscription-datetime-handling
Revert "Normalize subscription timestamps to UTC"
2025-10-08 03:56:41 +03:00
Egor 4be51bbe51 Revert "Normalize subscription timestamps to UTC" 2025-10-08 03:56:31 +03:00
Egor 2416bd6ad5 Merge pull request #878 from Fr1ngg/xg90tu-bedolaga/fix-subscription-datetime-handling
Normalize subscription timestamps to UTC
2025-10-08 03:54:54 +03:00
Egor b3697b1871 Normalize subscription timestamps to UTC 2025-10-08 03:54:37 +03:00
Egor 483fab8427 Merge pull request #877 from Fr1ngg/dor5e8-bedolaga/fix-user-sync-ignoring-time-zone
Fix RemnaWave user sync timezone handling
2025-10-08 03:47:19 +03:00
Egor 95ae04d553 Fix RemnaWave user sync timezone handling 2025-10-08 03:47:03 +03:00
Egor a6d8e77f9e Merge pull request #876 from Fr1ngg/revert-875-t8r8ec-bedolaga/fix-user-sync-ignoring-time-zone
Revert "Normalize RemnaWave expiry dates to UTC"
2025-10-08 03:46:47 +03:00
Egor ae4814d1cb Revert "Normalize RemnaWave expiry dates to UTC" 2025-10-08 03:46:39 +03:00
Egor fef4f81744 Merge pull request #875 from Fr1ngg/t8r8ec-bedolaga/fix-user-sync-ignoring-time-zone
Normalize RemnaWave expiry dates to UTC
2025-10-08 03:43:50 +03:00
Egor 65057348e9 Normalize RemnaWave expiry dates to UTC 2025-10-08 03:43:35 +03:00
Egor 4d32e509e0 Merge pull request #874 from Fr1ngg/revert-873-virkxj-bedolaga/fix-user-sync-ignoring-time-zone
Revert "Fix RemnaWave sync timezone handling"
2025-10-08 03:43:02 +03:00
Egor 8e336e1f7b Revert "Fix RemnaWave sync timezone handling" 2025-10-08 03:42:53 +03:00
Egor ee7078705b Merge pull request #873 from Fr1ngg/virkxj-bedolaga/fix-user-sync-ignoring-time-zone
Fix RemnaWave sync timezone handling
2025-10-08 03:33:46 +03:00
Egor 48fe845ff4 Fix RemnaWave sync timezone handling 2025-10-08 03:33:32 +03:00
Egor 99c882110a Merge pull request #872 from Fr1ngg/bedolaga-g891y6
Add missing Russian privacy policy locale strings
2025-10-08 03:27:31 +03:00
Egor a4f1323a81 Add missing Russian privacy policy translations 2025-10-08 03:27:11 +03:00
Egor aad1bb8805 Merge pull request #871 from Fr1ngg/dev2
Раздел (Скидки за траты) в Инфо
2025-10-08 03:08:37 +03:00
Egor d2eb1d35db Update ru.json 2025-10-08 03:07:39 +03:00
Egor d6fa2a8f3c Update ru.json 2025-10-08 03:06:59 +03:00
Egor 32b074efd4 Merge pull request #870 from Fr1ngg/rm42ap-bedolaga/add-promo-groups-section-to-bot
Add promo group progress overview to Info menu
2025-10-08 02:57:47 +03:00
Egor 787d801722 Add promo groups info section 2025-10-08 02:57:31 +03:00
Egor 91c3f2766a Merge pull request #869 from Fr1ngg/dev2
Dev2
2025-10-08 02:44:38 +03:00
Egor 911a20a4e3 Merge pull request #868 from Fr1ngg/main
w
2025-10-08 02:39:16 +03:00
Egor 8e04a0e2b5 Merge pull request #867 from Fr1ngg/lf0mas-bedolaga/add-external-admin-panel-settings
Очистить токен внешней админки при подмене
2025-10-08 02:31:37 +03:00
Egor 6225463af1 Очистка токена внешней админки при подмене 2025-10-08 02:31:12 +03:00
Egor d4badacd4a Merge pull request #866 from Fr1ngg/revert-865-auudl3-bedolaga/add-external-admin-panel-settings
Revert "Защитить токен внешней админки от подмены"
2025-10-08 02:30:48 +03:00
Egor 63bf04bb70 Revert "Защитить токен внешней админки от подмены" 2025-10-08 02:30:39 +03:00
Egor 2529c9bca2 Merge pull request #865 from Fr1ngg/auudl3-bedolaga/add-external-admin-panel-settings
Защитить токен внешней админки от подмены
2025-10-08 02:18:11 +03:00
Egor bc09d2f610 Защитить токен внешней админки от подмены 2025-10-08 02:17:57 +03:00
Egor d530d7a296 Merge pull request #864 from Fr1ngg/revert-863-mvul3c-bedolaga/add-external-admin-panel-settings
Revert "Усилить генерацию токена внешней админки"
2025-10-08 02:17:23 +03:00
Egor bea0977a86 Revert "Усилить генерацию токена внешней админки" 2025-10-08 02:17:13 +03:00
Egor 634b2782d8 Merge pull request #863 from Fr1ngg/mvul3c-bedolaga/add-external-admin-panel-settings
Усилить генерацию токена внешней админки
2025-10-08 01:56:32 +03:00
Egor 0e28cfa326 Усилить генерацию токена внешней админки 2025-10-08 01:56:16 +03:00
Egor 3d2ca8a02d Merge pull request #862 from Fr1ngg/revert-861-hb3c6g-bedolaga/add-external-admin-panel-settings
Revert "Добавить токен внешней админки и защиту настройки"
2025-10-08 01:55:28 +03:00
Egor e0e673e7bb Revert "Добавить токен внешней админки и защиту настройки" 2025-10-08 01:55:17 +03:00
Egor 0d4cea1e22 Merge pull request #861 from Fr1ngg/hb3c6g-bedolaga/add-external-admin-panel-settings
Добавить токен внешней админки и защиту настройки
2025-10-08 01:46:24 +03:00
Egor b643f45c01 Добавить токен внешней админки и защиту настройки 2025-10-08 01:46:06 +03:00
Egor b5b30a07b8 Merge pull request #860 from Fr1ngg/revert-859-lxh4tz-bedolaga/add-external-admin-panel-settings
Revert "Add external admin token support"
2025-10-08 01:45:47 +03:00
Egor 09ea9b0193 Revert "Add external admin token support" 2025-10-08 01:45:35 +03:00
Egor cf76ac7893 Merge pull request #859 from Fr1ngg/lxh4tz-bedolaga/add-external-admin-panel-settings
Add external admin token support
2025-10-08 01:42:46 +03:00
Egor 83b370669e Add external admin token support 2025-10-08 01:42:30 +03:00
Egor 6735cf17cf Merge pull request #858 from Fr1ngg/revert-857-m9d0zq-bedolaga/-api
Revert "Add user API tokens and verification endpoint"
2025-10-08 01:26:50 +03:00
Egor 422686dd2c Revert "Add user API tokens and verification endpoint" 2025-10-08 01:26:42 +03:00
Egor 147dc0343c Merge pull request #857 from Fr1ngg/m9d0zq-bedolaga/-api
Add user API tokens and verification endpoint
2025-10-08 01:24:30 +03:00
Egor cdff9c4666 Add user API token management and external verification endpoint 2025-10-08 01:24:12 +03:00
Egor 25db434e47 Merge pull request #856 from Fr1ngg/revert-855-iex73b-bedolaga/-api
Revert "Add user-linked API tokens and issuance flow"
2025-10-08 01:23:47 +03:00
Egor 5a8aeadcd9 Revert "Add user-linked API tokens and issuance flow" 2025-10-08 01:23:38 +03:00
Egor bdceb9c3cf Merge pull request #855 from Fr1ngg/iex73b-bedolaga/-api
Add user-linked API tokens and issuance flow
2025-10-08 01:19:44 +03:00
Egor f43e0daa69 Add user API tokens linked to Telegram users 2025-10-08 01:19:24 +03:00
Egor c24c668299 Update Dockerfile 2025-10-07 06:47:33 +03:00
Egor 41df40eed3 Update docker-registry.yml 2025-10-07 06:47:26 +03:00
Egor b1975cc3ff Update docker-hub.yml 2025-10-07 06:47:14 +03:00
Egor 369e2c3c36 Merge pull request #853 from Fr1ngg/dev2
Дополнительные методы API для работы со страницами из ИНФО + вынесение хостов в конфигурации для платежек
2025-10-07 06:35:19 +03:00
Egor e2ec4b05db Merge pull request #852 from Fr1ngg/r6u84x-bedolaga/extract-host-to-bot-configuration
Allow configuring webhook hosts
2025-10-07 06:32:21 +03:00
Egor 7493b13348 Allow configuring YooKassa webhook host 2025-10-07 06:32:03 +03:00
Egor 0a65aa16cd Merge pull request #851 from Fr1ngg/461rmg-bedolaga/add-api-endpoints-for-offer-faq-terms
Allow FAQ creation API to pass display order and status
2025-10-07 06:29:22 +03:00
Egor 64a4ece0fe Allow FAQ creation API to pass display order and status 2025-10-07 06:28:26 +03:00
Egor 71664cca3e Merge pull request #850 from Fr1ngg/revert-847-avd1an-bedolaga/add-api-endpoints-for-offer-faq-terms
Revert "Add informational pages endpoints to admin web API"
2025-10-07 06:28:09 +03:00
Egor b21177f685 Revert "Add informational pages endpoints to admin web API" 2025-10-07 06:28:00 +03:00
Egor cde6aa4c26 Merge pull request #849 from Fr1ngg/revert-848-5sy6f4-bedolaga/extract-host-to-bot-configuration
Revert "Allow configuring Tribute webhook host"
2025-10-07 06:26:51 +03:00
Egor 5264fc0d90 Revert "Allow configuring Tribute webhook host" 2025-10-07 06:26:37 +03:00
Egor 7aa2004f27 Merge pull request #848 from Fr1ngg/5sy6f4-bedolaga/extract-host-to-bot-configuration
Allow configuring Tribute webhook host
2025-10-07 06:23:26 +03:00
Egor 40a34d1e55 Allow configuring Tribute webhook host 2025-10-07 06:22:41 +03:00
Egor 4204b459e2 Merge pull request #847 from Fr1ngg/avd1an-bedolaga/add-api-endpoints-for-offer-faq-terms
Add informational pages endpoints to admin web API
2025-10-07 06:21:03 +03:00
Egor cbe80e9447 Add pages management endpoints to web API 2025-10-07 06:20:44 +03:00
Egor 0cfd8d3c7b Merge pull request #846 from Fr1ngg/dev2
FAQ, Политика Конф, Оферта
2025-10-07 06:08:48 +03:00
Egor ddfa61c56f Merge pull request #845 from Fr1ngg/revert-842-revert-839-g9mnci-bedolaga/add-faq-button-to-bot-info-section
Revert "Revert "Add FAQ management and user menu support""
2025-10-07 06:03:01 +03:00
Egor 7e340bc13a Revert "Revert "Add FAQ management and user menu support"" 2025-10-07 06:02:50 +03:00
Egor f7dd3be27c Merge pull request #844 from Fr1ngg/rcd0rc-bedolaga/add-offer-button-to-bot-info-section
Fix admin offer preview HTML truncation
2025-10-07 06:02:38 +03:00
Egor 3ff06ed9d1 Use paginator for admin offer preview 2025-10-07 06:02:19 +03:00
Egor 08d6a26b23 Merge pull request #843 from Fr1ngg/revert-841-ptoawe-bedolaga/add-offer-button-to-bot-info-section
Revert "Add configurable public offer info section"
2025-10-07 06:01:38 +03:00
Egor 37e472d78a Revert "Add configurable public offer info section" 2025-10-07 06:01:25 +03:00
Egor 8cc04e01e9 Merge pull request #842 from Fr1ngg/revert-839-g9mnci-bedolaga/add-faq-button-to-bot-info-section
Revert "Add FAQ management and user menu support"
2025-10-07 06:01:18 +03:00
Egor c199d04b91 Revert "Add FAQ management and user menu support" 2025-10-07 06:01:09 +03:00
Egor 102d5d0a9c Merge pull request #839 from Fr1ngg/g9mnci-bedolaga/add-faq-button-to-bot-info-section
Add FAQ management and user menu support
2025-10-07 05:52:53 +03:00
Egor 8e7b44811e Merge pull request #841 from Fr1ngg/ptoawe-bedolaga/add-offer-button-to-bot-info-section
Add configurable public offer info section
2025-10-07 05:52:37 +03:00
Egor ab1be9943b Fix HTML paginator preserving entities 2025-10-07 05:52:19 +03:00
Egor 61b702272b Merge pull request #840 from Fr1ngg/revert-838-5gl2xr-bedolaga/add-offer-button-to-bot-info-section
Revert "Add configurable public offer info section"
2025-10-07 05:51:59 +03:00
Egor 84bce0043d Revert "Add configurable public offer info section" 2025-10-07 05:51:51 +03:00
Egor f54528f2d5 Add FAQ management and user menu support 2025-10-07 05:47:41 +03:00
Egor b66911f4f7 Merge pull request #838 from Fr1ngg/5gl2xr-bedolaga/add-offer-button-to-bot-info-section
Add configurable public offer info section
2025-10-07 05:45:04 +03:00
Egor 352c14331d Handle HTML tags when paginating public offer text 2025-10-07 05:44:16 +03:00
Egor 7ea91f344c Merge pull request #837 from Fr1ngg/revert-836-t8dpxo-bedolaga/add-offer-button-to-bot-info-section
Revert "Add configurable public offer info section"
2025-10-07 05:40:05 +03:00
Egor d2d5b6c823 Revert "Add configurable public offer info section" 2025-10-07 05:39:54 +03:00
Egor 4405c2287a Merge pull request #836 from Fr1ngg/t8dpxo-bedolaga/add-offer-button-to-bot-info-section
Add configurable public offer info section
2025-10-07 05:27:38 +03:00
Egor 288a72f739 Fix per-language public offer disabling 2025-10-07 05:27:21 +03:00
Egor 60391a8b89 Merge pull request #835 from Fr1ngg/revert-834-plexvn-bedolaga/add-offer-button-to-bot-info-section
Revert "Add configurable public offer info section"
2025-10-07 05:27:06 +03:00
Egor f2d4da9514 Revert "Add configurable public offer info section" 2025-10-07 05:26:57 +03:00
Egor 648ad24e6d Merge pull request #834 from Fr1ngg/plexvn-bedolaga/add-offer-button-to-bot-info-section
Add configurable public offer info section
2025-10-07 05:17:12 +03:00
Egor 5d28cd9930 Add configurable public offer info section 2025-10-07 05:16:53 +03:00
Egor 43974d1685 Merge pull request #833 from Fr1ngg/kq9kol-bedolaga/add-config-policy-button-to-bot-info
Add privacy policy info menu and admin controls
2025-10-07 04:57:06 +03:00
Egor 39ccc7fb4a Add privacy policy section and admin management 2025-10-07 04:56:45 +03:00
Egor d391a46b0e Merge pull request #831 from Fr1ngg/dev2
Расширения функционала отправки промо предложений / отправка конкретному юзеру
2025-10-07 02:39:35 +03:00
Egor 59dd638dd7 Merge pull request #830 from Fr1ngg/bedolaga-5bnc62
Fix promo offer durations and active discount display
2025-10-07 02:35:47 +03:00
Egor 3062061270 Fix promo offer durations and active discount display 2025-10-07 02:34:47 +03:00
Egor 5b0a64964e Merge pull request #829 from Fr1ngg/revert-828-bedolaga-48se3m
Revert "Add detailed promo offer status for user view"
2025-10-07 02:33:52 +03:00
Egor 70e3517d3a Revert "Add detailed promo offer status for user view" 2025-10-07 02:33:44 +03:00
Egor 08b64abb03 Merge pull request #828 from Fr1ngg/bedolaga-48se3m
Add detailed promo offer status for user view
2025-10-07 02:21:14 +03:00
Egor 84a3179974 Add detailed promo offer status for user view 2025-10-07 02:21:02 +03:00
Egor fa56ad8791 Merge pull request #827 from Fr1ngg/revert-826-bedolaga-ztqa24
Revert "Improve promo offer user view with status details"
2025-10-07 02:20:45 +03:00
Egor f584cb78f1 Revert "Improve promo offer user view with status details" 2025-10-07 02:20:36 +03:00
Egor ff3ac2b9f3 Merge pull request #826 from Fr1ngg/bedolaga-ztqa24
Improve promo offer user view with status details
2025-10-07 02:19:06 +03:00
Egor deabcfba28 Enhance promo offer user detail stats 2025-10-07 02:18:45 +03:00
Egor 85775604a8 Merge pull request #825 from Fr1ngg/revert-824-bedolaga-pzccm9
Revert "Fix promo offer localization placeholder mismatch"
2025-10-07 02:16:00 +03:00
Egor bf9b99818d Revert "Fix promo offer localization placeholder mismatch" 2025-10-07 02:15:52 +03:00
Egor 93a8c182bc Merge pull request #824 from Fr1ngg/bedolaga-pzccm9
Fix promo offer localization placeholder mismatch
2025-10-07 02:04:54 +03:00
Egor 7febaf9db2 Fix promo offer profile summary placeholders 2025-10-07 02:04:38 +03:00
Egor 21583279c6 Merge pull request #823 from Fr1ngg/revert-822-bedolaga-b9dizq
Revert "Cleanup promo offer user search prompt and add localization"
2025-10-07 02:04:11 +03:00
Egor df11e09d3a Revert "Cleanup promo offer user search prompt and add localization" 2025-10-07 02:04:02 +03:00
Egor 0b2ebbcdd8 Merge pull request #822 from Fr1ngg/bedolaga-b9dizq
Cleanup promo offer user search prompt and add localization
2025-10-07 01:57:12 +03:00
Egor 28be0954f9 Fix promo offer user search UX and translations 2025-10-07 01:56:59 +03:00
Egor 44164b2948 Merge pull request #821 from Fr1ngg/revert-820-bedolaga-72teet
Revert "Add user-targeted promo offer sending workflow"
2025-10-07 01:56:25 +03:00
Egor d9d4f171f6 Revert "Add user-targeted promo offer sending workflow" 2025-10-07 01:56:17 +03:00
Egor 4c73d1d0b6 Merge pull request #820 from Fr1ngg/bedolaga-72teet
Add user-targeted promo offer sending workflow
2025-10-07 01:41:34 +03:00
Egor 0bf71971f9 feat: add user-targeted promo offer sending 2025-10-07 01:41:15 +03:00
Egor ff19fb8950 Merge pull request #819 from Fr1ngg/revert-818-bedolaga-pm5a67
Revert "Add promo template editing workflow and restrictions"
2025-10-07 01:21:32 +03:00
Egor 68a37fcf72 Revert "Add promo template editing workflow and restrictions" 2025-10-07 01:21:20 +03:00
Egor 2caf16ccc6 Merge pull request #818 from Fr1ngg/bedolaga-pm5a67
Add promo template editing workflow and restrictions
2025-10-07 01:19:23 +03:00
Egor b38ee36698 Add promo template editing workflow and restrictions 2025-10-07 01:19:05 +03:00
Egor 3ee9c3de7b Merge pull request #817 from Fr1ngg/revert-816-bedolaga-m8ijxh
Revert "Improve admin promo template restrictions and editing"
2025-10-07 01:18:50 +03:00
Egor 0fce289926 Revert "Improve admin promo template restrictions and editing" 2025-10-07 01:18:42 +03:00
Egor b1e7f83d11 Merge pull request #816 from Fr1ngg/bedolaga-m8ijxh
Improve admin promo template restrictions and editing
2025-10-07 01:17:09 +03:00
Egor ff28f82240 Refine admin promo template workflow 2025-10-07 01:16:44 +03:00
Egor d5082be405 Merge pull request #815 from Fr1ngg/revert-814-bedolaga
Revert "Add per-user promo template sending in admin panel"
2025-10-07 01:16:13 +03:00
Egor 3e830a32ea Revert "Add per-user promo template sending in admin panel" 2025-10-07 01:16:05 +03:00
Egor c8a99ceed7 Merge pull request #814 from Fr1ngg/bedolaga
Add per-user promo template sending in admin panel
2025-10-07 00:19:18 +03:00
Egor 602ddd4013 feat(admin): manage user promo offers 2025-10-07 00:18:59 +03:00
Egor 33443955c7 Merge pull request #811 from Fr1ngg/dev2
Dev2
2025-10-06 04:48:28 +03:00
Egor 8f8b6ac1fe Merge pull request #810 from Fr1ngg/bedolaga/add-info-section-to-main-page
Add info section to main menu
2025-10-06 04:46:16 +03:00
Egor 8bb0795b1e Add info section to main menu 2025-10-06 04:45:54 +03:00
Egor 0055560d3e Merge pull request #809 from Fr1ngg/dev2
api promo offers
2025-10-06 04:37:05 +03:00
Egor dcc209deea Merge pull request #808 from Fr1ngg/bedolaga/add-api-endpoints-for-promo-offers
Add promo offer management endpoints
2025-10-06 04:27:40 +03:00
Egor a2cb7527f8 feat(api): add promo offers endpoints and docs 2025-10-06 04:27:21 +03:00
Egor 523c6ab69c Merge pull request #807 from Fr1ngg/dev2
Фикс промо предложений
2025-10-06 02:58:38 +03:00
Egor 91220b0b5f Merge pull request #806 from Fr1ngg/main
w
2025-10-06 02:57:53 +03:00
Egor f87dac8efa Merge pull request #805 from Fr1ngg/bedolaga/fix-discount-claim-error
Handle custom placeholders in discount claim message
2025-10-06 02:53:03 +03:00
Egor 44b75be97a Handle missing placeholders in discount claim message 2025-10-06 02:52:44 +03:00
Egor 500ed4cdf9 Update Dockerfile 2025-10-06 01:59:41 +03:00
Egor 498bd77076 Update docker-registry.yml 2025-10-06 01:59:29 +03:00
Egor 29afb6e67f Update docker-hub.yml 2025-10-06 01:59:16 +03:00
Egor 01d10b58dd Merge pull request #804 from Fr1ngg/dev2
Рассылка промопредложений + выдача сквадов(серверов) на время
2025-10-06 01:50:20 +03:00
Egor cf7aed9abf Merge pull request #803 from Fr1ngg/main
w
2025-10-06 01:46:02 +03:00
Egor 1b57531b28 Merge pull request #802 from Fr1ngg/bedolaga/fix-telegram-button-privacy-restriction-error-ptdrym
Handle Telegram privacy restriction fallback
2025-10-06 01:37:31 +03:00
Egor 375c096e82 Handle Telegram privacy restriction fallback 2025-10-06 01:37:12 +03:00
Egor 89b0daae1d Merge pull request #800 from Fr1ngg/bedolaga/fix-bot-main-message-for-test-server-lplld3
Escape braces in promo hint server names
2025-10-06 01:25:44 +03:00
Egor 754c21a32f Escape braces in promo hint server names 2025-10-06 01:25:26 +03:00
Egor 4928cdde43 Merge pull request #799 from Fr1ngg/revert-798-bedolaga/fix-bot-main-message-for-test-server-36fdi2
Revert "Escape test server names in promo hint"
2025-10-06 01:25:14 +03:00
Egor 057e5f6ab0 Revert "Escape test server names in promo hint" 2025-10-06 01:25:03 +03:00
Egor 6d66d6f627 Merge pull request #798 from Fr1ngg/bedolaga/fix-bot-main-message-for-test-server-36fdi2
Escape test server names in promo hint
2025-10-06 01:15:58 +03:00
Egor be28e9bec4 Escape server names in promo hint 2025-10-06 01:15:42 +03:00
Egor b923c4bcaa Merge pull request #797 from Fr1ngg/revert-796-bedolaga/fix-bot-main-message-for-test-server
Revert "Display test server names in promo hint"
2025-10-06 01:15:26 +03:00
Egor 3a749c1864 Revert "Display test server names in promo hint" 2025-10-06 01:14:58 +03:00
Egor d16d5b7918 Merge pull request #796 from Fr1ngg/bedolaga/fix-bot-main-message-for-test-server
Display test server names in promo hint
2025-10-06 01:09:23 +03:00
Egor 8484515722 Show test server names in promo hint 2025-10-06 01:08:53 +03:00
Egor b9efff6e97 Update ru.json 2025-10-06 01:01:41 +03:00
Egor 061557c438 Update ru.json 2025-10-06 00:58:53 +03:00
Egor df09ccfbd3 Merge pull request #795 from Fr1ngg/bedolaga-4ou2e3
Display promo offer details on main menu
2025-10-06 00:49:26 +03:00
Egor 16c7e140d0 Guard promo offer extra_data lookups 2025-10-06 00:49:09 +03:00
Egor 967924a1ff Merge pull request #794 from Fr1ngg/revert-793-revert-790-revert-789-bedolaga-ly8q4p
Revert "Revert "Revert "Display promo offer details on main menu"""
2025-10-06 00:48:55 +03:00
Egor e028a25df2 Revert "Revert "Revert "Display promo offer details on main menu""" 2025-10-06 00:48:46 +03:00
Egor d3fcd11201 Merge pull request #793 from Fr1ngg/revert-790-revert-789-bedolaga-ly8q4p
Revert "Revert "Display promo offer details on main menu""
2025-10-06 00:42:56 +03:00
Egor 97b9056b0b Revert "Revert "Display promo offer details on main menu"" 2025-10-06 00:42:46 +03:00
Egor 00ba52672b Merge pull request #792 from Fr1ngg/revert-791-bedolaga-g6lr1v
Revert "Add active promo info to main menu"
2025-10-06 00:42:01 +03:00
Egor efe16b4269 Revert "Add active promo info to main menu" 2025-10-06 00:41:42 +03:00
Egor 5e94ad2189 Merge pull request #791 from Fr1ngg/bedolaga-g6lr1v
Add active promo info to main menu
2025-10-06 00:37:37 +03:00
Egor 53bb582c81 Add active promo info to main menu 2025-10-06 00:37:19 +03:00
Egor 4c20ec19ab Merge pull request #790 from Fr1ngg/revert-789-bedolaga-ly8q4p
Revert "Display promo offer details on main menu"
2025-10-06 00:36:36 +03:00
Egor a7042ea012 Revert "Display promo offer details on main menu" 2025-10-06 00:36:26 +03:00
Egor f700285415 Merge pull request #789 from Fr1ngg/bedolaga-ly8q4p
Display promo offer details on main menu
2025-10-06 00:32:44 +03:00
Egor ed1fc22f6b Show promo offer details on main menu 2025-10-06 00:32:27 +03:00
Egor c3c0c764fd Merge pull request #788 from Fr1ngg/bedolaga/update-promo-offer-message-and-add-status-bar
Add promo offer timer to subscription flows
2025-10-06 00:10:22 +03:00
Egor 746e810c58 Add promo offer timer and updated discount messaging 2025-10-06 00:10:06 +03:00
Egor 2df5d21a9e Merge pull request #787 from Fr1ngg/bedolaga/fix-subscription-button-text-for-trial-users
Fix promo purchase discount CTA for trials
2025-10-05 23:35:36 +03:00
Egor d664b99e1c Adjust promo discount CTA for purchase offers 2025-10-05 23:35:09 +03:00
Egor 74fe3b09a2 Merge pull request #786 from Fr1ngg/revert-785-bedolaga-8svrvz
Revert "Adjust discount claim CTA for purchase offers"
2025-10-05 17:28:03 +03:00
Egor efea0e8414 Revert "Adjust discount claim CTA for purchase offers" 2025-10-05 17:27:54 +03:00
Egor 1dc4d0e04d Merge pull request #785 from Fr1ngg/bedolaga-8svrvz
Adjust discount claim CTA for purchase offers
2025-10-05 17:24:41 +03:00
Egor 84f06a96b3 Adjust discount claim CTA for purchase offers 2025-10-05 17:24:25 +03:00
Egor 4a7cc3306a Merge pull request #783 from Fr1ngg/bedolaga/add-duration-for-subscription-promo-discounts
Add expiration control for promo offer discounts
2025-10-05 17:01:38 +03:00
Egor 5d576bc727 Add expiration control for promo offer discounts 2025-10-05 17:01:11 +03:00
Egor 19a7a8e6b1 Merge pull request #782 from Fr1ngg/bedolaga/update-promo-message-buttons
Adjust promo offer keyboards to add close button
2025-10-05 06:11:11 +03:00
Egor 064f64afd0 Restrict promo offer keyboards to activation and close 2025-10-05 06:10:49 +03:00
Egor bfa5e5aab9 Merge pull request #781 from Fr1ngg/bedolaga-zcm3jb
Fix discount activation CTA to extend subscription
2025-10-05 05:47:29 +03:00
Egor f59bda57cf Adjust discount activation CTA 2025-10-05 05:46:55 +03:00
Egor 6887c265ad Merge pull request #780 from Fr1ngg/bedolaga/fix-chat-message-retention-during-promo-editing
Удаление сообщений администратора при редактировании промо-предложений
2025-10-05 05:31:06 +03:00
Egor 7db31ff5b5 Удаление пользовательских сообщений при редактировании промо 2025-10-05 05:30:45 +03:00
Egor 6bee1bb85d Merge pull request #779 from Fr1ngg/bedolaga/add-active-trials-category-to-promotions
Add active trial segment to purchase discount offer
2025-10-05 05:11:01 +03:00
Egor c604c5f6ff Add active trial segment to purchase promo 2025-10-05 05:10:45 +03:00
Egor ed5e6aa550 Merge pull request #777 from Fr1ngg/bedolaga/add-paginated-log-for-promo-offers-qglaer
fix: rollback session after promo log failures
2025-10-05 04:06:21 +03:00
Egor 6b78d0f63e Merge pull request #778 from Fr1ngg/revert-776-bedolaga/add-paginated-log-for-promo-offers-nmhsmh
Revert "fix: restore promo test access claim flow"
2025-10-05 04:05:56 +03:00
Egor fea1819ef8 Revert "fix: restore promo test access claim flow" 2025-10-05 04:05:47 +03:00
Egor 45586277ab fix: rollback session after promo log failures 2025-10-05 04:05:43 +03:00
Egor 8ff3383246 Merge pull request #776 from Fr1ngg/bedolaga/add-paginated-log-for-promo-offers-nmhsmh
fix: restore promo test access claim flow
2025-10-05 03:54:57 +03:00
Egor 7879dc5e43 fix: define newly added test access squads 2025-10-05 03:54:39 +03:00
Egor 392084e436 Merge pull request #775 from Fr1ngg/revert-774-revert-771-revert-770-bedolaga/add-paginated-log-for-promo-offers
Revert "Revert "Revert "feat: add promo offer operation logs"""
2025-10-05 03:53:28 +03:00
Egor 256e1cb778 Revert "Revert "Revert "feat: add promo offer operation logs""" 2025-10-05 03:53:16 +03:00
Egor e131f77de3 Merge pull request #774 from Fr1ngg/revert-771-revert-770-bedolaga/add-paginated-log-for-promo-offers
Revert "Revert "feat: add promo offer operation logs""
2025-10-05 03:46:36 +03:00
Egor a60082e5e5 Revert "Revert "feat: add promo offer operation logs"" 2025-10-05 03:46:23 +03:00
Egor 81bb8f8f4a Merge pull request #773 from Fr1ngg/revert-772-bedolaga/add-paginated-log-for-promo-offers-3spy1o
Revert "Add promo offer activity log in admin panel"
2025-10-05 03:46:05 +03:00
Egor a9af75d9df Revert "Add promo offer activity log in admin panel" 2025-10-05 03:45:55 +03:00
Egor 30758848fa Merge pull request #772 from Fr1ngg/bedolaga/add-paginated-log-for-promo-offers-3spy1o
Add promo offer activity log in admin panel
2025-10-05 03:44:46 +03:00
Egor 00c024bd2c Add promo offer activity log in admin panel 2025-10-05 03:44:28 +03:00
Egor a69d28fb13 Merge pull request #771 from Fr1ngg/revert-770-bedolaga/add-paginated-log-for-promo-offers
Revert "feat: add promo offer operation logs"
2025-10-05 03:44:03 +03:00
Egor ac818ccf34 Revert "feat: add promo offer operation logs" 2025-10-05 03:43:53 +03:00
Egor 533335bad9 Merge pull request #770 from Fr1ngg/bedolaga/add-paginated-log-for-promo-offers
feat: add promo offer operation logs
2025-10-05 03:32:40 +03:00
Egor 37c99ae1fe feat: add promo offer operation logs 2025-10-05 03:32:24 +03:00
Egor d9b232b87b Merge pull request #769 from Fr1ngg/bedolaga/add-buttons-for-subscription-promotions
Enhance promo offer messaging and safeguards
2025-10-04 17:06:41 +03:00
Egor b8df611d0d Enhance promo offer flows 2025-10-04 17:06:24 +03:00
Egor e0b5beed02 Merge pull request #768 from Fr1ngg/bedolaga/add-purchase-button-to-discount-message
Fix promo discount message button and checkout crash
2025-10-04 16:43:04 +03:00
Egor f92cf97f63 Fix promo discount claim actions 2025-10-04 16:42:44 +03:00
Egor 9ae11bc525 Merge pull request #767 from Fr1ngg/bedolaga/update-promo-offer-functionality-in-admin-panel
Adjust promo test offers with server targeting
2025-10-04 16:29:38 +03:00
Egor ef1426a938 Adjust promo test offers with server targeting 2025-10-04 16:29:23 +03:00
Egor 456b02e2d9 Merge pull request #766 from Fr1ngg/bedolaga/enhance-promo-offer-functionality-onp0w2
Ensure subscription service reloads RemnaWave configuration
2025-10-04 16:12:25 +03:00
Egor abb156a7b1 Fix RemnaWave config refresh in subscription service 2025-10-04 16:12:03 +03:00
Egor ca8917a43c Merge pull request #765 from Fr1ngg/revert-764-bedolaga/enhance-promo-offer-functionality-mxpx1o
Revert "Fix test access sync and add menu navigation"
2025-10-04 16:08:54 +03:00
Egor 8ce75007ff Revert "Fix test access sync and add menu navigation" 2025-10-04 16:08:45 +03:00
Egor 76ced9aeeb Merge pull request #764 from Fr1ngg/bedolaga/enhance-promo-offer-functionality-mxpx1o
Fix test access sync and add menu navigation
2025-10-04 13:10:40 +03:00
Egor 42eeb67039 Fix test access sync and add back to menu button 2025-10-04 13:10:23 +03:00
Egor c11be659b3 Merge pull request #763 from Fr1ngg/revert-762-bedolaga/enhance-promo-offer-functionality-wktrm4
Revert "Improve test access promo syncing and messaging"
2025-10-04 13:09:58 +03:00
Egor 2a44d98015 Revert "Improve test access promo syncing and messaging" 2025-10-04 13:09:48 +03:00
Egor 885fa2fb1a Merge pull request #762 from Fr1ngg/bedolaga/enhance-promo-offer-functionality-wktrm4
Improve test access promo syncing and messaging
2025-10-04 13:07:00 +03:00
Egor 8bcc2135ca Handle Remnawave sync failures for test access offers 2025-10-04 13:06:43 +03:00
Egor a26d2fb50b Merge pull request #761 from Fr1ngg/revert-760-bedolaga/enhance-promo-offer-functionality
Revert "Add admin squad picker for test access promo offers"
2025-10-04 13:06:18 +03:00
Egor ad3a8b3af3 Revert "Add admin squad picker for test access promo offers" 2025-10-04 13:06:09 +03:00
Egor 918a450e63 Merge pull request #760 from Fr1ngg/bedolaga/enhance-promo-offer-functionality
Add admin squad picker for test access promo offers
2025-10-04 12:52:52 +03:00
Egor 596053c720 feat: add squad picker for test promo offers 2025-10-04 12:52:27 +03:00
Egor abb83b43bc Merge pull request #759 from Fr1ngg/bedolaga/fix-promo-discount-handling-and-messages
Fix promo discount consumption race and refresh promo offer texts
2025-10-04 12:25:22 +03:00
Egor 6f67a36a58 Fix promo discount consumption and admin copy 2025-10-04 12:25:00 +03:00
Egor 9cbc9bb3d5 Merge pull request #758 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount-5mdcu6
Honor promo offer discounts in autopay renewals
2025-10-04 12:10:34 +03:00
Egor 1cbf3c5643 Recalculate promo offer discount during subscription confirmation 2025-10-04 12:10:04 +03:00
Egor 8f35d56e6a Merge pull request #757 from Fr1ngg/revert-756-bedolaga/update-promo-offers-to-apply-percentage-discount-3jskcs
Revert "Honor promo offer discounts in autopay renewals"
2025-10-04 12:09:13 +03:00
Egor b5773ba668 Revert "Honor promo offer discounts in autopay renewals" 2025-10-04 12:09:04 +03:00
Egor fa5847ee64 Merge pull request #756 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount-3jskcs
Honor promo offer discounts in autopay renewals
2025-10-04 11:53:09 +03:00
Egor b561fbc9e5 Apply promo offer discounts to autopay renewals 2025-10-04 11:52:38 +03:00
Egor 305a0b6b95 Merge pull request #755 from Fr1ngg/revert-754-bedolaga/update-promo-offers-to-apply-percentage-discount-u1051x
Revert "Fix promo offer validation to use pre-discount totals"
2025-10-04 11:52:02 +03:00
Egor 12a16097cf Revert "Fix promo offer validation to use pre-discount totals" 2025-10-04 11:51:52 +03:00
Egor c8986bea40 Merge pull request #754 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount-u1051x
Fix promo offer validation to use pre-discount totals
2025-10-04 11:43:00 +03:00
Egor bcf01ecf6a Fix promo offer validation to use pre-discount totals 2025-10-04 11:42:43 +03:00
Egor a334fbffb7 Merge pull request #753 from Fr1ngg/revert-752-bedolaga/update-promo-offers-to-apply-percentage-discount-lt6fpc
Revert "Refactor promo offers to apply stackable discounts"
2025-10-04 11:42:16 +03:00
Egor fc8f55195f Revert "Refactor promo offers to apply stackable discounts" 2025-10-04 11:42:06 +03:00
Egor dbd79cbc65 Merge pull request #752 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount-lt6fpc
Refactor promo offers to apply stackable discounts
2025-10-04 11:35:06 +03:00
Egor 436686dd1b Ensure promo offers use stackable discounts 2025-10-04 11:34:46 +03:00
Egor 227e754f4a Merge pull request #751 from Fr1ngg/revert-750-bedolaga/update-promo-offers-to-apply-percentage-discount-c11ife
Revert "Implement percent-based promo discounts"
2025-10-04 11:34:31 +03:00
Egor 3d43358b86 Revert "Implement percent-based promo discounts" 2025-10-04 11:34:22 +03:00
Egor f05f32b92e Merge pull request #750 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount-c11ife
Implement percent-based promo discounts
2025-10-04 11:32:43 +03:00
Egor 7628b9aaa7 Implement percent-based promo discounts 2025-10-04 11:31:55 +03:00
Egor 9ab8f1dee9 Merge pull request #748 from Fr1ngg/revert-747-bedolaga/update-promo-offers-to-apply-percentage-discount
Revert "Implement stackable percent-based promo offers"
2025-10-04 11:31:05 +03:00
Egor 092ea034ec Revert "Implement stackable percent-based promo offers" 2025-10-04 11:30:57 +03:00
Egor c4e5261b31 Merge pull request #747 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount
Implement stackable percent-based promo offers
2025-10-04 11:29:04 +03:00
Egor 2baf2b9abb Adjust promo offer templates for percent discounts 2025-10-04 11:28:47 +03:00
Egor 87a6b05e54 Merge pull request #746 from Fr1ngg/revert-745-bedolaga/update-promo-offers-for-discounts-rwpidj
Revert "Implement percent-based promo offers"
2025-10-04 11:05:56 +03:00
Egor 0925dfdf80 Revert "Implement percent-based promo offers" 2025-10-04 11:05:47 +03:00
Egor cf294fae0a Merge pull request #745 from Fr1ngg/bedolaga/update-promo-offers-for-discounts-rwpidj
Implement percent-based promo offers
2025-10-04 10:58:35 +03:00
Egor 12e77cd6bf Implement percent-based promo offers 2025-10-04 10:57:00 +03:00
Egor 867ae1b807 Merge pull request #744 from Fr1ngg/revert-743-bedolaga/update-promo-offers-for-discounts-9jz4ar
Revert "Clear expired promo discounts when preparing checkout summaries"
2025-10-04 10:55:45 +03:00
Egor db9f91b336 Revert "Clear expired promo discounts when preparing checkout summaries" 2025-10-04 10:55:32 +03:00
Egor df0792d4dd Merge pull request #743 from Fr1ngg/bedolaga/update-promo-offers-for-discounts-9jz4ar
Clear expired promo discounts when preparing checkout summaries
2025-10-04 10:54:35 +03:00
Egor 0e0569fe23 Clear expired promo discounts before checkout 2025-10-04 10:53:09 +03:00
Egor daa37370e3 Merge pull request #741 from Fr1ngg/bedolaga/fix-invalid-values-error-for-active-subscriptions
Fix parsing of promo offer send callbacks
2025-10-04 10:19:36 +03:00
Egor b35c731ba5 Fix promo offer segment parsing 2025-10-04 10:19:05 +03:00
Egor 06879089a1 Merge pull request #740 from Fr1ngg/bedolaga/fix-missing-join-in-promo-segment-query
Fix promo segment join for no subscription users
2025-10-04 10:14:01 +03:00
Egor b96d530808 Fix promo segment no subscription join 2025-10-04 10:13:45 +03:00
Egor 8f3c8f37c1 Merge pull request #739 from Fr1ngg/bedolaga/add-promotional-offers-feature-to-bot-mgwac3
Add admin promo offer management and targeted promotions
2025-10-04 10:05:54 +03:00
Egor e4c7a9cf15 Add admin promo offers and targeted discount workflows 2025-10-04 10:05:31 +03:00
Egor 935e2356db Merge pull request #738 from Fr1ngg/revert-737-bedolaga/add-promotional-offers-feature-to-bot
Revert "Implement targeted promotional offer campaigns"
2025-10-04 10:04:59 +03:00
Egor 3b392a671d Revert "Implement targeted promotional offer campaigns" 2025-10-04 10:04:49 +03:00
Egor 4ceb09d609 Merge pull request #737 from Fr1ngg/bedolaga/add-promotional-offers-feature-to-bot
Implement targeted promotional offer campaigns
2025-10-04 09:59:41 +03:00
Egor 3f8ea7f8fd Add promotional offer broadcasting support 2025-10-04 09:59:17 +03:00
Egor 95525f2132 Update README.md 2025-10-04 08:03:44 +03:00
Egor d15b46dede Merge pull request #736 from Legacyyy777/DEV
Мулен 3
2025-10-04 07:44:54 +03:00
Egor f2fc7581a3 Update Dockerfile 2025-10-04 07:37:47 +03:00
Egor e96010d24d Update docker-registry.yml 2025-10-04 07:37:32 +03:00
Egor 35fc39b9f2 Update docker-hub.yml 2025-10-04 07:37:13 +03:00
Legacyyy777 7129625ab5 Merge branch 'Fr1ngg:main' into DEV 2025-10-04 09:23:31 +05:00
Legacyyy777 e480356447 Оптимизирована обработка подключения к БД в WebhookServer. Удален цикл async for, добавлена обработка исключений при получении соединения с БД. 2025-10-04 09:21:49 +05:00
Egor 46ea2de300 Merge pull request #735 from Fr1ngg/dev2
Перенос настроек цен и управления серверов + рефактор запуска бота
2025-10-04 06:58:37 +03:00
Egor 2152ded3c4 Merge pull request #734 from Fr1ngg/bedolaga/improve-bot-startup-log-format-ih803q
feat: add structured startup timeline logging
2025-10-04 06:51:36 +03:00
Egor da0e8a2e3e feat: add structured startup timeline logging 2025-10-04 06:51:19 +03:00
Egor da540e8680 Merge pull request #733 from Fr1ngg/revert-732-bedolaga/improve-bot-startup-log-format
Revert "Enhance startup log presentation"
2025-10-04 06:51:07 +03:00
Egor a12ad52c7e Revert "Enhance startup log presentation" 2025-10-04 06:50:59 +03:00
Egor 38b5fa297b Merge pull request #732 from Fr1ngg/bedolaga/improve-bot-startup-log-format
Enhance startup log presentation
2025-10-04 06:49:05 +03:00
Egor d50475974c Improve startup logging aesthetics 2025-10-04 06:48:49 +03:00
Egor ada891f2d9 Merge pull request #731 from Fr1ngg/bedolaga/resolve-missing-localization-keys
Add missing admin pricing locale strings
2025-10-04 06:40:13 +03:00
Egor e7467e5434 Add missing admin pricing translations 2025-10-04 06:38:45 +03:00
Egor fa4a692574 Merge pull request #730 from Fr1ngg/bedolaga/fix-subscription-price-update-in-admin-panel-y4nuoo
Mutate period price cache in place
2025-10-04 06:31:18 +03:00
Egor 9c60ef2c33 Mutate period price cache in place 2025-10-04 06:30:50 +03:00
Egor 634633d12e Merge pull request #729 from Fr1ngg/revert-728-bedolaga/fix-subscription-price-update-in-admin-panel-ivhvb7
Revert "Fix subscription period price cache update"
2025-10-04 06:30:25 +03:00
Egor b3fd0c50ca Revert "Fix subscription period price cache update" 2025-10-04 06:30:17 +03:00
Egor acb267668e Merge pull request #728 from Fr1ngg/bedolaga/fix-subscription-price-update-in-admin-panel-ivhvb7
Fix subscription period price cache update
2025-10-04 06:24:55 +03:00
Egor 344cffef60 Fix period price cache refresh 2025-10-04 06:24:38 +03:00
Egor ef652d4518 Merge pull request #727 from Fr1ngg/revert-726-bedolaga/fix-subscription-price-update-in-admin-panel-8wrvf1
Revert "Fix subscription period price updates in admin panel"
2025-10-04 06:23:25 +03:00
Egor a66f9bbf28 Revert "Fix subscription period price updates in admin panel" 2025-10-04 06:23:17 +03:00
Egor 3e5fbcf2bd Merge pull request #726 from Fr1ngg/bedolaga/fix-subscription-price-update-in-admin-panel-8wrvf1
Fix subscription period price updates in admin panel
2025-10-04 06:20:12 +03:00
Egor ba2de9e150 Fix period price updates in admin 2025-10-04 06:19:52 +03:00
Egor b638e04629 Merge pull request #725 from Fr1ngg/revert-724-bedolaga/fix-subscription-price-update-in-admin-panel
Revert "Fix subscription price refresh in admin panel"
2025-10-04 06:19:32 +03:00
Egor 45a5f012c2 Revert "Fix subscription price refresh in admin panel" 2025-10-04 06:19:24 +03:00
Egor 1a66094800 Merge pull request #724 from Fr1ngg/bedolaga/fix-subscription-price-update-in-admin-panel
Fix subscription price refresh in admin panel
2025-10-04 06:17:21 +03:00
Egor f7afe481bb Fix subscription price refresh in admin panel 2025-10-04 06:17:04 +03:00
Egor f04bf43962 Merge pull request #723 from Fr1ngg/bedolaga/add-trial-period-management-in-admin-panel-emq0t0
Refine admin pricing menu UX
2025-10-04 06:07:50 +03:00
Egor 3964ed1f3c Improve pricing admin panel UX 2025-10-04 06:07:35 +03:00
Egor 98fa8eef5d Merge pull request #722 from Fr1ngg/revert-721-bedolaga/add-trial-period-management-in-admin-panel-2opyl2
Revert "Add trial and tariff configuration controls to admin pricing panel and refine UX"
2025-10-04 06:07:18 +03:00
Egor c7b47eef4d Revert "Add trial and tariff configuration controls to admin pricing panel and refine UX" 2025-10-04 06:07:10 +03:00
Egor 9270bb3bf5 Merge pull request #721 from Fr1ngg/bedolaga/add-trial-period-management-in-admin-panel-2opyl2
Add trial and tariff configuration controls to admin pricing panel and refine UX
2025-10-04 06:04:55 +03:00
Egor ad2f6f6f22 Improve pricing admin UX 2025-10-04 06:04:33 +03:00
Egor 62749f9ffe Merge pull request #720 from Fr1ngg/revert-719-bedolaga/add-trial-period-management-in-admin-panel-zipxk2
Revert "Add trial and tariff settings to admin pricing"
2025-10-04 05:58:04 +03:00
Egor e623f9df3a Revert "Add trial and tariff settings to admin pricing" 2025-10-04 05:57:49 +03:00
Egor 2b79de7d82 Merge pull request #719 from Fr1ngg/bedolaga/add-trial-period-management-in-admin-panel-zipxk2
Add trial and tariff settings to admin pricing
2025-10-04 05:56:26 +03:00
Egor cdcc3b4c4a Add trial and tariff settings to admin pricing 2025-10-04 05:56:07 +03:00
Egor e757569628 Merge pull request #718 from Fr1ngg/revert-717-bedolaga/add-trial-period-management-in-admin-panel
Revert "Add trial and tariff configuration controls to admin pricing panel"
2025-10-04 05:55:34 +03:00
Egor 4f88cdf7db Revert "Add trial and tariff configuration controls to admin pricing panel" 2025-10-04 05:55:26 +03:00
Egor 406c13aea2 Merge pull request #717 from Fr1ngg/bedolaga/add-trial-period-management-in-admin-panel
Add trial and tariff configuration controls to admin pricing panel
2025-10-04 05:51:31 +03:00
Egor c95b1e4fad feat(admin): extend pricing panel settings 2025-10-04 05:51:12 +03:00
Egor 43c480fa33 Merge pull request #716 from Fr1ngg/revert-713-revert-712-bedolaga/reorganize-admin-panel-menu-mzg1j9
Revert "Revert "Refactor admin menu for servers and pricing management""
2025-10-04 05:37:11 +03:00
Egor cb25bbb2ed Revert "Revert "Refactor admin menu for servers and pricing management"" 2025-10-04 05:36:59 +03:00
Egor 2602a05fb2 Merge pull request #715 from Fr1ngg/revert-714-bedolaga/reorganize-admin-panel-menu-t36ykc
Revert "Reorganize admin menu and add pricing management"
2025-10-04 05:35:24 +03:00
Egor c31da6c2ae Revert "Reorganize admin menu and add pricing management" 2025-10-04 05:35:06 +03:00
Egor 17d51ab286 Merge pull request #714 from Fr1ngg/bedolaga/reorganize-admin-panel-menu-t36ykc
Reorganize admin menu and add pricing management
2025-10-04 05:33:40 +03:00
Egor e576cb97ec feat: reorganize admin menu with pricing section 2025-10-04 05:33:25 +03:00
Egor 217fed9c9c Merge pull request #713 from Fr1ngg/revert-712-bedolaga/reorganize-admin-panel-menu-mzg1j9
Revert "Refactor admin menu for servers and pricing management"
2025-10-04 05:33:14 +03:00
Egor 030f4efbe1 Revert "Refactor admin menu for servers and pricing management" 2025-10-04 05:33:06 +03:00
Egor 33b3828abc Merge pull request #712 from Fr1ngg/bedolaga/reorganize-admin-panel-menu-mzg1j9
Refactor admin menu for servers and pricing management
2025-10-04 05:30:27 +03:00
Egor f2876fd02c Refactor admin menu for servers and pricing management 2025-10-04 05:30:08 +03:00
Egor ee9ba71f3c Merge pull request #711 from Fr1ngg/revert-710-bedolaga/reorganize-admin-panel-menu
Revert "Reorganize admin panel pricing and server controls"
2025-10-04 05:29:48 +03:00
Egor 6a5d5d960f Revert "Reorganize admin panel pricing and server controls" 2025-10-04 05:29:41 +03:00
Egor 910409cdfb Merge pull request #710 from Fr1ngg/bedolaga/reorganize-admin-panel-menu
Reorganize admin panel pricing and server controls
2025-10-04 05:27:32 +03:00
Egor d78b36b60e Add dedicated pricing management section to admin panel 2025-10-04 05:27:10 +03:00
Egor ca2f83db88 Обновление меню конфигурации бота
Dev2
2025-10-04 04:58:05 +03:00
Egor bf591f55ea Merge pull request #708 from Fr1ngg/bedolaga-hw3yna
feat: enhance admin settings control panel
2025-10-04 04:49:29 +03:00
Egor 99aa09349e feat: enhance admin settings control panel 2025-10-04 04:49:15 +03:00
Egor a391b635ed Merge pull request #707 from Fr1ngg/revert-704-bedolaga-8nay3u
Revert "Complete bot configuration dashboard actions"
2025-10-04 04:48:54 +03:00
Egor 4f1ff34dce Revert "Complete bot configuration dashboard actions" 2025-10-04 04:47:38 +03:00
Egor 9a23a305b5 Merge pull request #706 from Fr1ngg/revert-705-bedolaga/add-alembic-migration-for-system_settings_history-table
Revert "Add migration for system settings history audit table"
2025-10-04 04:47:20 +03:00
Egor 539e7e9066 Revert "Add migration for system settings history audit table" 2025-10-04 04:47:12 +03:00
Egor 6b1173a1b5 Merge pull request #705 from Fr1ngg/bedolaga/add-alembic-migration-for-system_settings_history-table
Add migration for system settings history audit table
2025-10-04 04:39:02 +03:00
Egor 23f8e158ad Add migration for system settings history audit table 2025-10-04 04:38:44 +03:00
Egor 578061be8c Merge pull request #704 from Fr1ngg/bedolaga-8nay3u
Complete bot configuration dashboard actions
2025-10-04 04:24:00 +03:00
Egor 84816b84e7 Complete bot configuration dashboard actions 2025-10-04 04:23:41 +03:00
Egor 0ad2f5bb8a Merge pull request #703 from Fr1ngg/revert-702-bedolaga-1gwn05
Revert "Implement admin bot configuration experience"
2025-10-04 04:23:22 +03:00
Egor e415b4b2ee Revert "Implement admin bot configuration experience" 2025-10-04 04:23:14 +03:00
Egor 7d6ecaaa1b Merge pull request #702 from Fr1ngg/bedolaga-1gwn05
Implement admin bot configuration experience
2025-10-04 04:21:20 +03:00
Egor 588461c53a Implement admin bot configuration experience 2025-10-04 04:21:01 +03:00
Egor 25c23bd653 Merge pull request #701 from Fr1ngg/revert-700-bedolaga-i1q6pf
Revert "feat: enrich bot configuration admin panel"
2025-10-04 04:20:45 +03:00
Egor 1a033de259 Revert "feat: enrich bot configuration admin panel" 2025-10-04 04:20:36 +03:00
Egor 850f8d2b2e Merge pull request #700 from Fr1ngg/bedolaga-i1q6pf
feat: enrich bot configuration admin panel
2025-10-04 04:15:48 +03:00
Egor 39374095eb feat: enrich bot configuration admin panel 2025-10-04 04:15:31 +03:00
Egor f4fe8913fa Merge pull request #699 from Fr1ngg/revert-698-bedolaga/add-subscription-plan-menu-in-admin-panel
Revert "Add admin tariffs menu scaffolding"
2025-10-04 03:48:07 +03:00
Egor 9a5203770b Revert "Add admin tariffs menu scaffolding" 2025-10-04 03:47:57 +03:00
Egor 64b0f5161c Merge pull request #698 from Fr1ngg/bedolaga/add-subscription-plan-menu-in-admin-panel
Add admin tariffs menu scaffolding
2025-10-04 02:40:28 +03:00
Egor 8e12609746 Add admin tariffs menu scaffolding 2025-10-04 02:39:16 +03:00
Egor 703d09c556 Merge pull request #697 from Fr1ngg/dev2
Dev2
2025-10-04 00:41:22 +03:00
Egor 7b238a32b4 Merge pull request #696 from Fr1ngg/main
w
2025-10-04 00:40:34 +03:00
Egor c72fa990fa Merge pull request #694 from Legacyyy777/DEV
Мулен правки 2
2025-10-04 00:39:28 +03:00
Egor f8fc929cbf Merge pull request #695 from Fr1ngg/bedolaga/update-install_bot.sh-for-docker-compose
Add Docker Compose pull update option and display container status
2025-10-04 00:38:58 +03:00
Egor 744aaeb5d6 feat: add docker compose pull update 2025-10-04 00:38:34 +03:00
Legacyyy777 515088ff45 Merge branch 'Fr1ngg:main' into DEV 2025-10-04 02:33:07 +05:00
Legacyyy777 96b64df989 Добавлен return после цикла async for db in get_db().
Добавлена обработка случая, когда не удается получить соединение с БД.
2025-10-04 02:32:04 +05:00
Egor f51c22c67e Merge pull request #693 from Fr1ngg/dev2
Dev2
2025-10-04 00:19:03 +03:00
Egor 010c412268 Merge pull request #682 from Fr1ngg/main
w
2025-10-04 00:14:53 +03:00
Egor 7d204684e1 Update install_bot.sh 2025-10-04 00:11:19 +03:00
Egor 08f47694ee Merge pull request #692 from Fr1ngg/bedolaga/fix-automigration-error-at-bot-startup-mc8p92
Fix PostgreSQL sequence sync query
2025-10-03 23:55:59 +03:00
Egor e61977a368 Fix PostgreSQL sequence sync query 2025-10-03 23:55:43 +03:00
Egor 72ae83e6ef Merge pull request #691 from Fr1ngg/revert-690-bedolaga/fix-automigration-error-at-bot-startup
Revert "Fix PostgreSQL sequence sync for newer versions"
2025-10-03 23:55:19 +03:00
Egor 80aa0c2443 Revert "Fix PostgreSQL sequence sync for newer versions" 2025-10-03 23:55:09 +03:00
Egor 9e0eb3fd4a Merge pull request #690 from Fr1ngg/bedolaga/fix-automigration-error-at-bot-startup
Fix PostgreSQL sequence sync for newer versions
2025-10-03 23:54:29 +03:00
Egor 6fe4bad240 Fix PostgreSQL sequence sync for newer versions 2025-10-03 23:54:11 +03:00
Egor 859089e91d Merge pull request #689 from Fr1ngg/bedolaga/fix-duplicate-key-error-in-user-registration-hr68zi
Handle users sequence desync after backup restore
2025-10-03 23:36:04 +03:00
Egor c4fa25321e Handle users sequence desync after backup restore 2025-10-03 23:35:47 +03:00
Egor 96a6f26b1e Merge pull request #688 from Fr1ngg/bedolaga/fix-attributeerror-for-base_url-w44me1
Handle missing RemnaWave configuration in subscription service
2025-10-03 22:12:59 +03:00
Egor 3f0d125207 Handle missing RemnaWave configuration in subscription service 2025-10-03 22:12:43 +03:00
Egor cb5821fffc Merge pull request #687 from Fr1ngg/revert-686-bedolaga/fix-attributeerror-for-base_url
Revert "Handle missing RemnaWave API configuration gracefully"
2025-10-03 22:12:13 +03:00
Egor f79420a9b0 Revert "Handle missing RemnaWave API configuration gracefully" 2025-10-03 22:11:51 +03:00
Egor a7b4dc985f Merge pull request #686 from Fr1ngg/bedolaga/fix-attributeerror-for-base_url
Handle missing RemnaWave API configuration gracefully
2025-10-03 22:10:50 +03:00
Egor 38562744ab Handle missing RemnaWave API configuration gracefully 2025-10-03 22:09:29 +03:00
Egor 3d413ced59 Merge pull request #685 from Fr1ngg/revert-684-bedolaga/fix-notification-error-for-payment-updates-q9qbo5
Revert "Fix admin top-up notifications failing after webhook"
2025-10-03 22:01:44 +03:00
Egor 0002ce0a9a Revert "Fix admin top-up notifications failing after webhook" 2025-10-03 22:01:35 +03:00
Egor 140d3580e9 Merge pull request #684 from Fr1ngg/bedolaga/fix-notification-error-for-payment-updates-q9qbo5
Fix admin top-up notifications failing after webhook
2025-10-03 21:57:26 +03:00
Egor cf5cb406d0 Fix admin top-up notifications failing after webhook 2025-10-03 21:57:07 +03:00
Egor 8c3f3d56c8 Merge pull request #681 from Legacyyy777/DEV
Правим мулен
2025-10-03 21:43:47 +03:00
Egor d33831dfe4 Delete .github/workflows/deploy.yml 2025-10-03 21:43:30 +03:00
Legacyyy777 b68f1256ca Добавлено логирование заголовков вебхука Mulen Pay для отладки и временно отключена проверка подписи. Обновлены сообщения логирования для улучшения диагностики. 2025-10-03 10:28:02 +05:00
Legacyyy777 3d3b019e60 Merge branch 'Fr1ngg:main' into DEV 2025-10-03 10:12:31 +05:00
Egor f1a801d693 Merge pull request #680 from Fr1ngg/dev2
Dev2
2025-10-03 00:35:27 +03:00
Egor de33e15ae0 Merge pull request #679 from Fr1ngg/bedolaga/fix-duplicate-key-error-in-user-registration
Sync PostgreSQL sequences after restore
2025-10-03 00:29:39 +03:00
Egor abd87c2a48 Sync PostgreSQL sequences after restore 2025-10-03 00:29:11 +03:00
Egor f691e91560 Merge pull request #678 from Fr1ngg/main
w
2025-10-03 00:23:44 +03:00
Egor 7dafb0c29f Update README.md 2025-10-02 23:56:23 +03:00
Egor 5037d92e36 Update index.html 2025-10-02 23:15:53 +03:00
Egor d765e08017 Merge pull request #676 from Fr1ngg/bedolaga/document-manual-reverse-proxy-setup-for-caddy-and-nginx
docs: mount miniapp assets in caddy
2025-10-02 19:57:17 +03:00
Egor 4958613ad9 Update README.md 2025-10-02 19:56:59 +03:00
Egor 489667c847 Update README.md 2025-10-02 19:52:55 +03:00
Egor ccedfad51f docs: mount miniapp assets in caddy 2025-10-02 19:50:46 +03:00
Egor 841ac5cdb3 Update README.md 2025-10-02 19:13:40 +03:00
Egor a8b6202fe9 Merge pull request #675 from Fr1ngg/bedolaga/update-readme-for-install_bot.sh
docs: document installer usage
2025-10-02 19:12:02 +03:00
Egor 4708c67dcc docs: document installer usage 2025-10-02 19:11:34 +03:00
Egor f705b508a1 Merge pull request #674 from Fr1ngg/dev2
Dev2
2025-10-02 17:35:18 +03:00
Egor 6030fb08ec Update install_bot.sh 2025-10-02 17:32:17 +03:00
Egor a5b152e73a Merge pull request #673 from Fr1ngg/bedolaga/remove-nginx-support-and-connect-to-caddy-dzg7sf
Refactor install script for Caddy-only proxy support
2025-10-02 03:49:05 +03:00
Egor 087a57fc11 Refactor installer to focus on Caddy proxy 2025-10-02 03:48:49 +03:00
Egor 95e44f99a2 Merge pull request #672 from Fr1ngg/revert-671-bedolaga/remove-nginx-support-and-connect-to-caddy
Revert "Refine installer Caddy integration"
2025-10-02 03:48:12 +03:00
Egor b8bb2acd64 Revert "Refine installer Caddy integration" 2025-10-02 03:48:03 +03:00
Egor eb3e806e0f Merge pull request #671 from Fr1ngg/bedolaga/remove-nginx-support-and-connect-to-caddy
Refine installer Caddy integration
2025-10-02 03:44:40 +03:00
Egor 33911aca93 Update installer to rely solely on Caddy 2025-10-02 03:44:22 +03:00
Egor 63c6e64cbe Merge pull request #655 from Fr1ngg/bedolaga/add-subscription-link-redirect-for-desktop-289875
Add desktop redirect fallback for Happ subscription links
2025-10-02 03:40:49 +03:00
Egor 358c8aed3b Merge branch 'main' into bedolaga/add-subscription-link-redirect-for-desktop-289875 2025-10-02 03:40:18 +03:00
Egor 4ac994454b Merge pull request #670 from Fr1ngg/bedolaga/fix-bot-startup-validation-errors-bnh3fm
Allow optional RemnaWave settings for initial startup
2025-10-02 03:14:14 +03:00
Egor 5d6f080663 Allow missing RemnaWave settings on first start 2025-10-02 03:13:31 +03:00
Egor 33f0e3b9ba Merge pull request #669 from Fr1ngg/revert-668-bedolaga/fix-bot-startup-validation-errors
Revert "Allow optional settings to initialize without env overrides"
2025-10-02 03:13:01 +03:00
Egor ef809de9a2 Revert "Allow optional settings to initialize without env overrides" 2025-10-02 03:12:50 +03:00
Egor 628c53513c Merge pull request #668 from Fr1ngg/bedolaga/fix-bot-startup-validation-errors
Allow optional settings to initialize without env overrides
2025-10-02 02:10:50 +03:00
Egor 876362cd80 Make optional env settings optional 2025-10-02 02:10:22 +03:00
Egor 1a9efd65a1 Update install_bot.sh 2025-10-02 02:02:21 +03:00
Egor 121918c33a Merge pull request #667 from Fr1ngg/bedolaga/remove-installation-check-in-install_bot.sh-xyoa0f
Improve install bot state persistence and path resolution
2025-10-02 00:45:11 +03:00
Egor b46c3bd142 Improve install state handling in install_bot 2025-10-02 00:44:53 +03:00
Egor 566b563626 Merge pull request #666 from Fr1ngg/revert-665-bedolaga/remove-installation-check-in-install_bot.sh
Revert "Handle missing installation state in install_bot.sh"
2025-10-02 00:44:16 +03:00
Egor 397af1cc86 Revert "Handle missing installation state in install_bot.sh" 2025-10-02 00:44:07 +03:00
Egor bdfa2a3039 Merge pull request #665 from Fr1ngg/bedolaga/remove-installation-check-in-install_bot.sh
Handle missing installation state in install_bot.sh
2025-10-02 00:32:31 +03:00
Egor 38001410b8 Handle missing bot install state 2025-10-02 00:31:54 +03:00
Egor c2693fc46a Merge pull request #663 from Fr1ngg/bedolaga/fix-install_bot.sh-to-append-domains
Preserve existing Caddyfile entries when updating webhooks
2025-10-01 23:31:08 +03:00
Egor 9db84af7a9 Preserve existing Caddyfile entries when updating webhooks 2025-10-01 23:25:16 +03:00
Egor 0172138084 Merge pull request #662 from Fr1ngg/bedolaga/fix-script-not-removing-created-network
Add docker network cleanup option to proxy menu
2025-10-01 23:20:05 +03:00
Egor 0e913ec78b Add docker network cleanup option to proxy menu 2025-10-01 23:11:06 +03:00
Egor 6a5d0181b0 Update install_bot.sh 2025-10-01 23:07:05 +03:00
Egor 7684ed382e Update install_bot.sh 2025-10-01 21:55:46 +03:00
Egor 0d47acf044 Merge pull request #661 from Fr1ngg/bedolaga/create-auto-installer-for-bot
Add interactive installation script for the bot
2025-10-01 21:47:15 +03:00
Egor 2d09b10f36 Add interactive installation and monitoring script 2025-10-01 21:46:58 +03:00
Egor 77c99ad33e Merge pull request #660 from Fr1ngg/revert-659-bedolaga/create-auto-installer-for-bot-monitoring-ucg6rt
Revert "Add automated installer and monitoring script"
2025-10-01 21:33:19 +03:00
Egor a41531944c Revert "Add automated installer and monitoring script" 2025-10-01 21:33:11 +03:00
Egor 9b2d9406d3 Merge pull request #659 from Fr1ngg/bedolaga/create-auto-installer-for-bot-monitoring-ucg6rt
Add automated installer and monitoring script
2025-10-01 21:25:42 +03:00
Egor 1776a5b424 Add automated installer and monitoring script 2025-10-01 21:24:00 +03:00
Egor 419b921487 Merge pull request #658 from Fr1ngg/revert-657-bedolaga/create-auto-installer-for-bot-monitoring
Revert "Add automated installation and monitoring script"
2025-10-01 21:23:20 +03:00
Egor c1f7404912 Revert "Add automated installation and monitoring script" 2025-10-01 21:23:11 +03:00
Egor 1c07f40d43 Merge pull request #657 from Fr1ngg/bedolaga/create-auto-installer-for-bot-monitoring
Add automated installation and monitoring script
2025-10-01 21:16:43 +03:00
Egor d2e6a42eb8 Add automated installer and monitoring script 2025-10-01 21:16:26 +03:00
Egor 5a7960bb14 Merge pull request #656 from Fr1ngg/main
w
2025-10-01 21:03:53 +03:00
Egor 27d48f68b4 Update index.html 2025-10-01 20:27:54 +03:00
Egor 9465c6cba3 Create index.html 2025-10-01 20:25:50 +03:00
Egor 3088be1dde Add desktop redirect fallback for Happ subscription links 2025-10-01 19:46:15 +03:00
Egor 845a8175fa Merge pull request #652 from Fr1ngg/revert-651-bedolaga/d
Revert "Fix desktop handling of happ subscription links"
2025-10-01 19:34:46 +03:00
Egor af27fb570c Revert "Fix desktop handling of happ subscription links" 2025-10-01 19:34:38 +03:00
Egor 181bb137fb Merge pull request #651 from Fr1ngg/bedolaga/d
Fix desktop handling of happ subscription links
2025-10-01 19:29:54 +03:00
Egor 220646c7ac Fix desktop handling of happ subscription links 2025-10-01 19:29:30 +03:00
Egor 4513deb2a1 Add files via upload 2025-10-01 07:11:06 +03:00
Legacyyy777 02a9ac9be5 Merge branch 'Fr1ngg:main' into DEV 2025-09-26 20:39:23 +05:00
Legacyyy777 23f53dc5df Merge branch 'Fr1ngg:main' into DEV 2025-09-25 19:15:06 +05:00
Legacyyy777 b4d857eb88 Merge branch 'Fr1ngg:main' into DEV 2025-09-23 21:40:33 +05:00
Legacyyy777 51ba5b980c Merge branch 'Fr1ngg:main' into DEV 2025-09-21 10:57:47 +05:00
Legacyyy777 9fb6d7ecf6 Merge branch 'Fr1ngg:main' into DEV 2025-09-20 18:13:25 +05:00
Legacyyy777 d297e9b6d1 Add deploy workflow 2025-09-19 22:08:52 +05:00
166 changed files with 64888 additions and 11170 deletions
+14
View File
@@ -136,6 +136,7 @@ TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,
PRICE_PER_DEVICE=5000
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
REFERRAL_PROGRAM_ENABLED=true
REFERRAL_MINIMUM_TOPUP_KOPEKS=10000
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
REFERRAL_INVITER_BONUS_KOPEKS=10000
@@ -147,6 +148,7 @@ REFERRAL_NOTIFICATION_RETRY_ATTEMPTS=3
# ===== АВТОПРОДЛЕНИЕ =====
AUTOPAY_WARNING_DAYS=3,1
DEFAULT_AUTOPAY_ENABLED=true
DEFAULT_AUTOPAY_DAYS_BEFORE=3
MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
@@ -161,6 +163,7 @@ TRIBUTE_ENABLED=false
TRIBUTE_API_KEY=
TRIBUTE_DONATE_LINK=
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
TRIBUTE_WEBHOOK_HOST=0.0.0.0
TRIBUTE_WEBHOOK_PORT=8081
# YooKassa (https://yookassa.ru)
@@ -211,6 +214,7 @@ YOOKASSA_PAYMENT_SUBJECT=service
# Webhook настройки
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
YOOKASSA_WEBHOOK_HOST=0.0.0.0
YOOKASSA_WEBHOOK_PORT=8082
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
@@ -221,6 +225,9 @@ YOOKASSA_MAX_AMOUNT_KOPEKS=1000000
# Быстрый выбор суммы пополнения через YooKassa
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
DISABLE_TOPUP_BUTTONS=false
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
# Эти настройки позволяют изменить описания платежей,
# чтобы избежать блокировок платежных систем
@@ -270,6 +277,10 @@ PAL24_PAYMENT_DESCRIPTION="Пополнение баланса"
PAL24_MIN_AMOUNT_KOPEKS=10000
PAL24_MAX_AMOUNT_KOPEKS=100000000
PAL24_REQUEST_TIMEOUT=30
# Отображать кнопку СБП в PayPalych (true - отображать, false - скрывать)
PAL24_SBP_BUTTON_VISIBLE=true
# Отображать кнопку оплаты картой в PayPalych (true - отображать, false - скрывать)
PAL24_CARD_BUTTON_VISIBLE=true
# ===== ИНТЕРФЕЙС И UX =====
@@ -277,6 +288,9 @@ PAL24_REQUEST_TIMEOUT=30
ENABLE_LOGO_MODE=true
LOGO_FILE=vpn_logo.png
# Режим главного меню (default - классический режим работы бота, text - режим работы с активным ЛК MiniApp, отключает покупку/управление подпиской в меню, заменяет все кнопками открытия в MiniApp ЛК)
MAIN_MENU_MODE=default
# Скрыть блок с ссылкой подключения в разделе с информацией о подписке
HIDE_SUBSCRIPTION_LINK=false
+3 -3
View File
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.4.2-$(git rev-parse --short HEAD)"
VERSION="v2.5.2-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.4.2-dev-$(git rev-parse --short HEAD)"
VERSION="v2.5.2-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.4.2-pr-$(git rev-parse --short HEAD)"
VERSION="v2.5.2-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+3 -3
View File
@@ -49,13 +49,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.4.2-$(git rev-parse --short HEAD)"
VERSION="v2.5.2-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.4.2-dev-$(git rev-parse --short HEAD)"
VERSION="v2.5.2-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.4.2-pr-$(git rev-parse --short HEAD)"
VERSION="v2.5.2-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+3
View File
@@ -4,6 +4,7 @@
# Исключения: разрешаем только нужные файлы
!.dockerignore
!.env.example
!install_bot.sh
!Dockerfile
!app-config.json
!main.py
@@ -16,6 +17,8 @@
!app/**
!locales/
!locales/**
!tests/
!tests/**
# Дополнительно разрешаем README и лицензию (опционально)
!README.md
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v2.4.2"
ARG VERSION="v2.5.2"
ARG BUILD_DATE
ARG VCS_REF
+511 -216
View File
@@ -35,7 +35,7 @@
### ⚡ **Полная автоматизация VPN бизнеса**
- 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + ЮKassa + MulenPay + PayPalych + P2P
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + YooKassa + MulenPay + PayPalych (СБП)
- 🔄 **Автоматизация 99%** - от регистрации до продления подписок
- 📊 **Детальная аналитика** - полная картина вашего бизнеса
- 💬 **Уведомления в топики** об: Активация триала 💎 Покупка подписки 🔄 Конверсия из триала в платную ⏰ Продление подписки 💰 Пополнение баланса 🚧 Включении тех работ ♻️ Появлении новой версии бота
@@ -71,7 +71,53 @@ eGames Cookies | Cookies в формате key:value | Для панелей eGa
## 🚀 Быстрый старт
### 🐳 Docker запуск
### 🧙‍♂️ Автоустановка через `install_bot.sh`
Скрипт-установщик берёт на себя подготовку окружения, настройку конфигурации и дальнейшее обслуживание бота. Он работает поверх Docker Compose и требует заранее установить:
- **Docker Engine** и **Docker Compose plugin** (2.20+);
- **Git** и **Bash** (по умолчанию есть в большинстве Linux дистрибутивов);
- `openssl` (используется для генерации токенов, но не обязателен — при отсутствии скрипт использует `urandom`).
```bash
# 1. Скачай репозиторий
git clone https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot.git
cd remnawave-bedolaga-telegram-bot
# 2. Создай необходимые директории
mkdir -p ./logs ./data ./data/backups ./data/referral_qr
chmod -R 755 ./logs ./data
sudo chown -R 1000:1000 ./logs ./data
# 3. Запусти мастер установки
chmod +x install_bot.sh # один раз, если файл не исполняемый
./install_bot.sh
```
На первом запуске мастер:
1. Спросит путь установки и сохранит его в `./.bot_install_state` — можно оставлять путь по умолчанию (текущая директория).
2. Поможет собрать `.env`: запросит обязательные токены (бот, Remnawave, админы), при необходимости сгенерирует Web API и PostgreSQL пароли, предложит авторизацию Basic Auth или eGames secret.
3. Подготовит структуру каталогов (`logs`, `data`, `backups` и т. д.) и проверит, что Docker готов к запуску.
4. Создаст (или обновит) `docker-compose.yml`, настроит внешнюю сеть `bot_network`, чтобы дальнейшие сервисы (например, Caddy) могли подключаться.
5. Запустит контейнеры бота, PostgreSQL и Redis и выведет их статус.
После установки повторный запуск `./install_bot.sh` открывает **интерактивное меню управления**:
- 📊 Мониторинг состояния контейнеров и ресурсов.
- ⚙️ Управление сервисами (запуск/остановка/пересборка).
- 📋 Просмотр и поиск по логам.
- 🔄 Обновление проекта из Git с автоматическим бэкапом.
- 💾 Создание и 📦 восстановление резервных копий (включая базу данных).
- 🧹 Очистка логов, бэкапов и образов.
- 🌐 Помощник настройки обратного прокси Caddy (webhook + miniapp, обновление `docker-compose`, перезагрузка).
- ⚙️ Конфигуратор `.env` (редактирование, пересоздание, маскировка секретов).
> 💡 Скрипт можно запускать сколько угодно раз — он хранит путь установки и понимает, когда конфигурация уже создана. Меню работает и по SSH (достаточно TTY), а для скриптов можно передать путь установки через stdin.
### 🐳 Ручной Docker запуск
Если не хочется пользоваться мастером, можно настроить всё вручную:
```bash
# 1. Скачай репозиторий
@@ -91,11 +137,269 @@ sudo chown -R 1000:1000 ./logs ./data
docker compose up -d
# 5. Проверь статус
docker compose logs
docker compose logs
```
---
## 🌐 Настройка обратного прокси и доменов
> Этот раздел описывает полноценную ручную настройку обратного прокси для **двух разных доменов**: отдельный домен для вебхуков
> (`hooks.example.com`) и отдельный домен для мини-приложения (`miniapp.example.com`). Оба прокси-сервера (Caddy или nginx) дол
> жны работать в одной Docker-сети с ботом, чтобы обращаться к сервису по внутреннему имени `remnawave_bot` без проброса порто
> в наружу.
### 1. Планирование доменов и переменных окружения
1. Добавьте в DNS по **A/AAAA-записи** для обоих доменов на IP сервера, где запущен бот.
2. Убедитесь, что входящий трафик на **80/tcp и 443/tcp** открыт (брандмауэр, облачный фаервол).
3. В `.env` пропишите корректные URL, чтобы бот формировал ссылки с HTTPS-доменами:
```env
WEBHOOK_URL=https://hooks.example.com
WEB_API_ENABLED=true
WEB_API_ALLOWED_ORIGINS=https://miniapp.example.com
MINIAPP_CUSTOM_URL=https://miniapp.example.com
```
При необходимости добавьте пути для дополнительных провайдеров (`TRIBUTE_WEBHOOK_PATH`, `YOOKASSA_WEBHOOK_PATH` и т. д.) —
далее в конфигурациях мы их пробрасываем на соответствующие порты контейнера бота.
### 2. Общая Docker-сеть для бота и прокси
`docker-compose.yml` бота создаёт сеть `bot_network`. Чтобы внешний прокси видел сервис `remnawave_bot`, нужно:
```bash
# Если бот уже запущен через docker compose up -d
docker compose ps
# Убедиться, что сеть существует
docker network ls | grep bot_network || docker network create bot_network
# Подключить прокси (если контейнер уже запущен отдельно)
docker network connect bot_network <proxy_container_name>
```
Если прокси запускается через **собственный docker-compose**, в файле нужно объявить ту же сеть как внешнюю:
```yaml
networks:
bot_network:
external: true
```
Именно так поступает мастер `install_bot.sh`: при первичной установке он создаёт `bot_network` и при повторном запуске в меню №
8 («🌐 Настройка обратного прокси (Caddy)») может автоматически подготовить `docker-compose` и `Caddyfile`, обновить сеть и пер
езапустить сервис.
### 3. Ручная установка Caddy в Docker
1. Создайте каталог для конфигурации:
```bash
mkdir -p ~/caddy
cd ~/caddy
```
2. Сохраните docker-compose-файл `docker-compose.caddy.yml`:
```yaml
services:
caddy:
image: caddy:2-alpine
container_name: remnawave_caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
# Укажите путь к каталогу miniapp из репозитория бота
- /root/remnawave-bedolaga-telegram-bot/miniapp:/miniapp:ro
- /root/remnawave-bedolaga-telegram-bot/miniapp/redirect:/miniapp/redirect:ro
networks:
- bot_network
volumes:
caddy_data:
caddy_config:
networks:
bot_network:
external: true
```
3. Создайте `Caddyfile` с двумя виртуальными хостами:
```caddy
webhook.domain.com {
handle /tribute-webhook* {
reverse_proxy localhost:8081
}
handle /cryptobot-webhook* {
reverse_proxy localhost:8081
}
handle /mulenpay-webhook* {
reverse_proxy localhost:8081
}
handle /pal24-webhook* {
reverse_proxy localhost:8084
}
handle /yookassa-webhook* {
reverse_proxy localhost:8082
}
handle /health {
reverse_proxy localhost:8081/health
}
}
miniapp.domain.com {
encode gzip zstd
root * /var/www/remnawave-miniapp
file_server
@config path /app-config.json
header @config Access-Control-Allow-Origin "*"
# Redirect for /miniapp/redirect/index.html
@redirect path /miniapp/redirect/index.html
redir @redirect /miniapp/redirect/index.html permanent
reverse_proxy /miniapp/* 127.0.0.1:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
}
}
```
- Каталог `miniapp` из репозитория должен быть смонтирован в контейнер Caddy по путям `/miniapp` и `/miniapp/redirect`,
чтобы статические файлы мини-приложения и страницы редиректа отдавались без дополнительной сборки. Замените
`/root/remnawave-bedolaga-telegram-bot` в примере на реальный путь до установленного репозитория.
- Caddy автоматически выпустит TLS-сертификаты через ACME, если DNS настроен правильно.
4. Запустите прокси и убедитесь, что он присоединился к сети бота:
```bash
docker compose -f docker-compose.caddy.yml up -d
docker network inspect bot_network | grep remnawave_caddy
```
5. Проверьте журналы Caddy и бота, выполните тестовый запрос `curl https://hooks.example.com/webhook` (должен отвечать 405/200
в зависимости от webhook).
### 4. Ручная настройка nginx в Docker
1. Создайте каталог `/opt/nginx-remnawave` и поместите туда `docker-compose.nginx.yml`:
```yaml
services:
nginx:
image: nginx:1.25-alpine
container_name: remnawave_nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/ssl/private:ro
- ./miniapp:/var/www/remnawave-miniapp:ro
networks:
- bot_network
networks:
bot_network:
external: true
```
Сертификаты можно выпускать вручную (certbot) и складывать в `./certs` либо использовать внешний контейнер `certbot`.
2. Пример `nginx.conf` с двумя серверными блоками:
```nginx
events {}
http {
include /etc/nginx/mime.types;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
upstream remnawave_bot_hooks {
server remnawave_bot:8081;
}
upstream remnawave_bot_yookassa {
server remnawave_bot:8082;
}
upstream remnawave_bot_api {
server remnawave_bot:8080;
}
server {
listen 80;
listen 443 ssl http2;
server_name hooks.example.com;
ssl_certificate /etc/ssl/private/hooks.fullchain.pem;
ssl_certificate_key /etc/ssl/private/hooks.privkey.pem;
location = /webhook { proxy_pass http://remnawave_bot_hooks; }
location /tribute-webhook { proxy_pass http://remnawave_bot_hooks; }
location /cryptobot-webhook { proxy_pass http://remnawave_bot_hooks; }
location /mulenpay-webhook { proxy_pass http://remnawave_bot_hooks; }
location /pal24-webhook { proxy_pass http://remnawave_bot:8084; }
location /yookassa-webhook { proxy_pass http://remnawave_bot_yookassa; }
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
server {
listen 80;
listen 443 ssl http2;
server_name miniapp.example.com;
ssl_certificate /etc/ssl/private/miniapp.fullchain.pem;
ssl_certificate_key /etc/ssl/private/miniapp.privkey.pem;
root /var/www/remnawave-miniapp;
index index.html;
location = /app-config.json {
add_header Access-Control-Allow-Origin "*";
try_files $uri =404;
}
location / {
try_files $uri /index.html =404;
}
location /miniapp/ {
proxy_pass http://remnawave_bot_api/miniapp/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-API-Key "КЛЮЧ-WEBAPI";
}
}
}
```
3. Запустите прокси и убедитесь, что он в сети `bot_network`:
```bash
docker compose -f docker-compose.nginx.yml up -d
docker network inspect bot_network | grep remnawave_nginx
```
4. После запуска перепроверьте, что `curl -I https://hooks.example.com/webhook` возвращает HTTP-ответ 405/200, а `curl -H "X-AP
I-Key: ..." https://miniapp.example.com/miniapp/health` отвечает 200.
### 5. Чек-лист проверки
- [ ] DNS-записи указывают на сервер, сертификаты валидны.
- [ ] Контейнер прокси присоединён к `bot_network` и видит сервис `remnawave_bot`.
- [ ] `WEBHOOK_URL` и `MINIAPP_CUSTOM_URL` в `.env` указывают на HTTPS-домены.
- [ ] В логах бота нет ошибок `connection refused` или `invalid host header` при обращении из прокси.
- [ ] Триггеры оплаты (Tribute, YooKassa и др.) успешно получают HTTP 200 при тестовых запросах на свои вебхуки.
---
## ⚙️ Конфигурация
### 🔧 Основные параметры
@@ -392,6 +696,7 @@ REFERRAL_NOTIFICATION_RETRY_ATTEMPTS=3
# ===== АВТОПРОДЛЕНИЕ =====
AUTOPAY_WARNING_DAYS=3,1
DEFAULT_AUTOPAY_ENABLED=true
DEFAULT_AUTOPAY_DAYS_BEFORE=3
MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
@@ -406,6 +711,7 @@ TRIBUTE_ENABLED=false
TRIBUTE_API_KEY=
TRIBUTE_DONATE_LINK=
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
TRIBUTE_WEBHOOK_HOST=0.0.0.0
TRIBUTE_WEBHOOK_PORT=8081
# YooKassa (https://yookassa.ru)
@@ -456,6 +762,7 @@ YOOKASSA_PAYMENT_SUBJECT=service
# Webhook настройки
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
YOOKASSA_WEBHOOK_HOST=0.0.0.0
YOOKASSA_WEBHOOK_PORT=8082
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
@@ -522,6 +829,9 @@ PAL24_REQUEST_TIMEOUT=30
ENABLE_LOGO_MODE=true
LOGO_FILE=vpn_logo.png
# Режим главного меню (default - классический режим работы бота, text - режим работы с активным ЛК MiniApp, отключает покупку/управление подпиской в меню, заменяет все кнопками открытия в MiniApp ЛК)
MAIN_MENU_MODE=default
# Скрыть блок с ссылкой подключения в разделе с информацией о подписке
HIDE_SUBSCRIPTION_LINK=false
@@ -645,105 +955,111 @@ WEBHOOK_PATH=/webhook
### 👤 **Для пользователей**
🧭 **Онбординг и доступ**
- 🌐 Выбор языка интерфейса, динамическая локализация и настройка кнопки меню.
- 📜 Принятие правил, оферты и политики конфиденциальности прямо в боте.
- 📡 Проверка подписки на обязательный канал, deeplink-инвайты и UTM-кампании.
- 👤 Сбор контактных данных с антиспам-защитой и быстрым восстановлением доступа.
🛒 **Умная покупка подписок**
- 📅 Гибкие периоды (14-360 дней)
- 📊 Выбор трафика или фиксированный лимит
- 🌍 Автоматический выбор серверов (для одного сквада) или ручной выбор из множества
- 📱 Настройка количества устройств и серверов прямо при оформлении
- 🧾 Понятный калькулятор стоимости с учетом всех опций
- 📅 Гибкие периоды (14360 дней) и скидки за длительные подписки.
- 📊 Выбор трафика: фиксированный лимит, квоты или безлимит.
- 🌍 Автоматический выбор сквада или мультивыбор из десятков серверов.
- 📱 Настройка количества устройств, стран и доп. услуг прямо в чек-ауте.
- 🧾 Динамический калькулятор стоимости, черновики и возвращение к незавершённым заказам.
🧪 **Тестовая подписка**
- Настраиваемый триал-период
- Уведомления об истечении и сценарий мягкой конверсии в платную подписку
- Приветственное сообщение с предложением активации триала (с возможностью отключения)
- Поддержка рекламных кампаний с автоматической выдачей бонусных подписок
- Гибко настраиваемый триал и welcome-цепочка.
- Уведомления об истечении и автоконверсия в платный тариф.
- Автовыдача бонусов за участие в кампаниях и инвайт-линках.
- Контроль за обязательной подпиской на канал даже в режиме триала.
💰 **Удобные платежи**
- ⭐ Telegram Stars
- 💳 Tribute
- 💳 YooKassa (включая СБП и онлайн-чек)
- 💳 MulenPay
- 💳 PayPalych (Pal24)
- 💰 CryptoBot (мультивалюта и срок жизни инвойсов)
- 🎁 Реферальные и промо-бонусы
- Детальная история транзакций и чеков
💰 **Платежи и баланс**
- ⭐ Telegram Stars, 💳 Tribute, 💳 YooKassa (СБП + онлайн-чек), 💳 MulenPay, 🏦 PayPalych (СБП), 💰 CryptoBot.
- 📥 Автогенерация счетов, контроль статусов через вебхуки и уведомления.
- 💼 История операций с фильтрами, квитанциями и выгрузкой чеков.
- 🔄 Автоплатёж с настройкой дня списания и уведомлениями об ошибках.
- 🎁 Реферальные, промо- и кэшбэк-бонусы автоматически начисляются на баланс.
📱 **Управление подписками**
- 📈 Статистика использования в реальном времени (трафик, устройства, серверы)
- 🔄 Автопродление с баланса с настройкой дней и мгновенными уведомлениями
- 🔄 Управление трафиком и лимитами без участия оператора
- 🌍 Переключение серверов Remnawave на лету
- 📱 Управление устройствами + сброс HWID
- 🧩 Смена языка и персональных настроек
- 📈 Реальный трафик, устройства и география серверов прямо в диалоге.
- 🌍 Переключение серверов, стран и лимитов без помощи оператора.
- 📱 Сброс HWID, повторная выдача конфигов и инструкции подключения.
- 🧩 Смена языка, промогруппы и персональных параметров.
- 🧾 Просмотр активных услуг, статуса оплаты и сроков действия.
🎁 **Бонусная система**
- 🎫 Промокоды на деньги/дни/длинный триал
- 👥 Защищенная реферальная программа с комиссиями и антифродом
- 📊 Детальная статистика рефералов и кампаний
- 📨 Персональные рассылки и уведомления о новых бонусах
🛟 **Поддержка и самообслуживание**
- 🎫 Тикеты с приоритетами, вложениями и уведомлениями об ответе.
- 📚 FAQ, правила, оферта и политика доступны из меню.
- 💬 Быстрые ссылки на живую поддержку или кастомные каналы связи.
🧩 **Бонусы и рефералы**
- 🎫 Промокоды на деньги, дни, устройства, страны и расширенные триалы.
- 👥 Реферальная программа с комиссиями, бонусами и антифрод-фильтрами.
- 🧾 Подробная аналитика доходов, конверсии и активности приглашённых.
- 🔗 Генерация реферальных ссылок и QR-кодов в один тап.
🪪 **Mini App и инструкции**
- 📱 Telegram WebApp с обзором подписки, конфигами и кнопкой «Подключить».
- 📥 Библиотека загрузочных ссылок для клиентов Happ и кастомных гайдов.
- 🛰️ Web API для получения статуса подписки в мини-приложениях и внешних витринах.
</td>
<td width="50%" valign="top">
### ⚙️ **Для администраторов**
📊 **Мощная аналитика**
- 👥 Детальная статистика пользователей и подписок
- 💰 Анализ платежей по источникам (Stars, YooKassa, Tribute, MulenPay, PayPalych, CryptoBot)
- 🖥️ Мониторинг серверов Remnawave и статуса сквадов
- 📈 Финансовые отчеты, конверсии и эффективность рекламных кампаний
📊 **Аналитика и отчётность**
- 📈 Дашборды по пользователям, подпискам, трафику и удержанию.
- 💰 Детализация платежей по источникам (Stars, YooKassa, Tribute, MulenPay, PayPalych, CryptoBot).
- 🧮 Продажи по тарифам, устройствам, странам и промогруппам.
- 📣 Эффективность кампаний, промокодов, UTM-источников и реферальных потоков.
👥 **Управление пользователями**
- 🔍 Поиск и редактирование профилей
- 💰 Управление балансами и ручные начисления
- 📱 Изменение лимита устройств (1-X)
- 📊 Настройка лимитов трафика (0-10000 ГБ) и подключенных сквадов
- 🌍 Мультивыбор серверов и мгновенная синхронизация с панелью
- 🔄 Сброс HWID устройств и обновление конфигураций
- 👥 Детальная реферальная статистика в профиле пользователя
- 🔄 Изменение типа подписки триал/платная и ручное продление
- 🚫 Блокировка/разблокировка/удаление аккаунтов
- 🔍 Поиск, фильтры и детальные карточки с историей операций.
- 💰 Ручное изменение баланса, компенсации и корректировки.
- 📱 Изменение лимитов устройств, трафика, стран и подключенных сквадов.
- 🔄 Сброс HWID, перегенерация подписки и синхронизация с Remnawave.
- 🎯 Назначение промогрупп, тарифов и ручных продлений/обновлений.
- 🚫 Блокировки с таймером, полное удаление и аудит действий персонала.
🎯 **Маркетинг и продажи**
- 🎫 Гибкая промо-система (деньги/дни/длинный триал)
- 📣 Рекламные кампании с автоматическими бонусами за регистрацию
- 📊 Детальная статистика использования промокодов и кампаний
- 📨 Рассылки по сегментам с фильтрами по активности, подпискам и языкам
🎯 **Продажи, маркетинг и удержание**
- 🎫 Промокоды (разовые/многоразовые), бонусные предложения и бесплатные пакеты.
- 💳 Промогруппы со скидками на серверы, трафик, устройства и допуслуги.
- 📣 Кампании с deeplink-ссылками, UTM и автопродвижением триалов.
- 📨 Рассылки по сегментам, кастомные кнопки и медиа, отмена «в полёте».
- 🔘 Настройка кнопок главного меню, приветственных экранов и мини-приложений.
🖥️ **Умный мониторинг**
- 💚 Контроль доступности Remnawave панели в реальном времени
- 🔄 Автоматическая синхронизация данных и реконнект при ошибках
- 🌐 Управление сквадами с актуальным статусом и массовыми действиями
- 🚧 Автоматический режим тех. работ с уведомлениями пользователей
- 📋 Логи и диагностика, включая журнал сбоев и действий бота
- 📦 Проверка обновлений проекта и уведомления о новых релизах
🛟 **Поддержка и модерация**
- 🎫 Центр тикетов с приоритетами, статусами и перепиской.
- 🧑‍⚖️ Разграничение ролей (админы, модераторы), журнал аудита действий.
- ⏱️ SLA таймеры, автоматические напоминания и уведомления в канал.
- 🚫 Массовые блокировки нарушителей и автоматическое снятие блокировок.
- 🧾 История диалогов, прикреплённые файлы и быстрые ответы.
📨 **Коммуникации и поддержка**
- 📢 Рассылки по сегментам и мгновенные оповещения
- 🔔 Автоуведомления о продлении, задолженностях и автоплатежах
- 💬 Система поддержки с HTML разметкой и история обращений
- 📝 Настройка правил сервиса, FAQ и приветственных экранов
- 🧪 Настраиваемое приветственное сообщение с предложением активации триала
🔔 **Уведомления и коммуникации**
- 📢 Топики для событий (покупки, триалы, техработы, ошибки интеграций).
- 🔔 Настройка пользовательских и админских уведомлений, расписаний и каналов.
- 📨 Шаблоны правил, оферты, FAQ, приветствий и страницы поддержки.
- 💬 Автоматические сообщения о задолженностях, автоплатежах и продлениях.
📨 **Уведомления в закрытый канал**
- 🎯 Активация триала, продления и конверсии в платную подписку
- 💎 Покупка подписки и пополнение баланса
- 🔄 Изменения параметров подписки пользователем
- 🚧 Потеря соединения с Remnawave и включение техработ
- 🗄️ Авто- и ручные бекапы/восстановления с отправкой файлов
- ♻️ Выхлоп новых версий бота и результаты автоплатежей
🧰 **Обслуживание и DevOps**
- 🛠️ `install_bot.sh`: управление контейнерами, логами, обновлениями, прокси и резервными копиями.
- 🚧 Ручной и авто-режим техработ с кастомными сообщениями.
- 🗒️ Просмотр системных логов, health-check, мониторинг Remnawave и серверов.
- ♻️ Проверка обновлений репозитория, уведомления о новых версиях и плавный деплой.
🗄️ **Бекапы и восстановление**
- Ручной запуск бекапа с выбором содержимого (включая логи)
- Автоматические бекапы по расписанию и управление retention-политикой
- Восстановление из бекапа без остановки сервиса
- Отправка копии бекапа в выделенный чат/топик и ведение метаданных
- 🗓️ Планировщик авто-бекапов, гибкая политика хранения и компрессия.
- 📦 Ручные бекапы с выбором содержимого (БД, конфиги, логи).
- 📤 Отправка архивов в выделенный чат/топик, журнал попыток и статусов.
- 🔁 Восстановление из бекапа без остановки бота, проверка целостности.
💳 **Автоплатежи и биллинг**
- Настройки автоплатежа на уровне пользователя (вкл/выкл, дни до списания)
- Принудительная проверка готовности автосписаний
- Уведомления об успешных и неудачных автоплатежах с подсказками действий
- Настройка минимального баланса и логирование всех списаний
💳 **Биллинг и автоплатежи**
- ⚙️ Глобальные настройки автосписаний, минимального баланса и напоминаний.
- 🧪 Тестовые платежи для каждого провайдера прямо из админки.
- 🪝 Управление вебхуками Tribute, YooKassa, CryptoBot, MulenPay и PayPalych.
- 🧾 Экспорт транзакций, сверка с платёжками и интеграция с бухгалтерией.
</td>
</tr>
@@ -758,6 +1074,16 @@ WEBHOOK_PATH=/webhook
- 📦 **Интеграция с Remnawave API** — автоматическое создание пользователей, управление сквадами и синхронизация подписок.
- 🧾 **История операций** — хранение транзакций, уведомлений, рассылок, кампаний и бекапов для аудита.
### 🌐 Веб-API, мини-приложение и интеграции
- ⚙️ **FastAPI Web API** (`app/webapi`) с эндпоинтами для управления токенами, конфигурациями, рассылками, кампаниями, промогруппами, статистикой, подписками, транзакциями, тикетами и бекапами.
- 🔑 **Управление API-ключами** — выпуск, отзыв, реактивация и аудит токенов прямо из админки.
- 🛡️ **Гибкая авторизация** — API-key, Bearer, Basic Auth и cookie eGames для совместимости с внешними панелями.
- 🛰️ **Mini App** — статический фронтенд в `miniapp/` и proxied эндпоинты `/miniapp/*` для отображения статуса подписки внутри Telegram.
- 🧭 **App Config** — централизованная раздача ссылок на клиенты и инструкции (`app-config.json`, `miniapp/app-config.json`).
- 🪝 **Платёжные вебхуки** — встроенные Flask-серверы для Tribute, YooKassa, CryptoBot, MulenPay и PayPalych (Pal24) с health-check и логированием.
- 📡 **Мониторинг серверов** — REST-эндпоинты для просмотра нод Remnawave, статистики нагрузки и управления сквадами из внешних систем.
### 🧭 Навигация по проекту
- `app/bot.py` — точка входа бота и регистрация middlewares/handlers.
@@ -1043,121 +1369,6 @@ ADMIN_NOTIFICATIONS_TOPIC_ID=123 # ID топика (опционал
- **🔗 SQLAlchemy ORM** - безопасная работа с БД
- **🚀 aiogram 3** - современная Telegram Bot API
### 📁 Структура проекта
```
bedolaga_bot/
├── 🎯 main.py # Точка входа
├── 📦 requirements.txt # Зависимости
├── ⚙️ .env.example # Конфиг
├── ⚙️ app-config.json # Информация для гайда подключения
├── 📱 app/
│ ├── 🤖 bot.py # Инициализация бота
│ ├── ⚙️ config.py # Настройки
│ ├── 🎛️ states.py # FSM состояния
│ │
│ ├── 🎮 handlers/ # Обработчики событий
│ │ ├── 🏠 start.py # Регистрация и старт
│ │ ├── 🛒 subscription.py # Подписки
│ │ ├── 💰 balance.py # Баланс и платежи
│ │ ├── 🎁 promocode.py # Промокоды
│ │ ├── 👥 referral.py # Реферальная система
│ │ ├── 🌟 stars_payment.py # Start платежи
│ │ ├── common.py
│ │ ├── 💬 support.py # Техподдержка
│ │ └── 👑 admin/ # Админ панель
│ │ ├── 📊 statistics.py # Статистика
│ │ ├── 🗄️ backup.py # Бекапы
│ │ ├── 👥 users.py # Управление юзерами
│ │ ├── 🎫 promocodes.py # Управление промокодами
│ │ ├── 🚧 maintenance.py # Тех работы
│ │ ├── 📨 messages.py # Рассылки
│ │ ├── 📨 user_messages.py # Рандомные сообщения в меню
│ │ ├── 📨 welcome_text.py # Приветственное сообщение
│ │ ├── ⚙️ main.py # Админское меню
│ │ ├── 📖 rules.py # Правила
│ │ ├── 🙋 referrals.py # Правила
│ │ ├── 🌎 servers.py # Сервера
│ │ ├── 📱 subscriptions.py # Подписки
│ │ ├── 🔍 monitoring.py # Мониторинг
│ │ └── 🔗 remnawave.py # Система Remnawave
│ │
│ ├── 🗄️ database/ # База данных
│ │ ├── 📊 models.py # Модели SQLAlchemy
│ │ ├── 🔗 database.py # Подключение к БД
│ │ ├── 🔄 universal_migration.py # Миграции
│ │ └── 📝 crud/ # CRUD операции
│ │ ├── 👤 user.py # Операции с пользователями
│ │ ├── 📋 subscription.py # Операции с подписками
│ │ ├── 💰 transaction.py # Операции с транзакциями
│ │ ├── 📜 rules.py # Правила сервиса
│ │ ├── 📜 subscription_conversion.py # Правила сервиса
│ │ ├── 💳 yookassa.py # YooKassa операции
│ │ ├── 📨 welcome_text.py # Приветственное сообщение
│ │ ├── 💳 cryptobot.py # CryptoBot операции
│ │ ├── 🌐 server_squad.py # Серверы и сквады
│ │ ├── 🎁 promocode.py # Промокоды
│ │ └── 👥 referral.py # Партнерка
│ │
│ ├── 🔧 services/ # Бизнес-логика
│ │ ├── 👤 user_service.py # Сервис пользователей
│ │ ├── 📋 subscription_service.py # Сервис подписок
│ │ ├── 💰 payment_service.py # Платежи
│ │ ├── 🗄️ backup_service.py # Бекапы
│ │ ├── 🎁 promocode_service.py # Промокоды
│ │ ├── 🚧 maintenance_service.py # Промокоды
│ │ ├── 👥 referral_service.py # Партнерка
│ │ ├── 💬 admin_notification_service.py # Уведомления для администраторов в чаты
│ │ ├── 🔍 monitoring_service.py # Мониторинг
│ │ ├── ♻️ version_service.py # Проверка версий бота
│ │ ├── 🎖️ tribute_service.py # Tribute платежи
│ │ ├── 💳 yookassa_service.py # YooKassa платежи
│ │ └── 🌐 remnawave_service.py # Интеграция с Remnawave
│ │
│ ├── 🛠️ utils/ # Утилиты
│ │ ├── 🎨 decorators.py # Декораторы
│ │ ├── 📝 formatters.py # Форматирование данных
│ │ ├── ✅ validators.py # Валидация
│ │ ├── ✅ subscription_utils.py # Проверка подписок
│ │ ├── 📄 pagination.py # Пагинация
│ │ ├── 📄 pricing_utils.py # Цены
│ │ ├── 📄 message_patch.py
│ │ ├── 📄 photo_message.py
│ │ ├── ‼️ global_error.py # Обработка ошибок
│ │ ├── 👤 user_utils.py # Утилиты для пользователей
│ │ ├── 🫰 currency_converter.py # Курсы для CryptoBota
│ │ └── ⚡ cache.py # Кеширование
│ │
│ ├── 🛡️ middlewares/ # Middleware
│ │ ├── 🔐 auth.py # Авторизация
│ │ ├── 📋 logging.py # Логирование
│ │ ├── 🚧 maintenance.py # тех работы
│ │ ├── 🔐 subscription_checker.py # тех работы
│ │ ├── 🔐 channel_checker.py
│ │ └── ⏱️ throttling.py # Ограничение запросов
│ │
│ ├── 🌐 localization/ # Локализация
│ │ ├── 📝 texts.py # Тексты интерфейса
│ │ └── 🗣️ languages/
│ │
│ ├── ⌨️ keyboards/ # Клавиатуры
│ │ ├── 🔗 inline.py # Inline клавиатуры
│ │ ├── 💬 reply.py # Reply клавиатуры
│ │ └── 👑 admin.py # Админские клавиатуры
│ │
│ └── 🔌 external/ # Внешние API
│ ├── 🌐 remnawave_api.py # Remnawave API
│ ├── ⭐ telegram_stars.py # Telegram Stars
│ ├── 💳 yookassa_webhook.py # YooKassa webhook
│ ├── 🌐 webhook_server.py # Webhook сервер
│ ├── 💳 cryptobot.py # CryptoBot Api
│ └── 🎖️ tribute.py # Tribute платежи
├── 🔄 migrations/ # Миграции БД
└── 📋 logs/ # Логи системы
```
---
## 🐛 Устранение неполадок
@@ -1401,62 +1612,146 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
<tr>
<td>🥇</td>
<td><strong>@SmartSystemCompany</strong></td>
<td>₽8,500</td>
<td>За щедрую поддержку и вклад в развитие</td>
</tr>
<tr>
<td>🥈</td>
<td><strong>@pilot_737800</strong></td>
<td>₽4,750</td>
<td>₽7,750</td>
<td>За веру в проект с самого начала</td>
</tr>
<tr>
<td>🥈</td>
<td><strong>@Legacyyy777</strong></td>
<td>₽2,600</td>
<td>За ценные предложения по улучшению</td>
<td><strong>@psych0O</strong></td>
<td>$60</td>
<td>За щедрую поддержку и вклад в развитие</td>
</tr>
<tr>
<td>🥉</td>
<td><strong>Илья (@ispanec_nn)</strong></td>
<td>$30</td>
<td>За активное тестирование и фидбек</td>
<td><strong>@Vldmrmtn</strong></td>
<td>₽5,000</td>
<td>За значительную поддержку проекта</td>
</tr>
<tr>
<td>4</td>
<td><strong>@k0tbtc</strong></td>
<td>₽3,000</td>
<td>За поддержку и доверие</td>
</tr>
<tr>
<td>5</td>
<td><strong>@Legacyyy777</strong></td>
<td>₽2,900</td>
<td>За ценные предложения по улучшению</td>
</tr>
<tr>
<td>6</td>
<td><strong>@sklvg</strong></td>
<td>₽3,000</td>
<td>За международную поддержку</td>
</tr>
<tr>
<td>7</td>
<td><strong>@bolgov0zero</strong></td>
<td>₽2,000</td>
<td>За поддержку и доверие</td>
</tr>
<tr>
<td>8</td>
<td><strong>@i_burjuy</strong></td>
<td>₽1,300</td>
<td>За активное участие в проекте</td>
</tr>
<tr>
<td>9</td>
<td><strong>@kr33njee</strong></td>
<td>₽1,550</td>
<td>За поддержку и доверие</td>
</tr>
<tr>
<td>10</td>
<td><strong>@Nav1_0</strong></td>
<td>₽2,000</td>
<td>За поддержку и доверие</td>
</tr>
<tr>
<td>11</td>
<td><strong>@vitos_7771997</strong></td>
<td>₽1,500</td>
<td>За поддержку и доверие</td>
</tr>
<tr>
<td>12</td>
<td><strong>@haxonate</strong></td>
<td>₽1,500</td>
<td>За поддержку и доверие</td>
</tr>
<tr>
<td>13</td>
<td><strong>@fso404</strong></td>
<td>₽1,000</td>
<td>За поддержку и доверие</td>
</tr>
<tr>
<td>5</td>
<td>14</td>
<td><strong>@edward_forix</strong></td>
<td>₽1,000</td>
<td>За поддержку и доверие</td>
</tr>
<tr>
<td>5</td>
<td><strong>@Nav1_0</strong></td>
<td>₽1,000</td>
<td>За поддержку и доверие</td>
<td>15</td>
<td><strong>@Илья (@ispanec_nn)</strong></td>
<td>$30</td>
<td>За активное тестирование и фидбек</td>
</tr>
<tr>
<td>6</td>
<td><strong>@kr33njee</strong></td>
<td>₽800</td>
<td>За поддержку и доверие</td>
<td>16</td>
<td><strong>@DonMatteoRU</strong></td>
<td>₽500</td>
<td>За участие в развитии</td>
</tr>
<tr>
<td>7</td>
<td><strong>@SmartSystemCompany</strong></td>
<td>₽600</td>
<td>За поддержку и доверие</td>
<td>17</td>
<td><strong>@pedzeo</strong></td>
<td>₽500</td>
<td>За участие в развитии</td>
</tr>
<tr>
<td>8</td>
<td>18</td>
<td><strong>@suzumuj</strong></td>
<td>₽400</td>
<td>За участие в развитии</td>
</tr>
<tr>
<td>19</td>
<td><strong>@andrey_93rus</strong></td>
<td>₽300</td>
<td>За участие в развитии</td>
</tr>
<tr>
<td>20</td>
<td><strong>@PhiLin58</strong></td>
<td>₽300</td>
<td>За участие в развитии</td>
+486 -61
View File
@@ -1,5 +1,16 @@
{
"config": {},
"config": {
"additionalLocales": [
"ru",
"zh",
"fa"
],
"branding": {
"name": "Subscription",
"logoUrl": "https://raw.githubusercontent.com/Fr1ngg/remnawave-bedolaga-telegram-bot/bf0c1ce711a26fa2f24559e7e4443820e68d758b/assets/bedolaga_app3.svg",
"supportUrl": "https://t.me"
}
},
"platforms": {
"ios": [
{
@@ -12,19 +23,102 @@
{
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
"buttonText": {
"en": "App Store [EU]",
"fa": "App Store [EU]",
"ru": "App Store [EU]",
"zh": "App Store [EU]"
"en": "Open in App Store [EU]",
"fa": "باز کردن در App Store [EU]",
"ru": "Открыть в App Store [EU]",
"zh": "App Store 中打开 [EU]"
}
},
{
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
"buttonText": {
"en": "App Store [RU]",
"fa": "App Store [RU]",
"ru": "App Store [RU]",
"zh": "App Store [RU]"
"en": "Open in App Store [RU]",
"fa": "باز کردن در App Store [RU]",
"ru": "Открыть в App Store [RU]",
"zh": "App Store 中打开 [RU]"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
},
{
"id": "streisand",
"name": "Streisand",
"isFeatured": false,
"urlScheme": "streisand://import/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/ru/app/streisand/id6450534064",
"buttonText": {
"en": "Open in App Store",
"fa": "باز کردن در App Store",
"ru": "Открыть в App Store",
"zh": "在 App Store 中打开"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
},
{
"id": "shadowrocket",
"name": "Shadowrocket",
"isFeatured": false,
"urlScheme": "sub://",
"isNeedBase64Encoding": true,
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/ru/app/shadowrocket/id932747118",
"buttonText": {
"en": "Open in App Store",
"fa": "باز کردن در App Store",
"ru": "Открыть в App Store",
"zh": "在 App Store 中打开"
}
}
],
@@ -64,10 +158,10 @@
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
"buttonText": {
"en": "Google Play",
"fa": "Google Play",
"ru": "Google Play",
"zh": "Google Play"
"en": "Open in Google Play",
"fa": "باز کردن در Google Play",
"ru": "Открыть в Google Play",
"zh": "Google Play 中打开"
}
},
{
@@ -103,53 +197,210 @@
"zh": "打开应用并连接到服务器"
}
}
}
],
"macos": [
},
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"id": "clash-meta",
"name": "Clash Meta",
"isFeatured": false,
"urlScheme": "clash://install-config?url=",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
"buttonLink": "https://github.com/MetaCubeX/ClashMetaForAndroid/releases/download/v2.11.7/cmfa-2.11.7-meta-universal-release.apk",
"buttonText": {
"en": "App Store [EU]",
"fa": "App Store [EU]",
"ru": "App Store [EU]",
"zh": "App Store [EU]"
"en": "Download APK",
"fa": "دانلود APK",
"ru": "Скачать APK",
"zh": "下载 APK"
}
},
{
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
"buttonLink": "https://f-droid.org/packages/com.github.metacubex.clash.meta/",
"buttonText": {
"en": "App Store [RU]",
"fa": "App Store [RU]",
"ru": "App Store [RU]",
"zh": "App Store [RU]"
"en": "Open in F-Droid",
"fa": "در F-Droid باز کنید",
"ru": "Открыть в F-Droid",
"zh": "在 F-Droid 中打开"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
"en": "Download and install Clash Meta APK",
"fa": "دانلود و نصب Clash Meta APK",
"ru": "Скачайте и установите Clash Meta APK",
"zh": "下载并安装 Clash Meta APK"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
"en": "Tap the button to import configuration",
"fa": "برای وارد کردن پیکربندی روی دکمه ضربه بزنید",
"ru": "Нажмите кнопку, чтобы импортировать конфигурацию",
"zh": "点击按钮导入配置"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"en": "Open Clash Meta and tap on Connect",
"fa": "Clash Meta را باز کنید و روی اتصال ضربه بزنید",
"ru": "Откройте Clash Meta и нажмите Подключиться",
"zh": "打开 Clash Meta 并点击连接"
}
}
}
],
"macos": [
{
"id": "clash-verge",
"name": "Clash Verge",
"isFeatured": true,
"urlScheme": "clash://install-config?url=",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64-setup.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64.dmg",
"buttonText": {
"en": "macOS (Intel)",
"fa": "مک (اینتل)",
"ru": "macOS (Intel)",
"zh": "macOS (Intel)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_aarch64.dmg",
"buttonText": {
"en": "macOS (Apple Silicon)",
"fa": "مک (Apple Silicon)",
"ru": "macOS (Apple Silicon)",
"zh": "macOS (Apple Silicon)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "Choose the version for your device, click the button below and install the app.",
"fa": "نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
"zh": "选择适合您设备的版本,点击下方按钮并安装应用。"
}
},
"additionalBeforeAddSubscriptionStep": {
"buttons": [],
"description": {
"en": "After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
"fa": "پس از راه‌اندازی برنامه، می‌توانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
"ru": "После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
"zh": "启动应用后,您可以在设置中更改语言。在左侧面板找到齿轮图标,然后导航到 Verge 设置并选择语言设置。"
},
"title": {
"en": "Change language",
"fa": "تغییر زبان",
"ru": "Смена языка",
"zh": "更改语言"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"additionalAfterAddSubscriptionStep": {
"buttons": [],
"title": {
"en": "If the subscription is not added",
"fa": "اگر اشتراک در برنامه نصب نشده است",
"ru": "Если подписка не добавилась",
"zh": "如果订阅未添加"
},
"description": {
"en": "If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
"fa": "اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایل‌ها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
"ru": "Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
"zh": "如果点击按钮后没有反应,请手动添加订阅。点击此页面右上角的获取链接按钮,复制链接。在 Clash Verge 中,转到配置文件部分,将链接粘贴到文本字段中,然后点击导入按钮。"
}
},
"connectAndUseStep": {
"description": {
"en": "You can select a server in the Proxy section, and enable VPN in the Settings section. Set the TUN Mode switch to ON.",
"fa": "می‌توانید در بخش پروکسی سرور را انتخاب کنید و در بخش تنظیمات VPN را فعال کنید. کلید TUN Mode را در حالت روشن قرار دهید.",
"ru": "Выбрать сервер можно в разделе Прокси, включить VPN можно в разделе Настройки. Установите переключатель TUN Mode в положение ВКЛ.",
"zh": "您可以在代理部分选择服务器,在设置部分启用 VPN。将 TUN 模式开关设置为开启。"
}
}
},
{
"id": "hiddify",
"name": "Hiddify",
"isFeatured": false,
"urlScheme": "hiddify://import/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Windows-Setup-x64.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-MacOS.dmg",
"buttonText": {
"en": "macOS",
"fa": "مک",
"ru": "macOS",
"zh": "macOS"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Linux-x64.AppImage",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
"zh": "在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
@@ -159,75 +410,249 @@
],
"windows": [
{
"id": "happ",
"name": "Happ",
"isFeatured": false,
"urlScheme": "happ://add/",
"id": "clash-verge",
"name": "Clash Verge",
"isFeatured": true,
"urlScheme": "clash://install-config?url=",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x86.exe",
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64-setup.exe",
"buttonText": {
"en": "Download",
"ru": "Скачать"
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64.dmg",
"buttonText": {
"en": "macOS (Intel)",
"fa": "مک (اینتل)",
"ru": "macOS (Intel)",
"zh": "macOS (Intel)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_aarch64.dmg",
"buttonText": {
"en": "macOS (Apple Silicon)",
"fa": "مک (Apple Silicon)",
"ru": "macOS (Apple Silicon)",
"zh": "macOS (Apple Silicon)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "Download and install Happ.",
"ru": "Скачайте и установите Happ"
"en": "Choose the version for your device, click the button below and install the app.",
"fa": "نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
"zh": "选择适合您设备的版本,点击下方按钮并安装应用。"
}
},
"additionalBeforeAddSubscriptionStep": {
"buttons": [],
"description": {
"en": "After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
"fa": "پس از راه‌اندازی برنامه، می‌توانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
"ru": "После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
"zh": "启动应用后,您可以在设置中更改语言。在左侧面板找到齿轮图标,然后导航到 Verge 设置并选择语言设置。"
},
"title": {
"en": "Change language",
"fa": "تغییر زبان",
"ru": "Смена языка",
"zh": "更改语言"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"additionalAfterAddSubscriptionStep": {
"buttons": [],
"title": {
"en": "If the subscription is not added",
"fa": "اگر اشتراک در برنامه نصب نشده است",
"ru": "Если подписка не добавилась",
"zh": "如果订阅未添加"
},
"description": {
"en": "If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
"fa": "اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایل‌ها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
"ru": "Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
"zh": "如果点击按钮后没有反应,请手动添加订阅。点击此页面右上角的获取链接按钮,复制链接。在 Clash Verge 中,转到配置文件部分,将链接粘贴到文本字段中,然后点击导入按钮。"
}
},
"connectAndUseStep": {
"description": {
"en": "You can select a server",
"ru": "Выберете локацию, включить VPN"
"en": "You can select a server in the Proxy section, and enable VPN in the Settings section. Set the TUN Mode switch to ON.",
"fa": "می‌توانید در بخش پروکسی سرور را انتخاب کنید و در بخش تنظیمات VPN را فعال کنید. کلید TUN Mode را در حالت روشن قرار دهید.",
"ru": "Выبрать сервер можно в разделе Прокси, включить VPN можно в разделе Настройки. Установите переключатель TUN Mode в положение ВКЛ.",
"zh": "您可以在代理部分选择服务器,在设置部分启用 VPN。将 TUN 模式开关设置为开启。"
}
}
},
{
"id": "hiddify",
"name": "Hiddify",
"isFeatured": false,
"urlScheme": "hiddify://import/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Windows-Setup-x64.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-MacOS.dmg",
"buttonText": {
"en": "macOS",
"fa": "مک",
"ru": "macOS",
"zh": "macOS"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Linux-x64.AppImage",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
"zh": "在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
}
],
"linux": [],
"androidTV": [
{
"id": "vpn4tv",
"name": "VPN4TV",
"id": "new-app-androidtv-1760203310792",
"name": "Happ",
"isFeatured": true,
"urlScheme": "",
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
"buttonText": {
"en": "Google Play",
"ru": "Google Play"
"ru": "Button TextGoogle Play",
"zh": "Button Text",
"fa": "Button Text"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"ru": "Откройте страницу в Google Play и установите приложение"
"ru": "Откройте страницу в Google Play и установите приложение",
"zh": "-",
"fa": "-"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал"
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
"zh": "-",
"fa": "-"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN"
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
"zh": "-",
"fa": "-"
}
}
}
],
"linux": [],
"appleTV": []
"appleTV": [
{
"id": "new-app-appletv-1760203488851",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
"buttonText": {
"en": "Google Play",
"ru": "Google Play",
"zh": "Button Text",
"fa": "Button Text"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"ru": "Откройте страницу в Google Play и установите приложение",
"zh": "-",
"fa": "-"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
"zh": "-",
"fa": "-"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
"zh": "-",
"fa": "-"
}
}
}
]
}
}
}
+17 -2
View File
@@ -10,7 +10,8 @@ from app.middlewares.auth import AuthMiddleware
from app.middlewares.logging import LoggingMiddleware
from app.middlewares.throttling import ThrottlingMiddleware
from app.middlewares.subscription_checker import SubscriptionStatusMiddleware
from app.middlewares.maintenance import MaintenanceMiddleware
from app.middlewares.maintenance import MaintenanceMiddleware
from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware
from app.services.maintenance_service import maintenance_service
from app.utils.cache import cache
@@ -33,6 +34,7 @@ from app.handlers.admin import (
maintenance as admin_maintenance,
promo_groups as admin_promo_groups,
campaigns as admin_campaigns,
promo_offers as admin_promo_offers,
user_messages as admin_user_messages,
updates as admin_updates,
backup as admin_backup,
@@ -41,6 +43,10 @@ from app.handlers.admin import (
tickets as admin_tickets,
reports as admin_reports,
bot_configuration as admin_bot_configuration,
pricing as admin_pricing,
privacy_policy as admin_privacy_policy,
public_offer as admin_public_offer,
faq as admin_faq,
)
from app.handlers.stars_payments import register_stars_handlers
@@ -97,6 +103,10 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
dp.callback_query.middleware(LoggingMiddleware())
dp.message.middleware(MaintenanceMiddleware())
dp.callback_query.middleware(MaintenanceMiddleware())
display_name_middleware = DisplayNameRestrictionMiddleware()
dp.message.middleware(display_name_middleware)
dp.callback_query.middleware(display_name_middleware)
dp.pre_checkout_query.middleware(display_name_middleware)
dp.message.middleware(ThrottlingMiddleware())
dp.callback_query.middleware(ThrottlingMiddleware())
@@ -117,7 +127,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
start.register_handlers(dp)
menu.register_handlers(dp)
subscription.register_handlers(dp)
balance.register_handlers(dp)
balance.register_balance_handlers(dp)
promocode.register_handlers(dp)
referral.register_handlers(dp)
support.register_handlers(dp)
@@ -136,6 +146,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_statistics.register_handlers(dp)
admin_promo_groups.register_handlers(dp)
admin_campaigns.register_handlers(dp)
admin_promo_offers.register_handlers(dp)
admin_maintenance.register_handlers(dp)
admin_user_messages.register_handlers(dp)
admin_updates.register_handlers(dp)
@@ -145,6 +156,10 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_tickets.register_handlers(dp)
admin_reports.register_handlers(dp)
admin_bot_configuration.register_handlers(dp)
admin_pricing.register_handlers(dp)
admin_privacy_policy.register_handlers(dp)
admin_public_offer.register_handlers(dp)
admin_faq.register_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
+267 -19
View File
@@ -1,3 +1,5 @@
import hashlib
import hmac
import logging
import os
import re
@@ -10,9 +12,16 @@ from pydantic import field_validator, Field
from pathlib import Path
DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS = [
"tme",
"joingroup",
]
class Settings(BaseSettings):
BOT_TOKEN: str
BOT_USERNAME: Optional[str] = None
ADMIN_IDS: str = ""
SUPPORT_USERNAME: str = "@support"
SUPPORT_MENU_ENABLED: bool = True
@@ -38,7 +47,7 @@ class Settings(BaseSettings):
CHANNEL_LINK: Optional[str] = None
CHANNEL_IS_REQUIRED_SUB: bool = False
DATABASE_URL: str = ""
DATABASE_URL: Optional[str] = None
POSTGRES_HOST: str = "postgres"
POSTGRES_PORT: int = 5432
@@ -53,8 +62,8 @@ class Settings(BaseSettings):
REDIS_URL: str = "redis://localhost:6379/0"
REMNAWAVE_API_URL: str = ""
REMNAWAVE_API_KEY: str = ""
REMNAWAVE_API_URL: Optional[str] = None
REMNAWAVE_API_KEY: Optional[str] = None
REMNAWAVE_SECRET_KEY: Optional[str] = None
REMNAWAVE_USERNAME: Optional[str] = None
@@ -62,6 +71,8 @@ class Settings(BaseSettings):
REMNAWAVE_AUTH_TYPE: str = "api_key"
REMNAWAVE_USER_DESCRIPTION_TEMPLATE: str = "Bot user: {full_name} {username}"
REMNAWAVE_USER_DELETE_MODE: str = "delete" # "delete" или "disable"
REMNAWAVE_AUTO_SYNC_ENABLED: bool = False
REMNAWAVE_AUTO_SYNC_TIMES: str = "03:00"
TRIAL_DURATION_DAYS: int = 3
TRIAL_TRAFFIC_LIMIT_GB: int = 10
@@ -69,7 +80,7 @@ class Settings(BaseSettings):
TRIAL_ADD_REMAINING_DAYS_TO_PAID: bool = False
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
DEFAULT_DEVICE_LIMIT: int = 1
TRIAL_SQUAD_UUID: str = ""
TRIAL_SQUAD_UUID: Optional[str] = None
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
RESET_TRAFFIC_ON_PAYMENT: bool = False
MAX_DEVICES_LIMIT: int = 20
@@ -125,13 +136,15 @@ class Settings(BaseSettings):
REFERRAL_INVITER_BONUS_KOPEKS: int = 10000
REFERRAL_COMMISSION_PERCENT: int = 25
REFERRAL_PROGRAM_ENABLED: bool = True
REFERRAL_NOTIFICATIONS_ENABLED: bool = True
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS: int = 3
REFERRED_USER_REWARD: int = 0
AUTOPAY_WARNING_DAYS: str = "3,1"
DEFAULT_AUTOPAY_DAYS_BEFORE: int = 3
DEFAULT_AUTOPAY_ENABLED: bool = False
DEFAULT_AUTOPAY_DAYS_BEFORE: int = 3
MIN_BALANCE_FOR_AUTOPAY_KOPEKS: int = 10000
MONITORING_INTERVAL: int = 60
@@ -149,6 +162,7 @@ class Settings(BaseSettings):
TRIBUTE_API_KEY: Optional[str] = None
TRIBUTE_DONATE_LINK: Optional[str] = None
TRIBUTE_WEBHOOK_PATH: str = "/tribute-webhook"
TRIBUTE_WEBHOOK_HOST: str = "0.0.0.0"
TRIBUTE_WEBHOOK_PORT: int = 8081
YOOKASSA_ENABLED: bool = False
@@ -158,14 +172,16 @@ class Settings(BaseSettings):
YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = None
YOOKASSA_VAT_CODE: int = 1
YOOKASSA_SBP_ENABLED: bool = False
YOOKASSA_PAYMENT_MODE: str = "full_payment"
YOOKASSA_PAYMENT_MODE: str = "full_payment"
YOOKASSA_PAYMENT_SUBJECT: str = "service"
YOOKASSA_WEBHOOK_PATH: str = "/yookassa-webhook"
YOOKASSA_WEBHOOK_HOST: str = "0.0.0.0"
YOOKASSA_WEBHOOK_PORT: int = 8082
YOOKASSA_WEBHOOK_SECRET: Optional[str] = None
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
DISABLE_TOPUP_BUTTONS: bool = False
PAYMENT_BALANCE_DESCRIPTION: str = "Пополнение баланса"
PAYMENT_SUBSCRIPTION_DESCRIPTION: str = "Оплата подписки"
PAYMENT_SERVICE_NAME: str = "Интернет-сервис"
@@ -192,6 +208,10 @@ class Settings(BaseSettings):
MULENPAY_DESCRIPTION: str = "Пополнение баланса"
MULENPAY_LANGUAGE: str = "ru"
MULENPAY_VAT_CODE: int = 0
DISPLAY_NAME_BANNED_KEYWORDS: str = "\n".join(
DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS
)
MULENPAY_PAYMENT_SUBJECT: int = 4
MULENPAY_PAYMENT_MODE: int = 4
MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000
@@ -210,7 +230,23 @@ class Settings(BaseSettings):
PAL24_REQUEST_TIMEOUT: int = 30
PAL24_SBP_BUTTON_TEXT: Optional[str] = None
PAL24_CARD_BUTTON_TEXT: Optional[str] = None
PAL24_SBP_BUTTON_VISIBLE: bool = True
PAL24_CARD_BUTTON_VISIBLE: bool = True
WATA_ENABLED: bool = False
WATA_BASE_URL: str = "https://api.wata.pro/api/h2h"
WATA_ACCESS_TOKEN: Optional[str] = None
WATA_TERMINAL_PUBLIC_ID: Optional[str] = None
WATA_PAYMENT_DESCRIPTION: str = "Пополнение баланса"
WATA_PAYMENT_TYPE: str = "OneTime"
WATA_SUCCESS_REDIRECT_URL: Optional[str] = None
WATA_FAIL_REDIRECT_URL: Optional[str] = None
WATA_LINK_TTL_MINUTES: Optional[int] = None
WATA_MIN_AMOUNT_KOPEKS: int = 10000
WATA_MAX_AMOUNT_KOPEKS: int = 100000000
WATA_REQUEST_TIMEOUT: int = 30
MAIN_MENU_MODE: str = "default"
CONNECT_BUTTON_MODE: str = "guide"
MINIAPP_CUSTOM_URL: str = ""
MINIAPP_PURCHASE_URL: str = ""
@@ -274,6 +310,32 @@ class Settings(BaseSettings):
BACKUP_SEND_CHAT_ID: Optional[str] = None
BACKUP_SEND_TOPIC_ID: Optional[int] = None
EXTERNAL_ADMIN_TOKEN: Optional[str] = None
EXTERNAL_ADMIN_TOKEN_BOT_ID: Optional[int] = None
@field_validator('MAIN_MENU_MODE', mode='before')
@classmethod
def normalize_main_menu_mode(cls, value: Optional[str]) -> str:
if not value:
return "default"
normalized = str(value).strip().lower()
aliases = {
"classic": "default",
"default": "default",
"full": "default",
"standard": "default",
"text": "text",
"text_only": "text",
"textual": "text",
"minimal": "text",
}
mode = aliases.get(normalized, normalized)
if mode not in {"default", "text"}:
raise ValueError("MAIN_MENU_MODE must be one of: default, text")
return mode
@field_validator('SERVER_STATUS_MODE', mode='before')
@classmethod
def normalize_server_status_mode(cls, value: Optional[str]) -> str:
@@ -415,6 +477,12 @@ class Settings(BaseSettings):
value = (self.PAL24_CARD_BUTTON_TEXT or "").strip()
return value or fallback
def is_pal24_sbp_button_visible(self) -> bool:
return self.PAL24_SBP_BUTTON_VISIBLE
def is_pal24_card_button_visible(self) -> bool:
return self.PAL24_CARD_BUTTON_VISIBLE
def get_remnawave_user_delete_mode(self) -> str:
"""Возвращает режим удаления пользователей: 'delete' или 'disable'"""
mode = self.REMNAWAVE_USER_DELETE_MODE.lower().strip()
@@ -446,6 +514,65 @@ class Settings(BaseSettings):
description = re.sub(r'\s+', ' ', description).strip()
return description
@staticmethod
def parse_daily_time_list(raw_value: Optional[str]) -> List[time]:
if not raw_value:
return []
segments = re.split(r"[\s,;]+", raw_value.strip())
seen: set[tuple[int, int]] = set()
parsed: List[time] = []
for segment in segments:
if not segment:
continue
try:
hours_str, minutes_str = segment.split(":", 1)
hours = int(hours_str)
minutes = int(minutes_str)
except (ValueError, AttributeError):
continue
if not (0 <= hours < 24 and 0 <= minutes < 60):
continue
key = (hours, minutes)
if key in seen:
continue
seen.add(key)
parsed.append(time(hour=hours, minute=minutes))
parsed.sort()
return parsed
def get_remnawave_auto_sync_times(self) -> List[time]:
return self.parse_daily_time_list(self.REMNAWAVE_AUTO_SYNC_TIMES)
def get_display_name_banned_keywords(self) -> List[str]:
raw_value = self.DISPLAY_NAME_BANNED_KEYWORDS
if raw_value is None:
return []
if isinstance(raw_value, str):
candidates = re.split(r"[\n,]+", raw_value)
else:
candidates = list(raw_value)
unique: List[str] = []
seen: set[str] = set()
for candidate in candidates:
normalized = str(candidate).strip().lower()
if not normalized:
continue
if normalized in seen:
continue
seen.add(normalized)
unique.append(normalized)
return unique
def get_autopay_warning_days(self) -> List[int]:
try:
@@ -457,6 +584,15 @@ class Settings(BaseSettings):
return [3, 1]
except (ValueError, AttributeError):
return [3, 1]
def is_autopay_enabled_by_default(self) -> bool:
value = getattr(self, "DEFAULT_AUTOPAY_ENABLED", True)
if isinstance(value, str):
normalized = value.strip().lower()
return normalized in {"1", "true", "yes", "on"}
return bool(value)
def get_available_languages(self) -> List[str]:
try:
@@ -473,8 +609,14 @@ class Settings(BaseSettings):
return bool(getattr(self, "LANGUAGE_SELECTION_ENABLED", True))
def format_price(self, price_kopeks: int) -> str:
rubles = price_kopeks // 100
return f"{rubles}"
sign = "-" if price_kopeks < 0 else ""
rubles, kopeks = divmod(abs(price_kopeks), 100)
if kopeks:
value = f"{sign}{rubles}.{kopeks:02d}".rstrip("0").rstrip(".")
return f"{value}"
return f"{sign}{rubles}"
def get_reports_chat_id(self) -> Optional[str]:
if self.ADMIN_REPORTS_CHAT_ID:
@@ -510,9 +652,29 @@ class Settings(BaseSettings):
def get_trial_warning_hours(self) -> int:
return self.TRIAL_WARNING_HOURS
def get_bot_username(self) -> Optional[str]:
username = getattr(self, "BOT_USERNAME", None)
if not username:
return None
normalized = str(username).strip().lstrip("@")
return normalized or None
def is_notifications_enabled(self) -> bool:
return self.ENABLE_NOTIFICATIONS
def get_main_menu_mode(self) -> str:
return getattr(self, "MAIN_MENU_MODE", "default")
def is_text_main_menu_mode(self) -> bool:
return self.get_main_menu_mode() == "text"
def get_main_menu_miniapp_url(self) -> Optional[str]:
for candidate in [self.MINIAPP_CUSTOM_URL, self.MINIAPP_PURCHASE_URL]:
value = (candidate or "").strip()
if value:
return value
return None
def get_app_config_path(self) -> str:
if os.path.isabs(self.APP_CONFIG_PATH):
@@ -554,6 +716,37 @@ class Settings(BaseSettings):
def get_app_config_cache_ttl(self) -> int:
return self.APP_CONFIG_CACHE_TTL
def build_external_admin_token(self, bot_username: str) -> str:
"""Генерирует детерминированный и криптографически стойкий токен внешней админки."""
normalized = (bot_username or "").strip().lstrip("@").lower()
if not normalized:
raise ValueError("Bot username is required to build external admin token")
secret = (self.BOT_TOKEN or "").strip()
if not secret:
raise ValueError("Bot token is required to build external admin token")
digest = hmac.new(
key=secret.encode("utf-8"),
msg=f"remnawave.external_admin::{normalized}".encode("utf-8"),
digestmod=hashlib.sha256,
).hexdigest()
return digest[:48]
def get_external_admin_token(self) -> Optional[str]:
token = (self.EXTERNAL_ADMIN_TOKEN or "").strip()
return token or None
def get_external_admin_bot_id(self) -> Optional[int]:
try:
return int(self.EXTERNAL_ADMIN_TOKEN_BOT_ID) if self.EXTERNAL_ADMIN_TOKEN_BOT_ID else None
except (TypeError, ValueError): # pragma: no cover - защитная ветка для некорректных значений
logging.getLogger(__name__).warning(
"Некорректный идентификатор бота для внешней админки: %s",
self.EXTERNAL_ADMIN_TOKEN_BOT_ID,
)
return None
def is_traffic_selectable(self) -> bool:
return self.TRAFFIC_SELECTION_MODE.lower() == "selectable"
@@ -595,6 +788,13 @@ class Settings(BaseSettings):
and self.PAL24_SHOP_ID is not None
)
def is_wata_enabled(self) -> bool:
return (
self.WATA_ENABLED
and self.WATA_ACCESS_TOKEN is not None
and self.WATA_TERMINAL_PUBLIC_ID is not None
)
def get_cryptobot_base_url(self) -> str:
if self.CRYPTOBOT_TESTNET:
return "https://testnet-pay.crypt.bot"
@@ -789,16 +989,48 @@ class Settings(BaseSettings):
return (self.BACKUP_SEND_ENABLED and
self.get_backup_send_chat_id() is not None)
def get_referred_user_reward_kopeks(self) -> int:
"""Return the referred user reward normalized to kopeks.
Historically the value was stored in kopeks, however some
installations provide it in rubles. To keep backward compatibility we
treat any value greater than or equal to one thousand as already being
in kopeks (≥ 10 ₽). Smaller positive values are assumed to be provided
in rubles and therefore converted to kopeks.
"""
raw_value = getattr(self, "REFERRED_USER_REWARD", 0)
try:
value = int(raw_value)
except (TypeError, ValueError):
return 0
if value <= 0:
return 0
if value >= 1000:
return value
return value * 100
def get_referral_settings(self) -> Dict:
referred_reward_kopeks = self.get_referred_user_reward_kopeks()
return {
"program_enabled": self.is_referral_program_enabled(),
"minimum_topup_kopeks": self.REFERRAL_MINIMUM_TOPUP_KOPEKS,
"first_topup_bonus_kopeks": self.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS,
"inviter_bonus_kopeks": self.REFERRAL_INVITER_BONUS_KOPEKS,
"commission_percent": self.REFERRAL_COMMISSION_PERCENT,
"notifications_enabled": self.REFERRAL_NOTIFICATIONS_ENABLED,
"referred_user_reward": self.REFERRED_USER_REWARD
"referred_user_reward": referred_reward_kopeks,
"referred_user_reward_kopeks": referred_reward_kopeks,
}
def is_referral_program_enabled(self) -> bool:
return bool(self.REFERRAL_PROGRAM_ENABLED)
def is_referral_notifications_enabled(self) -> bool:
return self.REFERRAL_NOTIFICATIONS_ENABLED
@@ -1061,15 +1293,31 @@ class Settings(BaseSettings):
settings = Settings()
PERIOD_PRICES = {
14: settings.PRICE_14_DAYS,
30: settings.PRICE_30_DAYS,
60: settings.PRICE_60_DAYS,
90: settings.PRICE_90_DAYS,
180: settings.PRICE_180_DAYS,
360: settings.PRICE_360_DAYS,
_PERIOD_PRICE_FIELDS: Dict[int, str] = {
14: "PRICE_14_DAYS",
30: "PRICE_30_DAYS",
60: "PRICE_60_DAYS",
90: "PRICE_90_DAYS",
180: "PRICE_180_DAYS",
360: "PRICE_360_DAYS",
}
def refresh_period_prices() -> None:
"""Rebuild cached period price mapping using the latest settings."""
PERIOD_PRICES.clear()
PERIOD_PRICES.update(
{
days: getattr(settings, field_name, 0)
for days, field_name in _PERIOD_PRICE_FIELDS.items()
}
)
PERIOD_PRICES: Dict[int, int] = {}
refresh_period_prices()
def get_traffic_prices() -> Dict[int, int]:
packages = settings.get_traffic_packages()
return {package["gb"]: package["price"] for package in packages}
+189 -4
View File
@@ -1,11 +1,18 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import Optional
from typing import List, Optional
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.crud.promo_offer_log import log_promo_offer_action
from app.database.models import DiscountOffer
logger = logging.getLogger(__name__)
async def upsert_discount_offer(
db: AsyncSession,
@@ -16,6 +23,8 @@ async def upsert_discount_offer(
discount_percent: int,
bonus_amount_kopeks: int,
valid_hours: int,
effect_type: str = "percent_discount",
extra_data: Optional[dict] = None,
) -> DiscountOffer:
"""Create or refresh a discount offer for a user."""
@@ -37,6 +46,8 @@ async def upsert_discount_offer(
offer.bonus_amount_kopeks = bonus_amount_kopeks
offer.expires_at = expires_at
offer.subscription_id = subscription_id
offer.effect_type = effect_type
offer.extra_data = extra_data
else:
offer = DiscountOffer(
user_id=user_id,
@@ -46,6 +57,8 @@ async def upsert_discount_offer(
bonus_amount_kopeks=bonus_amount_kopeks,
expires_at=expires_at,
is_active=True,
effect_type=effect_type,
extra_data=extra_data,
)
db.add(offer)
@@ -56,16 +69,128 @@ async def upsert_discount_offer(
async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountOffer]:
result = await db.execute(
select(DiscountOffer).where(DiscountOffer.id == offer_id)
select(DiscountOffer)
.options(
selectinload(DiscountOffer.user),
selectinload(DiscountOffer.subscription),
)
.where(DiscountOffer.id == offer_id)
)
return result.scalar_one_or_none()
async def mark_offer_claimed(db: AsyncSession, offer: DiscountOffer) -> DiscountOffer:
async def list_discount_offers(
db: AsyncSession,
*,
offset: int = 0,
limit: int = 50,
user_id: Optional[int] = None,
notification_type: Optional[str] = None,
is_active: Optional[bool] = None,
) -> List[DiscountOffer]:
stmt = (
select(DiscountOffer)
.options(
selectinload(DiscountOffer.user),
selectinload(DiscountOffer.subscription),
)
.order_by(DiscountOffer.created_at.desc())
.offset(offset)
.limit(limit)
)
if user_id is not None:
stmt = stmt.where(DiscountOffer.user_id == user_id)
if notification_type:
stmt = stmt.where(DiscountOffer.notification_type == notification_type)
if is_active is not None:
stmt = stmt.where(DiscountOffer.is_active == is_active)
result = await db.execute(stmt)
return result.scalars().all()
async def list_active_discount_offers_for_user(
db: AsyncSession,
user_id: int,
) -> List[DiscountOffer]:
"""Return active (not yet claimed) offers for a user."""
now = datetime.utcnow()
stmt = (
select(DiscountOffer)
.options(
selectinload(DiscountOffer.user),
selectinload(DiscountOffer.subscription),
)
.where(
DiscountOffer.user_id == user_id,
DiscountOffer.is_active == True, # noqa: E712
DiscountOffer.expires_at > now,
)
.order_by(DiscountOffer.expires_at.asc())
)
result = await db.execute(stmt)
return result.scalars().all()
async def count_discount_offers(
db: AsyncSession,
*,
user_id: Optional[int] = None,
notification_type: Optional[str] = None,
is_active: Optional[bool] = None,
) -> int:
stmt = select(func.count(DiscountOffer.id))
if user_id is not None:
stmt = stmt.where(DiscountOffer.user_id == user_id)
if notification_type:
stmt = stmt.where(DiscountOffer.notification_type == notification_type)
if is_active is not None:
stmt = stmt.where(DiscountOffer.is_active == is_active)
result = await db.execute(stmt)
return int(result.scalar() or 0)
async def mark_offer_claimed(
db: AsyncSession,
offer: DiscountOffer,
*,
details: Optional[dict] = None,
) -> DiscountOffer:
offer.claimed_at = datetime.utcnow()
offer.is_active = False
await db.commit()
await db.refresh(offer)
try:
await log_promo_offer_action(
db,
user_id=offer.user_id,
offer_id=offer.id,
action="claimed",
source=offer.notification_type,
percent=offer.discount_percent,
effect_type=offer.effect_type,
details=details,
)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning(
"Failed to record promo offer claim log for offer %s: %s",
offer.id,
exc,
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to rollback session after promo offer claim log failure: %s",
rollback_error,
)
return offer
@@ -82,9 +207,69 @@ async def deactivate_expired_offers(db: AsyncSession) -> int:
return 0
count = 0
log_payloads = []
for offer in offers:
offer.is_active = False
count += 1
log_payloads.append(
{
"user_id": offer.user_id,
"offer_id": offer.id,
"source": offer.notification_type,
"percent": offer.discount_percent,
"effect_type": offer.effect_type,
}
)
await db.commit()
for payload in log_payloads:
if not payload.get("user_id"):
continue
try:
await log_promo_offer_action(
db,
user_id=payload["user_id"],
offer_id=payload["offer_id"],
action="disabled",
source=payload.get("source"),
percent=payload.get("percent"),
effect_type=payload.get("effect_type"),
details={"reason": "offer_expired"},
)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning(
"Failed to record promo offer disable log for offer %s: %s",
payload.get("offer_id"),
exc,
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to rollback session after promo offer disable log failure: %s",
rollback_error,
)
return count
async def get_latest_claimed_offer_for_user(
db: AsyncSession,
user_id: int,
source: Optional[str] = None,
) -> Optional[DiscountOffer]:
stmt = (
select(DiscountOffer)
.where(
DiscountOffer.user_id == user_id,
DiscountOffer.claimed_at.isnot(None),
)
.order_by(DiscountOffer.claimed_at.desc())
)
if source:
stmt = stmt.where(DiscountOffer.notification_type == source)
result = await db.execute(stmt)
return result.scalars().first()
+150
View File
@@ -0,0 +1,150 @@
import logging
from datetime import datetime
from typing import Iterable, Optional
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import FaqPage, FaqSetting
logger = logging.getLogger(__name__)
async def get_faq_setting(db: AsyncSession, language: str) -> Optional[FaqSetting]:
result = await db.execute(
select(FaqSetting).where(FaqSetting.language == language)
)
return result.scalar_one_or_none()
async def set_faq_enabled(db: AsyncSession, language: str, enabled: bool) -> FaqSetting:
setting = await get_faq_setting(db, language)
if setting:
setting.is_enabled = bool(enabled)
setting.updated_at = datetime.utcnow()
else:
setting = FaqSetting(
language=language,
is_enabled=bool(enabled),
)
db.add(setting)
await db.commit()
await db.refresh(setting)
logger.info(
"✅ Статус FAQ для языка %s обновлен: %s",
language,
"enabled" if setting.is_enabled else "disabled",
)
return setting
async def upsert_faq_setting(db: AsyncSession, language: str, enabled: bool) -> FaqSetting:
return await set_faq_enabled(db, language, enabled)
async def get_faq_pages(
db: AsyncSession,
language: str,
*,
include_inactive: bool = False,
) -> list[FaqPage]:
query = select(FaqPage).where(FaqPage.language == language)
if not include_inactive:
query = query.where(FaqPage.is_active.is_(True))
query = query.order_by(FaqPage.display_order.asc(), FaqPage.id.asc())
result = await db.execute(query)
pages = list(result.scalars().all())
return pages
async def get_faq_page_by_id(db: AsyncSession, page_id: int) -> Optional[FaqPage]:
result = await db.execute(select(FaqPage).where(FaqPage.id == page_id))
return result.scalar_one_or_none()
async def create_faq_page(
db: AsyncSession,
*,
language: str,
title: str,
content: str,
display_order: Optional[int] = None,
is_active: bool = True,
) -> FaqPage:
if display_order is None:
result = await db.execute(
select(func.max(FaqPage.display_order)).where(FaqPage.language == language)
)
max_order = result.scalar() or 0
display_order = max_order + 1
page = FaqPage(
language=language,
title=title,
content=content,
display_order=display_order,
is_active=is_active,
)
db.add(page)
await db.commit()
await db.refresh(page)
logger.info("✅ Создана страница FAQ %s для языка %s", page.id, language)
return page
async def update_faq_page(
db: AsyncSession,
page: FaqPage,
*,
title: Optional[str] = None,
content: Optional[str] = None,
display_order: Optional[int] = None,
is_active: Optional[bool] = None,
) -> FaqPage:
if title is not None:
page.title = title
if content is not None:
page.content = content
if display_order is not None:
page.display_order = display_order
if is_active is not None:
page.is_active = bool(is_active)
page.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(page)
logger.info("✅ Страница FAQ %s обновлена", page.id)
return page
async def delete_faq_page(db: AsyncSession, page_id: int) -> None:
await db.execute(delete(FaqPage).where(FaqPage.id == page_id))
await db.commit()
logger.info("🗑️ Страница FAQ %s удалена", page_id)
async def bulk_update_order(
db: AsyncSession,
pages: Iterable[tuple[int, int]],
) -> None:
for page_id, order in pages:
await db.execute(
update(FaqPage)
.where(FaqPage.id == page_id)
.values(display_order=order, updated_at=datetime.utcnow())
)
await db.commit()
+145
View File
@@ -0,0 +1,145 @@
from __future__ import annotations
from typing import Optional, Sequence
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import (
MainMenuButton,
MainMenuButtonActionType,
MainMenuButtonVisibility,
)
async def count_main_menu_buttons(db: AsyncSession) -> int:
result = await db.execute(select(func.count()).select_from(MainMenuButton))
return int(result.scalar() or 0)
async def get_main_menu_buttons(
db: AsyncSession,
*,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> list[MainMenuButton]:
stmt = select(MainMenuButton).order_by(
MainMenuButton.display_order.asc(),
MainMenuButton.id.asc(),
)
if offset is not None:
stmt = stmt.offset(offset)
if limit is not None:
stmt = stmt.limit(limit)
result = await db.execute(stmt)
return list(result.scalars().all())
async def get_main_menu_button_by_id(
db: AsyncSession, button_id: int
) -> MainMenuButton | None:
result = await db.execute(
select(MainMenuButton).where(MainMenuButton.id == button_id)
)
return result.scalar_one_or_none()
async def get_next_display_order(db: AsyncSession) -> int:
result = await db.execute(select(func.max(MainMenuButton.display_order)))
current_max = result.scalar()
return (int(current_max) if current_max is not None else 0) + 1
def _enum_value(value, enum_cls):
if value is None:
return None
if isinstance(value, enum_cls):
return value.value
return str(value)
async def create_main_menu_button(
db: AsyncSession,
*,
text: str,
action_type: MainMenuButtonActionType | str,
action_value: str,
visibility: MainMenuButtonVisibility | str = MainMenuButtonVisibility.ALL,
is_active: bool = True,
display_order: Optional[int] = None,
) -> MainMenuButton:
if display_order is None:
display_order = await get_next_display_order(db)
button = MainMenuButton(
text=text,
action_type=_enum_value(action_type, MainMenuButtonActionType),
action_value=action_value,
visibility=_enum_value(visibility, MainMenuButtonVisibility)
or MainMenuButtonVisibility.ALL.value,
is_active=bool(is_active),
display_order=int(display_order),
)
db.add(button)
await db.commit()
await db.refresh(button)
return button
async def update_main_menu_button(
db: AsyncSession,
button: MainMenuButton,
*,
text: Optional[str] = None,
action_type: MainMenuButtonActionType | str | None = None,
action_value: Optional[str] = None,
visibility: MainMenuButtonVisibility | str | None = None,
is_active: Optional[bool] = None,
display_order: Optional[int] = None,
) -> MainMenuButton:
if text is not None:
button.text = text
if action_type is not None:
button.action_type = _enum_value(action_type, MainMenuButtonActionType)
if action_value is not None:
button.action_value = action_value
if visibility is not None:
button.visibility = _enum_value(visibility, MainMenuButtonVisibility)
if is_active is not None:
button.is_active = bool(is_active)
if display_order is not None:
button.display_order = int(display_order)
await db.commit()
await db.refresh(button)
return button
async def delete_main_menu_button(db: AsyncSession, button: MainMenuButton) -> None:
await db.delete(button)
await db.commit()
async def reorder_main_menu_buttons(
db: AsyncSession,
ordered_ids: Sequence[int],
) -> None:
if not ordered_ids:
return
order_map = {int(button_id): index for index, button_id in enumerate(ordered_ids)}
result = await db.execute(
select(MainMenuButton).where(MainMenuButton.id.in_(order_map.keys()))
)
buttons = result.scalars().all()
for button in buttons:
desired_order = order_map.get(button.id)
if desired_order is not None:
button.display_order = desired_order
await db.commit()
+79
View File
@@ -0,0 +1,79 @@
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PrivacyPolicy
logger = logging.getLogger(__name__)
async def get_privacy_policy(db: AsyncSession, language: str) -> Optional[PrivacyPolicy]:
result = await db.execute(
select(PrivacyPolicy).where(PrivacyPolicy.language == language)
)
return result.scalar_one_or_none()
async def upsert_privacy_policy(
db: AsyncSession,
language: str,
content: str,
*,
enable_if_new: bool = True,
) -> PrivacyPolicy:
policy = await get_privacy_policy(db, language)
if policy:
policy.content = content or ""
policy.updated_at = datetime.utcnow()
else:
policy = PrivacyPolicy(
language=language,
content=content or "",
is_enabled=True if enable_if_new else False,
)
db.add(policy)
await db.commit()
await db.refresh(policy)
logger.info(
"✅ Политика конфиденциальности для языка %s обновлена (ID: %s)",
language,
policy.id,
)
return policy
async def set_privacy_policy_enabled(
db: AsyncSession,
language: str,
enabled: bool,
) -> PrivacyPolicy:
policy = await get_privacy_policy(db, language)
if policy:
policy.is_enabled = bool(enabled)
policy.updated_at = datetime.utcnow()
else:
policy = PrivacyPolicy(
language=language,
content="",
is_enabled=bool(enabled),
)
db.add(policy)
await db.commit()
await db.refresh(policy)
logger.info(
"✅ Статус политики конфиденциальности для языка %s обновлен: %s",
language,
"enabled" if policy.is_enabled else "disabled",
)
return policy
+19
View File
@@ -50,6 +50,25 @@ async def get_promo_groups_with_counts(
return result.all()
async def get_auto_assign_promo_groups(db: AsyncSession) -> List[PromoGroup]:
result = await db.execute(
select(PromoGroup)
.where(PromoGroup.auto_assign_total_spent_kopeks.is_not(None))
.where(PromoGroup.auto_assign_total_spent_kopeks > 0)
.order_by(PromoGroup.auto_assign_total_spent_kopeks, PromoGroup.id)
)
return result.scalars().all()
async def has_auto_assign_promo_groups(db: AsyncSession) -> bool:
result = await db.execute(
select(func.count(PromoGroup.id))
.where(PromoGroup.auto_assign_total_spent_kopeks.is_not(None))
.where(PromoGroup.auto_assign_total_spent_kopeks > 0)
)
return bool(result.scalar_one())
async def get_promo_group_by_id(db: AsyncSession, group_id: int) -> Optional[PromoGroup]:
return await db.get(PromoGroup, group_id)
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
import logging
from typing import Dict, List, Optional, Tuple
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import PromoOfferLog
logger = logging.getLogger(__name__)
async def log_promo_offer_action(
db: AsyncSession,
*,
user_id: Optional[int],
offer_id: Optional[int],
action: str,
source: Optional[str] = None,
percent: Optional[int] = None,
effect_type: Optional[str] = None,
details: Optional[Dict[str, object]] = None,
commit: bool = True,
) -> PromoOfferLog:
"""Persist a promo offer log entry."""
entry = PromoOfferLog(
user_id=user_id,
offer_id=offer_id,
action=action,
source=source,
percent=percent,
effect_type=effect_type,
details=(details or {}).copy(),
)
db.add(entry)
if commit:
try:
await db.commit()
await db.refresh(entry)
except Exception:
logger.exception("Failed to commit promo offer log entry")
raise
return entry
async def list_promo_offer_logs(
db: AsyncSession,
offset: int = 0,
limit: int = 20,
*,
user_id: Optional[int] = None,
offer_id: Optional[int] = None,
action: Optional[str] = None,
source: Optional[str] = None,
) -> Tuple[List[PromoOfferLog], int]:
stmt = (
select(PromoOfferLog)
.options(
selectinload(PromoOfferLog.user),
selectinload(PromoOfferLog.offer),
)
.order_by(PromoOfferLog.created_at.desc())
.offset(offset)
.limit(limit)
)
if user_id is not None:
stmt = stmt.where(PromoOfferLog.user_id == user_id)
if offer_id is not None:
stmt = stmt.where(PromoOfferLog.offer_id == offer_id)
if action:
stmt = stmt.where(PromoOfferLog.action == action)
if source:
stmt = stmt.where(PromoOfferLog.source == source)
result = await db.execute(stmt)
logs = result.scalars().all()
count_stmt = select(func.count(PromoOfferLog.id))
if user_id is not None:
count_stmt = count_stmt.where(PromoOfferLog.user_id == user_id)
if offer_id is not None:
count_stmt = count_stmt.where(PromoOfferLog.offer_id == offer_id)
if action:
count_stmt = count_stmt.where(PromoOfferLog.action == action)
if source:
count_stmt = count_stmt.where(PromoOfferLog.source == source)
total = (await db.execute(count_stmt)).scalar() or 0
return logs, total
+224
View File
@@ -0,0 +1,224 @@
from __future__ import annotations
from datetime import datetime
from typing import Iterable, List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PromoOfferTemplate
UPDATED_TEMPLATE_MESSAGES = {
"extend_discount": (
"💎 Экономия {discount_percent}% при продлении\n\n"
"Скидка суммируется с промогруппой и действует один раз.\n"
"Срок действия предложения — {valid_hours} ч.\n"
"После активации скидка действует {active_discount_hours} ч."
),
"purchase_discount": (
"🎯 Вернитесь со скидкой {discount_percent}%\n\n"
"Скидка суммируется с промогруппой и действует один раз.\n"
"Предложение действует {valid_hours} ч.\n"
"После активации скидка действует {active_discount_hours} ч."
),
}
LEGACY_TEMPLATE_MESSAGES = {
"extend_discount": (
"💎 <b>Экономия {discount_percent}% при продлении</b>\n\n"
"Активируйте предложение и получите дополнительную скидку на оплату продления. "
"Она суммируется с вашими промогрупповыми скидками и действует один раз.\n"
"Срок действия предложения — {valid_hours} ч.\n"
"После активации скидка действует {active_discount_hours} ч."
),
"purchase_discount": (
"🎯 <b>Вернитесь со скидкой {discount_percent}%</b>\n\n"
"После активации мы применим дополнительную скидку к вашей следующей оплате подписки. "
"Скидка суммируется с промогруппой и действует один раз.\n"
"Предложение действует {valid_hours} ч.\n"
"После активации скидка действует {active_discount_hours} ч."
),
}
DEFAULT_TEMPLATES: tuple[dict, ...] = (
{
"offer_type": "test_access",
"name": "Тестовые сервера",
"message_text": (
"🔥 <b>Испытайте новые сервера</b>\n\n"
"Активируйте предложение и получите временный доступ к дополнительным сквадам на {test_duration_hours} ч.\n"
"Предложение действительно {valid_hours} ч."
),
"button_text": "🚀 Попробовать серверы",
"valid_hours": 24,
"discount_percent": 0,
"bonus_amount_kopeks": 0,
"active_discount_hours": None,
"test_duration_hours": 24,
"test_squad_uuids": [],
},
{
"offer_type": "extend_discount",
"name": "Скидка на продление",
"message_text": UPDATED_TEMPLATE_MESSAGES["extend_discount"],
"button_text": "🎁 Получить скидку",
"valid_hours": 24,
"discount_percent": 20,
"bonus_amount_kopeks": 0,
"active_discount_hours": 24,
"test_duration_hours": None,
"test_squad_uuids": [],
},
{
"offer_type": "purchase_discount",
"name": "Скидка на покупку",
"message_text": UPDATED_TEMPLATE_MESSAGES["purchase_discount"],
"button_text": "🎁 Забрать скидку",
"valid_hours": 48,
"discount_percent": 25,
"bonus_amount_kopeks": 0,
"active_discount_hours": 48,
"test_duration_hours": None,
"test_squad_uuids": [],
},
)
def _format_template_fields(payload: dict) -> dict:
data = dict(payload)
data.setdefault("valid_hours", 24)
data.setdefault("discount_percent", 0)
data.setdefault("bonus_amount_kopeks", 0)
data.setdefault("active_discount_hours", None)
data.setdefault("test_duration_hours", None)
data.setdefault("test_squad_uuids", [])
return data
async def ensure_default_templates(db: AsyncSession, *, created_by: Optional[int] = None) -> List[PromoOfferTemplate]:
templates: List[PromoOfferTemplate] = []
for template_data in DEFAULT_TEMPLATES:
result = await db.execute(
select(PromoOfferTemplate).where(PromoOfferTemplate.offer_type == template_data["offer_type"])
)
existing = result.scalars().first()
if existing:
new_message = UPDATED_TEMPLATE_MESSAGES.get(template_data["offer_type"])
legacy_message = LEGACY_TEMPLATE_MESSAGES.get(template_data["offer_type"])
should_update = False
if new_message and legacy_message and existing.message_text == legacy_message:
should_update = True
elif new_message and (
"{bonus_amount" in existing.message_text or "Мы начислим" in existing.message_text
):
should_update = True
if should_update and new_message:
existing.message_text = new_message
existing.updated_at = datetime.utcnow()
await db.flush()
target_active_hours = template_data.get("active_discount_hours")
if (
target_active_hours is not None
and target_active_hours > 0
and not existing.active_discount_hours
):
existing.active_discount_hours = target_active_hours
existing.updated_at = datetime.utcnow()
await db.flush()
templates.append(existing)
continue
payload = _format_template_fields(template_data)
template = PromoOfferTemplate(
name=payload["name"],
offer_type=payload["offer_type"],
message_text=payload["message_text"],
button_text=payload["button_text"],
valid_hours=payload["valid_hours"],
discount_percent=payload["discount_percent"],
bonus_amount_kopeks=payload["bonus_amount_kopeks"],
active_discount_hours=payload["active_discount_hours"],
test_duration_hours=payload["test_duration_hours"],
test_squad_uuids=payload["test_squad_uuids"],
is_active=True,
created_by=created_by,
)
db.add(template)
await db.flush()
templates.append(template)
await db.commit()
return templates
async def list_promo_offer_templates(db: AsyncSession) -> List[PromoOfferTemplate]:
result = await db.execute(
select(PromoOfferTemplate).order_by(PromoOfferTemplate.offer_type, PromoOfferTemplate.id)
)
return result.scalars().all()
async def get_promo_offer_template_by_id(db: AsyncSession, template_id: int) -> Optional[PromoOfferTemplate]:
result = await db.execute(
select(PromoOfferTemplate).where(PromoOfferTemplate.id == template_id)
)
return result.scalar_one_or_none()
async def get_promo_offer_template_by_type(db: AsyncSession, offer_type: str) -> Optional[PromoOfferTemplate]:
result = await db.execute(
select(PromoOfferTemplate).where(PromoOfferTemplate.offer_type == offer_type)
)
return result.scalar_one_or_none()
async def update_promo_offer_template(
db: AsyncSession,
template: PromoOfferTemplate,
*,
name: Optional[str] = None,
message_text: Optional[str] = None,
button_text: Optional[str] = None,
valid_hours: Optional[int] = None,
discount_percent: Optional[int] = None,
bonus_amount_kopeks: Optional[int] = None,
active_discount_hours: Optional[int] = None,
test_duration_hours: Optional[int] = None,
test_squad_uuids: Optional[Iterable[str]] = None,
is_active: Optional[bool] = None,
) -> PromoOfferTemplate:
if name is not None:
template.name = name
if message_text is not None:
template.message_text = message_text
if button_text is not None:
template.button_text = button_text
if valid_hours is not None:
template.valid_hours = valid_hours
if discount_percent is not None:
template.discount_percent = discount_percent
if bonus_amount_kopeks is not None:
template.bonus_amount_kopeks = bonus_amount_kopeks
if active_discount_hours is not None:
template.active_discount_hours = active_discount_hours
if test_duration_hours is not None:
template.test_duration_hours = test_duration_hours
if test_squad_uuids is not None:
template.test_squad_uuids = list(test_squad_uuids)
if is_active is not None:
template.is_active = is_active
template.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(template)
return template
+79
View File
@@ -0,0 +1,79 @@
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PublicOffer
logger = logging.getLogger(__name__)
async def get_public_offer(db: AsyncSession, language: str) -> Optional[PublicOffer]:
result = await db.execute(
select(PublicOffer).where(PublicOffer.language == language)
)
return result.scalar_one_or_none()
async def upsert_public_offer(
db: AsyncSession,
language: str,
content: str,
*,
enable_if_new: bool = True,
) -> PublicOffer:
offer = await get_public_offer(db, language)
if offer:
offer.content = content or ""
offer.updated_at = datetime.utcnow()
else:
offer = PublicOffer(
language=language,
content=content or "",
is_enabled=True if enable_if_new else False,
)
db.add(offer)
await db.commit()
await db.refresh(offer)
logger.info(
"✅ Публичная оферта для языка %s обновлена (ID: %s)",
language,
offer.id,
)
return offer
async def set_public_offer_enabled(
db: AsyncSession,
language: str,
enabled: bool,
) -> PublicOffer:
offer = await get_public_offer(db, language)
if offer:
offer.is_enabled = bool(enabled)
offer.updated_at = datetime.utcnow()
else:
offer = PublicOffer(
language=language,
content="",
is_enabled=bool(enabled),
)
db.add(offer)
await db.commit()
await db.refresh(offer)
logger.info(
"✅ Статус публичной оферты для языка %s обновлен: %s",
language,
"enabled" if offer.is_enabled else "disabled",
)
return offer
+96 -5
View File
@@ -1,4 +1,5 @@
import logging
import random
from datetime import datetime
from typing import Iterable, List, Optional, Sequence, Tuple
@@ -16,7 +17,14 @@ from sqlalchemy import (
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import PromoGroup, ServerSquad, SubscriptionServer, Subscription, User
from app.database.models import (
PromoGroup,
ServerSquad,
SubscriptionServer,
Subscription,
SubscriptionStatus,
User,
)
logger = logging.getLogger(__name__)
@@ -38,6 +46,7 @@ async def create_server_squad(
description: str = None,
max_users: int = None,
is_available: bool = True,
is_trial_eligible: bool = False,
promo_group_ids: Optional[Iterable[int]] = None,
) -> ServerSquad:
@@ -70,6 +79,7 @@ async def create_server_squad(
description=description,
max_users=max_users,
is_available=is_available,
is_trial_eligible=is_trial_eligible,
allowed_promo_groups=promo_groups,
)
@@ -199,7 +209,7 @@ async def update_server_squad(
valid_fields = {
'display_name', 'country_code', 'price_kopeks', 'description',
'max_users', 'is_available', 'sort_order'
'max_users', 'is_available', 'sort_order', 'is_trial_eligible'
}
filtered_updates = {k: v for k, v in updates.items() if k in valid_fields}
@@ -393,8 +403,70 @@ async def get_server_connected_users(
return result.scalars().unique().all()
async def get_trial_eligible_server_squads(
db: AsyncSession,
include_unavailable: bool = False,
) -> List[ServerSquad]:
query = select(ServerSquad).where(ServerSquad.is_trial_eligible.is_(True))
result = await db.execute(query)
squads = result.scalars().unique().all()
if include_unavailable:
return squads
preferred_squads: List[ServerSquad] = []
fallback_squads: List[ServerSquad] = []
for squad in squads:
current_users = squad.current_users or 0
is_full = squad.max_users is not None and current_users >= squad.max_users
if is_full:
continue
if squad.is_available:
preferred_squads.append(squad)
else:
fallback_squads.append(squad)
if preferred_squads:
return preferred_squads
if fallback_squads:
return fallback_squads
return squads
async def choose_random_trial_server_squad(
db: AsyncSession,
) -> Optional[ServerSquad]:
squads = await get_trial_eligible_server_squads(db)
if not squads:
return None
return random.choice(squads)
async def get_random_trial_squad_uuid(
db: AsyncSession,
fallback_uuid: Optional[str] = None,
) -> Optional[str]:
squad = await choose_random_trial_server_squad(db)
if squad:
return squad.squad_uuid
return fallback_uuid
def _generate_display_name(original_name: str) -> str:
country_names = {
'NL': '🇳🇱 Нидерланды',
'DE': '🇩🇪 Германия',
@@ -430,10 +502,10 @@ def _extract_country_code(original_name: str) -> Optional[str]:
async def get_server_statistics(db: AsyncSession) -> dict:
total_result = await db.execute(select(func.count(ServerSquad.id)))
total_servers = total_result.scalar()
available_result = await db.execute(
select(func.count(ServerSquad.id))
.where(ServerSquad.is_available == True)
@@ -472,6 +544,25 @@ async def get_server_statistics(db: AsyncSession) -> dict:
'total_revenue_rubles': total_revenue_kopeks / 100
}
async def count_active_users_for_squad(db: AsyncSession, squad_uuid: str) -> int:
"""Возвращает количество активных подписок, подключенных к указанному скваду."""
result = await db.execute(
select(func.count(Subscription.id)).where(
Subscription.status.in_(
[
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.TRIAL.value,
]
),
cast(Subscription.connected_squads, String).like(f'%"{squad_uuid}"%'),
)
)
return result.scalar() or 0
async def add_user_to_servers(
db: AsyncSession,
server_squad_ids: List[int]
+182 -14
View File
@@ -1,6 +1,6 @@
import logging
from datetime import datetime, timedelta
from typing import Optional, List, Tuple
from typing import Iterable, Optional, List, Tuple
from sqlalchemy import select, and_, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -47,10 +47,31 @@ async def create_trial_subscription(
duration_days = duration_days or settings.TRIAL_DURATION_DAYS
traffic_limit_gb = traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB
device_limit = device_limit or settings.TRIAL_DEVICE_LIMIT
squad_uuid = squad_uuid or settings.TRIAL_SQUAD_UUID
if not squad_uuid:
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
squad_uuid = await get_random_trial_squad_uuid(
db,
settings.TRIAL_SQUAD_UUID,
)
if squad_uuid:
logger.debug(
"Выбран сквад %s для триальной подписки пользователя %s",
squad_uuid,
user_id,
)
except Exception as error:
logger.error(
"Не удалось получить сквад для триальной подписки пользователя %s: %s",
user_id,
error,
)
squad_uuid = settings.TRIAL_SQUAD_UUID
end_date = datetime.utcnow() + timedelta(days=duration_days)
subscription = Subscription(
user_id=user_id,
status=SubscriptionStatus.ACTIVE.value,
@@ -59,14 +80,43 @@ async def create_trial_subscription(
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=[squad_uuid] if squad_uuid else []
connected_squads=[squad_uuid] if squad_uuid else [],
autopay_enabled=settings.is_autopay_enabled_by_default(),
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
)
db.add(subscription)
await db.commit()
await db.refresh(subscription)
logger.info(f"🎁 Создана триальная подписка для пользователя {user_id}")
if squad_uuid:
try:
from app.database.crud.server_squad import (
get_server_ids_by_uuids,
add_user_to_servers,
)
server_ids = await get_server_ids_by_uuids(db, [squad_uuid])
if server_ids:
await add_user_to_servers(db, server_ids)
logger.info(
"📈 Обновлен счетчик пользователей для триального сквада %s",
squad_uuid,
)
else:
logger.warning(
"⚠️ Не удалось найти серверы для обновления счетчика (сквад %s)",
squad_uuid,
)
except Exception as error:
logger.error(
"⚠️ Ошибка обновления счетчика пользователей для триального сквада %s: %s",
squad_uuid,
error,
)
return subscription
@@ -74,9 +124,10 @@ async def create_paid_subscription(
db: AsyncSession,
user_id: int,
duration_days: int,
traffic_limit_gb: int = 0,
traffic_limit_gb: int = 0,
device_limit: int = 1,
connected_squads: List[str] = None
connected_squads: List[str] = None,
update_server_counters: bool = False,
) -> Subscription:
end_date = datetime.utcnow() + timedelta(days=duration_days)
@@ -89,7 +140,9 @@ 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=connected_squads or [],
autopay_enabled=settings.is_autopay_enabled_by_default(),
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
)
db.add(subscription)
@@ -97,6 +150,36 @@ async def create_paid_subscription(
await db.refresh(subscription)
logger.info(f"💎 Создана платная подписка для пользователя {user_id}")
squad_uuids = list(connected_squads or [])
if update_server_counters and squad_uuids:
try:
from app.database.crud.server_squad import (
get_server_ids_by_uuids,
add_user_to_servers,
)
server_ids = await get_server_ids_by_uuids(db, squad_uuids)
if server_ids:
await add_user_to_servers(db, server_ids)
logger.info(
"📈 Обновлен счетчик пользователей для платной подписки пользователя %s (сквады: %s)",
user_id,
squad_uuids,
)
else:
logger.warning(
"⚠️ Не удалось найти серверы для обновления счетчика платной подписки пользователя %s (сквады: %s)",
user_id,
squad_uuids,
)
except Exception as error:
logger.error(
"⚠️ Ошибка обновления счетчика пользователей серверов для платной подписки пользователя %s: %s",
user_id,
error,
)
return subscription
@@ -110,7 +193,14 @@ async def extend_subscription(
logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней")
logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}")
if subscription.end_date > current_time:
if days < 0:
subscription.end_date = subscription.end_date + timedelta(days=days)
logger.info(
"📅 Срок подписки уменьшен на %s дней, новая дата окончания: %s",
abs(days),
subscription.end_date,
)
elif subscription.end_date > current_time:
subscription.end_date = subscription.end_date + timedelta(days=days)
logger.info(f"📅 Подписка активна, добавляем {days} дней к текущей дате окончания")
else:
@@ -133,9 +223,17 @@ async def extend_subscription(
if subscription.user:
subscription.user.has_had_paid_subscription = True
if subscription.status == SubscriptionStatus.EXPIRED.value:
if days > 0 and subscription.status in (
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
):
previous_status = subscription.status
subscription.status = SubscriptionStatus.ACTIVE.value
logger.info(f"🔄 Статус изменён с EXPIRED на ACTIVE")
logger.info(
"🔄 Статус подписки %s изменён с %s на ACTIVE",
subscription.id,
previous_status,
)
if settings.RESET_TRAFFIC_ON_PAYMENT:
subscription.traffic_used_gb = 0.0
@@ -223,6 +321,64 @@ async def remove_subscription_squad(
return subscription
async def decrement_subscription_server_counts(
db: AsyncSession,
subscription: Optional[Subscription],
*,
subscription_servers: Optional[Iterable[SubscriptionServer]] = None,
) -> None:
"""Decrease server counters linked to the provided subscription."""
if not subscription:
return
server_ids: set[int] = set()
if subscription_servers is not None:
for sub_server in subscription_servers:
if sub_server and sub_server.server_squad_id is not None:
server_ids.add(sub_server.server_squad_id)
else:
try:
ids_from_links = await get_subscription_server_ids(db, subscription.id)
server_ids.update(ids_from_links)
except Exception as error:
logger.error(
"⚠️ Не удалось получить серверы подписки %s для уменьшения счетчика: %s",
subscription.id,
error,
)
connected_squads = list(subscription.connected_squads or [])
if connected_squads:
try:
from app.database.crud.server_squad import get_server_ids_by_uuids
squad_server_ids = await get_server_ids_by_uuids(db, connected_squads)
server_ids.update(squad_server_ids)
except Exception as error:
logger.error(
"⚠️ Не удалось сопоставить сквады подписки %s с серверами: %s",
subscription.id,
error,
)
if not server_ids:
return
try:
from app.database.crud.server_squad import remove_user_from_servers
await remove_user_from_servers(db, sorted(server_ids))
except Exception as error:
logger.error(
"⚠️ Ошибка уменьшения счетчика пользователей серверов %s для подписки %s: %s",
list(server_ids),
subscription.id,
error,
)
async def update_subscription_autopay(
db: AsyncSession,
subscription: Subscription,
@@ -986,7 +1142,9 @@ async def create_subscription(
connected_squads: list = None,
remnawave_short_uuid: str = None,
subscription_url: str = "",
subscription_crypto_link: str = ""
subscription_crypto_link: str = "",
autopay_enabled: Optional[bool] = None,
autopay_days_before: Optional[int] = None,
) -> Subscription:
if end_date is None:
@@ -1006,7 +1164,17 @@ async def create_subscription(
connected_squads=connected_squads,
remnawave_short_uuid=remnawave_short_uuid,
subscription_url=subscription_url,
subscription_crypto_link=subscription_crypto_link
subscription_crypto_link=subscription_crypto_link,
autopay_enabled=(
settings.is_autopay_enabled_by_default()
if autopay_enabled is None
else autopay_enabled
),
autopay_days_before=(
settings.DEFAULT_AUTOPAY_DAYS_BEFORE
if autopay_days_before is None
else autopay_days_before
),
)
db.add(subscription)
+304 -38
View File
@@ -3,14 +3,16 @@ import secrets
import string
from datetime import datetime, timedelta
from typing import Optional, List, Dict
from sqlalchemy import select, and_, or_, func, case, nullslast
from sqlalchemy import select, and_, or_, func, case, nullslast, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy.exc import IntegrityError
from app.database.models import (
User,
UserStatus,
Subscription,
SubscriptionStatus,
Transaction,
PromoGroup,
PaymentMethod,
@@ -18,6 +20,8 @@ from app.database.models import (
)
from app.config import settings
from app.database.crud.promo_group import get_default_promo_group
from app.database.crud.discount_offer import get_latest_claimed_offer_for_user
from app.database.crud.promo_offer_log import log_promo_offer_action
from app.utils.validators import sanitize_telegram_name
logger = logging.getLogger(__name__)
@@ -35,6 +39,7 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.referrer),
)
.where(User.id == user_id)
)
@@ -52,6 +57,7 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.referrer),
)
.where(User.telegram_id == telegram_id)
)
@@ -85,6 +91,37 @@ async def create_unique_referral_code(db: AsyncSession) -> str:
return f"ref{timestamp}"
async def _sync_users_sequence(db: AsyncSession) -> None:
"""Ensure the users.id sequence matches the current max ID."""
await db.execute(
text(
"SELECT setval('users_id_seq', "
"COALESCE((SELECT MAX(id) FROM users), 0) + 1, false)"
)
)
await db.commit()
logger.warning(
"🔄 Последовательность users_id_seq была синхронизирована с текущим максимумом id"
)
async def _get_or_create_default_promo_group(db: AsyncSession) -> PromoGroup:
default_group = await get_default_promo_group(db)
if default_group:
return default_group
default_group = PromoGroup(
name="Базовый юзер",
server_discount_percent=0,
traffic_discount_percent=0,
device_discount_percent=0,
is_default=True,
)
db.add(default_group)
await db.flush()
return default_group
async def create_user(
db: AsyncSession,
telegram_id: int,
@@ -99,46 +136,61 @@ async def create_user(
if not referral_code:
referral_code = await create_unique_referral_code(db)
default_group = await get_default_promo_group(db)
if not default_group:
default_group = PromoGroup(
name="Базовый юзер",
server_discount_percent=0,
traffic_discount_percent=0,
device_discount_percent=0,
is_default=True,
attempts = 3
for attempt in range(1, attempts + 1):
default_group = await _get_or_create_default_promo_group(db)
promo_group_id = default_group.id
safe_first = sanitize_telegram_name(first_name)
safe_last = sanitize_telegram_name(last_name)
user = User(
telegram_id=telegram_id,
username=username,
first_name=safe_first,
last_name=safe_last,
language=language,
referred_by_id=referred_by_id,
referral_code=referral_code,
balance_kopeks=0,
has_had_paid_subscription=False,
has_made_first_topup=False,
promo_group_id=promo_group_id,
)
db.add(default_group)
await db.flush()
promo_group_id = default_group.id
db.add(user)
safe_first = sanitize_telegram_name(first_name)
safe_last = sanitize_telegram_name(last_name)
user = User(
telegram_id=telegram_id,
username=username,
first_name=safe_first,
last_name=safe_last,
language=language,
referred_by_id=referred_by_id,
referral_code=referral_code,
balance_kopeks=0,
has_had_paid_subscription=False,
has_made_first_topup=False,
promo_group_id=promo_group_id,
)
db.add(user)
await db.commit()
await db.refresh(user)
if default_group:
user.promo_group = default_group
try:
await db.commit()
await db.refresh(user)
logger.info(f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}")
user.promo_group = default_group
logger.info(
f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}"
)
return user
return user
except IntegrityError as exc:
await db.rollback()
if (
isinstance(getattr(exc, "orig", None), Exception)
and "users_pkey" in str(exc.orig)
and attempt < attempts
):
logger.warning(
"⚠️ Обнаружено несоответствие последовательности users_id_seq при создании пользователя %s. "
"Выполняем повторную синхронизацию (попытка %s/%s)",
telegram_id,
attempt,
attempts,
)
await _sync_users_sequence(db)
continue
raise
raise RuntimeError("Не удалось создать пользователя после синхронизации последовательности")
async def update_user(
@@ -225,6 +277,8 @@ async def subtract_user_balance(
description: str,
create_transaction: bool = False,
payment_method: Optional[PaymentMethod] = None,
*,
consume_promo_offer: bool = False,
) -> bool:
logger.error(f"💸 ОТЛАДКА subtract_user_balance:")
logger.error(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
@@ -232,15 +286,57 @@ async def subtract_user_balance(
logger.error(f" 💸 Сумма к списанию: {amount_kopeks} копеек")
logger.error(f" 📝 Описание: {description}")
log_context: Optional[Dict[str, object]] = None
if consume_promo_offer:
try:
current_percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
except (TypeError, ValueError):
current_percent = 0
if current_percent > 0:
source = getattr(user, "promo_offer_discount_source", None)
log_context = {
"offer_id": None,
"percent": current_percent,
"source": source,
"effect_type": None,
"details": {
"reason": "manual_charge",
"description": description,
"amount_kopeks": amount_kopeks,
},
}
try:
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
except Exception as lookup_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to fetch latest claimed promo offer for user %s: %s",
user.id,
lookup_error,
)
offer = None
if offer:
log_context["offer_id"] = offer.id
log_context["effect_type"] = offer.effect_type
if not log_context["percent"] and offer.discount_percent:
log_context["percent"] = offer.discount_percent
if user.balance_kopeks < amount_kopeks:
logger.error(f" ❌ НЕДОСТАТОЧНО СРЕДСТВ!")
return False
try:
old_balance = user.balance_kopeks
user.balance_kopeks -= amount_kopeks
if consume_promo_offer 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
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
@@ -258,6 +354,32 @@ async def subtract_user_balance(
payment_method=payment_method,
)
if consume_promo_offer and log_context:
try:
await log_promo_offer_action(
db,
user_id=user.id,
offer_id=log_context.get("offer_id"),
action="consumed",
source=log_context.get("source"),
percent=log_context.get("percent"),
effect_type=log_context.get("effect_type"),
details=log_context.get("details"),
)
except Exception as log_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to record promo offer consumption log for user %s: %s",
user.id,
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: %s",
rollback_error,
)
logger.error(f" ✅ Средства списаны: {old_balance}{user.balance_kopeks}")
return True
@@ -267,6 +389,97 @@ async def subtract_user_balance(
return False
async def cleanup_expired_promo_offer_discounts(db: AsyncSession) -> int:
now = datetime.utcnow()
result = await db.execute(
select(User).where(
User.promo_offer_discount_percent > 0,
User.promo_offer_discount_expires_at.isnot(None),
User.promo_offer_discount_expires_at <= now,
)
)
users = result.scalars().all()
if not users:
return 0
log_payloads: List[Dict[str, object]] = []
for user in users:
try:
percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
except (TypeError, ValueError):
percent = 0
source = getattr(user, "promo_offer_discount_source", None)
offer_id = None
effect_type = None
if source:
try:
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
except Exception as lookup_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to fetch latest claimed promo offer for user %s during expiration cleanup: %s",
user.id,
lookup_error,
)
offer = None
if offer:
offer_id = offer.id
effect_type = offer.effect_type
if not percent and offer.discount_percent:
percent = offer.discount_percent
log_payloads.append(
{
"user_id": user.id,
"offer_id": offer_id,
"source": source,
"percent": percent,
"effect_type": effect_type,
}
)
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
user.updated_at = now
await db.commit()
for payload in log_payloads:
user_id = payload.get("user_id")
if not user_id:
continue
try:
await log_promo_offer_action(
db,
user_id=user_id,
offer_id=payload.get("offer_id"),
action="disabled",
source=payload.get("source"),
percent=payload.get("percent"),
effect_type=payload.get("effect_type"),
details={"reason": "offer_expired"},
)
except Exception as log_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to log promo offer expiration for user %s: %s",
user_id,
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 expiration log failure: %s",
rollback_error,
)
return len(users)
async def get_users_list(
db: AsyncSession,
offset: int = 0,
@@ -468,6 +681,59 @@ async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
return result.scalars().all()
async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[User]:
now = datetime.utcnow()
base_query = (
select(User)
.options(selectinload(User.subscription))
.where(User.status == UserStatus.ACTIVE.value)
)
if segment == "no_subscription":
query = (
base_query.outerjoin(Subscription, Subscription.user_id == User.id)
.where(Subscription.id.is_(None))
)
else:
query = base_query.join(Subscription)
if segment == "paid_active":
query = query.where(
Subscription.is_trial == False, # noqa: E712
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > now,
)
elif segment == "paid_expired":
query = query.where(
Subscription.is_trial == False, # noqa: E712
or_(
Subscription.status == SubscriptionStatus.EXPIRED.value,
Subscription.end_date <= now,
),
)
elif segment == "trial_active":
query = query.where(
Subscription.is_trial == True, # noqa: E712
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > now,
)
elif segment == "trial_expired":
query = query.where(
Subscription.is_trial == True, # noqa: E712
or_(
Subscription.status == SubscriptionStatus.EXPIRED.value,
Subscription.end_date <= now,
),
)
else:
logger.warning("Неизвестный сегмент для промо: %s", segment)
return []
result = await db.execute(query.order_by(User.id))
return result.scalars().unique().all()
async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
threshold_date = datetime.utcnow() - timedelta(days=months * 30)
+168
View File
@@ -0,0 +1,168 @@
"""CRUD helpers for WATA payment records."""
import logging
from datetime import datetime
from typing import Any, Dict, Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import WataPayment
logger = logging.getLogger(__name__)
async def create_wata_payment(
db: AsyncSession,
*,
user_id: int,
payment_link_id: str,
amount_kopeks: int,
currency: str,
description: Optional[str],
status: str,
type_: Optional[str],
url: Optional[str],
order_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
expires_at: Optional[datetime] = None,
terminal_public_id: Optional[str] = None,
success_redirect_url: Optional[str] = None,
fail_redirect_url: Optional[str] = None,
) -> WataPayment:
payment = WataPayment(
user_id=user_id,
payment_link_id=payment_link_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
status=status,
type=type_,
url=url,
metadata_json=metadata or {},
expires_at=expires_at,
terminal_public_id=terminal_public_id,
success_redirect_url=success_redirect_url,
fail_redirect_url=fail_redirect_url,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(
"Создан Wata платеж #%s для пользователя %s: %s копеек (статус %s)",
payment.id,
user_id,
amount_kopeks,
status,
)
return payment
async def get_wata_payment_by_id(
db: AsyncSession,
payment_id: int,
) -> Optional[WataPayment]:
result = await db.execute(
select(WataPayment).where(WataPayment.id == payment_id)
)
return result.scalar_one_or_none()
async def get_wata_payment_by_link_id(
db: AsyncSession,
payment_link_id: str,
) -> Optional[WataPayment]:
result = await db.execute(
select(WataPayment).where(WataPayment.payment_link_id == payment_link_id)
)
return result.scalar_one_or_none()
async def get_wata_payment_by_order_id(
db: AsyncSession,
order_id: str,
) -> Optional[WataPayment]:
result = await db.execute(
select(WataPayment).where(WataPayment.order_id == order_id)
)
return result.scalar_one_or_none()
async def update_wata_payment_status(
db: AsyncSession,
payment: WataPayment,
*,
status: Optional[str] = None,
is_paid: Optional[bool] = None,
paid_at: Optional[datetime] = None,
last_status: Optional[str] = None,
url: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
callback_payload: Optional[Dict[str, Any]] = None,
terminal_public_id: Optional[str] = None,
) -> WataPayment:
update_values: Dict[str, Any] = {}
if status is not None:
update_values["status"] = status
if is_paid is not None:
update_values["is_paid"] = is_paid
if paid_at is not None:
update_values["paid_at"] = paid_at
if last_status is not None:
update_values["last_status"] = last_status
if url is not None:
update_values["url"] = url
if metadata is not None:
update_values["metadata_json"] = metadata
if callback_payload is not None:
update_values["callback_payload"] = callback_payload
if terminal_public_id is not None:
update_values["terminal_public_id"] = terminal_public_id
if not update_values:
return payment
await db.execute(
update(WataPayment)
.where(WataPayment.id == payment.id)
.values(**update_values)
)
await db.commit()
await db.refresh(payment)
logger.info(
"Обновлен Wata платеж %s: статус=%s, is_paid=%s",
payment.payment_link_id,
payment.status,
payment.is_paid,
)
return payment
async def link_wata_payment_to_transaction(
db: AsyncSession,
payment: WataPayment,
transaction_id: int,
) -> WataPayment:
await db.execute(
update(WataPayment)
.where(WataPayment.id == payment.id)
.values(transaction_id=transaction_id)
)
await db.commit()
await db.refresh(payment)
logger.info(
"Wata платеж %s привязан к транзакции %s",
payment.payment_link_id,
transaction_id,
)
return payment
+217 -4
View File
@@ -77,8 +77,20 @@ class PaymentMethod(Enum):
CRYPTOBOT = "cryptobot"
MULENPAY = "mulenpay"
PAL24 = "pal24"
WATA = "wata"
MANUAL = "manual"
class MainMenuButtonActionType(Enum):
URL = "url"
MINI_APP = "mini_app"
class MainMenuButtonVisibility(Enum):
ALL = "all"
ADMINS = "admins"
SUBSCRIBERS = "subscribers"
class YooKassaPayment(Base):
__tablename__ = "yookassa_payments"
@@ -282,6 +294,56 @@ class Pal24Payment(Base):
)
class WataPayment(Base):
__tablename__ = "wata_payments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
payment_link_id = Column(String(64), unique=True, nullable=False, index=True)
order_id = Column(String(255), nullable=True, index=True)
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default="RUB")
description = Column(Text, nullable=True)
type = Column(String(50), nullable=True)
status = Column(String(50), nullable=False, default="Opened")
is_paid = Column(Boolean, default=False)
paid_at = Column(DateTime, nullable=True)
last_status = Column(String(50), nullable=True)
terminal_public_id = Column(String(64), nullable=True)
url = Column(Text, nullable=True)
success_redirect_url = Column(Text, nullable=True)
fail_redirect_url = Column(Text, nullable=True)
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
expires_at = Column(DateTime, nullable=True)
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
user = relationship("User", backref="wata_payments")
transaction = relationship("Transaction", backref="wata_payment")
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
def __repr__(self) -> str: # pragma: no cover - debug helper
return (
"<WataPayment(id={0}, link_id={1}, amount={2}₽, status={3})>".format(
self.id,
self.payment_link_id,
self.amount_rubles,
self.status,
)
)
class PromoGroup(Base):
__tablename__ = "promo_groups"
@@ -386,9 +448,13 @@ class User(Base):
transactions = relationship("Transaction", back_populates="user")
referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user")
discount_offers = relationship("DiscountOffer", back_populates="user")
promo_offer_logs = relationship("PromoOfferLog", back_populates="user")
lifetime_used_traffic_bytes = Column(BigInteger, default=0)
auto_promo_group_assigned = Column(Boolean, nullable=False, default=False)
auto_promo_group_threshold_kopeks = Column(BigInteger, nullable=False, default=0)
promo_offer_discount_percent = Column(Integer, nullable=False, default=0)
promo_offer_discount_source = Column(String(100), nullable=True)
promo_offer_discount_expires_at = Column(DateTime, nullable=True)
last_remnawave_sync = Column(DateTime, nullable=True)
trojan_password = Column(String(255), nullable=True)
vless_uuid = Column(String(255), nullable=True)
@@ -453,6 +519,7 @@ class Subscription(Base):
user = relationship("User", back_populates="subscription")
discount_offers = relationship("DiscountOffer", back_populates="subscription")
temporary_accesses = relationship("SubscriptionTemporaryAccess", back_populates="subscription")
@property
def is_active(self) -> bool:
@@ -739,9 +806,9 @@ class Squad(Base):
class ServiceRule(Base):
__tablename__ = "service_rules"
id = Column(Integer, primary_key=True, index=True)
order = Column(Integer, default=0)
title = Column(String(255), nullable=False)
@@ -755,9 +822,54 @@ class ServiceRule(Base):
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
class PrivacyPolicy(Base):
__tablename__ = "privacy_policies"
id = Column(Integer, primary_key=True, index=True)
language = Column(String(10), nullable=False, unique=True)
content = Column(Text, nullable=False)
is_enabled = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
class PublicOffer(Base):
__tablename__ = "public_offers"
id = Column(Integer, primary_key=True, index=True)
language = Column(String(10), nullable=False, unique=True)
content = Column(Text, nullable=False)
is_enabled = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
class FaqSetting(Base):
__tablename__ = "faq_settings"
id = Column(Integer, primary_key=True, index=True)
language = Column(String(10), nullable=False, unique=True)
is_enabled = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
class FaqPage(Base):
__tablename__ = "faq_pages"
id = Column(Integer, primary_key=True, index=True)
language = Column(String(10), nullable=False, index=True)
title = Column(String(255), nullable=False)
content = Column(Text, nullable=False)
display_order = Column(Integer, default=0, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
class SystemSetting(Base):
__tablename__ = "system_settings"
id = Column(Integer, primary_key=True, index=True)
key = Column(String(255), unique=True, nullable=False)
value = Column(Text, nullable=True)
@@ -811,11 +923,73 @@ class DiscountOffer(Base):
expires_at = Column(DateTime, nullable=False)
claimed_at = Column(DateTime, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
effect_type = Column(String(50), nullable=False, default="percent_discount")
extra_data = Column(JSON, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
user = relationship("User", back_populates="discount_offers")
subscription = relationship("Subscription", back_populates="discount_offers")
logs = relationship("PromoOfferLog", back_populates="offer")
class PromoOfferTemplate(Base):
__tablename__ = "promo_offer_templates"
__table_args__ = (
Index("ix_promo_offer_templates_type", "offer_type"),
)
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
offer_type = Column(String(50), nullable=False)
message_text = Column(Text, nullable=False)
button_text = Column(String(255), nullable=False)
valid_hours = Column(Integer, nullable=False, default=24)
discount_percent = Column(Integer, nullable=False, default=0)
bonus_amount_kopeks = Column(Integer, nullable=False, default=0)
active_discount_hours = Column(Integer, nullable=True)
test_duration_hours = Column(Integer, nullable=True)
test_squad_uuids = Column(JSON, default=list)
is_active = Column(Boolean, default=True, nullable=False)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
creator = relationship("User")
class SubscriptionTemporaryAccess(Base):
__tablename__ = "subscription_temporary_access"
id = Column(Integer, primary_key=True, index=True)
subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="CASCADE"), nullable=False)
offer_id = Column(Integer, ForeignKey("discount_offers.id", ondelete="CASCADE"), nullable=False)
squad_uuid = Column(String(255), nullable=False)
expires_at = Column(DateTime, nullable=False)
created_at = Column(DateTime, default=func.now())
deactivated_at = Column(DateTime, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
was_already_connected = Column(Boolean, default=False, nullable=False)
subscription = relationship("Subscription", back_populates="temporary_accesses")
offer = relationship("DiscountOffer")
class PromoOfferLog(Base):
__tablename__ = "promo_offer_logs"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
offer_id = Column(Integer, ForeignKey("discount_offers.id", ondelete="SET NULL"), nullable=True, index=True)
action = Column(String(50), nullable=False)
source = Column(String(100), nullable=True)
percent = Column(Integer, nullable=True)
effect_type = Column(String(50), nullable=True)
details = Column(JSON, nullable=True)
created_at = Column(DateTime, default=func.now())
user = relationship("User", back_populates="promo_offer_logs")
offer = relationship("DiscountOffer", back_populates="logs")
class BroadcastHistory(Base):
__tablename__ = "broadcast_history"
@@ -851,6 +1025,7 @@ class ServerSquad(Base):
country_code = Column(String(5), nullable=True)
is_available = Column(Boolean, default=True)
is_trial_eligible = Column(Boolean, default=False, nullable=False)
price_kopeks = Column(Integer, default=0)
@@ -1140,4 +1315,42 @@ class WebApiToken(Base):
def __repr__(self) -> str:
status = "active" if self.is_active else "revoked"
return f"<WebApiToken id={self.id} name='{self.name}' status={status}>"
return f"<WebApiToken id={self.id} name='{self.name}' status={status}>"
class MainMenuButton(Base):
__tablename__ = "main_menu_buttons"
id = Column(Integer, primary_key=True, index=True)
text = Column(String(64), nullable=False)
action_type = Column(String(20), nullable=False)
action_value = Column(Text, nullable=False)
visibility = Column(String(20), nullable=False, default=MainMenuButtonVisibility.ALL.value)
is_active = Column(Boolean, nullable=False, default=True)
display_order = Column(Integer, nullable=False, default=0)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
__table_args__ = (
Index("ix_main_menu_buttons_order", "display_order", "id"),
)
@property
def action_type_enum(self) -> MainMenuButtonActionType:
try:
return MainMenuButtonActionType(self.action_type)
except ValueError:
return MainMenuButtonActionType.URL
@property
def visibility_enum(self) -> MainMenuButtonVisibility:
try:
return MainMenuButtonVisibility(self.visibility)
except ValueError:
return MainMenuButtonVisibility.ALL
def __repr__(self) -> str:
return (
f"<MainMenuButton id={self.id} text='{self.text}' "
f"action={self.action_type} visibility={self.visibility} active={self.is_active}>"
)
File diff suppressed because it is too large Load Diff
+12 -10
View File
@@ -1,4 +1,5 @@
import logging
from decimal import Decimal, ROUND_HALF_UP
from typing import Optional, Dict, Any
from aiogram import Bot
from aiogram.types import LabeledPrice, InlineKeyboardMarkup, InlineKeyboardButton
@@ -18,8 +19,9 @@ class TelegramStarsService:
return settings.rubles_to_stars(rubles)
@staticmethod
def calculate_rubles_from_stars(stars: int) -> float:
return settings.stars_to_rubles(stars)
def calculate_rubles_from_stars(stars: int) -> Decimal:
rate = Decimal(str(settings.get_stars_rate()))
return (Decimal(stars) * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
async def create_invoice(
self,
@@ -31,10 +33,10 @@ class TelegramStarsService:
start_parameter: Optional[str] = None
) -> Optional[str]:
try:
amount_rubles = amount_kopeks / 100
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
amount_rubles = Decimal(amount_kopeks) / Decimal(100)
stars_amount = self.calculate_stars_from_rubles(float(amount_rubles))
stars_rate = settings.get_stars_rate()
invoice_link = await self.bot.create_invoice_link(
title=title,
description=description,
@@ -46,7 +48,7 @@ class TelegramStarsService:
)
logger.info(
f"Создан Stars invoice на {stars_amount} звезд (~{int(amount_rubles)}) "
f"Создан Stars invoice на {stars_amount} звезд (~{settings.format_price(amount_kopeks)}) "
f"для {chat_id}, курс: {stars_rate}₽/⭐"
)
return invoice_link
@@ -65,8 +67,8 @@ class TelegramStarsService:
keyboard: Optional[InlineKeyboardMarkup] = None
) -> Optional[Dict[str, Any]]:
try:
amount_rubles = amount_kopeks / 100
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
amount_rubles = Decimal(amount_kopeks) / Decimal(100)
stars_amount = self.calculate_stars_from_rubles(float(amount_rubles))
stars_rate = settings.get_stars_rate()
message = await self.bot.send_invoice(
@@ -82,12 +84,12 @@ class TelegramStarsService:
logger.info(
f"Отправлен Stars invoice {message.message_id} на {stars_amount} звезд "
f"(~{int(amount_rubles)}), курс: {stars_rate}₽/⭐"
f"(~{settings.format_price(amount_kopeks)}), курс: {stars_rate}₽/⭐"
)
return {
"message_id": message.message_id,
"stars_amount": stars_amount,
"rubles_amount": amount_rubles,
"rubles_amount": float(amount_rubles),
"payload": payload
}
+60 -19
View File
@@ -66,20 +66,37 @@ class WebhookServer:
self.site = web.TCPSite(
self.runner,
host='0.0.0.0',
host=settings.TRIBUTE_WEBHOOK_HOST,
port=settings.TRIBUTE_WEBHOOK_PORT
)
await self.site.start()
logger.info(f"Webhook сервер запущен на порту {settings.TRIBUTE_WEBHOOK_PORT}")
logger.info(f"Tribute webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
logger.info(
"Webhook сервер запущен на %s:%s",
settings.TRIBUTE_WEBHOOK_HOST,
settings.TRIBUTE_WEBHOOK_PORT,
)
logger.info(
"Tribute webhook URL: http://%s:%s%s",
settings.TRIBUTE_WEBHOOK_HOST,
settings.TRIBUTE_WEBHOOK_PORT,
settings.TRIBUTE_WEBHOOK_PATH,
)
if settings.is_mulenpay_enabled():
logger.info(
f"Mulen Pay webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.MULENPAY_WEBHOOK_PATH}"
"Mulen Pay webhook URL: http://%s:%s%s",
settings.TRIBUTE_WEBHOOK_HOST,
settings.TRIBUTE_WEBHOOK_PORT,
settings.MULENPAY_WEBHOOK_PATH,
)
if settings.is_cryptobot_enabled():
logger.info(f"CryptoBot webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.CRYPTOBOT_WEBHOOK_PATH}")
logger.info(
"CryptoBot webhook URL: http://%s:%s%s",
settings.TRIBUTE_WEBHOOK_HOST,
settings.TRIBUTE_WEBHOOK_PORT,
settings.CRYPTOBOT_WEBHOOK_PATH,
)
except Exception as e:
logger.error(f"Ошибка запуска webhook сервера: {e}")
@@ -112,14 +129,18 @@ class WebhookServer:
async def _mulenpay_webhook_handler(self, request: web.Request) -> web.Response:
try:
logger.info(f"Получен Mulen Pay webhook: {request.method} {request.path}")
logger.info(f"MulenPay webhook headers: {dict(request.headers)}")
raw_body = await request.read()
if not raw_body:
logger.warning("Пустой Mulen Pay webhook")
return web.json_response({"status": "error", "reason": "empty_body"}, status=400)
# Временно отключаем проверку подписи для отладки
# TODO: Включить обратно после настройки MulenPay
if not self._verify_mulenpay_signature(request, raw_body):
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
logger.warning("MulenPay webhook signature verification failed, but processing anyway for debugging")
# return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
try:
payload = json.loads(raw_body.decode('utf-8'))
@@ -129,17 +150,23 @@ class WebhookServer:
payment_service = PaymentService(self.bot)
async for db in get_db():
# Получаем соединение с БД
db_generator = get_db()
db = await db_generator.__anext__()
try:
success = await payment_service.process_mulenpay_callback(db, payload)
if success:
return web.json_response({"status": "ok"}, status=200)
return web.json_response({"status": "error", "reason": "processing_failed"}, status=400)
except Exception as error:
logger.error(f"Ошибка обработки Mulen Pay webhook: {error}", exc_info=True)
return web.json_response({"status": "error", "reason": "internal_error"}, status=500)
finally:
try:
success = await payment_service.process_mulenpay_callback(db, payload)
if success:
return web.json_response({"status": "ok"}, status=200)
return web.json_response({"status": "error", "reason": "processing_failed"}, status=400)
except Exception as error:
logger.error(f"Ошибка обработки Mulen Pay webhook: {error}", exc_info=True)
return web.json_response({"status": "error", "reason": "internal_error"}, status=500)
finally:
break
await db_generator.__anext__()
except StopAsyncIteration:
pass
except Exception as error:
logger.error(f"Критическая ошибка Mulen Pay webhook: {error}", exc_info=True)
@@ -160,6 +187,12 @@ class WebhookServer:
logger.error("Mulen Pay secret key is not configured")
return False
# Логируем все заголовки для отладки
logger.info("MulenPay webhook headers for signature verification:")
for header_name, header_value in request.headers.items():
if any(keyword in header_name.lower() for keyword in ['signature', 'sign', 'token', 'auth']):
logger.info(f" {header_name}: {header_value}")
signature = WebhookServer._extract_mulenpay_header(
request,
(
@@ -171,6 +204,14 @@ class WebhookServer:
'X-MULENPAY-WEBHOOK-SIGNATURE',
'X-Signature',
'Signature',
'X-MulenPay-Sign',
'X-Mulenpay-Sign',
'X-MULENPAY-SIGN',
'MulenPay-Signature',
'Mulenpay-Signature',
'MULENPAY-SIGNATURE',
'signature',
'sign',
)
)
if signature:
@@ -228,7 +269,7 @@ class WebhookServer:
if fallback_token and hmac.compare_digest(fallback_token, secret_key):
return True
logger.debug(
logger.info(
"Mulen Pay webhook headers received: %s",
{key: value for key, value in request.headers.items() if 'authorization' not in key.lower()}
)
+13 -4
View File
@@ -218,15 +218,24 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None
await runner.setup()
site = web.TCPSite(
runner,
host='0.0.0.0',
runner,
host=settings.YOOKASSA_WEBHOOK_HOST,
port=settings.YOOKASSA_WEBHOOK_PORT
)
await site.start()
logger.info(f"✅ YooKassa webhook сервер запущен на порту {settings.YOOKASSA_WEBHOOK_PORT}")
logger.info(f"🎯 YooKassa webhook URL: http://0.0.0.0:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}")
logger.info(
" YooKassa webhook сервер запущен на %s:%s",
settings.YOOKASSA_WEBHOOK_HOST,
settings.YOOKASSA_WEBHOOK_PORT,
)
logger.info(
"🎯 YooKassa webhook URL: http://%s:%s%s",
settings.YOOKASSA_WEBHOOK_HOST,
settings.YOOKASSA_WEBHOOK_PORT,
settings.YOOKASSA_WEBHOOK_PATH,
)
try:
while True:
+3
View File
@@ -0,0 +1,3 @@
from . import balance
__all__ = ["balance"]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+22 -5
View File
@@ -28,12 +28,21 @@ from app.localization.texts import get_texts
from app.database.crud.user import get_users_list
from app.database.crud.subscription import get_expiring_subscriptions
from app.utils.decorators import admin_required, error_handler
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
logger = logging.getLogger(__name__)
BUTTON_ROWS = BROADCAST_BUTTON_ROWS
DEFAULT_SELECTED_BUTTONS = DEFAULT_BROADCAST_BUTTONS
TEXT_MENU_MINIAPP_BUTTON_KEYS = {
"balance",
"referrals",
"promocode",
"connect",
"subscription",
}
def get_message_buttons_selector_keyboard(language: str = "ru") -> types.InlineKeyboardMarkup:
return get_updated_message_buttons_selector_keyboard(list(DEFAULT_SELECTED_BUTTONS), language)
@@ -54,12 +63,20 @@ def create_broadcast_keyboard(selected_buttons: list, language: str = "ru") -> O
if button_key not in selected_buttons:
continue
button_config = button_config_map[button_key]
row_buttons.append(
types.InlineKeyboardButton(
text=button_config["text"],
callback_data=button_config["callback"]
if settings.is_text_main_menu_mode() and button_key in TEXT_MENU_MINIAPP_BUTTON_KEYS:
row_buttons.append(
build_miniapp_or_callback_button(
text=button_config["text"],
callback_data=button_config["callback"],
)
)
else:
row_buttons.append(
types.InlineKeyboardButton(
text=button_config["text"],
callback_data=button_config["callback"]
)
)
)
if row_buttons:
keyboard.append(row_buttons)
+4 -8
View File
@@ -226,18 +226,16 @@ def _build_notification_preview_message(language: str, notification_type: str):
elif notification_type == "expired_2d":
percent = NotificationSettingsService.get_second_wave_discount_percent()
valid_hours = NotificationSettingsService.get_second_wave_valid_hours()
bonus_amount = settings.PRICE_30_DAYS * percent // 100
template = texts.get(
"SUBSCRIPTION_EXPIRED_SECOND_WAVE",
(
"🔥 <b>Скидка {percent}% на продление</b>\n\n"
"Нажмите «Получить скидку», и мы начислим {bonus} на баланс. "
"Предложение действует до {expires_at}."
"Активируйте предложение, чтобы получить дополнительную скидку. "
"Она суммируется с вашей промогруппой и действует до {expires_at}."
),
)
message = template.format(
percent=percent,
bonus=settings.format_price(bonus_amount),
expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"),
trigger_days=3,
)
@@ -273,18 +271,16 @@ def _build_notification_preview_message(language: str, notification_type: str):
percent = NotificationSettingsService.get_third_wave_discount_percent()
valid_hours = NotificationSettingsService.get_third_wave_valid_hours()
trigger_days = NotificationSettingsService.get_third_wave_trigger_days()
bonus_amount = settings.PRICE_30_DAYS * percent // 100
template = texts.get(
"SUBSCRIPTION_EXPIRED_THIRD_WAVE",
(
"🎁 <b>Индивидуальная скидка {percent}%</b>\n\n"
"Прошло {trigger_days} дней без подписки — возвращайтесь, и мы добавим {bonus} на баланс. "
"Скидка действует до {expires_at}."
"Прошло {trigger_days} дней без подписки — возвращайтесь и активируйте дополнительную скидку. "
"Она суммируется с промогруппой и действует до {expires_at}."
),
)
message = template.format(
percent=percent,
bonus=settings.format_price(bonus_amount),
trigger_days=trigger_days,
expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"),
)
File diff suppressed because it is too large Load Diff
+512
View File
@@ -0,0 +1,512 @@
import html
import logging
from datetime import datetime
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.localization.texts import get_texts
from app.services.privacy_policy_service import PrivacyPolicyService
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
from app.utils.validators import validate_html_tags, get_html_help_text
logger = logging.getLogger(__name__)
def _format_timestamp(value: datetime | None) -> str:
if not value:
return ""
try:
return value.strftime("%d.%m.%Y %H:%M")
except Exception:
return ""
async def _build_overview(
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
policy = await PrivacyPolicyService.get_policy(
db,
db_user.language,
fallback=False,
)
normalized_language = PrivacyPolicyService.normalize_language(db_user.language)
has_content = bool(policy and policy.content and policy.content.strip())
description = texts.t(
"ADMIN_PRIVACY_POLICY_DESCRIPTION",
"Политика конфиденциальности отображается в разделе «Инфо».",
)
status_text = texts.t(
"ADMIN_PRIVACY_POLICY_STATUS_DISABLED",
"⚠️ Показ политики выключен или текст отсутствует.",
)
if policy and policy.is_enabled and has_content:
status_text = texts.t(
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED",
"✅ Политика активна и показывается пользователям.",
)
elif policy and policy.is_enabled:
status_text = texts.t(
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY",
"⚠️ Политика включена, но текст пуст — пользователи её не увидят.",
)
updated_at = _format_timestamp(getattr(policy, "updated_at", None))
updated_block = ""
if updated_at:
updated_block = texts.t(
"ADMIN_PRIVACY_POLICY_UPDATED_AT",
"Последнее обновление: {timestamp}",
).format(timestamp=updated_at)
preview_block = texts.t(
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY",
"Текст ещё не задан.",
)
if has_content:
preview_title = texts.t(
"ADMIN_PRIVACY_POLICY_PREVIEW_TITLE",
"<b>Превью текста:</b>",
)
preview_raw = policy.content.strip()
preview_trimmed = preview_raw[:400]
if len(preview_raw) > 400:
preview_trimmed += "..."
preview_block = (
f"{preview_title}\n"
f"<code>{html.escape(preview_trimmed)}</code>"
)
language_block = texts.t(
"ADMIN_PRIVACY_POLICY_LANGUAGE",
"Язык: <code>{lang}</code>",
).format(lang=normalized_language)
header = texts.t(
"ADMIN_PRIVACY_POLICY_HEADER",
"🛡️ <b>Политика конфиденциальности</b>",
)
actions_prompt = texts.t(
"ADMIN_PRIVACY_POLICY_ACTION_PROMPT",
"Выберите действие:",
)
message_parts = [
header,
description,
language_block,
status_text,
]
if updated_block:
message_parts.append(updated_block)
message_parts.append(preview_block)
message_parts.append(actions_prompt)
overview_text = "\n\n".join(part for part in message_parts if part)
buttons: list[list[types.InlineKeyboardButton]] = []
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON",
"✏️ Изменить текст",
),
callback_data="admin_privacy_policy_edit",
)
])
if has_content:
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_VIEW_BUTTON",
"👀 Просмотреть текущий текст",
),
callback_data="admin_privacy_policy_view",
)
])
toggle_text = texts.t(
"ADMIN_PRIVACY_POLICY_ENABLE_BUTTON",
"✅ Включить показ",
)
if policy and policy.is_enabled:
toggle_text = texts.t(
"ADMIN_PRIVACY_POLICY_DISABLE_BUTTON",
"🚫 Отключить показ",
)
buttons.append([
types.InlineKeyboardButton(
text=toggle_text,
callback_data="admin_privacy_policy_toggle",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_HTML_HELP",
"️ HTML помощь",
),
callback_data="admin_privacy_policy_help",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.BACK,
callback_data="admin_submenu_settings",
)
])
return overview_text, types.InlineKeyboardMarkup(inline_keyboard=buttons), policy
@admin_required
@error_handler
async def show_privacy_policy_management(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer()
@admin_required
@error_handler
async def toggle_privacy_policy(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
updated_policy = await PrivacyPolicyService.toggle_enabled(db, db_user.language)
logger.info(
"Админ %s переключил показ политики конфиденциальности: %s",
db_user.telegram_id,
"enabled" if updated_policy.is_enabled else "disabled",
)
status_message = (
texts.t("ADMIN_PRIVACY_POLICY_ENABLED", "✅ Политика включена")
if updated_policy.is_enabled
else texts.t("ADMIN_PRIVACY_POLICY_DISABLED", "🚫 Политика отключена")
)
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer(status_message)
@admin_required
@error_handler
async def start_edit_privacy_policy(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
policy = await PrivacyPolicyService.get_policy(
db,
db_user.language,
fallback=False,
)
current_preview = ""
if policy and policy.content:
preview = policy.content.strip()[:400]
if len(policy.content.strip()) > 400:
preview += "..."
current_preview = (
texts.t(
"ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW",
"Текущий текст (превью):",
)
+ f"\n<code>{html.escape(preview)}</code>\n\n"
)
prompt = texts.t(
"ADMIN_PRIVACY_POLICY_EDIT_PROMPT",
"Отправьте новый текст политики конфиденциальности. Допускается HTML-разметка.",
)
hint = texts.t(
"ADMIN_PRIVACY_POLICY_EDIT_HINT",
"Используйте /html_help для справки по тегам.",
)
message_text = (
f"📝 <b>{texts.t('ADMIN_PRIVACY_POLICY_EDIT_TITLE', 'Редактирование политики')}</b>\n\n"
f"{current_preview}{prompt}\n\n{hint}"
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_HTML_HELP",
"️ HTML помощь",
),
callback_data="admin_privacy_policy_help",
)
],
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_PRIVACY_POLICY_CANCEL", "❌ Отмена"),
callback_data="admin_privacy_policy_cancel",
)
],
]
)
await callback.message.edit_text(message_text, reply_markup=keyboard)
await state.set_state(AdminStates.editing_privacy_policy)
await callback.answer()
@admin_required
@error_handler
async def cancel_edit_privacy_policy(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
await state.clear()
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer()
@admin_required
@error_handler
async def process_privacy_policy_edit(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
new_text = message.text or ""
if len(new_text) > 4000:
await message.answer(
texts.t(
"ADMIN_PRIVACY_POLICY_TOO_LONG",
"❌ Текст политики слишком длинный. Максимум 4000 символов.",
)
)
return
is_valid, error_message = validate_html_tags(new_text)
if not is_valid:
await message.answer(
texts.t(
"ADMIN_PRIVACY_POLICY_HTML_ERROR",
"❌ Ошибка в HTML: {error}",
).format(error=error_message)
)
return
await PrivacyPolicyService.save_policy(db, db_user.language, new_text)
logger.info(
"Админ %s обновил текст политики конфиденциальности (%d символов)",
db_user.telegram_id,
len(new_text),
)
await state.clear()
success_text = texts.t(
"ADMIN_PRIVACY_POLICY_SAVED",
"✅ Политика конфиденциальности обновлена.",
)
reply_markup = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_BACK_BUTTON",
"⬅️ К настройкам политики",
),
callback_data="admin_privacy_policy",
)
]
]
)
await message.answer(success_text, reply_markup=reply_markup)
@admin_required
@error_handler
async def view_privacy_policy(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
policy = await PrivacyPolicyService.get_policy(
db,
db_user.language,
fallback=False,
)
if not policy or not policy.content or not policy.content.strip():
await callback.answer(
texts.t(
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT",
"Текст политики пока не задан.",
),
show_alert=True,
)
return
content = policy.content.strip()
truncated = False
max_length = 3800
if len(content) > max_length:
content = content[: max_length - 3] + "..."
truncated = True
header = texts.t(
"ADMIN_PRIVACY_POLICY_VIEW_TITLE",
"👀 <b>Текущий текст политики</b>",
)
note = ""
if truncated:
note = texts.t(
"ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED",
"\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_BACK_BUTTON",
"⬅️ К настройкам политики",
),
callback_data="admin_privacy_policy",
)
],
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON",
"✏️ Изменить текст",
),
callback_data="admin_privacy_policy_edit",
)
],
]
)
await callback.message.edit_text(
f"{header}\n\n{content}{note}",
reply_markup=keyboard,
)
await callback.answer()
@admin_required
@error_handler
async def show_privacy_policy_html_help(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
help_text = get_html_help_text()
current_state = await state.get_state()
buttons: list[list[types.InlineKeyboardButton]] = []
if current_state == AdminStates.editing_privacy_policy.state:
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT",
"⬅️ Назад к редактированию",
),
callback_data="admin_privacy_policy_edit",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_BACK_BUTTON",
"⬅️ К настройкам политики",
),
callback_data="admin_privacy_policy",
)
])
await callback.message.edit_text(
help_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
)
await callback.answer()
def register_handlers(dp: Dispatcher) -> None:
dp.callback_query.register(
show_privacy_policy_management,
F.data == "admin_privacy_policy",
)
dp.callback_query.register(
toggle_privacy_policy,
F.data == "admin_privacy_policy_toggle",
)
dp.callback_query.register(
start_edit_privacy_policy,
F.data == "admin_privacy_policy_edit",
)
dp.callback_query.register(
cancel_edit_privacy_policy,
F.data == "admin_privacy_policy_cancel",
)
dp.callback_query.register(
view_privacy_policy,
F.data == "admin_privacy_policy_view",
)
dp.callback_query.register(
show_privacy_policy_html_help,
F.data == "admin_privacy_policy_help",
)
dp.message.register(
process_privacy_policy_edit,
AdminStates.editing_privacy_policy,
)
File diff suppressed because it is too large Load Diff
+530
View File
@@ -0,0 +1,530 @@
import html
import logging
from datetime import datetime
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.localization.texts import get_texts
from app.services.public_offer_service import PublicOfferService
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
from app.utils.validators import validate_html_tags, get_html_help_text
logger = logging.getLogger(__name__)
def _format_timestamp(value: datetime | None) -> str:
if not value:
return ""
try:
return value.strftime("%d.%m.%Y %H:%M")
except Exception:
return ""
async def _build_overview(
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
offer = await PublicOfferService.get_offer(
db,
db_user.language,
fallback=False,
)
normalized_language = PublicOfferService.normalize_language(db_user.language)
has_content = bool(offer and offer.content and offer.content.strip())
description = texts.t(
"ADMIN_PUBLIC_OFFER_DESCRIPTION",
"Публичная оферта отображается в разделе «Инфо».",
)
status_text = texts.t(
"ADMIN_PUBLIC_OFFER_STATUS_DISABLED",
"⚠️ Показ оферты выключен или текст отсутствует.",
)
if offer and offer.is_enabled and has_content:
status_text = texts.t(
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED",
"✅ Оферта активна и показывается пользователям.",
)
elif offer and offer.is_enabled:
status_text = texts.t(
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY",
"⚠️ Оферта включена, но текст пуст — пользователи её не увидят.",
)
updated_at = _format_timestamp(getattr(offer, "updated_at", None))
updated_block = ""
if updated_at:
updated_block = texts.t(
"ADMIN_PUBLIC_OFFER_UPDATED_AT",
"Последнее обновление: {timestamp}",
).format(timestamp=updated_at)
preview_block = texts.t(
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY",
"Текст ещё не задан.",
)
if has_content:
preview_title = texts.t(
"ADMIN_PUBLIC_OFFER_PREVIEW_TITLE",
"<b>Превью текста:</b>",
)
preview_raw = offer.content.strip()
preview_trimmed = preview_raw[:400]
if len(preview_raw) > 400:
preview_trimmed += "..."
preview_block = (
f"{preview_title}\n"
f"<code>{html.escape(preview_trimmed)}</code>"
)
language_block = texts.t(
"ADMIN_PUBLIC_OFFER_LANGUAGE",
"Язык: <code>{lang}</code>",
).format(lang=normalized_language)
header = texts.t(
"ADMIN_PUBLIC_OFFER_HEADER",
"📄 <b>Публичная оферта</b>",
)
actions_prompt = texts.t(
"ADMIN_PUBLIC_OFFER_ACTION_PROMPT",
"Выберите действие:",
)
message_parts = [
header,
description,
language_block,
status_text,
]
if updated_block:
message_parts.append(updated_block)
message_parts.append(preview_block)
message_parts.append(actions_prompt)
overview_text = "\n\n".join(part for part in message_parts if part)
buttons: list[list[types.InlineKeyboardButton]] = []
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON",
"✏️ Изменить текст",
),
callback_data="admin_public_offer_edit",
)
])
if has_content:
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_VIEW_BUTTON",
"👀 Просмотреть текущий текст",
),
callback_data="admin_public_offer_view",
)
])
toggle_text = texts.t(
"ADMIN_PUBLIC_OFFER_ENABLE_BUTTON",
"✅ Включить показ",
)
if offer and offer.is_enabled:
toggle_text = texts.t(
"ADMIN_PUBLIC_OFFER_DISABLE_BUTTON",
"🚫 Отключить показ",
)
buttons.append([
types.InlineKeyboardButton(
text=toggle_text,
callback_data="admin_public_offer_toggle",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_HTML_HELP",
"️ HTML помощь",
),
callback_data="admin_public_offer_help",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.BACK,
callback_data="admin_submenu_settings",
)
])
return overview_text, types.InlineKeyboardMarkup(inline_keyboard=buttons), offer
@admin_required
@error_handler
async def show_public_offer_management(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer()
@admin_required
@error_handler
async def toggle_public_offer(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
updated_offer = await PublicOfferService.toggle_enabled(db, db_user.language)
logger.info(
"Админ %s переключил показ публичной оферты: %s",
db_user.telegram_id,
"enabled" if updated_offer.is_enabled else "disabled",
)
status_message = (
texts.t("ADMIN_PUBLIC_OFFER_ENABLED", "✅ Оферта включена")
if updated_offer.is_enabled
else texts.t("ADMIN_PUBLIC_OFFER_DISABLED", "🚫 Оферта отключена")
)
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer(status_message)
@admin_required
@error_handler
async def start_edit_public_offer(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
offer = await PublicOfferService.get_offer(
db,
db_user.language,
fallback=False,
)
current_preview = ""
if offer and offer.content:
preview = offer.content.strip()[:400]
if len(offer.content.strip()) > 400:
preview += "..."
current_preview = (
texts.t(
"ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW",
"Текущий текст (превью):",
)
+ f"\n<code>{html.escape(preview)}</code>\n\n"
)
prompt = texts.t(
"ADMIN_PUBLIC_OFFER_EDIT_PROMPT",
"Отправьте новый текст публичной оферты. Допускается HTML-разметка.",
)
hint = texts.t(
"ADMIN_PUBLIC_OFFER_EDIT_HINT",
"Используйте /html_help для справки по тегам.",
)
message_text = (
f"📝 <b>{texts.t('ADMIN_PUBLIC_OFFER_EDIT_TITLE', 'Редактирование оферты')}</b>\n\n"
f"{current_preview}{prompt}\n\n{hint}"
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_HTML_HELP",
"️ HTML помощь",
),
callback_data="admin_public_offer_help",
)
],
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_PUBLIC_OFFER_CANCEL", "❌ Отмена"),
callback_data="admin_public_offer_cancel",
)
],
]
)
await callback.message.edit_text(message_text, reply_markup=keyboard)
await state.set_state(AdminStates.editing_public_offer)
await callback.answer()
@admin_required
@error_handler
async def cancel_edit_public_offer(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
await state.clear()
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer(
get_texts(db_user.language).t(
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED",
"Редактирование оферты отменено.",
)
)
@admin_required
@error_handler
async def process_public_offer_edit(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
new_text = message.text or ""
if len(new_text) > 4000:
await message.answer(
texts.t(
"ADMIN_PUBLIC_OFFER_TOO_LONG",
"❌ Текст оферты слишком длинный. Максимум 4000 символов.",
)
)
return
is_valid, error_message = validate_html_tags(new_text)
if not is_valid:
await message.answer(
texts.t(
"ADMIN_PUBLIC_OFFER_HTML_ERROR",
"❌ Ошибка в HTML: {error}",
).format(error=error_message)
)
return
await PublicOfferService.save_offer(db, db_user.language, new_text)
logger.info(
"Админ %s обновил текст публичной оферты (%d символов)",
db_user.telegram_id,
len(new_text),
)
await state.clear()
success_text = texts.t(
"ADMIN_PUBLIC_OFFER_SAVED",
"✅ Публичная оферта обновлена.",
)
reply_markup = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_BACK_BUTTON",
"⬅️ К настройкам оферты",
),
callback_data="admin_public_offer",
)
]
]
)
await message.answer(success_text, reply_markup=reply_markup)
@admin_required
@error_handler
async def view_public_offer(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
offer = await PublicOfferService.get_offer(
db,
db_user.language,
fallback=False,
)
if not offer or not offer.content or not offer.content.strip():
await callback.answer(
texts.t(
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT",
"Текст оферты пока не задан.",
),
show_alert=True,
)
return
content = offer.content.strip()
max_length = 3800
pages = PublicOfferService.split_content_into_pages(
content,
max_length=max_length,
)
if not pages:
await callback.answer(
texts.t(
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT",
"Текст оферты пока не задан.",
),
show_alert=True,
)
return
preview = pages[0]
truncated = len(pages) > 1
header = texts.t(
"ADMIN_PUBLIC_OFFER_VIEW_TITLE",
"👀 <b>Текущий текст оферты</b>",
)
note = ""
if truncated:
note = texts.t(
"ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED",
"\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_BACK_BUTTON",
"⬅️ К настройкам оферты",
),
callback_data="admin_public_offer",
)
],
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON",
"✏️ Изменить текст",
),
callback_data="admin_public_offer_edit",
)
],
]
)
await callback.message.edit_text(
f"{header}\n\n{preview}{note}",
reply_markup=keyboard,
)
await callback.answer()
@admin_required
@error_handler
async def show_public_offer_html_help(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
help_text = get_html_help_text()
current_state = await state.get_state()
buttons: list[list[types.InlineKeyboardButton]] = []
if current_state == AdminStates.editing_public_offer.state:
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT",
"⬅️ Назад к редактированию",
),
callback_data="admin_public_offer_edit",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_BACK_BUTTON",
"⬅️ К настройкам оферты",
),
callback_data="admin_public_offer",
)
])
await callback.message.edit_text(
help_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
)
await callback.answer()
def register_handlers(dp: Dispatcher) -> None:
dp.callback_query.register(
show_public_offer_management,
F.data == "admin_public_offer",
)
dp.callback_query.register(
toggle_public_offer,
F.data == "admin_public_offer_toggle",
)
dp.callback_query.register(
start_edit_public_offer,
F.data == "admin_public_offer_edit",
)
dp.callback_query.register(
cancel_edit_public_offer,
F.data == "admin_public_offer_cancel",
)
dp.callback_query.register(
view_public_offer,
F.data == "admin_public_offer_view",
)
dp.callback_query.register(
show_public_offer_html_help,
F.data == "admin_public_offer_help",
)
dp.message.register(
process_public_offer_edit,
AdminStates.editing_public_offer,
)
+3 -3
View File
@@ -68,7 +68,7 @@ async def show_referral_statistics(
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
- Бонус новому пользователю: {settings.format_price(settings.REFERRED_USER_REWARD)}
- Бонус новому пользователю: {settings.format_price(settings.get_referred_user_reward_kopeks())}
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
- Уведомления: {'✅ Включены' if settings.REFERRAL_NOTIFICATIONS_ENABLED else '❌ Отключены'}
@@ -105,7 +105,7 @@ async def show_referral_statistics(
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
- Бонус новому пользователю: {settings.format_price(settings.REFERRED_USER_REWARD)}
- Бонус новому пользователю: {settings.format_price(settings.get_referred_user_reward_kopeks())}
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
<i>🕐 Время: {current_time}</i>
@@ -190,7 +190,7 @@ async def show_referral_settings(
Минимальная сумма пополнения для участия: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
Бонус за первое пополнение реферала: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
Бонус пригласившему за первое пополнение: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
Бонус новому пользователю при регистрации: {settings.format_price(settings.REFERRED_USER_REWARD)}
Бонус новому пользователю при регистрации: {settings.format_price(settings.get_referred_user_reward_kopeks())}
<b>Комиссионные:</b>
Процент с каждой покупки реферала: {settings.REFERRAL_COMMISSION_PERCENT}%
File diff suppressed because it is too large Load Diff
+43 -1
View File
@@ -35,6 +35,8 @@ def _build_server_edit_view(server):
else "Не выбраны"
)
trial_status = "✅ Да" if server.is_trial_eligible else "⚪️ Нет"
text = f"""
🌐 <b>Редактирование сервера</b>
@@ -51,6 +53,7 @@ def _build_server_edit_view(server):
Лимит пользователей: {server.max_users or 'Без лимита'}
Текущих пользователей: {server.current_users}
Промогруппы: {promo_groups_text}
Выдача триала: {trial_status}
<b>Описание:</b>
{server.description or 'Не указано'}
@@ -80,6 +83,12 @@ def _build_server_edit_view(server):
text="👥 Юзеры", callback_data=f"admin_server_users_{server.id}"
),
],
[
types.InlineKeyboardButton(
text="🎁 Выдавать сквад" if not server.is_trial_eligible else "🚫 Не выдавать сквад",
callback_data=f"admin_server_trial_{server.id}",
),
],
[
types.InlineKeyboardButton(
text="🎯 Промогруппы", callback_data=f"admin_server_edit_promo_{server.id}"
@@ -171,7 +180,7 @@ async def show_servers_menu(
types.InlineKeyboardButton(text="📈 Подробная статистика", callback_data="admin_servers_stats")
],
[
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
]
]
@@ -527,6 +536,38 @@ async def toggle_server_availability(
)
@admin_required
@error_handler
async def toggle_server_trial_assignment(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
server_id = int(callback.data.split('_')[-1])
server = await get_server_squad_by_id(db, server_id)
if not server:
await callback.answer("❌ Сервер не найден!", show_alert=True)
return
new_status = not server.is_trial_eligible
await update_server_squad(db, server_id, is_trial_eligible=new_status)
status_text = "будет выдаваться" if new_status else "перестанет выдаваться"
await callback.answer(f"✅ Сквад {status_text} в триал")
server = await get_server_squad_by_id(db, server_id)
text, keyboard = _build_server_edit_view(server)
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="HTML"
)
@admin_required
@error_handler
async def start_server_edit_price(
@@ -1255,6 +1296,7 @@ def register_handlers(dp: Dispatcher):
& ~F.data.contains("promo"),
)
dp.callback_query.register(toggle_server_availability, F.data.startswith("admin_server_toggle_"))
dp.callback_query.register(toggle_server_trial_assignment, F.data.startswith("admin_server_trial_"))
dp.callback_query.register(show_server_users, F.data.startswith("admin_server_users_"))
dp.callback_query.register(start_server_edit_name, F.data.startswith("admin_server_edit_name_"))
-56
View File
@@ -4,7 +4,6 @@ from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.config import settings
from app.states import AdminStates
from app.database.models import User
from app.keyboards.admin import get_admin_subscriptions_keyboard
@@ -87,10 +86,6 @@ async def show_subscriptions_menu(
],
[
types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_subs_stats"),
types.InlineKeyboardButton(text="💰 Настройки цен", callback_data="admin_subs_pricing")
],
[
types.InlineKeyboardButton(text="🌐 Управление серверами", callback_data="admin_servers"),
types.InlineKeyboardButton(text="🌍 География", callback_data="admin_subs_countries")
],
[
@@ -277,56 +272,6 @@ async def show_subscriptions_stats(
@admin_required
@error_handler
async def show_pricing_settings(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
text = f"""
<b>Настройки цен</b>
<b>Периоды подписки:</b>
- 14 дней: {settings.format_price(settings.PRICE_14_DAYS)}
- 30 дней: {settings.format_price(settings.PRICE_30_DAYS)}
- 60 дней: {settings.format_price(settings.PRICE_60_DAYS)}
- 90 дней: {settings.format_price(settings.PRICE_90_DAYS)}
- 180 дней: {settings.format_price(settings.PRICE_180_DAYS)}
- 360 дней: {settings.format_price(settings.PRICE_360_DAYS)}
<b>Трафик-пакеты:</b>
- 5 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_5GB)}
- 10 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_10GB)}
- 25 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_25GB)}
- 50 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_50GB)}
- 100 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_100GB)}
- 250 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_250GB)}
<b>Дополнительно:</b>
- За устройство: {settings.format_price(settings.PRICE_PER_DEVICE)}
"""
keyboard = [
# [
# types.InlineKeyboardButton(text="📅 Периоды", callback_data="admin_edit_period_prices"),
# types.InlineKeyboardButton(text="📈 Трафик", callback_data="admin_edit_traffic_prices")
# ],
# [
# types.InlineKeyboardButton(text="📱 Устройства", callback_data="admin_edit_device_price")
# ],
[
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def show_countries_management(
callback: types.CallbackQuery,
db_user: User,
@@ -487,7 +432,6 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_subscriptions_list, F.data == "admin_subs_list")
dp.callback_query.register(show_expiring_subscriptions, F.data == "admin_subs_expiring")
dp.callback_query.register(show_subscriptions_stats, F.data == "admin_subs_stats")
dp.callback_query.register(show_pricing_settings, F.data == "admin_subs_pricing")
dp.callback_query.register(show_countries_management, F.data == "admin_subs_countries")
dp.callback_query.register(send_expiry_reminders, F.data == "admin_send_expiry_reminders")
+41 -22
View File
@@ -371,7 +371,7 @@ async def handle_admin_ticket_reply(
return
# Обычный режим ответа админа
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False, load_user=True)
if not ticket:
texts = get_texts(db_user.language)
await message.answer(
@@ -859,52 +859,71 @@ async def notify_user_about_ticket_reply(bot: Bot, ticket: Ticket, reply_text: s
except Exception:
pass
from app.localization.texts import get_texts
# Получаем тикет с пользователем
ticket_with_user = await TicketCRUD.get_ticket_by_id(db, ticket.id, load_user=True)
if not ticket_with_user or not ticket_with_user.user:
# Обеспечим наличие данных пользователя в объекте тикета
ticket_with_user = ticket
if not getattr(ticket_with_user, "user", None):
ticket_with_user = await TicketCRUD.get_ticket_by_id(db, ticket.id, load_user=True)
user = getattr(ticket_with_user, "user", None)
if not user:
logger.error(f"User not found for ticket #{ticket.id}")
return
texts = get_texts(ticket_with_user.user.language)
if not getattr(user, "telegram_id", None):
logger.error(
"Cannot notify ticket #%s user without telegram_id (username=%s)",
ticket.id,
getattr(user, "username", None),
)
return
chat_id = int(user.telegram_id)
texts = get_texts(user.language)
# Формируем уведомление
base_text = texts.t(
"TICKET_REPLY_NOTIFICATION",
"TICKET_REPLY_NOTIFICATION",
"🎫 Получен ответ по тикету #{ticket_id}\n\n{reply_preview}\n\nНажмите кнопку ниже, чтобы перейти к тикету:"
).format(
ticket_id=ticket.id,
reply_preview=reply_text[:100] + "..." if len(reply_text) > 100 else reply_text
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text=texts.t("VIEW_TICKET", "👁️ Посмотреть тикет"), callback_data=f"view_ticket_{ticket.id}")],
[types.InlineKeyboardButton(text=texts.t("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")]
])
# Если было фото в последнем ответе админа — отправим как фото
last_message = await TicketMessageCRUD.get_last_message(db, ticket.id)
if last_message and last_message.has_media and last_message.media_type == "photo" and last_message.is_from_admin:
caption = base_text
try:
await bot.send_photo(
chat_id=ticket_with_user.user.telegram_id,
chat_id=chat_id,
photo=last_message.media_file_id,
caption=caption,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text=texts.t("VIEW_TICKET", "👁️ Посмотреть тикет"), callback_data=f"view_ticket_{ticket.id}")],
[types.InlineKeyboardButton(text=texts.t("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")]
])
reply_markup=keyboard,
)
return
except TelegramBadRequest as photo_error:
logger.error(
"Не удалось отправить фото-уведомление пользователю %s для тикета %s: %s",
chat_id,
ticket.id,
photo_error,
)
except Exception as e:
logger.error(f"Не удалось отправить фото-уведомление: {e}")
# Фоллбек: текстовое уведомление
await bot.send_message(
chat_id=ticket_with_user.user.telegram_id,
chat_id=chat_id,
text=base_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text=texts.t("VIEW_TICKET", "👁️ Посмотреть тикет"), callback_data=f"view_ticket_{ticket.id}")],
[types.InlineKeyboardButton(text=texts.t("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")]
])
reply_markup=keyboard,
)
logger.info(f"Ticket #{ticket.id} reply notification sent to user {ticket_with_user.user.telegram_id}")
logger.info(f"Ticket #{ticket.id} reply notification sent to user {chat_id}")
except Exception as e:
logger.error(f"Error notifying user about ticket reply: {e}")
+54 -13
View File
@@ -2080,11 +2080,16 @@ async def extend_user_subscription(
await callback.message.edit_text(
"⏰ <b>Продление подписки</b>\n\n"
"Введите количество дней для продления:\n"
"Например: 30, 7, 90\n"
"Максимум: 365 дней\n\n"
"Введите количество дней для изменения:\n"
"Положительные значения продлят подписку\n"
"Отрицательные сократят срок подписки\n"
"• Диапазон: от -365 до 365 дней (0 недопустимо)\n\n"
"Или нажмите /cancel для отмены",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(text="-7 дней", callback_data=f"admin_sub_extend_days_{user_id}_-7"),
types.InlineKeyboardButton(text="-30 дней", callback_data=f"admin_sub_extend_days_{user_id}_-30")
],
[
types.InlineKeyboardButton(text="7 дней", callback_data=f"admin_sub_extend_days_{user_id}_7"),
types.InlineKeyboardButton(text="30 дней", callback_data=f"admin_sub_extend_days_{user_id}_30")
@@ -2114,11 +2119,19 @@ async def process_subscription_extension_days(
user_id = int(parts[-2])
days = int(parts[-1])
if days == 0 or days < -365 or days > 365:
await callback.answer("❌ Количество дней должно быть от -365 до 365, исключая 0", show_alert=True)
return
success = await _extend_subscription_by_days(db, user_id, days, db_user.id)
if success:
if days > 0:
action_text = f"продлена на {days} дней"
else:
action_text = f"уменьшена на {abs(days)} дней"
await callback.message.edit_text(
f"✅ Подписка пользователя продлена на {days} дней",
f"✅ Подписка пользователя {action_text}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
@@ -2153,15 +2166,19 @@ async def process_subscription_extension_text(
try:
days = int(message.text.strip())
if days <= 0 or days > 365:
await message.answer("❌ Количество дней должно быть от 1 до 365")
if days == 0 or days < -365 or days > 365:
await message.answer("❌ Количество дней должно быть от -365 до 365, исключая 0")
return
success = await _extend_subscription_by_days(db, user_id, days, db_user.id)
if success:
if days > 0:
action_text = f"продлена на {days} дней"
else:
action_text = f"уменьшена на {abs(days)} дней"
await message.answer(
f"✅ Подписка пользователя продлена на {days} дней",
f"✅ Подписка пользователя {action_text}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
@@ -3115,9 +3132,12 @@ async def _extend_subscription_by_days(db: AsyncSession, user_id: int, days: int
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
logger.info(f"Админ {admin_id} продлил подписку пользователя {user_id} на {days} дней")
if days > 0:
logger.info(f"Админ {admin_id} продлил подписку пользователя {user_id} на {days} дней")
else:
logger.info(f"Админ {admin_id} сократил подписку пользователя {user_id} на {abs(days)} дней")
return True
except Exception as e:
logger.error(f"Ошибка продления подписки: {e}")
return False
@@ -3240,13 +3260,34 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad
logger.error(f"У пользователя {user_id} уже есть подписка")
return False
trial_squads: list[str] = []
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
trial_uuid = await get_random_trial_squad_uuid(
db,
getattr(settings, "TRIAL_SQUAD_UUID", None),
)
if trial_uuid:
trial_squads = [trial_uuid]
except Exception as error:
logger.error(
"Не удалось подобрать сквад при выдаче подписки админом %s: %s",
admin_id,
error,
)
if getattr(settings, "TRIAL_SQUAD_UUID", None):
trial_squads = [settings.TRIAL_SQUAD_UUID]
subscription = await create_paid_subscription(
db=db,
user_id=user_id,
duration_days=days,
traffic_limit_gb=settings.DEFAULT_TRAFFIC_LIMIT_GB,
device_limit=settings.DEFAULT_DEVICE_LIMIT,
connected_squads=[settings.TRIAL_SQUAD_UUID] if settings.TRIAL_SQUAD_UUID else []
connected_squads=trial_squads,
update_server_counters=True,
)
subscription_service = SubscriptionService()
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
from .main import register_balance_handlers
__all__ = ["register_balance_handlers"]
+241
View File
@@ -0,0 +1,241 @@
import logging
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.utils.decorators import error_handler
from app.states import BalanceStates
logger = logging.getLogger(__name__)
@error_handler
async def start_cryptobot_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.is_cryptobot_enabled():
await callback.answer("❌ Оплата криптовалютой временно недоступна", show_alert=True)
return
from app.utils.currency_converter import currency_converter
try:
current_rate = await currency_converter.get_usd_to_rub_rate()
rate_text = f"💱 Текущий курс: 1 USD = {current_rate:.2f}"
except Exception as e:
logger.warning(f"Не удалось получить курс валют: {e}")
current_rate = 95.0
rate_text = f"💱 Курс: 1 USD ≈ {current_rate:.0f}"
available_assets = settings.get_cryptobot_assets()
assets_text = ", ".join(available_assets)
# Формируем текст сообщения в зависимости от настройки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
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 для оплаты."
)
# Создаем клавиатуру
keyboard = get_back_keyboard(db_user.language)
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="cryptobot", current_rate=current_rate)
await callback.answer()
@error_handler
async def process_cryptobot_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.is_cryptobot_enabled():
await message.answer("❌ Оплата криптовалютой временно недоступна")
return
amount_rubles = amount_kopeks / 100
if amount_rubles < 100:
await message.answer("Минимальная сумма пополнения: 100 ₽")
return
if amount_rubles > 100000:
await message.answer("Максимальная сумма пополнения: 100,000 ₽")
return
try:
data = await state.get_data()
current_rate = data.get('current_rate')
if not current_rate:
from app.utils.currency_converter import currency_converter
current_rate = await currency_converter.get_usd_to_rub_rate()
amount_usd = amount_rubles / current_rate
amount_usd = round(amount_usd, 2)
if amount_usd < 1:
await message.answer("❌ Минимальная сумма для оплаты в USD: 1.00 USD")
return
if amount_usd > 1000:
await message.answer("❌ Максимальная сумма для оплаты в USD: 1,000 USD")
return
payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_cryptobot_payment(
db=db,
user_id=db_user.id,
amount_usd=amount_usd,
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
description=f"Пополнение баланса на {amount_rubles:.0f} ₽ ({amount_usd:.2f} USD)",
payload=f"balance_{db_user.id}_{amount_kopeks}"
)
if not payment_result:
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
await state.clear()
return
bot_invoice_url = payment_result.get("bot_invoice_url")
mini_app_invoice_url = payment_result.get("mini_app_invoice_url")
payment_url = bot_invoice_url or mini_app_invoice_url
if not payment_url:
await message.answer("❌ Ошибка получения ссылки для оплаты. Обратитесь в поддержку.")
await state.clear()
return
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🪙 Оплатить", url=payment_url)],
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_cryptobot_{payment_result['local_payment_id']}")],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await message.answer(
f"🪙 <b>Оплата криптовалютой</b>\n\n"
f"💰 Сумма к зачислению: {amount_rubles:.0f}\n"
f"💵 К оплате: {amount_usd:.2f} USD\n"
f"🪙 Актив: {payment_result['asset']}\n"
f"💱 Курс: 1 USD = {current_rate:.2f}\n"
f"🆔 ID платежа: {payment_result['invoice_id'][:8]}...\n\n"
f"📱 <b>Инструкция:</b>\n"
f"1. Нажмите кнопку 'Оплатить'\n"
f"2. Выберите удобный актив\n"
f"3. Переведите указанную сумму\n"
f"4. Деньги поступят на баланс автоматически\n\n"
f"🔒 Оплата проходит через защищенную систему CryptoBot\n"
f"⚡ Поддерживаемые активы: USDT, TON, BTC, ETH\n\n"
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}",
reply_markup=keyboard,
parse_mode="HTML"
)
await state.clear()
logger.info(f"Создан CryptoBot платеж для пользователя {db_user.telegram_id}: "
f"{amount_rubles:.0f} ₽ ({amount_usd:.2f} USD), ID: {payment_result['invoice_id']}")
except Exception as e:
logger.error(f"Ошибка создания CryptoBot платежа: {e}")
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
await state.clear()
@error_handler
async def check_cryptobot_payment_status(
callback: types.CallbackQuery,
db: AsyncSession
):
try:
local_payment_id = int(callback.data.split('_')[-1])
from app.database.crud.cryptobot import get_cryptobot_payment_by_id
payment = await get_cryptobot_payment_by_id(db, local_payment_id)
if not payment:
await callback.answer("❌ Платеж не найден", show_alert=True)
return
status_emoji = {
"active": "",
"paid": "",
"expired": ""
}
status_text = {
"active": "Ожидает оплаты",
"paid": "Оплачен",
"expired": "Истек"
}
emoji = status_emoji.get(payment.status, "")
status = status_text.get(payment.status, "Неизвестно")
message_text = (f"🪙 Статус платежа:\n\n"
f"🆔 ID: {payment.invoice_id[:8]}...\n"
f"💰 Сумма: {payment.amount} {payment.asset}\n"
f"📊 Статус: {emoji} {status}\n"
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n")
if payment.is_paid:
message_text += "\n✅ Платеж успешно завершен!\n\nСредства зачислены на баланс."
elif payment.is_pending:
message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше."
elif payment.is_expired:
message_text += (
f"\n❌ Платеж истек. Обратитесь в {settings.get_support_contact_display()}"
)
await callback.answer(message_text, show_alert=True)
except Exception as e:
logger.error(f"Ошибка проверки статуса CryptoBot платежа: {e}")
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
+728
View File
@@ -0,0 +1,728 @@
import html
import logging
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.states import BalanceStates
from app.database.crud.user import add_user_balance
from app.database.crud.transaction import (
get_user_transactions, get_user_transactions_count,
create_transaction
)
from app.database.models import User, TransactionType, PaymentMethod
from app.keyboards.inline import (
get_balance_keyboard, get_payment_methods_keyboard,
get_back_keyboard, get_pagination_keyboard
)
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.utils.pagination import paginate_list
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
TRANSACTIONS_PER_PAGE = 10
def get_quick_amount_buttons(language: str) -> list:
if not settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED or settings.DISABLE_TOPUP_BUTTONS:
return []
buttons = []
periods = settings.get_available_subscription_periods()
periods = periods[:6]
for period in periods:
price_attr = f"PRICE_{period}_DAYS"
if hasattr(settings, price_attr):
price_kopeks = getattr(settings, price_attr)
price_rubles = price_kopeks // 100
callback_data = f"quick_amount_{price_kopeks}"
buttons.append(
types.InlineKeyboardButton(
text=f"{price_rubles} ₽ ({period} дней)",
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
):
texts = get_texts(db_user.language)
balance_text = texts.BALANCE_INFO.format(
balance=texts.format_price(db_user.balance_kopeks)
)
await callback.message.edit_text(
balance_text,
reply_markup=get_balance_keyboard(db_user.language)
)
await callback.answer()
@error_handler
async def show_balance_history(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
page: int = 1
):
texts = get_texts(db_user.language)
offset = (page - 1) * TRANSACTIONS_PER_PAGE
raw_transactions = await get_user_transactions(
db, db_user.id,
limit=TRANSACTIONS_PER_PAGE * 3,
offset=offset
)
seen_transactions = set()
unique_transactions = []
for transaction in raw_transactions:
rounded_time = transaction.created_at.replace(second=0, microsecond=0)
transaction_key = (
transaction.amount_kopeks,
transaction.description,
rounded_time
)
if transaction_key not in seen_transactions:
seen_transactions.add(transaction_key)
unique_transactions.append(transaction)
if len(unique_transactions) >= TRANSACTIONS_PER_PAGE:
break
all_transactions = await get_user_transactions(db, db_user.id, limit=1000)
seen_all = set()
total_unique = 0
for transaction in all_transactions:
rounded_time = transaction.created_at.replace(second=0, microsecond=0)
transaction_key = (
transaction.amount_kopeks,
transaction.description,
rounded_time
)
if transaction_key not in seen_all:
seen_all.add(transaction_key)
total_unique += 1
if not unique_transactions:
await callback.message.edit_text(
"📊 История операций пуста",
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()
return
text = "📊 <b>История операций</b>\n\n"
for transaction in unique_transactions:
emoji = "💰" if transaction.type == TransactionType.DEPOSIT.value else "💸"
amount_text = f"+{texts.format_price(transaction.amount_kopeks)}" if transaction.type == TransactionType.DEPOSIT.value else f"-{texts.format_price(transaction.amount_kopeks)}"
text += f"{emoji} {amount_text}\n"
text += f"📝 {transaction.description}\n"
text += f"📅 {transaction.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
keyboard = []
total_pages = (total_unique + TRANSACTIONS_PER_PAGE - 1) // TRANSACTIONS_PER_PAGE
if total_pages > 1:
pagination_row = get_pagination_keyboard(
page, total_pages, "balance_history", db_user.language
)
keyboard.extend(pagination_row)
keyboard.append([
types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await callback.answer()
@error_handler
async def handle_balance_history_pagination(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
page = int(callback.data.split('_')[-1])
await show_balance_history(callback, db_user, db, page)
@error_handler
async def show_payment_methods(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
from app.utils.payment_utils import get_payment_methods_text
texts = get_texts(db_user.language)
payment_text = get_payment_methods_text(db_user.language)
await callback.message.edit_text(
payment_text,
reply_markup=get_payment_methods_keyboard(0, db_user.language),
parse_mode="HTML"
)
await callback.answer()
@error_handler
async def handle_payment_methods_unavailable(
callback: types.CallbackQuery,
db_user: User
):
texts = get_texts(db_user.language)
await callback.answer(
texts.t(
"PAYMENT_METHODS_UNAVAILABLE_ALERT",
"⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.",
),
show_alert=True
)
@error_handler
async def handle_successful_topup_with_cart(
user_id: int,
amount_kopeks: int,
bot,
db: AsyncSession
):
from app.database.crud.user import get_user_by_id
from aiogram.fsm.context import FSMContext
from aiogram.fsm.storage.base import StorageKey
from app.bot import dp
user = await get_user_by_id(db, user_id)
if not user:
return
storage = dp.storage
key = StorageKey(bot_id=bot.id, chat_id=user.telegram_id, user_id=user.telegram_id)
try:
state_data = await storage.get_data(key)
current_state = await storage.get_state(key)
if (current_state == "SubscriptionStates:cart_saved_for_topup" and
state_data.get('saved_cart')):
texts = get_texts(user.language)
total_price = state_data.get('total_price', 0)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text="🛒 Вернуться к оформлению подписки",
callback_data="return_to_saved_cart"
)],
[types.InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance"
)],
[types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu"
)]
])
success_text = (
f"✅ Баланс пополнен на {texts.format_price(amount_kopeks)}!\n\n"
f"💰 Текущий баланс: {texts.format_price(user.balance_kopeks)}\n\n"
f"🛒 У вас есть сохраненная корзина подписки\n"
f"Стоимость: {texts.format_price(total_price)}\n\n"
f"Хотите продолжить оформление?"
)
await bot.send_message(
chat_id=user.telegram_id,
text=success_text,
reply_markup=keyboard,
parse_mode="HTML"
)
except Exception as e:
logger.error(f"Ошибка обработки успешного пополнения с корзиной: {e}")
@error_handler
async def request_support_topup(
callback: types.CallbackQuery,
db_user: User
):
texts = get_texts(db_user.language)
support_text = f"""
🛠 <b>Пополнение через поддержку</b>
Для пополнения баланса обратитесь в техподдержку:
{settings.get_support_contact_display_html()}
Укажите:
ID: {db_user.telegram_id}
Сумму пополнения
Способ оплаты
Время обработки: 1-24 часа
<b>Доступные способы:</b>
Криптовалюта
Переводы между банками
Другие платежные системы
"""
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text="💬 Написать в поддержку",
url=settings.get_support_contact_url() or "https://t.me/"
)],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await callback.message.edit_text(
support_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await callback.answer()
@error_handler
async def process_topup_amount(
message: types.Message,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
try:
if not message.text:
if message.successful_payment:
logger.info(
"Получено сообщение об успешном платеже без текста, "
"обработчик суммы пополнения завершает работу"
)
await state.clear()
return
await message.answer(
texts.INVALID_AMOUNT,
reply_markup=get_back_keyboard(db_user.language)
)
return
amount_text = message.text.strip()
if not amount_text:
await message.answer(
texts.INVALID_AMOUNT,
reply_markup=get_back_keyboard(db_user.language)
)
return
amount_rubles = float(amount_text.replace(',', '.'))
if amount_rubles < 1:
await message.answer("Минимальная сумма пополнения: 1 ₽")
return
if amount_rubles > 50000:
await message.answer("Максимальная сумма пополнения: 50,000 ₽")
return
amount_kopeks = int(amount_rubles * 100)
data = await state.get_data()
payment_method = data.get("payment_method", "stars")
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}")
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(',', ' '))
return
if payment_method == "stars":
from .stars import process_stars_payment_amount
await process_stars_payment_amount(message, db_user, amount_kopeks, state)
elif payment_method == "yookassa":
from app.database.database import AsyncSessionLocal
from .yookassa import process_yookassa_payment_amount
async with AsyncSessionLocal() as db:
await process_yookassa_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "yookassa_sbp":
from app.database.database import AsyncSessionLocal
from .yookassa import process_yookassa_sbp_payment_amount
async with AsyncSessionLocal() as db:
await process_yookassa_sbp_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "mulenpay":
from app.database.database import AsyncSessionLocal
from .mulenpay import process_mulenpay_payment_amount
async with AsyncSessionLocal() as db:
await process_mulenpay_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "wata":
from app.database.database import AsyncSessionLocal
from .wata import process_wata_payment_amount
async with AsyncSessionLocal() as db:
await process_wata_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "pal24":
from app.database.database import AsyncSessionLocal
from .pal24 import process_pal24_payment_amount
async with AsyncSessionLocal() as db:
await process_pal24_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "cryptobot":
from app.database.database import AsyncSessionLocal
from .cryptobot import process_cryptobot_payment_amount
async with AsyncSessionLocal() as db:
await process_cryptobot_payment_amount(message, db_user, db, amount_kopeks, state)
else:
await message.answer("Неизвестный способ оплаты")
except ValueError:
await message.answer(
texts.INVALID_AMOUNT,
reply_markup=get_back_keyboard(db_user.language)
)
@error_handler
async def handle_sbp_payment(
callback: types.CallbackQuery,
db: AsyncSession
):
try:
local_payment_id = int(callback.data.split('_')[-1])
from app.database.crud.yookassa import get_yookassa_payment_by_local_id
payment = await get_yookassa_payment_by_local_id(db, local_payment_id)
if not payment:
await callback.answer("❌ Платеж не найден", show_alert=True)
return
import json
metadata = json.loads(payment.metadata_json) if payment.metadata_json else {}
confirmation_token = metadata.get("confirmation_token")
if not confirmation_token:
await callback.answer("❌ Токен подтверждения не найден", show_alert=True)
return
await callback.message.answer(
f"Для оплаты через СБП откройте приложение вашего банка и подтвердите платеж.\\n\\n"
f"Если у вас не открылось банковское приложение автоматически, вы можете:\\n"
f"1. Скопировать этот токен: <code>{confirmation_token}</code>\\n"
f"2. Открыть приложение вашего банка\\n"
f"3. Найти функцию оплаты по токену\\n"
f"4. Вставить токен и подтвердить платеж",
parse_mode="HTML"
)
await callback.answer("Информация об оплате отправлена", show_alert=True)
except Exception as e:
logger.error(f"Ошибка обработки embedded платежа СБП: {e}")
await callback.answer("❌ Ошибка обработки платежа", show_alert=True)
@error_handler
async def handle_quick_amount_selection(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
"""
Обработчик выбора суммы через кнопки быстрого выбора
"""
# Извлекаем сумму из callback_data
try:
amount_kopeks = int(callback.data.split('_')[-1])
amount_rubles = amount_kopeks / 100
# Получаем метод оплаты из состояния
data = await state.get_data()
payment_method = data.get("payment_method", "yookassa")
# Проверяем, какой метод оплаты был выбран и вызываем соответствующий обработчик
if payment_method == "yookassa":
from app.database.database import AsyncSessionLocal
from .yookassa import process_yookassa_payment_amount
async with AsyncSessionLocal() as db:
await process_yookassa_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "yookassa_sbp":
from app.database.database import AsyncSessionLocal
from .yookassa import process_yookassa_sbp_payment_amount
async with AsyncSessionLocal() as db:
await process_yookassa_sbp_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "mulenpay":
from app.database.database import AsyncSessionLocal
from .mulenpay import process_mulenpay_payment_amount
async with AsyncSessionLocal() as db:
await process_mulenpay_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "wata":
from app.database.database import AsyncSessionLocal
from .wata import process_wata_payment_amount
async with AsyncSessionLocal() as db:
await process_wata_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "pal24":
from app.database.database import AsyncSessionLocal
from .pal24 import process_pal24_payment_amount
async with AsyncSessionLocal() as db:
await process_pal24_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
else:
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
return
except ValueError:
await callback.answer("❌ Ошибка обработки суммы", show_alert=True)
except Exception as e:
logger.error(f"Ошибка обработки быстрого выбора суммы: {e}")
await callback.answer("❌ Ошибка обработки запроса", show_alert=True)
@error_handler
async def handle_topup_amount_callback(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
try:
_, method, amount_str = callback.data.split("|", 2)
amount_kopeks = int(amount_str)
except ValueError:
await callback.answer("❌ Некорректный запрос", show_alert=True)
return
if amount_kopeks <= 0:
await callback.answer("❌ Некорректная сумма", show_alert=True)
return
try:
if method == "yookassa":
from app.database.database import AsyncSessionLocal
from .yookassa import process_yookassa_payment_amount
async with AsyncSessionLocal() as db:
await process_yookassa_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "yookassa_sbp":
from app.database.database import AsyncSessionLocal
from .yookassa import process_yookassa_sbp_payment_amount
async with AsyncSessionLocal() as db:
await process_yookassa_sbp_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "mulenpay":
from app.database.database import AsyncSessionLocal
from .mulenpay import process_mulenpay_payment_amount
async with AsyncSessionLocal() as db:
await process_mulenpay_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "pal24":
from app.database.database import AsyncSessionLocal
from .pal24 import process_pal24_payment_amount
async with AsyncSessionLocal() as db:
await process_pal24_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "cryptobot":
from app.database.database import AsyncSessionLocal
from .cryptobot import process_cryptobot_payment_amount
async with AsyncSessionLocal() as db:
await process_cryptobot_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "stars":
from .stars import process_stars_payment_amount
await process_stars_payment_amount(
callback.message, db_user, amount_kopeks, state
)
elif method == "tribute":
from .tribute import start_tribute_payment
await start_tribute_payment(callback, db_user)
return
else:
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
return
await callback.answer()
except Exception as error:
logger.error(f"Ошибка быстрого пополнения: {error}")
await callback.answer("❌ Ошибка обработки запроса", show_alert=True)
def register_balance_handlers(dp: Dispatcher):
dp.callback_query.register(
show_balance_menu,
F.data == "menu_balance"
)
dp.callback_query.register(
show_balance_history,
F.data == "balance_history"
)
dp.callback_query.register(
handle_balance_history_pagination,
F.data.startswith("balance_history_page_")
)
dp.callback_query.register(
show_payment_methods,
F.data == "balance_topup"
)
from .stars import start_stars_payment
dp.callback_query.register(
start_stars_payment,
F.data == "topup_stars"
)
from .yookassa import start_yookassa_payment
dp.callback_query.register(
start_yookassa_payment,
F.data == "topup_yookassa"
)
from .yookassa import start_yookassa_sbp_payment
dp.callback_query.register(
start_yookassa_sbp_payment,
F.data == "topup_yookassa_sbp"
)
from .mulenpay import start_mulenpay_payment
dp.callback_query.register(
start_mulenpay_payment,
F.data == "topup_mulenpay"
)
from .wata import start_wata_payment
dp.callback_query.register(
start_wata_payment,
F.data == "topup_wata"
)
from .pal24 import start_pal24_payment
dp.callback_query.register(
start_pal24_payment,
F.data == "topup_pal24"
)
from .yookassa import check_yookassa_payment_status
dp.callback_query.register(
check_yookassa_payment_status,
F.data.startswith("check_yookassa_")
)
from .tribute import start_tribute_payment
dp.callback_query.register(
start_tribute_payment,
F.data == "topup_tribute"
)
dp.callback_query.register(
request_support_topup,
F.data == "topup_support"
)
from .yookassa import check_yookassa_payment_status
dp.callback_query.register(
check_yookassa_payment_status,
F.data.startswith("check_yookassa_")
)
dp.message.register(
process_topup_amount,
BalanceStates.waiting_for_amount
)
from .cryptobot import start_cryptobot_payment
dp.callback_query.register(
start_cryptobot_payment,
F.data == "topup_cryptobot"
)
from .cryptobot import check_cryptobot_payment_status
dp.callback_query.register(
check_cryptobot_payment_status,
F.data.startswith("check_cryptobot_")
)
from .mulenpay import check_mulenpay_payment_status
dp.callback_query.register(
check_mulenpay_payment_status,
F.data.startswith("check_mulenpay_")
)
from .wata import check_wata_payment_status
dp.callback_query.register(
check_wata_payment_status,
F.data.startswith("check_wata_")
)
from .pal24 import check_pal24_payment_status
dp.callback_query.register(
check_pal24_payment_status,
F.data.startswith("check_pal24_")
)
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|")
)
+238
View File
@@ -0,0 +1,238 @@
import logging
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.utils.decorators import error_handler
from app.states import BalanceStates
logger = logging.getLogger(__name__)
@error_handler
async def start_mulenpay_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_mulenpay_enabled():
await callback.answer("❌ Оплата через Mulen Pay временно недоступна", show_alert=True)
return
message_text = texts.t(
"MULENPAY_TOPUP_PROMPT",
(
"💳 <b>Оплата через Mulen Pay</b>\n\n"
"Введите сумму для пополнения от 100 до 100 000 ₽.\n"
"Оплата происходит через защищенную платформу Mulen Pay."
),
)
keyboard = get_back_keyboard(db_user.language)
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="mulenpay")
await callback.answer()
@error_handler
async def process_mulenpay_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_mulenpay_enabled():
await message.answer("❌ Оплата через Mulen Pay временно недоступна")
return
if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
await message.answer(
f"Минимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MIN_AMOUNT_KOPEKS)}"
)
return
if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS:
await message.answer(
f"Максимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MAX_AMOUNT_KOPEKS)}"
)
return
amount_rubles = amount_kopeks / 100
try:
payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_mulenpay_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
language=db_user.language,
)
if not payment_result or not payment_result.get("payment_url"):
await message.answer(
texts.t(
"MULENPAY_PAYMENT_ERROR",
"❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.",
)
)
await state.clear()
return
payment_url = payment_result.get("payment_url")
mulen_payment_id = payment_result.get("mulen_payment_id")
local_payment_id = payment_result.get("local_payment_id")
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"MULENPAY_PAY_BUTTON",
"💳 Оплатить через Mulen Pay",
),
url=payment_url,
)
],
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
callback_data=f"check_mulenpay_{local_payment_id}",
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
]
)
payment_id_display = mulen_payment_id if mulen_payment_id is not None else local_payment_id
message_template = texts.t(
"MULENPAY_PAYMENT_INSTRUCTIONS",
(
"💳 <b>Оплата через Mulen Pay</b>\n\n"
"💰 Сумма: {amount}\n"
"🆔 ID платежа: {payment_id}\n\n"
"📱 <b>Инструкция:</b>\n"
"1. Нажмите кнопку 'Оплатить через Mulen Pay'\n"
"2. Следуйте подсказкам платежной системы\n"
"3. Подтвердите перевод\n"
"4. Средства зачислятся автоматически\n\n"
"❓ Если возникнут проблемы, обратитесь в {support}"
),
)
message_text = message_template.format(
amount=settings.format_price(amount_kopeks),
payment_id=payment_id_display,
support=settings.get_support_contact_display_html(),
)
await message.answer(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
await state.clear()
logger.info(
"Создан MulenPay платеж для пользователя %s: %s₽, ID: %s",
db_user.telegram_id,
amount_rubles,
payment_id_display,
)
except Exception as e:
logger.error(f"Ошибка создания MulenPay платежа: {e}")
await message.answer(
texts.t(
"MULENPAY_PAYMENT_ERROR",
"❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.",
)
)
await state.clear()
@error_handler
async def check_mulenpay_payment_status(
callback: types.CallbackQuery,
db: AsyncSession
):
try:
local_payment_id = int(callback.data.split('_')[-1])
payment_service = PaymentService(callback.bot)
status_info = await payment_service.get_mulenpay_payment_status(db, local_payment_id)
if not status_info:
await callback.answer("❌ Платеж не найден", show_alert=True)
return
payment = status_info["payment"]
status_labels = {
"created": ("", "Ожидает оплаты"),
"processing": ("", "Обрабатывается"),
"success": ("", "Оплачен"),
"canceled": ("", "Отменен"),
"error": ("⚠️", "Ошибка"),
"hold": ("🔒", "Холд"),
"unknown": ("", "Неизвестно"),
}
emoji, status_text = status_labels.get(payment.status, ("", "Неизвестно"))
message_lines = [
"💳 Статус платежа Mulen Pay:\n\n",
f"🆔 ID: {payment.mulen_payment_id or payment.id}\n",
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n",
f"📊 Статус: {emoji} {status_text}\n",
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n",
]
if payment.is_paid:
message_lines.append("\n✅ Платеж успешно завершен! Средства уже на балансе.")
elif payment.status in {"created", "processing"}:
message_lines.append(
"\n⏳ Платеж еще не завершен. Завершите оплату по ссылке и проверьте статус позже."
)
if payment.payment_url:
message_lines.append(f"\n🔗 Ссылка на оплату: {payment.payment_url}")
elif payment.status in {"canceled", "error"}:
message_lines.append(
f"\n❌ Платеж не был завершен. Попробуйте создать новый платеж или обратитесь в {settings.get_support_contact_display()}"
)
message_text = "".join(message_lines)
if len(message_text) > 190:
await callback.message.answer(message_text)
await callback.answer("ℹ️ Статус платежа отправлен в чат", show_alert=True)
else:
await callback.answer(message_text, show_alert=True)
except Exception as e:
logger.error(f"Ошибка проверки статуса MulenPay: {e}")
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
+364
View File
@@ -0,0 +1,364 @@
import html
import logging
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.utils.decorators import error_handler
from app.states import BalanceStates
logger = logging.getLogger(__name__)
@error_handler
async def start_pal24_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_pal24_enabled():
await callback.answer("❌ Оплата через PayPalych временно недоступна", show_alert=True)
return
# Формируем текст сообщения в зависимости от доступных способов оплаты
if settings.is_pal24_sbp_button_visible() and settings.is_pal24_card_button_visible():
payment_methods_text = "СБП и банковской картой"
elif settings.is_pal24_sbp_button_visible():
payment_methods_text = "СБП"
elif settings.is_pal24_card_button_visible():
payment_methods_text = "банковской картой"
else:
# Если обе кнопки отключены, используем общий текст
payment_methods_text = "доступными способами"
message_text = texts.t(
"PAL24_TOPUP_PROMPT",
(
f"🏦 <b>Оплата через PayPalych ({payment_methods_text})</b>\n\n"
"Введите сумму для пополнения от 100 до 1 000 000 ₽.\n"
f"Оплата проходит через PayPalych ({payment_methods_text})."
),
)
keyboard = get_back_keyboard(db_user.language)
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="pal24")
await callback.answer()
@error_handler
async def process_pal24_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_pal24_enabled():
await message.answer("❌ Оплата через PayPalych временно недоступна")
return
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
min_rubles = settings.PAL24_MIN_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Минимальная сумма для оплаты через PayPalych: {min_rubles:.0f}")
return
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
max_rubles = settings.PAL24_MAX_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Максимальная сумма для оплаты через PayPalych: {max_rubles:,.0f}".replace(',', ' '))
return
try:
payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_pal24_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
language=db_user.language,
)
if not payment_result:
await message.answer(
texts.t(
"PAL24_PAYMENT_ERROR",
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
)
)
await state.clear()
return
sbp_url = (
payment_result.get("sbp_url")
or payment_result.get("transfer_url")
)
card_url = payment_result.get("card_url")
fallback_url = (
payment_result.get("link_page_url")
or payment_result.get("link_url")
)
if not (sbp_url or card_url or fallback_url):
await message.answer(
texts.t(
"PAL24_PAYMENT_ERROR",
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
)
)
await state.clear()
return
if not sbp_url:
sbp_url = fallback_url
bill_id = payment_result.get("bill_id")
local_payment_id = payment_result.get("local_payment_id")
pay_buttons: list[list[types.InlineKeyboardButton]] = []
steps: list[str] = []
step_counter = 1
default_sbp_text = texts.t(
"PAL24_SBP_PAY_BUTTON",
"🏦 Оплатить через PayPalych (СБП)",
)
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
if sbp_url and settings.is_pal24_sbp_button_visible():
pay_buttons.append(
[
types.InlineKeyboardButton(
text=sbp_button_text,
url=sbp_url,
)
]
)
steps.append(
texts.t(
"PAL24_INSTRUCTION_BUTTON",
"{step}. Нажмите кнопку «{button}»",
).format(step=step_counter, button=html.escape(sbp_button_text))
)
step_counter += 1
default_card_text = texts.t(
"PAL24_CARD_PAY_BUTTON",
"💳 Оплатить банковской картой (PayPalych)",
)
card_button_text = settings.get_pal24_card_button_text(default_card_text)
if card_url and card_url != sbp_url and settings.is_pal24_card_button_visible():
pay_buttons.append(
[
types.InlineKeyboardButton(
text=card_button_text,
url=card_url,
)
]
)
steps.append(
texts.t(
"PAL24_INSTRUCTION_BUTTON",
"{step}. Нажмите кнопку «{button}»",
).format(step=step_counter, button=html.escape(card_button_text))
)
step_counter += 1
if not pay_buttons and fallback_url and settings.is_pal24_sbp_button_visible():
pay_buttons.append(
[
types.InlineKeyboardButton(
text=sbp_button_text,
url=fallback_url,
)
]
)
steps.append(
texts.t(
"PAL24_INSTRUCTION_BUTTON",
"{step}. Нажмите кнопку «{button}»",
).format(step=step_counter, button=html.escape(sbp_button_text))
)
step_counter += 1
follow_template = texts.t(
"PAL24_INSTRUCTION_FOLLOW",
"{step}. Следуйте подсказкам платёжной системы",
)
steps.append(follow_template.format(step=step_counter))
step_counter += 1
confirm_template = texts.t(
"PAL24_INSTRUCTION_CONFIRM",
"{step}. Подтвердите перевод",
)
steps.append(confirm_template.format(step=step_counter))
step_counter += 1
success_template = texts.t(
"PAL24_INSTRUCTION_COMPLETE",
"{step}. Средства зачислятся автоматически",
)
steps.append(success_template.format(step=step_counter))
message_template = texts.t(
"PAL24_PAYMENT_INSTRUCTIONS",
(
"🏦 <b>Оплата через PayPalych</b>\n\n"
"💰 Сумма: {amount}\n"
"🆔 ID счета: {bill_id}\n\n"
"📱 <b>Инструкция:</b>\n{steps}\n\n"
"❓ Если возникнут проблемы, обратитесь в {support}"
),
)
keyboard_rows = pay_buttons + [
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
callback_data=f"check_pal24_{local_payment_id}",
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
]
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
message_text = message_template.format(
amount=settings.format_price(amount_kopeks),
bill_id=bill_id,
steps="\n".join(steps),
support=settings.get_support_contact_display_html(),
)
await message.answer(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
await state.clear()
logger.info(
"Создан PayPalych счет для пользователя %s: %s₽, ID: %s",
db_user.telegram_id,
amount_kopeks / 100,
bill_id,
)
except Exception as e:
logger.error(f"Ошибка создания PayPalych платежа: {e}")
await message.answer(
texts.t(
"PAL24_PAYMENT_ERROR",
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
)
)
await state.clear()
@error_handler
async def check_pal24_payment_status(
callback: types.CallbackQuery,
db: AsyncSession,
):
try:
local_payment_id = int(callback.data.split('_')[-1])
payment_service = PaymentService(callback.bot)
status_info = await payment_service.get_pal24_payment_status(db, local_payment_id)
if not status_info:
await callback.answer("❌ Платеж не найден", show_alert=True)
return
payment = status_info["payment"]
status_labels = {
"NEW": ("", "Ожидает оплаты"),
"PROCESS": ("", "Обрабатывается"),
"SUCCESS": ("", "Оплачен"),
"FAIL": ("", "Отменен"),
"UNDERPAID": ("⚠️", "Недоплата"),
"OVERPAID": ("⚠️", "Переплата"),
}
emoji, status_text = status_labels.get(payment.status, ("", "Неизвестно"))
metadata = payment.metadata_json or {}
links_meta = metadata.get("links") if isinstance(metadata, dict) else None
if not isinstance(links_meta, dict):
links_meta = {}
sbp_link = links_meta.get("sbp") or payment.link_url
card_link = links_meta.get("card")
if not card_link and payment.link_page_url and payment.link_page_url != sbp_link:
card_link = payment.link_page_url
message_lines = [
"🏦 Статус платежа PayPalych:",
"",
f"🆔 ID счета: {payment.bill_id}",
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}",
f"📊 Статус: {emoji} {status_text}",
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}",
]
if payment.is_paid:
message_lines.append("")
message_lines.append("✅ Платеж успешно завершен! Средства уже на балансе.")
elif payment.status in {"NEW", "PROCESS"}:
message_lines.append("")
message_lines.append("⏳ Платеж еще не завершен. Оплатите счет и проверьте статус позже.")
if sbp_link:
message_lines.append("")
message_lines.append(f"🏦 СБП: {sbp_link}")
if card_link and card_link != sbp_link:
message_lines.append(f"💳 Банковская карта: {card_link}")
elif payment.status in {"FAIL", "UNDERPAID", "OVERPAID"}:
message_lines.append("")
message_lines.append(
f"❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}"
)
from app.localization.texts import get_texts
db_user = getattr(callback, 'db_user', None)
texts = get_texts(db_user.language if db_user else 'ru') if db_user else get_texts('ru')
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), callback_data=f"check_pal24_{local_payment_id}")],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await callback.answer()
await callback.message.edit_text(
"\n".join(message_lines),
reply_markup=keyboard,
disable_web_page_preview=True,
)
except Exception as e:
logger.error(f"Ошибка проверки статуса PayPalych: {e}")
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
+104
View File
@@ -0,0 +1,104 @@
import logging
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard, get_payment_methods_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
from app.external.telegram_stars import TelegramStarsService
logger = logging.getLogger(__name__)
@error_handler
async def start_stars_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.TELEGRAM_STARS_ENABLED:
await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True)
return
# Формируем текст сообщения в зависимости от настройки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
message_text = (
f"⭐ <b>Пополнение через Telegram Stars</b>\n\n"
f"Выберите сумму пополнения или введите вручную:"
)
else:
message_text = texts.TOP_UP_AMOUNT
# Создаем клавиатуру
keyboard = get_back_keyboard(db_user.language)
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="stars")
await callback.answer()
@error_handler
async def process_stars_payment_amount(
message: types.Message,
db_user: User,
amount_kopeks: int,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.TELEGRAM_STARS_ENABLED:
await message.answer("⚠️ Оплата Stars временно недоступна")
return
try:
amount_rubles = amount_kopeks / 100
stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles)
stars_rate = settings.get_stars_rate()
payment_service = PaymentService(message.bot)
invoice_link = await payment_service.create_stars_invoice(
amount_kopeks=amount_kopeks,
description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}",
payload=f"balance_{db_user.id}_{amount_kopeks}"
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await message.answer(
f"⭐ <b>Оплата через Telegram Stars</b>\n\n"
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
f"К оплате: {stars_amount} звезд\n"
f"📊 Курс: {stars_rate}₽ за звезду\n\n"
f"Нажмите кнопку ниже для оплаты:",
reply_markup=keyboard,
parse_mode="HTML"
)
await state.clear()
except Exception as e:
logger.error(f"Ошибка создания Stars invoice: {e}")
await message.answer("⚠️ Ошибка создания платежа")
+59
View File
@@ -0,0 +1,59 @@
import logging
from aiogram import types
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.utils.decorators import error_handler
logger = logging.getLogger(__name__)
@error_handler
async def start_tribute_payment(
callback: types.CallbackQuery,
db_user: User
):
texts = get_texts(db_user.language)
if not settings.TRIBUTE_ENABLED:
await callback.answer("❌ Оплата картой временно недоступна", show_alert=True)
return
try:
from app.services.tribute_service import TributeService
tribute_service = TributeService(callback.bot)
payment_url = await tribute_service.create_payment_link(
user_id=db_user.telegram_id,
amount_kopeks=0,
description="Пополнение баланса VPN"
)
if not payment_url:
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
return
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await callback.message.edit_text(
f"💳 <b>Пополнение банковской картой</b>\n\n"
f"• Введите любую сумму от 100₽\n"
f"• Безопасная оплата через Tribute\n"
f"• Мгновенное зачисление на баланс\n"
f"• Принимаем карты Visa, MasterCard, МИР\n\n"
f"• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n"
f"Нажмите кнопку для перехода к оплате:",
reply_markup=keyboard,
parse_mode="HTML"
)
except Exception as e:
logger.error(f"Ошибка создания Tribute платежа: {e}")
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
await callback.answer()
+234
View File
@@ -0,0 +1,234 @@
import logging
from typing import Dict
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService, get_user_by_id as fetch_user_by_id
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
@error_handler
async def start_wata_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_wata_enabled():
await callback.answer("❌ Оплата через WATA временно недоступна", show_alert=True)
return
message_text = texts.t(
"WATA_TOPUP_PROMPT",
(
"💳 <b>Оплата через WATA</b>\n\n"
"Введите сумму пополнения. Минимальная сумма — {min_amount}, максимальная — {max_amount}.\n"
"Оплата происходит через защищенную форму WATA."
),
).format(
min_amount=settings.format_price(settings.WATA_MIN_AMOUNT_KOPEKS),
max_amount=settings.format_price(settings.WATA_MAX_AMOUNT_KOPEKS),
)
keyboard = get_back_keyboard(db_user.language)
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="wata")
await callback.answer()
@error_handler
async def process_wata_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_wata_enabled():
await message.answer("❌ Оплата через WATA временно недоступна")
return
if amount_kopeks < settings.WATA_MIN_AMOUNT_KOPEKS:
await message.answer(
texts.t(
"WATA_AMOUNT_TOO_LOW",
"Минимальная сумма пополнения: {amount}",
).format(amount=settings.format_price(settings.WATA_MIN_AMOUNT_KOPEKS))
)
return
if amount_kopeks > settings.WATA_MAX_AMOUNT_KOPEKS:
await message.answer(
texts.t(
"WATA_AMOUNT_TOO_HIGH",
"Максимальная сумма пополнения: {amount}",
).format(amount=settings.format_price(settings.WATA_MAX_AMOUNT_KOPEKS))
)
return
payment_service = PaymentService(message.bot)
try:
result = await payment_service.create_wata_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
language=db_user.language,
)
except Exception as error: # pragma: no cover - handled by decorator logs
logger.exception("Ошибка создания WATA платежа: %s", error)
result = None
if not result or not result.get("payment_url"):
await message.answer(
texts.t(
"WATA_PAYMENT_ERROR",
"❌ Ошибка создания платежа WATA. Попробуйте позже или обратитесь в поддержку.",
)
)
await state.clear()
return
payment_url = result["payment_url"]
payment_link_id = result["payment_link_id"]
local_payment_id = result["local_payment_id"]
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t("WATA_PAY_BUTTON", "💳 Оплатить через WATA"),
url=payment_url,
)
],
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
callback_data=f"check_wata_{local_payment_id}",
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
]
)
message_template = texts.t(
"WATA_PAYMENT_INSTRUCTIONS",
(
"💳 <b>Оплата через WATA</b>\n\n"
"💰 Сумма: {amount}\n"
"🆔 ID платежа: {payment_id}\n\n"
"📱 <b>Инструкция:</b>\n"
"1. Нажмите кнопку 'Оплатить через WATA'\n"
"2. Следуйте подсказкам платежной системы\n"
"3. Подтвердите перевод\n"
"4. Средства зачислятся автоматически\n\n"
"❓ Если возникнут проблемы, обратитесь в {support}"
),
)
message_text = message_template.format(
amount=settings.format_price(amount_kopeks),
payment_id=payment_link_id,
support=settings.get_support_contact_display_html(),
)
await message.answer(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
await state.clear()
logger.info(
"Создан WATA платеж для пользователя %s: %s₽, ссылка: %s",
db_user.telegram_id,
amount_kopeks / 100,
payment_link_id,
)
@error_handler
async def check_wata_payment_status(
callback: types.CallbackQuery,
db: AsyncSession,
):
try:
local_payment_id = int(callback.data.split("_")[-1])
except (ValueError, IndexError):
await callback.answer("❌ Некорректный идентификатор платежа", show_alert=True)
return
payment_service = PaymentService(callback.bot)
status_info = await payment_service.get_wata_payment_status(db, local_payment_id)
if not status_info:
await callback.answer("❌ Платеж не найден", show_alert=True)
return
payment = status_info["payment"]
user_language = "ru"
try:
user = await fetch_user_by_id(db, payment.user_id)
if user and getattr(user, "language", None):
user_language = user.language
except Exception as error:
logger.debug("Не удалось получить пользователя для WATA статуса: %s", error)
texts = get_texts(user_language)
status_labels: Dict[str, Dict[str, str]] = {
"Opened": {"emoji": "", "label": texts.t("WATA_STATUS_OPENED", "Ожидает оплаты")},
"Closed": {"emoji": "", "label": texts.t("WATA_STATUS_CLOSED", "Обрабатывается")},
"Paid": {"emoji": "", "label": texts.t("WATA_STATUS_PAID", "Оплачен")},
"Declined": {"emoji": "", "label": texts.t("WATA_STATUS_DECLINED", "Отклонен")},
}
label_info = status_labels.get(payment.status, {"emoji": "", "label": texts.t("WATA_STATUS_UNKNOWN", "Неизвестно")})
message_lines = [
texts.t("WATA_STATUS_TITLE", "💳 <b>Статус платежа WATA</b>"),
"",
f"🆔 ID: {payment.payment_link_id}",
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}",
f"📊 Статус: {label_info['emoji']} {label_info['label']}",
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M') if payment.created_at else ''}",
]
if payment.is_paid:
message_lines.append("\n✅ Платеж успешно завершен! Средства уже на балансе.")
elif payment.status in {"Opened", "Closed"}:
message_lines.append(
"\n⏳ Платеж еще не завершен. Завершите оплату по ссылке и проверьте статус позже."
)
await callback.message.answer("\n".join(message_lines), parse_mode="HTML")
await callback.answer()
+340
View File
@@ -0,0 +1,340 @@
import logging
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.utils.decorators import error_handler
from app.states import BalanceStates
logger = logging.getLogger(__name__)
@error_handler
async def start_yookassa_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.is_yookassa_enabled():
await callback.answer("❌ Оплата картой через YooKassa временно недоступна", show_alert=True)
return
min_amount_rub = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
# Формируем текст сообщения в зависимости от настройки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
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} рублей:"
)
# Создаем клавиатуру
keyboard = get_back_keyboard(db_user.language)
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="yookassa")
await callback.answer()
@error_handler
async def start_yookassa_sbp_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.is_yookassa_enabled() or not settings.YOOKASSA_SBP_ENABLED:
await callback.answer("❌ Оплата через СБП временно недоступна", show_alert=True)
return
min_amount_rub = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
# Формируем текст сообщения в зависимости от настройки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
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} рублей:"
)
# Создаем клавиатуру
keyboard = get_back_keyboard(db_user.language)
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="yookassa_sbp")
await callback.answer()
@error_handler
async def process_yookassa_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.is_yookassa_enabled():
await message.answer("❌ Оплата через YooKassa временно недоступна")
return
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Минимальная сумма для оплаты картой: {min_rubles:.0f}")
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(',', ' '))
return
try:
payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_yookassa_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
receipt_email=None,
receipt_phone=None,
metadata={
"user_telegram_id": str(db_user.telegram_id),
"user_username": db_user.username or "",
"purpose": "balance_topup"
}
)
if not payment_result:
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
await state.clear()
return
confirmation_url = payment_result.get("confirmation_url")
if not confirmation_url:
await message.answer("❌ Ошибка получения ссылки для оплаты. Обратитесь в поддержку.")
await state.clear()
return
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="💳 Оплатить картой", url=confirmation_url)],
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await message.answer(
f"💳 <b>Оплата банковской картой</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
f"📱 <b>Инструкция:</b>\n"
f"1. Нажмите кнопку 'Оплатить картой'\n"
f"2. Введите данные вашей карты\n"
f"3. Подтвердите платеж\n"
f"4. Деньги поступят на баланс автоматически\n\n"
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
f"✅ Принимаем карты: Visa, MasterCard, МИР\n\n"
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}",
reply_markup=keyboard,
parse_mode="HTML"
)
await state.clear()
logger.info(f"Создан платеж YooKassa для пользователя {db_user.telegram_id}: "
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
except Exception as e:
logger.error(f"Ошибка создания YooKassa платежа: {e}")
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
await state.clear()
@error_handler
async def process_yookassa_sbp_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.is_yookassa_enabled() or not settings.YOOKASSA_SBP_ENABLED:
await message.answer("❌ Оплата через СБП временно недоступна")
return
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Минимальная сумма для оплаты через СБП: {min_rubles:.0f}")
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(',', ' '))
return
try:
payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_yookassa_sbp_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
receipt_email=None,
receipt_phone=None,
metadata={
"user_telegram_id": str(db_user.telegram_id),
"user_username": db_user.username or "",
"purpose": "balance_topup_sbp"
}
)
if not payment_result:
await message.answer("❌ Ошибка создания платежа через СБП. Попробуйте позже или обратитесь в поддержку.")
await state.clear()
return
confirmation_url = payment_result.get("confirmation_url")
if not confirmation_url:
await message.answer("❌ Ошибка получения ссылки для оплаты через СБП. Обратитесь в поддержку.")
await state.clear()
return
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🏦 Оплатить через СБП", url=confirmation_url)],
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await message.answer(
f"🏦 <b>Оплата через СБП</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
f"📱 <b>Инструкция:</b>\n"
f"1. Нажмите кнопку 'Оплатить через СБП'\n"
f"2. Вас перенаправит в приложение вашего банка\n"
f"3. Подтвердите платеж через СБП\n"
f"4. Деньги поступят на баланс автоматически\n\n"
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
f"✅ Принимаем СБП от всех банков-участников\n\n"
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}",
reply_markup=keyboard,
parse_mode="HTML"
)
await state.clear()
logger.info(f"Создан платеж YooKassa СБП для пользователя {db_user.telegram_id}: "
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
except Exception as e:
logger.error(f"Ошибка создания YooKassa СБП платежа: {e}")
await message.answer("❌ Ошибка создания платежа через СБП. Попробуйте позже или обратитесь в поддержку.")
await state.clear()
@error_handler
async def check_yookassa_payment_status(
callback: types.CallbackQuery,
db: AsyncSession
):
try:
local_payment_id = int(callback.data.split('_')[-1])
from app.database.crud.yookassa import get_yookassa_payment_by_local_id
payment = await get_yookassa_payment_by_local_id(db, local_payment_id)
if not payment:
await callback.answer("❌ Платеж не найден", show_alert=True)
return
status_emoji = {
"pending": "",
"waiting_for_capture": "",
"succeeded": "",
"canceled": "",
"failed": ""
}
status_text = {
"pending": "Ожидает оплаты",
"waiting_for_capture": "Ожидает подтверждения",
"succeeded": "Оплачен",
"canceled": "Отменен",
"failed": "Ошибка"
}
emoji = status_emoji.get(payment.status, "")
status = status_text.get(payment.status, "Неизвестно")
message_text = (f"💳 Статус платежа:\n\n"
f"🆔 ID: {payment.yookassa_payment_id[:8]}...\n"
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
f"📊 Статус: {emoji} {status}\n"
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n")
if payment.is_succeeded:
message_text += "\n✅ Платеж успешно завершен!\n\nСредства зачислены на баланс."
elif payment.is_pending:
message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше."
elif payment.is_failed:
message_text += (
f"\n❌ Платеж не прошел. Обратитесь в {settings.get_support_contact_display()}"
)
await callback.answer(message_text, show_alert=True)
except Exception as e:
logger.error(f"Ошибка проверки статуса платежа: {e}")
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
+804 -4
View File
@@ -1,4 +1,7 @@
import html
import logging
from decimal import Decimal
from typing import Dict, List
from aiogram import Dispatcher, types, F
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
@@ -7,9 +10,18 @@ from datetime import datetime
from app.config import settings
from app.database.crud.user import get_user_by_telegram_id, update_user
from app.keyboards.inline import get_main_menu_keyboard, get_language_selection_keyboard
from app.database.crud.promo_group import (
get_auto_assign_promo_groups,
has_auto_assign_promo_groups,
)
from app.database.crud.transaction import get_user_total_spent_kopeks
from app.keyboards.inline import (
get_main_menu_keyboard,
get_language_selection_keyboard,
get_info_menu_keyboard,
)
from app.localization.texts import get_texts, get_rules
from app.database.models import User
from app.database.models import PromoGroup, User
from app.database.crud.user_message import get_random_active_message
from app.services.subscription_checkout_service import (
has_subscription_checkout_draft,
@@ -17,10 +29,118 @@ from app.services.subscription_checkout_service import (
)
from app.utils.photo_message import edit_or_answer_photo
from app.services.support_settings_service import SupportSettingsService
from app.services.main_menu_button_service import MainMenuButtonService
from app.services.user_cart_service import user_cart_service
from app.utils.promo_offer import (
build_promo_offer_hint,
build_test_access_hint,
)
from app.services.privacy_policy_service import PrivacyPolicyService
from app.services.public_offer_service import PublicOfferService
from app.services.faq_service import FaqService
from app.utils.pricing_utils import format_period_description
logger = logging.getLogger(__name__)
def _format_rubles(amount_kopeks: int) -> str:
rubles = Decimal(amount_kopeks) / Decimal(100)
if rubles == rubles.to_integral_value():
formatted = f"{rubles:,.0f}"
else:
formatted = f"{rubles:,.2f}"
return f"{formatted.replace(',', ' ')}"
def _collect_period_discounts(group: PromoGroup) -> Dict[int, int]:
discounts: Dict[int, int] = {}
raw_discounts = getattr(group, "period_discounts", None)
if isinstance(raw_discounts, dict):
for key, value in raw_discounts.items():
try:
period = int(key)
percent = int(value)
except (TypeError, ValueError):
continue
normalized_percent = max(0, min(100, percent))
if normalized_percent > 0:
discounts[period] = normalized_percent
if group.is_default and settings.is_base_promo_group_period_discount_enabled():
try:
base_discounts = settings.get_base_promo_group_period_discounts() or {}
except Exception:
base_discounts = {}
for key, value in base_discounts.items():
try:
period = int(key)
percent = int(value)
except (TypeError, ValueError):
continue
if period in discounts:
continue
normalized_percent = max(0, min(100, percent))
if normalized_percent > 0:
discounts[period] = normalized_percent
return dict(sorted(discounts.items()))
def _build_group_discount_lines(group: PromoGroup, texts, language: str) -> list[str]:
lines: list[str] = []
if getattr(group, "server_discount_percent", 0) > 0:
lines.append(
texts.t("PROMO_GROUP_DISCOUNT_SERVERS", "🌍 Серверы: {percent}%").format(
percent=group.server_discount_percent
)
)
if getattr(group, "traffic_discount_percent", 0) > 0:
lines.append(
texts.t("PROMO_GROUP_DISCOUNT_TRAFFIC", "📊 Трафик: {percent}%").format(
percent=group.traffic_discount_percent
)
)
if getattr(group, "device_discount_percent", 0) > 0:
lines.append(
texts.t("PROMO_GROUP_DISCOUNT_DEVICES", "📱 Доп. устройства: {percent}%").format(
percent=group.device_discount_percent
)
)
period_discounts = _collect_period_discounts(group)
if period_discounts:
lines.append(
texts.t(
"PROMO_GROUP_PERIOD_DISCOUNTS_HEADER",
"⏳ Скидки за длительный период:",
)
)
for period_days, percent in period_discounts.items():
lines.append(
texts.t(
"PROMO_GROUP_PERIOD_DISCOUNT_ITEM",
"{period}{percent}%",
).format(
period=format_period_description(period_days, language),
percent=percent,
)
)
return lines
async def show_main_menu(
callback: types.CallbackQuery,
db_user: User,
@@ -43,12 +163,28 @@ async def show_main_menu(
draft_exists = await has_subscription_checkout_draft(db_user.id)
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
# Проверяем наличие сохраненной корзины в Redis
try:
has_saved_cart = await user_cart_service.has_user_cart(db_user.id)
except Exception as e:
logger.error(f"Ошибка проверки сохраненной корзины для пользователя {db_user.id}: {e}")
has_saved_cart = False
is_admin = settings.is_admin(db_user.telegram_id)
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(
db_user.telegram_id
)
custom_buttons = []
if not settings.is_text_main_menu_mode():
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
await edit_or_answer_photo(
callback=callback,
caption=menu_text,
@@ -62,13 +198,32 @@ async def show_main_menu(
balance_kopeks=db_user.balance_kopeks,
subscription=db_user.subscription,
show_resume_checkout=show_resume_checkout,
has_saved_cart=has_saved_cart, # Добавляем параметр для отображения уведомления о сохраненной корзине
custom_buttons=custom_buttons,
),
parse_mode="HTML",
force_text=settings.is_text_main_menu_mode(),
)
if not skip_callback_answer:
await callback.answer()
async def handle_profile_unavailable(callback: types.CallbackQuery) -> None:
language = getattr(callback.from_user, "language_code", None) or settings.DEFAULT_LANGUAGE
try:
texts = get_texts(language)
except Exception:
texts = get_texts()
await callback.answer(
texts.t(
"MENU_PROFILE_UNAVAILABLE",
"❗️ Личный кабинет пока недоступен. Попробуйте позже.",
),
show_alert=True,
)
async def show_service_rules(
callback: types.CallbackQuery,
db_user: User,
@@ -91,6 +246,558 @@ async def show_service_rules(
await callback.answer()
async def show_info_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
header = texts.t("MENU_INFO_HEADER", "️ <b>Инфо</b>")
prompt = texts.t("MENU_INFO_PROMPT", "Выберите раздел:")
caption = f"{header}\n\n{prompt}" if prompt else header
privacy_enabled = await PrivacyPolicyService.is_policy_enabled(db, db_user.language)
public_offer_enabled = await PublicOfferService.is_offer_enabled(db, db_user.language)
faq_enabled = await FaqService.is_enabled(db, db_user.language)
promo_groups_available = await has_auto_assign_promo_groups(db)
await edit_or_answer_photo(
callback=callback,
caption=caption,
keyboard=get_info_menu_keyboard(
language=db_user.language,
show_privacy_policy=privacy_enabled,
show_public_offer=public_offer_enabled,
show_faq=faq_enabled,
show_promo_groups=promo_groups_available,
),
parse_mode="HTML",
)
await callback.answer()
async def show_promo_groups_info(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
promo_groups = await get_auto_assign_promo_groups(db)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")]]
)
if not promo_groups:
empty_text = texts.t(
"PROMO_GROUPS_INFO_EMPTY",
"Промогруппы с автовыдачей ещё не настроены.",
)
header = texts.t("PROMO_GROUPS_INFO_HEADER", "🎯 <b>Промогруппы</b>")
message = f"{header}\n\n{empty_text}" if empty_text else header
await callback.message.edit_text(
message,
reply_markup=keyboard,
parse_mode="HTML",
)
await callback.answer()
return
total_spent_kopeks = await get_user_total_spent_kopeks(db, db_user.id)
total_spent_text = _format_rubles(total_spent_kopeks)
sorted_groups = sorted(
promo_groups,
key=lambda group: (group.auto_assign_total_spent_kopeks or 0, group.id),
)
achieved_groups: List[PromoGroup] = [
group
for group in sorted_groups
if (group.auto_assign_total_spent_kopeks or 0) > 0
and total_spent_kopeks >= (group.auto_assign_total_spent_kopeks or 0)
]
current_group = next(
(group for group in sorted_groups if group.id == db_user.promo_group_id),
None,
)
if not current_group and achieved_groups:
current_group = achieved_groups[-1]
next_group = next(
(
group
for group in sorted_groups
if (group.auto_assign_total_spent_kopeks or 0) > total_spent_kopeks
),
None,
)
header = texts.t("PROMO_GROUPS_INFO_HEADER", "🎯 <b>Промогруппы</b>")
lines: List[str] = [header, ""]
spent_line = texts.t(
"PROMO_GROUPS_INFO_TOTAL_SPENT",
"💰 Потрачено в боте: {amount}",
).format(amount=total_spent_text)
lines.append(spent_line)
if current_group:
lines.append(
texts.t(
"PROMO_GROUPS_INFO_CURRENT_LEVEL",
"🏆 Текущий уровень: {name}",
).format(name=html.escape(current_group.name)),
)
else:
lines.append(
texts.t(
"PROMO_GROUPS_INFO_NO_LEVEL",
"🏆 Текущий уровень: пока не получен",
)
)
if next_group:
remaining_kopeks = (next_group.auto_assign_total_spent_kopeks or 0) - total_spent_kopeks
lines.append(
texts.t(
"PROMO_GROUPS_INFO_NEXT_LEVEL",
"📈 До уровня «{name}»: осталось {amount}",
).format(
name=html.escape(next_group.name),
amount=_format_rubles(max(remaining_kopeks, 0)),
)
)
else:
lines.append(
texts.t(
"PROMO_GROUPS_INFO_MAX_LEVEL",
"🏆 Вы уже получили максимальный уровень скидок!",
)
)
lines.extend(["", texts.t("PROMO_GROUPS_INFO_LEVELS_HEADER", "📋 Уровни с автовыдачей:")])
for group in sorted_groups:
threshold = group.auto_assign_total_spent_kopeks or 0
status_icon = "" if total_spent_kopeks >= threshold else "🔒"
lines.append(
texts.t(
"PROMO_GROUPS_INFO_LEVEL_LINE",
"{status} <b>{name}</b> — от {amount}",
).format(
status=status_icon,
name=html.escape(group.name),
amount=_format_rubles(threshold),
)
)
discount_lines = _build_group_discount_lines(group, texts, db_user.language)
for discount_line in discount_lines:
if discount_line:
lines.append(f" {discount_line}")
lines.append("")
while lines and not lines[-1]:
lines.pop()
message_text = "\n".join(lines)
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
await callback.answer()
async def show_faq_pages(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
pages = await FaqService.get_pages(db, db_user.language)
if not pages:
await callback.answer(
texts.t("FAQ_NOT_AVAILABLE", "FAQ временно недоступен."),
show_alert=True,
)
return
header = texts.t("FAQ_HEADER", "❓ <b>FAQ</b>")
prompt = texts.t("FAQ_PAGES_PROMPT", "Выберите вопрос:" )
caption = f"{header}\n\n{prompt}" if prompt else header
buttons: list[list[types.InlineKeyboardButton]] = []
for index, page in enumerate(pages, start=1):
raw_title = (page.title or "").strip()
if not raw_title:
raw_title = texts.t("FAQ_PAGE_UNTITLED", "Без названия")
if len(raw_title) > 60:
raw_title = f"{raw_title[:57]}..."
buttons.append([
types.InlineKeyboardButton(
text=f"{index}. {raw_title}",
callback_data=f"menu_faq_page:{page.id}:1",
)
])
buttons.append([
types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")
])
await callback.message.edit_text(
caption,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
)
await callback.answer()
async def show_faq_page(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
raw_data = callback.data or ""
parts = raw_data.split(":")
page_id = None
requested_page = 1
if len(parts) >= 2:
try:
page_id = int(parts[1])
except ValueError:
page_id = None
if len(parts) >= 3:
try:
requested_page = int(parts[2])
except ValueError:
requested_page = 1
if not page_id:
await callback.answer()
return
page = await FaqService.get_page(db, page_id, db_user.language)
if not page or not page.is_active:
await callback.answer(
texts.t("FAQ_PAGE_NOT_AVAILABLE", "Эта страница FAQ недоступна."),
show_alert=True,
)
return
content_pages = FaqService.split_content_into_pages(page.content)
if not content_pages:
await callback.answer(
texts.t("FAQ_PAGE_EMPTY", "Текст для этой страницы ещё не добавлен."),
show_alert=True,
)
return
total_pages = len(content_pages)
current_page = max(1, min(requested_page, total_pages))
header = texts.t("FAQ_HEADER", "❓ <b>FAQ</b>")
title_template = texts.t("FAQ_PAGE_TITLE", "<b>{title}</b>")
page_title = (page.title or "").strip()
if not page_title:
page_title = texts.t("FAQ_PAGE_UNTITLED", "Без названия")
title_block = title_template.format(title=html.escape(page_title))
body = content_pages[current_page - 1]
footer_template = texts.t(
"FAQ_PAGE_FOOTER",
"Страница {current} из {total}",
)
footer = ""
if total_pages > 1 and footer_template:
try:
footer = footer_template.format(current=current_page, total=total_pages)
except Exception:
footer = f"{current_page}/{total_pages}"
parts_to_join = [header, title_block]
if body:
parts_to_join.append(body)
if footer:
parts_to_join.append(f"<code>{footer}</code>")
message_text = "\n\n".join(segment for segment in parts_to_join if segment)
keyboard_rows: list[list[types.InlineKeyboardButton]] = []
if total_pages > 1:
nav_row: list[types.InlineKeyboardButton] = []
if current_page > 1:
nav_row.append(
types.InlineKeyboardButton(
text=texts.t("PAGINATION_PREV", "⬅️"),
callback_data=f"menu_faq_page:{page.id}:{current_page - 1}",
)
)
nav_row.append(
types.InlineKeyboardButton(
text=f"{current_page}/{total_pages}",
callback_data="noop",
)
)
if current_page < total_pages:
nav_row.append(
types.InlineKeyboardButton(
text=texts.t("PAGINATION_NEXT", "➡️"),
callback_data=f"menu_faq_page:{page.id}:{current_page + 1}",
)
)
keyboard_rows.append(nav_row)
keyboard_rows.append([
types.InlineKeyboardButton(
text=texts.t("FAQ_BACK_TO_LIST", "⬅️ К списку FAQ"),
callback_data="menu_faq",
)
])
keyboard_rows.append([
types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")
])
await callback.message.edit_text(
message_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
)
await callback.answer()
async def show_privacy_policy(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
raw_page = 1
if callback.data and ":" in callback.data:
try:
raw_page = int(callback.data.split(":", 1)[1])
except ValueError:
raw_page = 1
if raw_page < 1:
raw_page = 1
policy = await PrivacyPolicyService.get_active_policy(db, db_user.language)
if not policy:
await callback.answer(
texts.t(
"PRIVACY_POLICY_NOT_AVAILABLE",
"Политика конфиденциальности временно недоступна.",
),
show_alert=True,
)
return
pages = PrivacyPolicyService.split_content_into_pages(policy.content)
if not pages:
await callback.answer(
texts.t(
"PRIVACY_POLICY_EMPTY_ALERT",
"Политика конфиденциальности ещё не заполнена.",
),
show_alert=True,
)
return
total_pages = len(pages)
current_page = raw_page if raw_page <= total_pages else total_pages
header = texts.t(
"PRIVACY_POLICY_HEADER",
"🛡️ <b>Политика конфиденциальности</b>",
)
body = pages[current_page - 1]
footer_template = texts.t(
"PRIVACY_POLICY_PAGE_INFO",
"Страница {current} из {total}",
)
footer = ""
if total_pages > 1 and footer_template:
try:
footer = footer_template.format(current=current_page, total=total_pages)
except Exception:
footer = f"{current_page}/{total_pages}"
message_text = header
if body:
message_text += f"\n\n{body}"
if footer:
message_text += f"\n\n<code>{footer}</code>"
keyboard_rows: list[list[types.InlineKeyboardButton]] = []
if total_pages > 1:
nav_row: list[types.InlineKeyboardButton] = []
if current_page > 1:
nav_row.append(
types.InlineKeyboardButton(
text=texts.t("PAGINATION_PREV", "⬅️"),
callback_data=f"menu_privacy_policy:{current_page - 1}",
)
)
nav_row.append(
types.InlineKeyboardButton(
text=f"{current_page}/{total_pages}",
callback_data="noop",
)
)
if current_page < total_pages:
nav_row.append(
types.InlineKeyboardButton(
text=texts.t("PAGINATION_NEXT", "➡️"),
callback_data=f"menu_privacy_policy:{current_page + 1}",
)
)
keyboard_rows.append(nav_row)
keyboard_rows.append(
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")]
)
await callback.message.edit_text(
message_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
)
await callback.answer()
async def show_public_offer(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
raw_page = 1
if callback.data and ":" in callback.data:
try:
raw_page = int(callback.data.split(":", 1)[1])
except ValueError:
raw_page = 1
if raw_page < 1:
raw_page = 1
offer = await PublicOfferService.get_active_offer(db, db_user.language)
if not offer:
await callback.answer(
texts.t(
"PUBLIC_OFFER_NOT_AVAILABLE",
"Публичная оферта временно недоступна.",
),
show_alert=True,
)
return
pages = PublicOfferService.split_content_into_pages(offer.content)
if not pages:
await callback.answer(
texts.t(
"PUBLIC_OFFER_EMPTY_ALERT",
"Публичная оферта ещё не заполнена.",
),
show_alert=True,
)
return
total_pages = len(pages)
current_page = raw_page if raw_page <= total_pages else total_pages
header = texts.t(
"PUBLIC_OFFER_HEADER",
"📄 <b>Публичная оферта</b>",
)
body = pages[current_page - 1]
footer_template = texts.t(
"PUBLIC_OFFER_PAGE_INFO",
"Страница {current} из {total}",
)
footer = ""
if total_pages > 1 and footer_template:
try:
footer = footer_template.format(current=current_page, total=total_pages)
except Exception:
footer = f"{current_page}/{total_pages}"
message_text = header
if body:
message_text += f"\n\n{body}"
if footer:
message_text += f"\n\n<code>{footer}</code>"
keyboard_rows: list[list[types.InlineKeyboardButton]] = []
if total_pages > 1:
nav_row: list[types.InlineKeyboardButton] = []
if current_page > 1:
nav_row.append(
types.InlineKeyboardButton(
text=texts.t("PAGINATION_PREV", "⬅️"),
callback_data=f"menu_public_offer:{current_page - 1}",
)
)
nav_row.append(
types.InlineKeyboardButton(
text=f"{current_page}/{total_pages}",
callback_data="noop",
)
)
if current_page < total_pages:
nav_row.append(
types.InlineKeyboardButton(
text=texts.t("PAGINATION_NEXT", "➡️"),
callback_data=f"menu_public_offer:{current_page + 1}",
)
)
keyboard_rows.append(nav_row)
keyboard_rows.append(
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")]
)
await callback.message.edit_text(
message_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
)
await callback.answer()
async def show_language_menu(
callback: types.CallbackQuery,
db_user: User,
@@ -195,12 +902,28 @@ async def handle_back_to_menu(
draft_exists = await has_subscription_checkout_draft(db_user.id)
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
# Проверяем наличие сохраненной корзины в Redis
try:
has_saved_cart = await user_cart_service.has_user_cart(db_user.id)
except Exception as e:
logger.error(f"Ошибка проверки сохраненной корзины для пользователя {db_user.id}: {e}")
has_saved_cart = False
is_admin = settings.is_admin(db_user.telegram_id)
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(
db_user.telegram_id
)
custom_buttons = []
if not settings.is_text_main_menu_mode():
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
await edit_or_answer_photo(
callback=callback,
caption=menu_text,
@@ -214,8 +937,11 @@ async def handle_back_to_menu(
balance_kopeks=db_user.balance_kopeks,
subscription=db_user.subscription,
show_resume_checkout=show_resume_checkout,
has_saved_cart=has_saved_cart, # Добавляем параметр для отображения уведомления о сохраненной корзине
custom_buttons=custom_buttons,
),
parse_mode="HTML",
force_text=settings.is_text_main_menu_mode(),
)
await callback.answer()
@@ -300,9 +1026,38 @@ async def get_main_menu_text(user, texts, db: AsyncSession):
user_name=user.full_name,
subscription_status=_get_subscription_status(user, texts)
)
action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:")
info_sections: list[str] = []
try:
promo_hint = await build_promo_offer_hint(db, user, texts)
if promo_hint:
info_sections.append(promo_hint.strip())
except Exception as hint_error:
logger.debug(
"Не удалось построить подсказку промо-предложения для пользователя %s: %s",
getattr(user, "id", None),
hint_error,
)
try:
test_access_hint = await build_test_access_hint(db, user, texts)
if test_access_hint:
info_sections.append(test_access_hint.strip())
except Exception as test_error:
logger.debug(
"Не удалось построить подсказку тестового доступа для пользователя %s: %s",
getattr(user, "id", None),
test_error,
)
if info_sections:
extra_block = "\n\n".join(section for section in info_sections if section)
if extra_block:
base_text = _insert_random_message(base_text, extra_block, action_prompt)
try:
random_message = await get_random_active_message(db)
if random_message:
@@ -320,12 +1075,57 @@ def register_handlers(dp: Dispatcher):
handle_back_to_menu,
F.data == "back_to_menu"
)
dp.callback_query.register(
handle_profile_unavailable,
F.data == "menu_profile_unavailable",
)
dp.callback_query.register(
show_service_rules,
F.data == "menu_rules"
)
dp.callback_query.register(
show_info_menu,
F.data == "menu_info",
)
dp.callback_query.register(
show_promo_groups_info,
F.data == "menu_info_promo_groups",
)
dp.callback_query.register(
show_faq_pages,
F.data == "menu_faq",
)
dp.callback_query.register(
show_faq_page,
F.data.startswith("menu_faq_page:"),
)
dp.callback_query.register(
show_privacy_policy,
F.data == "menu_privacy_policy",
)
dp.callback_query.register(
show_privacy_policy,
F.data.startswith("menu_privacy_policy:"),
)
dp.callback_query.register(
show_public_offer,
F.data == "menu_public_offer",
)
dp.callback_query.register(
show_public_offer,
F.data.startswith("menu_public_offer:"),
)
dp.callback_query.register(
show_language_menu,
F.data == "menu_language"
+9 -3
View File
@@ -1,7 +1,9 @@
import logging
from decimal import Decimal, ROUND_HALF_UP
from aiogram import Dispatcher, types, F
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.services.payment_service import PaymentService
from app.external.telegram_stars import TelegramStarsService
@@ -114,6 +116,8 @@ async def handle_successful_payment(
if success:
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount)
amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP))
amount_text = settings.format_price(amount_kopeks).replace("", "")
keyboard = await payment_service.build_topup_success_keyboard(user)
@@ -129,7 +133,7 @@ async def handle_successful_payment(
"Спасибо за пополнение! 🚀",
).format(
stars_spent=payment.total_amount,
amount=int(rubles_amount),
amount=amount_text,
transaction_id=transaction_id_short,
),
parse_mode="HTML",
@@ -137,8 +141,10 @@ async def handle_successful_payment(
)
logger.info(
f"✅ Stars платеж успешно обработан: "
f"пользователь {user.id}, {payment.total_amount} звезд → {int(rubles_amount)}"
"✅ Stars платеж успешно обработан: пользователь %s, %s звезд → %s",
user.id,
payment.total_amount,
settings.format_price(amount_kopeks),
)
else:
logger.error(f"Ошибка обработки Stars платежа для пользователя {user.id}")
+146 -1
View File
@@ -31,8 +31,14 @@ from app.services.campaign_service import AdvertisingCampaignService
from app.services.admin_notification_service import AdminNotificationService
from app.services.subscription_service import SubscriptionService
from app.services.support_settings_service import SupportSettingsService
from app.services.main_menu_button_service import MainMenuButtonService
from app.utils.user_utils import generate_unique_referral_code
from app.utils.promo_offer import (
build_promo_offer_hint,
build_test_access_hint,
)
from app.database.crud.user_message import get_random_active_message
from app.database.crud.subscription import decrement_subscription_server_counts
logger = logging.getLogger(__name__)
@@ -216,12 +222,28 @@ async def _continue_registration_after_language(
async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, db_user=None):
logger.info(f"🚀 START: Обработка /start от {message.from_user.id}")
data = await state.get_data() or {}
pending_start_payload = data.pop("pending_start_payload", None)
referral_code = None
campaign = None
start_args = message.text.split()
start_parameter = None
if len(start_args) > 1:
start_parameter = start_args[1]
elif pending_start_payload:
start_parameter = pending_start_payload
logger.info(
"📦 START: Используем сохраненный payload '%s'",
pending_start_payload,
)
if pending_start_payload is not None:
await state.set_data(data)
if start_parameter:
campaign = await get_campaign_by_start_parameter(
db,
start_parameter,
@@ -329,6 +351,15 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
user.telegram_id
)
custom_buttons = []
if not settings.is_text_main_menu_mode():
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
await message.answer(
menu_text,
reply_markup=get_main_menu_keyboard(
@@ -340,6 +371,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
balance_kopeks=user.balance_kopeks,
subscription=user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
),
parse_mode="HTML"
)
@@ -358,6 +390,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
from sqlalchemy import delete
if user.subscription:
await decrement_subscription_server_counts(db, user.subscription)
await db.execute(
delete(SubscriptionServer).where(
SubscriptionServer.subscription_id == user.subscription.id
@@ -742,6 +775,15 @@ async def complete_registration_from_callback(
and SupportSettingsService.is_moderator(existing_user.telegram_id)
)
custom_buttons = []
if not settings.is_text_main_menu_mode():
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
try:
await callback.message.answer(
menu_text,
@@ -754,6 +796,7 @@ async def complete_registration_from_callback(
balance_kopeks=existing_user.balance_kopeks,
subscription=existing_user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
),
parse_mode="HTML"
)
@@ -914,6 +957,15 @@ async def complete_registration_from_callback(
and SupportSettingsService.is_moderator(user.telegram_id)
)
custom_buttons = []
if not settings.is_text_main_menu_mode():
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
try:
await callback.message.answer(
menu_text,
@@ -926,6 +978,7 @@ async def complete_registration_from_callback(
balance_kopeks=user.balance_kopeks,
subscription=user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
),
parse_mode="HTML"
)
@@ -980,6 +1033,15 @@ async def complete_registration(
and SupportSettingsService.is_moderator(existing_user.telegram_id)
)
custom_buttons = []
if not settings.is_text_main_menu_mode():
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
try:
await message.answer(
menu_text,
@@ -992,6 +1054,7 @@ async def complete_registration(
balance_kopeks=existing_user.balance_kopeks,
subscription=existing_user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
),
parse_mode="HTML"
)
@@ -1152,6 +1215,15 @@ async def complete_registration(
and SupportSettingsService.is_moderator(user.telegram_id)
)
custom_buttons = []
if not settings.is_text_main_menu_mode():
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
try:
await message.answer(
menu_text,
@@ -1164,6 +1236,7 @@ async def complete_registration(
balance_kopeks=user.balance_kopeks,
subscription=user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
),
parse_mode="HTML"
)
@@ -1279,6 +1352,35 @@ async def get_main_menu_text(user, texts, db: AsyncSession):
action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:")
info_sections: list[str] = []
try:
promo_hint = await build_promo_offer_hint(db, user, texts)
if promo_hint:
info_sections.append(promo_hint.strip())
except Exception as hint_error:
logger.debug(
"Не удалось построить подсказку промо-предложения для пользователя %s: %s",
getattr(user, "id", None),
hint_error,
)
try:
test_access_hint = await build_test_access_hint(db, user, texts)
if test_access_hint:
info_sections.append(test_access_hint.strip())
except Exception as test_error:
logger.debug(
"Не удалось построить подсказку тестового доступа для пользователя %s: %s",
getattr(user, "id", None),
test_error,
)
if info_sections:
extra_block = "\n\n".join(section for section in info_sections if section)
if extra_block:
base_text = _insert_random_message(base_text, extra_block, action_prompt)
try:
random_message = await get_random_active_message(db)
if random_message:
@@ -1323,6 +1425,41 @@ async def required_sub_channel_check(
try:
state_data = await state.get_data() or {}
pending_start_payload = state_data.pop("pending_start_payload", None)
state_updated = pending_start_payload is not None
if pending_start_payload:
logger.info(
"📦 CHANNEL CHECK: Найден сохраненный payload '%s'",
pending_start_payload,
)
if "campaign_id" not in state_data and "referral_code" not in state_data:
campaign = await get_campaign_by_start_parameter(
db,
pending_start_payload,
only_active=True,
)
if campaign:
state_data["campaign_id"] = campaign.id
logger.info(
"📣 CHANNEL CHECK: Кампания %s восстановлена из payload",
campaign.id,
)
else:
state_data["referral_code"] = pending_start_payload
logger.info(
"🎯 CHANNEL CHECK: Payload интерпретирован как реферальный код",
)
else:
logger.debug(
"️ CHANNEL CHECK: Payload уже обработан ранее, пропускаем восстановление",
)
if state_updated:
await state.set_data(state_data)
user = db_user
if not user:
user = await get_user_by_telegram_id(db, query.from_user.id)
@@ -1398,6 +1535,13 @@ async def required_sub_channel_check(
and SupportSettingsService.is_moderator(user.telegram_id)
)
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
keyboard = get_main_menu_keyboard(
language=user.language,
is_admin=is_admin,
@@ -1407,6 +1551,7 @@ async def required_sub_channel_check(
balance_kopeks=user.balance_kopeks,
subscription=user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
)
if settings.ENABLE_LOGO_MODE:
File diff suppressed because it is too large Load Diff
+211
View File
@@ -0,0 +1,211 @@
# Automatically generated module exports
from .autopay import (
handle_autopay_menu,
handle_subscription_cancel,
handle_subscription_config_back,
set_autopay_days,
show_autopay_days,
toggle_autopay,
)
from .common import (
build_redirect_link,
create_deep_link,
format_additional_section,
format_traffic_display,
get_apps_for_device,
get_confirm_switch_traffic_keyboard,
get_device_name,
get_localized_value,
get_reset_devices_confirm_keyboard,
get_step_description,
get_traffic_switch_keyboard,
load_app_config,
update_traffic_prices,
validate_traffic_price,
)
from .countries import (
apply_countries_changes,
confirm_add_countries_to_subscription,
countries_continue,
get_countries_price_by_uuids_fallback,
handle_add_countries,
handle_add_country_to_subscription,
handle_manage_country,
select_country,
)
from .devices import (
confirm_add_devices,
confirm_change_devices,
confirm_reset_devices,
execute_change_devices,
get_current_devices_count,
get_current_devices_detailed,
get_servers_display_names,
handle_all_devices_reset_from_management,
handle_app_selection,
handle_change_devices,
handle_device_guide,
handle_device_management,
handle_devices_page,
handle_reset_devices,
handle_single_device_reset,
handle_specific_app_guide,
show_device_connection_help,
show_devices_page,
)
from .happ import (
handle_happ_download_back,
handle_happ_download_close,
handle_happ_download_platform_choice,
handle_happ_download_request,
)
from .links import (
handle_connect_subscription,
handle_open_subscription_link,
)
from .notifications import (
send_extension_notification,
send_purchase_notification,
send_trial_notification,
)
from .pricing import (
get_subscription_cost,
get_subscription_info_text,
)
from .promo import (
claim_discount_offer,
handle_promo_offer_close,
)
from .purchase import (
activate_trial,
clear_saved_cart,
confirm_extend_subscription,
confirm_purchase,
create_paid_subscription_with_traffic_mode,
devices_continue,
handle_extend_subscription,
handle_subscription_settings,
register_handlers,
resume_subscription_checkout,
return_to_saved_cart,
save_cart_and_redirect_to_topup,
select_devices,
select_period,
show_subscription_info,
show_trial_offer,
start_subscription_purchase,
)
from .traffic import (
add_traffic,
confirm_reset_traffic,
confirm_switch_traffic,
execute_switch_traffic,
get_traffic_packages_info,
handle_add_traffic,
handle_no_traffic_packages,
handle_reset_traffic,
handle_switch_traffic,
refresh_traffic_config,
select_traffic,
)
__all__ = [
'activate_trial',
'add_traffic',
'apply_countries_changes',
'build_redirect_link',
'claim_discount_offer',
'clear_saved_cart',
'confirm_add_countries_to_subscription',
'confirm_add_devices',
'confirm_change_devices',
'confirm_extend_subscription',
'confirm_purchase',
'confirm_reset_devices',
'confirm_reset_traffic',
'confirm_switch_traffic',
'countries_continue',
'create_deep_link',
'create_paid_subscription_with_traffic_mode',
'devices_continue',
'execute_change_devices',
'execute_switch_traffic',
'format_additional_section',
'format_traffic_display',
'get_apps_for_device',
'get_confirm_switch_traffic_keyboard',
'get_countries_price_by_uuids_fallback',
'get_current_devices_count',
'get_current_devices_detailed',
'get_device_name',
'get_localized_value',
'get_reset_devices_confirm_keyboard',
'get_servers_display_names',
'get_step_description',
'get_subscription_cost',
'get_subscription_info_text',
'get_traffic_packages_info',
'get_traffic_switch_keyboard',
'handle_add_countries',
'handle_add_country_to_subscription',
'handle_add_traffic',
'handle_all_devices_reset_from_management',
'handle_app_selection',
'handle_autopay_menu',
'handle_change_devices',
'handle_connect_subscription',
'handle_device_guide',
'handle_device_management',
'handle_devices_page',
'handle_extend_subscription',
'handle_happ_download_back',
'handle_happ_download_close',
'handle_happ_download_platform_choice',
'handle_happ_download_request',
'handle_manage_country',
'handle_no_traffic_packages',
'handle_open_subscription_link',
'handle_promo_offer_close',
'handle_reset_devices',
'handle_reset_traffic',
'handle_single_device_reset',
'handle_specific_app_guide',
'handle_subscription_cancel',
'handle_subscription_config_back',
'handle_subscription_settings',
'handle_switch_traffic',
'load_app_config',
'refresh_traffic_config',
'register_handlers',
'resume_subscription_checkout',
'return_to_saved_cart',
'save_cart_and_redirect_to_topup',
'select_country',
'select_devices',
'select_period',
'select_traffic',
'send_extension_notification',
'send_purchase_notification',
'send_trial_notification',
'set_autopay_days',
'show_autopay_days',
'show_device_connection_help',
'show_devices_page',
'show_subscription_info',
'show_trial_offer',
'start_subscription_purchase',
'toggle_autopay',
'update_traffic_prices',
'validate_traffic_price',
]
+255
View File
@@ -0,0 +1,255 @@
import base64
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any, Tuple, Optional
from urllib.parse import quote
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings, PERIOD_PRICES, get_traffic_prices
from app.database.crud.discount_offer import (
get_offer_by_id,
mark_offer_claimed,
)
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.subscription import (
create_trial_subscription,
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
update_subscription_autopay
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import (
User, TransactionType, SubscriptionStatus,
Subscription
)
from app.keyboards.inline import (
get_subscription_keyboard, get_trial_keyboard,
get_subscription_period_keyboard, get_traffic_packages_keyboard,
get_countries_keyboard, get_devices_keyboard,
get_subscription_confirm_keyboard, get_autopay_keyboard,
get_autopay_days_keyboard, get_back_keyboard,
get_add_traffic_keyboard,
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
get_manage_countries_keyboard,
get_device_selection_keyboard, get_connection_guide_keyboard,
get_app_selection_keyboard, get_specific_app_keyboard,
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
get_devices_management_keyboard, get_device_management_help_keyboard,
get_happ_cryptolink_keyboard,
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
get_happ_download_button_row,
get_payment_methods_keyboard_with_cart,
get_subscription_confirm_keyboard_with_cart,
get_insufficient_balance_keyboard_with_cart
)
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_checkout_service import (
clear_subscription_checkout_draft,
get_subscription_checkout_draft,
save_subscription_checkout_draft,
should_offer_checkout_resume,
)
from app.services.subscription_service import SubscriptionService
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.services.promo_offer_service import promo_offer_service
from app.states import SubscriptionStates
from app.utils.pagination import paginate_list
from app.utils.pricing_utils import (
calculate_months_from_days,
get_remaining_months,
calculate_prorated_price,
validate_pricing_calculation,
format_period_description,
apply_percentage_discount,
)
from app.utils.subscription_utils import (
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
convert_subscription_link_to_happ_scheme,
)
from app.utils.promo_offer import (
build_promo_offer_hint,
get_user_active_promo_discount_percent,
)
from .countries import _get_available_countries, _should_show_countries_management
from .pricing import _build_subscription_period_prompt
async def handle_autopay_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not subscription:
await callback.answer(
texts.t("SUBSCRIPTION_ACTIVE_REQUIRED", "⚠️ У вас нет активной подписки!"),
show_alert=True,
)
return
status = (
texts.t("AUTOPAY_STATUS_ENABLED", "включен")
if subscription.autopay_enabled
else texts.t("AUTOPAY_STATUS_DISABLED", "выключен")
)
days = subscription.autopay_days_before
text = texts.t(
"AUTOPAY_MENU_TEXT",
(
"💳 <b>Автоплатеж</b>\n\n"
"📊 <b>Статус:</b> {status}\n"
"⏰ <b>Списание за:</b> {days} дн. до окончания\n\n"
"Выберите действие:"
),
).format(status=status, days=days)
await callback.message.edit_text(
text,
reply_markup=get_autopay_keyboard(db_user.language),
parse_mode="HTML",
)
await callback.answer()
async def toggle_autopay(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
subscription = db_user.subscription
enable = callback.data == "autopay_enable"
await update_subscription_autopay(db, subscription, enable)
texts = get_texts(db_user.language)
status = (
texts.t("AUTOPAY_STATUS_ENABLED", "включен")
if enable
else texts.t("AUTOPAY_STATUS_DISABLED", "выключен")
)
await callback.answer(
texts.t("AUTOPAY_TOGGLE_SUCCESS", "✅ Автоплатеж {status}!").format(status=status)
)
await handle_autopay_menu(callback, db_user, db)
async def show_autopay_days(
callback: types.CallbackQuery,
db_user: User
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.t(
"AUTOPAY_SELECT_DAYS_PROMPT",
"⏰ Выберите за сколько дней до окончания списывать средства:",
),
reply_markup=get_autopay_days_keyboard(db_user.language)
)
await callback.answer()
async def set_autopay_days(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
days = int(callback.data.split('_')[2])
subscription = db_user.subscription
await update_subscription_autopay(
db, subscription, subscription.autopay_enabled, days
)
texts = get_texts(db_user.language)
await callback.answer(
texts.t("AUTOPAY_DAYS_SET", "✅ Установлено {days} дней!").format(days=days)
)
await handle_autopay_menu(callback, db_user, db)
async def handle_subscription_config_back(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession
):
current_state = await state.get_state()
texts = get_texts(db_user.language)
if current_state == SubscriptionStates.selecting_traffic.state:
await callback.message.edit_text(
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.selecting_period)
elif current_state == SubscriptionStates.selecting_countries.state:
if settings.is_traffic_selectable():
await callback.message.edit_text(
texts.SELECT_TRAFFIC,
reply_markup=get_traffic_packages_keyboard(db_user.language)
)
await state.set_state(SubscriptionStates.selecting_traffic)
else:
await callback.message.edit_text(
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.selecting_period)
elif current_state == SubscriptionStates.selecting_devices.state:
if await _should_show_countries_management(db_user):
countries = await _get_available_countries(db_user.promo_group_id)
data = await state.get_data()
selected_countries = data.get('countries', [])
await callback.message.edit_text(
texts.SELECT_COUNTRIES,
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_countries)
elif settings.is_traffic_selectable():
await callback.message.edit_text(
texts.SELECT_TRAFFIC,
reply_markup=get_traffic_packages_keyboard(db_user.language)
)
await state.set_state(SubscriptionStates.selecting_traffic)
else:
await callback.message.edit_text(
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.selecting_period)
else:
from app.handlers.menu import show_main_menu
await show_main_menu(callback, db_user, db)
await state.clear()
await callback.answer()
async def handle_subscription_cancel(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
await state.clear()
await clear_subscription_checkout_draft(db_user.id)
from app.handlers.menu import show_main_menu
await show_main_menu(callback, db_user, db)
await callback.answer("❌ Покупка отменена")
+487
View File
@@ -0,0 +1,487 @@
import base64
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any, Tuple, Optional
from urllib.parse import quote
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings, PERIOD_PRICES, get_traffic_prices
from app.database.crud.discount_offer import (
get_offer_by_id,
mark_offer_claimed,
)
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.subscription import (
create_trial_subscription,
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
update_subscription_autopay
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import (
User, TransactionType, SubscriptionStatus,
Subscription
)
from app.keyboards.inline import (
get_subscription_keyboard, get_trial_keyboard,
get_subscription_period_keyboard, get_traffic_packages_keyboard,
get_countries_keyboard, get_devices_keyboard,
get_subscription_confirm_keyboard, get_autopay_keyboard,
get_autopay_days_keyboard, get_back_keyboard,
get_add_traffic_keyboard,
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
get_manage_countries_keyboard,
get_device_selection_keyboard, get_connection_guide_keyboard,
get_app_selection_keyboard, get_specific_app_keyboard,
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
get_devices_management_keyboard, get_device_management_help_keyboard,
get_happ_cryptolink_keyboard,
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
get_happ_download_button_row,
get_payment_methods_keyboard_with_cart,
get_subscription_confirm_keyboard_with_cart,
get_insufficient_balance_keyboard_with_cart
)
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_checkout_service import (
clear_subscription_checkout_draft,
get_subscription_checkout_draft,
save_subscription_checkout_draft,
should_offer_checkout_resume,
)
from app.services.subscription_service import SubscriptionService
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.services.promo_offer_service import promo_offer_service
from app.states import SubscriptionStates
from app.utils.pagination import paginate_list
from app.utils.pricing_utils import (
calculate_months_from_days,
get_remaining_months,
calculate_prorated_price,
validate_pricing_calculation,
format_period_description,
apply_percentage_discount,
)
from app.utils.subscription_utils import (
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
convert_subscription_link_to_happ_scheme,
)
from app.utils.promo_offer import (
build_promo_offer_hint,
get_user_active_promo_discount_percent,
)
logger = logging.getLogger(__name__)
TRAFFIC_PRICES = get_traffic_prices()
class _SafeFormatDict(dict):
def __missing__(self, key: str) -> str: # pragma: no cover - defensive fallback
return "{" + key + "}"
def _format_text_with_placeholders(template: str, values: Dict[str, Any]) -> str:
if not isinstance(template, str):
return template
safe_values = _SafeFormatDict()
safe_values.update(values)
try:
return template.format_map(safe_values)
except Exception: # pragma: no cover - defensive logging
logger.warning("Failed to format template '%s' with values %s", template, values)
return template
def _get_addon_discount_percent_for_user(
user: Optional[User],
category: str,
period_days_hint: Optional[int] = None,
) -> int:
if user is None:
return 0
promo_group = getattr(user, "promo_group", None)
if promo_group is None:
return 0
if not getattr(promo_group, "apply_discounts_to_addons", True):
return 0
try:
return user.get_promo_discount(category, period_days_hint)
except AttributeError:
return 0
def _apply_addon_discount(
user: Optional[User],
category: str,
amount: int,
period_days_hint: Optional[int] = None,
) -> Dict[str, int]:
percent = _get_addon_discount_percent_for_user(user, category, period_days_hint)
discounted_amount, discount_value = apply_percentage_discount(amount, percent)
return {
"discounted": discounted_amount,
"discount": discount_value,
"percent": percent,
}
def _get_promo_offer_discount_percent(user: Optional[User]) -> int:
return get_user_active_promo_discount_percent(user)
def _apply_promo_offer_discount(user: Optional[User], amount: int) -> Dict[str, int]:
percent = _get_promo_offer_discount_percent(user)
if amount <= 0 or percent <= 0:
return {"discounted": amount, "discount": 0, "percent": 0}
discounted, discount_value = apply_percentage_discount(amount, percent)
return {"discounted": discounted, "discount": discount_value, "percent": percent}
def _get_period_hint_from_subscription(subscription: Optional[Subscription]) -> Optional[int]:
if not subscription:
return None
months_remaining = get_remaining_months(subscription.end_date)
if months_remaining <= 0:
return None
return months_remaining * 30
def _apply_discount_to_monthly_component(
amount_per_month: int,
percent: int,
months: int,
) -> Dict[str, int]:
discounted_per_month, discount_per_month = apply_percentage_discount(amount_per_month, percent)
return {
"original_per_month": amount_per_month,
"discounted_per_month": discounted_per_month,
"discount_percent": max(0, min(100, percent)),
"discount_per_month": discount_per_month,
"total": discounted_per_month * months,
"discount_total": discount_per_month * months,
}
def update_traffic_prices():
from app.config import refresh_traffic_prices
refresh_traffic_prices()
logger.info("🔄 TRAFFIC_PRICES обновлены из конфигурации")
def format_traffic_display(traffic_gb: int, is_fixed_mode: bool = None) -> str:
if is_fixed_mode is None:
is_fixed_mode = settings.is_traffic_fixed()
if traffic_gb == 0:
if is_fixed_mode:
return "Безлимитный"
else:
return "Безлимитный"
else:
if is_fixed_mode:
return f"{traffic_gb} ГБ"
else:
return f"{traffic_gb} ГБ"
def validate_traffic_price(gb: int) -> bool:
from app.config import settings
price = settings.get_traffic_price(gb)
if gb == 0:
return True
return price > 0
def load_app_config() -> Dict[str, Any]:
try:
from app.config import settings
config_path = settings.get_app_config_path()
with open(config_path, 'r', encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, dict):
return data
logger.error("Некорректный формат app-config.json: ожидается объект")
except Exception as e:
logger.error(f"Ошибка загрузки конфига приложений: {e}")
return {}
def get_localized_value(values: Any, language: str, default_language: str = "en") -> str:
if not isinstance(values, dict):
return ""
candidates: List[str] = []
normalized_language = (language or "").strip().lower()
if normalized_language:
candidates.append(normalized_language)
if "-" in normalized_language:
candidates.append(normalized_language.split("-")[0])
default_language = (default_language or "").strip().lower()
if default_language and default_language not in candidates:
candidates.append(default_language)
for candidate in candidates:
if not candidate:
continue
value = values.get(candidate)
if isinstance(value, str) and value.strip():
return value
for value in values.values():
if isinstance(value, str) and value.strip():
return value
return ""
def get_step_description(app: Dict[str, Any], step_key: str, language: str) -> str:
if not isinstance(app, dict):
return ""
step = app.get(step_key)
if not isinstance(step, dict):
return ""
description = step.get("description")
return get_localized_value(description, language)
def format_additional_section(additional: Any, texts, language: str) -> str:
if not isinstance(additional, dict):
return ""
title = get_localized_value(additional.get("title"), language)
description = get_localized_value(additional.get("description"), language)
parts: List[str] = []
if title:
parts.append(
texts.t(
"SUBSCRIPTION_ADDITIONAL_STEP_TITLE",
"<b>{title}:</b>",
).format(title=title)
)
if description:
parts.append(description)
return "\n".join(parts)
def build_redirect_link(target_link: Optional[str], template: Optional[str]) -> Optional[str]:
if not target_link or not template:
return None
normalized_target = str(target_link).strip()
normalized_template = str(template).strip()
if not normalized_target or not normalized_template:
return None
encoded_target = quote(normalized_target, safe="")
result = normalized_template
replaced = False
replacements = [
("{subscription_link}", encoded_target),
("{link}", encoded_target),
("{subscription_link_raw}", normalized_target),
("{link_raw}", normalized_target),
]
for placeholder, replacement in replacements:
if placeholder in result:
result = result.replace(placeholder, replacement)
replaced = True
if not replaced:
result = f"{result}{encoded_target}"
return result
def get_apps_for_device(device_type: str, language: str = "ru") -> List[Dict[str, Any]]:
config = load_app_config()
platforms = config.get("platforms", {}) if isinstance(config, dict) else {}
if not isinstance(platforms, dict):
return []
device_mapping = {
'ios': 'ios',
'android': 'android',
'windows': 'windows',
'mac': 'macos',
'tv': 'androidTV',
'appletv': 'appleTV',
'apple_tv': 'appleTV',
}
config_key = device_mapping.get(device_type, device_type)
apps = platforms.get(config_key, [])
return apps if isinstance(apps, list) else []
def get_device_name(device_type: str, language: str = "ru") -> str:
names = {
'ios': 'iPhone/iPad',
'android': 'Android',
'windows': 'Windows',
'mac': 'macOS',
'tv': 'Android TV',
'appletv': 'Apple TV',
'apple_tv': 'Apple TV',
}
return names.get(device_type, device_type)
def create_deep_link(app: Dict[str, Any], subscription_url: str) -> Optional[str]:
if not subscription_url:
return None
if not isinstance(app, dict):
return subscription_url
scheme = str(app.get("urlScheme", "")).strip()
payload = subscription_url
if app.get("isNeedBase64Encoding"):
try:
payload = base64.b64encode(subscription_url.encode("utf-8")).decode("utf-8")
except Exception as exc:
logger.warning(
"Не удалось закодировать ссылку подписки в base64 для приложения %s: %s",
app.get("id"),
exc,
)
payload = subscription_url
scheme_link = f"{scheme}{payload}" if scheme else None
template = settings.get_happ_cryptolink_redirect_template()
redirect_link = build_redirect_link(scheme_link, template) if scheme_link and template else None
return redirect_link or scheme_link or subscription_url
def get_reset_devices_confirm_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="✅ Да, сбросить все устройства",
callback_data="confirm_reset_devices"
)
],
[
InlineKeyboardButton(text="❌ Отмена", callback_data="menu_subscription")
]
])
def get_traffic_switch_keyboard(
current_traffic_gb: int,
language: str = "ru",
subscription_end_date: datetime = None,
discount_percent: int = 0,
) -> InlineKeyboardMarkup:
from app.config import settings
months_multiplier = 1
period_text = ""
if subscription_end_date:
months_multiplier = get_remaining_months(subscription_end_date)
if months_multiplier > 1:
period_text = f" (за {months_multiplier} мес)"
packages = settings.get_traffic_packages()
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
current_price_per_month = settings.get_traffic_price(current_traffic_gb)
discounted_current_per_month, _ = apply_percentage_discount(
current_price_per_month,
discount_percent,
)
buttons = []
for package in enabled_packages:
gb = package['gb']
price_per_month = package['price']
discounted_price_per_month, _ = apply_percentage_discount(
price_per_month,
discount_percent,
)
price_diff_per_month = discounted_price_per_month - discounted_current_per_month
total_price_diff = price_diff_per_month * months_multiplier
if gb == current_traffic_gb:
emoji = ""
action_text = " (текущий)"
price_text = ""
elif total_price_diff > 0:
emoji = "⬆️"
action_text = ""
price_text = f" (+{total_price_diff // 100}{period_text})"
if discount_percent > 0:
discount_total = (
(price_per_month - current_price_per_month) * months_multiplier
- total_price_diff
)
if discount_total > 0:
price_text += f" (скидка {discount_percent}%: -{discount_total // 100}₽)"
elif total_price_diff < 0:
emoji = "⬇️"
action_text = ""
price_text = " (без возврата)"
else:
emoji = "🔄"
action_text = ""
price_text = " (бесплатно)"
if gb == 0:
traffic_text = "Безлимит"
else:
traffic_text = f"{gb} ГБ"
button_text = f"{emoji} {traffic_text}{action_text}{price_text}"
buttons.append([
InlineKeyboardButton(text=button_text, callback_data=f"switch_traffic_{gb}")
])
buttons.append([
InlineKeyboardButton(
text="⬅️ Назад" if language == "ru" else "⬅️ Back",
callback_data="subscription_settings"
)
])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_confirm_switch_traffic_keyboard(
new_traffic_gb: int,
price_difference: int,
language: str = "ru"
) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="✅ Подтвердить переключение",
callback_data=f"confirm_switch_traffic_{new_traffic_gb}_{price_difference}"
)
],
[
InlineKeyboardButton(
text="❌ Отмена",
callback_data="subscription_settings"
)
]
])
+958
View File
@@ -0,0 +1,958 @@
import base64
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any, Tuple, Optional
from urllib.parse import quote
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings, PERIOD_PRICES, get_traffic_prices
from app.database.crud.discount_offer import (
get_offer_by_id,
mark_offer_claimed,
)
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.subscription import (
create_trial_subscription,
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
update_subscription_autopay
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import (
User, TransactionType, SubscriptionStatus,
Subscription
)
from app.keyboards.inline import (
get_subscription_keyboard, get_trial_keyboard,
get_subscription_period_keyboard, get_traffic_packages_keyboard,
get_countries_keyboard, get_devices_keyboard,
get_subscription_confirm_keyboard, get_autopay_keyboard,
get_autopay_days_keyboard, get_back_keyboard,
get_add_traffic_keyboard,
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
get_manage_countries_keyboard,
get_device_selection_keyboard, get_connection_guide_keyboard,
get_app_selection_keyboard, get_specific_app_keyboard,
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
get_devices_management_keyboard, get_device_management_help_keyboard,
get_happ_cryptolink_keyboard,
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
get_happ_download_button_row,
get_payment_methods_keyboard_with_cart,
get_subscription_confirm_keyboard_with_cart,
get_insufficient_balance_keyboard_with_cart
)
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_checkout_service import (
clear_subscription_checkout_draft,
get_subscription_checkout_draft,
save_subscription_checkout_draft,
should_offer_checkout_resume,
)
from app.services.subscription_service import SubscriptionService
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.services.promo_offer_service import promo_offer_service
from app.states import SubscriptionStates
from app.utils.pagination import paginate_list
from app.utils.pricing_utils import (
calculate_months_from_days,
get_remaining_months,
calculate_prorated_price,
validate_pricing_calculation,
format_period_description,
apply_percentage_discount,
)
from app.utils.subscription_utils import (
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
convert_subscription_link_to_happ_scheme,
)
from app.utils.promo_offer import (
build_promo_offer_hint,
get_user_active_promo_discount_percent,
)
from .common import _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, logger
async def handle_add_countries(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
if not await _should_show_countries_management(db_user):
texts = get_texts(db_user.language)
await callback.answer(
texts.t(
"COUNTRY_MANAGEMENT_UNAVAILABLE",
"ℹ️ Управление серверами недоступно - доступен только один сервер",
),
show_alert=True,
)
return
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not subscription or subscription.is_trial:
await callback.answer(
texts.t("PAID_FEATURE_ONLY", "⚠ Эта функция доступна только для платных подписок"),
show_alert=True,
)
return
countries = await _get_available_countries(db_user.promo_group_id)
current_countries = subscription.connected_squads
period_hint_days = _get_period_hint_from_subscription(subscription)
servers_discount_percent = _get_addon_discount_percent_for_user(
db_user,
"servers",
period_hint_days,
)
current_countries_names = []
for country in countries:
if country['uuid'] in current_countries:
current_countries_names.append(country['name'])
current_list = (
"\n".join(f"{name}" for name in current_countries_names)
if current_countries_names
else texts.t("COUNTRY_MANAGEMENT_NONE", "Нет подключенных стран")
)
text = texts.t(
"COUNTRY_MANAGEMENT_PROMPT",
(
"🌍 <b>Управление странами подписки</b>\n\n"
"📋 <b>Текущие страны ({current_count}):</b>\n"
"{current_list}\n\n"
"💡 <b>Инструкция:</b>\n"
"✅ - страна подключена\n"
"➕ - будет добавлена (платно)\n"
"➖ - будет отключена (бесплатно)\n"
"⚪ - не выбрана\n\n"
"⚠️ <b>Важно:</b> Повторное подключение отключенных стран будет платным!"
),
).format(
current_count=len(current_countries),
current_list=current_list,
)
await state.update_data(countries=current_countries.copy())
await callback.message.edit_text(
text,
reply_markup=get_manage_countries_keyboard(
countries,
current_countries.copy(),
current_countries,
db_user.language,
subscription.end_date,
servers_discount_percent,
),
parse_mode="HTML"
)
await callback.answer()
async def get_countries_price_by_uuids_fallback(
country_uuids: List[str],
db: AsyncSession,
promo_group_id: Optional[int] = None,
) -> Tuple[int, List[int]]:
try:
from app.database.crud.server_squad import get_server_squad_by_uuid
total_price = 0
prices_list = []
for country_uuid in country_uuids:
try:
server = await get_server_squad_by_uuid(db, country_uuid)
is_allowed = True
if promo_group_id is not None and server:
allowed_ids = {pg.id for pg in server.allowed_promo_groups}
is_allowed = promo_group_id in allowed_ids
if server and server.is_available and not server.is_full and is_allowed:
price = server.price_kopeks
total_price += price
prices_list.append(price)
else:
default_price = 0
total_price += default_price
prices_list.append(default_price)
except Exception:
default_price = 0
total_price += default_price
prices_list.append(default_price)
return total_price, prices_list
except Exception as e:
logger.error(f"Ошибка fallback функции: {e}")
default_prices = [0] * len(country_uuids)
return sum(default_prices), default_prices
async def handle_manage_country(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
logger.info(f"🔍 Управление страной: {callback.data}")
country_uuid = callback.data.split('_')[2]
subscription = db_user.subscription
if not subscription or subscription.is_trial:
texts = get_texts(db_user.language)
await callback.answer(
texts.t("PAID_FEATURE_ONLY_SHORT", "⚠ Только для платных подписок"),
show_alert=True,
)
return
data = await state.get_data()
current_selected = data.get('countries', subscription.connected_squads.copy())
countries = await _get_available_countries(db_user.promo_group_id)
allowed_country_ids = {country['uuid'] for country in countries}
if country_uuid not in allowed_country_ids and country_uuid not in current_selected:
texts = get_texts(db_user.language)
await callback.answer(
texts.t(
"COUNTRY_NOT_AVAILABLE_PROMOGROUP",
"❌ Сервер недоступен для вашей промогруппы",
),
show_alert=True,
)
return
if country_uuid in current_selected:
current_selected.remove(country_uuid)
action = "removed"
else:
current_selected.append(country_uuid)
action = "added"
logger.info(f"🔍 Страна {country_uuid} {action}")
await state.update_data(countries=current_selected)
period_hint_days = _get_period_hint_from_subscription(subscription)
servers_discount_percent = _get_addon_discount_percent_for_user(
db_user,
"servers",
period_hint_days,
)
try:
await callback.message.edit_reply_markup(
reply_markup=get_manage_countries_keyboard(
countries,
current_selected,
subscription.connected_squads,
db_user.language,
subscription.end_date,
servers_discount_percent,
)
)
logger.info(f"✅ Клавиатура обновлена")
except Exception as e:
logger.error(f"⚠ Ошибка обновления клавиатуры: {e}")
await callback.answer()
async def apply_countries_changes(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
logger.info(f"🔧 Применение изменений стран")
data = await state.get_data()
texts = get_texts(db_user.language)
await save_subscription_checkout_draft(db_user.id, dict(data))
resume_callback = (
"subscription_resume_checkout"
if should_offer_checkout_resume(db_user, True)
else None
)
subscription = db_user.subscription
selected_countries = data.get('countries', [])
current_countries = subscription.connected_squads
countries = await _get_available_countries(db_user.promo_group_id)
allowed_country_ids = {country['uuid'] for country in countries}
selected_countries = [
country_uuid
for country_uuid in selected_countries
if country_uuid in allowed_country_ids or country_uuid in current_countries
]
added = [c for c in selected_countries if c not in current_countries]
removed = [c for c in current_countries if c not in selected_countries]
if not added and not removed:
await callback.answer(
texts.t("COUNTRY_CHANGES_NOT_FOUND", "⚠️ Изменения не обнаружены"),
show_alert=True,
)
return
logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}")
months_to_pay = get_remaining_months(subscription.end_date)
period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None
servers_discount_percent = _get_addon_discount_percent_for_user(
db_user,
"servers",
period_hint_days,
)
cost_per_month = 0
added_names = []
removed_names = []
added_server_components: List[Dict[str, int]] = []
for country in countries:
if not country.get('is_available', True):
continue
if country['uuid'] in added:
server_price_per_month = country['price_kopeks']
discounted_per_month, discount_per_month = apply_percentage_discount(
server_price_per_month,
servers_discount_percent,
)
cost_per_month += discounted_per_month
added_names.append(country['name'])
added_server_components.append(
{
"discounted_per_month": discounted_per_month,
"discount_per_month": discount_per_month,
"original_per_month": server_price_per_month,
}
)
if country['uuid'] in removed:
removed_names.append(country['name'])
total_cost, charged_months = calculate_prorated_price(cost_per_month, subscription.end_date)
added_server_prices = [
component["discounted_per_month"] * charged_months
for component in added_server_components
]
total_discount = sum(
component["discount_per_month"] * charged_months
for component in added_server_components
)
if added_names:
logger.info(
"Стоимость новых серверов: %.2f₽/мес × %s мес = %.2f₽ (скидка %.2f₽)",
cost_per_month / 100,
charged_months,
total_cost / 100,
total_discount / 100,
)
if total_cost > 0 and db_user.balance_kopeks < total_cost:
missing_kopeks = total_cost - db_user.balance_kopeks
required_text = f"{texts.format_price(total_cost)} (за {charged_months} мес)"
message_text = texts.t(
"ADDON_INSUFFICIENT_FUNDS_MESSAGE",
(
"⚠️ <b>Недостаточно средств</b>\n\n"
"Стоимость услуги: {required}\n"
"На балансе: {balance}\n"
"Не хватает: {missing}\n\n"
"Выберите способ пополнения. Сумма подставится автоматически."
),
).format(
required=required_text,
balance=texts.format_price(db_user.balance_kopeks),
missing=texts.format_price(missing_kopeks),
)
await callback.message.answer(
message_text,
reply_markup=get_insufficient_balance_keyboard(
db_user.language,
resume_callback=resume_callback,
amount_kopeks=missing_kopeks,
),
parse_mode="HTML",
)
await callback.answer()
return
try:
if added and total_cost > 0:
success = await subtract_user_balance(
db, db_user, total_cost,
f"Добавление стран: {', '.join(added_names)} на {charged_months} мес"
)
if not success:
await callback.answer(
texts.t("PAYMENT_CHARGE_ERROR", "⚠️ Ошибка списания средств"),
show_alert=True,
)
return
await create_transaction(
db=db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=total_cost,
description=f"Добавление стран к подписке: {', '.join(added_names)} на {charged_months} мес"
)
if added:
from app.database.crud.server_squad import get_server_ids_by_uuids, add_user_to_servers
from app.database.crud.subscription import add_subscription_servers
added_server_ids = await get_server_ids_by_uuids(db, added)
if added_server_ids:
await add_subscription_servers(db, subscription, added_server_ids, added_server_prices)
await add_user_to_servers(db, added_server_ids)
logger.info(
f"📊 Добавлены серверы с ценами за {charged_months} мес: {list(zip(added_server_ids, added_server_prices))}")
subscription.connected_squads = selected_countries
subscription.updated_at = datetime.utcnow()
await db.commit()
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
await db.refresh(subscription)
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_update_notification(
db, db_user, subscription, "servers", current_countries, selected_countries, total_cost
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления об изменении серверов: {e}")
success_text = texts.t(
"COUNTRY_CHANGES_SUCCESS_HEADER",
"✅ <b>Страны успешно обновлены!</b>\n\n",
)
if added_names:
success_text += texts.t(
"COUNTRY_CHANGES_ADDED_HEADER",
"➕ <b>Добавлены страны:</b>\n",
)
success_text += "\n".join(f"{name}" for name in added_names)
if total_cost > 0:
success_text += "\n" + texts.t(
"COUNTRY_CHANGES_CHARGED",
"💰 Списано: {amount} (за {months} мес)",
).format(
amount=texts.format_price(total_cost),
months=charged_months,
)
if total_discount > 0:
success_text += texts.t(
"COUNTRY_CHANGES_DISCOUNT_INFO",
" (скидка {percent}%: -{amount})",
).format(
percent=servers_discount_percent,
amount=texts.format_price(total_discount),
)
success_text += "\n"
if removed_names:
success_text += "\n" + texts.t(
"COUNTRY_CHANGES_REMOVED_HEADER",
"➖ <b>Отключены страны:</b>\n",
)
success_text += "\n".join(f"{name}" for name in removed_names)
success_text += "\n" + texts.t(
"COUNTRY_CHANGES_REMOVED_WARNING",
"ℹ️ Повторное подключение будет платным",
) + "\n"
success_text += "\n" + texts.t(
"COUNTRY_CHANGES_ACTIVE_COUNT",
"🌐 <b>Активных стран:</b> {count}",
).format(count=len(selected_countries))
await callback.message.edit_text(
success_text,
reply_markup=get_back_keyboard(db_user.language),
parse_mode="HTML"
)
await state.clear()
logger.info(
f"✅ Пользователь {db_user.telegram_id} обновил страны. Добавлено: {len(added)}, удалено: {len(removed)}, заплатил: {total_cost / 100}")
except Exception as e:
logger.error(f"⚠️ Ошибка применения изменений: {e}")
await callback.message.edit_text(
texts.ERROR,
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()
async def select_country(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession
):
country_uuid = callback.data.split('_')[1]
data = await state.get_data()
selected_countries = data.get('countries', [])
if country_uuid in selected_countries:
selected_countries.remove(country_uuid)
else:
selected_countries.append(country_uuid)
countries = await _get_available_countries(db_user.promo_group_id)
allowed_country_ids = {country['uuid'] for country in countries}
if country_uuid not in allowed_country_ids and country_uuid not in selected_countries:
await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True)
return
period_base_price = PERIOD_PRICES[data['period_days']]
discounted_base_price, _ = apply_percentage_discount(
period_base_price,
db_user.get_promo_discount("period", data['period_days']),
)
base_price = discounted_base_price + settings.get_traffic_price(data['traffic_gb'])
try:
subscription_service = SubscriptionService()
countries_price, _ = await subscription_service.get_countries_price_by_uuids(
selected_countries,
db,
promo_group_id=db_user.promo_group_id,
)
except AttributeError:
logger.warning("Используем fallback функцию для расчета цен стран")
countries_price, _ = await get_countries_price_by_uuids_fallback(
selected_countries,
db,
promo_group_id=db_user.promo_group_id,
)
data['countries'] = selected_countries
data['total_price'] = base_price + countries_price
await state.set_data(data)
await callback.message.edit_reply_markup(
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
)
await callback.answer()
async def countries_continue(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User
):
data = await state.get_data()
texts = get_texts(db_user.language)
if not data.get('countries'):
await callback.answer("⚠️ Выберите хотя бы одну страну!", show_alert=True)
return
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
await callback.message.edit_text(
texts.SELECT_DEVICES,
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_devices)
await callback.answer()
async def _get_available_countries(promo_group_id: Optional[int] = None):
from app.utils.cache import cache, cache_key
from app.database.database import AsyncSessionLocal
from app.database.crud.server_squad import get_available_server_squads
cache_key_value = cache_key("available_countries", promo_group_id or "all")
cached_countries = await cache.get(cache_key_value)
if cached_countries:
return cached_countries
try:
async with AsyncSessionLocal() as db:
available_servers = await get_available_server_squads(
db, promo_group_id=promo_group_id
)
if promo_group_id is not None and not available_servers:
logger.info(
"Промогруппа %s не имеет доступных серверов, возврат пустого списка",
promo_group_id,
)
await cache.set(cache_key_value, [], 60)
return []
countries = []
for server in available_servers:
countries.append({
"uuid": server.squad_uuid,
"name": server.display_name,
"price_kopeks": server.price_kopeks,
"country_code": server.country_code,
"is_available": server.is_available and not server.is_full
})
if not countries:
logger.info("🔄 Серверов в БД нет, получаем из RemnaWave...")
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
squads = await service.get_all_squads()
for squad in squads:
squad_name = squad["name"]
if not any(flag in squad_name for flag in
["🇳🇱", "🇩🇪", "🇺🇸", "🇫🇷", "🇬🇧", "🇮🇹", "🇪🇸", "🇨🇦", "🇯🇵", "🇸🇬", "🇦🇺"]):
name_lower = squad_name.lower()
if "netherlands" in name_lower or "нидерланды" in name_lower or "nl" in name_lower:
squad_name = f"🇳🇱 {squad_name}"
elif "germany" in name_lower or "германия" in name_lower or "de" in name_lower:
squad_name = f"🇩🇪 {squad_name}"
elif "usa" in name_lower or "сша" in name_lower or "america" in name_lower or "us" in name_lower:
squad_name = f"🇺🇸 {squad_name}"
else:
squad_name = f"🌐 {squad_name}"
countries.append({
"uuid": squad["uuid"],
"name": squad_name,
"price_kopeks": 0,
"is_available": True
})
await cache.set(cache_key_value, countries, 300)
return countries
except Exception as e:
logger.error(f"Ошибка получения списка стран: {e}")
fallback_countries = [
{"uuid": "default-free", "name": "🆓 Бесплатный сервер", "price_kopeks": 0, "is_available": True},
]
await cache.set(cache_key_value, fallback_countries, 60)
return fallback_countries
async def _get_countries_info(squad_uuids):
countries = await _get_available_countries()
return [c for c in countries if c['uuid'] in squad_uuids]
async def handle_add_country_to_subscription(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
logger.info(f"🔍 handle_add_country_to_subscription вызван для {db_user.telegram_id}")
logger.info(f"🔍 Callback data: {callback.data}")
current_state = await state.get_state()
logger.info(f"🔍 Текущее состояние: {current_state}")
country_uuid = callback.data.split('_')[1]
data = await state.get_data()
logger.info(f"🔍 Данные состояния: {data}")
selected_countries = data.get('countries', [])
countries = await _get_available_countries(db_user.promo_group_id)
allowed_country_ids = {country['uuid'] for country in countries}
if country_uuid not in allowed_country_ids and country_uuid not in selected_countries:
await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True)
return
if country_uuid in selected_countries:
selected_countries.remove(country_uuid)
logger.info(f"🔍 Удалена страна: {country_uuid}")
else:
selected_countries.append(country_uuid)
logger.info(f"🔍 Добавлена страна: {country_uuid}")
total_price = 0
subscription = db_user.subscription
period_hint_days = _get_period_hint_from_subscription(subscription)
servers_discount_percent = _get_addon_discount_percent_for_user(
db_user,
"servers",
period_hint_days,
)
for country in countries:
if not country.get('is_available', True):
continue
if (
country['uuid'] in selected_countries
and country['uuid'] not in subscription.connected_squads
):
server_price = country['price_kopeks']
if servers_discount_percent > 0 and server_price > 0:
discounted_price, _ = apply_percentage_discount(
server_price,
servers_discount_percent,
)
else:
discounted_price = server_price
total_price += discounted_price
data['countries'] = selected_countries
data['total_price'] = total_price
await state.set_data(data)
logger.info(f"🔍 Новые выбранные страны: {selected_countries}")
logger.info(f"🔍 Общая стоимость: {total_price}")
try:
from app.keyboards.inline import get_manage_countries_keyboard
await callback.message.edit_reply_markup(
reply_markup=get_manage_countries_keyboard(
countries,
selected_countries,
subscription.connected_squads,
db_user.language,
subscription.end_date,
servers_discount_percent,
)
)
logger.info(f"✅ Клавиатура обновлена")
except Exception as e:
logger.error(f"❌ Ошибка обновления клавиатуры: {e}")
await callback.answer()
async def _should_show_countries_management(user: Optional[User] = None) -> bool:
try:
promo_group_id = user.promo_group_id if user else None
promo_group = getattr(user, "promo_group", None) if user else None
if promo_group and getattr(promo_group, "server_squads", None):
allowed_servers = [
server
for server in promo_group.server_squads
if server.is_available and not server.is_full
]
if allowed_servers:
if len(allowed_servers) > 1:
logger.debug(
"Промогруппа %s имеет %s доступных серверов, показываем управление странами",
promo_group.id,
len(allowed_servers),
)
return True
logger.debug(
"Промогруппа %s имеет всего %s доступный сервер, пропускаем шаг выбора стран",
promo_group.id,
len(allowed_servers),
)
return False
countries = await _get_available_countries(promo_group_id)
available_countries = [c for c in countries if c.get('is_available', True)]
return len(available_countries) > 1
except Exception as e:
logger.error(f"Ошибка проверки доступных серверов: {e}")
return True
async def confirm_add_countries_to_subscription(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
data = await state.get_data()
texts = get_texts(db_user.language)
subscription = db_user.subscription
selected_countries = data.get('countries', [])
current_countries = subscription.connected_squads
countries = await _get_available_countries(db_user.promo_group_id)
allowed_country_ids = {country['uuid'] for country in countries}
selected_countries = [
country_uuid
for country_uuid in selected_countries
if country_uuid in allowed_country_ids or country_uuid in current_countries
]
new_countries = [c for c in selected_countries if c not in current_countries]
removed_countries = [c for c in current_countries if c not in selected_countries]
if not new_countries and not removed_countries:
await callback.answer("⚠️ Изменения не обнаружены", show_alert=True)
return
total_price = 0
new_countries_names = []
removed_countries_names = []
period_hint_days = _get_period_hint_from_subscription(subscription)
servers_discount_percent = _get_addon_discount_percent_for_user(
db_user,
"servers",
period_hint_days,
)
total_discount_value = 0
for country in countries:
if not country.get('is_available', True):
continue
if country['uuid'] in new_countries:
server_price = country['price_kopeks']
if servers_discount_percent > 0 and server_price > 0:
discounted_per_month, discount_per_month = apply_percentage_discount(
server_price,
servers_discount_percent,
)
else:
discounted_per_month = server_price
discount_per_month = 0
charged_price, charged_months = calculate_prorated_price(
discounted_per_month,
subscription.end_date,
)
total_price += charged_price
total_discount_value += discount_per_month * charged_months
new_countries_names.append(country['name'])
if country['uuid'] in removed_countries:
removed_countries_names.append(country['name'])
if new_countries and db_user.balance_kopeks < total_price:
missing_kopeks = total_price - db_user.balance_kopeks
message_text = texts.t(
"ADDON_INSUFFICIENT_FUNDS_MESSAGE",
(
"⚠️ <b>Недостаточно средств</b>\n\n"
"Стоимость услуги: {required}\n"
"На балансе: {balance}\n"
"Не хватает: {missing}\n\n"
"Выберите способ пополнения. Сумма подставится автоматически."
),
).format(
required=texts.format_price(total_price),
balance=texts.format_price(db_user.balance_kopeks),
missing=texts.format_price(missing_kopeks),
)
await callback.message.edit_text(
message_text,
reply_markup=get_insufficient_balance_keyboard(
db_user.language,
amount_kopeks=missing_kopeks,
),
parse_mode="HTML",
)
await state.clear()
await callback.answer()
return
try:
if new_countries and total_price > 0:
success = await subtract_user_balance(
db, db_user, total_price,
f"Добавление стран к подписке: {', '.join(new_countries_names)}"
)
if not success:
await callback.answer("❌ Ошибка списания средств", show_alert=True)
return
await create_transaction(
db=db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=total_price,
description=f"Добавление стран к подписке: {', '.join(new_countries_names)}"
)
subscription.connected_squads = selected_countries
subscription.updated_at = datetime.utcnow()
await db.commit()
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
await db.refresh(db_user)
await db.refresh(subscription)
success_text = "✅ Страны успешно обновлены!\n\n"
if new_countries_names:
success_text += f" Добавлены страны:\n{chr(10).join(f'{name}' for name in new_countries_names)}\n"
if total_price > 0:
success_text += f"💰 Списано: {texts.format_price(total_price)}"
if total_discount_value > 0:
success_text += (
f" (скидка {servers_discount_percent}%:"
f" -{texts.format_price(total_discount_value)})"
)
success_text += "\n"
if removed_countries_names:
success_text += f"\n Отключены страны:\n{chr(10).join(f'{name}' for name in removed_countries_names)}\n"
success_text += "ℹ️ Повторное подключение будет платным\n"
success_text += f"\n🌍 Активных стран: {len(selected_countries)}"
await callback.message.edit_text(
success_text,
reply_markup=get_back_keyboard(db_user.language)
)
logger.info(
f"✅ Пользователь {db_user.telegram_id} обновил страны подписки. Добавлено: {len(new_countries)}, убрано: {len(removed_countries)}")
except Exception as e:
logger.error(f"Ошибка обновления стран подписки: {e}")
await callback.message.edit_text(
texts.ERROR,
reply_markup=get_back_keyboard(db_user.language)
)
await state.clear()
await callback.answer()
File diff suppressed because it is too large Load Diff
+158
View File
@@ -0,0 +1,158 @@
import base64
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any, Tuple, Optional
from urllib.parse import quote
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings, PERIOD_PRICES, get_traffic_prices
from app.database.crud.discount_offer import (
get_offer_by_id,
mark_offer_claimed,
)
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.subscription import (
create_trial_subscription,
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
update_subscription_autopay
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import (
User, TransactionType, SubscriptionStatus,
Subscription
)
from app.keyboards.inline import (
get_subscription_keyboard, get_trial_keyboard,
get_subscription_period_keyboard, get_traffic_packages_keyboard,
get_countries_keyboard, get_devices_keyboard,
get_subscription_confirm_keyboard, get_autopay_keyboard,
get_autopay_days_keyboard, get_back_keyboard,
get_add_traffic_keyboard,
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
get_manage_countries_keyboard,
get_device_selection_keyboard, get_connection_guide_keyboard,
get_app_selection_keyboard, get_specific_app_keyboard,
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
get_devices_management_keyboard, get_device_management_help_keyboard,
get_happ_cryptolink_keyboard,
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
get_happ_download_button_row,
get_payment_methods_keyboard_with_cart,
get_subscription_confirm_keyboard_with_cart,
get_insufficient_balance_keyboard_with_cart
)
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_checkout_service import (
clear_subscription_checkout_draft,
get_subscription_checkout_draft,
save_subscription_checkout_draft,
should_offer_checkout_resume,
)
from app.services.subscription_service import SubscriptionService
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.services.promo_offer_service import promo_offer_service
from app.states import SubscriptionStates
from app.utils.pagination import paginate_list
from app.utils.pricing_utils import (
calculate_months_from_days,
get_remaining_months,
calculate_prorated_price,
validate_pricing_calculation,
format_period_description,
apply_percentage_discount,
)
from app.utils.subscription_utils import (
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
convert_subscription_link_to_happ_scheme,
)
from app.utils.promo_offer import (
build_promo_offer_hint,
get_user_active_promo_discount_percent,
)
async def handle_happ_download_request(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
prompt_text = texts.t(
"HAPP_DOWNLOAD_PROMPT",
"📥 <b>Скачать Happ</b>\nВыберите ваше устройство:",
)
keyboard = get_happ_download_platform_keyboard(db_user.language)
await callback.message.answer(prompt_text, reply_markup=keyboard, parse_mode="HTML")
await callback.answer()
async def handle_happ_download_platform_choice(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
platform = callback.data.split('_')[-1]
if platform == "pc":
platform = "windows"
texts = get_texts(db_user.language)
link = settings.get_happ_download_link(platform)
if not link:
await callback.answer(
texts.t("HAPP_DOWNLOAD_LINK_NOT_SET", "❌ Ссылка для этого устройства не настроена"),
show_alert=True,
)
return
platform_names = {
"ios": texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"),
"android": texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"),
"macos": texts.t("HAPP_PLATFORM_MACOS", "🖥️ Mac OS"),
"windows": texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"),
}
link_text = texts.t(
"HAPP_DOWNLOAD_LINK_MESSAGE",
"⬇️ Скачайте Happ для {platform}:",
).format(platform=platform_names.get(platform, platform.upper()))
keyboard = get_happ_download_link_keyboard(db_user.language, link)
await callback.message.edit_text(link_text, reply_markup=keyboard, parse_mode="HTML")
await callback.answer()
async def handle_happ_download_close(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
try:
await callback.message.delete()
except Exception:
pass
await callback.answer()
async def handle_happ_download_back(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
prompt_text = texts.t(
"HAPP_DOWNLOAD_PROMPT",
"📥 <b>Скачать Happ</b>\nВыберите ваше устройство:",
)
keyboard = get_happ_download_platform_keyboard(db_user.language)
await callback.message.edit_text(prompt_text, reply_markup=keyboard, parse_mode="HTML")
await callback.answer()
+354
View File
@@ -0,0 +1,354 @@
import base64
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any, Tuple, Optional
from urllib.parse import quote
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings, PERIOD_PRICES, get_traffic_prices
from app.database.crud.discount_offer import (
get_offer_by_id,
mark_offer_claimed,
)
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.subscription import (
create_trial_subscription,
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
update_subscription_autopay
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import (
User, TransactionType, SubscriptionStatus,
Subscription
)
from app.keyboards.inline import (
get_subscription_keyboard, get_trial_keyboard,
get_subscription_period_keyboard, get_traffic_packages_keyboard,
get_countries_keyboard, get_devices_keyboard,
get_subscription_confirm_keyboard, get_autopay_keyboard,
get_autopay_days_keyboard, get_back_keyboard,
get_add_traffic_keyboard,
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
get_manage_countries_keyboard,
get_device_selection_keyboard, get_connection_guide_keyboard,
get_app_selection_keyboard, get_specific_app_keyboard,
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
get_devices_management_keyboard, get_device_management_help_keyboard,
get_happ_cryptolink_keyboard,
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
get_happ_download_button_row,
get_payment_methods_keyboard_with_cart,
get_subscription_confirm_keyboard_with_cart,
get_insufficient_balance_keyboard_with_cart
)
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_checkout_service import (
clear_subscription_checkout_draft,
get_subscription_checkout_draft,
save_subscription_checkout_draft,
should_offer_checkout_resume,
)
from app.services.subscription_service import SubscriptionService
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.services.promo_offer_service import promo_offer_service
from app.states import SubscriptionStates
from app.utils.pagination import paginate_list
from app.utils.pricing_utils import (
calculate_months_from_days,
get_remaining_months,
calculate_prorated_price,
validate_pricing_calculation,
format_period_description,
apply_percentage_discount,
)
from app.utils.subscription_utils import (
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
convert_subscription_link_to_happ_scheme,
)
from app.utils.promo_offer import (
build_promo_offer_hint,
get_user_active_promo_discount_percent,
)
async def handle_connect_subscription(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
subscription = db_user.subscription
subscription_link = get_display_subscription_link(subscription)
hide_subscription_link = settings.should_hide_subscription_link()
if not subscription_link:
await callback.answer(
texts.t(
"SUBSCRIPTION_NO_ACTIVE_LINK",
"⚠ У вас нет активной подписки или ссылка еще генерируется",
),
show_alert=True,
)
return
connect_mode = settings.CONNECT_BUTTON_MODE
if connect_mode == "miniapp_subscription":
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
web_app=types.WebAppInfo(url=subscription_link)
)
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
]
])
await callback.message.edit_text(
texts.t(
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE",
"""📱 <b>Подключить подписку</b>
🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:""",
),
reply_markup=keyboard,
parse_mode="HTML"
)
elif connect_mode == "miniapp_custom":
if not settings.MINIAPP_CUSTOM_URL:
await callback.answer(
texts.t(
"CUSTOM_MINIAPP_URL_NOT_SET",
"⚠ Кастомная ссылка для мини-приложения не настроена",
),
show_alert=True,
)
return
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL)
)
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
]
])
await callback.message.edit_text(
texts.t(
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE",
"""🚀 <b>Подключить подписку</b>
📱 Нажмите кнопку ниже, чтобы открыть приложение:""",
),
reply_markup=keyboard,
parse_mode="HTML"
)
elif connect_mode == "link":
rows = [
[
InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
url=subscription_link
)
]
]
happ_row = get_happ_download_button_row(texts)
if happ_row:
rows.append(happ_row)
rows.append([
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
])
keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
await callback.message.edit_text(
texts.t(
"SUBSCRIPTION_CONNECT_LINK_MESSAGE",
"""🚀 <b>Подключить подписку</b>",
🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:""",
),
reply_markup=keyboard,
parse_mode="HTML"
)
elif connect_mode == "happ_cryptolink":
rows = [
[
InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
callback_data="open_subscription_link",
)
]
]
happ_row = get_happ_download_button_row(texts)
if happ_row:
rows.append(happ_row)
rows.append([
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
])
keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
await callback.message.edit_text(
texts.t(
"SUBSCRIPTION_CONNECT_LINK_MESSAGE",
"""🚀 <b>Подключить подписку</b>",
🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:""",
),
reply_markup=keyboard,
parse_mode="HTML"
)
else:
if hide_subscription_link:
device_text = texts.t(
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN",
"""📱 <b>Подключить подписку</b>
Ссылка подписки доступна по кнопкам ниже или в разделе "Моя подписка".
💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:""",
)
else:
device_text = texts.t(
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE",
"""📱 <b>Подключить подписку</b>
🔗 <b>Ссылка подписки:</b>
<code>{subscription_url}</code>
💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:""",
).format(subscription_url=subscription_link)
await callback.message.edit_text(
device_text,
reply_markup=get_device_selection_keyboard(db_user.language),
parse_mode="HTML"
)
await callback.answer()
async def handle_open_subscription_link(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
subscription = db_user.subscription
subscription_link = get_display_subscription_link(subscription)
if not subscription_link:
await callback.answer(
texts.t("SUBSCRIPTION_LINK_UNAVAILABLE", "❌ Ссылка подписки недоступна"),
show_alert=True,
)
return
if settings.is_happ_cryptolink_mode():
redirect_link = get_happ_cryptolink_redirect_link(subscription_link)
happ_scheme_link = convert_subscription_link_to_happ_scheme(subscription_link)
happ_message = (
texts.t(
"SUBSCRIPTION_HAPP_OPEN_TITLE",
"🔗 <b>Подключение через Happ</b>",
)
+ "\n\n"
+ texts.t(
"SUBSCRIPTION_HAPP_OPEN_LINK",
"<a href=\"{subscription_link}\">🔓 Открыть ссылку в Happ</a>",
).format(subscription_link=happ_scheme_link)
+ "\n\n"
+ texts.t(
"SUBSCRIPTION_HAPP_OPEN_HINT",
"💡 Если ссылка не открывается автоматически, скопируйте её вручную:",
)
)
if redirect_link:
happ_message += "\n\n" + texts.t(
"SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT",
"▶️ Нажмите кнопку \"Подключиться\" ниже, чтобы открыть Happ и добавить подписку автоматически.",
)
happ_message += "\n\n" + texts.t(
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK",
"<blockquote expandable><code>{crypto_link}</code></blockquote>",
).format(crypto_link=subscription_link)
keyboard = get_happ_cryptolink_keyboard(
subscription_link,
db_user.language,
redirect_link=redirect_link,
)
await callback.message.answer(
happ_message,
parse_mode="HTML",
disable_web_page_preview=True,
reply_markup=keyboard,
)
await callback.answer()
return
link_text = (
texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 <b>Ссылка подписки:</b>")
+ "\n\n"
+ f"<code>{subscription_link}</code>\n\n"
+ texts.t("SUBSCRIPTION_LINK_USAGE_TITLE", "📱 <b>Как использовать:</b>")
+ "\n"
+ "\n".join(
[
texts.t(
"SUBSCRIPTION_LINK_STEP1",
"1. Нажмите на ссылку выше чтобы её скопировать",
),
texts.t(
"SUBSCRIPTION_LINK_STEP2",
"2. Откройте ваше VPN приложение",
),
texts.t(
"SUBSCRIPTION_LINK_STEP3",
"3. Найдите функцию \"Добавить подписку\" или \"Import\"",
),
texts.t(
"SUBSCRIPTION_LINK_STEP4",
"4. Вставьте скопированную ссылку",
),
]
)
+ "\n\n"
+ texts.t(
"SUBSCRIPTION_LINK_HINT",
"💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.",
)
)
await callback.message.edit_text(
link_text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
callback_data="subscription_connect")
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
]
]),
parse_mode="HTML"
)
await callback.answer()
+131
View File
@@ -0,0 +1,131 @@
import base64
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any, Tuple, Optional
from urllib.parse import quote
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings, PERIOD_PRICES, get_traffic_prices
from app.database.crud.discount_offer import (
get_offer_by_id,
mark_offer_claimed,
)
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.subscription import (
create_trial_subscription,
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
update_subscription_autopay
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import (
User, TransactionType, SubscriptionStatus,
Subscription
)
from app.keyboards.inline import (
get_subscription_keyboard, get_trial_keyboard,
get_subscription_period_keyboard, get_traffic_packages_keyboard,
get_countries_keyboard, get_devices_keyboard,
get_subscription_confirm_keyboard, get_autopay_keyboard,
get_autopay_days_keyboard, get_back_keyboard,
get_add_traffic_keyboard,
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
get_manage_countries_keyboard,
get_device_selection_keyboard, get_connection_guide_keyboard,
get_app_selection_keyboard, get_specific_app_keyboard,
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
get_devices_management_keyboard, get_device_management_help_keyboard,
get_happ_cryptolink_keyboard,
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
get_happ_download_button_row,
get_payment_methods_keyboard_with_cart,
get_subscription_confirm_keyboard_with_cart,
get_insufficient_balance_keyboard_with_cart
)
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_checkout_service import (
clear_subscription_checkout_draft,
get_subscription_checkout_draft,
save_subscription_checkout_draft,
should_offer_checkout_resume,
)
from app.services.subscription_service import SubscriptionService
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.services.promo_offer_service import promo_offer_service
from app.states import SubscriptionStates
from app.utils.pagination import paginate_list
from app.utils.pricing_utils import (
calculate_months_from_days,
get_remaining_months,
calculate_prorated_price,
validate_pricing_calculation,
format_period_description,
apply_percentage_discount,
)
from app.utils.subscription_utils import (
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
convert_subscription_link_to_happ_scheme,
)
from app.utils.promo_offer import (
build_promo_offer_hint,
get_user_active_promo_discount_percent,
)
from .common import logger
async def send_trial_notification(callback: types.CallbackQuery, db: AsyncSession, db_user: User,
subscription: Subscription):
try:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_trial_activation_notification(db, db_user, subscription)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о триале: {e}")
async def send_purchase_notification(
callback: types.CallbackQuery,
db: AsyncSession,
db_user: User,
subscription: Subscription,
transaction_id: int,
period_days: int,
was_trial_conversion: bool = False
):
try:
from app.database.crud.transaction import get_transaction_by_id
transaction = await get_transaction_by_id(db, transaction_id)
if transaction:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_purchase_notification(
db, db_user, subscription, transaction, period_days, was_trial_conversion
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о покупке: {e}")
async def send_extension_notification(
callback: types.CallbackQuery,
db: AsyncSession,
db_user: User,
subscription: Subscription,
transaction_id: int,
extended_days: int,
old_end_date: datetime
):
try:
from app.database.crud.transaction import get_transaction_by_id
transaction = await get_transaction_by_id(db, transaction_id)
if transaction:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_extension_notification(
db, db_user, subscription, transaction, extended_days, old_end_date
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о продлении: {e}")
+465
View File
@@ -0,0 +1,465 @@
import base64
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any, Tuple, Optional
from urllib.parse import quote
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings, PERIOD_PRICES, get_traffic_prices
from app.database.crud.discount_offer import (
get_offer_by_id,
mark_offer_claimed,
)
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.subscription import (
create_trial_subscription,
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
update_subscription_autopay
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import (
User, TransactionType, SubscriptionStatus,
Subscription
)
from app.keyboards.inline import (
get_subscription_keyboard, get_trial_keyboard,
get_subscription_period_keyboard, get_traffic_packages_keyboard,
get_countries_keyboard, get_devices_keyboard,
get_subscription_confirm_keyboard, get_autopay_keyboard,
get_autopay_days_keyboard, get_back_keyboard,
get_add_traffic_keyboard,
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
get_manage_countries_keyboard,
get_device_selection_keyboard, get_connection_guide_keyboard,
get_app_selection_keyboard, get_specific_app_keyboard,
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
get_devices_management_keyboard, get_device_management_help_keyboard,
get_happ_cryptolink_keyboard,
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
get_happ_download_button_row,
get_payment_methods_keyboard_with_cart,
get_subscription_confirm_keyboard_with_cart,
get_insufficient_balance_keyboard_with_cart
)
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_checkout_service import (
clear_subscription_checkout_draft,
get_subscription_checkout_draft,
save_subscription_checkout_draft,
should_offer_checkout_resume,
)
from app.services.subscription_service import SubscriptionService
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.services.promo_offer_service import promo_offer_service
from app.states import SubscriptionStates
from app.utils.pagination import paginate_list
from app.utils.pricing_utils import (
calculate_months_from_days,
get_remaining_months,
calculate_prorated_price,
validate_pricing_calculation,
format_period_description,
apply_percentage_discount,
)
from app.utils.subscription_utils import (
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
convert_subscription_link_to_happ_scheme,
)
from app.utils.promo_offer import (
build_promo_offer_hint,
get_user_active_promo_discount_percent,
)
from .common import _apply_discount_to_monthly_component, _apply_promo_offer_discount, logger
from .countries import _get_available_countries, _get_countries_info, get_countries_price_by_uuids_fallback
from .devices import get_current_devices_count
from .promo import _build_promo_group_discount_text, _get_promo_offer_hint
async def _prepare_subscription_summary(
db_user: User,
data: Dict[str, Any],
texts,
) -> Tuple[str, Dict[str, Any]]:
summary_data = dict(data)
countries = await _get_available_countries(db_user.promo_group_id)
months_in_period = calculate_months_from_days(summary_data['period_days'])
period_display = format_period_description(summary_data['period_days'], db_user.language)
base_price_original = PERIOD_PRICES[summary_data['period_days']]
period_discount_percent = db_user.get_promo_discount(
"period",
summary_data['period_days'],
)
base_price, base_discount_total = apply_percentage_discount(
base_price_original,
period_discount_percent,
)
if settings.is_traffic_fixed():
traffic_limit = settings.get_fixed_traffic_limit()
traffic_price_per_month = settings.get_traffic_price(traffic_limit)
final_traffic_gb = traffic_limit
else:
traffic_gb = summary_data.get('traffic_gb', 0)
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
final_traffic_gb = traffic_gb
traffic_discount_percent = db_user.get_promo_discount(
"traffic",
summary_data['period_days'],
)
traffic_component = _apply_discount_to_monthly_component(
traffic_price_per_month,
traffic_discount_percent,
months_in_period,
)
total_traffic_price = traffic_component["total"]
countries_price_per_month = 0
selected_countries_names: List[str] = []
selected_server_prices: List[int] = []
server_monthly_prices: List[int] = []
selected_country_ids = set(summary_data.get('countries', []))
for country in countries:
if country['uuid'] in selected_country_ids:
server_price_per_month = country['price_kopeks']
countries_price_per_month += server_price_per_month
selected_countries_names.append(country['name'])
server_monthly_prices.append(server_price_per_month)
servers_discount_percent = db_user.get_promo_discount(
"servers",
summary_data['period_days'],
)
total_countries_price = 0
total_servers_discount = 0
discounted_servers_price_per_month = 0
for server_price_per_month in server_monthly_prices:
discounted_per_month, discount_per_month = apply_percentage_discount(
server_price_per_month,
servers_discount_percent,
)
total_price_for_server = discounted_per_month * months_in_period
total_discount_for_server = discount_per_month * months_in_period
discounted_servers_price_per_month += discounted_per_month
total_countries_price += total_price_for_server
total_servers_discount += total_discount_for_server
selected_server_prices.append(total_price_for_server)
devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_discount_percent = db_user.get_promo_discount(
"devices",
summary_data['period_days'],
)
devices_component = _apply_discount_to_monthly_component(
devices_price_per_month,
devices_discount_percent,
months_in_period,
)
total_devices_price = devices_component["total"]
total_price = base_price + total_traffic_price + total_countries_price + total_devices_price
discounted_monthly_additions = (
traffic_component["discounted_per_month"]
+ discounted_servers_price_per_month
+ devices_component["discounted_per_month"]
)
is_valid = validate_pricing_calculation(
base_price,
discounted_monthly_additions,
months_in_period,
total_price,
)
if not is_valid:
raise ValueError("Subscription price calculation validation failed")
original_total_price = total_price
promo_offer_component = _apply_promo_offer_discount(db_user, total_price)
if promo_offer_component["discount"] > 0:
total_price = promo_offer_component["discounted"]
summary_data['total_price'] = total_price
if promo_offer_component["discount"] > 0:
summary_data['promo_offer_discount_percent'] = promo_offer_component["percent"]
summary_data['promo_offer_discount_value'] = promo_offer_component["discount"]
summary_data['total_price_before_promo_offer'] = original_total_price
else:
summary_data.pop('promo_offer_discount_percent', None)
summary_data.pop('promo_offer_discount_value', None)
summary_data.pop('total_price_before_promo_offer', None)
summary_data['server_prices_for_period'] = selected_server_prices
summary_data['months_in_period'] = months_in_period
summary_data['base_price'] = base_price
summary_data['base_price_original'] = base_price_original
summary_data['base_discount_percent'] = period_discount_percent
summary_data['base_discount_total'] = base_discount_total
summary_data['final_traffic_gb'] = final_traffic_gb
summary_data['traffic_price_per_month'] = traffic_price_per_month
summary_data['traffic_discount_percent'] = traffic_component["discount_percent"]
summary_data['traffic_discount_total'] = traffic_component["discount_total"]
summary_data['traffic_discounted_price_per_month'] = traffic_component["discounted_per_month"]
summary_data['total_traffic_price'] = total_traffic_price
summary_data['servers_price_per_month'] = countries_price_per_month
summary_data['countries_price_per_month'] = countries_price_per_month
summary_data['servers_discount_percent'] = servers_discount_percent
summary_data['servers_discount_total'] = total_servers_discount
summary_data['servers_discounted_price_per_month'] = discounted_servers_price_per_month
summary_data['total_servers_price'] = total_countries_price
summary_data['total_countries_price'] = total_countries_price
summary_data['devices_price_per_month'] = devices_price_per_month
summary_data['devices_discount_percent'] = devices_component["discount_percent"]
summary_data['devices_discount_total'] = devices_component["discount_total"]
summary_data['devices_discounted_price_per_month'] = devices_component["discounted_per_month"]
summary_data['total_devices_price'] = total_devices_price
summary_data['discounted_monthly_additions'] = discounted_monthly_additions
if settings.is_traffic_fixed():
if final_traffic_gb == 0:
traffic_display = "Безлимитный"
else:
traffic_display = f"{final_traffic_gb} ГБ"
else:
if summary_data.get('traffic_gb', 0) == 0:
traffic_display = "Безлимитный"
else:
traffic_display = f"{summary_data.get('traffic_gb', 0)} ГБ"
if base_discount_total > 0:
base_line = (
f"- Базовый период: <s>{texts.format_price(base_price_original)}</s> "
f"{texts.format_price(base_price)}"
f" (скидка {period_discount_percent}%:"
f" -{texts.format_price(base_discount_total)})"
)
else:
base_line = f"- Базовый период: {texts.format_price(base_price_original)}"
details_lines = [base_line]
if total_traffic_price > 0:
traffic_line = (
f"- Трафик: {texts.format_price(traffic_price_per_month)}/мес × {months_in_period}"
f" = {texts.format_price(total_traffic_price)}"
)
if traffic_component["discount_total"] > 0:
traffic_line += (
f" (скидка {traffic_component['discount_percent']}%:"
f" -{texts.format_price(traffic_component['discount_total'])})"
)
details_lines.append(traffic_line)
if total_countries_price > 0:
servers_line = (
f"- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period}"
f" = {texts.format_price(total_countries_price)}"
)
if total_servers_discount > 0:
servers_line += (
f" (скидка {servers_discount_percent}%:"
f" -{texts.format_price(total_servers_discount)})"
)
details_lines.append(servers_line)
if total_devices_price > 0:
devices_line = (
f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period}"
f" = {texts.format_price(total_devices_price)}"
)
if devices_component["discount_total"] > 0:
devices_line += (
f" (скидка {devices_component['discount_percent']}%:"
f" -{texts.format_price(devices_component['discount_total'])})"
)
details_lines.append(devices_line)
if promo_offer_component["discount"] > 0:
details_lines.append(
texts.t(
"SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT",
"- Промо-предложение: -{amount} ({percent}% дополнительно)",
).format(
amount=texts.format_price(promo_offer_component["discount"]),
percent=promo_offer_component["percent"],
)
)
details_text = "\n".join(details_lines)
summary_text = (
"📋 <b>Сводка заказа</b>\n\n"
f"📅 <b>Период:</b> {period_display}\n"
f"📊 <b>Трафик:</b> {traffic_display}\n"
f"🌍 <b>Страны:</b> {', '.join(selected_countries_names)}\n"
f"📱 <b>Устройства:</b> {devices_selected}\n\n"
"💰 <b>Детализация стоимости:</b>\n"
f"{details_text}\n\n"
f"💎 <b>Общая стоимость:</b> {texts.format_price(total_price)}\n\n"
"Подтверждаете покупку?"
)
return summary_text, summary_data
async def _build_subscription_period_prompt(
db_user: User,
texts,
db: AsyncSession,
) -> str:
base_text = texts.BUY_SUBSCRIPTION_START.rstrip()
lines: List[str] = [base_text]
promo_offer_hint = await _get_promo_offer_hint(db, db_user, texts)
if promo_offer_hint:
lines.extend(["", promo_offer_hint])
promo_text = _build_promo_group_discount_text(
db_user,
settings.get_available_subscription_periods(),
texts=texts,
)
if promo_text:
lines.extend(["", promo_text])
return "\n".join(lines) + "\n"
async def get_subscription_cost(subscription, db: AsyncSession) -> int:
try:
if subscription.is_trial:
return 0
from app.config import settings
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
base_cost_original = PERIOD_PRICES.get(30, 0)
try:
owner = subscription.user
except AttributeError:
owner = None
promo_group_id = getattr(owner, "promo_group_id", None) if owner else None
period_discount_percent = 0
if owner:
try:
period_discount_percent = owner.get_promo_discount("period", 30)
except AttributeError:
period_discount_percent = 0
base_cost, _ = apply_percentage_discount(
base_cost_original,
period_discount_percent,
)
try:
servers_cost, _ = await subscription_service.get_countries_price_by_uuids(
subscription.connected_squads,
db,
promo_group_id=promo_group_id,
)
except AttributeError:
servers_cost, _ = await get_countries_price_by_uuids_fallback(
subscription.connected_squads,
db,
promo_group_id=promo_group_id,
)
traffic_cost = settings.get_traffic_price(subscription.traffic_limit_gb)
devices_cost = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
total_cost = base_cost + servers_cost + traffic_cost + devices_cost
logger.info(f"📊 Месячная стоимость конфигурации подписки {subscription.id}:")
base_log = f" 📅 Базовый тариф (30 дней): {base_cost_original / 100}"
if period_discount_percent > 0:
discount_value = base_cost_original * period_discount_percent // 100
base_log += (
f"{base_cost / 100}"
f" (скидка {period_discount_percent}%: -{discount_value / 100}₽)"
)
logger.info(base_log)
if servers_cost > 0:
logger.info(f" 🌍 Серверы: {servers_cost / 100}")
if traffic_cost > 0:
logger.info(f" 📊 Трафик: {traffic_cost / 100}")
if devices_cost > 0:
logger.info(f" 📱 Устройства: {devices_cost / 100}")
logger.info(f" 💎 ИТОГО: {total_cost / 100}")
return total_cost
except Exception as e:
logger.error(f"⚠️ Ошибка расчета стоимости подписки: {e}")
return 0
async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSession):
devices_used = await get_current_devices_count(db_user)
countries_info = await _get_countries_info(subscription.connected_squads)
countries_text = ", ".join([c['name'] for c in countries_info]) if countries_info else "Нет"
subscription_url = getattr(subscription, 'subscription_url', None) or "Генерируется..."
if subscription.is_trial:
status_text = "🎁 Тестовая"
type_text = "Триал"
else:
if subscription.is_active:
status_text = "✅ Оплачена"
else:
status_text = "⌛ Истекла"
type_text = "Платная подписка"
if subscription.traffic_limit_gb == 0:
if settings.is_traffic_fixed():
traffic_text = "∞ Безлимитный"
else:
traffic_text = "∞ Безлимитный"
else:
if settings.is_traffic_fixed():
traffic_text = f"{subscription.traffic_limit_gb} ГБ"
else:
traffic_text = f"{subscription.traffic_limit_gb} ГБ"
subscription_cost = await get_subscription_cost(subscription, db)
info_text = texts.SUBSCRIPTION_INFO.format(
status=status_text,
type=type_text,
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
days_left=max(0, subscription.days_left),
traffic_used=texts.format_traffic(subscription.traffic_used_gb),
traffic_limit=traffic_text,
countries_count=len(subscription.connected_squads),
devices_used=devices_used,
devices_limit=subscription.device_limit,
autopay_status="✅ Включен" if subscription.autopay_enabled else "⌛ Выключен"
)
if subscription_cost > 0:
info_text += f"\n💰 <b>Стоимость подписки в месяц:</b> {texts.format_price(subscription_cost)}"
if (
subscription_url
and subscription_url != "Генерируется..."
and not settings.should_hide_subscription_link()
):
info_text += f"\n\n🔗 <b>Ваша ссылка для импорта в VPN приложениe:</b>\n<code>{subscription_url}</code>"
return info_text
+459
View File
@@ -0,0 +1,459 @@
import base64
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any, Tuple, Optional
from urllib.parse import quote
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings, PERIOD_PRICES, get_traffic_prices
from app.database.crud.discount_offer import (
get_offer_by_id,
mark_offer_claimed,
)
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.subscription import (
create_trial_subscription,
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
update_subscription_autopay
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import (
User, TransactionType, SubscriptionStatus,
Subscription
)
from app.keyboards.inline import (
get_subscription_keyboard, get_trial_keyboard,
get_subscription_period_keyboard, get_traffic_packages_keyboard,
get_countries_keyboard, get_devices_keyboard,
get_subscription_confirm_keyboard, get_autopay_keyboard,
get_autopay_days_keyboard, get_back_keyboard,
get_add_traffic_keyboard,
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
get_manage_countries_keyboard,
get_device_selection_keyboard, get_connection_guide_keyboard,
get_app_selection_keyboard, get_specific_app_keyboard,
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
get_devices_management_keyboard, get_device_management_help_keyboard,
get_happ_cryptolink_keyboard,
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
get_happ_download_button_row,
get_payment_methods_keyboard_with_cart,
get_subscription_confirm_keyboard_with_cart,
get_insufficient_balance_keyboard_with_cart
)
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_checkout_service import (
clear_subscription_checkout_draft,
get_subscription_checkout_draft,
save_subscription_checkout_draft,
should_offer_checkout_resume,
)
from app.services.subscription_service import SubscriptionService
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.services.promo_offer_service import promo_offer_service
from app.states import SubscriptionStates
from app.utils.pagination import paginate_list
from app.utils.pricing_utils import (
calculate_months_from_days,
get_remaining_months,
calculate_prorated_price,
validate_pricing_calculation,
format_period_description,
apply_percentage_discount,
)
from app.utils.subscription_utils import (
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
convert_subscription_link_to_happ_scheme,
)
from app.utils.promo_offer import (
build_promo_offer_hint,
get_user_active_promo_discount_percent,
)
from .common import _format_text_with_placeholders
async def _get_promo_offer_hint(
db: AsyncSession,
db_user: User,
texts,
percent: Optional[int] = None,
) -> Optional[str]:
return await build_promo_offer_hint(db, db_user, texts, percent)
def _build_promo_group_discount_text(
db_user: User,
periods: Optional[List[int]] = None,
texts=None,
) -> str:
promo_group = getattr(db_user, "promo_group", None)
if not promo_group:
return ""
if texts is None:
texts = get_texts(db_user.language)
service_lines: List[str] = []
if promo_group.server_discount_percent > 0:
service_lines.append(
texts.PROMO_GROUP_DISCOUNT_SERVERS.format(
percent=promo_group.server_discount_percent
)
)
if promo_group.traffic_discount_percent > 0:
service_lines.append(
texts.PROMO_GROUP_DISCOUNT_TRAFFIC.format(
percent=promo_group.traffic_discount_percent
)
)
if promo_group.device_discount_percent > 0:
service_lines.append(
texts.PROMO_GROUP_DISCOUNT_DEVICES.format(
percent=promo_group.device_discount_percent
)
)
period_lines: List[str] = []
period_candidates: set[int] = set(periods or [])
raw_period_discounts = getattr(promo_group, "period_discounts", None)
if isinstance(raw_period_discounts, dict):
for key in raw_period_discounts.keys():
try:
period_candidates.add(int(key))
except (TypeError, ValueError):
continue
for period_days in sorted(period_candidates):
percent = promo_group.get_discount_percent("period", period_days)
if percent <= 0:
continue
period_display = format_period_description(period_days, db_user.language)
period_lines.append(
texts.PROMO_GROUP_PERIOD_DISCOUNT_ITEM.format(
period=period_display,
percent=percent,
)
)
if not service_lines and not period_lines:
return ""
lines: List[str] = [texts.PROMO_GROUP_DISCOUNTS_HEADER]
if service_lines:
lines.extend(service_lines)
if period_lines:
if service_lines:
lines.append("")
lines.append(texts.PROMO_GROUP_PERIOD_DISCOUNTS_HEADER)
lines.extend(period_lines)
return "\n".join(lines)
async def claim_discount_offer(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
try:
offer_id = int(callback.data.split("_")[-1])
except (ValueError, AttributeError):
await callback.answer(
texts.get("DISCOUNT_CLAIM_NOT_FOUND", "❌ Предложение не найдено"),
show_alert=True,
)
return
offer = await get_offer_by_id(db, offer_id)
if not offer or offer.user_id != db_user.id:
await callback.answer(
texts.get("DISCOUNT_CLAIM_NOT_FOUND", "❌ Предложение не найдено"),
show_alert=True,
)
return
now = datetime.utcnow()
if offer.claimed_at is not None:
await callback.answer(
texts.get("DISCOUNT_CLAIM_ALREADY", "ℹ️ Скидка уже была активирована"),
show_alert=True,
)
return
if not offer.is_active or offer.expires_at <= now:
offer.is_active = False
await db.commit()
await callback.answer(
texts.get("DISCOUNT_CLAIM_EXPIRED", "⚠️ Время действия предложения истекло"),
show_alert=True,
)
return
effect_type = (offer.effect_type or "percent_discount").lower()
if effect_type == "balance_bonus":
effect_type = "percent_discount"
if effect_type == "test_access":
success, newly_added, expires_at, error_code = await promo_offer_service.grant_test_access(
db,
db_user,
offer,
)
if not success:
if error_code == "subscription_missing":
error_message = texts.get(
"TEST_ACCESS_NO_SUBSCRIPTION",
"❌ Для активации предложения необходима действующая подписка.",
)
elif error_code == "squads_missing":
error_message = texts.get(
"TEST_ACCESS_NO_SQUADS",
"❌ Не удалось определить список серверов для теста. Обратитесь к администратору.",
)
elif error_code == "already_connected":
error_message = texts.get(
"TEST_ACCESS_ALREADY_CONNECTED",
"ℹ️ Этот сервер уже подключен к вашей подписке.",
)
elif error_code == "remnawave_sync_failed":
error_message = texts.get(
"TEST_ACCESS_REMNAWAVE_ERROR",
"❌ Не удалось подключить серверы. Попробуйте позже или обратитесь в поддержку.",
)
else:
error_message = texts.get(
"TEST_ACCESS_UNKNOWN_ERROR",
"❌ Не удалось активировать предложение. Попробуйте позже.",
)
await callback.answer(error_message, show_alert=True)
return
await mark_offer_claimed(
db,
offer,
details={
"context": "test_access_claim",
"new_squads": newly_added,
"expires_at": expires_at.isoformat() if expires_at else None,
},
)
expires_text = expires_at.strftime("%d.%m.%Y %H:%M") if expires_at else ""
success_message = texts.get(
"TEST_ACCESS_ACTIVATED_MESSAGE",
"🎉 Тестовые сервера подключены! Доступ активен до {expires_at}.",
).format(expires_at=expires_text)
popup_text = texts.get("TEST_ACCESS_ACTIVATED_POPUP", "✅ Доступ выдан!")
await callback.answer(popup_text, show_alert=True)
back_keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.get("BACK_TO_MENU", "🏠 В главное меню"),
callback_data="back_to_menu",
)
]
]
)
await callback.message.answer(success_message, reply_markup=back_keyboard)
return
discount_percent = int(offer.discount_percent or 0)
if discount_percent <= 0:
await callback.answer(
texts.get("DISCOUNT_CLAIM_ERROR", "❌ Не удалось активировать скидку. Попробуйте позже."),
show_alert=True,
)
return
db_user.promo_offer_discount_percent = discount_percent
db_user.promo_offer_discount_source = offer.notification_type
db_user.updated_at = now
extra_data = offer.extra_data or {}
raw_duration = extra_data.get("active_discount_hours")
template_id = extra_data.get("template_id")
if raw_duration in (None, "") and template_id:
try:
template = await get_promo_offer_template_by_id(db, int(template_id))
except (ValueError, TypeError):
template = None
if template and template.active_discount_hours:
raw_duration = template.active_discount_hours
try:
duration_hours = int(raw_duration) if raw_duration is not None else None
except (TypeError, ValueError):
duration_hours = None
if duration_hours and duration_hours > 0:
discount_expires_at = now + timedelta(hours=duration_hours)
else:
discount_expires_at = None
db_user.promo_offer_discount_expires_at = discount_expires_at
await mark_offer_claimed(
db,
offer,
details={
"context": "discount_claim",
"discount_percent": discount_percent,
"discount_expires_at": discount_expires_at.isoformat() if discount_expires_at else None,
},
)
await db.refresh(db_user)
success_template = texts.get(
"DISCOUNT_CLAIM_SUCCESS",
"🎉 Скидка {percent}% активирована! Она автоматически применится при следующей оплате.",
)
expires_text = (
discount_expires_at.strftime("%d.%m.%Y %H:%M") if discount_expires_at else ""
)
format_values: Dict[str, Any] = {"percent": discount_percent}
if duration_hours and duration_hours > 0:
format_values.setdefault("hours", duration_hours)
format_values.setdefault("duration_hours", duration_hours)
if discount_expires_at:
format_values.setdefault("expires_at", expires_text)
format_values.setdefault("expires_at_iso", discount_expires_at.isoformat())
try:
expires_timestamp = int(discount_expires_at.timestamp())
except (OverflowError, OSError, ValueError):
expires_timestamp = None
if expires_timestamp:
format_values.setdefault("expires_at_ts", expires_timestamp)
remaining_hours = int((discount_expires_at - now).total_seconds() // 3600)
if remaining_hours > 0:
format_values.setdefault("expires_in_hours", remaining_hours)
amount_text = ""
if isinstance(extra_data, dict):
raw_amount_text = (
extra_data.get("amount_text")
or extra_data.get("discount_amount_text")
or extra_data.get("formatted_amount")
)
if isinstance(raw_amount_text, str) and raw_amount_text.strip():
amount_text = raw_amount_text.strip()
else:
raw_amount = extra_data.get("amount") or extra_data.get("discount_amount")
if isinstance(raw_amount, (int, float)):
amount_text = settings.format_price(int(raw_amount))
elif isinstance(raw_amount, str) and raw_amount.strip():
amount_text = raw_amount.strip()
if not amount_text:
for key in ("discount_amount_kopeks", "amount_kopeks", "bonus_amount_kopeks"):
maybe_amount = extra_data.get(key)
try:
amount_value = int(maybe_amount)
except (TypeError, ValueError):
continue
if amount_value > 0:
amount_text = settings.format_price(amount_value)
break
for key, value in extra_data.items():
if (
isinstance(key, str)
and key.isidentifier()
and key not in format_values
and isinstance(value, (str, int, float))
):
format_values[key] = value
if not amount_text:
try:
bonus_amount = int(getattr(offer, "bonus_amount_kopeks", 0))
except (TypeError, ValueError):
bonus_amount = 0
if bonus_amount > 0:
amount_text = settings.format_price(bonus_amount)
if amount_text:
format_values.setdefault("amount", amount_text)
success_message = _format_text_with_placeholders(success_template, format_values)
await callback.answer("✅ Скидка активирована!", show_alert=True)
offer_type = None
if isinstance(extra_data, dict):
offer_type = extra_data.get("offer_type")
subscription = getattr(db_user, "subscription", None)
if offer_type == "purchase_discount":
button_text = texts.get("MENU_BUY_SUBSCRIPTION", "💎 Купить подписку")
button_callback = "subscription_upgrade"
elif offer_type == "extend_discount":
button_text = texts.get("SUBSCRIPTION_EXTEND", "💎 Продлить подписку")
button_callback = "subscription_extend"
else:
has_active_paid_subscription = bool(
subscription
and getattr(subscription, "is_active", False)
and not getattr(subscription, "is_trial", False)
)
if has_active_paid_subscription:
button_text = texts.get("SUBSCRIPTION_EXTEND", "💎 Продлить подписку")
button_callback = "subscription_extend"
else:
button_text = texts.get("MENU_BUY_SUBSCRIPTION", "💎 Купить подписку")
button_callback = "subscription_upgrade"
buy_keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
build_miniapp_or_callback_button(
text=button_text,
callback_data=button_callback,
)
]
]
)
await callback.message.answer(success_message, reply_markup=buy_keyboard)
async def handle_promo_offer_close(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
try:
await callback.message.delete()
except Exception:
try:
await callback.message.edit_reply_markup()
except Exception:
pass
await callback.answer()
File diff suppressed because it is too large Load Diff
+734
View File
@@ -0,0 +1,734 @@
import base64
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any, Tuple, Optional
from urllib.parse import quote
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings, PERIOD_PRICES, get_traffic_prices
from app.database.crud.discount_offer import (
get_offer_by_id,
mark_offer_claimed,
)
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.subscription import (
create_trial_subscription,
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
update_subscription_autopay
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import (
User, TransactionType, SubscriptionStatus,
Subscription
)
from app.keyboards.inline import (
get_subscription_keyboard, get_trial_keyboard,
get_subscription_period_keyboard, get_traffic_packages_keyboard,
get_countries_keyboard, get_devices_keyboard,
get_subscription_confirm_keyboard, get_autopay_keyboard,
get_autopay_days_keyboard, get_back_keyboard,
get_add_traffic_keyboard,
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
get_manage_countries_keyboard,
get_device_selection_keyboard, get_connection_guide_keyboard,
get_app_selection_keyboard, get_specific_app_keyboard,
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
get_devices_management_keyboard, get_device_management_help_keyboard,
get_happ_cryptolink_keyboard,
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
get_happ_download_button_row,
get_payment_methods_keyboard_with_cart,
get_subscription_confirm_keyboard_with_cart,
get_insufficient_balance_keyboard_with_cart
)
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_checkout_service import (
clear_subscription_checkout_draft,
get_subscription_checkout_draft,
save_subscription_checkout_draft,
should_offer_checkout_resume,
)
from app.services.subscription_service import SubscriptionService
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.services.promo_offer_service import promo_offer_service
from app.states import SubscriptionStates
from app.utils.pagination import paginate_list
from app.utils.pricing_utils import (
calculate_months_from_days,
get_remaining_months,
calculate_prorated_price,
validate_pricing_calculation,
format_period_description,
apply_percentage_discount,
)
from app.utils.subscription_utils import (
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
convert_subscription_link_to_happ_scheme,
)
from app.utils.promo_offer import (
build_promo_offer_hint,
get_user_active_promo_discount_percent,
)
from .common import _apply_addon_discount, _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, get_confirm_switch_traffic_keyboard, get_traffic_switch_keyboard, logger
from .countries import _get_available_countries, _should_show_countries_management
async def handle_add_traffic(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
from app.config import settings
texts = get_texts(db_user.language)
if settings.is_traffic_fixed():
await callback.answer(
texts.t(
"TRAFFIC_FIXED_MODE",
"⚠️ В текущем режиме трафик фиксированный и не может быть изменен",
),
show_alert=True,
)
return
subscription = db_user.subscription
if not subscription or subscription.is_trial:
await callback.answer(
texts.t("PAID_FEATURE_ONLY", "⚠ Эта функция доступна только для платных подписок"),
show_alert=True,
)
return
if subscription.traffic_limit_gb == 0:
await callback.answer(
texts.t("TRAFFIC_ALREADY_UNLIMITED", "⚠ У вас уже безлимитный трафик"),
show_alert=True,
)
return
current_traffic = subscription.traffic_limit_gb
period_hint_days = _get_period_hint_from_subscription(subscription)
traffic_discount_percent = _get_addon_discount_percent_for_user(
db_user,
"traffic",
period_hint_days,
)
prompt_text = texts.t(
"ADD_TRAFFIC_PROMPT",
(
"📈 <b>Добавить трафик к подписке</b>\n\n"
"Текущий лимит: {current_traffic}\n"
"Выберите дополнительный трафик:"
),
).format(current_traffic=texts.format_traffic(current_traffic))
await callback.message.edit_text(
prompt_text,
reply_markup=get_add_traffic_keyboard(
db_user.language,
subscription.end_date,
traffic_discount_percent,
),
parse_mode="HTML"
)
await callback.answer()
async def handle_reset_traffic(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
from app.config import settings
if settings.is_traffic_fixed():
await callback.answer("⚠️ В текущем режиме трафик фиксированный и не может быть сброшен", show_alert=True)
return
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not subscription or subscription.is_trial:
await callback.answer("⌛ Эта функция доступна только для платных подписок", show_alert=True)
return
if subscription.traffic_limit_gb == 0:
await callback.answer("⌛ У вас безлимитный трафик", show_alert=True)
return
reset_price = PERIOD_PRICES[30]
if db_user.balance_kopeks < reset_price:
await callback.answer("⌛ Недостаточно средств на балансе", show_alert=True)
return
await callback.message.edit_text(
f"🔄 <b>Сброс трафика</b>\n\n"
f"Использовано: {texts.format_traffic(subscription.traffic_used_gb)}\n"
f"Лимит: {texts.format_traffic(subscription.traffic_limit_gb)}\n\n"
f"Стоимость сброса: {texts.format_price(reset_price)}\n\n"
"После сброса счетчик использованного трафика станет равным 0.",
reply_markup=get_reset_traffic_confirm_keyboard(reset_price, db_user.language)
)
await callback.answer()
async def confirm_reset_traffic(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
from app.config import settings
if settings.is_traffic_fixed():
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
return
texts = get_texts(db_user.language)
subscription = db_user.subscription
reset_price = PERIOD_PRICES[30]
if db_user.balance_kopeks < reset_price:
missing_kopeks = reset_price - db_user.balance_kopeks
message_text = texts.t(
"ADDON_INSUFFICIENT_FUNDS_MESSAGE",
(
"⚠️ <b>Недостаточно средств</b>\n\n"
"Стоимость услуги: {required}\n"
"На балансе: {balance}\n"
"Не хватает: {missing}\n\n"
"Выберите способ пополнения. Сумма подставится автоматически."
),
).format(
required=texts.format_price(reset_price),
balance=texts.format_price(db_user.balance_kopeks),
missing=texts.format_price(missing_kopeks),
)
await callback.message.edit_text(
message_text,
reply_markup=get_insufficient_balance_keyboard(
db_user.language,
amount_kopeks=missing_kopeks,
),
parse_mode="HTML",
)
await callback.answer()
return
try:
success = await subtract_user_balance(
db, db_user, reset_price,
"Сброс трафика"
)
if not success:
await callback.answer("⌛ Ошибка списания средств", show_alert=True)
return
subscription.traffic_used_gb = 0.0
subscription.updated_at = datetime.utcnow()
await db.commit()
subscription_service = SubscriptionService()
remnawave_service = RemnaWaveService()
user = db_user
if user.remnawave_uuid:
async with remnawave_service.get_api_client() as api:
await api.reset_user_traffic(user.remnawave_uuid)
await create_transaction(
db=db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=reset_price,
description="Сброс трафика"
)
await db.refresh(db_user)
await db.refresh(subscription)
await callback.message.edit_text(
f"✅ Трафик успешно сброшен!\n\n"
f"🔄 Использованный трафик обнулен\n"
f"📊 Лимит: {texts.format_traffic(subscription.traffic_limit_gb)}",
reply_markup=get_back_keyboard(db_user.language)
)
logger.info(f"✅ Пользователь {db_user.telegram_id} сбросил трафик")
except Exception as e:
logger.error(f"Ошибка сброса трафика: {e}")
await callback.message.edit_text(
texts.ERROR,
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()
async def refresh_traffic_config():
try:
from app.config import refresh_traffic_prices
refresh_traffic_prices()
packages = settings.get_traffic_packages()
enabled_count = sum(1 for pkg in packages if pkg['enabled'])
logger.info(f"🔄 Конфигурация трафика обновлена: {enabled_count} активных пакетов")
for pkg in packages:
if pkg['enabled']:
gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ"
logger.info(f" 📦 {gb_text}: {pkg['price'] / 100}")
return True
except Exception as e:
logger.error(f"⚠️ Ошибка обновления конфигурации трафика: {e}")
return False
async def get_traffic_packages_info() -> str:
try:
packages = settings.get_traffic_packages()
info_lines = ["📦 Настроенные пакеты трафика:"]
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
disabled_packages = [pkg for pkg in packages if not pkg['enabled']]
if enabled_packages:
info_lines.append("\n✅ Активные:")
for pkg in enabled_packages:
gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ"
info_lines.append(f"{gb_text}: {pkg['price'] // 100}")
if disabled_packages:
info_lines.append("\n❌ Отключенные:")
for pkg in disabled_packages:
gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ"
info_lines.append(f"{gb_text}: {pkg['price'] // 100}")
info_lines.append(f"\n📊 Всего пакетов: {len(packages)}")
info_lines.append(f"🟢 Активных: {len(enabled_packages)}")
info_lines.append(f"🔴 Отключенных: {len(disabled_packages)}")
return "\n".join(info_lines)
except Exception as e:
return f"⚠️ Ошибка получения информации: {e}"
async def select_traffic(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User
):
traffic_gb = int(callback.data.split('_')[1])
texts = get_texts(db_user.language)
data = await state.get_data()
data['traffic_gb'] = traffic_gb
traffic_price = settings.get_traffic_price(traffic_gb)
data['total_price'] += traffic_price
await state.set_data(data)
if await _should_show_countries_management(db_user):
countries = await _get_available_countries(db_user.promo_group_id)
await callback.message.edit_text(
texts.SELECT_COUNTRIES,
reply_markup=get_countries_keyboard(countries, [], db_user.language)
)
await state.set_state(SubscriptionStates.selecting_countries)
else:
countries = await _get_available_countries(db_user.promo_group_id)
available_countries = [c for c in countries if c.get('is_available', True)]
data['countries'] = [available_countries[0]['uuid']] if available_countries else []
await state.set_data(data)
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
await callback.message.edit_text(
texts.SELECT_DEVICES,
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_devices)
await callback.answer()
async def add_traffic(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
if settings.is_traffic_fixed():
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
return
traffic_gb = int(callback.data.split('_')[2])
texts = get_texts(db_user.language)
subscription = db_user.subscription
base_price = settings.get_traffic_price(traffic_gb)
if base_price == 0 and traffic_gb != 0:
await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True)
return
period_hint_days = _get_period_hint_from_subscription(subscription)
discount_result = _apply_addon_discount(
db_user,
"traffic",
base_price,
period_hint_days,
)
discounted_per_month = discount_result["discounted"]
discount_per_month = discount_result["discount"]
charged_months = 1
if subscription:
price, charged_months = calculate_prorated_price(
discounted_per_month,
subscription.end_date,
)
else:
price = discounted_per_month
total_discount_value = discount_per_month * charged_months
if db_user.balance_kopeks < price:
missing_kopeks = price - db_user.balance_kopeks
message_text = texts.t(
"ADDON_INSUFFICIENT_FUNDS_MESSAGE",
(
"⚠️ <b>Недостаточно средств</b>\n\n"
"Стоимость услуги: {required}\n"
"На балансе: {balance}\n"
"Не хватает: {missing}\n\n"
"Выберите способ пополнения. Сумма подставится автоматически."
),
).format(
required=texts.format_price(price),
balance=texts.format_price(db_user.balance_kopeks),
missing=texts.format_price(missing_kopeks),
)
await callback.message.edit_text(
message_text,
reply_markup=get_insufficient_balance_keyboard(
db_user.language,
amount_kopeks=missing_kopeks,
),
parse_mode="HTML",
)
await callback.answer()
return
try:
success = await subtract_user_balance(
db,
db_user,
price,
f"Добавление {traffic_gb} ГБ трафика",
)
if not success:
await callback.answer("⚠️ Ошибка списания средств", show_alert=True)
return
if traffic_gb == 0:
subscription.traffic_limit_gb = 0
else:
await add_subscription_traffic(db, subscription, traffic_gb)
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
await create_transaction(
db=db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=price,
description=f"Добавление {traffic_gb} ГБ трафика",
)
await db.refresh(db_user)
await db.refresh(subscription)
success_text = f"✅ Трафик успешно добавлен!\n\n"
if traffic_gb == 0:
success_text += "🎉 Теперь у вас безлимитный трафик!"
else:
success_text += f"📈 Добавлено: {traffic_gb} ГБ\n"
success_text += f"Новый лимит: {texts.format_traffic(subscription.traffic_limit_gb)}"
if price > 0:
success_text += f"\n💰 Списано: {texts.format_price(price)}"
if total_discount_value > 0:
success_text += (
f" (скидка {discount_result['percent']}%:"
f" -{texts.format_price(total_discount_value)})"
)
await callback.message.edit_text(
success_text,
reply_markup=get_back_keyboard(db_user.language)
)
logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {traffic_gb} ГБ трафика")
except Exception as e:
logger.error(f"Ошибка добавления трафика: {e}")
await callback.message.edit_text(
texts.ERROR,
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()
async def handle_no_traffic_packages(
callback: types.CallbackQuery,
db_user: User
):
await callback.answer(
"⚠️ В данный момент нет доступных пакетов трафика. "
"Обратитесь в техподдержку для получения информации.",
show_alert=True
)
async def handle_switch_traffic(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
from app.config import settings
if settings.is_traffic_fixed():
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
return
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not subscription or subscription.is_trial:
await callback.answer("⚠️ Эта функция доступна только для платных подписок", show_alert=True)
return
current_traffic = subscription.traffic_limit_gb
period_hint_days = _get_period_hint_from_subscription(subscription)
traffic_discount_percent = _get_addon_discount_percent_for_user(
db_user,
"traffic",
period_hint_days,
)
await callback.message.edit_text(
f"🔄 <b>Переключение лимита трафика</b>\n\n"
f"Текущий лимит: {texts.format_traffic(current_traffic)}\n"
f"Выберите новый лимит трафика:\n\n"
f"💡 <b>Важно:</b>\n"
f"• При увеличении - доплата за разницу\n"
f"• При уменьшении - возврат средств не производится",
reply_markup=get_traffic_switch_keyboard(
current_traffic,
db_user.language,
subscription.end_date,
traffic_discount_percent,
),
parse_mode="HTML"
)
await callback.answer()
async def confirm_switch_traffic(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
new_traffic_gb = int(callback.data.split('_')[2])
texts = get_texts(db_user.language)
subscription = db_user.subscription
current_traffic = subscription.traffic_limit_gb
if new_traffic_gb == current_traffic:
await callback.answer("ℹ️ Лимит трафика не изменился", show_alert=True)
return
old_price_per_month = settings.get_traffic_price(current_traffic)
new_price_per_month = settings.get_traffic_price(new_traffic_gb)
months_remaining = get_remaining_months(subscription.end_date)
period_hint_days = months_remaining * 30 if months_remaining > 0 else None
traffic_discount_percent = _get_addon_discount_percent_for_user(
db_user,
"traffic",
period_hint_days,
)
discounted_old_per_month, _ = apply_percentage_discount(
old_price_per_month,
traffic_discount_percent,
)
discounted_new_per_month, _ = apply_percentage_discount(
new_price_per_month,
traffic_discount_percent,
)
price_difference_per_month = discounted_new_per_month - discounted_old_per_month
discount_savings_per_month = (
(new_price_per_month - old_price_per_month) - price_difference_per_month
)
if price_difference_per_month > 0:
total_price_difference = price_difference_per_month * months_remaining
if db_user.balance_kopeks < total_price_difference:
missing_kopeks = total_price_difference - db_user.balance_kopeks
message_text = texts.t(
"ADDON_INSUFFICIENT_FUNDS_MESSAGE",
(
"⚠️ <b>Недостаточно средств</b>\n\n"
"Стоимость услуги: {required}\n"
"На балансе: {balance}\n"
"Не хватает: {missing}\n\n"
"Выберите способ пополнения. Сумма подставится автоматически."
),
).format(
required=f"{texts.format_price(total_price_difference)} (за {months_remaining} мес)",
balance=texts.format_price(db_user.balance_kopeks),
missing=texts.format_price(missing_kopeks),
)
await callback.message.edit_text(
message_text,
reply_markup=get_insufficient_balance_keyboard(
db_user.language,
amount_kopeks=missing_kopeks,
),
parse_mode="HTML",
)
await callback.answer()
return
action_text = f"увеличить до {texts.format_traffic(new_traffic_gb)}"
cost_text = f"Доплата: {texts.format_price(total_price_difference)} (за {months_remaining} мес)"
if discount_savings_per_month > 0:
total_discount_savings = discount_savings_per_month * months_remaining
cost_text += (
f" (скидка {traffic_discount_percent}%:"
f" -{texts.format_price(total_discount_savings)})"
)
else:
total_price_difference = 0
action_text = f"уменьшить до {texts.format_traffic(new_traffic_gb)}"
cost_text = "Возврат средств не производится"
confirm_text = f"🔄 <b>Подтверждение переключения трафика</b>\n\n"
confirm_text += f"Текущий лимит: {texts.format_traffic(current_traffic)}\n"
confirm_text += f"Новый лимит: {texts.format_traffic(new_traffic_gb)}\n\n"
confirm_text += f"Действие: {action_text}\n"
confirm_text += f"💰 {cost_text}\n\n"
confirm_text += "Подтвердить переключение?"
await callback.message.edit_text(
confirm_text,
reply_markup=get_confirm_switch_traffic_keyboard(new_traffic_gb, total_price_difference, db_user.language),
parse_mode="HTML"
)
await callback.answer()
async def execute_switch_traffic(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
callback_parts = callback.data.split('_')
new_traffic_gb = int(callback_parts[3])
price_difference = int(callback_parts[4])
texts = get_texts(db_user.language)
subscription = db_user.subscription
current_traffic = subscription.traffic_limit_gb
try:
if price_difference > 0:
success = await subtract_user_balance(
db, db_user, price_difference,
f"Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB"
)
if not success:
await callback.answer("⚠️ Ошибка списания средств", show_alert=True)
return
months_remaining = get_remaining_months(subscription.end_date)
await create_transaction(
db=db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=price_difference,
description=f"Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB на {months_remaining} мес"
)
subscription.traffic_limit_gb = new_traffic_gb
subscription.updated_at = datetime.utcnow()
await db.commit()
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
await db.refresh(db_user)
await db.refresh(subscription)
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_update_notification(
db, db_user, subscription, "traffic", current_traffic, new_traffic_gb, price_difference
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления об изменении трафика: {e}")
if new_traffic_gb > current_traffic:
success_text = f"✅ Лимит трафика увеличен!\n\n"
success_text += f"📊 Было: {texts.format_traffic(current_traffic)}"
success_text += f"Стало: {texts.format_traffic(new_traffic_gb)}\n"
if price_difference > 0:
success_text += f"💰 Списано: {texts.format_price(price_difference)}"
elif new_traffic_gb < current_traffic:
success_text = f"✅ Лимит трафика уменьшен!\n\n"
success_text += f"📊 Было: {texts.format_traffic(current_traffic)}"
success_text += f"Стало: {texts.format_traffic(new_traffic_gb)}\n"
success_text += f"ℹ️ Возврат средств не производится"
await callback.message.edit_text(
success_text,
reply_markup=get_back_keyboard(db_user.language)
)
logger.info(
f"✅ Пользователь {db_user.telegram_id} переключил трафик с {current_traffic}GB на {new_traffic_gb}GB, доплата: {price_difference / 100}")
except Exception as e:
logger.error(f"Ошибка переключения трафика: {e}")
await callback.message.edit_text(
texts.ERROR,
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()
+70 -10
View File
@@ -13,12 +13,46 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=_t(texts, "ADMIN_MAIN_USERS_SUBSCRIPTIONS", "👥 Юзеры/Подписки"), callback_data="admin_submenu_users")],
[InlineKeyboardButton(text=_t(texts, "ADMIN_MAIN_PROMO_STATS", "💰 Промокоды/Статистика"), callback_data="admin_submenu_promo")],
[InlineKeyboardButton(text=_t(texts, "ADMIN_MAIN_SUPPORT", "🛟 Поддержка"), callback_data="admin_submenu_support")],
[InlineKeyboardButton(text=_t(texts, "ADMIN_MAIN_MESSAGES", "📨 Сообщения"), callback_data="admin_submenu_communications")],
[InlineKeyboardButton(text=_t(texts, "ADMIN_MAIN_SETTINGS", "⚙️ Настройки"), callback_data="admin_submenu_settings")],
[InlineKeyboardButton(text=_t(texts, "ADMIN_MAIN_SYSTEM", "🛠️ Система"), callback_data="admin_submenu_system")],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_MAIN_USERS_SUBSCRIPTIONS", "👥 Юзеры/Подписки"),
callback_data="admin_submenu_users",
),
InlineKeyboardButton(
text=_t(texts, "ADMIN_MAIN_SERVERS", "🌐 Серверы"),
callback_data="admin_servers",
),
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_MAIN_PRICING", "💰 Цены"),
callback_data="admin_pricing",
),
InlineKeyboardButton(
text=_t(texts, "ADMIN_MAIN_PROMO_STATS", "💰 Промокоды/Статистика"),
callback_data="admin_submenu_promo",
),
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_MAIN_SUPPORT", "🛟 Поддержка"),
callback_data="admin_submenu_support",
),
InlineKeyboardButton(
text=_t(texts, "ADMIN_MAIN_MESSAGES", "📨 Сообщения"),
callback_data="admin_submenu_communications",
),
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_MAIN_SETTINGS", "⚙️ Настройки"),
callback_data="admin_submenu_settings",
),
InlineKeyboardButton(
text=_t(texts, "ADMIN_MAIN_SYSTEM", "🛠️ Система"),
callback_data="admin_submenu_system",
),
],
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
])
@@ -67,6 +101,12 @@ def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKey
[
InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages")
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_COMMUNICATIONS_PROMO_OFFERS", "🎯 Промо-предложения"),
callback_data="admin_promo_offers"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_COMMUNICATIONS_WELCOME_TEXT", "👋 Приветственный текст"),
@@ -138,6 +178,24 @@ def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardM
callback_data="maintenance_panel"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_SETTINGS_PRIVACY_POLICY", "🛡️ Политика конф."),
callback_data="admin_privacy_policy",
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_SETTINGS_PUBLIC_OFFER", "📄 Публичная оферта"),
callback_data="admin_public_offer",
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_SETTINGS_FAQ", "❓ FAQ"),
callback_data="admin_faq",
)
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")
]
@@ -299,10 +357,6 @@ def get_admin_subscriptions_keyboard(language: str = "ru") -> InlineKeyboardMark
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_SUBSCRIPTIONS_PRICING", "⚙️ Настройки цен"),
callback_data="admin_subs_pricing"
),
InlineKeyboardButton(
text=_t(texts, "ADMIN_SUBSCRIPTIONS_COUNTRIES", "🌍 Управление странами"),
callback_data="admin_subs_countries"
@@ -620,6 +674,12 @@ def get_admin_remnawave_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
callback_data="admin_rw_squads"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_REMNAWAVE_MIGRATION", "🚚 Переезд"),
callback_data="admin_rw_migration"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_REMNAWAVE_TRAFFIC", "📈 Трафик"),
+396 -103
View File
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES
from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.utils.pricing_utils import format_period_description, apply_percentage_discount
from app.utils.subscription_utils import (
get_display_subscription_link,
@@ -17,6 +18,64 @@ import logging
logger = logging.getLogger(__name__)
def _get_localized_value(values, language: str, default_language: str = "en") -> str:
if not isinstance(values, dict):
return ""
candidates = []
normalized_language = (language or "").strip().lower()
if normalized_language:
candidates.append(normalized_language)
if "-" in normalized_language:
candidates.append(normalized_language.split("-")[0])
default_language = (default_language or "").strip().lower()
if default_language and default_language not in candidates:
candidates.append(default_language)
for candidate in candidates:
if not candidate:
continue
value = values.get(candidate)
if isinstance(value, str) and value.strip():
return value
for value in values.values():
if isinstance(value, str) and value.strip():
return value
return ""
def _build_additional_buttons(additional_section, language: str) -> List[InlineKeyboardButton]:
if not isinstance(additional_section, dict):
return []
buttons = additional_section.get("buttons")
if not isinstance(buttons, list):
return []
localized_buttons: List[InlineKeyboardButton] = []
for button in buttons:
if not isinstance(button, dict):
continue
button_text = _get_localized_value(button.get("buttonText"), language)
button_link = button.get("buttonLink")
if not button_text or not button_link:
continue
localized_buttons.append(
InlineKeyboardButton(text=button_text, url=button_link)
)
return localized_buttons
_LANGUAGE_DISPLAY_NAMES = {
"ru": "🇷🇺 Русский",
"en": "🇬🇧 English",
@@ -110,6 +169,59 @@ def get_language_selection_keyboard(
return InlineKeyboardMarkup(inline_keyboard=buttons)
def _build_text_main_menu_keyboard(
language: str,
texts,
*,
is_admin: bool,
is_moderator: bool,
) -> InlineKeyboardMarkup:
profile_text = texts.t("MENU_PROFILE", "👤 Личный кабинет")
miniapp_url = settings.get_main_menu_miniapp_url()
if miniapp_url:
profile_button = InlineKeyboardButton(
text=profile_text,
web_app=types.WebAppInfo(url=miniapp_url),
)
else:
profile_button = InlineKeyboardButton(
text=profile_text,
callback_data="menu_profile_unavailable",
)
keyboard_rows: List[List[InlineKeyboardButton]] = [[profile_button]]
if settings.is_language_selection_enabled():
keyboard_rows.append([
InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data="menu_language")
])
support_enabled = False
try:
from app.services.support_settings_service import SupportSettingsService
support_enabled = SupportSettingsService.is_support_menu_enabled()
except Exception:
support_enabled = settings.SUPPORT_MENU_ENABLED
if support_enabled:
keyboard_rows.append([
InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support")
])
if is_admin:
keyboard_rows.append([
InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data="admin_panel")
])
elif is_moderator:
keyboard_rows.append([
InlineKeyboardButton(text="🧑‍⚖️ Модерация", callback_data="moderator_panel")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
def get_main_menu_keyboard(
language: str = DEFAULT_LANGUAGE,
is_admin: bool = False,
@@ -119,10 +231,20 @@ def get_main_menu_keyboard(
balance_kopeks: int = 0,
subscription=None,
show_resume_checkout: bool = False,
has_saved_cart: bool = False, # Новый параметр для отображения уведомления о сохраненной корзине
*,
is_moderator: bool = False,
custom_buttons: Optional[list[InlineKeyboardButton]] = None,
) -> InlineKeyboardMarkup:
texts = get_texts(language)
if settings.is_text_main_menu_mode():
return _build_text_main_menu_keyboard(
language,
texts,
is_admin=is_admin,
is_moderator=is_moderator,
)
if settings.DEBUG:
print(f"DEBUG KEYBOARD: language={language}, is_admin={is_admin}, has_had_paid={has_had_paid_subscription}, has_active={has_active_subscription}, sub_active={subscription_is_active}, balance={balance_kopeks}")
@@ -222,7 +344,7 @@ def get_main_menu_keyboard(
else:
keyboard.append([subscription_buttons[0]])
if show_resume_checkout:
if show_resume_checkout or has_saved_cart:
keyboard.append([
InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
@@ -230,36 +352,23 @@ def get_main_menu_keyboard(
)
])
keyboard.extend([
[
InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode"),
if custom_buttons:
for button in custom_buttons:
if isinstance(button, InlineKeyboardButton):
keyboard.append([button])
# Добавляем кнопки промокода и рефералов, учитывая настройки
additional_buttons = [
InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode")
]
# Добавляем кнопку рефералов только если программа включена
if settings.is_referral_program_enabled():
additional_buttons.append(
InlineKeyboardButton(text=texts.MENU_REFERRALS, callback_data="menu_referrals")
]
])
# Server status button
server_status_mode = settings.get_server_status_mode()
server_status_text = texts.t("MENU_SERVER_STATUS", "📊 Статус серверов")
if server_status_mode == "external_link":
status_url = settings.get_server_status_external_url()
if status_url:
keyboard.append([
InlineKeyboardButton(text=server_status_text, url=status_url)
])
elif server_status_mode == "external_link_miniapp":
status_url = settings.get_server_status_external_url()
if status_url:
keyboard.append([
InlineKeyboardButton(
text=server_status_text,
web_app=types.WebAppInfo(url=status_url),
)
])
elif server_status_mode == "xray":
keyboard.append([
InlineKeyboardButton(text=server_status_text, callback_data="menu_server_status")
])
)
keyboard.append(additional_buttons)
# Support button is configurable (runtime via service)
try:
@@ -267,11 +376,17 @@ def get_main_menu_keyboard(
support_enabled = SupportSettingsService.is_support_menu_enabled()
except Exception:
support_enabled = settings.SUPPORT_MENU_ENABLED
support_row = []
if support_enabled:
support_row.append(InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support"))
support_row.append(InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules"))
keyboard.append(support_row)
keyboard.append([
InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support")
])
keyboard.append([
InlineKeyboardButton(
text=texts.t("MENU_INFO", "️ Инфо"),
callback_data="menu_info",
)
])
if settings.is_language_selection_enabled():
keyboard.append([
@@ -298,6 +413,82 @@ def get_main_menu_keyboard(
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_info_menu_keyboard(
language: str = DEFAULT_LANGUAGE,
show_privacy_policy: bool = False,
show_public_offer: bool = False,
show_faq: bool = False,
show_promo_groups: bool = False,
) -> InlineKeyboardMarkup:
texts = get_texts(language)
buttons: List[List[InlineKeyboardButton]] = []
if show_faq:
buttons.append([
InlineKeyboardButton(
text=texts.t("MENU_FAQ", "❓ FAQ"),
callback_data="menu_faq",
)
])
if show_promo_groups:
buttons.append([
InlineKeyboardButton(
text=texts.t("MENU_PROMO_GROUPS_INFO", "🎯 Промогруппы"),
callback_data="menu_info_promo_groups",
)
])
if show_privacy_policy:
buttons.append([
InlineKeyboardButton(
text=texts.t("MENU_PRIVACY_POLICY", "🛡️ Политика конф."),
callback_data="menu_privacy_policy",
)
])
if show_public_offer:
buttons.append([
InlineKeyboardButton(
text=texts.t("MENU_PUBLIC_OFFER", "📄 Оферта"),
callback_data="menu_public_offer",
)
])
buttons.append([
InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules")
])
server_status_mode = settings.get_server_status_mode()
server_status_text = texts.t("MENU_SERVER_STATUS", "📊 Статус серверов")
if server_status_mode == "external_link":
status_url = settings.get_server_status_external_url()
if status_url:
buttons.append([InlineKeyboardButton(text=server_status_text, url=status_url)])
elif server_status_mode == "external_link_miniapp":
status_url = settings.get_server_status_external_url()
if status_url:
buttons.append([
InlineKeyboardButton(
text=server_status_text,
web_app=types.WebAppInfo(url=status_url),
)
])
elif server_status_mode == "xray":
buttons.append([
InlineKeyboardButton(
text=server_status_text,
callback_data="menu_server_status",
)
])
buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_happ_download_button_row(texts) -> Optional[List[InlineKeyboardButton]]:
if not settings.is_happ_download_button_enabled():
return None
@@ -440,6 +631,7 @@ def get_insufficient_balance_keyboard(
language: str = DEFAULT_LANGUAGE,
resume_callback: str | None = None,
amount_kopeks: int | None = None,
has_saved_cart: bool = False, # Новый параметр для указания наличия сохраненной корзины
) -> InlineKeyboardMarkup:
texts = get_texts(language)
@@ -460,14 +652,23 @@ def get_insufficient_balance_keyboard(
)
back_row_index = len(keyboard.inline_keyboard) - 1
if resume_callback:
# Если есть сохраненная корзина, добавляем кнопку возврата к оформлению
if has_saved_cart:
return_row = [
InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout",
)
]
insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard)
keyboard.inline_keyboard.insert(insert_index, return_row)
elif resume_callback:
return_row = [
InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data=resume_callback,
)
]
insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard)
keyboard.inline_keyboard.insert(insert_index, return_row)
@@ -573,6 +774,7 @@ def get_payment_methods_keyboard_with_cart(
language: str = "ru",
amount_kopeks: int = 0,
) -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = get_payment_methods_keyboard(amount_kopeks, language)
# Добавляем кнопку "Очистить корзину"
@@ -583,9 +785,18 @@ def get_payment_methods_keyboard_with_cart(
)
])
# Добавляем кнопку возврата к оформлению подписки
keyboard.inline_keyboard.insert(-1, [ # Вставляем перед кнопкой "назад"
InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout"
)
])
return keyboard
def get_subscription_confirm_keyboard_with_cart(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text="✅ Подтвердить покупку",
@@ -596,8 +807,8 @@ def get_subscription_confirm_keyboard_with_cart(language: str = "ru") -> InlineK
callback_data="clear_saved_cart"
)],
[InlineKeyboardButton(
text="🔙 Назад",
callback_data="back_to_menu"
text=texts.BACK,
callback_data="subscription_config_back" # Изменили на возврат к настройке
)]
])
@@ -605,11 +816,14 @@ def get_insufficient_balance_keyboard_with_cart(
language: str = "ru",
amount_kopeks: int = 0,
) -> InlineKeyboardMarkup:
# Используем обновленную версию с флагом has_saved_cart=True
keyboard = get_insufficient_balance_keyboard(
language,
amount_kopeks=amount_kopeks,
has_saved_cart=True,
)
# Добавляем кнопку очистки корзины в начало
keyboard.inline_keyboard.insert(
0,
[
@@ -851,21 +1065,21 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
])
if settings.is_yookassa_enabled():
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_CARD_YOOKASSA", "💳 Банковская карта (YooKassa)"),
callback_data=_build_callback("yookassa")
)
])
if settings.YOOKASSA_SBP_ENABLED:
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_SBP_YOOKASSA", "🏦 Оплатить по СБП (YooKassa)"),
callback_data=_build_callback("yookassa_sbp")
callback_data=_build_callback("yookassa_sbp"),
)
])
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_CARD_YOOKASSA", "💳 Банковская карта (YooKassa)"),
callback_data=_build_callback("yookassa"),
)
])
if settings.TRIBUTE_ENABLED:
keyboard.append([
InlineKeyboardButton(
@@ -882,6 +1096,14 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
])
if settings.is_wata_enabled():
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_CARD_WATA", "💳 Банковская карта (WATA)"),
callback_data=_build_callback("wata")
)
])
if settings.is_pal24_enabled():
keyboard.append([
InlineKeyboardButton(
@@ -949,17 +1171,17 @@ def get_yookassa_payment_keyboard(
def get_autopay_notification_keyboard(subscription_id: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t("TOPUP_BALANCE_BUTTON", "💳 Пополнить баланс"),
build_miniapp_or_callback_button(
text=texts.t("TOPUP_BALANCE_BUTTON", "💳 Пополнить баланс"),
callback_data="balance_topup"
)
],
[
InlineKeyboardButton(
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
build_miniapp_or_callback_button(
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
callback_data="menu_subscription"
)
]
@@ -967,23 +1189,23 @@ def get_autopay_notification_keyboard(subscription_id: int, language: str = DEFA
def get_subscription_expiring_keyboard(subscription_id: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text=texts.MENU_EXTEND_SUBSCRIPTION,
build_miniapp_or_callback_button(
text=texts.MENU_EXTEND_SUBSCRIPTION,
callback_data="subscription_extend"
)
],
[
InlineKeyboardButton(
text=texts.t("TOPUP_BALANCE_BUTTON", "💳 Пополнить баланс"),
build_miniapp_or_callback_button(
text=texts.t("TOPUP_BALANCE_BUTTON", "💳 Пополнить баланс"),
callback_data="balance_topup"
)
],
[
InlineKeyboardButton(
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
build_miniapp_or_callback_button(
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
callback_data="menu_subscription"
)
]
@@ -1483,7 +1705,8 @@ def get_device_selection_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKey
InlineKeyboardButton(text=texts.t("DEVICE_GUIDE_MAC", "🎯 macOS"), callback_data="device_guide_mac")
],
[
InlineKeyboardButton(text=texts.t("DEVICE_GUIDE_ANDROID_TV", "📺 Android TV"), callback_data="device_guide_tv")
InlineKeyboardButton(text=texts.t("DEVICE_GUIDE_ANDROID_TV", "📺 Android TV"), callback_data="device_guide_tv"),
InlineKeyboardButton(text=texts.t("DEVICE_GUIDE_APPLE_TV", "📺 Apple TV"), callback_data="device_guide_appletv")
]
]
@@ -1500,42 +1723,80 @@ def get_device_selection_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKey
def get_connection_guide_keyboard(
subscription_url: str,
app: dict,
language: str = DEFAULT_LANGUAGE
subscription_url: str,
app: dict,
device_type: str,
language: str = DEFAULT_LANGUAGE,
has_other_apps: bool = False,
) -> InlineKeyboardMarkup:
from app.handlers.subscription import create_deep_link
texts = get_texts(language)
keyboard = []
if 'installationStep' in app and 'buttons' in app['installationStep']:
app_buttons = []
for button in app['installationStep']['buttons']:
button_text = button['buttonText'].get(language, button['buttonText']['en'])
button_text = _get_localized_value(button.get('buttonText'), language)
button_link = button.get('buttonLink')
if not button_text or not button_link:
continue
app_buttons.append(
InlineKeyboardButton(text=f"📥 {button_text}", url=button['buttonLink'])
InlineKeyboardButton(text=f"📥 {button_text}", url=button_link)
)
if len(app_buttons) == 2:
keyboard.append(app_buttons)
app_buttons = []
if app_buttons:
keyboard.append(app_buttons)
if settings.is_happ_cryptolink_mode():
copy_button = InlineKeyboardButton(
text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"),
additional_before_buttons = _build_additional_buttons(
app.get('additionalBeforeAddSubscriptionStep'),
language,
)
for button in additional_before_buttons:
keyboard.append([button])
connect_link = create_deep_link(app, subscription_url)
if connect_link:
connect_button = InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
url=connect_link,
)
elif settings.is_happ_cryptolink_mode():
connect_button = InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
callback_data="open_subscription_link",
)
else:
copy_button = InlineKeyboardButton(
text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"),
connect_button = InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
url=subscription_url,
)
keyboard.append([copy_button])
keyboard.append([connect_button])
additional_after_buttons = _build_additional_buttons(
app.get('additionalAfterAddSubscriptionStep'),
language,
)
for button in additional_after_buttons:
keyboard.append([button])
if has_other_apps:
keyboard.append([
InlineKeyboardButton(
text=texts.t("OTHER_APPS_BUTTON", "📋 Другие приложения"),
callback_data=f"app_list_{device_type}",
)
])
keyboard.extend([
[
InlineKeyboardButton(text=texts.t("CHOOSE_ANOTHER_DEVICE", "📱 Выбрать другое устройство"), callback_data="subscription_connect")
@@ -1588,43 +1849,64 @@ def get_specific_app_keyboard(
) -> InlineKeyboardMarkup:
from app.handlers.subscription import create_deep_link
texts = get_texts(language)
keyboard = []
if 'installationStep' in app and 'buttons' in app['installationStep']:
app_buttons = []
for button in app['installationStep']['buttons']:
button_text = button['buttonText'].get(language, button['buttonText']['en'])
button_text = _get_localized_value(button.get('buttonText'), language)
button_link = button.get('buttonLink')
if not button_text or not button_link:
continue
app_buttons.append(
InlineKeyboardButton(text=f"📥 {button_text}", url=button['buttonLink'])
InlineKeyboardButton(text=f"📥 {button_text}", url=button_link)
)
if len(app_buttons) == 2:
keyboard.append(app_buttons)
app_buttons = []
if app_buttons:
keyboard.append(app_buttons)
if settings.is_happ_cryptolink_mode():
copy_button = InlineKeyboardButton(
text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"),
additional_before_buttons = _build_additional_buttons(
app.get('additionalBeforeAddSubscriptionStep'),
language,
)
for button in additional_before_buttons:
keyboard.append([button])
connect_link = create_deep_link(app, subscription_url)
if connect_link:
connect_button = InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
url=connect_link,
)
elif settings.is_happ_cryptolink_mode():
connect_button = InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
callback_data="open_subscription_link",
)
else:
copy_button = InlineKeyboardButton(
text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"),
connect_button = InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
url=subscription_url,
)
keyboard.append([copy_button])
if 'additionalAfterAddSubscriptionStep' in app and 'buttons' in app['additionalAfterAddSubscriptionStep']:
for button in app['additionalAfterAddSubscriptionStep']['buttons']:
button_text = button['buttonText'].get(language, button['buttonText']['en'])
keyboard.append([
InlineKeyboardButton(text=button_text, url=button['buttonLink'])
])
keyboard.append([connect_button])
additional_after_buttons = _build_additional_buttons(
app.get('additionalAfterAddSubscriptionStep'),
language,
)
for button in additional_after_buttons:
keyboard.append([button])
keyboard.extend([
[
InlineKeyboardButton(text=texts.t("OTHER_APPS_BUTTON", "📋 Другие приложения"), callback_data=f"app_list_{device_type}")
@@ -1646,14 +1928,25 @@ def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) ->
available_periods = settings.get_available_renewal_periods()
for days in available_periods:
if days in prices:
period_display = format_period_description(days, language)
keyboard.append([
InlineKeyboardButton(
text=f"📅 {period_display} - {texts.format_price(prices[days])}",
callback_data=f"extend_period_{days}"
)
])
if days not in prices:
continue
price_info = prices[days]
if isinstance(price_info, dict):
final_price = price_info.get("final")
if final_price is None:
final_price = price_info.get("original", 0)
else:
final_price = price_info
period_display = format_period_description(days, language)
keyboard.append([
InlineKeyboardButton(
text=f"📅 {period_display} - {texts.format_price(final_price)}",
callback_data=f"extend_period_{days}"
)
])
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
+23 -14
View File
@@ -2,26 +2,35 @@ from aiogram.types import ReplyKeyboardMarkup, KeyboardButton, ReplyKeyboardRemo
from typing import List
from app.localization.texts import get_texts
from app.config import settings
def get_main_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
texts = get_texts(language)
keyboard = [
[
KeyboardButton(text=texts.MENU_BALANCE),
KeyboardButton(text=texts.MENU_SUBSCRIPTION)
]
]
# Добавляем кнопки промокода и рефералов, учитывая настройки
second_row = [KeyboardButton(text=texts.MENU_PROMOCODE)]
# Добавляем кнопку рефералов только если программа включена
if settings.is_referral_program_enabled():
second_row.append(KeyboardButton(text=texts.MENU_REFERRALS))
keyboard.append(second_row)
keyboard.append([
KeyboardButton(text=texts.MENU_SUPPORT),
KeyboardButton(text=texts.MENU_RULES)
])
return ReplyKeyboardMarkup(
keyboard=[
[
KeyboardButton(text=texts.MENU_BALANCE),
KeyboardButton(text=texts.MENU_SUBSCRIPTION)
],
[
KeyboardButton(text=texts.MENU_PROMOCODE),
KeyboardButton(text=texts.MENU_REFERRALS)
],
[
KeyboardButton(text=texts.MENU_SUPPORT),
KeyboardButton(text=texts.MENU_RULES)
]
],
keyboard=keyboard,
resize_keyboard=True,
one_time_keyboard=False
)
+106 -6
View File
@@ -5,6 +5,7 @@
"COUNTRY_MANAGEMENT_NONE": "No countries connected",
"PAID_FEATURE_ONLY": "⚠ This feature is available only for paid subscriptions",
"PAID_FEATURE_ONLY_SHORT": "⚠ Paid subscriptions only",
"SUSPICIOUS_DISPLAY_NAME_BLOCKED": "🚫 We detected that your display name looks like a link or an official account. Please change your Telegram name and try again.",
"COUNTRY_NOT_AVAILABLE_PROMOGROUP": "❌ This server is not available for your promo group",
"COUNTRY_CHANGES_NOT_FOUND": "⚠️ No changes detected",
"COUNTRY_CHANGES_SUCCESS_HEADER": "✅ <b>Countries updated!</b>\n\n",
@@ -58,6 +59,7 @@
"DEVICE_CONNECTION_HELP": "❓ How to reconnect a device?",
"DEVICE_GUIDE_ANDROID": "🤖 Android",
"DEVICE_GUIDE_ANDROID_TV": "📺 Android TV",
"DEVICE_GUIDE_APPLE_TV": "📺 Apple TV",
"DEVICE_GUIDE_IOS": "📱 iOS (iPhone/iPad)",
"DEVICE_GUIDE_MAC": "🎯 macOS",
"DEVICE_GUIDE_WINDOWS": "💻 Windows",
@@ -75,6 +77,8 @@
"LOADING": "⏳ Loading...",
"MAIN_MENU": "👤 <b>{user_name}</b>\n\n📱 <b>Subscription:</b> {subscription_status}\n\nChoose an option:\n",
"MAIN_MENU_ACTION_PROMPT": "Choose an option:",
"MAIN_MENU_TEST_ACCESS_HEADER": "🧪 Test servers active: {servers}",
"MAIN_MENU_TEST_ACCESS_TIMER": "⏳ Access active for {time_left}\n<code>{bar}</code>",
"MAIN_MENU_BUTTON": "🏠 Main menu",
"MANAGE_DEVICES_BUTTON": "🔧 Manage devices",
"DEVICE_UUID_NOT_FOUND": "❌ User UUID not found",
@@ -275,6 +279,7 @@
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n✅ Device limit increased!\n\n📱 Was: {old_count} → Now: {new_count}\n💰 Charged: {amount}\n",
"CHANGE_DEVICES_TITLE": "📱 Change device limit",
"CONTACT_SUPPORT": "💬 Contact support",
"PRIVACY_RESTRICTED_BUTTON_HINT": "⚠️ <b>Telegram blocked the contact request button because of your privacy settings.</b>\n\nAllow sharing your contact details in Telegram settings or send the required information manually in the chat.",
"CREATE_INVITE": "📝 Create invite",
"DEVICES_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}",
"DEVICES_LIMIT_EXCEEDED": "⚠️ Maximum device limit exceeded ({limit})",
@@ -299,6 +304,19 @@
"MENU_ADMIN": "⚙️ Admin panel",
"MENU_BUY_SUBSCRIPTION": "💎 Buy subscription",
"MENU_EXTEND_SUBSCRIPTION": "⏰ Extend subscription",
"MENU_INFO": "️ Info",
"MENU_INFO_HEADER": "️ <b>Info</b>",
"MENU_INFO_PROMPT": "Choose a section:",
"MENU_PROMO_GROUPS_INFO": "🎯 Promo groups",
"PROMO_GROUPS_INFO_HEADER": "🎯 <b>Promo groups</b>",
"PROMO_GROUPS_INFO_TOTAL_SPENT": "💰 Spent in the bot: {amount}",
"PROMO_GROUPS_INFO_CURRENT_LEVEL": "🏆 Current level: {name}",
"PROMO_GROUPS_INFO_NO_LEVEL": "🏆 Current level: not unlocked yet",
"PROMO_GROUPS_INFO_NEXT_LEVEL": "📈 Until level “{name}”: spend {amount} more",
"PROMO_GROUPS_INFO_MAX_LEVEL": "🏆 You already have the highest discount level!",
"PROMO_GROUPS_INFO_LEVELS_HEADER": "📋 Auto-assigned levels:",
"PROMO_GROUPS_INFO_LEVEL_LINE": "{status} <b>{name}</b> — from {amount}",
"PROMO_GROUPS_INFO_EMPTY": "Auto-assigned promo groups are not configured yet.",
"MENU_PROMOCODE": "🎫 Promo code",
"MENU_REFERRALS": "🤝 Referral program",
"MENU_RULES": "📋 Service rules",
@@ -372,11 +390,15 @@
"TRIAL_ACTIVATED": "🎉 Trial subscription activated!",
"TRIAL_ALREADY_USED": "❌ The trial subscription has already been used",
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a free trial plan:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic} GB\n📱 <b>Devices:</b> {devices} pcs\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
"TRIAL_SERVER_RANDOM_POOL": "🎲 Random choice among {count} servers",
"TRIAL_SERVER_DEFAULT_NAME": "🎯 Trial server",
"TRIAL_ENDING_SOON": "\n🎁 <b>The trial subscription is ending soon!</b>\n\nYour trial expires in a few hours.\n\n💎 <b>Don't want to lose VPN access?</b>\nSwitch to the full subscription!\n\n🔥 <b>Special offer:</b>\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n",
"TRAFFIC_FIXED_MODE": "⚠️ Traffic is fixed in the current mode and cannot be changed",
"TRAFFIC_ALREADY_UNLIMITED": "⚠ You already have unlimited traffic",
"ADD_TRAFFIC_PROMPT": "📈 <b>Add traffic to your subscription</b>\n\nCurrent limit: {current_traffic}\nChoose extra traffic:",
"USER_NOT_FOUND": "❌ User not found",
"MENU_PROFILE": "👤 Personal account",
"MENU_PROFILE_UNAVAILABLE": "❗️ Personal account is not available yet. Please try again later.",
"MENU_LANGUAGE": "🌐 Language",
"SUBSCRIPTION_STATUS_EXPIRED": "Expired",
"SUBSCRIPTION_STATUS_TRIAL": "Trial",
@@ -416,12 +438,14 @@
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Setup for {device_name}</b>",
"SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 <b>Subscription link:</b>",
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Recommended app:</b> {app_name}",
"SUBSCRIPTION_DEVICE_OTHER_APPS": "📦 <b>Other apps:</b> {app_list}",
"SUBSCRIPTION_DEVICE_OTHER_APPS_HINT": "Tap the \"Other apps\" button below to choose another app.",
"SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "<b>Step 1 - Install:</b>",
"SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "<b>Step 2 - Add subscription:</b>",
"SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "<b>Step 3 - Connect:</b>",
"SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 <b>How to connect:</b>",
"SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Install the app from the link above",
"SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Copy the subscription link (tap on it)",
"SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Tap the \"Connect\" button below",
"SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Open the app and paste the link",
"SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Connect to a server",
"SUBSCRIPTION_APPS_TITLE": "📱 <b>Apps for {device_name}</b>",
@@ -522,6 +546,16 @@
"ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Enter a non-negative amount in rubles or 0 to disable.",
"ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Enter period:discount pairs separated by commas, e.g. 30:10, 90:15, or 0.",
"ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:",
"ADMIN_PROMO_OFFER_TEST_DURATION": "Access: {hours} h",
"ADMIN_PROMO_OFFER_TEST_SQUAD_NAME": "Server: {name}",
"ADMIN_PROMO_OFFER_TEST_SQUADS": "Squads: {squads}",
"ADMIN_PROMO_OFFER_TEST_SQUADS_EMPTY": "Squads: not specified",
"ADMIN_PROMO_OFFER_RESULT": "📬 Broadcast finished\nSent: {sent}\nFailed: {failed}",
"ADMIN_PROMO_OFFER_SKIPPED": "Skipped: {skipped} (already have access)",
"ADMIN_PROMO_OFFER_CTA_BUY": "Buy subscription",
"ADMIN_PROMO_OFFER_CTA_EXTEND": "Extend subscription",
"ADMIN_PROMO_OFFER_BACK_TO_TEMPLATE": "↩️ Back to offer",
"ADMIN_PROMO_OFFER_BACK_TO_LIST": "⬅️ Back to promo offers",
"ADMIN_REPORTS": "📊 Reports",
"ADMIN_TICKETS_TITLE": "🎫 All support tickets:",
"ADMIN_TICKETS_TITLE_CLOSED": "🎫 Closed support tickets:",
@@ -548,11 +582,17 @@
"CREATE_TICKET_BUTTON": "🎫 Create ticket",
"DELETE_MESSAGE": "🗑 Delete",
"DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus",
"SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT": "- Promo offer: -{amount} ({percent}% extra)",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Extra discount {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Extra {percent}% discount is active and will apply automatically. It stacks with other discounts.",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Discount active for {time_left}\n<code>{bar}</code>",
"DISCOUNT_CLAIM_ALREADY": "️ This discount has already been activated.",
"DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.",
"DISCOUNT_CLAIM_ERROR": "❌ Failed to activate the discount. Please try again later.",
"PROMO_OFFER_CLOSE": "❌ Close",
"DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.",
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.",
"DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! {amount} credited to your balance.",
"DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! It will be applied automatically to your next payment.",
"TEST_ACCESS_ALREADY_CONNECTED": "️ This server is already connected to your subscription.",
"ENTER_BLOCK_MINUTES": "Enter the number of minutes to block the user (e.g., 15):",
"LANGUAGE_SELECTION_DISABLED": "⚙️ Language selection is temporarily unavailable. Using the default language.",
"MARK_AS_ANSWERED": "✅ Mark as answered",
@@ -581,19 +621,34 @@
"PAL24_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)",
"PAL24_TOPUP_PROMPT": "🏦 <b>PayPalych (SBP) payment</b>\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed via the PayPalych Faster Payments System.",
"PAYMENT_CARD_MULENPAY": "💳 Bank card (Mulen Pay)",
"PAYMENT_CARD_WATA": "💳 Bank card (WATA)",
"PAYMENT_CARD_PAL24": "🏦 SBP (PayPalych)",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via Mulen Pay",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card (Mulen Pay)</b>",
"PAYMENT_METHOD_WATA_DESCRIPTION": "via WATA",
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Bank card (WATA)</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System",
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>SBP (PayPalych)</b>",
"WATA_TOPUP_PROMPT": "💳 <b>Pay via WATA</b>\n\nEnter the amount to top up. Minimum amount — {min_amount}, maximum — {max_amount}.\nPayments are processed through the secure WATA form.",
"WATA_AMOUNT_TOO_LOW": "Minimum top-up amount: {amount}",
"WATA_AMOUNT_TOO_HIGH": "Maximum top-up amount: {amount}",
"WATA_PAYMENT_ERROR": "❌ Failed to create WATA payment. Please try again later or contact support.",
"WATA_PAY_BUTTON": "💳 Pay via WATA",
"WATA_PAYMENT_INSTRUCTIONS": "💳 <b>Pay via WATA</b>\n\n💰 Amount: {amount}\n🆔 Payment ID: {payment_id}\n\n📱 <b>How to pay:</b>\n1. Tap Pay via WATA\n2. Follow the gateway prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
"WATA_STATUS_TITLE": "💳 <b>WATA payment status</b>",
"WATA_STATUS_OPENED": "Waiting for payment",
"WATA_STATUS_CLOSED": "Processing",
"WATA_STATUS_PAID": "Paid",
"WATA_STATUS_DECLINED": "Declined",
"WATA_STATUS_UNKNOWN": "Unknown",
"REPLY_TO_TICKET": "💬 Reply",
"REPORT_CLOSE": "❌ Close",
"REPORT_CLOSED": "✅ Report closed.",
"REPORT_CLOSE_ERROR": "❌ Failed to close the report.",
"SENDING_ATTACHMENTS": "📎 Sending attachments...",
"SUBSCRIPTION_EXPIRED_1D": "⛔ <b>Your subscription expired</b>\n\nAccess was disabled on {end_date}. Renew to return to the service.\n\n💎 Renewal price: {price}",
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>{percent}% discount on renewal</b>\n\nTap “Get discount” and we'll add {bonus} to your balance. The offer is valid until {expires_at}.",
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Personal {percent}% discount</b>\n\nIt's been {trigger_days} days without a subscription. Come back — tap “Get discount” and {bonus} will be credited. Offer valid until {expires_at}.",
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>{percent}% discount on renewal</b>\n\nActivate the offer to get an additional discount. It stacks with your promo group and is valid until {expires_at}.",
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Personal {percent}% discount</b>\n\nIt's been {trigger_days} days without a subscription. Come back and activate the extra discount — it stacks with your promo group and is valid until {expires_at}.",
"SUBSCRIPTION_EXTEND": "💎 Extend subscription",
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "<blockquote expandable><code>{crypto_link}</code></blockquote>",
"SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Subscription link is ready. Tap the \"Connect\" button below to open it in Happ.",
@@ -698,5 +753,50 @@
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Add to the description if needed.",
"ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Description updated.",
"ADMIN_SUPPORT_DESCRIPTION_SENT": "Description sent below",
"ADMIN_SUPPORT_MESSAGE_DELETED": "Message deleted"
"ADMIN_SUPPORT_MESSAGE_DELETED": "Message deleted",
"MENU_PUBLIC_OFFER": "📄 Offer",
"PUBLIC_OFFER_NOT_AVAILABLE": "Public offer is temporarily unavailable.",
"PUBLIC_OFFER_EMPTY_ALERT": "Public offer content is not provided yet.",
"PUBLIC_OFFER_HEADER": "📄 <b>Public Offer</b>",
"PUBLIC_OFFER_PAGE_INFO": "Page {current} of {total}",
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Public offer",
"ADMIN_PUBLIC_OFFER_HEADER": "📄 <b>Public offer</b>",
"ADMIN_PUBLIC_OFFER_DESCRIPTION": "The public offer is shown in the “Info” section.",
"ADMIN_PUBLIC_OFFER_LANGUAGE": "Language: <code>{lang}</code>",
"ADMIN_PUBLIC_OFFER_STATUS_DISABLED": "⚠️ Offer display is disabled or empty.",
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED": "✅ Offer is active and visible to users.",
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY": "⚠️ Offer is enabled but text is empty — users will not see it.",
"ADMIN_PUBLIC_OFFER_UPDATED_AT": "Last updated: {timestamp}",
"ADMIN_PUBLIC_OFFER_PREVIEW_TITLE": "<b>Text preview:</b>",
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY": "Text is not set yet.",
"ADMIN_PUBLIC_OFFER_ACTION_PROMPT": "Choose an action:",
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON": "✏️ Edit text",
"ADMIN_PUBLIC_OFFER_VIEW_BUTTON": "👀 View current text",
"ADMIN_PUBLIC_OFFER_ENABLE_BUTTON": "✅ Enable display",
"ADMIN_PUBLIC_OFFER_DISABLE_BUTTON": "🚫 Disable display",
"ADMIN_PUBLIC_OFFER_HTML_HELP": "️ HTML help",
"ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW": "Current text (preview):",
"ADMIN_PUBLIC_OFFER_EDIT_PROMPT": "Send a new public offer text. HTML markup is allowed.",
"ADMIN_PUBLIC_OFFER_EDIT_HINT": "Use /html_help for the list of allowed tags.",
"ADMIN_PUBLIC_OFFER_EDIT_TITLE": "Public offer editing",
"ADMIN_PUBLIC_OFFER_CANCEL": "❌ Cancel",
"ADMIN_PUBLIC_OFFER_TOO_LONG": "❌ Offer text is too long. Maximum 4000 characters.",
"ADMIN_PUBLIC_OFFER_HTML_ERROR": "❌ HTML error: {error}",
"ADMIN_PUBLIC_OFFER_SAVED": "✅ Public offer updated.",
"ADMIN_PUBLIC_OFFER_BACK_BUTTON": "⬅️ Back to offer settings",
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT": "Offer text is not set yet.",
"ADMIN_PUBLIC_OFFER_VIEW_TITLE": "👀 <b>Current offer text</b>",
"ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED": "\n\n⚠️ Text shortened for display. Users will see the full version in the menu.",
"ADMIN_PUBLIC_OFFER_ENABLED": "✅ Offer enabled",
"ADMIN_PUBLIC_OFFER_DISABLED": "🚫 Offer disabled",
"ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT": "⬅️ Back to editing",
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Offer editing cancelled.",
"ADMIN_PRICING_BUTTON_TRAFFIC_OPTIONS": "🚦 Package visibility",
"ADMIN_PRICING_TRAFFIC_PACKAGE_ENABLED": "Package enabled.",
"ADMIN_PRICING_TRAFFIC_PACKAGE_DISABLED": "Package disabled.",
"ADMIN_PRICING_TRAFFIC_PACKAGE_MIN": "At least one package must remain.",
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_TITLE": "🚦 Traffic package visibility",
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_ACTIVE": "Active packages: {items}",
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_NONE": "No active packages.",
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_PROMPT": "Tap a package to toggle its visibility."
}
+140 -6
View File
@@ -6,6 +6,7 @@
"COUNTRY_MANAGEMENT_NONE": "Нет подключенных стран",
"PAID_FEATURE_ONLY": "⚠ Эта функция доступна только для платных подписок",
"PAID_FEATURE_ONLY_SHORT": "⚠ Только для платных подписок",
"SUSPICIOUS_DISPLAY_NAME_BLOCKED": "🚫 Мы обнаружили, что ваше отображаемое имя похоже на ссылку или служебный аккаунт. Пожалуйста, измените имя в профиле Telegram и повторите попытку.",
"COUNTRY_NOT_AVAILABLE_PROMOGROUP": "❌ Сервер недоступен для вашей промогруппы",
"COUNTRY_CHANGES_NOT_FOUND": "⚠️ Изменения не обнаружены",
"COUNTRY_CHANGES_SUCCESS_HEADER": "✅ <b>Страны успешно обновлены!</b>\n\n",
@@ -158,6 +159,7 @@
"HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку",
"CONTACT_SUPPORT": "💬 Написать в поддержку",
"CONTINUE": "➡️ Продолжить",
"PRIVACY_RESTRICTED_BUTTON_HINT": "⚠️ <b>Telegram не позволяет боту запросить контакт из-за ваших настроек приватности.</b>\n\nРазрешите отправку контакта в настройках Telegram или поделитесь данными вручную в ответ на сообщение.",
"CONTINUE_BUTTON": "✅ Продолжить",
"COPY_SUBSCRIPTION_LINK": "📋 Скопировать ссылку подписки",
"CREATE_INVITE": "📝 Создать приглашение",
@@ -182,6 +184,7 @@
"DEVICE_CONNECTION_HELP": "❓ Как подключить устройство заново?",
"DEVICE_GUIDE_ANDROID": "🤖 Android",
"DEVICE_GUIDE_ANDROID_TV": "📺 Android TV",
"DEVICE_GUIDE_APPLE_TV": "📺 Apple TV",
"DEVICE_GUIDE_IOS": "📱 iOS (iPhone/iPad)",
"DEVICE_GUIDE_MAC": "🎯 macOS",
"DEVICE_GUIDE_WINDOWS": "💻 Windows",
@@ -202,6 +205,8 @@
"MAINTENANCE_MODE_API_ERROR": "\n🔧 Технические работы!\n\nСервис временно недоступен из-за проблем с подключением к серверам.\n\n⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.\n\n🔄 Последняя проверка: {last_check}\n",
"MAIN_MENU": "👤 <b>{user_name}</b>\n \n📱 <b>Подписка:</b> {subscription_status}\n\nВыберите действие:\n",
"MAIN_MENU_ACTION_PROMPT": "Выберите действие:",
"MAIN_MENU_TEST_ACCESS_HEADER": "🧪 Тестовые сервера активны: {servers}",
"MAIN_MENU_TEST_ACCESS_TIMER": "⏳ Доступ действует ещё: {time_left}\n<code>{bar}</code>",
"MAIN_MENU_BUTTON": "🏠 Главное меню",
"MANAGE_DEVICES_BUTTON": "🔧 Управление устройствами",
"DEVICE_UUID_NOT_FOUND": "❌ UUID пользователя не найден",
@@ -228,6 +233,31 @@
"MENU_BALANCE": "💰 Баланс",
"MENU_BUY_SUBSCRIPTION": "💎 Купить подписку",
"MENU_EXTEND_SUBSCRIPTION": "⏰ Продлить подписку",
"MENU_INFO": "️ Инфо",
"MENU_INFO_HEADER": "️ <b>Инфо</b>",
"MENU_INFO_PROMPT": "Выберите раздел:",
"MENU_PROMO_GROUPS_INFO": "🎯 Скидки за траты",
"PROMO_GROUPS_INFO_HEADER": "🎯 <b>Скидки за траты</b>",
"PROMO_GROUPS_INFO_TOTAL_SPENT": "💰 Потрачено в боте: {amount}",
"PROMO_GROUPS_INFO_CURRENT_LEVEL": "🏆 Текущий уровень: {name}",
"PROMO_GROUPS_INFO_NO_LEVEL": "🏆 Текущий уровень: пока не получен",
"PROMO_GROUPS_INFO_NEXT_LEVEL": "📈 До уровня «{name}»: осталось {amount}",
"PROMO_GROUPS_INFO_MAX_LEVEL": "🏆 Вы уже получили максимальный уровень скидок!",
"PROMO_GROUPS_INFO_LEVELS_HEADER": "📋 Уровни с автовыдачей:",
"PROMO_GROUPS_INFO_LEVEL_LINE": "{status} <b>{name}</b> — от {amount}",
"PROMO_GROUPS_INFO_EMPTY": "Промогруппы с автовыдачей ещё не настроены.",
"MENU_PRIVACY_POLICY": "🛡️ Политика конф.",
"MENU_PUBLIC_OFFER": "📄 Оферта",
"PRIVACY_POLICY_HEADER": "🛡️ <b>Политика конфиденциальности</b>",
"PRIVACY_POLICY_NOT_AVAILABLE": "Политика конфиденциальности временно недоступна.",
"PRIVACY_POLICY_EMPTY_ALERT": "Политика конфиденциальности ещё не заполнена.",
"PRIVACY_POLICY_PAGE_INFO": "Страница {current} из {total}",
"PUBLIC_OFFER_NOT_AVAILABLE": "Публичная оферта временно недоступна.",
"PUBLIC_OFFER_EMPTY_ALERT": "Публичная оферта ещё не заполнена.",
"PUBLIC_OFFER_HEADER": "📄 <b>Публичная оферта</b>",
"PUBLIC_OFFER_PAGE_INFO": "Страница {current} из {total}",
"MENU_PROFILE": "👤 Личный кабинет",
"MENU_PROFILE_UNAVAILABLE": "❗️ Личный кабинет пока недоступен. Попробуйте позже.",
"MENU_LANGUAGE": "🌐 Язык",
"MENU_PROMOCODE": "🎫 Промокод",
"MENU_REFERRALS": "🤝 Партнерка",
@@ -418,12 +448,14 @@
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Настройка для {device_name}</b>",
"SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 <b>Ссылка подписки:</b>",
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Рекомендуемое приложение:</b> {app_name}",
"SUBSCRIPTION_DEVICE_OTHER_APPS": "📦 <b>Другие приложения:</b> {app_list}",
"SUBSCRIPTION_DEVICE_OTHER_APPS_HINT": "Нажмите кнопку \"Другие приложения\" ниже, чтобы выбрать приложение.",
"SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "<b>Шаг 1 - Установка:</b>",
"SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "<b>Шаг 2 - Добавление подписки:</b>",
"SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "<b>Шаг 3 - Подключение:</b>",
"SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 <b>Как подключить:</b>",
"SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Установите приложение по ссылке выше",
"SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Скопируйте ссылку подписки (нажмите на неё)",
"SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Нажмите кнопку \"Подключиться\" ниже",
"SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Откройте приложение и вставьте ссылку",
"SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Подключитесь к серверу",
"SUBSCRIPTION_APPS_TITLE": "📱 <b>Приложения для {device_name}</b>",
@@ -524,6 +556,16 @@
"ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Введите неотрицательное число в рублях или 0 для отключения.",
"ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.",
"ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:",
"ADMIN_PROMO_OFFER_TEST_DURATION": "Доступ: {hours} ч",
"ADMIN_PROMO_OFFER_TEST_SQUAD_NAME": "Сервер: {name}",
"ADMIN_PROMO_OFFER_TEST_SQUADS": "Сквады: {squads}",
"ADMIN_PROMO_OFFER_TEST_SQUADS_EMPTY": "Сквады: не указаны",
"ADMIN_PROMO_OFFER_RESULT": "📬 Рассылка завершена\nОтправлено: {sent}\nОшибок: {failed}",
"ADMIN_PROMO_OFFER_SKIPPED": "Пропущено: {skipped} (уже есть доступ)",
"ADMIN_PROMO_OFFER_CTA_BUY": "Купить подписку",
"ADMIN_PROMO_OFFER_CTA_EXTEND": "Продлить подписку",
"ADMIN_PROMO_OFFER_BACK_TO_TEMPLATE": "↩️ К предложению",
"ADMIN_PROMO_OFFER_BACK_TO_LIST": "⬅️ К промопредложениям",
"ADMIN_REPORTS": "📊 Отчеты",
"ADMIN_TICKETS_TITLE": "🎫 Все тикеты поддержки:",
"ADMIN_TICKET_REPLY_INPUT": "Введите ответ от поддержки:",
@@ -548,11 +590,17 @@
"CREATE_TICKET_BUTTON": "🎫 Создать тикет",
"DELETE_MESSAGE": "🗑 Удалить",
"DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки",
"SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT": "- Промо-предложение: -{amount} ({percent}% дополнительно)",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Доп. скидка {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Активирована доп. скидка {percent}%. \n\nСуммируется с другими скидками!",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Скидка действует ещё: {time_left}\n<code>{bar}</code>",
"DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.",
"DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.",
"DISCOUNT_CLAIM_ERROR": "❌ Не удалось активировать скидку. Попробуйте позже.",
"PROMO_OFFER_CLOSE": "❌ Закрыть",
"DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.",
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.",
"DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.",
"DISCOUNT_CLAIM_SUCCESS": "🎉 <b>Скидка {percent}% активирована!</b> \n\nОна суммируется с другими скидками и автоматически применится при следующей оплате.",
"TEST_ACCESS_ALREADY_CONNECTED": "ℹ️ Этот сервер уже подключен к вашей подписке.",
"ENTER_BLOCK_MINUTES": "Введите количество минут для блокировки пользователя (например, 15):",
"LANGUAGE_SELECTION_DISABLED": "⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.",
"MARK_AS_ANSWERED": "✅ Отметить как отвеченный",
@@ -581,19 +629,34 @@
"PAL24_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
"PAL24_TOPUP_PROMPT": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.",
"PAYMENT_CARD_MULENPAY": "💳 Банковская карта (Mulen Pay)",
"PAYMENT_CARD_WATA": "💳 Банковская карта (WATA)",
"PAYMENT_CARD_PAL24": "🏦 СБП (PayPalych)",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через Mulen Pay",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта (Mulen Pay)</b>",
"PAYMENT_METHOD_WATA_DESCRIPTION": "через WATA",
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Банковская карта (WATA)</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей",
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>СБП (PayPalych)</b>",
"WATA_TOPUP_PROMPT": "💳 <b>Оплата через WATA</b>\n\nВведите сумму пополнения. Минимальная сумма — {min_amount}, максимальная — {max_amount}.\nОплата происходит через защищенную форму WATA.",
"WATA_AMOUNT_TOO_LOW": "Минимальная сумма пополнения: {amount}",
"WATA_AMOUNT_TOO_HIGH": "Максимальная сумма пополнения: {amount}",
"WATA_PAYMENT_ERROR": "❌ Ошибка создания платежа WATA. Попробуйте позже или обратитесь в поддержку.",
"WATA_PAY_BUTTON": "💳 Оплатить через WATA",
"WATA_PAYMENT_INSTRUCTIONS": "💳 <b>Оплата через WATA</b>\n\n💰 Сумма: {amount}\n🆔 ID платежа: {payment_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку 'Оплатить через WATA'\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
"WATA_STATUS_TITLE": "💳 <b>Статус платежа WATA</b>",
"WATA_STATUS_OPENED": "Ожидает оплаты",
"WATA_STATUS_CLOSED": "Обрабатывается",
"WATA_STATUS_PAID": "Оплачен",
"WATA_STATUS_DECLINED": "Отклонен",
"WATA_STATUS_UNKNOWN": "Неизвестно",
"REPLY_TO_TICKET": "💬 Ответить",
"REPORT_CLOSE": "❌ Закрыть",
"REPORT_CLOSED": "✅ Отчет закрыт.",
"REPORT_CLOSE_ERROR": "❌ Не удалось закрыть отчет.",
"SENDING_ATTACHMENTS": "📎 Отправляю вложения...",
"SUBSCRIPTION_EXPIRED_1D": "⛔ <b>Подписка закончилась</b>\n\nДоступ был отключён {end_date}. Продлите подписку, чтобы вернуть полный доступ.\n\n💎 Стоимость продления: {price}",
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>Скидка {percent}% на продление</b>\n\nНажмите «Получить скидку», и мы начислим {bonus} на ваш баланс. Предложение действительно до {expires_at}.",
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Индивидуальная скидка {percent}%</b>\n\nПрошло {trigger_days} дней без подписки. Вернитесь — нажмите «Получить скидку», и {bonus} поступит на баланс. Предложение действительно до {expires_at}.",
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>Скидка {percent}% на продление</b>\n\nАктивируйте предложение, чтобы получить дополнительную скидку. Она суммируется с вашей промогруппой и действует до {expires_at}.",
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Индивидуальная скидка {percent}%</b>\n\nПрошло {trigger_days} дней без подписки. Вернитесь и активируйте дополнительную скидку — она суммируется с промогруппой и действует до {expires_at}.",
"SUBSCRIPTION_EXTEND": "💎 Продлить подписку",
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "<blockquote expandable><code>{crypto_link}</code></blockquote>",
"SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",
@@ -661,6 +724,69 @@
"ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Снятие блока",
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **Настройки системы**\n\n",
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Управление Remnawave, мониторингом и другими настройками:",
"ADMIN_SETTINGS_PRIVACY_POLICY": "🛡️ Политика конф.",
"ADMIN_PRIVACY_POLICY_HEADER": "🛡️ <b>Политика конфиденциальности</b>",
"ADMIN_PRIVACY_POLICY_DESCRIPTION": "Политика конфиденциальности отображается в разделе «Инфо».",
"ADMIN_PRIVACY_POLICY_LANGUAGE": "Язык: <code>{lang}</code>",
"ADMIN_PRIVACY_POLICY_STATUS_DISABLED": "⚠️ Показ политики выключен или текст отсутствует.",
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED": "✅ Политика активна и показывается пользователям.",
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY": "⚠️ Политика включена, но текст пуст — пользователи её не увидят.",
"ADMIN_PRIVACY_POLICY_UPDATED_AT": "Последнее обновление: {timestamp}",
"ADMIN_PRIVACY_POLICY_PREVIEW_TITLE": "<b>Превью текста:</b>",
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY": "Текст ещё не задан.",
"ADMIN_PRIVACY_POLICY_ACTION_PROMPT": "Выберите действие:",
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON": "✏️ Изменить текст",
"ADMIN_PRIVACY_POLICY_VIEW_BUTTON": "👀 Просмотреть текущий текст",
"ADMIN_PRIVACY_POLICY_ENABLE_BUTTON": "✅ Включить показ",
"ADMIN_PRIVACY_POLICY_DISABLE_BUTTON": "🚫 Отключить показ",
"ADMIN_PRIVACY_POLICY_HTML_HELP": "️ HTML помощь",
"ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW": "Текущий текст (превью):",
"ADMIN_PRIVACY_POLICY_EDIT_PROMPT": "Отправьте новый текст политики конфиденциальности. Допускается HTML-разметка.",
"ADMIN_PRIVACY_POLICY_EDIT_HINT": "Используйте /html_help для справки по тегам.",
"ADMIN_PRIVACY_POLICY_EDIT_TITLE": "Редактирование политики",
"ADMIN_PRIVACY_POLICY_CANCEL": "❌ Отмена",
"ADMIN_PRIVACY_POLICY_TOO_LONG": "❌ Текст политики слишком длинный. Максимум 4000 символов.",
"ADMIN_PRIVACY_POLICY_HTML_ERROR": "❌ Ошибка в HTML: {error}",
"ADMIN_PRIVACY_POLICY_SAVED": "✅ Политика конфиденциальности обновлена.",
"ADMIN_PRIVACY_POLICY_BACK_BUTTON": "⬅️ К настройкам политики",
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT": "Текст политики пока не задан.",
"ADMIN_PRIVACY_POLICY_VIEW_TITLE": "👀 <b>Текущий текст политики</b>",
"ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED": "\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
"ADMIN_PRIVACY_POLICY_ENABLED": "✅ Политика включена",
"ADMIN_PRIVACY_POLICY_DISABLED": "🚫 Политика отключена",
"ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT": "⬅️ Назад к редактированию",
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Публичная оферта",
"ADMIN_PUBLIC_OFFER_HEADER": "📄 <b>Публичная оферта</b>",
"ADMIN_PUBLIC_OFFER_DESCRIPTION": "Публичная оферта отображается в разделе «Инфо».",
"ADMIN_PUBLIC_OFFER_LANGUAGE": "Язык: <code>{lang}</code>",
"ADMIN_PUBLIC_OFFER_STATUS_DISABLED": "⚠️ Показ оферты выключен или текст отсутствует.",
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED": "✅ Оферта активна и показывается пользователям.",
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY": "⚠️ Оферта включена, но текст пуст — пользователи её не увидят.",
"ADMIN_PUBLIC_OFFER_UPDATED_AT": "Последнее обновление: {timestamp}",
"ADMIN_PUBLIC_OFFER_PREVIEW_TITLE": "<b>Превью текста:</b>",
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY": "Текст ещё не задан.",
"ADMIN_PUBLIC_OFFER_ACTION_PROMPT": "Выберите действие:",
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON": "✏️ Изменить текст",
"ADMIN_PUBLIC_OFFER_VIEW_BUTTON": "👀 Просмотреть текущий текст",
"ADMIN_PUBLIC_OFFER_ENABLE_BUTTON": "✅ Включить показ",
"ADMIN_PUBLIC_OFFER_DISABLE_BUTTON": "🚫 Отключить показ",
"ADMIN_PUBLIC_OFFER_HTML_HELP": "️ HTML помощь",
"ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW": "Текущий текст (превью):",
"ADMIN_PUBLIC_OFFER_EDIT_PROMPT": "Отправьте новый текст публичной оферты. Допускается HTML-разметка.",
"ADMIN_PUBLIC_OFFER_EDIT_HINT": "Используйте /html_help для справки по тегам.",
"ADMIN_PUBLIC_OFFER_EDIT_TITLE": "Редактирование оферты",
"ADMIN_PUBLIC_OFFER_CANCEL": "❌ Отмена",
"ADMIN_PUBLIC_OFFER_TOO_LONG": "❌ Текст оферты слишком длинный. Максимум 4000 символов.",
"ADMIN_PUBLIC_OFFER_HTML_ERROR": "❌ Ошибка в HTML: {error}",
"ADMIN_PUBLIC_OFFER_SAVED": "✅ Публичная оферта обновлена.",
"ADMIN_PUBLIC_OFFER_BACK_BUTTON": "⬅️ К настройкам оферты",
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT": "Текст оферты пока не задан.",
"ADMIN_PUBLIC_OFFER_VIEW_TITLE": "👀 <b>Текущий текст оферты</b>",
"ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED": "\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
"ADMIN_PUBLIC_OFFER_ENABLED": "✅ Оферта включена",
"ADMIN_PUBLIC_OFFER_DISABLED": "🚫 Оферта отключена",
"ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT": "⬅️ Назад к редактированию",
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Редактирование оферты отменено.",
"ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **Системные функции**\n\n",
"ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Отчеты, обновления, логи, резервные копии и системные операции:",
"ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED": "Включены",
@@ -698,5 +824,13 @@
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Добавьте в описание при необходимости.",
"ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Описание обновлено.",
"ADMIN_SUPPORT_DESCRIPTION_SENT": "Текст отправлен ниже",
"ADMIN_SUPPORT_MESSAGE_DELETED": "Сообщение удалено"
"ADMIN_SUPPORT_MESSAGE_DELETED": "Сообщение удалено",
"ADMIN_PRICING_BUTTON_TRAFFIC_OPTIONS": "🚦 Отображение пакетов",
"ADMIN_PRICING_TRAFFIC_PACKAGE_ENABLED": "Пакет включен.",
"ADMIN_PRICING_TRAFFIC_PACKAGE_DISABLED": "Пакет отключен.",
"ADMIN_PRICING_TRAFFIC_PACKAGE_MIN": "Должен оставаться хотя бы один пакет.",
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_TITLE": "🚦 Отображение пакетов трафика",
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_ACTIVE": "Активные пакеты: {items}",
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_NONE": "Активных пакетов нет.",
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_PROMPT": "Нажмите на пакет, чтобы включить или выключить его отображение."
}
+43 -5
View File
@@ -1,5 +1,5 @@
import logging
from typing import Callable, Dict, Any, Awaitable
from typing import Callable, Dict, Any, Awaitable, Optional
from aiogram import BaseMiddleware, Bot
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
from aiogram.fsm.context import FSMContext
@@ -86,16 +86,16 @@ class ChannelCheckerMiddleware(BaseMiddleware):
return await handler(event, data)
is_required = settings.CHANNEL_IS_REQUIRED_SUB
if not is_required:
logger.debug("⚠️ Обязательная подписка отключена, пропускаем проверку")
return await handler(event, data)
channel_link = settings.CHANNEL_LINK
try:
member = await bot.get_chat_member(chat_id=channel_id, user_id=telegram_id)
if member.status in self.GOOD_MEMBER_STATUS:
return await handler(event, data)
elif member.status in self.BAD_MEMBER_STATUS:
@@ -104,6 +104,8 @@ class ChannelCheckerMiddleware(BaseMiddleware):
if telegram_id:
await self._deactivate_trial_subscription(telegram_id)
await self._capture_start_payload(state, event)
if isinstance(event, CallbackQuery) and event.data == "sub_channel_check":
await event.answer("❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.", show_alert=True)
return
@@ -111,10 +113,12 @@ class ChannelCheckerMiddleware(BaseMiddleware):
return await self._deny_message(event, bot, channel_link)
else:
logger.warning(f"⚠️ Неожиданный статус пользователя {telegram_id}: {member.status}")
await self._capture_start_payload(state, event)
return await self._deny_message(event, bot, channel_link)
except TelegramForbiddenError as e:
logger.error(f"❌ Бот заблокирован в канале {channel_id}: {e}")
await self._capture_start_payload(state, event)
return await self._deny_message(event, bot, channel_link)
except TelegramBadRequest as e:
if "chat not found" in str(e).lower():
@@ -123,11 +127,45 @@ class ChannelCheckerMiddleware(BaseMiddleware):
logger.error(f"❌ Пользователь {telegram_id} не найден: {e}")
else:
logger.error(f"❌ Ошибка запроса к каналу {channel_id}: {e}")
await self._capture_start_payload(state, event)
return await self._deny_message(event, bot, channel_link)
except Exception as e:
logger.error(f"❌ Неожиданная ошибка при проверке подписки: {e}")
return await handler(event, data)
async def _capture_start_payload(self, state: Optional[FSMContext], event: TelegramObject) -> None:
if not state:
return
message: Optional[Message] = None
if isinstance(event, Message):
message = event
elif isinstance(event, CallbackQuery):
message = event.message
elif isinstance(event, Update):
message = event.message
if not message or not message.text:
return
text = message.text.strip()
if not text.startswith("/start"):
return
parts = text.split(maxsplit=1)
if len(parts) < 2 or not parts[1]:
return
payload = parts[1]
data = await state.get_data() or {}
if data.get("pending_start_payload") == payload:
return
data["pending_start_payload"] = payload
await state.set_data(data)
logger.debug("💾 Сохранен start payload %s для последующей обработки", payload)
async def _deactivate_trial_subscription(self, telegram_id: int) -> None:
async for db in get_db():
try:
+159
View File
@@ -0,0 +1,159 @@
import logging
import re
from typing import Any, Awaitable, Callable, Dict
from aiogram import BaseMiddleware
from aiogram.types import (
CallbackQuery,
Message,
PreCheckoutQuery,
TelegramObject,
User as TgUser,
)
from app.config import settings
from app.localization.texts import get_texts
logger = logging.getLogger(__name__)
ZERO_WIDTH_PATTERN = re.compile(r"[\u200B-\u200D\uFEFF]")
LINK_PATTERNS = [
re.compile(pattern, re.IGNORECASE)
for pattern in (
r"t\.me/\+",
r"joinchat",
r"https?://",
r"www\.",
r"tg://",
r"telegram\.me",
r"t\.me",
)
]
DOMAIN_OBFUSCATION_PATTERN = re.compile(
r"(?<![0-9a-zа-яё])(?:t|т)[\s\W_]*?(?:m|м)(?:e|е)",
re.IGNORECASE,
)
CHAR_TRANSLATION = str.maketrans({
"а": "a",
"е": "e",
"о": "o",
"р": "p",
"с": "c",
"х": "x",
"у": "y",
"к": "k",
"т": "t",
"г": "g",
"м": "m",
"н": "n",
"л": "l",
"і": "i",
"ї": "i",
"ё": "e",
"ь": "",
"ъ": "",
"ў": "u",
"": "@",
})
COLLAPSE_PATTERN = re.compile(r"[\s\._\-/\\|,:;•·﹒․⋅··`~'\"!?()\[\]{}<>+=]+")
class DisplayNameRestrictionMiddleware(BaseMiddleware):
"""Blocks users whose display name imitates links or official accounts."""
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any],
) -> Any:
user: TgUser | None = None
if isinstance(event, (Message, CallbackQuery, PreCheckoutQuery)):
user = event.from_user
if not user or user.is_bot:
return await handler(event, data)
display_name = self._build_display_name(user)
username = user.username or ""
display_suspicious = self._is_suspicious(display_name)
username_suspicious = self._is_suspicious(username)
if display_suspicious or username_suspicious:
suspicious_value = display_name if display_suspicious else username
language = self._resolve_language(user, data)
texts = get_texts(language)
warning = texts.get(
"SUSPICIOUS_DISPLAY_NAME_BLOCKED",
"🚫 Ваше отображаемое имя похоже на ссылку или служебный аккаунт. "
"Пожалуйста, измените имя и попробуйте снова.",
)
logger.warning(
"🚫 DisplayNameRestriction: user %s blocked due to suspicious name '%s'",
user.id,
suspicious_value,
)
if isinstance(event, Message):
await event.answer(warning)
elif isinstance(event, CallbackQuery):
await event.answer(warning, show_alert=True)
elif isinstance(event, PreCheckoutQuery):
await event.answer(ok=False, error_message=warning)
return None
return await handler(event, data)
@staticmethod
def _build_display_name(user: TgUser) -> str:
parts = [user.first_name or "", user.last_name or ""]
return " ".join(part for part in parts if part).strip()
@staticmethod
def _resolve_language(user: TgUser, data: Dict[str, Any]) -> str:
db_user = data.get("db_user")
if db_user and getattr(db_user, "language", None):
return db_user.language
language_code = getattr(user, "language_code", None)
return language_code or settings.DEFAULT_LANGUAGE
def _is_suspicious(self, value: str) -> bool:
if not value:
return False
cleaned = ZERO_WIDTH_PATTERN.sub("", value)
lower_value = cleaned.lower()
if "@" in cleaned or "" in cleaned:
return True
if any(pattern.search(lower_value) for pattern in LINK_PATTERNS):
return True
if DOMAIN_OBFUSCATION_PATTERN.search(lower_value):
return True
normalized = self._normalize_text(lower_value)
collapsed = COLLAPSE_PATTERN.sub("", normalized)
if "tme" in collapsed:
return True
banned_keywords = settings.get_display_name_banned_keywords()
return any(
keyword in normalized or keyword in collapsed
for keyword in banned_keywords
)
@staticmethod
def _normalize_text(value: str) -> str:
return value.translate(CHAR_TRANSLATION)
+145 -36
View File
@@ -3,12 +3,13 @@ from typing import Optional, Dict, Any, List
from datetime import datetime
from aiogram import Bot, types
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import MissingGreenlet
from app.config import settings
from app.database.crud.promo_group import get_promo_group_by_id
from app.database.crud.user import get_user_by_id
from app.database.crud.transaction import get_transaction_by_id
from app.database.models import (
AdvertisingCampaign,
PromoCodeType,
@@ -343,40 +344,24 @@ class AdminNotificationService:
logger.error(f"Ошибка отправки уведомления об ошибке проверки версий: {e}")
return False
async def send_balance_topup_notification(
def _build_balance_topup_message(
self,
db: AsyncSession,
user: User,
transaction: Transaction,
old_balance: int
) -> bool:
if not self._is_enabled():
return False
old_balance: int,
*,
topup_status: str,
referrer_info: str,
subscription: Subscription | None,
promo_group: PromoGroup | None,
) -> str:
payment_method = self._get_payment_method_display(transaction.payment_method)
balance_change = user.balance_kopeks - old_balance
subscription_status = self._get_subscription_status(subscription)
promo_block = self._format_promo_group_block(promo_group)
timestamp = datetime.now().strftime('%d.%m.%Y %H:%M:%S')
try:
deposit_count_result = await db.execute(
select(func.count())
.select_from(Transaction)
.where(
Transaction.user_id == user.id,
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed.is_(True)
)
)
deposit_count = deposit_count_result.scalar_one() or 0
topup_status = "🆕 Первое пополнение" if deposit_count <= 1 else "🔄 Пополнение"
payment_method = self._get_payment_method_display(transaction.payment_method)
balance_change = user.balance_kopeks - old_balance
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
subscription_result = await db.execute(
select(Subscription).where(Subscription.user_id == user.id)
)
subscription = subscription_result.scalar_one_or_none()
subscription_status = self._get_subscription_status(subscription)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
message = f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
return f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
👤 <b>Пользователь:</b> {user.full_name}
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
@@ -398,12 +383,137 @@ class AdminNotificationService:
🔗 <b>Реферер:</b> {referrer_info}
📱 <b>Подписка:</b> {subscription_status}
<i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
<i>{timestamp}</i>"""
async def _reload_topup_notification_entities(
self,
db: AsyncSession,
user: User,
transaction: Transaction,
) -> tuple[User, Transaction, Subscription | None, PromoGroup | None]:
refreshed_user = await get_user_by_id(db, user.id)
if not refreshed_user:
raise ValueError(
f"Не удалось повторно загрузить пользователя {user.id} для уведомления о пополнении"
)
refreshed_transaction = await get_transaction_by_id(db, transaction.id)
if not refreshed_transaction:
raise ValueError(
f"Не удалось повторно загрузить транзакцию {transaction.id} для уведомления о пополнении"
)
subscription = getattr(refreshed_user, "subscription", None)
promo_group = await self._get_user_promo_group(db, refreshed_user)
return refreshed_user, refreshed_transaction, subscription, promo_group
def _is_lazy_loading_error(self, error: Exception) -> bool:
message = str(error).lower()
return (
isinstance(error, MissingGreenlet)
or "greenlet_spawn" in message
or "await_only" in message
or "missinggreenlet" in message
)
async def send_balance_topup_notification(
self,
user: User,
transaction: Transaction,
old_balance: int,
*,
topup_status: str,
referrer_info: str,
subscription: Subscription | None,
promo_group: PromoGroup | None,
db: AsyncSession | None = None,
) -> bool:
if not self._is_enabled():
return False
logger.info("Начинаем отправку уведомления о пополнении баланса")
try:
logger.info("Пытаемся создать сообщение уведомления")
message = self._build_balance_topup_message(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
)
logger.info("Сообщение уведомления создано успешно")
except Exception as error:
logger.info(f"Перехвачена ошибка при создании сообщения уведомления: {type(error).__name__}: {error}")
if not self._is_lazy_loading_error(error):
logger.error(
"Ошибка подготовки уведомления о пополнении: %s",
error,
exc_info=True,
)
return False
if db is None:
logger.error(
"Недостаточно данных для уведомления о пополнении и отсутствует доступ к БД: %s",
error,
exc_info=True,
)
return False
logger.warning(
"Повторная загрузка данных для уведомления о пополнении после ошибки ленивой загрузки: %s",
error,
)
try:
logger.info("Пытаемся перезагрузить данные для уведомления")
(
user,
transaction,
subscription,
promo_group,
) = await self._reload_topup_notification_entities(db, user, transaction)
logger.info("Данные успешно перезагружены")
except Exception as reload_error:
logger.error(
"Ошибка повторной загрузки данных для уведомления о пополнении: %s",
reload_error,
exc_info=True,
)
return False
try:
logger.info("Пытаемся создать сообщение после перезагрузки данных")
message = self._build_balance_topup_message(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
)
logger.info("Сообщение успешно создано после перезагрузки данных")
except Exception as rebuild_error:
logger.error(
"Ошибка повторной подготовки уведомления о пополнении после повторной загрузки: %s",
rebuild_error,
exc_info=True,
)
return False
try:
return await self._send_message(message)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о пополнении: {e}")
logger.error(
f"Ошибка отправки уведомления о пополнении: {e}",
exc_info=True,
)
return False
async def send_subscription_extension_notification(
@@ -1088,4 +1198,3 @@ class AdminNotificationService:
if not (self._is_enabled() and runtime_enabled):
return False
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
+19 -2
View File
@@ -125,8 +125,24 @@ class AdvertisingCampaignService:
)
squads = list(campaign.subscription_squads or [])
if not squads and getattr(settings, "TRIAL_SQUAD_UUID", None):
squads = [settings.TRIAL_SQUAD_UUID]
if not squads:
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
trial_uuid = await get_random_trial_squad_uuid(
db,
getattr(settings, "TRIAL_SQUAD_UUID", None),
)
if trial_uuid:
squads = [trial_uuid]
except Exception as error:
logger.error(
"Не удалось подобрать сквад для кампании %s: %s",
campaign.id,
error,
)
if getattr(settings, "TRIAL_SQUAD_UUID", None):
squads = [settings.TRIAL_SQUAD_UUID]
new_subscription = await create_paid_subscription(
db=db,
@@ -135,6 +151,7 @@ class AdvertisingCampaignService:
traffic_limit_gb=traffic_limit or 0,
device_limit=device_limit,
connected_squads=squads,
update_server_counters=True,
)
try:
+160
View File
@@ -0,0 +1,160 @@
"""Утилиты для синхронизации токена внешней админки."""
from __future__ import annotations
import logging
from typing import Optional
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from app.config import settings
from app.database.database import AsyncSessionLocal
from app.database.models import SystemSetting
from app.services.system_settings_service import (
ReadOnlySettingError,
bot_configuration_service,
)
logger = logging.getLogger(__name__)
async def ensure_external_admin_token(
bot_username: Optional[str],
bot_id: Optional[int],
) -> Optional[str]:
"""Генерирует и сохраняет токен внешней админки, если требуется."""
username_raw = (bot_username or "").strip()
if not username_raw:
logger.warning(
"⚠️ Не удалось обеспечить токен внешней админки: username бота отсутствует",
)
return None
normalized_username = username_raw.lstrip("@").lower()
if not normalized_username:
logger.warning(
"⚠️ Не удалось обеспечить токен внешней админки: username пустой после нормализации",
)
return None
try:
token = settings.build_external_admin_token(normalized_username)
except Exception as error: # pragma: no cover - защитный блок
logger.error("❌ Ошибка генерации токена внешней админки: %s", error)
return None
try:
async with AsyncSessionLocal() as session:
result = await session.execute(
select(SystemSetting.key, SystemSetting.value).where(
SystemSetting.key.in_(
["EXTERNAL_ADMIN_TOKEN", "EXTERNAL_ADMIN_TOKEN_BOT_ID"]
)
)
)
rows = dict(result.all())
existing_token = rows.get("EXTERNAL_ADMIN_TOKEN")
existing_bot_id_raw = rows.get("EXTERNAL_ADMIN_TOKEN_BOT_ID")
existing_bot_id: Optional[int] = None
if existing_bot_id_raw is not None:
try:
existing_bot_id = int(existing_bot_id_raw)
except (TypeError, ValueError): # pragma: no cover - защита от мусорных значений
logger.warning(
"⚠️ Не удалось разобрать сохраненный идентификатор бота внешней админки: %s",
existing_bot_id_raw,
)
if existing_token == token and existing_bot_id == bot_id:
if settings.get_external_admin_token() != token:
settings.EXTERNAL_ADMIN_TOKEN = token
if settings.EXTERNAL_ADMIN_TOKEN_BOT_ID != existing_bot_id:
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = existing_bot_id
return token
if existing_bot_id is not None and bot_id is not None and existing_bot_id != bot_id:
logger.error(
"❌ Обнаружено несовпадение ID бота для токена внешней админки: сохранен %s, текущий %s",
existing_bot_id,
bot_id,
)
try:
await bot_configuration_service.reset_value(
session,
"EXTERNAL_ADMIN_TOKEN",
force=True,
)
await bot_configuration_service.reset_value(
session,
"EXTERNAL_ADMIN_TOKEN_BOT_ID",
force=True,
)
await session.commit()
logger.warning(
"⚠️ Токен внешней админки очищен из-за несовпадения идентификаторов бота",
)
except Exception as cleanup_error: # pragma: no cover - защитный блок
await session.rollback()
logger.error(
"❌ Не удалось очистить токен внешней админки после обнаружения подмены: %s",
cleanup_error,
)
finally:
settings.EXTERNAL_ADMIN_TOKEN = None
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = None
return None
updates: list[tuple[str, object]] = []
if existing_token != token:
updates.append(("EXTERNAL_ADMIN_TOKEN", token))
if bot_id is not None and existing_bot_id != bot_id:
updates.append(("EXTERNAL_ADMIN_TOKEN_BOT_ID", bot_id))
if not updates:
# Токен совпал, но могли отсутствовать значения в настройках приложения
if settings.get_external_admin_token() != (existing_token or token):
settings.EXTERNAL_ADMIN_TOKEN = existing_token or token
if existing_bot_id is not None and (
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID != existing_bot_id
):
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = existing_bot_id
elif (
bot_id is not None
and settings.EXTERNAL_ADMIN_TOKEN_BOT_ID != bot_id
and existing_bot_id is None
):
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = bot_id
return existing_token or token
try:
for key, value in updates:
await bot_configuration_service.set_value(
session,
key,
value,
force=True,
)
await session.commit()
logger.info(
"✅ Токен внешней админки синхронизирован для @%s",
normalized_username,
)
except ReadOnlySettingError: # pragma: no cover - force=True предотвращает исключение
await session.rollback()
logger.warning(
"⚠️ Не удалось сохранить токен внешней админки из-за ограничения доступа",
)
return None
return token
except SQLAlchemyError as error:
logger.error("❌ Ошибка сохранения токена внешней админки: %s", error)
return None
+273
View File
@@ -0,0 +1,273 @@
import logging
from typing import List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.faq import (
bulk_update_order,
create_faq_page,
delete_faq_page,
get_faq_page_by_id,
get_faq_pages,
get_faq_setting,
set_faq_enabled,
update_faq_page,
)
from app.database.models import FaqPage, FaqSetting
logger = logging.getLogger(__name__)
class FaqService:
MAX_PAGE_LENGTH = 3500
@staticmethod
def _normalize_language(language: str) -> str:
base_language = language or settings.DEFAULT_LANGUAGE or "ru"
return base_language.split("-")[0].lower()
@staticmethod
def normalize_language(language: str) -> str:
return FaqService._normalize_language(language)
@classmethod
async def get_setting(
cls,
db: AsyncSession,
language: str,
*,
fallback: bool = True,
) -> Optional[FaqSetting]:
lang = cls._normalize_language(language)
setting = await get_faq_setting(db, lang)
if setting or not fallback:
return setting
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
if lang != default_lang:
return await get_faq_setting(db, default_lang)
return setting
@classmethod
async def is_enabled(cls, db: AsyncSession, language: str) -> bool:
pages = await cls.get_pages(db, language)
return bool(pages)
@classmethod
async def set_enabled(
cls,
db: AsyncSession,
language: str,
enabled: bool,
) -> FaqSetting:
lang = cls._normalize_language(language)
return await set_faq_enabled(db, lang, enabled)
@classmethod
async def toggle_enabled(
cls,
db: AsyncSession,
language: str,
) -> FaqSetting:
lang = cls._normalize_language(language)
setting = await get_faq_setting(db, lang)
new_status = True
if setting:
new_status = not setting.is_enabled
return await set_faq_enabled(db, lang, new_status)
@classmethod
async def get_pages(
cls,
db: AsyncSession,
language: str,
*,
include_inactive: bool = False,
fallback: bool = True,
) -> List[FaqPage]:
lang = cls._normalize_language(language)
pages = await get_faq_pages(db, lang, include_inactive=include_inactive)
if pages:
setting = await get_faq_setting(db, lang)
if setting and not setting.is_enabled and not include_inactive:
return []
return pages
if not fallback:
return []
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
if lang == default_lang:
return []
fallback_pages = await get_faq_pages(
db,
default_lang,
include_inactive=include_inactive,
)
if not fallback_pages:
return []
setting = await get_faq_setting(db, default_lang)
if setting and not setting.is_enabled and not include_inactive:
return []
return fallback_pages
@classmethod
async def get_page(
cls,
db: AsyncSession,
page_id: int,
language: str,
*,
fallback: bool = True,
include_inactive: bool = False,
) -> Optional[FaqPage]:
page = await get_faq_page_by_id(db, page_id)
if not page:
return None
lang = cls._normalize_language(language)
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
if not include_inactive and not page.is_active:
return None
if page.language == lang:
return page
if fallback and page.language == default_lang:
return page
return None
@classmethod
async def create_page(
cls,
db: AsyncSession,
*,
language: str,
title: str,
content: str,
display_order: Optional[int] = None,
is_active: Optional[bool] = None,
) -> FaqPage:
lang = cls._normalize_language(language)
is_active_value = True if is_active is None else bool(is_active)
page = await create_faq_page(
db,
language=lang,
title=title,
content=content,
display_order=display_order,
is_active=is_active_value,
)
setting = await get_faq_setting(db, lang)
if not setting:
await set_faq_enabled(db, lang, True)
return page
@classmethod
async def update_page(
cls,
db: AsyncSession,
page: FaqPage,
*,
title: Optional[str] = None,
content: Optional[str] = None,
display_order: Optional[int] = None,
is_active: Optional[bool] = None,
) -> FaqPage:
return await update_faq_page(
db,
page,
title=title,
content=content,
display_order=display_order,
is_active=is_active,
)
@classmethod
async def delete_page(cls, db: AsyncSession, page_id: int) -> None:
await delete_faq_page(db, page_id)
@classmethod
async def reorder_pages(
cls,
db: AsyncSession,
language: str,
pages: List[FaqPage],
) -> None:
lang = cls._normalize_language(language)
ordered = [page for page in pages if page.language == lang]
payload = [(page.id, index + 1) for index, page in enumerate(ordered)]
await bulk_update_order(db, payload)
@staticmethod
def split_content_into_pages(
content: str,
*,
max_length: Optional[int] = None,
) -> List[str]:
if not content:
return []
normalized = content.replace("\r\n", "\n").strip()
if not normalized:
return []
limit = max_length or FaqService.MAX_PAGE_LENGTH
if len(normalized) <= limit:
return [normalized]
paragraphs = [
paragraph.strip()
for paragraph in normalized.split("\n\n")
if paragraph.strip()
]
pages: List[str] = []
current = ""
def flush_current() -> None:
nonlocal current
if current:
pages.append(current.strip())
current = ""
for paragraph in paragraphs:
candidate = f"{current}\n\n{paragraph}".strip() if current else paragraph
if len(candidate) <= limit:
current = candidate
continue
flush_current()
if len(paragraph) <= limit:
current = paragraph
continue
start = 0
while start < len(paragraph):
chunk = paragraph[start : start + limit]
pages.append(chunk.strip())
start += limit
current = ""
flush_current()
if not pages:
return [normalized[:limit]]
return pages
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import List
from aiogram import types
from aiogram.types import InlineKeyboardButton
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import (
MainMenuButton,
MainMenuButtonActionType,
MainMenuButtonVisibility,
)
from app.config import settings
@dataclass(frozen=True)
class _MainMenuButtonData:
text: str
action_type: MainMenuButtonActionType
action_value: str
visibility: MainMenuButtonVisibility
is_active: bool
display_order: int
class MainMenuButtonService:
_cache: List[_MainMenuButtonData] | None = None
_lock: asyncio.Lock = asyncio.Lock()
@classmethod
def invalidate_cache(cls) -> None:
cls._cache = None
@classmethod
async def _load_cache(cls, db: AsyncSession) -> List[_MainMenuButtonData]:
if cls._cache is not None:
return cls._cache
async with cls._lock:
if cls._cache is not None:
return cls._cache
result = await db.execute(
select(MainMenuButton).order_by(
MainMenuButton.display_order.asc(),
MainMenuButton.id.asc(),
)
)
items: List[_MainMenuButtonData] = []
for record in result.scalars().all():
text = (record.text or "").strip()
action_value = (record.action_value or "").strip()
if not text or not action_value:
continue
try:
action_type = MainMenuButtonActionType(record.action_type)
except ValueError:
continue
try:
visibility = MainMenuButtonVisibility(record.visibility)
except ValueError:
visibility = MainMenuButtonVisibility.ALL
items.append(
_MainMenuButtonData(
text=text,
action_type=action_type,
action_value=action_value,
visibility=visibility,
is_active=bool(record.is_active),
display_order=int(record.display_order or 0),
)
)
cls._cache = items
return items
@classmethod
async def get_buttons_for_user(
cls,
db: AsyncSession,
*,
is_admin: bool,
has_active_subscription: bool,
subscription_is_active: bool,
) -> list[InlineKeyboardButton]:
data = await cls._load_cache(db)
has_subscription = bool(has_active_subscription and subscription_is_active)
buttons: list[InlineKeyboardButton] = []
for item in data:
if not item.is_active:
continue
if item.visibility == MainMenuButtonVisibility.ADMINS and not is_admin:
continue
if item.visibility == MainMenuButtonVisibility.SUBSCRIBERS and not has_subscription:
continue
# Проверка реферальной программы: скрыть кнопки, связанные с рефералами, если программа отключена
if (
not settings.is_referral_program_enabled()
and (
"partner" in item.text.lower()
or "referr" in item.text.lower()
or "партнер" in item.text.lower()
or "реферал" in item.text.lower()
or "referral" in item.action_value.lower()
)
):
continue
button = cls._build_button(item)
if button:
buttons.append(button)
return buttons
@staticmethod
def _build_button(item: _MainMenuButtonData) -> InlineKeyboardButton | None:
if item.action_type == MainMenuButtonActionType.URL:
return InlineKeyboardButton(text=item.text, url=item.action_value)
if item.action_type == MainMenuButtonActionType.MINI_APP:
return InlineKeyboardButton(
text=item.text,
web_app=types.WebAppInfo(url=item.action_value),
)
return None
+171 -44
View File
@@ -15,8 +15,10 @@ from app.config import settings
from app.database.database import get_db
from app.database.crud.discount_offer import (
deactivate_expired_offers,
get_latest_claimed_offer_for_user,
upsert_discount_offer,
)
from app.database.crud.promo_offer_log import log_promo_offer_action
from app.database.crud.notification import (
clear_notification_by_type,
notification_sent,
@@ -34,12 +36,16 @@ from app.database.crud.user import (
get_inactive_users,
get_user_by_id,
subtract_user_balance,
cleanup_expired_promo_offer_discounts,
)
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus
from app.localization.texts import get_texts
from app.services.notification_settings_service import NotificationSettingsService
from app.services.payment_service import PaymentService
from app.services.subscription_service import SubscriptionService
from app.services.promo_offer_service import promo_offer_service
from app.utils.pricing_utils import apply_percentage_discount
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.external.remnawave_api import (
RemnaWaveAPIError,
@@ -179,6 +185,17 @@ class MonitoringService:
if expired_offers:
logger.info(f"🧹 Деактивировано {expired_offers} просроченных скидочных предложений")
expired_active_discounts = await cleanup_expired_promo_offer_discounts(db)
if expired_active_discounts:
logger.info(
"🧹 Сброшено %s активных скидок промо-предложений с истекшим сроком",
expired_active_discounts,
)
cleaned_test_access = await promo_offer_service.cleanup_expired_test_access(db)
if cleaned_test_access:
logger.info(f"🧹 Отозвано {cleaned_test_access} истекших тестовых доступов к сквадам")
await self._check_expired_subscriptions(db)
await self._check_expiring_subscriptions(db)
await self._check_trial_expiring_soon(db)
@@ -676,15 +693,15 @@ class MonitoringService:
if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave2"):
percent = NotificationSettingsService.get_second_wave_discount_percent()
valid_hours = NotificationSettingsService.get_second_wave_valid_hours()
bonus_amount = settings.PRICE_30_DAYS * percent // 100
offer = await upsert_discount_offer(
db,
user_id=user.id,
subscription_id=subscription.id,
notification_type="expired_discount_wave2",
discount_percent=percent,
bonus_amount_kopeks=bonus_amount,
bonus_amount_kopeks=0,
valid_hours=valid_hours,
effect_type="percent_discount",
)
success = await self._send_expired_discount_notification(
user,
@@ -693,7 +710,6 @@ class MonitoringService:
offer.expires_at,
offer.id,
"second",
bonus_amount,
)
if success:
await record_notification(db, user.id, subscription.id, "expired_discount_wave2")
@@ -706,15 +722,15 @@ class MonitoringService:
if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave3"):
percent = NotificationSettingsService.get_third_wave_discount_percent()
valid_hours = NotificationSettingsService.get_third_wave_valid_hours()
bonus_amount = settings.PRICE_30_DAYS * percent // 100
offer = await upsert_discount_offer(
db,
user_id=user.id,
subscription_id=subscription.id,
notification_type="expired_discount_wave3",
discount_percent=percent,
bonus_amount_kopeks=bonus_amount,
bonus_amount_kopeks=0,
valid_hours=valid_hours,
effect_type="percent_discount",
)
success = await self._send_expired_discount_notification(
user,
@@ -723,7 +739,6 @@ class MonitoringService:
offer.expires_at,
offer.id,
"third",
bonus_amount,
trigger_days=trigger_days,
)
if success:
@@ -777,6 +792,85 @@ class MonitoringService:
return subscriptions
@staticmethod
def _get_user_promo_offer_discount_percent(user: Optional[User]) -> int:
if not user:
return 0
try:
percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
except (TypeError, ValueError):
return 0
expires_at = getattr(user, "promo_offer_discount_expires_at", None)
if expires_at and expires_at <= datetime.utcnow():
return 0
return max(0, min(100, percent))
@staticmethod
async def _consume_user_promo_offer_discount(db: AsyncSession, user: User) -> None:
percent = MonitoringService._get_user_promo_offer_discount_percent(user)
if percent <= 0:
return
source = getattr(user, "promo_offer_discount_source", None)
log_payload = {
"offer_id": None,
"percent": percent,
"source": source,
"effect_type": None,
}
try:
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
except Exception as lookup_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to resolve latest claimed promo offer for user %s: %s",
user.id,
lookup_error,
)
offer = None
if offer:
log_payload["offer_id"] = offer.id
log_payload["effect_type"] = offer.effect_type
if not log_payload["percent"] and offer.discount_percent:
log_payload["percent"] = offer.discount_percent
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
try:
await log_promo_offer_action(
db,
user_id=user.id,
offer_id=log_payload.get("offer_id"),
action="consumed",
source=log_payload.get("source"),
percent=log_payload.get("percent"),
effect_type=log_payload.get("effect_type"),
details={"reason": "autopay_consumed"},
)
except Exception as log_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to record promo offer autopay log for user %s: %s",
user.id,
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 autopay log failure: %s",
rollback_error,
)
async def _process_autopayments(self, db: AsyncSession):
try:
current_time = datetime.utcnow()
@@ -809,17 +903,26 @@ class MonitoringService:
continue
renewal_cost = settings.PRICE_30_DAYS
promo_discount_percent = self._get_user_promo_offer_discount_percent(user)
charge_amount = renewal_cost
promo_discount_value = 0
if renewal_cost > 0 and promo_discount_percent > 0:
charge_amount, promo_discount_value = apply_percentage_discount(
renewal_cost,
promo_discount_percent,
)
autopay_key = f"autopay_{user.telegram_id}_{subscription.id}"
if autopay_key in self._notified_users:
continue
if user.balance_kopeks >= renewal_cost:
if user.balance_kopeks >= charge_amount:
success = await subtract_user_balance(
db, user, renewal_cost,
db, user, charge_amount,
"Автопродление подписки"
)
if success:
await extend_subscription(db, subscription, 30)
await self.subscription_service.update_remnawave_user(
@@ -828,22 +931,30 @@ class MonitoringService:
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason="автопродление подписки",
)
if promo_discount_value > 0:
await self._consume_user_promo_offer_discount(db, user)
if self.bot:
await self._send_autopay_success_notification(user, renewal_cost, 30)
await self._send_autopay_success_notification(user, charge_amount, 30)
processed_count += 1
self._notified_users.add(autopay_key)
logger.info(f"💳 Автопродление подписки пользователя {user.telegram_id} успешно")
logger.info(
"💳 Автопродление подписки пользователя %s успешно (списано %s, скидка %s%%)",
user.telegram_id,
charge_amount,
promo_discount_percent,
)
else:
failed_count += 1
if self.bot:
await self._send_autopay_failed_notification(user, user.balance_kopeks, renewal_cost)
await self._send_autopay_failed_notification(user, user.balance_kopeks, charge_amount)
logger.warning(f"💳 Ошибка списания средств для автопродления пользователя {user.telegram_id}")
else:
failed_count += 1
if self.bot:
await self._send_autopay_failed_notification(user, user.balance_kopeks, renewal_cost)
await self._send_autopay_failed_notification(user, user.balance_kopeks, charge_amount)
logger.warning(f"💳 Недостаточно средств для автопродления у пользователя {user.telegram_id}")
if processed_count > 0 or failed_count > 0:
@@ -867,10 +978,10 @@ class MonitoringService:
"""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💎 Купить подписку", callback_data="menu_buy")],
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")]
[build_miniapp_or_callback_button(text="💎 Купить подписку", callback_data="menu_buy")],
[build_miniapp_or_callback_button(text="💳 Пополнить баланс", callback_data="balance_topup")],
])
await self._send_message_with_logo(
@@ -923,11 +1034,11 @@ class MonitoringService:
"""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="⏰ Продлить подписку", callback_data="subscription_extend")],
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")],
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")]
[build_miniapp_or_callback_button(text="⏰ Продлить подписку", callback_data="subscription_extend")],
[build_miniapp_or_callback_button(text="💳 Пополнить баланс", callback_data="balance_topup")],
[build_miniapp_or_callback_button(text="📱 Моя подписка", callback_data="menu_subscription")],
])
await self._send_message_with_logo(
@@ -977,10 +1088,10 @@ class MonitoringService:
"""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💎 Купить подписку", callback_data="menu_buy")],
[InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="balance_topup")]
[build_miniapp_or_callback_button(text="💎 Купить подписку", callback_data="menu_buy")],
[build_miniapp_or_callback_button(text="💰 Пополнить баланс", callback_data="balance_topup")],
])
await self._send_message_with_logo(
@@ -1037,8 +1148,14 @@ class MonitoringService:
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")],
[InlineKeyboardButton(text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), callback_data="menu_subscription")],
[build_miniapp_or_callback_button(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
callback_data="subscription_connect",
)],
[build_miniapp_or_callback_button(
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
callback_data="menu_subscription",
)],
[InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")],
])
@@ -1148,8 +1265,14 @@ class MonitoringService:
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), callback_data="subscription_extend")],
[InlineKeyboardButton(text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), callback_data="balance_topup")],
[build_miniapp_or_callback_button(
text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"),
callback_data="subscription_extend",
)],
[build_miniapp_or_callback_button(
text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"),
callback_data="balance_topup",
)],
[InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")],
])
@@ -1186,7 +1309,6 @@ class MonitoringService:
expires_at: datetime,
offer_id: int,
wave: str,
bonus_amount: int,
trigger_days: int = None,
) -> bool:
try:
@@ -1197,8 +1319,8 @@ class MonitoringService:
"SUBSCRIPTION_EXPIRED_SECOND_WAVE",
(
"🔥 <b>Скидка {percent}% на продление</b>\n\n"
"Нажмите «Получить скидку», и мы начислим {bonus} на баланс. "
"Предложение действует до {expires_at}."
"Активируйте предложение, чтобы получить дополнительную скидку. "
"Она суммируется с вашей промогруппой и действует до {expires_at}."
),
)
else:
@@ -1206,14 +1328,13 @@ class MonitoringService:
"SUBSCRIPTION_EXPIRED_THIRD_WAVE",
(
"🎁 <b>Индивидуальная скидка {percent}%</b>\n\n"
"Прошло {trigger_days} дней без подписки — возвращайтесь, и мы добавим {bonus} на баланс. "
"Скидка действует до {expires_at}."
"Прошло {trigger_days} дней без подписки — возвращайтесь и активируйте дополнительную скидку. "
"Она суммируется с промогруппой и действует до {expires_at}."
),
)
message = template.format(
percent=percent,
bonus=settings.format_price(bonus_amount),
expires_at=expires_at.strftime("%d.%m.%Y %H:%M"),
trigger_days=trigger_days or "",
)
@@ -1221,9 +1342,15 @@ class MonitoringService:
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🎁 Получить скидку", callback_data=f"claim_discount_{offer_id}")],
[InlineKeyboardButton(text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), callback_data="subscription_extend")],
[InlineKeyboardButton(text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), callback_data="balance_topup")],
[build_miniapp_or_callback_button(text="🎁 Получить скидку", callback_data=f"claim_discount_{offer_id}")],
[build_miniapp_or_callback_button(
text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"),
callback_data="subscription_extend",
)],
[build_miniapp_or_callback_button(
text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"),
callback_data="balance_topup",
)],
[InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")],
])
@@ -1286,11 +1413,11 @@ class MonitoringService:
required=settings.format_price(required)
)
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.types import InlineKeyboardMarkup
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")],
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")]
[build_miniapp_or_callback_button(text="💳 Пополнить баланс", callback_data="balance_topup")],
[build_miniapp_or_callback_button(text="📱 Моя подписка", callback_data="menu_subscription")],
])
await self._send_message_with_logo(
+25
View File
@@ -0,0 +1,25 @@
"""Пакет с mixin-классами, делающими платёжный сервис модульным.
Здесь собираем все вспомогательные части, чтобы основной `PaymentService`
оставался компактным и импортировал только нужные компоненты.
"""
from .common import PaymentCommonMixin
from .stars import TelegramStarsMixin
from .yookassa import YooKassaPaymentMixin
from .tribute import TributePaymentMixin
from .cryptobot import CryptoBotPaymentMixin
from .mulenpay import MulenPayPaymentMixin
from .pal24 import Pal24PaymentMixin
from .wata import WataPaymentMixin
__all__ = [
"PaymentCommonMixin",
"TelegramStarsMixin",
"YooKassaPaymentMixin",
"TributePaymentMixin",
"CryptoBotPaymentMixin",
"MulenPayPaymentMixin",
"Pal24PaymentMixin",
"WataPaymentMixin",
]
+211
View File
@@ -0,0 +1,211 @@
"""Общие инструменты платёжного сервиса.
В этом модуле собраны методы, которые нужны всем платёжным каналам:
построение клавиатур, базовые уведомления и стандартная обработка
успешных платежей.
"""
from __future__ import annotations
import logging
from types import SimpleNamespace
from typing import Any
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.exc import MissingGreenlet
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.user import get_user_by_telegram_id
from app.database.database import get_db
from app.localization.texts import get_texts
from app.services.subscription_checkout_service import (
has_subscription_checkout_draft,
should_offer_checkout_resume,
)
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
logger = logging.getLogger(__name__)
class PaymentCommonMixin:
"""Mixin с базовой логикой, которую используют остальные платёжные блоки."""
async def build_topup_success_keyboard(self, user: Any) -> InlineKeyboardMarkup:
"""Формирует клавиатуру по завершении платежа, подстраиваясь под пользователя."""
# Загружаем нужные тексты с учётом выбранного языка пользователя.
texts = get_texts(user.language if user else "ru")
# Определяем статус подписки, чтобы показать подходящую кнопку.
has_active_subscription = bool(
user and user.subscription and not user.subscription.is_trial and user.subscription.is_active
)
first_button = build_miniapp_or_callback_button(
text=(
texts.MENU_EXTEND_SUBSCRIPTION
if has_active_subscription
else texts.MENU_BUY_SUBSCRIPTION
),
callback_data=(
"subscription_extend" if has_active_subscription else "menu_buy"
),
)
keyboard_rows: list[list[InlineKeyboardButton]] = [[first_button]]
# Если для пользователя есть незавершённый checkout, предлагаем вернуться к нему.
if user:
draft_exists = await has_subscription_checkout_draft(user.id)
if should_offer_checkout_resume(user, draft_exists):
keyboard_rows.append([
build_miniapp_or_callback_button(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout",
)
])
# Стандартные кнопки быстрого доступа к балансу и главному меню.
keyboard_rows.append([
build_miniapp_or_callback_button(
text="💰 Мой баланс",
callback_data="menu_balance",
)
])
keyboard_rows.append([
InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu",
)
])
return InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
async def _send_payment_success_notification(
self,
telegram_id: int,
amount_kopeks: int,
user: Any | None = None,
*,
db: AsyncSession | None = None,
payment_method_title: str | None = None,
) -> None:
"""Отправляет пользователю уведомление об успешном платеже."""
if not getattr(self, "bot", None):
# Если бот не передан (например, внутри фоновых задач), уведомление пропускаем.
return
user_snapshot = await self._ensure_user_snapshot(
telegram_id,
user,
db=db,
)
try:
keyboard = await self.build_topup_success_keyboard(user_snapshot)
payment_method = payment_method_title or "Банковская карта (YooKassa)"
message = (
"✅ <b>Платеж успешно завершен!</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"💳 Способ: {payment_method}\n\n"
"Средства зачислены на ваш баланс!"
)
await self.bot.send_message(
chat_id=telegram_id,
text=message,
parse_mode="HTML",
reply_markup=keyboard,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления пользователю %s: %s",
telegram_id,
error,
)
async def _ensure_user_snapshot(
self,
telegram_id: int,
user: Any | None,
*,
db: AsyncSession | None = None,
) -> Any | None:
"""Гарантирует, что данные пользователя пригодны для построения клавиатуры."""
def _build_snapshot(source: Any | None) -> SimpleNamespace | None:
if source is None:
return None
subscription = getattr(source, "subscription", None)
subscription_snapshot = None
if subscription is not None:
subscription_snapshot = SimpleNamespace(
is_trial=getattr(subscription, "is_trial", False),
is_active=getattr(subscription, "is_active", False),
actual_status=getattr(subscription, "actual_status", None),
)
return SimpleNamespace(
id=getattr(source, "id", None),
telegram_id=getattr(source, "telegram_id", None),
language=getattr(source, "language", "ru"),
subscription=subscription_snapshot,
)
try:
snapshot = _build_snapshot(user)
except MissingGreenlet:
snapshot = None
if snapshot is not None:
return snapshot
fetch_session = db
if fetch_session is not None:
try:
fetched_user = await get_user_by_telegram_id(fetch_session, telegram_id)
return _build_snapshot(fetched_user)
except Exception as fetch_error:
logger.warning(
"Не удалось обновить пользователя %s из переданной сессии: %s",
telegram_id,
fetch_error,
)
try:
async for db_session in get_db():
fetched_user = await get_user_by_telegram_id(db_session, telegram_id)
return _build_snapshot(fetched_user)
except Exception as fetch_error:
logger.warning(
"Не удалось получить пользователя %s для уведомления: %s",
telegram_id,
fetch_error,
)
return None
async def process_successful_payment(
self,
payment_id: str,
amount_kopeks: int,
user_id: int,
payment_method: str,
) -> bool:
"""Общая точка учёта успешных платежей (используется провайдерами при необходимости)."""
try:
logger.info(
"Обработан успешный платеж: %s, %s₽, пользователь %s, метод %s",
payment_id,
amount_kopeks / 100,
user_id,
payment_method,
)
return True
except Exception as error:
logger.error("Ошибка обработки платежа %s: %s", payment_id, error)
return False
+339
View File
@@ -0,0 +1,339 @@
"""Mixin с логикой обработки платежей CryptoBot."""
from __future__ import annotations
import logging
from datetime import datetime
from importlib import import_module
from typing import Any, Dict, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.utils.currency_converter import currency_converter
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
class CryptoBotPaymentMixin:
"""Mixin, отвечающий за генерацию инвойсов CryptoBot и обработку webhook."""
async def create_cryptobot_payment(
self,
db: AsyncSession,
user_id: int,
amount_usd: float,
asset: str = "USDT",
description: str = "Пополнение баланса",
payload: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Создаёт invoice в CryptoBot и сохраняет локальную запись."""
if not getattr(self, "cryptobot_service", None):
logger.error("CryptoBot сервис не инициализирован")
return None
try:
amount_str = f"{amount_usd:.2f}"
invoice_data = await self.cryptobot_service.create_invoice(
amount=amount_str,
asset=asset,
description=description,
payload=payload or f"balance_topup_{user_id}_{int(amount_usd * 100)}",
expires_in=settings.get_cryptobot_invoice_expires_seconds(),
)
if not invoice_data:
logger.error("Ошибка создания CryptoBot invoice")
return None
cryptobot_crud = import_module("app.database.crud.cryptobot")
local_payment = await cryptobot_crud.create_cryptobot_payment(
db=db,
user_id=user_id,
invoice_id=str(invoice_data["invoice_id"]),
amount=amount_str,
asset=asset,
status="active",
description=description,
payload=payload,
bot_invoice_url=invoice_data.get("bot_invoice_url"),
mini_app_invoice_url=invoice_data.get("mini_app_invoice_url"),
web_app_invoice_url=invoice_data.get("web_app_invoice_url"),
)
logger.info(
"Создан CryptoBot платеж %s на %s %s для пользователя %s",
invoice_data["invoice_id"],
amount_str,
asset,
user_id,
)
return {
"local_payment_id": local_payment.id,
"invoice_id": str(invoice_data["invoice_id"]),
"amount": amount_str,
"asset": asset,
"bot_invoice_url": invoice_data.get("bot_invoice_url"),
"mini_app_invoice_url": invoice_data.get("mini_app_invoice_url"),
"web_app_invoice_url": invoice_data.get("web_app_invoice_url"),
"status": "active",
"created_at": (
local_payment.created_at.isoformat()
if local_payment.created_at
else None
),
}
except Exception as error:
logger.error("Ошибка создания CryptoBot платежа: %s", error)
return None
async def process_cryptobot_webhook(
self,
db: AsyncSession,
webhook_data: Dict[str, Any],
) -> bool:
"""Обрабатывает webhook от CryptoBot и начисляет средства пользователю."""
try:
update_type = webhook_data.get("update_type")
if update_type != "invoice_paid":
logger.info("Пропуск CryptoBot webhook с типом: %s", update_type)
return True
payload = webhook_data.get("payload", {})
invoice_id = str(payload.get("invoice_id"))
status = "paid"
if not invoice_id:
logger.error("CryptoBot webhook без invoice_id")
return False
cryptobot_crud = import_module("app.database.crud.cryptobot")
payment = await cryptobot_crud.get_cryptobot_payment_by_invoice_id(
db, invoice_id
)
if not payment:
logger.error("CryptoBot платеж не найден в БД: %s", invoice_id)
return False
if payment.status == "paid":
logger.info("CryptoBot платеж %s уже обработан", invoice_id)
return True
paid_at_str = payload.get("paid_at")
if paid_at_str:
try:
paid_at = datetime.fromisoformat(
paid_at_str.replace("Z", "+00:00")
).replace(tzinfo=None)
except Exception:
paid_at = datetime.utcnow()
else:
paid_at = datetime.utcnow()
updated_payment = await cryptobot_crud.update_cryptobot_payment_status(
db, invoice_id, status, paid_at
)
if not updated_payment.transaction_id:
amount_usd = updated_payment.amount_float
try:
amount_rubles = await currency_converter.usd_to_rub(amount_usd)
amount_kopeks = int(amount_rubles * 100)
conversion_rate = (
amount_rubles / amount_usd if amount_usd > 0 else 0
)
logger.info(
"Конвертация USD->RUB: $%s -> %s₽ (курс: %.2f)",
amount_usd,
amount_rubles,
conversion_rate,
)
except Exception as error:
logger.warning(
"Ошибка конвертации валют для платежа %s, используем курс 1:1: %s",
invoice_id,
error,
)
amount_rubles = amount_usd
amount_kopeks = int(amount_usd * 100)
conversion_rate = 1.0
if amount_kopeks <= 0:
logger.error(
"Некорректная сумма после конвертации: %s копеек для платежа %s",
amount_kopeks,
invoice_id,
)
return False
payment_service_module = import_module("app.services.payment_service")
transaction = await payment_service_module.create_transaction(
db,
user_id=updated_payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=(
"Пополнение через CryptoBot "
f"({updated_payment.amount} {updated_payment.asset}{amount_rubles:.2f}₽)"
),
payment_method=PaymentMethod.CRYPTOBOT,
external_id=invoice_id,
is_completed=True,
)
await cryptobot_crud.link_cryptobot_payment_to_transaction(
db, invoice_id, transaction.id
)
get_user_by_id = payment_service_module.get_user_by_id
user = await get_user_by_id(db, updated_payment.user_id)
if not user:
logger.error(
"Пользователь с ID %s не найден при пополнении баланса",
updated_payment.user_id,
)
return False
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = getattr(user, "promo_group", None)
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = (
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
)
await db.commit()
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
amount_kopeks,
getattr(self, "bot", None),
)
except Exception as error:
logger.error(
"Ошибка обработки реферального пополнения CryptoBot: %s",
error,
)
if was_first_topup and not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import (
AdminNotificationService,
)
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о пополнении CryptoBot: %s",
error,
)
if getattr(self, "bot", None):
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"🪙 Платеж: {updated_payment.amount} {updated_payment.asset}\n"
f"💱 Курс: 1 USD = {conversion_rate:.2f}\n"
f"🆔 Транзакция: {invoice_id[:8]}...\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
reply_markup=keyboard,
)
logger.info(
"✅ Отправлено уведомление пользователю %s о пополнении на %s₽ (%s)",
user.telegram_id,
f"{amount_rubles:.2f}",
updated_payment.asset,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о пополнении CryptoBot: %s",
error,
)
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
try:
from app.services.user_cart_service import user_cart_service
from aiogram import types
has_saved_cart = await user_cart_service.has_user_cart(user.id)
if has_saved_cart and getattr(self, "bot", None):
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
from app.localization.texts import get_texts
texts = get_texts(user.language)
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
total_amount=settings.format_price(payment.amount_kopeks)
)
# Создаем клавиатуру с кнопками
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout"
)],
[types.InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance"
)],
[types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu"
)]
])
await self.bot.send_message(
chat_id=user.telegram_id,
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
reply_markup=keyboard
)
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
except Exception as e:
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
return True
except Exception as error:
logger.error(
"Ошибка обработки CryptoBot webhook: %s", error, exc_info=True
)
return False
+442
View File
@@ -0,0 +1,442 @@
"""Mixin, инкапсулирующий работу с MulenPay."""
from __future__ import annotations
import logging
import uuid
from importlib import import_module
from typing import Any, Dict, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
class MulenPayPaymentMixin:
"""Mixin с созданием платежей, обработкой callback и проверкой статусов MulenPay."""
async def create_mulenpay_payment(
self,
db: AsyncSession,
user_id: int,
amount_kopeks: int,
description: str,
language: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Создаёт локальный платеж и инициализирует сессию в MulenPay."""
if not getattr(self, "mulenpay_service", None):
logger.error("MulenPay сервис не инициализирован")
return None
if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
logger.warning(
"Сумма MulenPay меньше минимальной: %s < %s",
amount_kopeks,
settings.MULENPAY_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS:
logger.warning(
"Сумма MulenPay больше максимальной: %s > %s",
amount_kopeks,
settings.MULENPAY_MAX_AMOUNT_KOPEKS,
)
return None
payment_module = import_module("app.services.payment_service")
try:
payment_uuid = f"mulen_{user_id}_{uuid.uuid4().hex}"
amount_rubles = amount_kopeks / 100
items = [
{
"description": description[:128],
"quantity": 1,
"price": round(amount_rubles, 2),
"vat_code": settings.MULENPAY_VAT_CODE,
"payment_subject": settings.MULENPAY_PAYMENT_SUBJECT,
"payment_mode": settings.MULENPAY_PAYMENT_MODE,
}
]
response = await self.mulenpay_service.create_payment(
amount_kopeks=amount_kopeks,
description=description,
uuid=payment_uuid,
items=items,
language=language or settings.MULENPAY_LANGUAGE,
website_url=settings.WEBHOOK_URL,
)
if not response:
logger.error("Ошибка создания MulenPay платежа")
return None
mulen_payment_id = response.get("id")
payment_url = response.get("paymentUrl")
metadata = {
"user_id": user_id,
"amount_kopeks": amount_kopeks,
"description": description,
}
local_payment = await payment_module.create_mulenpay_payment(
db=db,
user_id=user_id,
amount_kopeks=amount_kopeks,
uuid=payment_uuid,
description=description,
payment_url=payment_url,
mulen_payment_id=mulen_payment_id,
currency="RUB",
status="created",
metadata=metadata,
)
logger.info(
"Создан MulenPay платеж %s на %s₽ для пользователя %s",
mulen_payment_id,
amount_rubles,
user_id,
)
return {
"local_payment_id": local_payment.id,
"mulen_payment_id": mulen_payment_id,
"payment_url": payment_url,
"amount_kopeks": amount_kopeks,
"uuid": payment_uuid,
"status": "created",
}
except Exception as error:
logger.error("Ошибка создания MulenPay платежа: %s", error)
return None
async def process_mulenpay_callback(
self,
db: AsyncSession,
callback_data: Dict[str, Any],
) -> bool:
"""Обрабатывает callback от MulenPay, обновляет статус и начисляет баланс."""
try:
payment_module = import_module("app.services.payment_service")
uuid_value = callback_data.get("uuid")
payment_status = (callback_data.get("payment_status") or "").lower()
mulen_payment_id_raw = callback_data.get("id")
mulen_payment_id_int: Optional[int] = None
if mulen_payment_id_raw is not None:
try:
mulen_payment_id_int = int(mulen_payment_id_raw)
except (TypeError, ValueError):
mulen_payment_id_int = None
amount_value = callback_data.get("amount")
logger.debug(
"MulenPay callback: uuid=%s, status=%s, amount=%s",
uuid_value,
payment_status,
amount_value,
)
if not uuid_value and mulen_payment_id_raw is None:
logger.error("MulenPay callback без uuid и id")
return False
payment = None
if uuid_value:
payment = await payment_module.get_mulenpay_payment_by_uuid(db, uuid_value)
if not payment and mulen_payment_id_int is not None:
payment = await payment_module.get_mulenpay_payment_by_mulen_id(
db, mulen_payment_id_int
)
if not payment:
logger.error(
"MulenPay платеж не найден (uuid=%s, id=%s)",
uuid_value,
mulen_payment_id_raw,
)
return False
if payment.is_paid:
logger.info(
"MulenPay платеж %s уже обработан, игнорируем повторный callback",
payment.uuid,
)
return True
if payment_status == "success":
await payment_module.update_mulenpay_payment_status(
db,
payment=payment,
status="success",
callback_payload=callback_data,
mulen_payment_id=mulen_payment_id_int,
)
if payment.transaction_id:
logger.info(
"Для MulenPay платежа %s уже создана транзакция",
payment.uuid,
)
return True
payment_description = getattr(
payment,
"description",
f"платеж {payment.uuid}",
)
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=f"Пополнение через MulenPay: {payment_description}",
payment_method=PaymentMethod.MULENPAY,
external_id=payment.uuid,
is_completed=True,
)
await payment_module.link_mulenpay_payment_to_transaction(
db=db,
payment=payment,
transaction_id=transaction.id,
)
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error(
"Пользователь %s не найден при обработке MulenPay",
payment.user_id,
)
return False
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
await payment_module.add_user_balance(
db,
user,
payment.amount_kopeks,
f"Пополнение MulenPay: {payment.amount_kopeks // 100}",
)
if was_first_topup and not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
promo_group = getattr(user, "promo_group", None)
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = (
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
)
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import (
AdminNotificationService,
)
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о пополнении MulenPay: %s",
error,
)
if getattr(self, "bot", None):
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
"🦊 Способ: Mulen Pay\n"
f"🆔 Транзакция: {transaction.id}\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
reply_markup=keyboard,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления пользователю MulenPay: %s",
error,
)
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
try:
from app.services.user_cart_service import user_cart_service
from aiogram import types
has_saved_cart = await user_cart_service.has_user_cart(user.id)
if has_saved_cart and getattr(self, "bot", None):
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
from app.localization.texts import get_texts
texts = get_texts(user.language)
cart_message = texts.t(
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
"🛒 У вас есть неоформленный заказ.\n\n"
"Вы можете продолжить оформление с теми же параметрами."
)
# Создаем клавиатуру с кнопками
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout"
)],
[types.InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance"
)],
[types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu"
)]
])
await self.bot.send_message(
chat_id=user.telegram_id,
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
reply_markup=keyboard
)
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
except Exception as e:
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
logger.info(
"✅ Обработан MulenPay платеж %s для пользователя %s",
payment.uuid,
payment.user_id,
)
return True
if payment_status == "cancel":
await payment_module.update_mulenpay_payment_status(
db,
payment=payment,
status="canceled",
callback_payload=callback_data,
mulen_payment_id=mulen_payment_id_int,
)
logger.info("MulenPay платеж %s отменен", payment.uuid)
return True
await payment_module.update_mulenpay_payment_status(
db,
payment=payment,
status=payment_status or "unknown",
callback_payload=callback_data,
mulen_payment_id=mulen_payment_id_int,
)
logger.info(
"Получен MulenPay callback со статусом %s для платежа %s",
payment_status,
payment.uuid,
)
return True
except Exception as error:
logger.error("Ошибка обработки MulenPay callback: %s", error, exc_info=True)
return False
def _map_mulenpay_status(self, status_code: Optional[int]) -> str:
"""Приводит числовой статус MulenPay к строковому значению."""
mapping = {
0: "created",
1: "processing",
2: "canceled",
3: "success",
4: "error",
5: "hold",
6: "hold",
}
return mapping.get(status_code, "unknown")
async def get_mulenpay_payment_status(
self,
db: AsyncSession,
local_payment_id: int,
) -> Optional[Dict[str, Any]]:
"""Возвращает текущее состояние платежа и при необходимости синхронизирует его."""
try:
payment_module = import_module("app.services.payment_service")
payment = await payment_module.get_mulenpay_payment_by_local_id(db, local_payment_id)
if not payment:
return None
remote_status_code = None
remote_data = None
if getattr(self, "mulenpay_service", None) and payment.mulen_payment_id is not None:
response = await self.mulenpay_service.get_payment(
payment.mulen_payment_id
)
if response and response.get("success"):
remote_data = response.get("payment")
if isinstance(remote_data, dict):
remote_status_code = remote_data.get("status")
mapped_status = self._map_mulenpay_status(remote_status_code)
if mapped_status == "success" and not payment.is_paid:
await self.process_mulenpay_callback(
db,
{
"uuid": payment.uuid,
"payment_status": "success",
"id": remote_data.get("id"),
"amount": remote_data.get("amount"),
},
)
payment = await payment_module.get_mulenpay_payment_by_local_id(
db, local_payment_id
)
elif mapped_status and mapped_status != payment.status:
await payment_module.update_mulenpay_payment_status(
db,
payment=payment,
status=mapped_status,
mulen_payment_id=remote_data.get("id"),
)
payment = await payment_module.get_mulenpay_payment_by_local_id(
db, local_payment_id
)
return {
"payment": payment,
"status": payment.status,
"is_paid": payment.is_paid,
"remote_status_code": remote_status_code,
"remote_data": remote_data,
}
except Exception as error:
logger.error(
"Ошибка получения статуса MulenPay: %s", error, exc_info=True
)
return None
+473
View File
@@ -0,0 +1,473 @@
"""Mixin для интеграции с PayPalych (Pal24)."""
from __future__ import annotations
import logging
from datetime import datetime
from importlib import import_module
import uuid
from typing import Any, Dict, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.pal24_service import Pal24APIError
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
class Pal24PaymentMixin:
"""Mixin с созданием счетов Pal24, обработкой postback и запросом статуса."""
async def create_pal24_payment(
self,
db: AsyncSession,
*,
user_id: int,
amount_kopeks: int,
description: str,
language: str,
ttl_seconds: Optional[int] = None,
payer_email: Optional[str] = None,
payment_method: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Создаёт счёт в Pal24 и сохраняет локальную запись."""
service = getattr(self, "pal24_service", None)
if not service or not service.is_configured:
logger.error("Pal24 сервис не инициализирован")
return None
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
logger.warning(
"Сумма Pal24 меньше минимальной: %s < %s",
amount_kopeks,
settings.PAL24_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
logger.warning(
"Сумма Pal24 больше максимальной: %s > %s",
amount_kopeks,
settings.PAL24_MAX_AMOUNT_KOPEKS,
)
return None
order_id = f"pal24_{user_id}_{uuid.uuid4().hex}"
custom_payload = {
"user_id": user_id,
"amount_kopeks": amount_kopeks,
"language": language,
}
normalized_payment_method = (payment_method or "SBP").upper()
payment_module = import_module("app.services.payment_service")
try:
response = await service.create_bill(
amount_kopeks=amount_kopeks,
user_id=user_id,
order_id=order_id,
description=description,
ttl_seconds=ttl_seconds,
custom_payload=custom_payload,
payer_email=payer_email,
payment_method=normalized_payment_method,
)
except Pal24APIError as error:
logger.error("Ошибка Pal24 API при создании счета: %s", error)
return None
if not response.get("success", True):
logger.error("Pal24 вернул ошибку при создании счета: %s", response)
return None
bill_id = response.get("bill_id")
if not bill_id:
logger.error("Pal24 не вернул bill_id: %s", response)
return None
def _pick_url(*keys: str) -> Optional[str]:
for key in keys:
value = response.get(key)
if value:
return str(value)
return None
transfer_url = _pick_url(
"transfer_url",
"transferUrl",
"transfer_link",
"transferLink",
"transfer",
"sbp_url",
"sbpUrl",
"sbp_link",
"sbpLink",
)
card_url = _pick_url(
"link_url",
"linkUrl",
"link",
"card_url",
"cardUrl",
"card_link",
"cardLink",
"payment_url",
"paymentUrl",
"url",
)
link_page_url = _pick_url(
"link_page_url",
"linkPageUrl",
"page_url",
"pageUrl",
)
primary_link = transfer_url or link_page_url or card_url
secondary_link = link_page_url or card_url or transfer_url
metadata_links = {
key: value
for key, value in {
"sbp": transfer_url,
"card": card_url,
"page": link_page_url,
}.items()
if value
}
metadata_payload = {
"user_id": user_id,
"amount_kopeks": amount_kopeks,
"description": description,
"links": metadata_links,
"raw_response": response,
}
payment = await payment_module.create_pal24_payment(
db,
user_id=user_id,
bill_id=bill_id,
amount_kopeks=amount_kopeks,
description=description,
status=response.get("status", "NEW"),
type_=response.get("type", "normal"),
currency=response.get("currency", "RUB"),
link_url=transfer_url or card_url,
link_page_url=link_page_url or primary_link,
order_id=order_id,
ttl=ttl_seconds,
metadata=metadata_payload,
)
logger.info(
"Создан Pal24 счет %s для пользователя %s (%s₽)",
bill_id,
user_id,
amount_kopeks / 100,
)
payment_status = getattr(payment, "status", response.get("status", "NEW"))
return {
"local_payment_id": payment.id,
"bill_id": bill_id,
"order_id": order_id,
"amount_kopeks": amount_kopeks,
"primary_url": primary_link,
"secondary_url": secondary_link,
"link_url": transfer_url,
"card_url": card_url,
"payment_method": normalized_payment_method,
"metadata_links": metadata_links,
"status": payment_status,
}
async def process_pal24_postback(
self,
db: AsyncSession,
postback: Dict[str, Any],
) -> bool:
"""Обрабатывает postback от Pal24 и начисляет баланс при успехе."""
try:
payment_module = import_module("app.services.payment_service")
def _first_non_empty(*values: Optional[str]) -> Optional[str]:
for value in values:
if value:
return value
return None
payment_id = _first_non_empty(
postback.get("id"),
postback.get("TrsId"),
postback.get("TrsID"),
)
bill_id = _first_non_empty(
postback.get("bill_id"),
postback.get("billId"),
postback.get("BillId"),
postback.get("BillID"),
)
order_id = _first_non_empty(
postback.get("order_id"),
postback.get("orderId"),
postback.get("InvId"),
postback.get("InvID"),
)
status = (postback.get("status") or postback.get("Status") or "").upper()
if not bill_id and not order_id:
logger.error("Pal24 postback без идентификаторов: %s", postback)
return False
payment = None
if bill_id:
payment = await payment_module.get_pal24_payment_by_bill_id(db, bill_id)
if not payment and order_id:
payment = await payment_module.get_pal24_payment_by_order_id(db, order_id)
if not payment:
logger.error("Pal24 платеж не найден: %s / %s", bill_id, order_id)
return False
if payment.is_paid:
logger.info("Pal24 платеж %s уже обработан", payment.bill_id)
return True
if status in {"PAID", "SUCCESS"}:
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error(
"Пользователь %s не найден для Pal24 платежа",
payment.user_id,
)
return False
await payment_module.update_pal24_payment_status(
db,
payment,
status=status,
postback_payload=postback,
payment_id=payment_id,
)
if payment.transaction_id:
logger.info(
"Для Pal24 платежа %s уже создана транзакция",
payment.bill_id,
)
return True
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=f"Пополнение через Pal24 ({payment_id})",
payment_method=PaymentMethod.PAL24,
external_id=str(payment_id) if payment_id else payment.bill_id,
is_completed=True,
)
await payment_module.link_pal24_payment_to_transaction(db, payment, transaction.id)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = getattr(user, "promo_group", None)
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = (
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
)
await db.commit()
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db, user.id, payment.amount_kopeks, getattr(self, "bot", None)
)
except Exception as error:
logger.error(
"Ошибка обработки реферального пополнения Pal24: %s",
error,
)
if was_first_topup and not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import (
AdminNotificationService,
)
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error(
"Ошибка отправки админ уведомления Pal24: %s", error
)
if getattr(self, "bot", None):
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
"🦊 Способ: PayPalych\n"
f"🆔 Транзакция: {transaction.id}\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
reply_markup=keyboard,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления пользователю Pal24: %s",
error,
)
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
try:
from app.services.user_cart_service import user_cart_service
from aiogram import types
has_saved_cart = await user_cart_service.has_user_cart(user.id)
if has_saved_cart and getattr(self, "bot", None):
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
from app.localization.texts import get_texts
texts = get_texts(user.language)
cart_message = texts.t(
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
"🛒 У вас есть неоформленный заказ.\n\n"
"Вы можете продолжить оформление с теми же параметрами."
)
# Создаем клавиатуру с кнопками
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout"
)],
[types.InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance"
)],
[types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu"
)]
])
await self.bot.send_message(
chat_id=user.telegram_id,
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
reply_markup=keyboard
)
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
except Exception as e:
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
logger.info(
"✅ Обработан Pal24 платеж %s для пользователя %s",
payment.bill_id,
payment.user_id,
)
return True
await payment_module.update_pal24_payment_status(
db,
payment,
status=status or "UNKNOWN",
postback_payload=postback,
payment_id=payment_id,
)
logger.info(
"Обновили Pal24 платеж %s до статуса %s",
payment.bill_id,
status,
)
return True
except Exception as error:
logger.error("Ошибка обработки Pal24 postback: %s", error, exc_info=True)
return False
async def get_pal24_payment_status(
self,
db: AsyncSession,
local_payment_id: int,
) -> Optional[Dict[str, Any]]:
"""Запрашивает актуальный статус платежа у Pal24 и синхронизирует локальную запись."""
try:
payment_module = import_module("app.services.payment_service")
payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id)
if not payment:
return None
remote_status = None
remote_data = None
service = getattr(self, "pal24_service", None)
if service and payment.bill_id:
try:
response = await service.get_bill_status(payment.bill_id)
remote_data = response
remote_status = response.get("status") or response.get(
"bill", {}
).get("status")
if remote_status and remote_status != payment.status:
await payment_module.update_pal24_payment_status(
db,
payment,
status=str(remote_status).upper(),
)
payment = await payment_module.get_pal24_payment_by_id(
db, local_payment_id
)
except Pal24APIError as error:
logger.error(
"Ошибка Pal24 API при получении статуса: %s", error
)
return {
"payment": payment,
"status": payment.status,
"is_paid": payment.is_paid,
"remote_status": remote_status,
"remote_data": remote_data,
}
except Exception as error:
logger.error("Ошибка получения статуса Pal24: %s", error, exc_info=True)
return None
+292
View File
@@ -0,0 +1,292 @@
"""Логика Telegram Stars вынесена в отдельный mixin.
Методы здесь отвечают только за работу с звёздами, что позволяет держать
основной сервис компактным и облегчает тестирование конкретных сценариев.
"""
from __future__ import annotations
import logging
from datetime import datetime
from decimal import Decimal, ROUND_FLOOR, ROUND_HALF_UP
from typing import Optional
from aiogram.types import LabeledPrice
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.transaction import create_transaction
from app.database.crud.user import get_user_by_id
from app.database.models import PaymentMethod, TransactionType
from app.external.telegram_stars import TelegramStarsService
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
class TelegramStarsMixin:
"""Mixin с операциями создания и обработки платежей через Telegram Stars."""
async def create_stars_invoice(
self,
amount_kopeks: int,
description: str,
payload: Optional[str] = None,
*,
stars_amount: Optional[int] = None,
) -> str:
"""Создаёт invoice в Telegram Stars, автоматически рассчитывая количество звёзд."""
if not self.bot or not getattr(self, "stars_service", None):
raise ValueError("Bot instance required for Stars payments")
try:
amount_rubles = Decimal(amount_kopeks) / Decimal(100)
# Если количество звёзд не задано, вычисляем его из курса.
if stars_amount is None:
rate = Decimal(str(settings.get_stars_rate()))
if rate <= 0:
raise ValueError("Stars rate must be positive")
normalized_stars = (amount_rubles / rate).to_integral_value(
rounding=ROUND_FLOOR
)
stars_amount = int(normalized_stars) or 1
if stars_amount <= 0:
raise ValueError("Stars amount must be positive")
invoice_link = await self.bot.create_invoice_link(
title="Пополнение баланса VPN",
description=f"{description} (≈{stars_amount} ⭐)",
payload=payload or f"balance_topup_{amount_kopeks}",
provider_token="",
currency="XTR",
prices=[LabeledPrice(label="Пополнение", amount=stars_amount)],
)
logger.info(
"Создан Stars invoice на %s звезд (~%s)",
stars_amount,
settings.format_price(amount_kopeks),
)
return invoice_link
except Exception as error:
logger.error("Ошибка создания Stars invoice: %s", error)
raise
async def process_stars_payment(
self,
db: AsyncSession,
user_id: int,
stars_amount: int,
payload: str,
telegram_payment_charge_id: str,
) -> bool:
"""Финализирует платеж, пришедший из Telegram Stars, и обновляет баланс пользователя."""
del payload # payload пока не используется, но оставляем аргумент для совместимости.
try:
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(
stars_amount
)
amount_kopeks = int(
(rubles_amount * Decimal(100)).to_integral_value(
rounding=ROUND_HALF_UP
)
)
transaction = await create_transaction(
db=db,
user_id=user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=f"Пополнение через Telegram Stars ({stars_amount} ⭐)",
payment_method=PaymentMethod.TELEGRAM_STARS,
external_id=telegram_payment_charge_id,
is_completed=True,
)
user = await get_user_by_id(db, user_id)
if not user:
logger.error(
"Пользователь с ID %s не найден при обработке Stars платежа",
user_id,
)
return False
# Запоминаем старые значения, чтобы корректно построить уведомления.
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
# Обновляем баланс в БД.
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = getattr(user, "promo_group", None)
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = (
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
)
await db.commit()
description_for_referral = (
f"Пополнение Stars: {settings.format_price(amount_kopeks)} ({stars_amount} ⭐)"
)
logger.info(
"🔍 Проверка реферальной логики для описания: '%s'",
description_for_referral,
)
lower_description = description_for_referral.lower()
contains_allowed_keywords = any(
word in lower_description
for word in ["пополнение", "stars", "yookassa", "topup"]
)
contains_forbidden_keywords = any(
word in lower_description for word in ["комиссия", "бонус"]
)
allow_referral = contains_allowed_keywords and not contains_forbidden_keywords
if allow_referral:
logger.info(
"🔞 Вызов process_referral_topup для пользователя %s",
user_id,
)
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db, user_id, amount_kopeks, getattr(self, "bot", None)
)
except Exception as error:
logger.error(
"Ошибка обработки реферального пополнения: %s", error
)
else:
logger.info(
"❌ Описание '%s' не подходит для реферальной логики",
description_for_referral,
)
if was_first_topup and not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
logger.info(
"💰 Баланс пользователя %s изменен: %s%s (Δ +%s)",
user.telegram_id,
old_balance,
user.balance_kopeks,
amount_kopeks,
)
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import (
AdminNotificationService,
)
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о пополнении Stars: %s",
error,
exc_info=True
)
if getattr(self, "bot", None):
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
"✅ <b>Пополнение успешно!</b>\n\n"
f"⭐ Звезд: {stars_amount}\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
"🦊 Способ: Telegram Stars\n"
f"🆔 Транзакция: {telegram_payment_charge_id[:8]}...\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
reply_markup=keyboard,
)
logger.info(
"✅ Отправлено уведомление пользователю %s о пополнении на %s",
user.telegram_id,
settings.format_price(amount_kopeks),
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о пополнении Stars: %s",
error,
)
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
try:
from app.services.user_cart_service import user_cart_service
from aiogram import types
has_saved_cart = await user_cart_service.has_user_cart(user.id)
if has_saved_cart and getattr(self, "bot", None):
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
from app.localization.texts import get_texts
texts = get_texts(user.language)
cart_message = texts.t(
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
"🛒 У вас есть неоформленный заказ.\n\n"
"Вы можете продолжить оформление с теми же параметрами."
)
# Создаем клавиатуру с кнопками
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout"
)],
[types.InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance"
)],
[types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu"
)]
])
await self.bot.send_message(
chat_id=user.telegram_id,
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n{cart_message}",
reply_markup=keyboard
)
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
except Exception as e:
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
logger.info(
"✅ Обработан Stars платеж: пользователь %s, %s звезд → %s",
user_id,
stars_amount,
settings.format_price(amount_kopeks),
)
return True
except Exception as error:
logger.error("Ошибка обработки Stars платежа: %s", error, exc_info=True)
return False
+71
View File
@@ -0,0 +1,71 @@
"""Mixin для платежей Tribute — простая вспомогательная обвязка."""
from __future__ import annotations
import hashlib
import hmac
import logging
from typing import Dict
from app.config import settings
logger = logging.getLogger(__name__)
class TributePaymentMixin:
"""Содержит методы создания платежей и проверки webhook от Tribute."""
async def create_tribute_payment(
self,
amount_kopeks: int,
user_id: int,
description: str,
) -> str:
"""Формирует URL оплаты для Tribute и логирует результат."""
if not settings.TRIBUTE_ENABLED:
raise ValueError("Tribute payments are disabled")
try:
# Сохраняем полезную информацию для метрик и отладки.
payment_data = {
"amount": amount_kopeks,
"currency": "RUB",
"description": description,
"user_id": user_id,
"callback_url": f"{settings.WEBHOOK_URL}/tribute/callback",
}
del payment_data # данные пока не отправляются вовне, но оставляем структуру для будущего API.
payment_url = (
f"https://tribute.ru/pay?amount={amount_kopeks}&user={user_id}"
)
logger.info(
"Создан Tribute платеж на %s₽ для пользователя %s",
amount_kopeks / 100,
user_id,
)
return payment_url
except Exception as error:
logger.error("Ошибка создания Tribute платежа: %s", error)
raise
def verify_tribute_webhook(self, data: Dict[str, object], signature: str) -> bool:
"""Проверяет подпись запроса, присланного Tribute."""
if not settings.TRIBUTE_API_KEY:
return False
try:
message = str(data).encode()
expected_signature = hmac.new(
settings.TRIBUTE_API_KEY.encode(),
message,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(signature, expected_signature)
except Exception as error:
logger.error("Ошибка проверки Tribute webhook: %s", error)
return False
+358
View File
@@ -0,0 +1,358 @@
"""Mixin for integrating WATA payment links into the payment service."""
from __future__ import annotations
import logging
import uuid
from datetime import datetime
from importlib import import_module
from typing import Any, Dict, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.wata_service import WataAPIError, WataService
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
class WataPaymentMixin:
"""Encapsulates creation and status handling for WATA payment links."""
async def create_wata_payment(
self,
db: AsyncSession,
user_id: int,
amount_kopeks: int,
description: str,
*,
language: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
if not getattr(self, "wata_service", None):
logger.error("WATA service is not initialised")
return None
if amount_kopeks < settings.WATA_MIN_AMOUNT_KOPEKS:
logger.warning(
"Сумма WATA меньше минимальной: %s < %s",
amount_kopeks,
settings.WATA_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.WATA_MAX_AMOUNT_KOPEKS:
logger.warning(
"Сумма WATA больше максимальной: %s > %s",
amount_kopeks,
settings.WATA_MAX_AMOUNT_KOPEKS,
)
return None
payment_module = import_module("app.services.payment_service")
order_id = f"wata_{user_id}_{uuid.uuid4().hex[:12]}"
try:
response = await self.wata_service.create_payment_link( # type: ignore[union-attr]
amount_kopeks=amount_kopeks,
currency="RUB",
description=description,
order_id=order_id,
)
except WataAPIError as error:
logger.error("Ошибка создания WATA платежа: %s", error)
return None
except Exception as error: # pragma: no cover - safety net
logger.exception("Непредвиденная ошибка при создании WATA платежа: %s", error)
return None
payment_link_id = response.get("id")
payment_url = response.get("url") or response.get("paymentUrl")
status = response.get("status") or "Opened"
terminal_public_id = response.get("terminalPublicId")
success_url = response.get("successRedirectUrl")
fail_url = response.get("failRedirectUrl")
if not payment_link_id:
logger.error("WATA API не вернула идентификатор платежной ссылки: %s", response)
return None
expiration_raw = response.get("expirationDateTime")
expires_at = WataService._parse_datetime(expiration_raw)
metadata = {
"response": response,
"language": language or settings.DEFAULT_LANGUAGE,
}
local_payment = await payment_module.create_wata_payment(
db=db,
user_id=user_id,
payment_link_id=payment_link_id,
amount_kopeks=amount_kopeks,
currency="RUB",
description=description,
status=status,
type_=response.get("type"),
url=payment_url,
order_id=order_id,
metadata=metadata,
expires_at=expires_at,
terminal_public_id=terminal_public_id,
success_redirect_url=success_url,
fail_redirect_url=fail_url,
)
logger.info(
"Создан WATA платеж %s на %s₽ для пользователя %s",
payment_link_id,
amount_kopeks / 100,
user_id,
)
return {
"local_payment_id": local_payment.id,
"payment_link_id": payment_link_id,
"payment_url": payment_url,
"status": status,
"order_id": order_id,
}
async def get_wata_payment_status(
self,
db: AsyncSession,
local_payment_id: int,
) -> Optional[Dict[str, Any]]:
payment_module = import_module("app.services.payment_service")
payment = await payment_module.get_wata_payment_by_id(db, local_payment_id)
if not payment:
return None
remote_link: Optional[Dict[str, Any]] = None
transaction_payload: Optional[Dict[str, Any]] = None
if getattr(self, "wata_service", None) and payment.payment_link_id:
try:
remote_link = await self.wata_service.get_payment_link(payment.payment_link_id) # type: ignore[union-attr]
except WataAPIError as error:
logger.error("Ошибка получения WATA ссылки %s: %s", payment.payment_link_id, error)
except Exception as error: # pragma: no cover - safety net
logger.exception("Непредвиденная ошибка при запросе WATA ссылки: %s", error)
if remote_link:
remote_status = remote_link.get("status") or payment.status
if remote_status != payment.status:
existing_metadata = dict(getattr(payment, "metadata_json", {}) or {})
existing_metadata["link"] = remote_link
await payment_module.update_wata_payment_status(
db,
payment=payment,
status=remote_status,
last_status=remote_status,
url=remote_link.get("url") or remote_link.get("paymentUrl"),
metadata=existing_metadata,
terminal_public_id=remote_link.get("terminalPublicId"),
)
payment = await payment_module.get_wata_payment_by_id(db, local_payment_id)
remote_status_normalized = (remote_status or "").lower()
if remote_status_normalized in {"closed", "paid"} and not payment.is_paid:
try:
tx_response = await self.wata_service.search_transactions( # type: ignore[union-attr]
order_id=payment.order_id,
payment_link_id=payment.payment_link_id,
status="Paid",
limit=5,
)
items = tx_response.get("items") or []
for item in items:
if (item or {}).get("status") == "Paid":
transaction_payload = item
break
except WataAPIError as error:
logger.error(
"Ошибка поиска WATA транзакций для %s: %s",
payment.payment_link_id,
error,
)
except Exception as error: # pragma: no cover - safety net
logger.exception("Непредвиденная ошибка при поиске WATA транзакции: %s", error)
if transaction_payload and not payment.is_paid:
payment = await self._finalize_wata_payment(db, payment, transaction_payload)
return {
"payment": payment,
"status": payment.status,
"is_paid": payment.is_paid,
"remote_link": remote_link,
"transaction": transaction_payload,
}
async def _finalize_wata_payment(
self,
db: AsyncSession,
payment: Any,
transaction_payload: Dict[str, Any],
) -> Any:
payment_module = import_module("app.services.payment_service")
paid_at = WataService._parse_datetime(transaction_payload.get("paymentTime"))
existing_metadata = dict(getattr(payment, "metadata_json", {}) or {})
existing_metadata["transaction"] = transaction_payload
await payment_module.update_wata_payment_status(
db,
payment=payment,
status="Paid",
is_paid=True,
paid_at=paid_at,
callback_payload=transaction_payload,
metadata=existing_metadata,
)
if payment.transaction_id:
logger.info(
"WATA платеж %s уже привязан к транзакции %s",
payment.payment_link_id,
payment.transaction_id,
)
return payment
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error("Пользователь %s не найден при обработке WATA", payment.user_id)
return payment
transaction_external_id = str(transaction_payload.get("id") or transaction_payload.get("transactionId") or "")
description = f"Пополнение через WATA ({payment.payment_link_id})"
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=description,
payment_method=PaymentMethod.WATA,
external_id=transaction_external_id or payment.payment_link_id,
is_completed=True,
)
await payment_module.link_wata_payment_to_transaction(db, payment, transaction.id)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
promo_group = getattr(user, "promo_group", None)
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
payment.amount_kopeks,
getattr(self, "bot", None),
)
except Exception as error:
logger.error("Ошибка обработки реферального пополнения WATA: %s", error)
if was_first_topup and not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error("Ошибка отправки админ уведомления WATA: %s", error)
if getattr(self, "bot", None):
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
"🦊 Способ: WATA\n"
f"🆔 Транзакция: {transaction.id}\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
reply_markup=keyboard,
)
except Exception as error:
logger.error("Ошибка отправки уведомления пользователю WATA: %s", error)
try:
from app.services.user_cart_service import user_cart_service
from aiogram import types
has_saved_cart = await user_cart_service.has_user_cart(user.id)
if has_saved_cart and getattr(self, "bot", None):
from app.localization.texts import get_texts
texts = get_texts(user.language)
cart_message = texts.t(
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
"🛒 У вас есть неоформленный заказ.\n\n"
"Вы можете продолжить оформление с теми же параметрами.",
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout",
)
],
[
types.InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance",
)
],
[
types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu",
)
],
]
)
await self.bot.send_message(
user.telegram_id,
cart_message,
reply_markup=keyboard,
)
except Exception as error:
logger.debug("Не удалось отправить напоминание о корзине после WATA: %s", error)
return payment
+425
View File
@@ -0,0 +1,425 @@
"""Функции работы с YooKassa вынесены в dedicated mixin.
Такое разделение облегчает поддержку и делает очевидным, какая часть
отвечает за конкретного провайдера.
"""
from __future__ import annotations
import logging
from datetime import datetime
from importlib import import_module
from typing import Any, Dict, Optional, TYPE_CHECKING
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from app.database.models import YooKassaPayment
class YooKassaPaymentMixin:
"""Mixin с операциями по созданию и подтверждению платежей YooKassa."""
async def create_yookassa_payment(
self,
db: AsyncSession,
user_id: int,
amount_kopeks: int,
description: str,
receipt_email: Optional[str] = None,
receipt_phone: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
"""Создаёт обычный платёж в YooKassa и сохраняет локальную запись."""
if not getattr(self, "yookassa_service", None):
logger.error("YooKassa сервис не инициализирован")
return None
payment_module = import_module("app.services.payment_service")
try:
amount_rubles = amount_kopeks / 100
payment_metadata = metadata.copy() if metadata else {}
payment_metadata.update(
{
"user_id": str(user_id),
"amount_kopeks": str(amount_kopeks),
"type": "balance_topup",
}
)
yookassa_response = await self.yookassa_service.create_payment(
amount=amount_rubles,
currency="RUB",
description=description,
metadata=payment_metadata,
receipt_email=receipt_email,
receipt_phone=receipt_phone,
)
if not yookassa_response or yookassa_response.get("error"):
logger.error(
"Ошибка создания платежа YooKassa: %s", yookassa_response
)
return None
yookassa_created_at: Optional[datetime] = None
if yookassa_response.get("created_at"):
try:
dt_with_tz = datetime.fromisoformat(
yookassa_response["created_at"].replace("Z", "+00:00")
)
yookassa_created_at = dt_with_tz.replace(tzinfo=None)
except Exception as error:
logger.warning("Не удалось распарсить created_at: %s", error)
yookassa_created_at = None
local_payment = await payment_module.create_yookassa_payment(
db=db,
user_id=user_id,
yookassa_payment_id=yookassa_response["id"],
amount_kopeks=amount_kopeks,
currency="RUB",
description=description,
status=yookassa_response["status"],
confirmation_url=yookassa_response.get("confirmation_url"),
metadata_json=payment_metadata,
payment_method_type=None,
yookassa_created_at=yookassa_created_at,
test_mode=yookassa_response.get("test_mode", False),
)
logger.info(
"Создан платеж YooKassa %s на %s₽ для пользователя %s",
yookassa_response["id"],
amount_rubles,
user_id,
)
return {
"local_payment_id": local_payment.id,
"yookassa_payment_id": yookassa_response["id"],
"confirmation_url": yookassa_response.get("confirmation_url"),
"amount_kopeks": amount_kopeks,
"amount_rubles": amount_rubles,
"status": yookassa_response["status"],
"created_at": local_payment.created_at,
}
except Exception as error:
logger.error("Ошибка создания платежа YooKassa: %s", error)
return None
async def create_yookassa_sbp_payment(
self,
db: AsyncSession,
user_id: int,
amount_kopeks: int,
description: str,
receipt_email: Optional[str] = None,
receipt_phone: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
"""Создаёт платёж по СБП через YooKassa."""
if not getattr(self, "yookassa_service", None):
logger.error("YooKassa сервис не инициализирован")
return None
payment_module = import_module("app.services.payment_service")
try:
amount_rubles = amount_kopeks / 100
payment_metadata = metadata.copy() if metadata else {}
payment_metadata.update(
{
"user_id": str(user_id),
"amount_kopeks": str(amount_kopeks),
"type": "balance_topup_sbp",
}
)
yookassa_response = (
await self.yookassa_service.create_sbp_payment(
amount=amount_rubles,
currency="RUB",
description=description,
metadata=payment_metadata,
receipt_email=receipt_email,
receipt_phone=receipt_phone,
)
)
if not yookassa_response or yookassa_response.get("error"):
logger.error(
"Ошибка создания платежа YooKassa СБП: %s",
yookassa_response,
)
return None
local_payment = await payment_module.create_yookassa_payment(
db=db,
user_id=user_id,
yookassa_payment_id=yookassa_response["id"],
amount_kopeks=amount_kopeks,
currency="RUB",
description=description,
status=yookassa_response["status"],
confirmation_url=yookassa_response.get("confirmation_url"),
metadata_json=payment_metadata,
payment_method_type="bank_card",
yookassa_created_at=None,
test_mode=yookassa_response.get("test_mode", False),
)
logger.info(
"Создан платеж YooKassa СБП %s на %s₽ для пользователя %s",
yookassa_response["id"],
amount_rubles,
user_id,
)
confirmation_token = (
yookassa_response.get("confirmation", {}) or {}
).get("confirmation_token")
return {
"local_payment_id": local_payment.id,
"yookassa_payment_id": yookassa_response["id"],
"confirmation_url": yookassa_response.get("confirmation_url"),
"confirmation_token": confirmation_token,
"amount_kopeks": amount_kopeks,
"amount_rubles": amount_rubles,
"status": yookassa_response["status"],
"created_at": local_payment.created_at,
}
except Exception as error:
logger.error("Ошибка создания платежа YooKassa СБП: %s", error)
return None
async def _process_successful_yookassa_payment(
self,
db: AsyncSession,
payment: "YooKassaPayment",
) -> bool:
"""Переносит успешный платёж YooKassa в транзакции и начисляет баланс пользователю."""
try:
payment_module = import_module("app.services.payment_service")
payment_description = getattr(payment, "description", "YooKassa платеж")
transaction = await payment_module.create_transaction(
db=db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=f"Пополнение через YooKassa: {payment_description}",
payment_method=PaymentMethod.YOOKASSA,
external_id=payment.yookassa_payment_id,
is_completed=True,
)
await payment_module.link_yookassa_payment_to_transaction(
db,
payment.yookassa_payment_id,
transaction.id,
)
user = await payment_module.get_user_by_id(db, payment.user_id)
if user:
old_balance = getattr(user, "balance_kopeks", 0)
was_first_topup = not getattr(user, "has_made_first_topup", False)
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = getattr(user, "promo_group", None)
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = ("🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение")
await db.commit()
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
payment.amount_kopeks,
getattr(self, "bot", None),
)
except Exception as error:
logger.error(
"Ошибка обработки реферального пополнения YooKassa: %s",
error,
)
if was_first_topup and not getattr(user, "has_made_first_topup", False):
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
# Отправляем уведомления админам
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import (
AdminNotificationService,
)
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
logger.info("Уведомление админам о пополнении отправлено успешно")
except Exception as error:
logger.error(
"Ошибка отправки уведомления админам о YooKassa пополнении: %s",
error,
exc_info=True # Добавляем полный стек вызовов для отладки
)
# Отправляем уведомление пользователю
if getattr(self, "bot", None):
try:
# Передаем только простые данные, чтобы избежать проблем с ленивой загрузкой
await self._send_payment_success_notification(
user.telegram_id,
payment.amount_kopeks,
user=None, # Передаем None, чтобы _ensure_user_snapshot загрузил данные сам
db=db,
payment_method_title="Банковская карта (YooKassa)",
)
logger.info("Уведомление пользователю о платеже отправлено успешно")
except Exception as error:
logger.error(
"Ошибка отправки уведомления о платеже: %s",
error,
exc_info=True # Добавляем полный стек вызовов для отладки
)
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
# ВАЖНО: этот код должен выполняться даже при ошибках в уведомлениях
logger.info(f"Проверяем наличие сохраненной корзины для пользователя {user.id}")
from app.services.user_cart_service import user_cart_service
try:
has_saved_cart = await user_cart_service.has_user_cart(user.id)
logger.info(f"Результат проверки корзины для пользователя {user.id}: {has_saved_cart}")
if has_saved_cart and getattr(self, "bot", None):
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
from app.localization.texts import get_texts
from aiogram import types
texts = get_texts(user.language)
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
total_amount=settings.format_price(payment.amount_kopeks)
)
# Создаем клавиатуру с кнопками
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout"
)],
[types.InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance"
)],
[types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu"
)]
])
await self.bot.send_message(
chat_id=user.telegram_id,
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
reply_markup=keyboard
)
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
else:
logger.info(f"У пользователя {user.id} нет сохраненной корзины или бот недоступен")
except Exception as e:
logger.error(f"Критическая ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
logger.info(
"Успешно обработан платеж YooKassa %s: пользователь %s получил %s",
payment.yookassa_payment_id,
payment.user_id,
payment.amount_kopeks / 100,
)
logger.info(
"Успешно обработан платеж YooKassa %s: пользователь %s получил %s",
payment.yookassa_payment_id,
payment.user_id,
payment.amount_kopeks / 100,
)
return True
except Exception as error:
logger.error(
"Ошибка обработки успешного платежа YooKassa %s: %s",
payment.yookassa_payment_id,
error,
)
return False
async def process_yookassa_webhook(
self,
db: AsyncSession,
event: Dict[str, Any],
) -> bool:
"""Обрабатывает входящий webhook YooKassa и синхронизирует состояние платежа."""
event_object = event.get("object", {})
yookassa_payment_id = event_object.get("id")
if not yookassa_payment_id:
logger.warning("Webhook без payment id: %s", event)
return False
payment_module = import_module("app.services.payment_service")
payment = await payment_module.get_yookassa_payment_by_id(db, yookassa_payment_id)
if not payment:
logger.warning(
"Локальный платеж для YooKassa id %s не найден", yookassa_payment_id
)
return False
payment.status = event_object.get("status", payment.status)
payment.confirmation_url = event_object.get("confirmation_url")
current_paid = getattr(payment, "paid", False)
payment.paid = event_object.get("paid", current_paid)
await db.commit()
await db.refresh(payment)
if payment.status == "succeeded" and payment.paid:
return await self._process_successful_yookassa_payment(db, payment)
logger.info(
"Webhook YooKassa обновил платеж %s до статуса %s",
yookassa_payment_id,
payment.status,
)
return True
File diff suppressed because it is too large Load Diff
+178
View File
@@ -0,0 +1,178 @@
import logging
from typing import Optional, List
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.privacy_policy import (
get_privacy_policy,
set_privacy_policy_enabled,
upsert_privacy_policy,
)
from app.database.models import PrivacyPolicy
logger = logging.getLogger(__name__)
class PrivacyPolicyService:
"""Utility helpers around privacy policy storage and presentation."""
MAX_PAGE_LENGTH = 3500
@staticmethod
def _normalize_language(language: str) -> str:
base_language = language or settings.DEFAULT_LANGUAGE or "ru"
return base_language.split("-")[0].lower()
@staticmethod
def normalize_language(language: str) -> str:
return PrivacyPolicyService._normalize_language(language)
@classmethod
async def get_policy(
cls,
db: AsyncSession,
language: str,
*,
fallback: bool = False,
) -> Optional[PrivacyPolicy]:
lang = cls._normalize_language(language)
policy = await get_privacy_policy(db, lang)
if policy or not fallback:
return policy
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
if lang != default_lang:
return await get_privacy_policy(db, default_lang)
return policy
@classmethod
async def get_active_policy(
cls,
db: AsyncSession,
language: str,
) -> Optional[PrivacyPolicy]:
lang = cls._normalize_language(language)
policy = await get_privacy_policy(db, lang)
if policy and policy.is_enabled and policy.content.strip():
return policy
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
if lang != default_lang:
fallback_policy = await get_privacy_policy(db, default_lang)
if fallback_policy and fallback_policy.is_enabled and fallback_policy.content.strip():
return fallback_policy
return None
@classmethod
async def is_policy_enabled(cls, db: AsyncSession, language: str) -> bool:
policy = await cls.get_active_policy(db, language)
return policy is not None
@classmethod
async def save_policy(
cls,
db: AsyncSession,
language: str,
content: str,
) -> PrivacyPolicy:
lang = cls._normalize_language(language)
enable_if_new = True
policy = await upsert_privacy_policy(
db,
lang,
content,
enable_if_new=enable_if_new,
)
logger.info("✅ Политика конфиденциальности обновлена для языка %s", lang)
return policy
@classmethod
async def set_enabled(
cls,
db: AsyncSession,
language: str,
enabled: bool,
) -> PrivacyPolicy:
lang = cls._normalize_language(language)
return await set_privacy_policy_enabled(db, lang, enabled)
@classmethod
async def toggle_enabled(
cls,
db: AsyncSession,
language: str,
) -> PrivacyPolicy:
lang = cls._normalize_language(language)
policy = await get_privacy_policy(db, lang)
if policy:
new_status = not policy.is_enabled
else:
new_status = True
return await set_privacy_policy_enabled(db, lang, new_status)
@staticmethod
def split_content_into_pages(
content: str,
*,
max_length: int = None,
) -> List[str]:
if not content:
return []
normalized = content.replace("\r\n", "\n").strip()
if not normalized:
return []
max_len = max_length or PrivacyPolicyService.MAX_PAGE_LENGTH
if len(normalized) <= max_len:
return [normalized]
paragraphs = [
paragraph.strip()
for paragraph in normalized.split("\n\n")
if paragraph.strip()
]
pages: List[str] = []
current = ""
def flush_current() -> None:
nonlocal current
if current:
pages.append(current.strip())
current = ""
for paragraph in paragraphs:
candidate = f"{current}\n\n{paragraph}".strip() if current else paragraph
if len(candidate) <= max_len:
current = candidate
continue
flush_current()
if len(paragraph) <= max_len:
current = paragraph
continue
start_index = 0
while start_index < len(paragraph):
chunk = paragraph[start_index:start_index + max_len]
pages.append(chunk.strip())
start_index += max_len
current = ""
flush_current()
if not pages:
return [normalized[:max_len]]
return pages
+228
View File
@@ -0,0 +1,228 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Sequence, Tuple
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import (
DiscountOffer,
Subscription,
SubscriptionTemporaryAccess,
User,
)
from app.services.subscription_service import SubscriptionService
from app.database.crud.promo_offer_log import log_promo_offer_action
logger = logging.getLogger(__name__)
class PromoOfferService:
def __init__(self) -> None:
self.subscription_service = SubscriptionService()
async def grant_test_access(
self,
db: AsyncSession,
user: User,
offer: DiscountOffer,
) -> Tuple[bool, Optional[List[str]], Optional[datetime], str]:
subscription = getattr(user, "subscription", None)
if not subscription:
return False, None, None, "subscription_missing"
payload = offer.extra_data or {}
raw_squads = payload.get("test_squad_uuids") or payload.get("squads") or []
if isinstance(raw_squads, str):
candidates = [raw_squads]
else:
try:
candidates = list(raw_squads)
except TypeError:
candidates = []
squad_uuids: Sequence[str] = [str(item) for item in candidates if item]
if not squad_uuids:
return False, None, None, "squads_missing"
squad_uuids = list(dict.fromkeys(squad_uuids))
connected = {str(item) for item in subscription.connected_squads or []}
if squad_uuids and set(squad_uuids).issubset(connected):
return False, None, None, "already_connected"
try:
duration_hours = int(payload.get("test_duration_hours") or payload.get("duration_hours") or 24)
except (TypeError, ValueError):
duration_hours = 24
if duration_hours <= 0:
duration_hours = 24
now = datetime.utcnow()
expires_at = now + timedelta(hours=duration_hours)
original_connected = set(connected)
newly_added: List[str] = []
changes_made = False
for squad_uuid in squad_uuids:
normalized_uuid = str(squad_uuid)
existing_result = await db.execute(
select(SubscriptionTemporaryAccess)
.where(
SubscriptionTemporaryAccess.offer_id == offer.id,
SubscriptionTemporaryAccess.squad_uuid == normalized_uuid,
)
.order_by(SubscriptionTemporaryAccess.id.desc())
)
existing_access = existing_result.scalars().first()
if existing_access and existing_access.is_active:
if existing_access.expires_at < expires_at:
existing_access.expires_at = expires_at
changes_made = True
continue
was_already_connected = normalized_uuid in connected
if not was_already_connected:
connected.add(normalized_uuid)
newly_added.append(normalized_uuid)
changes_made = True
access_entry = SubscriptionTemporaryAccess(
subscription_id=subscription.id,
offer_id=offer.id,
squad_uuid=normalized_uuid,
expires_at=expires_at,
is_active=True,
was_already_connected=was_already_connected,
)
db.add(access_entry)
changes_made = True
connected_changed = connected != original_connected
if newly_added:
subscription.connected_squads = list(connected)
subscription.updated_at = now
changes_made = True
if connected_changed:
remnawave_user = await self.subscription_service.update_remnawave_user(
db,
subscription,
)
if remnawave_user is None:
await db.rollback()
await db.refresh(subscription)
logger.error(
"Не удалось синхронизировать временный доступ подписки %s с RemnaWave",
subscription.id,
)
return False, None, None, "remnawave_sync_failed"
await db.refresh(subscription)
elif changes_made:
await db.commit()
await db.refresh(subscription)
return True, newly_added, expires_at, "ok"
async def cleanup_expired_test_access(self, db: AsyncSession) -> int:
now = datetime.utcnow()
result = await db.execute(
select(SubscriptionTemporaryAccess)
.options(
selectinload(SubscriptionTemporaryAccess.subscription),
selectinload(SubscriptionTemporaryAccess.offer),
)
.where(
SubscriptionTemporaryAccess.is_active == True, # noqa: E712
SubscriptionTemporaryAccess.expires_at <= now,
)
)
entries = result.scalars().all()
if not entries:
return 0
subscriptions_updates: dict[int, Tuple[Subscription, set[str]]] = {}
log_payloads: List[Dict[str, object]] = []
for entry in entries:
entry.is_active = False
entry.deactivated_at = now
subscription = entry.subscription
if not subscription:
continue
bucket = subscriptions_updates.setdefault(subscription.id, (subscription, set()))
if not entry.was_already_connected:
bucket[1].add(entry.squad_uuid)
user_id = subscription.user_id
if user_id:
offer = entry.offer
log_payloads.append(
{
"user_id": user_id,
"offer_id": entry.offer_id,
"source": getattr(offer, "notification_type", None),
"percent": getattr(offer, "discount_percent", None),
"effect_type": getattr(offer, "effect_type", "test_access"),
"details": {
"reason": "test_access_expired",
"squad_uuid": entry.squad_uuid,
},
}
)
for subscription, squads_to_remove in subscriptions_updates.values():
if not squads_to_remove:
continue
current = set(subscription.connected_squads or [])
updated = current.difference(squads_to_remove)
if updated != current:
subscription.connected_squads = list(updated)
subscription.updated_at = now
try:
await self.subscription_service.update_remnawave_user(db, subscription)
except Exception as exc: # pragma: no cover - defensive logging
logger.error(
"Ошибка обновления Remnawave при отзыве тестового доступа подписки %s: %s",
subscription.id,
exc,
)
await db.commit()
for payload in log_payloads:
try:
await log_promo_offer_action(
db,
user_id=payload["user_id"],
offer_id=payload.get("offer_id"),
action="disabled",
source=payload.get("source"),
percent=payload.get("percent"),
effect_type=payload.get("effect_type"),
details=payload.get("details"),
)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning(
"Failed to record promo offer test access disable log for user %s: %s",
payload.get("user_id"),
exc,
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to rollback session after promo offer test access log failure: %s",
rollback_error,
)
return len(entries)
promo_offer_service = PromoOfferService()
+23 -7
View File
@@ -113,16 +113,32 @@ class PromoCodeService:
from app.database.crud.subscription import create_paid_subscription
trial_squads = []
if hasattr(settings, 'TRIAL_SQUAD_UUID') and settings.TRIAL_SQUAD_UUID:
trial_squads = [settings.TRIAL_SQUAD_UUID]
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
trial_uuid = await get_random_trial_squad_uuid(
db,
settings.TRIAL_SQUAD_UUID,
)
if trial_uuid:
trial_squads = [trial_uuid]
except Exception as error:
logger.error(
"Не удалось подобрать сквад для подписки по промокоду %s: %s",
promocode.code,
error,
)
if getattr(settings, 'TRIAL_SQUAD_UUID', None):
trial_squads = [settings.TRIAL_SQUAD_UUID]
new_subscription = await create_paid_subscription(
db=db,
user_id=user.id,
duration_days=promocode.subscription_days,
traffic_limit_gb=0,
traffic_limit_gb=0,
device_limit=1,
connected_squads=trial_squads
connected_squads=trial_squads,
update_server_counters=True,
)
await self.subscription_service.create_remnawave_user(db, new_subscription)
@@ -140,9 +156,9 @@ class PromoCodeService:
trial_days = promocode.subscription_days if promocode.subscription_days > 0 else settings.TRIAL_DURATION_DAYS
trial_subscription = await create_trial_subscription(
db,
user.id,
duration_days=trial_days
db,
user.id,
duration_days=trial_days
)
await self.subscription_service.create_remnawave_user(db, trial_subscription)
+359
View File
@@ -0,0 +1,359 @@
import logging
from html.parser import HTMLParser
from typing import List, Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.public_offer import (
get_public_offer,
set_public_offer_enabled,
upsert_public_offer,
)
from app.database.models import PublicOffer
logger = logging.getLogger(__name__)
class PublicOfferService:
"""Helpers for managing the public offer text and visibility."""
MAX_PAGE_LENGTH = 3500
@staticmethod
def _normalize_language(language: str) -> str:
base_language = language or settings.DEFAULT_LANGUAGE or "ru"
return base_language.split("-")[0].lower()
@staticmethod
def normalize_language(language: str) -> str:
return PublicOfferService._normalize_language(language)
@classmethod
async def get_offer(
cls,
db: AsyncSession,
language: str,
*,
fallback: bool = False,
) -> Optional[PublicOffer]:
lang = cls._normalize_language(language)
offer = await get_public_offer(db, lang)
if offer or not fallback:
return offer
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
if lang != default_lang:
return await get_public_offer(db, default_lang)
return offer
@classmethod
async def get_active_offer(
cls,
db: AsyncSession,
language: str,
) -> Optional[PublicOffer]:
lang = cls._normalize_language(language)
offer = await get_public_offer(db, lang)
if offer:
if offer.is_enabled and offer.content.strip():
return offer
if not offer.is_enabled:
return None
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
if lang != default_lang:
fallback_offer = await get_public_offer(db, default_lang)
if fallback_offer and fallback_offer.is_enabled and fallback_offer.content.strip():
return fallback_offer
return None
@classmethod
async def is_offer_enabled(cls, db: AsyncSession, language: str) -> bool:
offer = await cls.get_active_offer(db, language)
return offer is not None
@classmethod
async def save_offer(
cls,
db: AsyncSession,
language: str,
content: str,
) -> PublicOffer:
lang = cls._normalize_language(language)
enable_if_new = True
offer = await upsert_public_offer(
db,
lang,
content,
enable_if_new=enable_if_new,
)
logger.info("✅ Публичная оферта обновлена для языка %s", lang)
return offer
@classmethod
async def set_enabled(
cls,
db: AsyncSession,
language: str,
enabled: bool,
) -> PublicOffer:
lang = cls._normalize_language(language)
return await set_public_offer_enabled(db, lang, enabled)
@classmethod
async def toggle_enabled(
cls,
db: AsyncSession,
language: str,
) -> PublicOffer:
lang = cls._normalize_language(language)
offer = await get_public_offer(db, lang)
if offer:
new_status = not offer.is_enabled
else:
new_status = True
return await set_public_offer_enabled(db, lang, new_status)
class _RichTextPaginator(HTMLParser):
"""Split HTML-like text into Telegram-safe chunks."""
SELF_CLOSING_TAGS = {"br", "hr", "img", "input", "meta", "link"}
def __init__(self, max_len: int) -> None:
super().__init__(convert_charrefs=False)
self.max_len = max_len
self.pages: List[str] = []
self.current_parts: List[str] = []
self.current_length = 0
self.open_stack: List[Tuple[str, str, int]] = []
self.closing_length = 0
self.needs_prefix = False
self.prefix_length = 0
def _closing_sequence(self) -> str:
return "".join(f"</{name}>" for name, _, _ in reversed(self.open_stack))
def _ensure_prefix(self) -> None:
if not self.needs_prefix:
return
prefix = "".join(token for _, token, _ in self.open_stack)
if prefix:
self.current_parts.append(prefix)
self.current_length += len(prefix)
self.needs_prefix = False
def _flush(self) -> None:
if not self.current_parts and not self.closing_length:
return
content = "".join(self.current_parts)
closing_tags = self._closing_sequence()
page = (content + closing_tags).strip()
if page:
self.pages.append(page)
self.current_parts = []
self.current_length = 0
if self.prefix_length + self.closing_length > self.max_len:
self.open_stack = []
self.closing_length = 0
self.prefix_length = 0
self.needs_prefix = False
else:
self.needs_prefix = bool(self.open_stack)
@staticmethod
def _format_attrs(attrs: List[Tuple[str, Optional[str]]]) -> str:
parts = []
for name, value in attrs:
if value is None:
parts.append(f" {name}")
else:
parts.append(f" {name}=\"{value}\"")
return "".join(parts)
def _append_token(self, token: str) -> None:
while True:
self._ensure_prefix()
if self.current_length + len(token) + self.closing_length <= self.max_len:
self.current_parts.append(token)
self.current_length += len(token)
break
self._flush()
if len(token) > self.max_len and not self.current_parts:
self.current_parts.append(token)
self.current_length += len(token)
break
def handle_data(self, data: str) -> None:
if not data:
return
remaining = data
while remaining:
self._ensure_prefix()
available = self.max_len - (self.current_length + self.closing_length)
if available <= 0:
self._flush()
continue
piece = remaining[:available]
self.current_parts.append(piece)
self.current_length += len(piece)
remaining = remaining[available:]
if remaining:
self._flush()
def handle_entityref(self, name: str) -> None:
self.handle_data(f"&{name};")
def handle_charref(self, name: str) -> None:
self.handle_data(f"&#{name};")
def _handle_self_closing(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
token = f"<{tag}{self._format_attrs(attrs)}/>"
self._append_token(token)
def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
if tag in self.SELF_CLOSING_TAGS:
self._handle_self_closing(tag, attrs)
return
token = f"<{tag}{self._format_attrs(attrs)}>"
closing_token = f"</{tag}>"
closing_len = len(closing_token)
while True:
self._ensure_prefix()
projected_length = self.current_length + len(token) + self.closing_length + closing_len
if projected_length <= self.max_len:
self.current_parts.append(token)
self.current_length += len(token)
self.open_stack.append((tag, token, closing_len))
self.closing_length += closing_len
self.prefix_length += len(token)
break
self._flush()
if len(token) + closing_len > self.max_len and not self.current_parts:
self.current_parts.append(token)
self.current_length += len(token)
self.open_stack.append((tag, token, closing_len))
self.closing_length += closing_len
self.prefix_length += len(token)
break
def handle_endtag(self, tag: str) -> None:
token = f"</{tag}>"
closing_len_reduction = 0
index_to_remove = None
for index in range(len(self.open_stack) - 1, -1, -1):
if self.open_stack[index][0] == tag:
closing_len_reduction = self.open_stack[index][2]
index_to_remove = index
break
while True:
self._ensure_prefix()
projected_closing_length = self.closing_length - closing_len_reduction
if projected_closing_length < 0:
projected_closing_length = 0
projected_total = self.current_length + len(token) + projected_closing_length
if projected_total <= self.max_len or not self.current_parts:
self.current_parts.append(token)
self.current_length += len(token)
if index_to_remove is not None:
removed_tag = self.open_stack.pop(index_to_remove)
self.closing_length -= closing_len_reduction
self.prefix_length -= len(removed_tag[1])
break
self._flush()
def handle_startendtag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
self._handle_self_closing(tag, attrs)
def finalize(self) -> List[str]:
self._flush()
return self.pages
@classmethod
def _split_rich_paragraph(cls, paragraph: str, max_len: int) -> List[str]:
if len(paragraph) <= max_len:
return [paragraph]
paginator = cls._RichTextPaginator(max_len)
paginator.feed(paragraph)
paginator.close()
pages = paginator.finalize()
return pages or [paragraph]
@classmethod
def split_content_into_pages(
cls,
content: str,
*,
max_length: int = None,
) -> List[str]:
if not content:
return []
normalized = content.replace("\r\n", "\n").strip()
if not normalized:
return []
max_len = max_length or cls.MAX_PAGE_LENGTH
if len(normalized) <= max_len:
return [normalized]
paragraphs = [
paragraph.strip()
for paragraph in normalized.split("\n\n")
if paragraph.strip()
]
pages: List[str] = []
current = ""
def flush_current() -> None:
nonlocal current
if current:
pages.append(current.strip())
current = ""
for paragraph in paragraphs:
segments = cls._split_rich_paragraph(paragraph, max_len)
for segment in segments:
segment = segment.strip()
if not segment:
continue
candidate = f"{current}\n\n{segment}".strip() if current else segment
if len(candidate) <= max_len:
current = candidate
continue
flush_current()
current = segment
flush_current()
if not pages:
return [normalized[:max_len]]
return pages
+294 -36
View File
@@ -1,21 +1,36 @@
import logging
from contextlib import asynccontextmanager
from typing import Dict, List, Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete
from datetime import datetime, timedelta
import os
import re
from contextlib import AsyncExitStack, asynccontextmanager
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from zoneinfo import ZoneInfo
from app.config import settings
from app.external.remnawave_api import (
RemnaWaveAPI, RemnaWaveUser, RemnaWaveInternalSquad,
RemnaWaveAPI, RemnaWaveUser, RemnaWaveInternalSquad,
RemnaWaveNode, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError
)
from sqlalchemy import and_, cast, delete, func, select, update, String
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.user import get_users_list, get_user_by_telegram_id, update_user
from app.database.crud.subscription import get_subscription_by_user_id, update_subscription_usage
from app.database.crud.subscription import (
get_subscription_by_user_id,
update_subscription_usage,
decrement_subscription_server_counts,
)
from app.database.crud.server_squad import get_server_squad_by_uuid
from app.database.models import (
User, SubscriptionServer, Transaction, ReferralEarning,
PromoCodeUse, SubscriptionStatus
User,
Subscription,
SubscriptionServer,
Transaction,
ReferralEarning,
PromoCodeUse,
SubscriptionStatus,
ServerSquad,
)
logger = logging.getLogger(__name__)
@@ -34,6 +49,16 @@ class RemnaWaveService:
self._config_error: Optional[str] = None
tz_name = os.getenv("TZ", "UTC")
try:
self._panel_timezone = ZoneInfo(tz_name)
except Exception:
logger.warning(
"⚠️ Не удалось загрузить временную зону '%s'. Используется UTC.",
tz_name,
)
self._panel_timezone = ZoneInfo("UTC")
if not base_url:
self._config_error = "REMNAWAVE_API_URL не настроен"
elif not api_key:
@@ -72,33 +97,41 @@ class RemnaWaveService:
async with self.api as api:
yield api
def _now_in_panel_timezone(self) -> datetime:
"""Возвращает текущее время без часового пояса в зоне панели."""
return datetime.now(self._panel_timezone).replace(tzinfo=None)
def _parse_remnawave_date(self, date_str: str) -> datetime:
if not date_str:
return datetime.utcnow() + timedelta(days=30)
return self._now_in_panel_timezone() + timedelta(days=30)
try:
cleaned_date = date_str.strip()
if cleaned_date.endswith('Z'):
cleaned_date = cleaned_date[:-1] + '+00:00'
if '+00:00+00:00' in cleaned_date:
cleaned_date = cleaned_date.replace('+00:00+00:00', '+00:00')
cleaned_date = re.sub(r'(\+\d{2}:\d{2})\+\d{2}:\d{2}$', r'\1', cleaned_date)
parsed_date = datetime.fromisoformat(cleaned_date)
if parsed_date.tzinfo is not None:
parsed_date = parsed_date.replace(tzinfo=None)
logger.debug(f"Успешно распарсена дата: {date_str} -> {parsed_date}")
return parsed_date
localized = parsed_date.astimezone(self._panel_timezone)
else:
localized = parsed_date.replace(tzinfo=self._panel_timezone)
localized_naive = localized.replace(tzinfo=None)
logger.debug(f"Успешно распарсена дата: {date_str} -> {localized_naive}")
return localized_naive
except Exception as e:
logger.warning(f"⚠️ Не удалось распарсить дату '{date_str}': {e}. Используем дефолтную дату.")
return datetime.utcnow() + timedelta(days=30)
return self._now_in_panel_timezone() + timedelta(days=30)
async def get_system_statistics(self) -> Dict[str, Any]:
try:
@@ -456,16 +489,237 @@ class RemnaWaveService:
try:
async with self.get_api_client() as api:
result = await api.delete_internal_squad(uuid)
if result:
logger.info(f"✅ Удален сквад {uuid}")
return result
except Exception as e:
logger.error(f"Ошибка удаления сквада {uuid}: {e}")
return False
async def migrate_squad_users(
self,
db: AsyncSession,
source_uuid: str,
target_uuid: str,
) -> Dict[str, Any]:
"""Переносит активных подписок с одного сквада на другой."""
if source_uuid == target_uuid:
return {
"success": False,
"error": "same_squad",
"message": "Источник и назначение совпадают",
}
source_uuid = source_uuid.strip()
target_uuid = target_uuid.strip()
source_server = await get_server_squad_by_uuid(db, source_uuid)
target_server = await get_server_squad_by_uuid(db, target_uuid)
if not source_server or not target_server:
return {
"success": False,
"error": "not_found",
"message": "Сквады не найдены",
}
subscription_query = (
select(Subscription)
.options(selectinload(Subscription.user))
.where(
Subscription.status.in_(
[
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.TRIAL.value,
]
),
cast(Subscription.connected_squads, String).like(
f'%"{source_uuid}"%'
),
)
)
result = await db.execute(subscription_query)
subscriptions = result.scalars().unique().all()
total_candidates = len(subscriptions)
if not subscriptions:
logger.info(
"🚚 Переезд сквада %s%s: подходящих подписок не найдено",
source_uuid,
target_uuid,
)
return {
"success": True,
"total": 0,
"updated": 0,
"panel_updated": 0,
"panel_failed": 0,
}
exit_stack = AsyncExitStack()
panel_updated = 0
panel_failed = 0
updated_subscriptions = 0
source_decrement = 0
target_increment = 0
try:
needs_panel_update = any(
subscription.user and subscription.user.remnawave_uuid
for subscription in subscriptions
)
api = None
if needs_panel_update:
api = await exit_stack.enter_async_context(self.get_api_client())
for subscription in subscriptions:
current_squads = list(subscription.connected_squads or [])
if source_uuid not in current_squads:
continue
had_target_before = target_uuid in current_squads
new_squads = [
squad_uuid for squad_uuid in current_squads if squad_uuid != source_uuid
]
if not had_target_before:
new_squads.append(target_uuid)
if subscription.user and subscription.user.remnawave_uuid:
if api is None:
panel_failed += 1
logger.error(
"❌ RemnaWave API недоступен для обновления пользователя %s",
subscription.user.telegram_id,
)
continue
try:
await api.update_user(
uuid=subscription.user.remnawave_uuid,
active_internal_squads=new_squads,
)
panel_updated += 1
except Exception as error:
panel_failed += 1
logger.error(
"❌ Ошибка обновления сквадов пользователя %s: %s",
subscription.user.telegram_id,
error,
)
continue
subscription.connected_squads = new_squads
subscription.updated_at = datetime.utcnow()
source_decrement += 1
if not had_target_before:
target_increment += 1
updated_subscriptions += 1
link_result = await db.execute(
select(SubscriptionServer)
.where(
and_(
SubscriptionServer.subscription_id == subscription.id,
SubscriptionServer.server_squad_id == source_server.id,
)
)
.limit(1)
)
link = link_result.scalars().first()
if link:
if had_target_before:
await db.execute(
delete(SubscriptionServer).where(
and_(
SubscriptionServer.subscription_id
== subscription.id,
SubscriptionServer.server_squad_id
== source_server.id,
)
)
)
else:
link.server_squad_id = target_server.id
elif not had_target_before:
db.add(
SubscriptionServer(
subscription_id=subscription.id,
server_squad_id=target_server.id,
paid_price_kopeks=0,
)
)
if updated_subscriptions:
if source_decrement:
await db.execute(
update(ServerSquad)
.where(ServerSquad.id == source_server.id)
.values(
current_users=func.greatest(
ServerSquad.current_users - source_decrement,
0,
)
)
)
if target_increment:
await db.execute(
update(ServerSquad)
.where(ServerSquad.id == target_server.id)
.values(
current_users=ServerSquad.current_users + target_increment
)
)
await db.commit()
else:
await db.rollback()
logger.info(
"🚚 Завершен переезд сквада %s%s: обновлено %s подписок (%s не обновлены в панели)",
source_uuid,
target_uuid,
updated_subscriptions,
panel_failed,
)
return {
"success": True,
"total": total_candidates,
"updated": updated_subscriptions,
"panel_updated": panel_updated,
"panel_failed": panel_failed,
"source_removed": source_decrement,
"target_added": target_increment,
}
except RemnaWaveConfigurationError:
await db.rollback()
raise
except Exception as error:
await db.rollback()
logger.error(
"❌ Ошибка переезда сквада %s%s: %s",
source_uuid,
target_uuid,
error,
)
return {
"success": False,
"error": "unexpected",
"message": str(error),
}
finally:
await exit_stack.aclose()
async def sync_users_from_panel(self, db: AsyncSession, sync_type: str = "all") -> Dict[str, int]:
try:
stats = {"created": 0, "updated": 0, "errors": 0, "deleted": 0}
@@ -600,7 +854,9 @@ class RemnaWaveService:
try:
from sqlalchemy import delete
from app.database.models import SubscriptionServer
await decrement_subscription_server_counts(db, subscription)
await db.execute(
delete(SubscriptionServer).where(
SubscriptionServer.subscription_id == subscription.id
@@ -652,7 +908,7 @@ class RemnaWaveService:
expire_at = self._parse_remnawave_date(expire_at_str)
panel_status = panel_user.get('status', 'ACTIVE')
current_time = datetime.utcnow()
current_time = self._now_in_panel_timezone()
if panel_status == 'ACTIVE' and expire_at > current_time:
status = SubscriptionStatus.ACTIVE
@@ -707,7 +963,7 @@ class RemnaWaveService:
user_id=user.id,
status=SubscriptionStatus.ACTIVE.value,
is_trial=False,
end_date=datetime.utcnow() + timedelta(days=30),
end_date=self._now_in_panel_timezone() + timedelta(days=30),
traffic_limit_gb=0,
traffic_used_gb=0.0,
device_limit=1,
@@ -744,7 +1000,7 @@ class RemnaWaveService:
subscription.end_date = expire_at
logger.debug(f"Обновлена дата окончания подписки до {expire_at}")
current_time = datetime.utcnow()
current_time = self._now_in_panel_timezone()
if panel_status == 'ACTIVE' and subscription.end_date > current_time:
new_status = SubscriptionStatus.ACTIVE.value
elif subscription.end_date <= current_time:
@@ -1116,6 +1372,8 @@ class RemnaWaveService:
)
if user.subscription:
await decrement_subscription_server_counts(db, user.subscription)
await db.execute(
delete(SubscriptionServer).where(
SubscriptionServer.subscription_id == user.subscription.id
@@ -1150,22 +1408,22 @@ class RemnaWaveService:
user.remnawave_uuid = None
user.has_had_paid_subscription = False
user.used_promocodes = 0
user.updated_at = datetime.utcnow()
user.updated_at = self._now_in_panel_timezone()
if user.subscription:
user.subscription.status = SubscriptionStatus.DISABLED.value
user.subscription.is_trial = True
user.subscription.end_date = datetime.utcnow()
user.subscription.end_date = self._now_in_panel_timezone()
user.subscription.traffic_limit_gb = 0
user.subscription.traffic_used_gb = 0.0
user.subscription.device_limit = 1
user.subscription.connected_squads = []
user.subscription.autopay_enabled = False
user.subscription.autopay_days_before = 3
user.subscription.autopay_days_before = settings.DEFAULT_AUTOPAY_DAYS_BEFORE
user.subscription.remnawave_short_uuid = None
user.subscription.subscription_url = ""
user.subscription.subscription_crypto_link = ""
user.subscription.updated_at = datetime.utcnow()
user.subscription.updated_at = self._now_in_panel_timezone()
await db.commit()
@@ -1334,7 +1592,7 @@ class RemnaWaveService:
user = subscription.user
issues_fixed = 0
current_time = datetime.utcnow()
current_time = self._now_in_panel_timezone()
if subscription.end_date <= current_time and subscription.status == SubscriptionStatus.ACTIVE.value:
logger.info(f"🔧 Исправляем статус просроченной подписки {user.telegram_id}")
subscription.status = SubscriptionStatus.EXPIRED.value
+282
View File
@@ -0,0 +1,282 @@
import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, time
from typing import Any, Callable, Dict, List, Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.database import AsyncSessionLocal
from app.database.crud.server_squad import sync_with_remnawave
from app.services.remnawave_service import (
RemnaWaveConfigurationError,
RemnaWaveService,
)
from app.utils.cache import cache
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class RemnaWaveAutoSyncStatus:
enabled: bool
times: List[time]
next_run: Optional[datetime]
last_run_started_at: Optional[datetime]
last_run_finished_at: Optional[datetime]
last_run_success: Optional[bool]
last_run_reason: Optional[str]
last_run_error: Optional[str]
last_user_stats: Optional[Dict[str, Any]]
last_server_stats: Optional[Dict[str, Any]]
is_running: bool
class RemnaWaveAutoSyncService:
def __init__(
self,
service_factory: Callable[[], RemnaWaveService] = RemnaWaveService,
) -> None:
self._scheduler_task: Optional[asyncio.Task] = None
self._scheduler_lock = asyncio.Lock()
self._sync_lock = asyncio.Lock()
self._service_factory = service_factory
self._service = self._service_factory()
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._initialized = False
self._pending_refresh = False
self._pending_run_immediately = False
self._next_run: Optional[datetime] = None
self._last_run_started_at: Optional[datetime] = None
self._last_run_finished_at: Optional[datetime] = None
self._last_run_success: Optional[bool] = None
self._last_run_reason: Optional[str] = None
self._last_run_error: Optional[str] = None
self._last_user_stats: Optional[Dict[str, Any]] = None
self._last_server_stats: Optional[Dict[str, Any]] = None
async def initialize(self) -> None:
self._loop = asyncio.get_running_loop()
self._initialized = True
run_immediately = self._pending_run_immediately
if self._pending_refresh:
self._pending_refresh = False
self._pending_run_immediately = False
await self.refresh_schedule(run_immediately=run_immediately)
else:
await self.refresh_schedule()
def refresh_configuration(self) -> None:
"""Rebuild the RemnaWave service instance using current settings."""
self._refresh_service()
async def refresh_schedule(self, *, run_immediately: bool = False) -> None:
async with self._scheduler_lock:
if self._scheduler_task and not self._scheduler_task.done():
self._scheduler_task.cancel()
try:
await self._scheduler_task
except asyncio.CancelledError:
pass
finally:
self._scheduler_task = None
if not settings.REMNAWAVE_AUTO_SYNC_ENABLED:
self._next_run = None
return
times = settings.get_remnawave_auto_sync_times()
if not times:
logger.warning(
"⚠️ Автосинхронизация включена, но расписание пустое. Укажите время запуска."
)
self._next_run = None
return
self._scheduler_task = asyncio.create_task(self._run_scheduler(times))
if run_immediately:
asyncio.create_task(self.run_sync_now(reason="immediate"))
def schedule_refresh(self, *, run_immediately: bool = False) -> None:
if not self._initialized:
self._pending_refresh = True
if run_immediately:
self._pending_run_immediately = True
return
loop = self._loop or asyncio.get_running_loop()
loop.create_task(self.refresh_schedule(run_immediately=run_immediately))
async def stop(self) -> None:
async with self._scheduler_lock:
if self._scheduler_task and not self._scheduler_task.done():
self._scheduler_task.cancel()
try:
await self._scheduler_task
except asyncio.CancelledError:
pass
self._scheduler_task = None
self._next_run = None
async def run_sync_now(self, *, reason: str = "manual") -> Dict[str, Any]:
if self._sync_lock.locked():
return {"started": False, "reason": "already_running"}
async with self._sync_lock:
self._last_run_started_at = datetime.utcnow()
self._last_run_finished_at = None
self._last_run_reason = reason
self._last_run_error = None
self._last_run_success = None
try:
user_stats, server_stats = await self._perform_sync()
except RemnaWaveConfigurationError as error:
message = str(error)
self._last_run_error = message
self._last_run_success = False
self._last_user_stats = None
self._last_server_stats = None
self._last_run_finished_at = datetime.utcnow()
logger.error("❌ Автосинхронизация RemnaWave: %s", message)
return {
"started": True,
"success": False,
"error": message,
"user_stats": None,
"server_stats": None,
}
except Exception as error:
message = str(error)
self._last_run_error = message
self._last_run_success = False
self._last_user_stats = None
self._last_server_stats = None
self._last_run_finished_at = datetime.utcnow()
logger.exception("❌ Ошибка автосинхронизации RemnaWave: %s", error)
return {
"started": True,
"success": False,
"error": message,
"user_stats": None,
"server_stats": None,
}
self._last_run_success = True
self._last_run_error = None
self._last_user_stats = user_stats
self._last_server_stats = server_stats
self._last_run_finished_at = datetime.utcnow()
return {
"started": True,
"success": True,
"error": None,
"user_stats": user_stats,
"server_stats": server_stats,
}
def get_status(self) -> RemnaWaveAutoSyncStatus:
times = settings.get_remnawave_auto_sync_times()
enabled = settings.REMNAWAVE_AUTO_SYNC_ENABLED and bool(times)
return RemnaWaveAutoSyncStatus(
enabled=enabled,
times=times,
next_run=self._next_run,
last_run_started_at=self._last_run_started_at,
last_run_finished_at=self._last_run_finished_at,
last_run_success=self._last_run_success,
last_run_reason=self._last_run_reason,
last_run_error=self._last_run_error,
last_user_stats=self._last_user_stats,
last_server_stats=self._last_server_stats,
is_running=self._sync_lock.locked(),
)
async def _run_scheduler(self, times: List[time]) -> None:
try:
while True:
next_run = self._calculate_next_run(times)
self._next_run = next_run
delay = (next_run - datetime.utcnow()).total_seconds()
if delay > 0:
await asyncio.sleep(delay)
await self.run_sync_now(reason="auto")
except asyncio.CancelledError:
raise
finally:
self._next_run = None
def _refresh_service(self) -> RemnaWaveService:
self._service = self._service_factory()
return self._service
async def _perform_sync(self) -> Tuple[Dict[str, Any], Dict[str, Any]]:
service = self._refresh_service()
if not service.is_configured:
raise RemnaWaveConfigurationError(
service.configuration_error or "RemnaWave API не настроен"
)
async with AsyncSessionLocal() as session:
user_stats = await service.sync_users_from_panel(session, "all")
server_stats = await self._sync_servers(session, service)
return user_stats, server_stats
async def _sync_servers(
self,
session: AsyncSession,
service: RemnaWaveService,
) -> Dict[str, Any]:
squads = await service.get_all_squads()
if not squads:
logger.warning("⚠️ Не удалось получить сквады из RemnaWave для автосинхронизации")
return {"created": 0, "updated": 0, "removed": 0, "total": 0}
created, updated, removed = await sync_with_remnawave(session, squads)
try:
await cache.delete_pattern("available_countries*")
except Exception as error:
logger.warning("⚠️ Не удалось очистить кеш стран после автосинхронизации: %s", error)
return {
"created": created,
"updated": updated,
"removed": removed,
"total": len(squads),
}
@staticmethod
def _calculate_next_run(times: List[time]) -> datetime:
now = datetime.utcnow()
today = now.date()
for scheduled in sorted(times):
candidate = datetime.combine(today, scheduled)
if candidate > now:
return candidate
first_time = sorted(times)[0]
next_day = today + timedelta(days=1)
return datetime.combine(next_day, first_time)
def _create_service() -> RemnaWaveAutoSyncService:
service = RemnaWaveAutoSyncService()
return service
remnawave_sync_service = _create_service()
File diff suppressed because it is too large Load Diff
+80 -15
View File
@@ -1,4 +1,5 @@
import logging
from contextlib import asynccontextmanager
from datetime import datetime, timedelta
from typing import Optional, List, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
@@ -6,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import Subscription, User, SubscriptionStatus, PromoGroup
from app.external.remnawave_api import (
RemnaWaveAPI, RemnaWaveUser, UserStatus,
RemnaWaveAPI, RemnaWaveUser, UserStatus,
TrafficLimitStrategy, RemnaWaveAPIError
)
from app.database.crud.user import get_user_by_id
@@ -75,16 +76,80 @@ def get_traffic_reset_strategy():
class SubscriptionService:
def __init__(self):
self._config_error: Optional[str] = None
self.api: Optional[RemnaWaveAPI] = None
self._last_config_signature: Optional[Tuple[str, ...]] = None
self._refresh_configuration()
def _refresh_configuration(self) -> None:
auth_params = settings.get_remnawave_auth_params()
self.api = RemnaWaveAPI(
base_url=auth_params["base_url"],
api_key=auth_params["api_key"],
secret_key=auth_params["secret_key"],
username=auth_params["username"],
password=auth_params["password"]
base_url = (auth_params.get("base_url") or "").strip()
api_key = (auth_params.get("api_key") or "").strip()
secret_key = (auth_params.get("secret_key") or "").strip() or None
username = (auth_params.get("username") or "").strip() or None
password = (auth_params.get("password") or "").strip() or None
auth_type = (auth_params.get("auth_type") or "").strip() or None
config_signature = (
base_url,
api_key,
secret_key or "",
username or "",
password or "",
auth_type or "",
)
if config_signature == self._last_config_signature:
return
if not base_url:
self._config_error = "REMNAWAVE_API_URL не настроен"
self.api = None
elif not api_key:
self._config_error = "REMNAWAVE_API_KEY не настроен"
self.api = None
else:
self._config_error = None
self.api = RemnaWaveAPI(
base_url=base_url,
api_key=api_key,
secret_key=secret_key,
username=username,
password=password,
)
if self._config_error:
logger.warning(
"RemnaWave API недоступен: %s. Подписочный сервис будет работать в оффлайн-режиме.",
self._config_error
)
self._last_config_signature = config_signature
@property
def is_configured(self) -> bool:
return self._config_error is None
@property
def configuration_error(self) -> Optional[str]:
return self._config_error
def _ensure_configured(self) -> None:
self._refresh_configuration()
if not self.api or not self.is_configured:
raise RemnaWaveAPIError(
self._config_error or "RemnaWave API не настроен"
)
@asynccontextmanager
async def get_api_client(self):
self._ensure_configured()
assert self.api is not None
async with self.api as api:
yield api
async def create_remnawave_user(
self,
@@ -106,7 +171,7 @@ class SubscriptionService:
logger.error(f"Ошибка валидации подписки для пользователя {user.telegram_id}")
return None
async with self.api as api:
async with self.get_api_client() as api:
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
if existing_users:
logger.info(f"🔄 Найден существующий пользователь в панели для {user.telegram_id}")
@@ -216,7 +281,7 @@ class SubscriptionService:
is_actually_active = False
logger.info(f"🔔 Статус подписки {subscription.id} автоматически изменен на 'expired'")
async with self.api as api:
async with self.get_api_client() as api:
updated_user = await api.update_user(
uuid=user.remnawave_uuid,
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
@@ -281,7 +346,7 @@ class SubscriptionService:
async def disable_remnawave_user(self, user_uuid: str) -> bool:
try:
async with self.api as api:
async with self.get_api_client() as api:
await api.disable_user(user_uuid)
logger.info(f"✅ Отключен RemnaWave пользователь {user_uuid}")
return True
@@ -301,7 +366,7 @@ class SubscriptionService:
if not user or not user.remnawave_uuid:
return None
async with self.api as api:
async with self.get_api_client() as api:
updated_user = await api.revoke_user_subscription(user.remnawave_uuid)
subscription.remnawave_short_uuid = updated_user.short_uuid
@@ -319,7 +384,7 @@ class SubscriptionService:
async def get_subscription_info(self, short_uuid: str) -> Optional[dict]:
try:
async with self.api as api:
async with self.get_api_client() as api:
info = await api.get_subscription_info(short_uuid)
return info
@@ -338,7 +403,7 @@ class SubscriptionService:
if not user or not user.remnawave_uuid:
return False
async with self.api as api:
async with self.get_api_client() as api:
remnawave_user = await api.get_user_by_uuid(user.remnawave_uuid)
if not remnawave_user:
return False
@@ -585,7 +650,7 @@ class SubscriptionService:
if user.remnawave_uuid:
try:
async with self.api as api:
async with self.get_api_client() as api:
remnawave_user = await api.get_user_by_uuid(user.remnawave_uuid)
if not remnawave_user:
+419 -77
View File
@@ -9,7 +9,7 @@ from app.database.universal_migration import ensure_default_web_api_token
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import Settings, settings
from app.config import Settings, settings, refresh_period_prices, refresh_traffic_prices
from app.database.crud.system_setting import (
delete_system_setting,
upsert_system_setting,
@@ -56,113 +56,200 @@ class ChoiceOption:
description: Optional[str] = None
class ReadOnlySettingError(RuntimeError):
"""Исключение, выбрасываемое при попытке изменить настройку только для чтения."""
class BotConfigurationService:
EXCLUDED_KEYS: set[str] = {"BOT_TOKEN", "ADMIN_IDS"}
READ_ONLY_KEYS: set[str] = {"EXTERNAL_ADMIN_TOKEN", "EXTERNAL_ADMIN_TOKEN_BOT_ID"}
PLAIN_TEXT_KEYS: set[str] = {"EXTERNAL_ADMIN_TOKEN", "EXTERNAL_ADMIN_TOKEN_BOT_ID"}
CATEGORY_TITLES: Dict[str, str] = {
"SUPPORT": "💬 Ссылка на поддержку",
"LOCALIZATION": "🌍 Языковые настройки",
"MAINTENANCE": "🛠️ Режим обслуживания",
"CORE": "🤖 Основные настройки",
"SUPPORT": "💬 Поддержка и тикеты",
"LOCALIZATION": "🌍 Языки интерфейса",
"CHANNEL": "📣 Обязательная подписка",
"ADMIN_NOTIFICATIONS": "🔔 Уведомления админам",
"ADMIN_REPORTS": "📊 Автоматические отчеты",
"TRIAL": "🎁 Триальная подписка",
"PAID_SUBSCRIPTION": "💰 Платные подписки",
"PERIODS": "📅 Периоды подписки",
"SUBSCRIPTION_PRICES": "💵 Цены за периоды",
"TRAFFIC": "🚦 Настройки трафика",
"TRAFFIC_PACKAGES": "📦 Пакеты трафика",
"DISCOUNTS": "🎯 Промо и скидки",
"PAYMENT": "⚙️ Общие настройки платежей",
"PAYMENT": "💳 Общие платежные настройки",
"TELEGRAM": "⭐ Telegram Stars",
"CRYPTOBOT": "💎 CryptoBot",
"YOOKASSA": "💸 YooKassa",
"CRYPTOBOT": "🪙 CryptoBot",
"YOOKASSA": "🟣 YooKassa",
"TRIBUTE": "🎁 Tribute",
"MULENPAY": "💰 MulenPay",
"PAL24": "🏦 Pal24/PayPalych",
"REMNAWAVE": "🔗 RemnaWave API",
"REFERRAL": "🤝 Реферальная система",
"PAL24": "🏦 PAL24 / PayPalych",
"WATA": "💠 Wata",
"EXTERNAL_ADMIN": "🛡️ Внешняя админка",
"SUBSCRIPTIONS_CORE": "📅 Подписки и лимиты",
"PERIODS": "📆 Периоды подписок",
"SUBSCRIPTION_PRICES": "💵 Стоимость тарифов",
"TRAFFIC": "📊 Трафик",
"TRAFFIC_PACKAGES": "📦 Пакеты трафика",
"TRIAL": "🎁 Пробный период",
"REFERRAL": "👥 Реферальная программа",
"AUTOPAY": "🔄 Автопродление",
"INTERFACE_BRANDING": "🖼️ Визуальные настройки",
"INTERFACE_SUBSCRIPTION": "🔗 Скрыть ссылку подписки",
"CONNECT_BUTTON": "🚀 Кнопка «Подключиться»",
"HAPP": "🅷 Happ настройки",
"SKIP": "⚡ Быстрый старт",
"ADDITIONAL": "📱 Приложения и DeepLinks",
"MINIAPP": "📱 Mini App",
"DATABASE": "🗄️ Режим БД",
"POSTGRES": "🐘 PostgreSQL",
"SQLITE": "💾 SQLite",
"REDIS": "🧠 Redis",
"MONITORING": "📈 Общий мониторинг",
"NOTIFICATIONS": "🔔 Уведомления пользователям",
"SERVER": "🖥️ Статус серверов",
"BACKUP": "💾 Система бэкапов",
"VERSION": "🔄 Обновления",
"LOG": "📝 Логирование",
"ADMIN_NOTIFICATIONS": "📣 Оповещения администраторам",
"ADMIN_REPORTS": "🗂 Автоматические отчеты",
"INTERFACE": "🎨 Интерфейс и брендинг",
"INTERFACE_BRANDING": "🖼️ Брендинг",
"INTERFACE_SUBSCRIPTION": "🔗 Ссылка на подписку",
"CONNECT_BUTTON": "🚀 Кнопка подключения",
"MINIAPP": "📱 Mini App",
"HAPP": "🅷 Happ",
"SKIP": "⚡ Быстрый старт",
"ADDITIONAL": "📱 Дополнительные приложения",
"DATABASE": "💾 База данных",
"POSTGRES": "🐘 PostgreSQL",
"SQLITE": "🧱 SQLite",
"REDIS": "🧠 Redis",
"REMNAWAVE": "🌐 RemnaWave API",
"SERVER_STATUS": "📊 Статус серверов",
"MONITORING": "📈 Мониторинг",
"MAINTENANCE": "🔧 Обслуживание",
"BACKUP": "💾 Резервные копии",
"VERSION": "🔄 Проверка версий",
"WEB_API": "⚡ Web API",
"WEBHOOK": "🌐 Webhook",
"WEB_API": "🌐 Web API",
"DEBUG": "🔧 Режим разработки",
"LOG": "📝 Логирование",
"DEBUG": "🧪 Режим разработки",
"MODERATION": "🛡️ Модерация и фильтры",
}
CATEGORY_DESCRIPTIONS: Dict[str, str] = {
"CORE": "Базовые параметры работы бота и обязательные ссылки.",
"SUPPORT": "Контакты поддержки, SLA и режимы обработки обращений.",
"LOCALIZATION": "Доступные языки, локализация интерфейса и выбор языка.",
"CHANNEL": "Настройки обязательной подписки на канал или группу.",
"PAYMENT": "Общие тексты платежей, описания чеков и шаблоны.",
"YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.",
"CRYPTOBOT": "CryptoBot и криптоплатежи через Telegram.",
"MULENPAY": "Платежи MulenPay и параметры магазина.",
"PAL24": "PAL24 / PayPalych подключения и лимиты.",
"TRIBUTE": "Tribute и донат-сервисы.",
"TELEGRAM": "Telegram Stars и их стоимость.",
"WATA": "Wata: токен доступа, тип платежа и пределы сумм.",
"EXTERNAL_ADMIN": "Токен внешней админки для проверки запросов.",
"SUBSCRIPTIONS_CORE": "Лимиты устройств, трафика и базовые цены подписок.",
"PERIODS": "Доступные периоды подписок и продлений.",
"SUBSCRIPTION_PRICES": "Стоимость подписок по периодам в копейках.",
"TRAFFIC": "Лимиты трафика и стратегии сброса.",
"TRAFFIC_PACKAGES": "Цены пакетов трафика и конфигурация предложений.",
"TRIAL": "Длительность и ограничения пробного периода.",
"REFERRAL": "Бонусы и пороги реферальной программы.",
"AUTOPAY": "Настройки автопродления и минимальный баланс.",
"NOTIFICATIONS": "Пользовательские уведомления и кэширование сообщений.",
"ADMIN_NOTIFICATIONS": "Оповещения админам о событиях и тикетах.",
"ADMIN_REPORTS": "Автоматические отчеты для команды.",
"INTERFACE": "Глобальные параметры интерфейса и брендирования.",
"INTERFACE_BRANDING": "Логотип и фирменный стиль.",
"INTERFACE_SUBSCRIPTION": "Отображение ссылок и кнопок подписок.",
"CONNECT_BUTTON": "Поведение кнопки «Подключиться» и miniapp.",
"MINIAPP": "Mini App и кастомные ссылки.",
"HAPP": "Интеграция Happ и связанные ссылки.",
"SKIP": "Настройки быстрого старта и гайд по подключению.",
"ADDITIONAL": "Конфигурация app-config.json, deep links и кеша.",
"DATABASE": "Режим работы базы данных и пути до файлов.",
"POSTGRES": "Параметры подключения к PostgreSQL.",
"SQLITE": "Файл SQLite и резервные параметры.",
"REDIS": "Подключение к Redis для кэша.",
"REMNAWAVE": "Параметры авторизации и интеграция с RemnaWave API.",
"SERVER_STATUS": "Отображение статуса серверов и external URL.",
"MONITORING": "Интервалы мониторинга и хранение логов.",
"MAINTENANCE": "Режим обслуживания, сообщения и интервалы.",
"BACKUP": "Резервное копирование и расписание.",
"VERSION": "Отслеживание обновлений репозитория.",
"WEB_API": "Web API, токены и права доступа.",
"WEBHOOK": "Пути и секреты вебхуков.",
"LOG": "Уровни логирования и ротация.",
"DEBUG": "Отладочные функции и безопасный режим.",
"MODERATION": "Настройки фильтров отображаемых имен и защиты от фишинга.",
}
CATEGORY_KEY_OVERRIDES: Dict[str, str] = {
"DATABASE_URL": "DATABASE",
"DATABASE_MODE": "DATABASE",
"LOCALES_PATH": "DATABASE",
"DEFAULT_DEVICE_LIMIT": "PAID_SUBSCRIPTION",
"DEFAULT_TRAFFIC_LIMIT_GB": "PAID_SUBSCRIPTION",
"MAX_DEVICES_LIMIT": "PAID_SUBSCRIPTION",
"PRICE_PER_DEVICE": "PAID_SUBSCRIPTION",
"LOCALES_PATH": "LOCALIZATION",
"CHANNEL_SUB_ID": "CHANNEL",
"CHANNEL_LINK": "CHANNEL",
"CHANNEL_IS_REQUIRED_SUB": "CHANNEL",
"BOT_USERNAME": "CORE",
"DEFAULT_LANGUAGE": "LOCALIZATION",
"AVAILABLE_LANGUAGES": "LOCALIZATION",
"LANGUAGE_SELECTION_ENABLED": "LOCALIZATION",
"DEFAULT_DEVICE_LIMIT": "SUBSCRIPTIONS_CORE",
"DEFAULT_TRAFFIC_LIMIT_GB": "SUBSCRIPTIONS_CORE",
"MAX_DEVICES_LIMIT": "SUBSCRIPTIONS_CORE",
"PRICE_PER_DEVICE": "SUBSCRIPTIONS_CORE",
"BASE_SUBSCRIPTION_PRICE": "SUBSCRIPTIONS_CORE",
"DEFAULT_TRAFFIC_RESET_STRATEGY": "TRAFFIC",
"RESET_TRAFFIC_ON_PAYMENT": "TRAFFIC",
"TRAFFIC_SELECTION_MODE": "TRAFFIC",
"FIXED_TRAFFIC_LIMIT_GB": "TRAFFIC",
"AVAILABLE_SUBSCRIPTION_PERIODS": "PERIODS",
"AVAILABLE_RENEWAL_PERIODS": "PERIODS",
"BASE_SUBSCRIPTION_PRICE": "SUBSCRIPTION_PRICES",
"PRICE_14_DAYS": "SUBSCRIPTION_PRICES",
"PRICE_30_DAYS": "SUBSCRIPTION_PRICES",
"PRICE_60_DAYS": "SUBSCRIPTION_PRICES",
"PRICE_90_DAYS": "SUBSCRIPTION_PRICES",
"PRICE_180_DAYS": "SUBSCRIPTION_PRICES",
"PRICE_360_DAYS": "SUBSCRIPTION_PRICES",
"PRICE_TRAFFIC_5GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_10GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_25GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_50GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_100GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_250GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_500GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_1000GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_UNLIMITED": "TRAFFIC_PACKAGES",
"TRAFFIC_PACKAGES_CONFIG": "TRAFFIC_PACKAGES",
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": "DISCOUNTS",
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS": "DISCOUNTS",
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": "SUBSCRIPTIONS_CORE",
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS": "SUBSCRIPTIONS_CORE",
"REFERRED_USER_REWARD": "REFERRAL",
"DEFAULT_AUTOPAY_ENABLED": "AUTOPAY",
"DEFAULT_AUTOPAY_DAYS_BEFORE": "AUTOPAY",
"MIN_BALANCE_FOR_AUTOPAY_KOPEKS": "AUTOPAY",
"TRIAL_WARNING_HOURS": "NOTIFICATIONS",
"ENABLE_NOTIFICATIONS": "NOTIFICATIONS",
"NOTIFICATION_RETRY_ATTEMPTS": "NOTIFICATIONS",
"NOTIFICATION_CACHE_HOURS": "NOTIFICATIONS",
"MONITORING_LOGS_RETENTION_DAYS": "MONITORING",
"ENABLE_LOGO_MODE": "INTERFACE_BRANDING",
"LOGO_FILE": "INTERFACE_BRANDING",
"HIDE_SUBSCRIPTION_LINK": "INTERFACE_SUBSCRIPTION",
"CONNECT_BUTTON_MODE": "CONNECT_BUTTON",
"MINIAPP_CUSTOM_URL": "CONNECT_BUTTON",
"APP_CONFIG_PATH": "ADDITIONAL",
"ENABLE_DEEP_LINKS": "ADDITIONAL",
"APP_CONFIG_CACHE_TTL": "ADDITIONAL",
"DEFAULT_LANGUAGE": "LOCALIZATION",
"AVAILABLE_LANGUAGES": "LOCALIZATION",
"TRIAL_WARNING_HOURS": "TRIAL",
"SUPPORT_USERNAME": "SUPPORT",
"SUPPORT_MENU_ENABLED": "SUPPORT",
"SUPPORT_SYSTEM_MODE": "SUPPORT",
"SUPPORT_TICKET_SLA_ENABLED": "SUPPORT",
"SUPPORT_TICKET_SLA_MINUTES": "SUPPORT",
"SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS": "SUPPORT",
"SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES": "SUPPORT",
"ADMIN_NOTIFICATIONS_ENABLED": "ADMIN_NOTIFICATIONS",
"ADMIN_NOTIFICATIONS_CHAT_ID": "ADMIN_NOTIFICATIONS",
"ADMIN_NOTIFICATIONS_TOPIC_ID": "ADMIN_NOTIFICATIONS",
"ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID": "ADMIN_NOTIFICATIONS",
"ADMIN_REPORTS_ENABLED": "ADMIN_REPORTS",
"ADMIN_REPORTS_CHAT_ID": "ADMIN_REPORTS",
"ADMIN_REPORTS_TOPIC_ID": "ADMIN_REPORTS",
"ADMIN_REPORTS_SEND_TIME": "ADMIN_REPORTS",
"PAYMENT_SERVICE_NAME": "PAYMENT",
"PAYMENT_BALANCE_DESCRIPTION": "PAYMENT",
"PAYMENT_SUBSCRIPTION_DESCRIPTION": "PAYMENT",
"PAYMENT_BALANCE_TEMPLATE": "PAYMENT",
"PAYMENT_SUBSCRIPTION_TEMPLATE": "PAYMENT",
"INACTIVE_USER_DELETE_MONTHS": "MONITORING",
"LANGUAGE_SELECTION_ENABLED": "LOCALIZATION",
"DISABLE_TOPUP_BUTTONS": "PAYMENT",
"ENABLE_NOTIFICATIONS": "NOTIFICATIONS",
"NOTIFICATION_RETRY_ATTEMPTS": "NOTIFICATIONS",
"NOTIFICATION_CACHE_HOURS": "NOTIFICATIONS",
"MONITORING_LOGS_RETENTION_DAYS": "MONITORING",
"MONITORING_INTERVAL": "MONITORING",
"ENABLE_LOGO_MODE": "INTERFACE_BRANDING",
"LOGO_FILE": "INTERFACE_BRANDING",
"HIDE_SUBSCRIPTION_LINK": "INTERFACE_SUBSCRIPTION",
"MAIN_MENU_MODE": "INTERFACE",
"CONNECT_BUTTON_MODE": "CONNECT_BUTTON",
"MINIAPP_CUSTOM_URL": "CONNECT_BUTTON",
"APP_CONFIG_PATH": "ADDITIONAL",
"ENABLE_DEEP_LINKS": "ADDITIONAL",
"APP_CONFIG_CACHE_TTL": "ADDITIONAL",
"INACTIVE_USER_DELETE_MONTHS": "MAINTENANCE",
"MAINTENANCE_MESSAGE": "MAINTENANCE",
"MAINTENANCE_CHECK_INTERVAL": "MAINTENANCE",
"MAINTENANCE_AUTO_ENABLE": "MAINTENANCE",
"WEBHOOK_URL": "WEBHOOK",
"WEBHOOK_SECRET": "WEBHOOK",
"VERSION_CHECK_ENABLED": "VERSION",
"VERSION_CHECK_REPO": "VERSION",
"VERSION_CHECK_INTERVAL_HOURS": "VERSION",
"TELEGRAM_STARS_RATE_RUB": "TELEGRAM",
"REMNAWAVE_USER_DESCRIPTION_TEMPLATE": "REMNAWAVE",
"REMNAWAVE_AUTO_SYNC_ENABLED": "REMNAWAVE",
"REMNAWAVE_AUTO_SYNC_TIMES": "REMNAWAVE",
}
CATEGORY_PREFIX_OVERRIDES: Dict[str, str] = {
@@ -187,13 +274,15 @@ class BotConfigurationService:
"MULENPAY_": "MULENPAY",
"PAL24_": "PAL24",
"PAYMENT_": "PAYMENT",
"WATA_": "WATA",
"EXTERNAL_ADMIN_": "EXTERNAL_ADMIN",
"CONNECT_BUTTON_HAPP": "HAPP",
"HAPP_": "HAPP",
"SKIP_": "SKIP",
"MINIAPP_": "MINIAPP",
"MONITORING_": "MONITORING",
"NOTIFICATION_": "NOTIFICATIONS",
"SERVER_STATUS": "SERVER",
"SERVER_STATUS": "SERVER_STATUS",
"MAINTENANCE_": "MAINTENANCE",
"VERSION_CHECK": "VERSION",
"BACKUP_": "BACKUP",
@@ -201,6 +290,7 @@ class BotConfigurationService:
"LOG_": "LOG",
"WEB_API_": "WEB_API",
"DEBUG": "DEBUG",
"DISPLAY_NAME_": "MODERATION",
}
CHOICES: Dict[str, List[ChoiceOption]] = {
@@ -239,6 +329,10 @@ class BotConfigurationService:
ChoiceOption("link", "🔗 Прямая ссылка"),
ChoiceOption("happ_cryptolink", "🪙 Happ CryptoLink"),
],
"MAIN_MENU_MODE": [
ChoiceOption("default", "📋 Полное меню"),
ChoiceOption("text", "📝 Текстовое меню"),
],
"SERVER_STATUS_MODE": [
ChoiceOption("disabled", "🚫 Отключено"),
ChoiceOption("external_link", "🌐 Внешняя ссылка"),
@@ -290,6 +384,194 @@ class BotConfigurationService:
],
}
SETTING_HINTS: Dict[str, Dict[str, str]] = {
"YOOKASSA_ENABLED": {
"description": "Включает оплату через YooKassa. Требует корректных идентификаторов магазина и секретного ключа.",
"format": "Булево значение: выберите \"Включить\" или \"Выключить\".",
"example": "Включено при полностью настроенной интеграции.",
"warning": "При включении без Shop ID и Secret Key пользователи увидят ошибки при оплате.",
"dependencies": "YOOKASSA_SHOP_ID, YOOKASSA_SECRET_KEY, YOOKASSA_RETURN_URL",
},
"CRYPTOBOT_ENABLED": {
"description": "Разрешает принимать криптоплатежи через CryptoBot.",
"format": "Булево значение.",
"example": "Включите после указания токена API и секрета вебхука.",
"warning": "Пустой токен или неверный вебхук приведут к отказам платежей.",
"dependencies": "CRYPTOBOT_API_TOKEN, CRYPTOBOT_WEBHOOK_SECRET",
},
"SUPPORT_TICKET_SLA_MINUTES": {
"description": "Лимит времени для ответа модераторов на тикет в минутах.",
"format": "Целое число от 1 до 1440.",
"example": "5",
"warning": "Слишком низкое значение может вызвать частые напоминания, слишком высокое — ухудшить SLA.",
"dependencies": "SUPPORT_TICKET_SLA_ENABLED, SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES",
},
"MAINTENANCE_MODE": {
"description": "Переводит бота в режим технического обслуживания и скрывает действия для пользователей.",
"format": "Булево значение.",
"example": "Включено на время плановых работ.",
"warning": "Не забудьте отключить после завершения работ, иначе бот останется недоступен.",
"dependencies": "MAINTENANCE_MESSAGE, MAINTENANCE_CHECK_INTERVAL",
},
"DISPLAY_NAME_BANNED_KEYWORDS": {
"description": "Список слов и фрагментов, при наличии которых в отображаемом имени пользователь будет заблокирован.",
"format": "Перечислите ключевые слова через запятую или с новой строки.",
"example": "support, security, служебн",
"warning": "Слишком агрессивные фильтры могут блокировать добросовестных пользователей.",
"dependencies": "Фильтр отображаемых имен",
},
"REMNAWAVE_API_URL": {
"description": "Базовый адрес панели RemnaWave, с которой синхронизируется бот.",
"format": "Полный URL вида https://panel.example.com.",
"example": "https://panel.remnawave.net",
"warning": "Недоступный адрес приведет к ошибкам при управлении VPN-учетками.",
"dependencies": "REMNAWAVE_API_KEY или REMNAWAVE_USERNAME/REMNAWAVE_PASSWORD",
},
"REMNAWAVE_AUTO_SYNC_ENABLED": {
"description": "Автоматически запускает синхронизацию пользователей и серверов с панелью RemnaWave.",
"format": "Булево значение.",
"example": "Включено при корректно настроенных API-ключах.",
"warning": "При включении без расписания синхронизация не будет выполнена.",
"dependencies": "REMNAWAVE_AUTO_SYNC_TIMES",
},
"REMNAWAVE_AUTO_SYNC_TIMES": {
"description": "Список времени в формате HH:MM, когда запускается автосинхронизация в течение суток.",
"format": "Перечислите время через запятую или с новой строки (например, 03:00, 15:00).",
"example": "03:00, 15:00",
"warning": "Минимальный интервал между запусками не ограничен, но слишком частые синхронизации нагружают панель.",
"dependencies": "REMNAWAVE_AUTO_SYNC_ENABLED",
},
"EXTERNAL_ADMIN_TOKEN": {
"description": "Приватный токен, который использует внешняя админка для проверки запросов.",
"format": "Значение генерируется автоматически из username бота и его токена и доступно только для чтения.",
"example": "Генерируется автоматически",
"warning": "Токен обновится при смене username или токена бота.",
"dependencies": "Username телеграм-бота, токен бота",
},
"EXTERNAL_ADMIN_TOKEN_BOT_ID": {
"description": "Идентификатор телеграм-бота, с которым связан токен внешней админки.",
"format": "Проставляется автоматически после первого запуска и не редактируется вручную.",
"example": "123456789",
"warning": "Несовпадение ID блокирует обновление токена, предотвращая его подмену на другом боте.",
"dependencies": "Результат вызова getMe() в Telegram Bot API",
},
}
@classmethod
def get_category_description(cls, category_key: str) -> str:
return cls.CATEGORY_DESCRIPTIONS.get(category_key, "")
@classmethod
def is_toggle(cls, key: str) -> bool:
definition = cls.get_definition(key)
return definition.python_type is bool
@classmethod
def is_read_only(cls, key: str) -> bool:
return key in cls.READ_ONLY_KEYS
@classmethod
def _format_numeric_with_unit(cls, key: str, value: Union[int, float]) -> Optional[str]:
if isinstance(value, bool):
return None
upper_key = key.upper()
if any(suffix in upper_key for suffix in ("PRICE", "_KOPEKS", "AMOUNT")):
try:
return settings.format_price(int(value))
except Exception:
return f"{value}"
if upper_key.endswith("_PERCENT") or "PERCENT" in upper_key:
return f"{value}%"
if upper_key.endswith("_HOURS"):
return f"{value} ч"
if upper_key.endswith("_MINUTES"):
return f"{value} мин"
if upper_key.endswith("_SECONDS"):
return f"{value} сек"
if upper_key.endswith("_DAYS"):
return f"{value} дн"
if upper_key.endswith("_GB"):
return f"{value} ГБ"
if upper_key.endswith("_MB"):
return f"{value} МБ"
return None
@classmethod
def _split_comma_values(cls, text: str) -> Optional[List[str]]:
raw = (text or "").strip()
if not raw or "," not in raw:
return None
parts = [segment.strip() for segment in raw.split(",") if segment.strip()]
return parts or None
@classmethod
def format_value_human(cls, key: str, value: Any) -> str:
if value is None:
return ""
if isinstance(value, bool):
return "✅ ВКЛЮЧЕНО" if value else "❌ ВЫКЛЮЧЕНО"
if isinstance(value, (int, float)):
formatted = cls._format_numeric_with_unit(key, value)
return formatted or str(value)
if isinstance(value, str):
cleaned = value.strip()
if not cleaned:
return ""
if key in cls.PLAIN_TEXT_KEYS:
return cleaned
if any(keyword in key.upper() for keyword in ("TOKEN", "SECRET", "PASSWORD", "KEY")):
return "••••••••"
items = cls._split_comma_values(cleaned)
if items:
return ", ".join(items)
return cleaned
if isinstance(value, (list, tuple, set)):
return ", ".join(str(item) for item in value)
if isinstance(value, dict):
try:
return json.dumps(value, ensure_ascii=False)
except Exception:
return str(value)
return str(value)
@classmethod
def get_setting_guidance(cls, key: str) -> Dict[str, str]:
definition = cls.get_definition(key)
original = cls.get_original_value(key)
type_label = definition.type_label
hints = dict(cls.SETTING_HINTS.get(key, {}))
base_description = (
hints.get("description")
or f"Параметр <b>{definition.display_name}</b> управляет категорией «{definition.category_label}»."
)
base_format = hints.get("format") or (
"Булево значение (да/нет)." if definition.python_type is bool
else "Введите значение соответствующего типа (число или строку)."
)
example = hints.get("example") or (
cls.format_value_human(key, original) if original is not None else ""
)
warning = hints.get("warning") or (
"Неверные значения могут привести к некорректной работе бота."
)
dependencies = hints.get("dependencies") or definition.category_label
return {
"description": base_description,
"format": base_format,
"example": example,
"warning": warning,
"dependencies": dependencies,
"type": type_label,
}
_definitions: Dict[str, SettingDefinition] = {}
_original_values: Dict[str, Any] = settings.model_dump()
_overrides_raw: Dict[str, Optional[str]] = {}
@@ -443,7 +725,7 @@ class BotConfigurationService:
@classmethod
def format_value_for_list(cls, key: str) -> str:
value = cls.get_current_value(key)
formatted = cls.format_value(value)
formatted = cls.format_value_human(key, value)
if formatted == "":
return formatted
return _truncate(formatted)
@@ -645,7 +927,17 @@ class BotConfigurationService:
return parsed_value
@classmethod
async def set_value(cls, db: AsyncSession, key: str, value: Any) -> None:
async def set_value(
cls,
db: AsyncSession,
key: str,
value: Any,
*,
force: bool = False,
) -> None:
if cls.is_read_only(key) and not force:
raise ReadOnlySettingError(f"Setting {key} is read-only")
raw_value = cls.serialize_value(key, value)
await upsert_system_setting(db, key, raw_value)
cls._overrides_raw[key] = raw_value
@@ -655,7 +947,16 @@ class BotConfigurationService:
await cls._sync_default_web_api_token()
@classmethod
async def reset_value(cls, db: AsyncSession, key: str) -> None:
async def reset_value(
cls,
db: AsyncSession,
key: str,
*,
force: bool = False,
) -> None:
if cls.is_read_only(key) and not force:
raise ReadOnlySettingError(f"Setting {key} is read-only")
await delete_system_setting(db, key)
cls._overrides_raw.pop(key, None)
original = cls.get_original_value(key)
@@ -668,6 +969,46 @@ class BotConfigurationService:
def _apply_to_settings(cls, key: str, value: Any) -> None:
try:
setattr(settings, key, value)
if key in {
"PRICE_14_DAYS",
"PRICE_30_DAYS",
"PRICE_60_DAYS",
"PRICE_90_DAYS",
"PRICE_180_DAYS",
"PRICE_360_DAYS",
}:
refresh_period_prices()
elif key.startswith("PRICE_TRAFFIC_") or key == "TRAFFIC_PACKAGES_CONFIG":
refresh_traffic_prices()
elif key in {"REMNAWAVE_AUTO_SYNC_ENABLED", "REMNAWAVE_AUTO_SYNC_TIMES"}:
try:
from app.services.remnawave_sync_service import remnawave_sync_service
remnawave_sync_service.schedule_refresh(
run_immediately=(key == "REMNAWAVE_AUTO_SYNC_ENABLED" and bool(value))
)
except Exception as error:
logger.error(
"Не удалось обновить сервис автосинхронизации RemnaWave: %s",
error,
)
elif key in {
"REMNAWAVE_API_URL",
"REMNAWAVE_API_KEY",
"REMNAWAVE_SECRET_KEY",
"REMNAWAVE_USERNAME",
"REMNAWAVE_PASSWORD",
"REMNAWAVE_AUTH_TYPE",
}:
try:
from app.services.remnawave_sync_service import remnawave_sync_service
remnawave_sync_service.refresh_configuration()
except Exception as error:
logger.error(
"Не удалось обновить конфигурацию сервиса автосинхронизации RemnaWave: %s",
error,
)
except Exception as error:
logger.error("Не удалось применить значение %s=%s: %s", key, value, error)
@@ -693,12 +1034,13 @@ class BotConfigurationService:
return {
"key": key,
"name": definition.display_name,
"current": cls.format_value(current),
"original": cls.format_value(original),
"current": cls.format_value_human(key, current),
"original": cls.format_value_human(key, original),
"type": definition.type_label,
"category_key": definition.category_key,
"category_label": definition.category_label,
"has_override": has_override,
"is_read_only": cls.is_read_only(key),
}
+73 -14
View File
@@ -11,9 +11,10 @@ from app.database.models import Transaction, TransactionType, PaymentMethod
from app.database.crud.transaction import (
create_transaction, get_transaction_by_external_id, complete_transaction
)
from app.database.crud.user import get_user_by_telegram_id, add_user_balance
from app.database.crud.user import get_user_by_telegram_id
from app.external.tribute import TributeService as TributeAPI
from app.services.payment_service import PaymentService
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
@@ -120,32 +121,53 @@ class TributeService:
amount_kopeks=amount_kopeks,
description=f"Пополнение через Tribute: {amount_kopeks/100}₽ (ID: {payment_id})"
)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = getattr(user, "promo_group", None)
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
await session.commit()
logger.info(f"✅ Баланс пользователя {user_telegram_id} обновлен: {old_balance} -> {user.balance_kopeks} коп (+{amount_kopeks})")
logger.info(f"✅ Создана транзакция ID: {transaction.id}")
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(session, user.id, amount_kopeks, self.bot)
except Exception as e:
logger.error(f"Ошибка обработки реферального пополнения Tribute: {e}")
if not user.has_made_first_topup:
if was_first_topup and not user.has_made_first_topup:
user.has_made_first_topup = True
await session.commit()
await session.refresh(user)
logger.info(
f"✅ Баланс пользователя {user_telegram_id} обновлен: {old_balance} -> {user.balance_kopeks} коп (+{amount_kopeks})"
)
logger.info(f"✅ Создана транзакция ID: {transaction.id}")
if was_first_topup:
logger.info(f"Отмечен первый топап для пользователя {user_telegram_id}")
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
session, user, transaction, old_balance
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=session,
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о Tribute пополнении: {e}")
@@ -213,6 +235,7 @@ class TributeService:
except Exception as e:
logger.error(f"Ошибка обработки возврата Tribute: {e}")
async def _send_success_notification(self, user_id: int, amount_kopeks: int):
try:
@@ -222,6 +245,7 @@ class TributeService:
user = await get_user_by_telegram_id(session, user_id)
break
# Сначала отправляем стандартное уведомление
payment_service = PaymentService(self.bot)
keyboard = await payment_service.build_topup_success_keyboard(user)
@@ -240,10 +264,45 @@ class TributeService:
parse_mode="Markdown"
)
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
from app.services.user_cart_service import user_cart_service
has_saved_cart = await user_cart_service.has_user_cart(user.id)
if has_saved_cart and self.bot:
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
from app.localization.texts import get_texts
from aiogram import types
texts = get_texts(user.language)
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
total_amount=settings.format_price(amount_kopeks)
)
# Создаем клавиатуру с кнопками
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout"
)],
[types.InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance"
)],
[types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu"
)]
])
await self.bot.send_message(
chat_id=user_id,
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n{cart_message}",
reply_markup=keyboard
)
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user_id}")
except Exception as e:
logger.error(f"Ошибка отправки уведомления об успешном платеже: {e}")
async def _send_failure_notification(self, user_id: int):
try:

Some files were not shown because too many files have changed in this diff Show More