Compare commits

...

1561 Commits

Author SHA1 Message Date
Egor 813193102c Update Dockerfile 2025-11-10 06:28:50 +03:00
Egor 42112d6c8a Update docker-registry.yml 2025-11-10 06:28:40 +03:00
Egor dfe5e36fe0 Update docker-hub.yml 2025-11-10 06:28:31 +03:00
Egor b5d4a54e97 Update README.md 2025-11-10 06:22:26 +03:00
Egor 1eaf7204ce Merge pull request #1859 from Fr1ngg/dev4
fix #1849 Round CryptoBot top-up amounts up to whole rubles
2025-11-10 06:13:51 +03:00
Egor 8cc37dc439 Merge pull request #1858 from Fr1ngg/bedolaga/fix-webhook-handler-error-in-cryptobot-i1csym
Round CryptoBot top-up amounts up to whole rubles
2025-11-10 06:11:42 +03:00
Egor ccafb18122 Round CryptoBot top-up amounts up to whole rubles 2025-11-10 06:11:18 +03:00
Egor 654b132ae6 Merge pull request #1857 from Fr1ngg/revert-1855-bedolaga/fix-webhook-handler-error-in-cryptobot
Revert "Refactor CryptoBot notifications to avoid transaction rollback"
2025-11-10 06:11:05 +03:00
Egor 815fa0560c Revert "Refactor CryptoBot notifications to avoid transaction rollback" 2025-11-10 06:10:54 +03:00
Egor c4d19810a4 Merge pull request #1856 from Fr1ngg/dev4
fix
2025-11-10 06:05:55 +03:00
Egor 9fbb32eb5b Merge pull request #1855 from Fr1ngg/bedolaga/fix-webhook-handler-error-in-cryptobot
Refactor CryptoBot notifications to avoid transaction rollback
2025-11-10 05:59:09 +03:00
Egor 3ce3cde859 Refactor CryptoBot notifications to avoid transaction rollback 2025-11-10 05:53:26 +03:00
Egor 4e71dcd2b1 Merge pull request #1853 from Fr1ngg/dev4
Update Pal24 callback handling and SBP link fallback
2025-11-10 05:33:11 +03:00
Egor acf21fa6d2 Merge pull request #1852 from Fr1ngg/bedolaga/update-requirements.txt-and-fix-pal24-webhook
Update Pal24 callback handling and SBP link fallback
2025-11-10 05:29:04 +03:00
Egor f2b724a78d Adjust Pal24 callback handling and SBP link fallback 2025-11-10 05:26:02 +03:00
Egor 03473360c1 Merge pull request #1851 from Fr1ngg/dev4
Dev4
2025-11-10 04:30:16 +03:00
Egor 27dc68be41 Merge pull request #1850 from Fr1ngg/bedolaga/fix-webhook-bot-error-handling
Prevent maintenance monitoring from crashing without RemnaWave URL
2025-11-10 04:26:35 +03:00
Egor b15d78f8af Handle missing RemnaWave config in maintenance monitoring 2025-11-10 04:25:52 +03:00
Egor d232d14937 Merge pull request #1848 from Fr1ngg/dev4
fix migration on new bases
2025-11-10 04:13:19 +03:00
Egor 927d469db2 Merge pull request #1847 from Fr1ngg/bedolaga/fix-database-migration-crash-on-first-bot-launch
Handle missing tables when creating indexes during init_db
2025-11-10 04:06:46 +03:00
Egor e20e9e7cec Fix init_db index creation on fresh databases 2025-11-10 04:06:28 +03:00
Egor 0f097c3108 Merge pull request #1830 from Gy9vin/main
Фикс простой покупки!
2025-11-10 03:37:31 +03:00
gy9vin eaa3c80d59 Фикс простой покупки! 2025-11-09 21:55:58 +03:00
Egor 0c49eddfde Merge pull request #1828 from Gy9vin/main
Фикс проверки зачисления платежа юкассы(защита от дублирования)
2025-11-09 20:09:15 +03:00
gy9vin 33882f1e93 Фикс проверки зачисления платежа юкассы(защита от дублирования) 2025-11-09 18:31:39 +03:00
Egor f94666b68d Merge pull request #1826 from Fr1ngg/dev4
Dev4
2025-11-09 10:29:27 +03:00
Egor d32a49f801 Merge pull request #1825 from Fr1ngg/bedolaga/fix-bulk_update-to-include-where-clause
Fix bulk_update to target records by primary key
2025-11-09 10:29:11 +03:00
Egor 9596f82ea6 Fix bulk_update to target records by primary key 2025-11-09 10:28:47 +03:00
Egor 0e3fdf7d61 Merge pull request #1824 from Fr1ngg/dev4
Razgonchik
2025-11-09 10:24:57 +03:00
Egor 54cfec6029 Merge pull request #1823 from Fr1ngg/bedolaga/fix-repeated-yookassa-deposits-y7sxo6
Ensure YooKassa retries finish pending credits
2025-11-09 10:13:20 +03:00
Egor 9e88eae416 Ensure YooKassa retries finish pending credits 2025-11-09 10:13:01 +03:00
Egor 8d32efac73 Merge pull request #1822 from Fr1ngg/revert-1821-bedolaga/fix-repeated-yookassa-deposits
Revert "Prevent double processing of YooKassa payments"
2025-11-09 10:04:02 +03:00
Egor 9f07b6007e Revert "Prevent double processing of YooKassa payments" 2025-11-09 10:03:54 +03:00
Egor 2fb3b6c5dc Merge pull request #1821 from Fr1ngg/bedolaga/fix-repeated-yookassa-deposits
Prevent double processing of YooKassa payments
2025-11-09 10:03:38 +03:00
Egor 0637576425 Prevent double processing of YooKassa payments 2025-11-09 10:00:17 +03:00
Egor ee7a445610 Update database.py 2025-11-09 09:46:40 +03:00
Egor ba4c7ee7ac Update database.py 2025-11-09 09:43:26 +03:00
Egor 4da169eb0f Merge pull request #1819 from Fr1ngg/bedolaga/fix-health-endpoints-for-sqlite-nullpool
Handle missing pool metrics for NullPool
2025-11-09 09:40:53 +03:00
Egor 28937abab4 Handle missing pool metrics for NullPool 2025-11-09 09:40:08 +03:00
Egor b1dcf2779e Update database.py 2025-11-09 09:25:59 +03:00
Egor 3219062ce2 Update database.py 2025-11-09 09:22:36 +03:00
Egor 2ac72951f8 Merge pull request #1818 from Fr1ngg/bedolaga/add-health-and-metrics-endpoints-to-fastapi
Add database health and pool metrics endpoints
2025-11-09 09:17:35 +03:00
Egor cefb8cba78 Add database health and pool metrics endpoints 2025-11-09 09:17:18 +03:00
Egor e869655028 Update database.py 2025-11-09 09:11:47 +03:00
Egor ae03ede209 Merge pull request #1817 from Fr1ngg/dev4
Dev
2025-11-09 08:34:49 +03:00
Egor a72bb25ea2 Update database.py 2025-11-09 08:30:38 +03:00
Egor 0b22bc7056 Merge pull request #1815 from Fr1ngg/bedolaga/-nullpool-queuepool-wh494i
Handle optional Pal24 metadata fields
2025-11-09 08:24:26 +03:00
Egor cde84ff20b Handle optional Pal24 metadata fields 2025-11-09 08:23:01 +03:00
Egor f76114d7dc Update database.py 2025-11-09 08:17:43 +03:00
Egor 9708a56fd7 Update database.py 2025-11-09 08:14:41 +03:00
Egor a360337660 Merge pull request #1813 from Fr1ngg/dev4
фикс бекапов/восстановления
2025-11-09 07:39:18 +03:00
Egor 9f78335651 Merge pull request #1812 from Fr1ngg/bedolaga/improve-backup-and-restore-mechanism-1wazcg
Avoid duplicate association errors during restore
2025-11-09 07:31:17 +03:00
Egor ef8142a99b Skip duplicate squad promo associations during restore 2025-11-09 07:30:54 +03:00
Egor 06d51f9fcf Merge pull request #1811 from Fr1ngg/revert-1810-bedolaga/improve-backup-and-restore-mechanism-gn1h90
Revert "Handle PostgreSQL backups without pg_dump"
2025-11-09 07:30:41 +03:00
Egor 4d94347179 Revert "Handle PostgreSQL backups without pg_dump" 2025-11-09 07:30:31 +03:00
Egor 2f6aa0b1e6 Merge pull request #1810 from Fr1ngg/bedolaga/improve-backup-and-restore-mechanism-gn1h90
Handle PostgreSQL backups without pg_dump
2025-11-09 07:23:11 +03:00
Egor 536a58d4d3 Handle PostgreSQL backups without pg_dump 2025-11-09 07:22:50 +03:00
Egor 62f7c07059 Merge pull request #1809 from Fr1ngg/revert-1808-bedolaga/improve-backup-and-restore-mechanism
Revert "Refactor backup pipeline to use archive dumps"
2025-11-09 07:22:10 +03:00
Egor 0648edb3c4 Revert "Refactor backup pipeline to use archive dumps" 2025-11-09 07:21:56 +03:00
Egor 9b6c235424 Merge pull request #1808 from Fr1ngg/bedolaga/improve-backup-and-restore-mechanism
Refactor backup pipeline to use archive dumps
2025-11-09 07:12:18 +03:00
Egor 2fc35950cf Refactor backup system with archive dumps 2025-11-09 07:12:01 +03:00
Egor d2a5d74239 Merge pull request #1807 from Fr1ngg/dev4
Dev4
2025-11-09 07:06:24 +03:00
Egor 6b04c6748e Merge pull request #1806 from Fr1ngg/bedolaga/add-trial-activation-funds-check
Prefill top-up amount for paid trial activation
2025-11-09 06:58:03 +03:00
Egor 10b44cbdb7 Prefill top-up amount for paid trial activation 2025-11-09 06:51:50 +03:00
Egor dde04c716c Merge pull request #1805 from Fr1ngg/bedolaga/fix-variable-loading-for-minimum-sum
Adjust Platega top-up prompt to show configured limits
2025-11-09 06:44:55 +03:00
Egor 819f19a7ea Update Platega prompt to use configured limits 2025-11-09 06:44:23 +03:00
Egor e7201597ab Merge pull request #1804 from Fr1ngg/bedolaga/fix-pall24-payment-method-issue
Fix Pal24 SBP link handling after status checks
2025-11-09 06:35:54 +03:00
Egor ea61aa7053 Fix Pal24 SBP link handling after status checks 2025-11-09 06:31:02 +03:00
Egor 14546a8485 Merge pull request #1803 from Fr1ngg/bedolaga/add-ip-validation-for-yookassa-webhooks
Handle Cloudflare proxied YooKassa webhooks
2025-11-09 06:22:17 +03:00
Egor f55455761f Trust Cloudflare headers for YooKassa webhooks 2025-11-09 06:21:40 +03:00
Egor b1a7767827 Merge pull request #1802 from Fr1ngg/revert-1801-bedolaga/add-server-category-feature-to-admin-panel-ltnwjl
Revert "Fix server category button routing in admin panel"
2025-11-09 05:55:16 +03:00
Egor 43c04b7e64 Revert "Fix server category button routing in admin panel" 2025-11-09 05:55:07 +03:00
Egor add679cf6f Merge pull request #1801 from Fr1ngg/bedolaga/add-server-category-feature-to-admin-panel-ltnwjl
Fix server category button routing in admin panel
2025-11-09 05:49:00 +03:00
Egor ee173190a0 Fix category edit menu callback 2025-11-09 05:48:45 +03:00
Egor a6e21b47f6 Merge pull request #1800 from Fr1ngg/revert-1799-bedolaga/add-server-category-feature-to-admin-panel-fwofd2
Revert "Fix category pricing to ignore full squads"
2025-11-09 05:40:56 +03:00
Egor cd627e5840 Revert "Fix category pricing to ignore full squads" 2025-11-09 05:40:47 +03:00
Egor fc096e6867 Merge pull request #1799 from Fr1ngg/bedolaga/add-server-category-feature-to-admin-panel-fwofd2
Fix category pricing to ignore full squads
2025-11-09 05:33:29 +03:00
Egor b591844c4e Fix category pricing to ignore full squads 2025-11-09 05:33:13 +03:00
Egor 9557acb546 Merge pull request #1798 from Fr1ngg/revert-1797-bedolaga/add-server-category-feature-to-admin-panel
Revert "Ensure server category selection skips full squads"
2025-11-09 05:31:20 +03:00
Egor 1fa6e75bd3 Revert "Ensure server category selection skips full squads" 2025-11-09 05:31:09 +03:00
Egor 8965e43a55 Merge pull request #1797 from Fr1ngg/bedolaga/add-server-category-feature-to-admin-panel
Ensure server category selection skips full squads
2025-11-09 05:24:50 +03:00
Egor 8e7a6beece Ensure category auto-selection respects capacity 2025-11-09 05:23:58 +03:00
Egor e9f4fd8007 Merge pull request #1796 from Fr1ngg/revert-1793-bedolaga/implement-server-grouping-system-for-vpn-bot
Revert "Add server group backend selection logic"
2025-11-09 04:54:55 +03:00
Egor a991c59d58 Revert "Add server group backend selection logic" 2025-11-09 04:54:47 +03:00
Egor dd0624adf8 Merge pull request #1795 from Fr1ngg/revert-1794-bedolaga/check-functionality-for-server-grouping
Revert "Fix autopurchase server selection and RemnaWave stats handling"
2025-11-09 04:54:31 +03:00
Egor ab51cbe96a Revert "Fix autopurchase server selection and RemnaWave stats handling" 2025-11-09 04:54:24 +03:00
Egor 2f7a08deb0 Merge pull request #1794 from Fr1ngg/bedolaga/check-functionality-for-server-grouping
Fix autopurchase server selection and RemnaWave stats handling
2025-11-09 04:50:50 +03:00
Egor a0fa7f986b Fix autopurchase server selection and RemnaWave stats handling 2025-11-09 04:50:32 +03:00
Egor b2d3eebe39 Merge pull request #1793 from Fr1ngg/bedolaga/implement-server-grouping-system-for-vpn-bot
Add server group backend selection logic
2025-11-09 04:39:54 +03:00
Egor a043fc0e46 feat: add backend support for server groups 2025-11-09 04:36:33 +03:00
Egor d6eec8787e Merge pull request #1792 from Fr1ngg/bedolaga/fix-payment-methods-display-for-platega.io
Add Platega payment option to balance top-up list
2025-11-09 03:31:21 +03:00
Egor 95eee42047 Добавить Platega в список пополнения 2025-11-09 03:30:49 +03:00
Egor c673d6db4f Merge pull request #1790 from Fr1ngg/dev4
Dev4
2025-11-08 18:58:33 +03:00
Egor bb32a63d48 Merge pull request #1789 from Fr1ngg/bedolaga/fix-null-value-constraint-in-promo_groups
Fix default promo group creation with priority column
2025-11-08 18:58:13 +03:00
Egor af5acb272c Fix default promo group creation with priority column 2025-11-08 18:57:53 +03:00
Egor 5cd5ac8710 Merge pull request #1788 from Fr1ngg/revert-1787-dev4
Revert "Dev4"
2025-11-08 12:05:27 +03:00
Egor 8bb58b44b3 Revert "Dev4" 2025-11-08 12:05:12 +03:00
Egor 511ecd506f Merge pull request #1787 from Fr1ngg/dev4
Dev4
2025-11-08 11:38:41 +03:00
Egor 62fc014bc3 Restore json serialization helper for YooKassa webhook tests 2025-11-08 11:38:18 +03:00
Egor 961f564274 Merge pull request #1786 from Fr1ngg/bedolaga/fix-webhook-ip-verification-for-lcydgf
Remove YooKassa IP filtering from webhooks
2025-11-08 11:27:53 +03:00
Egor 984870c78c Remove YooKassa IP filtering from webhooks 2025-11-08 11:27:37 +03:00
Egor 6196c135d2 Merge pull request #1785 from Fr1ngg/revert-1782-bedolaga/fix-webhook-ip-verification-for-w66vvd
Revert "Verify YooKassa webhooks against API"
2025-11-08 11:27:23 +03:00
Egor bf1b8315a8 Revert "Verify YooKassa webhooks against API" 2025-11-08 11:27:13 +03:00
Egor b7eabcf7e7 Merge pull request #1784 from Fr1ngg/revert-1783-dev4
Revert "Dev4"
2025-11-08 11:08:55 +03:00
Egor e83ebc4f02 Revert "Dev4" 2025-11-08 11:08:47 +03:00
Egor 1a4758f4cc Merge pull request #1783 from Fr1ngg/dev4
Dev4
2025-11-08 11:04:08 +03:00
Egor b80e8b3804 Merge pull request #1782 from Fr1ngg/bedolaga/fix-webhook-ip-verification-for-w66vvd
Verify YooKassa webhooks against API
2025-11-08 11:00:49 +03:00
Egor e036173670 Verify YooKassa webhooks against API 2025-11-08 11:00:28 +03:00
Egor bd728c6466 Merge pull request #1781 from Fr1ngg/revert-1777-bedolaga/fix-webhook-ip-verification-for
Revert "Handle Cloudflare forwarded IPs for YooKassa webhooks"
2025-11-08 10:58:48 +03:00
Egor e3aec310a3 Revert "Handle Cloudflare forwarded IPs for YooKassa webhooks" 2025-11-08 10:58:34 +03:00
Egor 1c59ce601e Merge pull request #1780 from Fr1ngg/revert-1779-dev4
Revert "da eb tvoyu mat' kakoy pidoras delal etu ukassu "
2025-11-08 10:58:21 +03:00
Egor 0563f7524f Revert "da eb tvoyu mat' kakoy pidoras delal etu ukassu " 2025-11-08 10:58:12 +03:00
Egor cedbe07759 Merge pull request #1779 from Fr1ngg/dev4
da eb tvoyu mat' kakoy pidoras delal etu ukassu
2025-11-08 10:53:02 +03:00
Egor 0746b2a5a7 Merge pull request #1778 from Fr1ngg/main
w
2025-11-08 10:52:15 +03:00
Egor e8a72860dc Merge pull request #1777 from Fr1ngg/bedolaga/fix-webhook-ip-verification-for
Handle Cloudflare forwarded IPs for YooKassa webhooks
2025-11-08 10:51:50 +03:00
Egor abbfe4a7d3 Handle Cloudflare forwarded IPs for YooKassa webhooks 2025-11-08 10:50:56 +03:00
Egor 1ad5d9123b Update Dockerfile 2025-11-08 09:05:30 +03:00
Egor 57031e4afe Update docker-registry.yml 2025-11-08 09:05:14 +03:00
Egor b54d2434c0 Update docker-hub.yml 2025-11-08 09:05:02 +03:00
Egor 7c2d7b6b4f Merge pull request #1776 from Fr1ngg/dev4
платный триал + фиксы вебхука ебучей юкассы(без негатива))(
2025-11-08 08:35:25 +03:00
Egor 9e731a5f18 Merge pull request #1775 from Fr1ngg/bedolaga/add-paid-trial-activation-feature-fo5kxf
Bedolaga/add paid trial activation feature fo5kxf
2025-11-08 08:25:32 +03:00
Egor cd3832fef0 Handle trial provisioning failures by refunding charges 2025-11-08 08:14:49 +03:00
Egor cb6d51ed3d Remove trial squad override setting 2025-11-08 07:55:24 +03:00
Egor 8b75768296 Merge pull request #1773 from Fr1ngg/revert-1772-bedolaga/add-paid-trial-activation-feature-pws6p8
Revert "Rollback trial activation when charge errors occur"
2025-11-08 07:55:14 +03:00
Egor 3f0a26b1c7 Revert "Rollback trial activation when charge errors occur" 2025-11-08 07:55:04 +03:00
Egor de4f608d09 Merge pull request #1772 from Fr1ngg/bedolaga/add-paid-trial-activation-feature-pws6p8
Rollback trial activation when charge errors occur
2025-11-08 07:42:33 +03:00
Egor 618327b9f1 Rollback trial trials when post-charge errors occur 2025-11-08 07:37:21 +03:00
Egor 54fdad8f0c Merge pull request #1771 from Fr1ngg/revert-1770-bedolaga/add-paid-trial-activation-feature-obw312
Revert "Delay trial activation charges until after subscription creation"
2025-11-08 07:27:03 +03:00
Egor e9bbd68aa1 Revert "Delay trial activation charges until after subscription creation" 2025-11-08 07:26:54 +03:00
Egor 5df4837087 Merge pull request #1770 from Fr1ngg/bedolaga/add-paid-trial-activation-feature-obw312
Delay trial activation charges until after subscription creation
2025-11-08 07:18:14 +03:00
Egor 025e74c462 Defer trial activation charges until subscription creation succeeds 2025-11-08 07:17:57 +03:00
Egor 3208303c23 Merge pull request #1767 from Fr1ngg/bedolaga/remove-webhook-secret-references-for-yukassa-n5vl0v
Handle YooKassa cancellations in FastAPI webhook
2025-11-08 05:59:35 +03:00
Egor 4cf0c5a971 Handle YooKassa cancellations in FastAPI webhook 2025-11-08 05:44:53 +03:00
Egor 7519352e79 Merge pull request #1765 from Fr1ngg/main
w
2025-11-08 04:47:01 +03:00
Egor e7f94f9661 Merge pull request #1763 from aleksandryants/patch-1
Add wata payment processing method
2025-11-08 04:46:16 +03:00
Egor cb68a612e9 Merge pull request #1764 from Gy9vin/main
Обновка!
2025-11-08 04:44:25 +03:00
Egor 44d4deb5a6 Update .gitignore 2025-11-08 04:43:58 +03:00
gy9vin 23c73d1f21 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-11-07 23:56:51 +03:00
gy9vin 607beca837 fix 2025-11-07 23:56:49 +03:00
Egor 31a5f8766f Delete QWEN.md 2025-11-07 23:21:06 +03:00
gy9vin cfdc829f48 update 2025-11-07 22:24:43 +03:00
aleksandryants c44cefb983 Add wata payment processing method
Добавить обработку платежа wata
2025-11-07 19:49:27 +03:00
Egor 79c2307c92 Merge pull request #1762 from Fr1ngg/dev4
ебучая юкасса
2025-11-07 10:55:04 +03:00
Egor 25f126a5cb Merge pull request #1761 from Fr1ngg/bedolaga/disable-webhook-secret-validation-and-check-ip-tr0szb
fix: allow trusted public proxies for YooKassa webhooks
2025-11-07 10:49:17 +03:00
Egor 45bd54e7b0 fix: allow trusted public proxies for YooKassa webhooks 2025-11-07 10:43:19 +03:00
Egor 4ce43f80b8 Merge pull request #1755 from Fr1ngg/dev4
Platega + small fixs
2025-11-07 08:40:52 +03:00
Egor f7a4cedbbf Fix missing logging import in Platega handler 2025-11-07 08:29:29 +03:00
Egor 6f83122e42 Merge pull request #1753 from Fr1ngg/bedolaga/update-readme-with-platega.io-info
docs: document Platega payment integration
2025-11-07 08:18:18 +03:00
Egor dbcf3541a8 docs: document Platega payment integration 2025-11-07 08:16:45 +03:00
Egor b7adf3e971 Merge pull request #1752 from Fr1ngg/main
w
2025-11-07 08:08:01 +03:00
Egor 037b1f876b Merge pull request #1751 from Fr1ngg/bedolaga/fix-display-issue-for-unlimited-traffic
Fix unlimited traffic display in payment methods
2025-11-07 08:01:35 +03:00
Egor 4495658fad Fix unlimited traffic display in payment methods 2025-11-07 07:58:54 +03:00
Egor 017b758e6c Merge pull request #1750 from Fr1ngg/bedolaga/move-platega-settings-to-payments-section
Add Platega payment tests
2025-11-07 07:52:37 +03:00
Egor edff6d5102 Add tests for Platega payment flows and method parsing 2025-11-07 07:49:38 +03:00
Egor 979fbe305a Merge pull request #1749 from Fr1ngg/bedolaga/fix-trial-access-issue-for-users
Handle missing devices placeholder in trial offer message
2025-11-07 07:24:34 +03:00
Egor d1edb8f892 Fix trial offer formatting for device limit 2025-11-07 07:17:53 +03:00
Egor 0eeda6b9ac Merge pull request #1748 from Fr1ngg/bedolaga/integrate-new-payment-method-for-bot
Integrate Platega balance top-ups
2025-11-07 07:05:45 +03:00
Egor 7c31c3616e Guard Platega finalization against duplicate callbacks 2025-11-07 07:05:22 +03:00
Egor fba80b1a0d Integrate Platega balance top-ups 2025-11-07 06:52:57 +03:00
Egor 86dbb317f3 Delete redirect directory 2025-11-07 05:10:03 +03:00
Egor 7432d43b55 Update README.md 2025-11-07 03:32:54 +03:00
Egor a61fb10784 Update Dockerfile 2025-11-07 03:04:36 +03:00
Egor 8d83881cf0 Update docker-registry.yml 2025-11-07 03:04:25 +03:00
Egor d624df5f65 Update docker-hub.yml 2025-11-07 03:04:06 +03:00
Egor a96a7de784 Update README.md 2025-11-07 02:55:42 +03:00
Egor fa188e04c5 Add files via upload 2025-11-07 02:32:46 +03:00
Egor e8c95ece8d Update README.md 2025-11-07 02:24:13 +03:00
Egor bc0f4adb7a Update README.md 2025-11-07 02:23:10 +03:00
Egor 3eb5a71e8d Merge pull request #1747 from Fr1ngg/dev4
Бот на Вебхуке Без смс и регистрации
2025-11-07 01:47:53 +03:00
Egor 37c97de5d6 Update README.md 2025-11-07 01:37:42 +03:00
Egor 3624832ed8 Update README.md 2025-11-07 01:37:06 +03:00
Egor 9ee39d5f71 Update README.md 2025-11-07 01:36:17 +03:00
Egor 3a0e558b6f Update README.md 2025-11-07 01:34:05 +03:00
Egor ca083c216e Merge pull request #1746 from Fr1ngg/bedolaga/fix-notification-sending-error
Fix trial expiration notification loading for monitoring service
2025-11-07 01:12:17 +03:00
Egor 8b9c1d1cc6 Fix trial notification promo group loading 2025-11-07 01:01:33 +03:00
Egor 78830a7884 Merge pull request #1745 from Fr1ngg/bedolaga/add-telegram-webhook-and-both-mode-agf2n2
Expose unified health status alongside admin API
2025-11-06 18:09:19 +03:00
Egor 9fe7d0592f Expose unified health status alongside admin API 2025-11-06 18:02:08 +03:00
Egor 6a3e3c6fc4 Merge pull request #1741 from Fr1ngg/revert-1740-bedolaga/add-telegram-webhook-and-both-mode
Revert "Restore FastAPI docs endpoints for webhook mode"
2025-11-06 17:30:04 +03:00
Egor 54bfff0ed6 Revert "Restore FastAPI docs endpoints for webhook mode" 2025-11-06 17:28:06 +03:00
Egor 6ee305cbba Merge pull request #1740 from Fr1ngg/bedolaga/add-telegram-webhook-and-both-mode
Restore FastAPI docs endpoints for webhook mode
2025-11-06 17:27:56 +03:00
Egor 340041ddae Restore FastAPI docs endpoints for webhook mode 2025-11-06 17:27:39 +03:00
Egor 8a3628a6c5 Merge pull request #1738 from Fr1ngg/dev4
Dev4
2025-11-06 13:54:27 +03:00
Egor 8ac3350ac6 Merge pull request #1735 from reshifter1/main
Проверка промокода при вводе реферального кода + фикс ебучего lazy loading
2025-11-06 13:46:52 +03:00
Pavel Stryuk a507ef9966 Промокоды на место реферального кода 2025-11-06 11:03:50 +01:00
Pavel Stryuk 113405ed71 Промокоды на место реферального кода 2025-11-06 09:26:24 +01:00
Egor 1bdf1a0f4c Update README.md 2025-11-06 09:46:33 +03:00
Egor ff7883e33c Update Dockerfile 2025-11-06 09:09:50 +03:00
Egor 9bd909c1b6 Update docker-registry.yml 2025-11-06 09:09:33 +03:00
Egor 408b4e8a4d Update docker-hub.yml 2025-11-06 09:09:21 +03:00
Egor 0ee9708b4d Merge pull request #1734 from Fr1ngg/dev4
Dev4
2025-11-06 08:45:07 +03:00
Egor 44fadf6709 Merge pull request #1733 from Fr1ngg/bedolaga/fix-high-priority-bugs-in-remnawave_service.py
Reset queued UUID mutations after rollbacks
2025-11-06 08:36:45 +03:00
Egor 7384763466 Reset pending UUID mutations on rollback 2025-11-06 08:32:21 +03:00
Egor 9b6f3b10c6 Fix UUID mutation cleanup ordering 2025-11-06 07:48:00 +03:00
Egor 0e4007bde6 Merge pull request #1728 from Fr1ngg/bedolaga/fix-user-visibility-issue-in-remnawave-bot-4srv7j
Restore RemnaWave UUID map after sync rollbacks
2025-11-06 07:31:11 +03:00
Egor 9fcffc7df1 Merge pull request #1729 from Fr1ngg/revert-1726-bedolaga/fix-user-visibility-issue-in-remnawave-bot
Revert "Fix RemnaWave user identifier refresh during sync"
2025-11-06 07:21:21 +03:00
Egor 8e1a88c5cd Revert "Fix RemnaWave user identifier refresh during sync" 2025-11-06 07:21:12 +03:00
Egor 7bb21c9f77 Protect RemnaWave UUID map updates during rollbacks 2025-11-06 07:20:34 +03:00
Egor 7333379fcf Merge pull request #1726 from Fr1ngg/bedolaga/fix-user-visibility-issue-in-remnawave-bot
Fix RemnaWave user identifier refresh during sync
2025-11-06 04:14:49 +03:00
Egor fc040007b4 Fix remnawave user mapping after sync 2025-11-06 04:05:04 +03:00
Egor b30a3c7961 Merge pull request #1725 from Fr1ngg/dev4
fix
2025-11-06 03:45:46 +03:00
Egor 62e18156f5 Merge pull request #1724 from Fr1ngg/main
w
2025-11-06 03:40:19 +03:00
Egor 853666af6f Merge pull request #1723 from Fr1ngg/bedolaga/fix-typeerror-for-none-connected_squads
Guard payment methods handler against missing connected squads
2025-11-06 03:39:29 +03:00
Egor 3416f7b56c Guard payment methods handler against missing connected squads 2025-11-06 03:39:12 +03:00
Egor d78d82c6ce Update main.py 2025-11-06 03:22:16 +03:00
Egor 47bd1be714 Update system_settings_service.py 2025-11-06 03:21:48 +03:00
Egor 1eec919b88 Update main.py 2025-11-06 03:17:08 +03:00
Egor 777ad52bad Update system_settings_service.py 2025-11-06 03:02:31 +03:00
Egor 5fcadb5544 Update system_settings_service.py 2025-11-06 02:48:36 +03:00
Egor 0a8af20b58 Update system_settings_service.py 2025-11-06 02:23:32 +03:00
Egor 95163a0e35 Update monitoring_service.py 2025-11-06 01:59:53 +03:00
Egor ac58466c1a Update promo_offers.py 2025-11-06 01:45:26 +03:00
Egor 5ce14dcd9b Update promo_offers.py 2025-11-06 01:39:08 +03:00
Egor 21d700e16d Add files via upload 2025-11-06 01:35:18 +03:00
Egor 5f2d4a5e04 Update poll_service.py 2025-11-06 01:30:17 +03:00
Egor 7b90d73007 Update poll_service.py 2025-11-06 01:26:18 +03:00
Egor 68c90aec44 Update poll_service.py 2025-11-06 01:22:07 +03:00
Egor 9280c9a39a Update poll_service.py 2025-11-06 01:17:49 +03:00
Egor 245e31e262 Update purchase.py 2025-11-06 01:12:27 +03:00
Egor c00d7ef4d3 Update poll_service.py 2025-11-05 22:03:38 +03:00
Egor 2a652c562b Refactor promo offer sending with asyncio semaphore
Refactor promo offer sending to use asyncio semaphore for concurrent processing and batch sending.
2025-11-05 21:52:04 +03:00
Egor 1cc31729eb Implement semaphore and batch processing for broadcasts
Refactor broadcast message sending to use asyncio semaphore for concurrency control and batch processing.
2025-11-05 21:42:03 +03:00
Egor 34e5c20c1a Update broadcast_service.py 2025-11-05 21:33:48 +03:00
Egor c8545fe725 Refactor import path for SubscriptionService 2025-11-05 21:06:18 +03:00
Egor a8339d376d Update main.py 2025-11-05 20:59:42 +03:00
Egor 47150ccbd0 Update main.py 2025-11-05 20:52:28 +03:00
Egor 0a0890d8bb Update monitoring.py 2025-11-05 20:41:59 +03:00
Egor 6bcf24e3d8 Update welcome_text.py 2025-11-05 20:34:48 +03:00
Egor 898008a1bd Update remnawave_api.py 2025-11-05 20:21:52 +03:00
Egor 09af2e1d41 Update main.py 2025-11-05 20:21:21 +03:00
Egor 045d44f451 Update app.py 2025-11-05 20:09:34 +03:00
Egor 04b2325cb9 Update pricing.py 2025-11-05 19:57:39 +03:00
Egor 504581b0f8 Merge pull request #1721 from DOFER998/c0mrade/makefile-refactor
feat(makefile): refactor structure and add auto-generated help command
2025-11-05 19:45:55 +03:00
c0mrade 0572f414d4 feat(makefile): refactor structure and add auto-generated help command 2025-11-05 19:42:00 +03:00
Egor 50736dc005 Update price_display.py 2025-11-05 19:36:38 +03:00
Egor fe4bdb6a04 Merge pull request #1720 from Fr1ngg/dev4
Update user.py
2025-11-05 18:56:24 +03:00
Egor 7d7c5f3972 Update user.py 2025-11-05 18:51:35 +03:00
Egor 131a2d18cc Merge pull request #1719 from Fr1ngg/dev4
Dev4
2025-11-05 18:26:53 +03:00
Egor 47296c5c08 Merge pull request #1718 from Fr1ngg/bedolaga/fix-user-creation-integrity-issue
Ensure create_user_no_commit flushes new users
2025-11-05 18:22:10 +03:00
Egor c15b7a63fb Flush user before returning from create_user_no_commit 2025-11-05 18:19:22 +03:00
Egor e476afe208 Merge pull request #1716 from Fr1ngg/bedolaga/fix-remnawave-sync-error
Ensure RemnaWave service exposes create_user_no_commit
2025-11-05 18:13:45 +03:00
Egor b59d422fb0 Ensure RemnaWave service exposes create_user_no_commit 2025-11-05 18:13:31 +03:00
Egor 8b105c2396 Merge pull request #1715 from Fr1ngg/dev4
Раъебочка от Паши @reshifter1
2025-11-05 18:04:21 +03:00
Egor 9b6e0b26c3 Ensure autopurchase sets paid flag for converted trials 2025-11-05 18:02:07 +03:00
Egor 4ca038e186 Merge pull request #1714 from Fr1ngg/bedolaga/keep-user_promo_groups-in-sync-on-delete
Fix promo group deletion to maintain user_promo_groups
2025-11-05 17:55:28 +03:00
Egor 3e185e7ffe Reassign promo group memberships when deleting groups 2025-11-05 17:49:47 +03:00
Egor 5e27dcdcfb Merge pull request #1712 from Fr1ngg/bedolaga/fix-sync-for-legacy-promo_group_id-qofcyl
Recalculate primary promo group when reusing auto assignment
2025-11-05 17:39:09 +03:00
Egor 8730a68b40 Recalculate primary promo group instead of forcing target 2025-11-05 17:35:09 +03:00
Egor d88f13784a Merge pull request #1708 from reshifter1/main
Пачка правок
2025-11-05 17:19:32 +03:00
Pavel Stryuk 6e471c9094 1) Отображение скидки на кнопках (красивое!)
2) У промогрупп появится приоритет
3) У пользователя может быть несколько промогрупп, но влиять будет только с наивысшим приоритетом
4) Промокоды с промогруппой
5) При выводе пользователей с промогруппой будет также выводиться ссылка на каждого. Можно будет отследить сливы промокодов "для своих". Я в целом это добавлю во все места, где пользователь выводится в админке
6) Исправить баг исчезновения триалки при пополнении
7) Исправить падающие тесты и добавить новых
8) Трафик: 0 ГБ в тестовой подписке исправить на Трафик: Безлимит
9) При попытке изменить промогруппу "Пользователь не найден" - исправил
2025-11-05 13:45:54 +01:00
reshifter1 3201b60ff2 Merge branch 'dev4' into main 2025-11-04 22:57:38 +03:00
Pavel Stryuk d30d1e2a29 1) Отображение скидки на кнопках (красивое!)
2) У промогрупп появится приоритет
3) У пользователя может быть несколько промогрупп, но влиять будет только с наивысшим приоритетом
4) Промокоды с промогруппой
5) При выводе пользователей с промогруппой будет также выводиться ссылка на каждого. Можно будет отследить сливы промокодов "для своих". Я в целом это добавлю во все места, где пользователь выводится в админке
6) Исправить баг исчезновения триалки при пополнении
7) Исправить падающие тесты и добавить новых
8) Трафик: 0 ГБ в тестовой подписке исправить на Трафик: Безлимит
9) При попытке изменить промогруппу "Пользователь не найден" - исправил
2025-11-04 20:52:17 +01:00
Pavel Stryuk bd73ad069f 1) Отображение скидки на кнопках (красивое!)
2) У промогрупп появится приоритет
3) У пользователя может быть несколько промогрупп, но влиять будет только с наивысшим приоритетом
4) Промокоды с промогруппой
5) При выводе пользователей с промогруппой будет также выводиться ссылка на каждого. Можно будет отследить сливы промокодов "для своих". Я в целом это добавлю во все места, где пользователь выводится в админке
6) Исправить баг исчезновения триалки при пополнении
7) Исправить падающие тесты и добавить новых
8) Трафик: 0 ГБ в тестовой подписке исправить на Трафик: Безлимит
9) При попытке изменить промогруппу "Пользователь не найден" - исправил
2025-11-04 20:36:01 +01:00
Egor 2186780c32 Merge pull request #1706 from Fr1ngg/dev4
Dev4
2025-11-04 16:40:27 +03:00
Egor fbb2e561c5 Merge pull request #1705 from Fr1ngg/bedolaga/fix-mulenpay-sbp-callback-error
Fix MulenPay callback lazy loading error
2025-11-04 16:39:57 +03:00
Egor c8aba42d3d Reload user after MulenPay callback commit 2025-11-04 16:33:34 +03:00
Pavel Stryuk 05d495e874 1) Отображение скидки на кнопках (красивое!)
2) У промогрупп появится приоритет
3) У пользователя может быть несколько промогрупп, но влиять будет только с наивысшим приоритетом
4) Промокоды с промогруппой
5) При выводе пользователей с промогруппой будет также выводиться ссылка на каждого. Можно будет отследить сливы промокодов "для своих". Я в целом это добавлю во все места, где пользователь выводится в админке
6) Исправить баг исчезновения триалки при пополнении
7) Исправить падающие тесты и добавить новых
8) Трафик: 0 ГБ в тестовой подписке исправить на Трафик: Безлимит
9) При попытке изменить промогруппу "Пользователь не найден" - исправил
2025-11-04 14:13:21 +01:00
Pavel Stryuk 427011fe41 1) Отображение скидки на кнопках (красивое!)
2) У промогрупп появится приоритет
3) У пользователя может быть несколько промогрупп, но влиять будет только с наивысшим приоритетом
4) К промокодам можно будет добавить промогруппу. Все активировавшие промокод получат её
5) При выводе пользователей с промогруппой будет также выводиться ссылка на каждого. Можно будет отследить сливы промокодов "для своих". Я в целом это добавлю во все места, где пользователь выводится в админке
6) Исправить баг исчезновения триалки при пополнении
7) Исправить падающие тесты и добавить новых
8) Трафик: 0 ГБ в тестовой подписке исправить на Трафик: Безлимит
2025-11-04 13:05:02 +01:00
Egor 2feacccb13 Merge pull request #1703 from Fr1ngg/dev4
fix
2025-11-04 11:40:21 +03:00
Egor dc196fb478 Optimize user promo_group and subscription access
Refactor user attribute access to avoid lazy loading.
2025-11-04 09:30:21 +03:00
Egor 71c219e6ea Update user.py 2025-11-04 09:29:46 +03:00
Egor e250812463 Update simple_subscription.py 2025-11-04 08:02:03 +03:00
Egor 477b0b26c4 Update docker-compose.yml 2025-11-04 07:53:53 +03:00
Egor 06323f7168 Enhance search functionality for telegram_id
Added error handling for converting search input to int for telegram_id.
2025-11-04 07:39:12 +03:00
Egor 6b471caa22 Merge pull request #1700 from Fr1ngg/main
w
2025-11-04 06:21:51 +03:00
Egor 6fb28dd363 Merge pull request #1697 from remnawave-contrib/ivan-nginx-tg-stars-disabled-by-default
fix: update Telegram Stars rate & disable by default in `.env`
2025-11-04 06:21:08 +03:00
Ivan.Nginx 22dcff47d1 fix: update Telegram Stars rate & disable by default in .env 2025-11-03 23:42:21 +03:00
Egor 1d9b5ea3c6 Merge pull request #1689 from Fr1ngg/7iu0vl-bedolaga/expand-bot-api-for-server-management
Prevent partial server updates when promo groups are invalid
2025-11-03 07:19:25 +03:00
Egor 08e0b3a657 Validate promo groups before updating server 2025-11-03 07:19:03 +03:00
Egor 10b80981b2 Merge pull request #1688 from Fr1ngg/revert-1687-txzghv-bedolaga/expand-bot-api-for-server-management
Revert "Fix RemnaWave panel user creation defaults"
2025-11-03 07:18:33 +03:00
Egor 348f02f34c Revert "Fix RemnaWave panel user creation defaults" 2025-11-03 07:18:24 +03:00
Egor 9e9e984f36 Merge pull request #1687 from Fr1ngg/txzghv-bedolaga/expand-bot-api-for-server-management
Fix RemnaWave panel user creation defaults
2025-11-03 07:01:38 +03:00
Egor d47ca550ae Fix RemnaWave user sync tests by restoring create_user usage 2025-11-03 06:59:29 +03:00
Egor 16f4285948 Update README.md 2025-11-03 06:09:14 +03:00
Egor 23cb6dd8d0 Merge pull request #1685 from Fr1ngg/main
w
2025-11-02 22:25:17 +03:00
Egor dfbb6b0058 Merge pull request #1686 from Fr1ngg/revert-1684-qt986v-bedolaga/fix-user-search-logic-for-large-integers
Revert "Fix user search when numeric query exceeds INT32 range"
2025-11-02 22:25:10 +03:00
Egor 62f8fdd9bb Revert "Fix user search when numeric query exceeds INT32 range" 2025-11-02 22:24:56 +03:00
Egor fc114d15a0 Merge pull request #1684 from Fr1ngg/qt986v-bedolaga/fix-user-search-logic-for-large-integers
Fix user search when numeric query exceeds INT32 range
2025-11-02 22:22:11 +03:00
Egor a9fb8a069d Fix user search when query exceeds int32 2025-11-02 22:21:50 +03:00
Egor 9c6f738382 Merge pull request #1678 from Gy9vin/main
массовая синхронизация
2025-11-02 21:09:02 +03:00
gy9vin 1a7b2eb908 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-11-02 16:08:09 +03:00
gy9vin b108cc6b60 Фикс синхронизации 2025-11-02 16:08:05 +03:00
Mikhail 92cb1e3971 Merge branch 'Fr1ngg:main' into main 2025-11-02 15:08:01 +03:00
gy9vin 005fc95433 Массовая синхронизация пользователей с ремнем! 2025-11-02 15:07:28 +03:00
Egor f09771bc1c Merge pull request #1677 from Fr1ngg/dev4
Dev4
2025-11-02 06:31:39 +03:00
Egor d5b72198d7 Merge pull request #1676 from Fr1ngg/main
w
2025-11-02 06:30:31 +03:00
Egor 991bf1161c Merge pull request #1675 from Fr1ngg/sq2b4y-bedolaga/fix
Handle repeated return-to-cart callbacks without redundant edits
2025-11-02 06:13:27 +03:00
Egor 1a955d920a Handle repeated return-to-cart callbacks without redundant edits 2025-11-02 06:13:16 +03:00
Egor d575c914a6 Merge pull request #1674 from Fr1ngg/revert-1673-i0kn8d-bedolaga/fix
Revert "Prevent duplicate edits when resuming saved subscription carts"
2025-11-02 06:12:09 +03:00
Egor 90a054e942 Revert "Prevent duplicate edits when resuming saved subscription carts" 2025-11-02 06:11:59 +03:00
Egor aac5fabbca Merge pull request #1673 from Fr1ngg/i0kn8d-bedolaga/fix
Prevent duplicate edits when resuming saved subscription carts
2025-11-02 06:08:17 +03:00
Egor 3ce5d1650b Avoid redundant edits when reopening saved subscription cart 2025-11-02 06:08:05 +03:00
Egor 24879b9cf2 Merge pull request #1672 from Fr1ngg/revert-1671-eup68i-bedolaga/fix
Revert "Fix saved subscription cart resume button when returning from balance top-up"
2025-11-02 06:07:36 +03:00
Egor 37f27df062 Revert "Fix saved subscription cart resume button when returning from balance top-up" 2025-11-02 06:07:27 +03:00
Egor b78ed6797a Merge pull request #1671 from Fr1ngg/eup68i-bedolaga/fix
Fix saved subscription cart resume button when returning from balance top-up
2025-11-02 05:33:03 +03:00
Egor f73dcd6f66 Fix saved cart resume button for subscription checkout 2025-11-02 05:32:47 +03:00
Egor 0634d01336 Merge pull request #1670 from Fr1ngg/9xjrx8-bedolaga/update-user-status-logic-in-bot
Stop disabling RemnaWave users when subscriptions expire
2025-11-02 04:52:32 +03:00
Egor b43d6a83ee Stop disabling panel users when subscriptions expire 2025-11-02 04:52:00 +03:00
Egor 93144eea0e Merge pull request #1669 from Fr1ngg/ulngpf-bedolaga/fix-user-sync-timezone-issue
Normalize RemnaWave sync times to UTC
2025-11-02 03:13:29 +03:00
Egor 7d078a58e1 Normalize RemnaWave sync times to UTC 2025-11-02 03:13:13 +03:00
Egor 224c346bc0 Merge pull request #1657 from remnawave-contrib/ivan-nginx-timezone
patch: add missed timezone parameter to .env.example
2025-11-02 03:08:21 +03:00
Egor 51e937ae0c Merge pull request #1662 from Fr1ngg/dev4
Dev4
2025-11-01 16:41:24 +03:00
Egor 04073815ca Merge pull request #1661 from Fr1ngg/main
w
2025-11-01 16:41:05 +03:00
Egor ff556ba347 Merge pull request #1660 from Fr1ngg/qrn17i-bedolaga/deactivate-active-subscriptions
Fix monitoring to only deactivate trial subscriptions after channel unsubscribe
2025-11-01 16:39:21 +03:00
Egor 2d751c238c Fix trial subscription monitoring not to disable paid plans 2025-11-01 16:39:04 +03:00
Ivan.Nginx 5d27fa2963 patch: add missed timezone parameter to .env.example 2025-11-01 09:10:39 +03:00
Ivan.Nginx fe71935d58 Merge branch 'Fr1ngg:main' into main 2025-11-01 08:59:41 +03:00
Egor d0d1b7617a Update Python version to 2.5.7 in Dockerfile 2025-11-01 06:15:32 +03:00
Egor 310990ae32 Update versioning to 2.5.7 in workflow 2025-11-01 06:15:15 +03:00
Egor b565c32e77 Update version number to 2.5.7 in workflow 2025-11-01 06:14:58 +03:00
Egor f3edb4344b Merge pull request #1655 from Fr1ngg/dev4
Фиксы
2025-11-01 05:45:50 +03:00
Egor ccdcff6a7b Merge pull request #1654 from Fr1ngg/7sqsqp-bedolaga/fix-user-deletion-errors
Handle Heleket payments when removing users
2025-11-01 05:43:19 +03:00
Egor 1d4090ea7a Handle Heleket payments on user deletion 2025-11-01 05:43:03 +03:00
Egor 68c7d35165 Merge pull request #1653 from Fr1ngg/fifw2h-bedolaga/move-configuration-sections-for-the-bot
Group timezone and payment verification settings
2025-11-01 05:28:12 +03:00
Egor 221cc05af3 Group timezone and payment verification settings 2025-11-01 05:27:56 +03:00
Egor 1524970401 Merge pull request #1652 from Fr1ngg/revert-1651-bw734r-bedolaga/move-configuration-sections-for-the-bot
Revert "Reorganize admin configuration categories"
2025-11-01 05:27:47 +03:00
Egor 32a03c5e4b Revert "Reorganize admin configuration categories" 2025-11-01 05:27:37 +03:00
Egor a03036c099 Merge pull request #1651 from Fr1ngg/bw734r-bedolaga/move-configuration-sections-for-the-bot
Reorganize admin configuration categories
2025-11-01 05:18:46 +03:00
Egor 811e8a97b3 Reorganize bot configuration categories 2025-11-01 05:18:31 +03:00
Egor c7625643c6 Merge pull request #1650 from Fr1ngg/h9h52u-bedolaga/fix-log-time-discrepancy-with-server-time
Fix subscription timestamps to use configured timezone
2025-11-01 04:54:29 +03:00
Egor 5ce87238c8 Fix subscription timestamps to use configured timezone 2025-11-01 04:54:12 +03:00
Egor 7d12a327ca Merge pull request #1649 from Fr1ngg/revert-1648-es7eok-bedolaga/fix-log-time-discrepancy-with-server-time
Revert "Improve timezone-aware logging"
2025-11-01 04:54:03 +03:00
Egor 8c93637d79 Revert "Improve timezone-aware logging" 2025-11-01 04:53:53 +03:00
Egor 2d0ea02b4b Merge pull request #1648 from Fr1ngg/es7eok-bedolaga/fix-log-time-discrepancy-with-server-time
Improve timezone-aware logging
2025-11-01 04:31:10 +03:00
Egor 98692ac3bd Improve timezone-aware logging 2025-11-01 04:30:32 +03:00
Egor 940b6b93b2 Merge pull request #1647 from Fr1ngg/d76wg3-bedolaga/modify-parameter-priority-logic-in-bot
Respect .env configuration priority
2025-11-01 02:50:43 +03:00
Egor 17768c303f Ensure env configuration takes priority over DB overrides 2025-11-01 02:50:24 +03:00
Egor 441fcd2a36 Merge pull request #1646 from Fr1ngg/dev4
Фиксы и приколы
2025-11-01 02:27:46 +03:00
Egor c2b6b4e370 Merge pull request #1644 from Fr1ngg/w96px9-bedolaga/add-referrals-button-to-user-management
Add admin user referrals management
2025-11-01 01:58:47 +03:00
Egor 2f8d27b1ec Merge pull request #1645 from Fr1ngg/revert-1643-81vlci-bedolaga/add-referrals-button-to-user-management
Revert "Add admin user referrals management"
2025-11-01 01:58:37 +03:00
Egor dc32f53585 Revert "Add admin user referrals management" 2025-11-01 01:58:29 +03:00
Egor 165691412b Clear referral edit state when returning to list 2025-11-01 01:58:08 +03:00
Egor 824d4b3a23 Merge pull request #1643 from Fr1ngg/81vlci-bedolaga/add-referrals-button-to-user-management
Add admin user referrals management
2025-11-01 01:35:36 +03:00
Egor 060f0ca199 Add admin user referrals management 2025-11-01 01:35:20 +03:00
Egor ac43f17a43 Merge pull request #1642 from Fr1ngg/zvatmo-bedolaga/add-referral-percentage-for-all-deposits
Allow referral commissions on any top-up amount
2025-11-01 01:19:53 +03:00
Egor 54fc1c029c Allow referral commissions on any top-up amount 2025-11-01 01:19:31 +03:00
Egor d259bcf169 Merge pull request #1641 from Fr1ngg/revert-1640-qbz9v3-bedolaga/add-parsing-for-current-telegram-stars-rate
Revert "Add automatic Telegram Stars rate fetching and toggle"
2025-11-01 00:56:04 +03:00
Egor da46c18210 Revert "Add automatic Telegram Stars rate fetching and toggle" 2025-11-01 00:55:55 +03:00
Egor ace7121a4e Merge pull request #1640 from Fr1ngg/qbz9v3-bedolaga/add-parsing-for-current-telegram-stars-rate
Add automatic Telegram Stars rate fetching and toggle
2025-11-01 00:26:11 +03:00
Egor 61a93f86b3 Add dynamic Telegram Stars rate with auto refresh 2025-11-01 00:25:55 +03:00
Egor 49bcd18a9a Merge pull request #1638 from Fr1ngg/4vtqjj-bedolaga/fix-500-error-in-/promo-groups-due-to-date-validation
Fix promo group timestamp serialization issues
2025-10-31 22:52:24 +03:00
Egor 7df64d8edf Fix admin notification service f-string syntax 2025-10-31 22:52:11 +03:00
Egor ca63b4d758 Merge pull request #1637 from Fr1ngg/5j1ved-bedolaga/specify-existing-parameters-for-payment-verification
Add payment verification auto-check options to env template
2025-10-31 22:31:42 +03:00
Egor 3cbb304e9b Document payment verification auto-check settings 2025-10-31 22:31:22 +03:00
Egor ef5192234a Merge pull request #1636 from Fr1ngg/dev4
Dev4
2025-10-31 22:18:24 +03:00
Egor f7bb9ac4d9 Merge pull request #1635 from Fr1ngg/main
w
2025-10-31 22:17:49 +03:00
Egor b8bb4f3c3f Merge pull request #1633 from Gy9vin/main
СРОЧНЫЙ ФИКС
2025-10-31 22:15:52 +03:00
Egor e43ff27639 Merge pull request #1634 from Fr1ngg/cw91a0-bedolaga/add-attempts-parameter-to-maintenance-check
Add configurable retry attempts for maintenance panel checks
2025-10-31 22:15:25 +03:00
Egor e02123c1ed Add configurable retry attempts for maintenance checks 2025-10-31 22:15:05 +03:00
gy9vin f26df389b4 СРОЧНЫЙ ФИКС 2025-10-31 22:13:23 +03:00
Egor 10d36a829d Merge pull request #1632 from Fr1ngg/p7wjzx-bedolaga/fix-ad-campaign-notification-bug
Fix admin notification for first advertising campaign clicks
2025-10-31 22:04:04 +03:00
Egor 61e936dd49 Fix campaign visit notifications on initial link click 2025-10-31 22:01:03 +03:00
Egor d0f9a04d5f Merge pull request #1631 from Fr1ngg/ishx2m-bedolaga/fix-maintenance-mode-persistence-issue
Add configuration flag to disable automatic maintenance monitoring
2025-10-31 21:46:26 +03:00
Egor cf6bbffd9b Respect maintenance monitoring setting 2025-10-31 21:45:58 +03:00
Egor 5ad45addfa Merge pull request #1630 from Fr1ngg/s4pdi7-bedolaga/add-placeholder-for-user-name
Add configurable template for RemnaWave panel usernames
2025-10-31 21:32:13 +03:00
Egor 2af013b75b Add template support for RemnaWave usernames 2025-10-31 21:31:54 +03:00
Egor 74bda5c986 Merge pull request #1629 from Fr1ngg/l1utns-bedolaga/fix-pal24-payment-status-error
Fix Pal24 payment status retrieval crash
2025-10-31 21:17:57 +03:00
Egor b290ccfff2 Add Pal24 bill payments proxy and tests 2025-10-31 21:17:34 +03:00
Egor 904d2883e8 Merge pull request #1628 from Fr1ngg/gvkfjc-bedolaga/fix-duplicate-messages-in-operation-history
Fix duplicate MulenPay transactions in history
2025-10-31 21:08:29 +03:00
Egor 1a54beed17 Fix duplicate MulenPay transactions in history 2025-10-31 21:07:48 +03:00
Egor 701a8d33af Merge pull request #1627 from Fr1ngg/ibnw5b-bedolaga/remove-unused-referred_user_reward-parameter
Remove referred user reward setting
2025-10-31 20:38:58 +03:00
Egor aa03384012 Remove referred user reward setting 2025-10-31 20:38:38 +03:00
Egor d346c63184 Merge pull request #1626 from Fr1ngg/dev4
fix [BUG] Неправильное отображение статуса подписки #1585
2025-10-31 19:53:27 +03:00
Egor b8005aaad7 Merge pull request #1625 from Fr1ngg/3evicu-bedolaga/fix-subscription-status-display-issue
Improve FakeSession execute stub in payment webhook tests
2025-10-31 19:43:20 +03:00
Egor bfa9490425 Fix FakeSession execute stub for payment webhook tests 2025-10-31 19:42:53 +03:00
Egor 1f4af21a07 Merge pull request #1624 from Fr1ngg/dev4
Отключение лимита устройств
2025-10-31 19:28:58 +03:00
Egor f952c0e38d Merge pull request #1623 from Fr1ngg/main
w
2025-10-31 19:27:52 +03:00
Egor d7dab5d30f Merge pull request #1622 from Fr1ngg/plexxe-bedolaga/add-option-to-disable-device-selection
Align trial offer with disabled device selection defaults
2025-10-31 19:16:07 +03:00
Egor 8407f7cdb1 Restore device management button in disabled mode 2025-10-31 19:15:48 +03:00
Egor 6c72987201 Merge pull request #1621 from Fr1ngg/revert-1620-7irv2f-bedolaga/add-option-to-disable-device-selection
Revert "Align trial offer with disabled device selection defaults"
2025-10-31 19:15:32 +03:00
Egor 81b6d3f18f Revert "Align trial offer with disabled device selection defaults" 2025-10-31 19:15:20 +03:00
Egor 95754ab224 Merge pull request #1620 from Fr1ngg/7irv2f-bedolaga/add-option-to-disable-device-selection
Align trial offer with disabled device selection defaults
2025-10-31 19:08:36 +03:00
Egor 949d964a7f Fix trial messaging and limits for disabled device selection 2025-10-31 19:08:21 +03:00
Egor 8eab79434a Merge pull request #1619 from Fr1ngg/revert-1618-rfh1ig-bedolaga/add-option-to-disable-device-selection
Revert "Restore RemnaWave HWID caps when device selection is disabled"
2025-10-31 19:08:06 +03:00
Egor 8ab2351995 Revert "Restore RemnaWave HWID caps when device selection is disabled" 2025-10-31 19:07:58 +03:00
Egor e8802dde55 Merge pull request #1618 from Fr1ngg/rfh1ig-bedolaga/add-option-to-disable-device-selection
Restore RemnaWave HWID caps when device selection is disabled
2025-10-31 18:40:10 +03:00
Egor acc5ace234 Ensure disabled device mode keeps RemnaWave caps aligned 2025-10-31 18:39:45 +03:00
Egor 7b33721305 Merge pull request #1617 from Fr1ngg/revert-1616-c6i24i-bedolaga/add-option-to-disable-device-selection
Revert "Guard disabled-mode device caps when unset"
2025-10-31 18:39:33 +03:00
Egor e94f4598b5 Revert "Guard disabled-mode device caps when unset" 2025-10-31 18:39:21 +03:00
Egor c66c8b7933 Merge pull request #1616 from Fr1ngg/c6i24i-bedolaga/add-option-to-disable-device-selection
Guard disabled-mode device caps when unset
2025-10-31 18:27:08 +03:00
Egor 0b934efa09 Guard disabled-mode device caps when unset 2025-10-31 18:26:50 +03:00
Egor 79e644f6dd Merge pull request #1615 from Fr1ngg/revert-1613-el6wtr-bedolaga/add-option-to-disable-device-selection
Revert "Align simple subscription device caps with disabled mode"
2025-10-31 18:26:39 +03:00
Egor 5c68a1aca7 Revert "Align simple subscription device caps with disabled mode" 2025-10-31 18:26:27 +03:00
Egor bde613c4bf Merge pull request #1613 from Fr1ngg/el6wtr-bedolaga/add-option-to-disable-device-selection
Align simple subscription device caps with disabled mode
2025-10-31 18:05:59 +03:00
Egor 184d8c3ba7 Merge pull request #1614 from Fr1ngg/revert-1612-9j4uf1-bedolaga/add-option-to-disable-device-selection
Revert "Add configurable device cap for disabled selection mode"
2025-10-31 18:05:49 +03:00
Egor d48221d57f Revert "Add configurable device cap for disabled selection mode" 2025-10-31 18:05:40 +03:00
Egor 657fd6d07d Align simple subscription device caps with disabled mode 2025-10-31 18:05:20 +03:00
Egor 1211cd22ad Merge pull request #1612 from Fr1ngg/9j4uf1-bedolaga/add-option-to-disable-device-selection
Add configurable device cap for disabled selection mode
2025-10-31 17:43:01 +03:00
Egor 19ed88009d Add disabled-mode device limit controls 2025-10-31 17:42:40 +03:00
Egor 0f30ee87ab Merge pull request #1611 from Fr1ngg/revert-1610-dvh9qt-bedolaga/add-option-to-disable-device-selection
Revert "Preserve saved cart metadata when normalizing devices"
2025-10-31 17:42:17 +03:00
Egor 4cc6778e45 Revert "Preserve saved cart metadata when normalizing devices" 2025-10-31 17:42:04 +03:00
Egor ab00a0d136 Merge pull request #1610 from Fr1ngg/dvh9qt-bedolaga/add-option-to-disable-device-selection
Preserve saved cart metadata when normalizing devices
2025-10-31 14:01:24 +03:00
Egor 3d4f1af9a9 Preserve saved cart metadata during normalization 2025-10-31 14:01:06 +03:00
Egor 0302176fd2 Merge pull request #1609 from Fr1ngg/revert-1608-0ol61g-bedolaga/add-option-to-disable-device-selection
Revert "Normalize saved cart devices when selection disabled"
2025-10-31 14:00:37 +03:00
Egor 8910780332 Revert "Normalize saved cart devices when selection disabled" 2025-10-31 14:00:28 +03:00
Egor 8bea64179a Merge pull request #1608 from Fr1ngg/0ol61g-bedolaga/add-option-to-disable-device-selection
Normalize saved cart devices when selection disabled
2025-10-31 13:48:02 +03:00
Egor 777f90eab1 Normalize saved cart devices when selection disabled 2025-10-31 13:47:42 +03:00
Egor ad0b0f475a Merge pull request #1607 from Fr1ngg/revert-1606-3jxg23-bedolaga/add-option-to-disable-device-selection
Revert "Hide device limits when device selection disabled"
2025-10-31 13:47:01 +03:00
Egor 499292eab6 Revert "Hide device limits when device selection disabled" 2025-10-31 13:46:52 +03:00
Egor 21b2f1bd2a Merge pull request #1606 from Fr1ngg/3jxg23-bedolaga/add-option-to-disable-device-selection
Hide device limits when device selection disabled
2025-10-31 13:21:58 +03:00
Egor 3c025b536e Preserve default devices when selection disabled 2025-10-31 13:21:14 +03:00
Egor bded6fe59f Merge pull request #1605 from Fr1ngg/revert-1603-ej06m7-bedolaga/add-option-to-disable-device-selection
Revert "Hide device limits when device selection disabled"
2025-10-31 13:21:01 +03:00
Egor 344ad8f3c0 Revert "Hide device limits when device selection disabled" 2025-10-31 13:20:52 +03:00
Egor b43dd3167f Merge pull request #1603 from Fr1ngg/ej06m7-bedolaga/add-option-to-disable-device-selection
Hide device limits when device selection disabled
2025-10-31 12:51:49 +03:00
Egor 79ed9dbab2 Merge pull request #1604 from Fr1ngg/revert-1602-n5lhq1-bedolaga/add-option-to-disable-device-selection
Revert "Add toggle to disable device selection during subscription checkout"
2025-10-31 12:51:41 +03:00
Egor 38ff59f71f Revert "Add toggle to disable device selection during subscription checkout" 2025-10-31 12:51:29 +03:00
Egor 65c85e146d Hide device info when device selection disabled 2025-10-31 12:50:44 +03:00
Egor 467955e7b3 Merge pull request #1602 from Fr1ngg/n5lhq1-bedolaga/add-option-to-disable-device-selection
Add toggle to disable device selection during subscription checkout
2025-10-31 12:19:29 +03:00
Egor 1a4250a7ed Add toggle to disable device selection 2025-10-31 12:19:10 +03:00
Egor e181e4325b Merge pull request #1601 from Fr1ngg/revert-1600-o0l191-bedolaga/add-option-to-disable-device-selection
Revert "Add toggle to disable device selection"
2025-10-31 12:18:34 +03:00
Egor 1a35237852 Revert "Add toggle to disable device selection" 2025-10-31 12:18:26 +03:00
Egor 90baf3da52 Merge pull request #1600 from Fr1ngg/o0l191-bedolaga/add-option-to-disable-device-selection
Add toggle to disable device selection
2025-10-31 12:13:32 +03:00
Egor 348e4dd4dc Add toggle to disable device selection 2025-10-31 12:13:11 +03:00
Egor d2cd96a45c Merge pull request #1599 from remnawave-contrib/ivan-nginx-issue-templates
Added issue templates
2025-10-31 06:32:35 +03:00
Ivan.Nginx 64238e3280 Merge pull request #1 from remnawave-contrib/ivan-nginx-issue-templates
Added issue templates
2025-10-31 05:02:44 +03:00
Ivan.Nginx fcfac2df1f Added issue templates
1. Bug Template — «Bug Bedolage»
2. Teature Template — «Feat Bedolage»
2025-10-31 05:02:00 +03:00
Egor a0734a6eb4 Merge pull request #1596 from Fr1ngg/dev4
Dev4
2025-10-31 03:32:04 +03:00
Egor 0d7a235715 Merge pull request #1595 from Fr1ngg/49rs9y-bedolaga/fix-ticket-system-telegram-error
Fix admin ticket view for users without username
2025-10-31 03:28:11 +03:00
Egor 180788bc7b Fix admin ticket view for users without username 2025-10-31 03:27:55 +03:00
Egor 922681891a Merge pull request #1594 from Fr1ngg/revert-1593-jy8fc9-bedolaga/fix-ticket-system-telegram-error
Revert "Fix admin ticket DM links to support ID-only users"
2025-10-31 03:27:47 +03:00
Egor 257c628cc8 Revert "Fix admin ticket DM links to support ID-only users" 2025-10-31 03:27:38 +03:00
Egor dcee842c92 Merge pull request #1593 from Fr1ngg/jy8fc9-bedolaga/fix-ticket-system-telegram-error
Fix admin ticket DM links to support ID-only users
2025-10-31 03:20:12 +03:00
Egor 5f02471fd5 Fix admin ticket DM links to support ID-only users 2025-10-31 03:19:54 +03:00
Egor 82f0f7ce17 Merge pull request #1575 from Fr1ngg/dev4
Dev4
2025-10-30 03:10:21 +03:00
Egor b805b7eef9 Merge pull request #1574 from Fr1ngg/3fc7wf-bedolaga/update-subscription-period-menu-pricing
Hide zero price in subscription period options
2025-10-30 03:06:34 +03:00
Egor ad03fa070a Hide zero price in subscription period options 2025-10-30 03:06:16 +03:00
Egor 9c354d971f Merge pull request #1573 from Fr1ngg/dev4
Dev4
2025-10-30 01:15:58 +03:00
Egor 5cc70c4bb8 Merge pull request #1572 from Fr1ngg/soqxxz-bedolaga/fix-duplicate-telegram-id-handling
Handle Telegram ID uniqueness during RemnaWave sync
2025-10-30 01:15:36 +03:00
Egor 9db806d387 Handle Telegram ID uniqueness during RemnaWave sync 2025-10-30 01:15:19 +03:00
Egor b7f7621d28 Merge pull request #1571 from Fr1ngg/revert-1569-hyyncd-bedolaga/fix-duplicate-telegram-id-handling
Revert "Deduplicate RemnaWave sync users with freshest subscriptions"
2025-10-30 01:14:40 +03:00
Egor 822ac2417d Revert "Deduplicate RemnaWave sync users with freshest subscriptions" 2025-10-30 01:14:32 +03:00
Egor b3c80a2a82 Merge pull request #1570 from Fr1ngg/dev4
Dev4
2025-10-30 01:04:37 +03:00
Egor 3e9d606647 Merge pull request #1569 from Fr1ngg/hyyncd-bedolaga/fix-duplicate-telegram-id-handling
Deduplicate RemnaWave sync users with freshest subscriptions
2025-10-30 01:04:13 +03:00
Egor 0ab89f4956 Deduplicate RemnaWave sync users by telegram id 2025-10-30 01:03:58 +03:00
Egor 0090f1c409 Merge pull request #1568 from Fr1ngg/revert-1567-tfx50u-bedolaga/fix-duplicate-telegram-id-handling
Revert "Handle duplicate RemnaWave panel users during sync"
2025-10-30 01:03:44 +03:00
Egor 1cc37a57c1 Revert "Handle duplicate RemnaWave panel users during sync" 2025-10-30 01:03:35 +03:00
Egor 7809efa556 Merge pull request #1567 from Fr1ngg/tfx50u-bedolaga/fix-duplicate-telegram-id-handling
Handle duplicate RemnaWave panel users during sync
2025-10-30 01:03:10 +03:00
Egor b4a69e3c85 Filter RemnaWave sync duplicates by latest subscription 2025-10-30 01:02:53 +03:00
Egor 67c975d9c2 Merge pull request #1566 from Fr1ngg/main
w
2025-10-30 00:55:05 +03:00
Egor c0812a7ee9 Merge pull request #1565 from Gy9vin/main
Очередной фикс юкассы
2025-10-29 11:32:10 +03:00
gy9vin 8ed755a339 Очередной фикс юкассы 2025-10-29 11:13:55 +03:00
Egor 1aae8783ca Update Dockerfile 2025-10-26 22:09:13 +03:00
Egor 5997a741fc Update docker-registry.yml 2025-10-26 22:09:01 +03:00
Egor c5d522e6b8 Update docker-hub.yml 2025-10-26 22:08:51 +03:00
Egor fa1d128d76 Merge pull request #1560 from Fr1ngg/dev4
Clear saved cart when cancelling subscription checkout
2025-10-26 21:36:44 +03:00
Egor f30516a6c2 Merge pull request #1559 from Fr1ngg/dh2k0d-bedolaga/fix-button-visibility-after-order-cancellation
Clear saved cart when cancelling subscription checkout
2025-10-26 21:32:30 +03:00
Egor 1feb0538cf Clear saved cart after subscription cancellation 2025-10-26 21:32:16 +03:00
Egor 3a791d624f Merge pull request #1558 from Fr1ngg/dev4
Dev4
2025-10-26 20:28:12 +03:00
Egor fe0f1f7641 Merge pull request #1557 from Fr1ngg/9scuix-bedolaga/fix-subscription-auto-purchase-after-top-up
Handle YooKassa auto-purchase subscription refresh
2025-10-26 20:27:34 +03:00
Egor 212783ae3d Fix auto-purchase subscription refresh after YooKassa top-up 2025-10-26 20:26:35 +03:00
Egor 7d72fc9555 Merge pull request #1556 from Fr1ngg/revert-1554-0ru012-bedolaga/fix-subscription-auto-purchase-after-top-up
Revert "Handle auto-purchase for subscription extensions after top-up"
2025-10-26 20:26:25 +03:00
Egor fb4df5cee1 Revert "Handle auto-purchase for subscription extensions after top-up" 2025-10-26 20:26:12 +03:00
Egor 45cd847b44 Merge pull request #1555 from Fr1ngg/dev4
Автопокупка и продление после пополнения баланса
2025-10-26 19:51:25 +03:00
Egor 98fd97dea2 Merge pull request #1554 from Fr1ngg/0ru012-bedolaga/fix-subscription-auto-purchase-after-top-up
Handle auto-purchase for subscription extensions after top-up
2025-10-26 19:40:50 +03:00
Egor e334086ad1 Fix auto-purchase for subscription extensions 2025-10-26 19:40:31 +03:00
Egor 2e792911f1 Merge pull request #1553 from Fr1ngg/revert-1552-hq8vy3-bedolaga/fix-subscription-auto-purchase-after-top-up
Revert "Handle simple subscription star purchase flow"
2025-10-26 19:40:00 +03:00
Egor 878b666a0b Revert "Handle simple subscription star purchase flow" 2025-10-26 19:39:51 +03:00
Egor 69c3239469 Merge pull request #1552 from Fr1ngg/hq8vy3-bedolaga/fix-subscription-auto-purchase-after-top-up
Handle simple subscription star purchase flow
2025-10-26 19:15:25 +03:00
Egor b8d48745c5 Handle simple subscription stars purchases 2025-10-26 19:15:02 +03:00
Egor 6b64e73d6e Merge pull request #1551 from Fr1ngg/revert-1550-yp0t6x-bedolaga/fix-subscription-auto-purchase-after-top-up
Revert "Fix auto-purchase after top-up to use miniapp purchase service"
2025-10-26 19:14:21 +03:00
Egor b433a96cd6 Revert "Fix auto-purchase after top-up to use miniapp purchase service" 2025-10-26 19:14:12 +03:00
Egor 0e4c83299d Merge pull request #1550 from Fr1ngg/yp0t6x-bedolaga/fix-subscription-auto-purchase-after-top-up
Fix auto-purchase after top-up to use miniapp purchase service
2025-10-26 18:51:54 +03:00
Egor c4d1f3eccc Fix auto-purchase to use miniapp service submit 2025-10-26 18:50:13 +03:00
Egor 22dcf425cb Merge pull request #1547 from Fr1ngg/revert-1544-revert-1543-tfkufc-bedolaga/add-automatic-subscription-purchase-feature
Revert "Revert "feat: auto purchase subscription after top-up""
2025-10-26 18:27:10 +03:00
Egor edf4132c2d Revert "Revert "feat: auto purchase subscription after top-up"" 2025-10-26 18:27:01 +03:00
Egor a56e14c736 Merge pull request #1546 from Fr1ngg/revert-1545-67lhig-bedolaga/add-automatic-subscription-purchase-feature
Revert "Add automatic subscription purchase after balance top-up"
2025-10-26 18:26:41 +03:00
Egor 2e3c707745 Revert "Add automatic subscription purchase after balance top-up" 2025-10-26 18:26:32 +03:00
Egor ebd8a10fd9 Merge pull request #1545 from Fr1ngg/67lhig-bedolaga/add-automatic-subscription-purchase-feature
Add automatic subscription purchase after balance top-up
2025-10-26 18:04:30 +03:00
Egor a681a9ff1b Add automatic subscription purchase after balance top-up 2025-10-26 18:04:13 +03:00
Egor d11458c4a8 Merge pull request #1544 from Fr1ngg/revert-1543-tfkufc-bedolaga/add-automatic-subscription-purchase-feature
Revert "feat: auto purchase subscription after top-up"
2025-10-26 18:03:58 +03:00
Egor 9d6e17b609 Revert "feat: auto purchase subscription after top-up" 2025-10-26 18:03:49 +03:00
Egor cb27487021 Merge pull request #1543 from Fr1ngg/tfkufc-bedolaga/add-automatic-subscription-purchase-feature
feat: auto purchase subscription after top-up
2025-10-26 17:56:15 +03:00
Egor e1e6b40a19 feat: auto purchase subscription after top-up 2025-10-26 17:55:51 +03:00
Egor 2616292f97 Merge pull request #1542 from Fr1ngg/revert-1541-bedolaga-oew5ch
Revert "Add automatic subscription purchase after top-ups"
2025-10-26 17:39:13 +03:00
Egor 2e1dfa6638 Revert "Add automatic subscription purchase after top-ups" 2025-10-26 17:39:04 +03:00
Egor 3a730492cb Merge pull request #1541 from Fr1ngg/bedolaga-oew5ch
Add automatic subscription purchase after top-ups
2025-10-26 17:35:16 +03:00
Egor c35da4b98d Add automatic subscription purchase after top-ups 2025-10-26 17:35:01 +03:00
Egor 5e98c25020 Merge pull request #1539 from Fr1ngg/dev4
Сервис автопроверки транзакций update
2025-10-26 10:47:42 +03:00
Egor 11a4d5b436 Merge pull request #1538 from Fr1ngg/revert-1536-revert-1533-4i58ws-bedolaga/add-auto-check-service-for-user-deposits
Revert "Revert "Improve Pal24 and Heleket verification coverage""
2025-10-26 10:46:48 +03:00
Egor 93c733aac0 Revert "Revert "Improve Pal24 and Heleket verification coverage"" 2025-10-26 10:46:38 +03:00
Egor 5b03e7ab8c Merge pull request #1537 from Fr1ngg/main
w
2025-10-26 10:46:24 +03:00
Egor 33912331af Merge pull request #1536 from Fr1ngg/revert-1533-4i58ws-bedolaga/add-auto-check-service-for-user-deposits
Revert "Improve Pal24 and Heleket verification coverage"
2025-10-26 10:45:44 +03:00
Egor aacc07835e Revert "Improve Pal24 and Heleket verification coverage" 2025-10-26 10:45:35 +03:00
Egor 86df2a1408 Update README.md 2025-10-26 10:44:32 +03:00
Egor 018de17c5b Merge pull request #1534 from Fr1ngg/74yls6-bedolaga/fix-back-button-error-during-payment
Handle back navigation from YooKassa SBP payments
2025-10-26 10:38:11 +03:00
Egor 66afa2b924 Handle back navigation from YooKassa SBP payments 2025-10-26 10:37:49 +03:00
Egor 4ee32fe0de Merge pull request #1533 from Fr1ngg/4i58ws-bedolaga/add-auto-check-service-for-user-deposits
Improve Pal24 and Heleket verification coverage
2025-10-26 10:28:57 +03:00
Egor 5b59ca3c4f Display transaction numbers in admin payment list 2025-10-26 10:28:32 +03:00
Egor 0108b0fc22 Merge pull request #1532 from Fr1ngg/revert-1531-ue9uqd-bedolaga/add-auto-check-service-for-user-deposits
Revert "Improve Pal24 and Heleket verification coverage"
2025-10-26 10:28:18 +03:00
Egor 2e575f82af Revert "Improve Pal24 and Heleket verification coverage" 2025-10-26 10:28:10 +03:00
Egor e8af09041d Merge pull request #1531 from Fr1ngg/ue9uqd-bedolaga/add-auto-check-service-for-user-deposits
Improve Pal24 and Heleket verification coverage
2025-10-26 10:15:51 +03:00
Egor 1cf041fb86 Improve Pal24 and Heleket verification coverage 2025-10-26 10:15:31 +03:00
Egor 63074cb009 Merge pull request #1529 from Fr1ngg/revert-1528-3wrea3-bedolaga/add-auto-check-service-for-user-deposits
Revert "Fix CryptoBot admin status checks"
2025-10-26 09:58:22 +03:00
Egor 89b9a6e04f Revert "Fix CryptoBot admin status checks" 2025-10-26 09:58:13 +03:00
Egor 4efa344e43 Merge pull request #1528 from Fr1ngg/3wrea3-bedolaga/add-auto-check-service-for-user-deposits
Fix CryptoBot admin status checks
2025-10-26 09:47:04 +03:00
Egor 2d3d1ba5d3 Fix CryptoBot admin status checks 2025-10-26 09:46:46 +03:00
Egor 849d781e92 Merge pull request #1527 from Fr1ngg/revert-1524-utdzdq-bedolaga/add-auto-check-service-for-user-deposits
Revert "Guard YooKassa manual checks from duplicate processing"
2025-10-26 09:46:33 +03:00
Egor 48a988aa43 Revert "Guard YooKassa manual checks from duplicate processing" 2025-10-26 09:46:25 +03:00
Egor ec71f733ad Merge pull request #1526 from Fr1ngg/revert-1525-dev4
Revert "Сервис проверки платежей"
2025-10-26 09:46:04 +03:00
Egor 051121a366 Revert "Сервис проверки платежей" 2025-10-26 09:45:56 +03:00
Egor e2c2b9127c Merge pull request #1525 from Fr1ngg/dev4
Сервис проверки платежей
2025-10-26 09:32:29 +03:00
Egor fdf7263bb0 Merge pull request #1524 from Fr1ngg/utdzdq-bedolaga/add-auto-check-service-for-user-deposits
Guard YooKassa manual checks from duplicate processing
2025-10-26 09:25:40 +03:00
Egor c8dcde0919 Guard auto verification commits 2025-10-26 09:25:14 +03:00
Egor 9db0cd6328 Merge pull request #1523 from Fr1ngg/revert-1522-z30p3c-bedolaga/add-auto-check-service-for-user-deposits
Revert "Guard YooKassa manual checks from duplicate processing"
2025-10-26 09:24:57 +03:00
Egor 2375bf51b3 Revert "Guard YooKassa manual checks from duplicate processing" 2025-10-26 09:24:49 +03:00
Egor 3631b49d6d Merge pull request #1522 from Fr1ngg/z30p3c-bedolaga/add-auto-check-service-for-user-deposits
Guard YooKassa manual checks from duplicate processing
2025-10-26 09:15:50 +03:00
Egor 3938b56802 Guard YooKassa manual checks from duplicate processing 2025-10-26 09:15:35 +03:00
Egor 3c3b793a9f Merge pull request #1521 from Fr1ngg/revert-1520-vcxme6-bedolaga/add-auto-check-service-for-user-deposits
Revert "Log payment verification service activation during startup"
2025-10-26 09:09:55 +03:00
Egor 8985a89a9f Revert "Log payment verification service activation during startup" 2025-10-26 09:09:46 +03:00
Egor 5750e6a4d2 Merge pull request #1520 from Fr1ngg/vcxme6-bedolaga/add-auto-check-service-for-user-deposits
Log payment verification service activation during startup
2025-10-26 08:54:28 +03:00
Egor 8d7f73edec Log payment verification service activation 2025-10-26 08:54:05 +03:00
Egor 4853d5b19b Merge pull request #1519 from Fr1ngg/revert-1518-x45isk-bedolaga/add-auto-check-service-for-user-deposits
Revert "Prevent admin payment detail handler from handling manual check callbacks"
2025-10-26 08:52:53 +03:00
Egor 901f5fd90d Revert "Prevent admin payment detail handler from handling manual check callbacks" 2025-10-26 08:52:46 +03:00
Egor 89e3865019 Merge pull request #1518 from Fr1ngg/x45isk-bedolaga/add-auto-check-service-for-user-deposits
Prevent admin payment detail handler from handling manual check callbacks
2025-10-26 08:36:43 +03:00
Egor 538b2b52d3 Avoid admin payment detail handler catching manual checks 2025-10-26 08:36:27 +03:00
Egor f1c9df8471 Merge pull request #1517 from Fr1ngg/revert-1516-kvif2x-bedolaga/add-auto-check-service-for-user-deposits
Revert "Show CryptoBot and Stars invoices in admin payments"
2025-10-26 08:32:39 +03:00
Egor 9e09e97ed8 Revert "Show CryptoBot and Stars invoices in admin payments" 2025-10-26 08:32:30 +03:00
Egor e50355be23 Merge pull request #1516 from Fr1ngg/kvif2x-bedolaga/add-auto-check-service-for-user-deposits
Show CryptoBot and Stars invoices in admin payments
2025-10-26 08:27:40 +03:00
Egor bd036fb4a9 Show CryptoBot and Stars invoices in admin payments 2025-10-26 08:27:22 +03:00
Egor 0181f8e5fb Merge pull request #1515 from Fr1ngg/revert-1514-vpe5pb-bedolaga/add-auto-check-service-for-user-deposits
Revert "Include CryptoBot and Stars invoices in admin checks"
2025-10-26 08:27:12 +03:00
Egor f5eea6798a Revert "Include CryptoBot and Stars invoices in admin checks" 2025-10-26 08:27:04 +03:00
Egor 7c8d0254b2 Merge pull request #1514 from Fr1ngg/vpe5pb-bedolaga/add-auto-check-service-for-user-deposits
Include CryptoBot and Stars invoices in admin checks
2025-10-26 08:26:31 +03:00
Egor 49c3c1c0c9 Include CryptoBot and Stars invoices in admin checks 2025-10-26 08:25:43 +03:00
Egor 38522442d1 Merge pull request #1513 from Fr1ngg/revert-1512-6709dc-bedolaga/add-auto-check-service-for-user-deposits
Revert "Include CryptoBot and Stars in admin payment verification"
2025-10-26 08:25:11 +03:00
Egor 0dacd79598 Revert "Include CryptoBot and Stars in admin payment verification" 2025-10-26 08:25:03 +03:00
Egor 2022654055 Merge pull request #1512 from Fr1ngg/6709dc-bedolaga/add-auto-check-service-for-user-deposits
Include CryptoBot and Stars in admin payment verification
2025-10-26 08:18:48 +03:00
Egor f3a1de6335 Include CryptoBot and Stars in admin top-up checks 2025-10-26 08:18:30 +03:00
Egor 0b5b89ea9a Merge pull request #1511 from Fr1ngg/revert-1510-k70a53-bedolaga/add-auto-check-service-for-user-deposits
Revert "Fix admin YooKassa manual check lookup"
2025-10-26 08:18:16 +03:00
Egor 8e21c34c8c Revert "Fix admin YooKassa manual check lookup" 2025-10-26 08:18:07 +03:00
Egor 400f3b01d6 Merge pull request #1510 from Fr1ngg/k70a53-bedolaga/add-auto-check-service-for-user-deposits
Fix admin YooKassa manual check lookup
2025-10-26 07:38:34 +03:00
Egor 6a0b2aabaf Add payment service wrapper for YooKassa local lookup 2025-10-26 07:38:17 +03:00
Egor 13a1ddad11 Merge pull request #1509 from Fr1ngg/revert-1508-ur8a6k-bedolaga/add-auto-check-service-for-user-deposits
Revert "Add admin top-up verification tools"
2025-10-26 07:37:32 +03:00
Egor ed404a0e54 Revert "Add admin top-up verification tools" 2025-10-26 07:37:15 +03:00
Egor 05acfc0733 Merge pull request #1508 from Fr1ngg/ur8a6k-bedolaga/add-auto-check-service-for-user-deposits
Add admin top-up verification tools
2025-10-26 07:24:23 +03:00
Egor 969cd6ed90 Add admin top-up verification tools 2025-10-26 07:24:03 +03:00
Egor 939c9b9b09 Merge pull request #1507 from Fr1ngg/revert-1506-6evcye-bedolaga/add-auto-check-service-for-user-deposits
Revert "Add admin pending payment verification endpoints"
2025-10-26 07:23:33 +03:00
Egor 051c0db106 Revert "Add admin pending payment verification endpoints" 2025-10-26 07:23:24 +03:00
Egor e5fbe005f8 Merge pull request #1506 from Fr1ngg/6evcye-bedolaga/add-auto-check-service-for-user-deposits
Add admin pending payment verification endpoints
2025-10-26 07:20:38 +03:00
Egor 63efb31785 Add admin APIs for pending payment checks 2025-10-26 07:20:23 +03:00
Egor 32586e177b Merge pull request #1505 from Fr1ngg/revert-1504-ig0xdj-bedolaga/add-auto-check-service-for-user-deposits
Revert "Add admin top-up monitoring and manual payment checks"
2025-10-26 07:19:49 +03:00
Egor 651dba2648 Revert "Add admin top-up monitoring and manual payment checks" 2025-10-26 07:19:41 +03:00
Egor 1bbcf2a95c Merge pull request #1504 from Fr1ngg/ig0xdj-bedolaga/add-auto-check-service-for-user-deposits
Add admin top-up monitoring and manual payment checks
2025-10-26 07:16:53 +03:00
Egor 82af176018 Add admin top-up monitoring and manual payment checks 2025-10-26 07:16:35 +03:00
Egor c10a4e9f8a Merge pull request #1503 from Fr1ngg/dev4
Fix mulen/юкасса webhooks
2025-10-26 06:56:37 +03:00
Egor 9727e07464 Merge pull request #1502 from Fr1ngg/9cf17y-bedolaga/improve-webhook-handling-for-payments
Improve YooKassa webhook recovery
2025-10-26 06:52:39 +03:00
Egor b780fe2278 Improve YooKassa webhook recovery 2025-10-26 06:52:18 +03:00
Egor 947d61f2a8 Merge pull request #1501 from Fr1ngg/revert-1500-pfpi1q-bedolaga/improve-webhook-handling-for-payments
Revert "Improve MulenPay hook resilience"
2025-10-26 06:47:15 +03:00
Egor 27725ec1a7 Revert "Improve MulenPay hook resilience" 2025-10-26 06:47:08 +03:00
Egor 93a29463f4 Merge pull request #1500 from Fr1ngg/pfpi1q-bedolaga/improve-webhook-handling-for-payments
Improve MulenPay hook resilience
2025-10-26 06:07:12 +03:00
Egor 84cfcf45fa Improve MulenPay hook resilience 2025-10-26 06:04:16 +03:00
Egor 6651e59f63 Merge pull request #1499 from Fr1ngg/main
ц
2025-10-26 05:55:27 +03:00
Egor ed46b7c919 Update miniapp-setup.md 2025-10-24 13:23:23 +03:00
Egor 88cd9fbc5b Update README.md 2025-10-24 13:14:17 +03:00
Egor 46fc5827ca Merge pull request #1497 from Fr1ngg/dev4
Dev4
2025-10-24 10:05:03 +03:00
Egor 65d386f3b2 Merge pull request #1496 from Fr1ngg/0x48r9-bedolaga/fix-datetime-error-in-payment-status-check
Normalize Heleket payment timestamps before saving
2025-10-24 10:01:40 +03:00
Egor 9db640931e Normalize Heleket paid_at timestamps to UTC 2025-10-24 10:01:23 +03:00
Egor 53e4dec12d Merge pull request #1495 from Fr1ngg/dev4
w
2025-10-24 09:45:03 +03:00
Egor 174d9f9b84 Merge pull request #1494 from Fr1ngg/ur3x81-bedolaga/expand-bot-api-for-surveys
Optimize poll listing response counts
2025-10-24 09:39:57 +03:00
Egor 30ec07f7fe Avoid loading poll responses in list endpoint 2025-10-24 09:39:39 +03:00
Egor dbf1fd8085 Merge pull request #1493 from Fr1ngg/revert-1492-yc9gsm-bedolaga/expand-bot-api-for-surveys
Revert "Document poll management endpoints in admin guide"
2025-10-24 09:36:37 +03:00
Egor 32e1f231d3 Revert "Document poll management endpoints in admin guide" 2025-10-24 09:36:28 +03:00
Egor 96b788693d Merge pull request #1492 from Fr1ngg/yc9gsm-bedolaga/expand-bot-api-for-surveys
Document poll management endpoints in admin guide
2025-10-24 09:32:52 +03:00
Egor a0e00c3735 Document poll administration API 2025-10-24 09:32:38 +03:00
Egor 51e68cf215 Merge pull request #1491 from Fr1ngg/wugnye-bedolaga/expand-bot-api-for-system-logs
Expose system log through admin API
2025-10-24 09:20:48 +03:00
Egor fa3ed3af81 Add system log endpoints to admin API 2025-10-24 09:20:34 +03:00
Egor 6e72cbb5b0 Merge pull request #1490 from Fr1ngg/u14a7y-bedolaga/expand-api-for-bot-logs-visibility
feat: expose monitoring logs through web api
2025-10-24 09:08:07 +03:00
Egor 5f316f85b3 feat: expose monitoring logs through web api 2025-10-24 09:06:08 +03:00
Egor 2abd1fd9df Merge pull request #1489 from Fr1ngg/main
w
2025-10-24 08:56:20 +03:00
Egor fcb5fedf9f Update README.md 2025-10-23 13:31:36 +03:00
Egor d35c9ad87e Merge pull request #1487 from Fr1ngg/dbunp5-bedolaga/fix-double-reward-issue-in-poll-system
Prevent double payout when rewarding poll
2025-10-23 08:21:19 +03:00
Egor 0cc0368b0b Prevent double payout when rewarding poll 2025-10-23 08:17:18 +03:00
Egor 00fb154cb9 Merge pull request #1485 from Fr1ngg/main
w
2025-10-23 08:12:14 +03:00
Egor 4441cf79ff Update Dockerfile 2025-10-23 08:02:41 +03:00
Egor 7428bd5cf7 Update docker-registry.yml 2025-10-23 08:02:29 +03:00
Egor 76c524e980 Update docker-hub.yml 2025-10-23 08:02:18 +03:00
Egor 5a9fa167c2 Merge pull request #1484 from Fr1ngg/dev4
Опросы
2025-10-23 07:52:15 +03:00
Egor c61e9c78ba Merge pull request #1483 from Fr1ngg/bcb1c4-bedolaga/fix-poll-sending-error-in-bot
Refresh poll answers after recording selections
2025-10-23 07:44:34 +03:00
Egor e5e3a9e4b5 Refresh poll answers after recording selections 2025-10-23 07:44:20 +03:00
Egor a1f47e13e3 Merge pull request #1482 from Fr1ngg/revert-1481-rnz085-bedolaga/fix-poll-sending-error-in-bot
Revert "Prevent duplicate taps when answering polls"
2025-10-23 07:43:51 +03:00
Egor 7ff0ed1248 Revert "Prevent duplicate taps when answering polls" 2025-10-23 07:43:43 +03:00
Egor 5eef24fb91 Merge pull request #1481 from Fr1ngg/rnz085-bedolaga/fix-poll-sending-error-in-bot
Prevent duplicate taps when answering polls
2025-10-23 07:40:19 +03:00
Egor 05ba26d3ac Avoid redundant poll edits and acknowledge answers 2025-10-23 07:39:57 +03:00
Egor ef68ce19ce Merge pull request #1480 from Fr1ngg/revert-1479-bqfm5n-bedolaga/fix-poll-sending-error-in-bot
Revert "Handle redundant poll message edits gracefully"
2025-10-23 07:39:30 +03:00
Egor 2fa84f6dc9 Revert "Handle redundant poll message edits gracefully" 2025-10-23 07:39:20 +03:00
Egor 32409d7471 Merge pull request #1479 from Fr1ngg/bqfm5n-bedolaga/fix-poll-sending-error-in-bot
Handle redundant poll message edits gracefully
2025-10-23 07:19:12 +03:00
Egor 270282a340 Gracefully handle poll message updates 2025-10-23 07:18:59 +03:00
Egor 87eaa06652 Merge pull request #1478 from Fr1ngg/revert-1477-9bx38j-bedolaga/fix-poll-sending-error-in-bot
Revert "Streamline poll creation prompts and skip unreachable chats"
2025-10-23 07:18:12 +03:00
Egor 845f03e873 Revert "Streamline poll creation prompts and skip unreachable chats" 2025-10-23 07:18:04 +03:00
Egor 97eabb0604 Merge pull request #1477 from Fr1ngg/9bx38j-bedolaga/fix-poll-sending-error-in-bot
Streamline poll creation prompts and skip unreachable chats
2025-10-23 07:10:27 +03:00
Egor f0d714d787 Improve poll creation prompts and poll delivery 2025-10-23 07:10:13 +03:00
Egor 27a34d6e9f Merge pull request #1476 from Fr1ngg/revert-1475-revert-1471-revert-1470-xvg3de-bedolaga/fix-poll-sending-error-in-bot
Revert "Revert "Revert "Stabilize poll creation UI and delivery loop"""
2025-10-23 07:10:00 +03:00
Egor a4e97e0317 Revert "Revert "Revert "Stabilize poll creation UI and delivery loop""" 2025-10-23 07:09:51 +03:00
Egor 97dcd0b7cc Merge pull request #1475 from Fr1ngg/revert-1471-revert-1470-xvg3de-bedolaga/fix-poll-sending-error-in-bot
Revert "Revert "Stabilize poll creation UI and delivery loop""
2025-10-23 06:58:40 +03:00
Egor 9b57904cf4 Revert "Revert "Stabilize poll creation UI and delivery loop"" 2025-10-23 06:58:29 +03:00
Egor 6ebdae0cae Merge pull request #1474 from Fr1ngg/revert-1469-56bjws-bedolaga/fix-poll-sending-error-in-bot
Revert "Stabilize poll creation flow and poll delivery"
2025-10-23 06:57:45 +03:00
Egor b9d4a8f8e9 Revert "Stabilize poll creation flow and poll delivery" 2025-10-23 06:57:36 +03:00
Egor 707c3c5daf Merge pull request #1469 from Fr1ngg/56bjws-bedolaga/fix-poll-sending-error-in-bot
Stabilize poll creation flow and poll delivery
2025-10-23 06:55:37 +03:00
Egor d075abfeea Merge pull request #1473 from Fr1ngg/revert-1472-1yvk15-bedolaga/fix-poll-sending-error-in-bot
Revert "Fix poll creation wizard updates and poll delivery"
2025-10-23 06:55:14 +03:00
Egor d3399c569c Revert "Fix poll creation wizard updates and poll delivery" 2025-10-23 06:55:06 +03:00
Egor e36e3f7f8f Merge pull request #1472 from Fr1ngg/1yvk15-bedolaga/fix-poll-sending-error-in-bot
Fix poll creation wizard updates and poll delivery
2025-10-23 06:52:24 +03:00
Egor 583e01f5e6 Stabilize poll creation prompts and delivery 2025-10-23 06:51:48 +03:00
Egor 784b5e0af6 Merge pull request #1471 from Fr1ngg/revert-1470-xvg3de-bedolaga/fix-poll-sending-error-in-bot
Revert "Stabilize poll creation UI and delivery loop"
2025-10-23 06:51:28 +03:00
Egor cc42d33fbe Revert "Stabilize poll creation UI and delivery loop" 2025-10-23 06:51:19 +03:00
Egor 9c3d8fd701 Merge pull request #1470 from Fr1ngg/xvg3de-bedolaga/fix-poll-sending-error-in-bot
Stabilize poll creation UI and delivery loop
2025-10-23 06:47:12 +03:00
Egor d281eeb69d Stabilize poll creation UI and delivery loop 2025-10-23 06:46:46 +03:00
Egor b510ffb7d8 Stabilize poll creation UI and sending 2025-10-23 06:46:11 +03:00
Egor 6855b7765c Merge pull request #1468 from Fr1ngg/revert-1467-revert-1462-revert-1461-ahggij-bedolaga/fix-poll-sending-error-in-bot
Revert "Revert "Revert "Fix poll sending flow and tidy poll creation prompts"""
2025-10-23 06:45:44 +03:00
Egor 9ed1129268 Revert "Revert "Revert "Fix poll sending flow and tidy poll creation prompts""" 2025-10-23 06:45:35 +03:00
Egor 0c4ba3c0d0 Merge pull request #1467 from Fr1ngg/revert-1462-revert-1461-ahggij-bedolaga/fix-poll-sending-error-in-bot
Revert "Revert "Fix poll sending flow and tidy poll creation prompts""
2025-10-23 06:32:55 +03:00
Egor 70337ac310 Revert "Revert "Fix poll sending flow and tidy poll creation prompts"" 2025-10-23 06:32:40 +03:00
Egor e981082713 Merge pull request #1466 from Fr1ngg/revert-1465-bh9v34-bedolaga/fix-poll-sending-error-in-bot
Revert "Fix poll delivery session usage and tidy poll creation prompts"
2025-10-23 06:32:01 +03:00
Egor 104d98542e Revert "Fix poll delivery session usage and tidy poll creation prompts" 2025-10-23 06:31:20 +03:00
Egor fe63ef5fc5 Merge pull request #1465 from Fr1ngg/bh9v34-bedolaga/fix-poll-sending-error-in-bot
Fix poll delivery session usage and tidy poll creation prompts
2025-10-23 06:30:12 +03:00
Egor b1ff964dd6 Fix poll sending session access and clean poll creation chat 2025-10-23 06:29:59 +03:00
Egor aba1d5dbe5 Merge pull request #1464 from Fr1ngg/revert-1463-hg0g0v-bedolaga/fix-poll-sending-error-in-bot
Revert "Fix poll sending DB session usage and clean poll creation chat"
2025-10-23 06:29:35 +03:00
Egor 0af7e4ac68 Revert "Fix poll sending DB session usage and clean poll creation chat" 2025-10-23 06:29:23 +03:00
Egor 7d9a572e6e Merge pull request #1463 from Fr1ngg/hg0g0v-bedolaga/fix-poll-sending-error-in-bot
Fix poll sending DB session usage and clean poll creation chat
2025-10-23 06:27:12 +03:00
Egor de6557d7e7 Fix poll sending session usage and clean creation prompts 2025-10-23 06:26:59 +03:00
Egor a052c7bb80 Merge pull request #1462 from Fr1ngg/revert-1461-ahggij-bedolaga/fix-poll-sending-error-in-bot
Revert "Fix poll sending flow and tidy poll creation prompts"
2025-10-23 06:26:13 +03:00
Egor 8c47f3c49b Revert "Fix poll sending flow and tidy poll creation prompts" 2025-10-23 06:26:04 +03:00
Egor 323ad903e7 Merge pull request #1461 from Fr1ngg/ahggij-bedolaga/fix-poll-sending-error-in-bot
Fix poll sending flow and tidy poll creation prompts
2025-10-23 06:22:46 +03:00
Egor 8105fe285d Fix poll sending flow and tidy poll creation prompts 2025-10-23 06:22:31 +03:00
Egor 82a19025f1 Merge pull request #1460 from Fr1ngg/revert-1455-revert-1454-k4r4kv-bedolaga/add-survey-functionality-to-bot
Revert "Revert "Add poll management and delivery system""
2025-10-23 06:03:49 +03:00
Egor e592b3e5c4 Revert "Revert "Add poll management and delivery system"" 2025-10-23 06:03:41 +03:00
Egor 2da12354a1 Merge pull request #1459 from Fr1ngg/revert-1458-itqeed-bedolaga/add-survey-functionality-to-bot
Revert "Fix aiogram Bot usage in poll handlers"
2025-10-23 06:03:17 +03:00
Egor fc65b62d65 Revert "Fix aiogram Bot usage in poll handlers" 2025-10-23 06:03:09 +03:00
Egor 28603a87e1 Merge pull request #1458 from Fr1ngg/itqeed-bedolaga/add-survey-functionality-to-bot
Fix aiogram Bot usage in poll handlers
2025-10-23 05:56:02 +03:00
Egor 8956aafc9a Fix poll handlers Bot type annotations 2025-10-23 05:55:48 +03:00
Egor 2ee9b263b8 Merge pull request #1457 from Fr1ngg/revert-1456-9v7qqh-bedolaga/add-survey-functionality-to-bot
Revert "Add admin-managed polls with user rewards"
2025-10-23 05:55:15 +03:00
Egor 3a5d838be3 Revert "Add admin-managed polls with user rewards" 2025-10-23 05:55:06 +03:00
Egor bf25fcfda6 Merge pull request #1456 from Fr1ngg/9v7qqh-bedolaga/add-survey-functionality-to-bot
Add admin-managed polls with user rewards
2025-10-23 05:38:12 +03:00
Egor ecdf2fcae4 feat: add admin polls with rewards and delivery 2025-10-23 05:38:01 +03:00
Egor 6fec14de4b Merge pull request #1455 from Fr1ngg/revert-1454-k4r4kv-bedolaga/add-survey-functionality-to-bot
Revert "Add poll management and delivery system"
2025-10-23 05:37:48 +03:00
Egor 7b4cbb27b7 Revert "Add poll management and delivery system" 2025-10-23 05:37:40 +03:00
Egor d7d7795257 Merge pull request #1454 from Fr1ngg/k4r4kv-bedolaga/add-survey-functionality-to-bot
Add poll management and delivery system
2025-10-23 05:28:28 +03:00
Egor 93554c7034 Add poll management and delivery system 2025-10-23 05:28:13 +03:00
Egor fbe7febdde Merge pull request #1453 from Fr1ngg/g16e9c-bedolaga/check-referral-income-on-balance-top-up
Ensure MulenPay top-ups trigger referral rewards
2025-10-23 05:05:31 +03:00
Egor f091b3ea5e Add referral rewards for MulenPay top-ups 2025-10-23 05:05:16 +03:00
Egor aae469853e Merge pull request #1452 from Fr1ngg/main
ц
2025-10-23 04:55:09 +03:00
Egor dac68ccd8c Merge pull request #1451 from Gy9vin/main
ФИКС ПО ЮКАССЕ
2025-10-23 03:13:21 +03:00
gy9vin fb140e82f6 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-10-22 13:59:24 +03:00
gy9vin 8cac442de1 ФИКС ПРОСТОЙ ПОКУПКИ 2025-10-22 13:59:20 +03:00
Egor 8d8a465c43 Merge pull request #1450 from Gy9vin/main
фикс простой покупки кнопка всегда отображается
2025-10-22 10:01:50 +03:00
gy9vin 51f2c660e8 фикс простой покупки кнопка все отображается 2025-10-22 09:35:22 +03:00
Egor 39ecca306d Merge pull request #1449 from Fr1ngg/dev4
Dev4
2025-10-22 03:06:09 +03:00
Egor 7896a24e13 Merge pull request #1448 from Fr1ngg/beb27u-bedolaga/fix-quick-purchase-price-calculation
Fix simple subscription price calculations
2025-10-22 02:36:40 +03:00
Egor c21325f260 Skip unlimited traffic surcharge for simple plans 2025-10-22 02:36:23 +03:00
Egor 18df7395a2 Merge pull request #1447 from Fr1ngg/revert-1446-qg2f9r-bedolaga/fix-quick-purchase-price-calculation
Revert "Fix simple subscription price calculations"
2025-10-22 02:36:12 +03:00
Egor f139251333 Revert "Fix simple subscription price calculations" 2025-10-22 02:36:04 +03:00
Egor 369e384b7d Merge pull request #1446 from Fr1ngg/qg2f9r-bedolaga/fix-quick-purchase-price-calculation
Fix simple subscription price calculations
2025-10-22 02:23:05 +03:00
Egor 008f62e02b Add async test runner shim and fix Pal24 adapter test 2025-10-22 02:22:50 +03:00
Egor 6e8981a245 Merge pull request #1445 from Fr1ngg/revert-1444-z1xs4t-bedolaga/fix-quick-purchase-price-calculation
Revert "Fix simple subscription price calculations"
2025-10-22 02:22:41 +03:00
Egor bb17cf7361 Revert "Fix simple subscription price calculations" 2025-10-22 02:22:33 +03:00
Egor d13c6767e6 Merge pull request #1444 from Fr1ngg/z1xs4t-bedolaga/fix-quick-purchase-price-calculation
Fix simple subscription price calculations
2025-10-22 02:12:40 +03:00
Egor bf2ee37f33 Fix simple subscription pricing to include squad costs 2025-10-22 02:12:23 +03:00
Egor b82929ac3d Merge pull request #1443 from Fr1ngg/dev4
Dev4
2025-10-21 20:23:34 +03:00
Egor 9b0ea9bf2a Merge pull request #1442 from Fr1ngg/0opqnu-bedolaga/fix-balance-update-issue-with-heleket
Improve Heleket payment status synchronisation
2025-10-21 20:05:17 +03:00
Egor 5bde7196dc Improve Heleket payment status synchronisation 2025-10-21 20:04:59 +03:00
Egor 71c3e7fe0d Merge pull request #1441 from Fr1ngg/dev4
Dev4
2025-10-21 19:29:43 +03:00
Egor 201543c263 Merge pull request #1440 from Fr1ngg/revert-1439-revert-1437-tnkwdf-bedolaga/fix-webhook-not-receiving-notifications
Revert "Revert "Fix YooKassa webhook paid flag persistence""
2025-10-21 19:28:16 +03:00
Egor 5ce7417c80 Revert "Revert "Fix YooKassa webhook paid flag persistence"" 2025-10-21 19:28:01 +03:00
Egor e98564b5ab Merge pull request #1438 from Fr1ngg/main
ww
2025-10-21 19:27:13 +03:00
Egor 37dd48cc20 Merge pull request #1439 from Fr1ngg/revert-1437-tnkwdf-bedolaga/fix-webhook-not-receiving-notifications
Revert "Fix YooKassa webhook paid flag persistence"
2025-10-21 19:26:28 +03:00
Egor 9dff6e8045 Revert "Fix YooKassa webhook paid flag persistence" 2025-10-21 19:26:18 +03:00
Egor 572094050e Merge pull request #1434 from Gy9vin/main
Доработки
2025-10-21 19:25:27 +03:00
Egor 2b41337bbf Merge pull request #1437 from Fr1ngg/tnkwdf-bedolaga/fix-webhook-not-receiving-notifications
Fix YooKassa webhook paid flag persistence
2025-10-21 19:20:17 +03:00
Egor ca94c402bd Fix YooKassa webhook paid flag 2025-10-21 19:19:50 +03:00
gy9vin 6fdc46c3c6 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-10-21 15:24:38 +03:00
gy9vin 34fc568f97 простую покупку можно сделать с активированной подпиской добавит n дней к текущему количеству 2025-10-21 15:24:35 +03:00
Egor 38664ff853 Merge pull request #1433 from Fr1ngg/dev4
Dev4
2025-10-21 12:01:56 +03:00
Egor e44284d4ba Merge pull request #1432 from Fr1ngg/main
w
2025-10-21 12:01:10 +03:00
Egor d54dc822cc Merge pull request #1431 from Fr1ngg/uum2gm-bedolaga/fix-unresolved-resolved_squad_uuid-error
Ensure resolved squad uuid is set for simple subscription payments
2025-10-21 11:59:59 +03:00
Egor dbe9c2d5e9 Ensure resolved squad is set for simple subscription payments 2025-10-21 11:57:41 +03:00
Egor b036d090c6 Update Dockerfile 2025-10-21 11:31:44 +03:00
Egor d44b303dd3 Update docker-registry.yml 2025-10-21 11:31:34 +03:00
Egor e69bfdac20 Update docker-hub.yml 2025-10-21 11:31:20 +03:00
Egor 53f6c4e71b Merge pull request #1429 from Fr1ngg/dev4
Heleket
2025-10-21 11:22:48 +03:00
Egor c67223a332 Merge pull request #1428 from Fr1ngg/7mhcv8-bedolaga/move-heleket-to-payment-systems
Move Heleket settings into payment systems group
2025-10-21 11:21:57 +03:00
Egor 2c253382d5 Move Heleket settings into payment systems group 2025-10-21 11:21:37 +03:00
Egor 02b65cb1c9 Merge pull request #1427 from Fr1ngg/kbxpuv-bedolaga/add-heleket-balance-top-up-method
Polish Heleket payment integration
2025-10-21 11:13:09 +03:00
Egor 49ace78a68 Polish Heleket payment integration 2025-10-21 11:12:47 +03:00
Egor 0669086742 Merge pull request #1426 from Fr1ngg/k9cky4-bedolaga/add-balance-refill-method-for-heleket
Add Heleket payment provider integration
2025-10-21 10:59:36 +03:00
Egor 2f2d9bc1af Add Heleket payment provider integration 2025-10-21 10:59:19 +03:00
Egor 9b2b11ee44 Merge pull request #1425 from Fr1ngg/1n7xv6-bedolaga/update-simple-subscription-logic
Fix simple subscription start state data usage
2025-10-21 10:09:57 +03:00
Egor d318738596 Fix state data lookup during simple subscription start 2025-10-21 10:09:43 +03:00
Egor 6ae41369e9 Merge pull request #1424 from Fr1ngg/revert-1423-v20afv-bedolaga/update-simple-subscription-logic
Revert "Fix simple subscription button visibility and random squad assignment"
2025-10-21 10:09:19 +03:00
Egor ce9f2e0d41 Revert "Fix simple subscription button visibility and random squad assignment" 2025-10-21 10:09:08 +03:00
Egor aa7bd42375 Merge pull request #1423 from Fr1ngg/v20afv-bedolaga/update-simple-subscription-logic
Fix simple subscription button visibility and random squad assignment
2025-10-21 10:01:17 +03:00
Egor a6b19e4b95 Adjust simple subscription menu and squad assignment 2025-10-21 10:01:01 +03:00
Egor 7a21518ce2 Merge pull request #1422 from Fr1ngg/dev4
Dev4
2025-10-21 09:45:23 +03:00
Egor ba92f7da6c Merge pull request #1421 from Fr1ngg/52n798-bedolaga/escape-referrer-names-in-html-report
Escape referrer names in HTML report
2025-10-21 09:36:43 +03:00
Egor f62f37581f Escape referrer names in HTML report 2025-10-21 09:36:30 +03:00
Egor 0d08c2c7f0 Merge pull request #1420 from Fr1ngg/8jof80-bedolaga/fix-syntaxerror-in-purchase.py
Fix syntax issues in subscription purchase handler
2025-10-21 09:31:32 +03:00
Egor 078e59f09c Fix syntax issues in subscription purchase handler 2025-10-21 09:31:18 +03:00
Egor ab708a78a8 Merge pull request #1419 from Fr1ngg/mjma6t-bedolaga/integrate-reporting-service-code
feat: enhance reporting statistics
2025-10-21 09:26:09 +03:00
Egor 630688ecf5 feat: enhance reporting statistics 2025-10-21 09:25:53 +03:00
Egor 2b6901e87a Merge pull request #1418 from Fr1ngg/xfpbps-bedolaga/fix-button-in-subscription-renewal-section
Fix autopay day button label
2025-10-21 09:10:41 +03:00
Egor 0e43daf48b Fix autopay day button label 2025-10-21 09:10:26 +03:00
Egor e9badff1d8 Merge pull request #1417 from Fr1ngg/x4oldj-bedolaga/fix-subscription_config_back-button-behavior
Fix back navigation during subscription confirmation
2025-10-21 09:06:03 +03:00
Egor f588191931 Fix subscription back navigation restoring configuration 2025-10-21 09:05:44 +03:00
Egor e17dfbff35 Merge pull request #1415 from Fr1ngg/uq97y3-bedolaga/fix-back-button-issue-in-subscription-purchase
Handle back navigation after simple subscription QR message
2025-10-21 09:00:24 +03:00
Egor b7e9770588 Handle back navigation after simple subscription QR 2025-10-21 09:00:08 +03:00
Egor 9371524257 Merge pull request #1414 from Fr1ngg/main
w
2025-10-21 07:42:49 +03:00
Egor c1f8fe69cf Update requirements.txt 2025-10-21 00:04:51 +03:00
Egor 87e3449444 Update Dockerfile 2025-10-21 00:04:31 +03:00
Egor e8ef1bc255 Update requirements.txt 2025-10-21 00:00:50 +03:00
Egor fe3a593da0 Update requirements.txt 2025-10-20 23:58:29 +03:00
Egor bf96d85edb Update requirements.txt 2025-10-20 23:56:30 +03:00
Egor 700d718da6 Update Dockerfile 2025-10-20 23:52:48 +03:00
Egor 0c9ad3cec8 Merge pull request #1413 from Fr1ngg/revert-1295-dependabot/pip/python-dependencies-e109b03380
Revert "deps(deps): bump the python-dependencies group across 1 directory with 10 updates"
2025-10-20 23:48:46 +03:00
Egor 73d789d3f7 Revert "deps(deps): bump the python-dependencies group across 1 directory with 10 updates" 2025-10-20 23:48:35 +03:00
Egor 0a09ae4290 Update requirements.txt 2025-10-20 23:44:04 +03:00
Egor 0758988b5f Update requirements.txt 2025-10-20 23:42:11 +03:00
Egor 089f668e14 Merge pull request #1295 from Fr1ngg/dependabot/pip/python-dependencies-e109b03380
deps(deps): bump the python-dependencies group across 1 directory with 10 updates
2025-10-20 23:37:14 +03:00
dependabot[bot] 1e5bf143c4 deps(deps): bump the python-dependencies group across 1 directory with 10 updates
---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: sqlalchemy
  dependency-version: 2.0.44
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
- dependency-name: alembic
  dependency-version: 1.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pydantic
  dependency-version: 2.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pydantic-settings
  dependency-version: 2.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pyyaml
  dependency-version: 6.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
- dependency-name: fastapi
  dependency-version: 0.119.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: uvicorn
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: yookassa
  dependency-version: 3.7.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
- dependency-name: flask
  dependency-version: 3.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
...

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-18 23:42:42 +00:00
Egor d4e1544fc8 Update Python version to v2.5.3 in Dockerfile 2025-10-19 02:41:35 +03:00
Egor 6db5cae013 Update docker-registry.yml 2025-10-19 02:41:28 +03:00
Egor 61089a9f30 Update docker-hub.yml 2025-10-19 02:41:19 +03:00
Egor 3ad1f0fc97 Merge pull request #1408 from Fr1ngg/dev4
Dev4
2025-10-19 02:29:38 +03:00
Egor 67ac3c933f Merge pull request #1407 from Fr1ngg/g0v1v5-bedolaga/add-button-to-close-all-open-tickets
Add admin bulk close control for support tickets
2025-10-19 02:27:39 +03:00
Egor d6f4751274 Add bulk close control for admin tickets 2025-10-19 02:27:19 +03:00
Egor 6fdb02af10 Merge pull request #1406 from Fr1ngg/qbwjrp-bedolaga/fix-mulenpay-callback-handling
Handle alternative MulenPay webhook status fields
2025-10-19 02:25:09 +03:00
Egor ce456c4f7a Fix MulenPay webhook status parsing 2025-10-19 02:24:55 +03:00
Egor 314b17ce79 Merge pull request #1405 from Fr1ngg/revert-1404-zcb4ql-bedolaga/fix-mulenpay-callback-handling
Revert "Fix MulenPay callback paid status handling"
2025-10-19 02:24:41 +03:00
Egor 598d9ad0a1 Revert "Fix MulenPay callback paid status handling" 2025-10-19 02:23:39 +03:00
Egor 734711dab7 Merge pull request #1404 from Fr1ngg/zcb4ql-bedolaga/fix-mulenpay-callback-handling
Fix MulenPay callback paid status handling
2025-10-19 02:21:10 +03:00
Egor c4c5d6a018 Fix MulenPay callback marking payments as paid 2025-10-19 02:20:55 +03:00
Egor 883cd5a952 Merge pull request #1403 from Fr1ngg/p642y1-bedolaga/fix-paid-status-check-expiry-validation
Validate subscription expiry before treating paid status as active
2025-10-19 02:18:49 +03:00
Egor 8cf7445b99 Ensure paid subscriptions respect expiration dates 2025-10-19 02:18:32 +03:00
Egor 4c11af4644 Merge pull request #1402 from Fr1ngg/h2w24i-bedolaga/add-subscription-prompt-after-expiry
Fix subscription purchase availability after expiry
2025-10-19 02:12:11 +03:00
Egor 47036d5616 Fix subscription purchase availability after expiry 2025-10-19 02:11:55 +03:00
Egor 025330e052 Merge pull request #1401 from Fr1ngg/hc9da4-bedolaga/fix-typeerror-in-stage.skip-call
Fix stage skip message formatting
2025-10-19 02:04:14 +03:00
Egor 8d18c78fb2 Fix stage skip message format 2025-10-19 02:03:58 +03:00
Egor d93a2a0682 Merge pull request #1400 from Fr1ngg/7xyspp-bedolaga/add-urlpay-payment-method-for-mulenpay
feat: make MulenPay display name configurable
2025-10-19 02:01:48 +03:00
Egor 0c93516056 feat: make MulenPay display name configurable 2025-10-19 01:55:10 +03:00
Egor bf126bdadb Merge pull request #1399 from Fr1ngg/m7z0jw-bedolaga/fix-datetime-error-in-payment-processing
Fix WATA datetime timezone handling
2025-10-19 01:25:15 +03:00
Egor d421d43522 Fix WATA datetime timezone handling 2025-10-19 01:24:57 +03:00
Egor 29cda4dcd7 Merge pull request #1398 from Gy9vin/main
Простая покупка подписки!
2025-10-19 01:06:51 +03:00
gy9vin dde90f9ca4 продление подписки после пополнения баланса 2025-10-18 21:10:02 +03:00
gy9vin 8415c68063 Отправка конкретному пользователю сообщения непосредственно из карточки пользователя 2025-10-18 20:56:22 +03:00
gy9vin 9edde19b8a Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-10-18 19:14:28 +03:00
gy9vin 83a473606f Простая покупка подписки 2025-10-18 18:42:03 +03:00
Egor 849f06ad2b Merge pull request #1397 from Fr1ngg/dev4
Dev4
2025-10-18 02:17:57 +03:00
Egor e27e1299f7 Merge pull request #1396 from Fr1ngg/hppcmp-bedolaga/fix-wata-webhook-not-triggering
Allow WATA webhook generator to commit before responding
2025-10-18 02:17:27 +03:00
Egor 03a45e39d4 Let WATA webhook session generator finish before responding 2025-10-18 02:09:50 +03:00
Egor 4663464ea4 Merge pull request #1394 from Fr1ngg/revert-1392-94xvf3-bedolaga/fix-wata-webhook-not-triggering
Revert "Ensure WATA webhook lets session commit before responding"
2025-10-18 02:09:28 +03:00
Egor 338b98bae3 Revert "Ensure WATA webhook lets session commit before responding" 2025-10-18 02:09:19 +03:00
Egor 7326d64fbe Merge pull request #1393 from Fr1ngg/dev4
Dev4
2025-10-18 01:59:58 +03:00
Egor bee94bcad0 Merge pull request #1392 from Fr1ngg/94xvf3-bedolaga/fix-wata-webhook-not-triggering
Ensure WATA webhook lets session commit before responding
2025-10-18 01:59:22 +03:00
Egor 414b796700 Ensure WATA webhook session commits before responding 2025-10-18 01:58:33 +03:00
Egor f76abce939 Merge pull request #1391 from Fr1ngg/revert-1388-qnq9d9-bedolaga/fix-wata-webhook-not-triggering
Revert "Fix WATA webhook session handling"
2025-10-18 01:58:19 +03:00
Egor 99460665f1 Revert "Fix WATA webhook session handling" 2025-10-18 01:58:06 +03:00
Egor 730a6ae185 Merge pull request #1390 from Fr1ngg/dev4
Dev4
2025-10-18 01:51:14 +03:00
Egor ab8a5ca336 Merge pull request #1388 from Fr1ngg/qnq9d9-bedolaga/fix-wata-webhook-not-triggering
Fix WATA webhook session handling
2025-10-18 01:50:47 +03:00
Egor d1f0cf5854 Merge pull request #1389 from Fr1ngg/revert-1385-wigsy6-bedolaga/fix-wata-webhook-not-triggering
Revert "Expose payment webhook ports in docker-compose"
2025-10-18 01:50:38 +03:00
Egor 1f3c85e182 Revert "Expose payment webhook ports in docker-compose" 2025-10-18 01:50:30 +03:00
Egor bfcfe0de3d Fix WATA webhook session handling 2025-10-18 01:50:04 +03:00
Egor f55f745080 Merge pull request #1386 from Fr1ngg/dev4
Dev4
2025-10-18 01:40:35 +03:00
Egor a26524e6ea Merge pull request #1385 from Fr1ngg/wigsy6-bedolaga/fix-wata-webhook-not-triggering
Expose payment webhook ports in docker-compose
2025-10-18 01:39:51 +03:00
Egor b5f57d4d0e Expose payment webhook ports in compose 2025-10-18 01:39:32 +03:00
Egor 5aa9c1316e Merge pull request #1384 from Fr1ngg/revert-1383-gb3ntc-bedolaga/fix-wata-webhook-not-triggering
Revert "Add WATA webhook listener with RSA signature verification"
2025-10-18 01:39:18 +03:00
Egor 524b4f5e64 Revert "Add WATA webhook listener with RSA signature verification" 2025-10-18 01:39:08 +03:00
Egor a2bb0b8341 Merge pull request #1383 from Fr1ngg/gb3ntc-bedolaga/fix-wata-webhook-not-triggering
Add WATA webhook listener with RSA signature verification
2025-10-18 01:21:19 +03:00
Egor 9cabbf72d2 Add WATA webhook support with signature verification 2025-10-18 01:20:51 +03:00
Egor 5f368f84ce Merge pull request #1382 from Fr1ngg/bedolaga-3a5xcg
Fix quick top-up buttons for Stars and CryptoBot
2025-10-18 01:02:15 +03:00
Egor ed01d2bc6f Fix quick top-up buttons for stars and cryptobot 2025-10-18 01:01:56 +03:00
Egor 7107070624 Merge pull request #1381 from Fr1ngg/dev4
Dev4
2025-10-17 17:23:05 +03:00
Egor c361f0c1d4 Merge pull request #1379 from Fr1ngg/noejf1-bedolaga/fix-bot-not-recognizing-payment-webhook
Fix Pal24 cart reminder callback
2025-10-17 17:22:21 +03:00
Egor ab95bc2192 Merge pull request #1380 from Fr1ngg/revert-1377-gkxf4k-bedolaga/fix-bot-not-recognizing-payment-webhook
Revert "Handle Pal24 payments when webhooks are missing"
2025-10-17 17:22:09 +03:00
Egor bf6a68ae27 Revert "Handle Pal24 payments when webhooks are missing" 2025-10-17 17:21:58 +03:00
Egor 1083651591 Fix Pal24 cart reminder callback 2025-10-17 17:21:11 +03:00
Egor b35fb824ec Merge pull request #1378 from Fr1ngg/dev4
Рефактор палыча
2025-10-17 17:14:29 +03:00
Egor ad839fb63d Merge pull request #1377 from Fr1ngg/gkxf4k-bedolaga/fix-bot-not-recognizing-payment-webhook
Handle Pal24 payments when webhooks are missing
2025-10-17 17:09:25 +03:00
Egor 377a731ca2 Handle Pal24 payments when webhooks are missing 2025-10-17 17:08:59 +03:00
Egor 3395021fcf Merge pull request #1376 from Fr1ngg/main
w
2025-10-17 16:48:45 +03:00
Egor a4ce1a83be Merge pull request #1375 from Gy9vin/main
QR-код в ЮКассе для оплаты с другого телефона!
2025-10-17 16:48:06 +03:00
gy9vin 8b2f9e1803 Оплата по qr с другого телефона по СБП через ЮКасса 2025-10-17 11:20:08 +03:00
Mikhail a40c07a6af Merge branch 'Fr1ngg:main' into main 2025-10-17 09:20:11 +03:00
Egor 2dbc23fa80 Merge pull request #1374 from Fr1ngg/dev4
Dev4
2025-10-16 17:24:54 +03:00
Egor 233401c057 Merge branch 'main' into dev4 2025-10-16 17:23:52 +03:00
Egor 66f17f90fd Merge pull request #1371 from Fr1ngg/v2kjyk-bedolaga/fix-paypal-balance-top-up-error
Fix Pal24 payment method values
2025-10-16 17:21:51 +03:00
Egor 9748cc6b3e Merge pull request #1373 from Fr1ngg/revert-1369-58400a-bedolaga/fix-paypal-balance-top-up-error
Revert "Fix Pal24 custom payload serialization"
2025-10-16 17:21:29 +03:00
Egor bcab53cfe3 Revert "Fix Pal24 custom payload serialization" 2025-10-16 17:21:20 +03:00
Egor 1af387de2b Merge pull request #1372 from Fr1ngg/revert-1370-dev4
Revert "Dev4"
2025-10-16 17:21:08 +03:00
Egor 25c54c588b Revert "Dev4" 2025-10-16 17:20:58 +03:00
Egor 6ba7c6ed5e Fix Pal24 payment method values 2025-10-16 17:20:25 +03:00
Egor 222c1a488f Merge pull request #1370 from Fr1ngg/dev4
Dev4
2025-10-16 16:55:24 +03:00
Egor c1a92adf41 Merge pull request #1369 from Fr1ngg/58400a-bedolaga/fix-paypal-balance-top-up-error
Fix Pal24 custom payload serialization
2025-10-16 16:54:58 +03:00
Egor 972bb18344 Serialize Pal24 custom payloads 2025-10-16 16:54:37 +03:00
Egor de11de4cd0 Merge pull request #1368 from Fr1ngg/dev4
Dev4
2025-10-16 16:37:47 +03:00
Egor f8c3daac23 Merge pull request #1367 from Fr1ngg/gpcn99-bedolaga/fix-balance-top-up-via-pal24
Fix Pal24 payment method mapping
2025-10-16 16:37:24 +03:00
Egor 91a3b14a1f Fix Pal24 payment method mapping 2025-10-16 16:36:56 +03:00
Egor d310896168 Merge pull request #1366 from Fr1ngg/revert-1363-xa5gb6-bedolaga/fix-paypal-payment-creation-error
Revert "Retry Pal24 bill creation with multiple method identifiers"
2025-10-16 16:29:15 +03:00
Egor d4df56b173 Revert "Retry Pal24 bill creation with multiple method identifiers" 2025-10-16 16:29:07 +03:00
Egor 3eab0b3848 Merge pull request #1365 from Fr1ngg/revert-1361-revert-1360-dev4
Revert "Revert "Dev4""
2025-10-16 16:28:03 +03:00
Egor 609a557502 Revert "Revert "Dev4"" 2025-10-16 16:25:53 +03:00
Egor 6a4ebad89f Merge pull request #1363 from Fr1ngg/xa5gb6-bedolaga/fix-paypal-payment-creation-error
Retry Pal24 bill creation with multiple method identifiers
2025-10-16 16:22:13 +03:00
Egor e229b625f6 Retry Pal24 bill creation with multiple method identifiers 2025-10-16 16:19:24 +03:00
Egor 3ff5dec4d3 Merge pull request #1362 from Fr1ngg/revert-1359-fc1v04-bedolaga/fix-paypal-payment-creation-error
Revert "Fix Pal24 payment method mapping"
2025-10-16 16:08:37 +03:00
Egor 6dfd7db115 Revert "Fix Pal24 payment method mapping" 2025-10-16 16:08:28 +03:00
Egor 782dff4f82 Merge pull request #1361 from Fr1ngg/revert-1360-dev4
Revert "Dev4"
2025-10-16 16:08:14 +03:00
Egor 1540d2665f Revert "Dev4" 2025-10-16 16:08:03 +03:00
Egor 567d0c1a8d Merge pull request #1360 from Fr1ngg/dev4
Dev4
2025-10-16 01:38:44 +03:00
Egor 6b031d4e9c Merge pull request #1359 from Fr1ngg/fc1v04-bedolaga/fix-paypal-payment-creation-error
Fix Pal24 payment method mapping
2025-10-16 01:36:48 +03:00
Egor 72046d1ccf Fix Pal24 payment method mapping 2025-10-16 01:35:54 +03:00
Egor d41af2cd85 Merge pull request #1358 from Fr1ngg/main
w
2025-10-16 01:29:47 +03:00
Egor 174647c963 Update README.md 2025-10-16 01:21:50 +03:00
Egor e5e52b839c Merge pull request #1357 from Fr1ngg/dev4
Фикс палыча
2025-10-15 23:49:28 +03:00
Egor ab19becdc8 Delete miniapp/new/miniapp directory 2025-10-15 23:47:35 +03:00
Egor f5e9a6f2b9 Merge pull request #1356 from Fr1ngg/4dxbnt-bedolaga/fix-pal24-payment-system-issues
Fix Pal24 webhook and payment flows
2025-10-15 23:39:09 +03:00
Egor b492437b54 Fix Pal24 payment flows and webhook handling 2025-10-15 23:38:51 +03:00
Egor 07546da0fd Merge pull request #1355 from Fr1ngg/main
w
2025-10-15 22:25:35 +03:00
Egor fc4423d9de Merge pull request #1354 from yazhog/main
Снес из репозитория пользовательские локали
2025-10-15 22:24:26 +03:00
Mikhail c9a0f59e28 Merge pull request #1 from Fr1ngg/main
ошибки пофиксил
2025-10-15 21:50:16 +03:00
yazhog 2d31bc6f23 Merge pull request #46 from yazhog/codex/customize-locale-phrases-for-git-pull
Sync default locales with custom keys
2025-10-15 20:33:08 +03:00
yazhog dac2e1c85f docs: document locales directory setup 2025-10-15 20:32:42 +03:00
yazhog f4d9b0d219 Clarify locale directory permission warnings 2025-10-15 20:32:35 +03:00
yazhog 9b485bf822 Handle unwritable locale directories gracefully 2025-10-15 20:21:52 +03:00
yazhog af3e8eab4e Ensure user locales include default templates 2025-10-15 20:15:48 +03:00
yazhog 63d7500398 Remove user locale overrides from repo 2025-10-15 20:09:32 +03:00
yazhog 545f52f207 Sync default locales with custom keys 2025-10-15 20:02:37 +03:00
Egor 7fbb4cf2da Add files via upload 2025-10-15 08:14:17 +03:00
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
dependabot[bot] 7be8757964 deps(deps): bump aiofiles from 23.2.1 to 25.1.0
Bumps [aiofiles](https://github.com/Tinche/aiofiles) from 23.2.1 to 25.1.0.
- [Release notes](https://github.com/Tinche/aiofiles/releases)
- [Changelog](https://github.com/Tinche/aiofiles/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Tinche/aiofiles/compare/v23.2.1...v25.1.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-13 07:22:49 +00:00
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
235 changed files with 76556 additions and 17584 deletions
+106 -8
View File
@@ -67,6 +67,14 @@ REMNAWAVE_SECRET_KEY=
# {telegram_id} — ID Telegram
REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}"
# Шаблон имени пользователя в панели Remnawave
# Доступные плейсхолдеры аналогичны описанию выше
# {full_name} — Имя, Фамилия из Telegram
# {username} — @логин из Telegram (c @)
# {username_clean} — логин из Telegram (без @)
# {telegram_id} — ID Telegram
REMNAWAVE_USER_USERNAME_TEMPLATE="user_{telegram_id}"
# Режим удаления пользователей из панели RemnaWave
# delete - полностью удалить пользователя из панели
# disable - только деактивировать пользователя
@@ -77,7 +85,8 @@ REMNAWAVE_USER_DELETE_MODE=delete
TRIAL_DURATION_DAYS=3
TRIAL_TRAFFIC_LIMIT_GB=10
TRIAL_DEVICE_LIMIT=1
TRIAL_SQUAD_UUID=
TRIAL_PAYMENT_ENABLED=false
TRIAL_ACTIVATION_PRICE=0
# ===== ПЛАТНАЯ ПОДПИСКА =====
# Сколько устройств доступно по дефолту при покупке платной подписки
@@ -113,12 +122,24 @@ FIXED_TRAFFIC_LIMIT_GB=100
AVAILABLE_SUBSCRIPTION_PERIODS=30,90,180
AVAILABLE_RENEWAL_PERIODS=30,90,180
# ===== НАСТРОЙКИ ПРОСТОЙ ПОКУПКИ =====
# Включить упрощённую покупку из меню
SIMPLE_SUBSCRIPTION_ENABLED=false
# Стандартный период (должен совпадать с одним из AVAILABLE_SUBSCRIPTION_PERIODS)
SIMPLE_SUBSCRIPTION_PERIOD_DAYS=30
# Сколько устройств выдаётся в рамках простой подписки
SIMPLE_SUBSCRIPTION_DEVICE_LIMIT=1
# Лимит трафика в ГБ (0 — безлимит)
SIMPLE_SUBSCRIPTION_TRAFFIC_GB=0
# UUID сквада (оставьте пустым, чтобы использовать сквады по умолчанию)
# SIMPLE_SUBSCRIPTION_SQUAD_UUID=
# ===== ЦЕНЫ (в копейках) =====
BASE_SUBSCRIPTION_PRICE=0
# Цены за периоды
PRICE_14_DAYS=7000
PRICE_30_DAYS=9900
PRICE_30_DAYS=1000
PRICE_60_DAYS=25900
PRICE_90_DAYS=36900
PRICE_180_DAYS=69900
@@ -130,12 +151,17 @@ BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED=false
BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
# Выводимые пакеты трафика и их цены в копейках
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:0:true"
# Цена за дополнительное устройство (DEFAULT_DEVICE_LIMIT идет бесплатно!)
PRICE_PER_DEVICE=5000
PRICE_PER_DEVICE=10000
# Включить выбор количества устройств при покупке и продлении
DEVICES_SELECTION_ENABLED=true
# Единое количество устройств для режима без выбора (0 — не назначать устройства)
DEVICES_SELECTION_DISABLED_AMOUNT=0
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
REFERRAL_PROGRAM_ENABLED=true
REFERRAL_MINIMUM_TOPUP_KOPEKS=10000
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
REFERRAL_INVITER_BONUS_KOPEKS=10000
@@ -154,8 +180,8 @@ MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
# ===== ПЛАТЕЖНЫЕ СИСТЕМЫ =====
# Telegram Stars (работает автоматически)
TELEGRAM_STARS_ENABLED=true
TELEGRAM_STARS_RATE_RUB=1.3
TELEGRAM_STARS_ENABLED=false
TELEGRAM_STARS_RATE_RUB=1.79
# Tribute (https://tribute.app)
TRIBUTE_ENABLED=false
@@ -163,6 +189,8 @@ TRIBUTE_API_KEY=
TRIBUTE_DONATE_LINK=
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
TRIBUTE_WEBHOOK_HOST=0.0.0.0
# Примечание: индивидуальные *_WEBHOOK_PORT используются только для старой схемы запуска.
# В режиме единого FastAPI сервера оставьте эти значения по умолчанию.
TRIBUTE_WEBHOOK_PORT=8081
# YooKassa (https://yookassa.ru)
@@ -215,7 +243,6 @@ YOOKASSA_PAYMENT_SUBJECT=service
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
YOOKASSA_WEBHOOK_HOST=0.0.0.0
YOOKASSA_WEBHOOK_PORT=8082
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
# Лимиты сумм пополнения через YooKassa (в копейках)
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
@@ -224,8 +251,16 @@ YOOKASSA_MAX_AMOUNT_KOPEKS=1000000
# Быстрый выбор суммы пополнения через YooKassa
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
DISABLE_TOPUP_BUTTONS=false
# Автоматическая проверка зависших пополнений и повторные обращения к провайдерам
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED=false
# Интервал (в минутах) между автоматическими проверками пополнений
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES=10
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
# Эти настройки позволяют изменить описания платежей,
# Эти настройки позволяют изменить описания платежей,
# чтобы избежать блокировок платежных систем
PAYMENT_SERVICE_NAME=Интернет-сервис
PAYMENT_BALANCE_DESCRIPTION=Пополнение баланса
@@ -240,11 +275,29 @@ CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here
CRYPTOBOT_BASE_URL=https://pay.crypt.bot
CRYPTOBOT_TESTNET=false
CRYPTOBOT_WEBHOOK_PATH=/cryptobot-webhook
# Эти порты используются только при ручном запуске старого aiohttp сервера.
# В новой конфигурации все вебхуки обрабатываются через порт 8080.
CRYPTOBOT_WEBHOOK_PORT=8081
CRYPTOBOT_DEFAULT_ASSET=USDT
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
# HELEKET
HELEKET_ENABLED=false
HELEKET_MERCHANT_ID=
HELEKET_API_KEY=
HELEKET_BASE_URL=https://api.heleket.com/v1
HELEKET_DEFAULT_CURRENCY=USDT
HELEKET_DEFAULT_NETWORK=
HELEKET_INVOICE_LIFETIME=3600
HELEKET_MARKUP_PERCENT=0
HELEKET_WEBHOOK_PATH=/heleket-webhook
HELEKET_WEBHOOK_HOST=0.0.0.0
HELEKET_WEBHOOK_PORT=8086
HELEKET_CALLBACK_URL=
HELEKET_RETURN_URL=
HELEKET_SUCCESS_URL=
# MULENPAY
MULENPAY_ENABLED=false
MULENPAY_API_KEY=
@@ -273,6 +326,26 @@ 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
# PLATEGA
PLATEGA_ENABLED=false
PLATEGA_MERCHANT_ID=
PLATEGA_SECRET=
PLATEGA_BASE_URL=https://app.platega.io
PLATEGA_RETURN_URL=
PLATEGA_FAILED_URL=
PLATEGA_CURRENCY=RUB
# Список ID активных методов из кабинета Platega (через запятую)
PLATEGA_ACTIVE_METHODS=2,10,11,12,13
PLATEGA_MIN_AMOUNT_KOPEKS=10000
PLATEGA_MAX_AMOUNT_KOPEKS=100000000
PLATEGA_WEBHOOK_PATH=/platega-webhook
PLATEGA_WEBHOOK_HOST=0.0.0.0
PLATEGA_WEBHOOK_PORT=8086
# ===== ИНТЕРФЕЙС И UX =====
@@ -280,6 +353,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
@@ -293,6 +369,7 @@ CONNECT_BUTTON_MODE=guide
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
MINIAPP_CUSTOM_URL=
MINIAPP_STATIC_PATH=miniapp
MINIAPP_SERVICE_NAME_EN=Bedolaga VPN
MINIAPP_SERVICE_NAME_RU=Bedolaga VPN
MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection
@@ -344,6 +421,8 @@ SERVER_STATUS_ITEMS_PER_PAGE=10
MAINTENANCE_MODE=false
MAINTENANCE_CHECK_INTERVAL=30
MAINTENANCE_AUTO_ENABLE=true
MAINTENANCE_MONITORING_ENABLED=true
MAINTENANCE_RETRY_ATTEMPTS=1
MAINTENANCE_MESSAGE=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
# ===== ЛОКАЛИЗАЦИЯ =====
@@ -352,6 +431,8 @@ DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en
# Включить выбор языка при старте и отображение кнопки в меню
LANGUAGE_SELECTION_ENABLED=true
# Часовой пояс
TZ=Europe/Moscow # или UTC, America/New_York и т.д.
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
# Конфигурация приложений для гайда подключения
@@ -389,3 +470,20 @@ LOG_FILE=logs/bot.log
DEBUG=false
WEBHOOK_URL=
WEBHOOK_PATH=/webhook
WEBHOOK_SECRET_TOKEN=
WEBHOOK_DROP_PENDING_UPDATES=true
WEBHOOK_MAX_QUEUE_SIZE=1024
WEBHOOK_WORKERS=4
WEBHOOK_ENQUEUE_TIMEOUT=0.1
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT=30.0
BOT_RUN_MODE=polling # polling, webhook или both
# ===== ЕДИНЫЙ ВЕБ-СЕРВЕР =====
WEB_API_ENABLED=false
WEB_API_HOST=0.0.0.0
WEB_API_PORT=8080
WEB_API_ALLOWED_ORIGINS=*
WEB_API_DOCS_ENABLED=false
WEB_API_DEFAULT_TOKEN=
WEB_API_DEFAULT_TOKEN_NAME=Bootstrap Token
MINIAPP_STATIC_PATH=miniapp
+42
View File
@@ -0,0 +1,42 @@
---
name: Bug Bedolage
about: Создать баг-репорт «Бедолаге»
title: "[BUG] Заголовок"
labels: ''
assignees: ''
---
## Описание / Конфигурация
Кратко описываем текущую проблему.
Указываем настройки из `.env` конфига (если таковые имеются):
```yml
```
## Шаги для воспроизведения
1.
2.
3.
### Фактический результат
*
*
*
### Скрины поведения
Копируем изображение и вставляем прям сюда, если есть. Если нет — удаляем секцию.
## Ожидаемое поведение
Опишите, как должно работать.
## Дополнительная информация
Добавляем сюда дополнительную инфу, если есть. Если нет — удаляем секцию.
+32
View File
@@ -0,0 +1,32 @@
---
name: Feat Bedolage
about: Создать запрос на фичу «Бедолаге»
title: "[FEAT] Заголовок"
labels: ''
assignees: ''
---
## Описание / Конфигурация
Кратко описываем что хотим.
Указываем настройки из `.env` конфига (если таковые к месту):
```yml
```
## Текущее поведение
### Скрины поведения
Копируем изображение и вставляем прям сюда, если есть. Если нет — удаляем секцию.
## Желаемое поведение
Опишите, как хотелось бы, чтобы было.
## Дополнительная информация
Добавляем сюда дополнительную инфу, если есть. Если нет — удаляем секцию.
+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.7-$(git rev-parse --short HEAD)"
VERSION="v2.6.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.7-dev-$(git rev-parse --short HEAD)"
VERSION="v2.6.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.7-pr-$(git rev-parse --short HEAD)"
VERSION="v2.6.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.7-$(git rev-parse --short HEAD)"
VERSION="v2.6.2-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.4.7-dev-$(git rev-parse --short HEAD)"
VERSION="v2.6.2-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.4.7-pr-$(git rev-parse --short HEAD)"
VERSION="v2.6.2-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+3 -2
View File
@@ -1,5 +1,6 @@
# Игнорируем все файлы и папки по умолчанию
*
docker-compose.override.yml
# Исключения: разрешаем только нужные файлы
!.dockerignore
@@ -15,8 +16,8 @@
# Разрешаем папку app/ и все её содержимое рекурсивно
!app/
!app/**
!locales/
!locales/**
!tests/
!tests/**
# Дополнительно разрешаем README и лицензию (опционально)
!README.md
+3 -3
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v2.4.7"
ARG VERSION="v2.6.2"
ARG BUILD_DATE
ARG VCS_REF
@@ -45,7 +45,7 @@ ENV PYTHONPATH=/app \
BUILD_DATE=${BUILD_DATE} \
VCS_REF=${VCS_REF}
EXPOSE 8080 8081 8082
EXPOSE 8080
LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
org.opencontainers.image.description="Telegram bot for RemnaWave VPN service" \
@@ -57,6 +57,6 @@ LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
org.opencontainers.image.vendor="fr1ngg"
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
CMD ["python", "main.py"]
+39
View File
@@ -0,0 +1,39 @@
.PHONY: up
up: ## Поднять контейнеры (detached)
@echo "🚀 Поднимаем контейнеры (detached)..."
docker compose up -d --build
.PHONY: up-follow
up-follow: ## Поднять контейнеры с логами
@echo "📡 Поднимаем контейнеры (в консоли)..."
docker compose up --build
.PHONY: down
down: ## Остановить и удалить контейнеры
@echo "🛑 Останавливаем и удаляем контейнеры..."
docker compose down
.PHONY: reload
reload: ## Перезапустить контейнеры (detached)
@$(MAKE) down
@$(MAKE) up
.PHONY: reload-follow
reload-follow: ## Перезапустить контейнеры с логами
@$(MAKE) down
@$(MAKE) up-follow
.PHONY: test
test: ## Запустить тесты
@echo "🧪 Запускаем тесты..."
pytest -v
.PHONY: help
help: ## Показать список доступных команд
@echo ""
@echo "📘 Команды Makefile:"
@echo ""
@grep -E '^[a-zA-Z0-9_-]+:.*?##' $(MAKEFILE_LIST) | \
sed -E 's/:.*?## /| /' | \
awk -F'|' '{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
@echo ""
+803 -1249
View File
File diff suppressed because it is too large Load Diff
+2 -4
View File
@@ -136,8 +136,7 @@ logger.debug(f"Webhook payload: {webhook_data}")
# Открыть только необходимые порты
ufw allow 80/tcp # HTTP (redirect to HTTPS)
ufw allow 443/tcp # HTTPS
ufw deny 8081/tcp # Webhook порты (только через reverse proxy)
ufw deny 8082/tcp
ufw deny 8080/tcp # Unified FastAPI сервер доступен только из внутренней сети
```
### 📊 Мониторинг безопасности
@@ -239,7 +238,6 @@ REMNAWAVE_API_KEY=your_api_key
ADMIN_IDS=123456789,987654321
# Webhook секреты - генерируйте случайные значения
YOOKASSA_WEBHOOK_SECRET=random_secret_here
TRIBUTE_WEBHOOK_SECRET=another_random_secret
CRYPTOBOT_WEBHOOK_SECRET=yet_another_secret
```
@@ -283,7 +281,7 @@ services:
# Не пробрасывайте порты наружу без необходимости
ports:
- "127.0.0.1:8081:8081" # Только localhost
- "127.0.0.1:8080:8080" # Только localhost
# Read-only root filesystem
read_only: true
+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": "-"
}
}
}
]
}
}
}
+35 -9
View File
@@ -10,14 +10,25 @@ 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
from app.handlers import (
start, menu, subscription, balance, promocode,
referral, support, server_status, common, tickets
start,
menu,
subscription,
balance,
promocode,
referral,
support,
server_status,
common,
tickets,
)
from app.handlers import polls as user_polls
from app.handlers import simple_subscription
from app.handlers.admin import (
main as admin_main,
users as admin_users,
@@ -29,6 +40,7 @@ from app.handlers.admin import (
rules as admin_rules,
remnawave as admin_remnawave,
statistics as admin_statistics,
polls as admin_polls,
servers as admin_servers,
maintenance as admin_maintenance,
promo_groups as admin_promo_groups,
@@ -46,6 +58,7 @@ from app.handlers.admin import (
privacy_policy as admin_privacy_policy,
public_offer as admin_public_offer,
faq as admin_faq,
payments as admin_payments,
)
from app.handlers.stars_payments import register_stars_handlers
@@ -102,6 +115,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())
@@ -122,7 +139,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)
@@ -139,6 +156,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_rules.register_handlers(dp)
admin_remnawave.register_handlers(dp)
admin_statistics.register_handlers(dp)
admin_polls.register_handlers(dp)
admin_promo_groups.register_handlers(dp)
admin_campaigns.register_handlers(dp)
admin_promo_offers.register_handlers(dp)
@@ -155,15 +173,23 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_privacy_policy.register_handlers(dp)
admin_public_offer.register_handlers(dp)
admin_faq.register_handlers(dp)
admin_payments.register_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
user_polls.register_handlers(dp)
simple_subscription.register_simple_subscription_handlers(dp)
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
logger.info("⚡ Зарегистрированы обработчики простой покупки")
logger.info("⚡ Зарегистрированы обработчики простой подписки")
try:
await maintenance_service.start_monitoring()
logger.info("Мониторинг техработ запущен")
except Exception as e:
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
if settings.is_maintenance_monitoring_enabled():
try:
await maintenance_service.start_monitoring()
logger.info("Мониторинг техработ запущен")
except Exception as e:
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
else:
logger.info("Мониторинг техработ отключен настройками")
logger.info("🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries")
logger.info("Бот успешно настроен")
+504 -40
View File
@@ -7,11 +7,21 @@ import html
from collections import defaultdict
from datetime import time
from typing import List, Optional, Union, Dict
from zoneinfo import ZoneInfo
from pydantic_settings import BaseSettings
from pydantic import field_validator, Field
from pathlib import Path
DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS = [
"tme",
"joingroup",
]
logger = logging.getLogger(__name__)
class Settings(BaseSettings):
BOT_TOKEN: str
@@ -51,6 +61,8 @@ class Settings(BaseSettings):
SQLITE_PATH: str = "./data/bot.db"
LOCALES_PATH: str = "./locales"
TIMEZONE: str = Field(default_factory=lambda: os.getenv("TZ", "UTC"))
DATABASE_MODE: str = "auto"
@@ -64,15 +76,19 @@ class Settings(BaseSettings):
REMNAWAVE_PASSWORD: Optional[str] = None
REMNAWAVE_AUTH_TYPE: str = "api_key"
REMNAWAVE_USER_DESCRIPTION_TEMPLATE: str = "Bot user: {full_name} {username}"
REMNAWAVE_USER_USERNAME_TEMPLATE: str = "user_{telegram_id}"
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
TRIAL_DEVICE_LIMIT: int = 2
TRIAL_ADD_REMAINING_DAYS_TO_PAID: bool = False
TRIAL_PAYMENT_ENABLED: bool = False
TRIAL_ACTIVATION_PRICE: int = 0
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
DEFAULT_DEVICE_LIMIT: int = 1
TRIAL_SQUAD_UUID: Optional[str] = None
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
RESET_TRAFFIC_ON_PAYMENT: bool = False
MAX_DEVICES_LIMIT: int = 20
@@ -112,10 +128,12 @@ class Settings(BaseSettings):
PRICE_TRAFFIC_500GB: int = 19000
PRICE_TRAFFIC_1000GB: int = 19500
PRICE_TRAFFIC_UNLIMITED: int = 20000
TRAFFIC_PACKAGES_CONFIG: str = ""
PRICE_PER_DEVICE: int = 5000
DEVICES_SELECTION_ENABLED: bool = True
DEVICES_SELECTION_DISABLED_AMOUNT: Optional[int] = None
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False
BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = ""
@@ -128,9 +146,9 @@ 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"
@@ -142,8 +160,10 @@ class Settings(BaseSettings):
INACTIVE_USER_DELETE_MONTHS: int = 3
MAINTENANCE_MODE: bool = False
MAINTENANCE_CHECK_INTERVAL: int = 30
MAINTENANCE_AUTO_ENABLE: bool = True
MAINTENANCE_CHECK_INTERVAL: int = 30
MAINTENANCE_AUTO_ENABLE: bool = True
MAINTENANCE_MONITORING_ENABLED: bool = True
MAINTENANCE_RETRY_ATTEMPTS: int = 1
MAINTENANCE_MESSAGE: str = "🔧 Ведутся технические работы. Сервис временно недоступен. Попробуйте позже."
TELEGRAM_STARS_ENABLED: bool = True
@@ -168,10 +188,22 @@ class Settings(BaseSettings):
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_TRUSTED_PROXY_NETWORKS: str = ""
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_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
# Настройки простой покупки
SIMPLE_SUBSCRIPTION_ENABLED: bool = False
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
SIMPLE_SUBSCRIPTION_DEVICE_LIMIT: int = 1
SIMPLE_SUBSCRIPTION_TRAFFIC_GB: int = 0 # 0 означает безлимит
SIMPLE_SUBSCRIPTION_SQUAD_UUID: Optional[str] = None
PAYMENT_BALANCE_DESCRIPTION: str = "Пополнение баланса"
PAYMENT_SUBSCRIPTION_DESCRIPTION: str = "Оплата подписки"
PAYMENT_SERVICE_NAME: str = "Интернет-сервис"
@@ -189,15 +221,35 @@ class Settings(BaseSettings):
CRYPTOBOT_ASSETS: str = "USDT,TON,BTC,ETH"
CRYPTOBOT_INVOICE_EXPIRES_HOURS: int = 24
HELEKET_ENABLED: bool = False
HELEKET_MERCHANT_ID: Optional[str] = None
HELEKET_API_KEY: Optional[str] = None
HELEKET_BASE_URL: str = "https://api.heleket.com/v1"
HELEKET_DEFAULT_CURRENCY: str = "USDT"
HELEKET_DEFAULT_NETWORK: Optional[str] = None
HELEKET_INVOICE_LIFETIME: int = 3600
HELEKET_MARKUP_PERCENT: float = 0.0
HELEKET_WEBHOOK_PATH: str = "/heleket-webhook"
HELEKET_WEBHOOK_HOST: str = "0.0.0.0"
HELEKET_WEBHOOK_PORT: int = 8086
HELEKET_CALLBACK_URL: Optional[str] = None
HELEKET_RETURN_URL: Optional[str] = None
HELEKET_SUCCESS_URL: Optional[str] = None
MULENPAY_ENABLED: bool = False
MULENPAY_API_KEY: Optional[str] = None
MULENPAY_SECRET_KEY: Optional[str] = None
MULENPAY_SHOP_ID: Optional[int] = None
MULENPAY_BASE_URL: str = "https://mulenpay.ru/api"
MULENPAY_WEBHOOK_PATH: str = "/mulenpay-webhook"
MULENPAY_DISPLAY_NAME: str = "Mulen Pay"
MULENPAY_DESCRIPTION: str = "Пополнение баланса"
MULENPAY_LANGUAGE: str = "ru"
MULENPAY_VAT_CODE: int = 0
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
@@ -216,9 +268,45 @@ 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
PLATEGA_ENABLED: bool = False
PLATEGA_MERCHANT_ID: Optional[str] = None
PLATEGA_SECRET: Optional[str] = None
PLATEGA_BASE_URL: str = "https://app.platega.io"
PLATEGA_RETURN_URL: Optional[str] = None
PLATEGA_FAILED_URL: Optional[str] = None
PLATEGA_CURRENCY: str = "RUB"
PLATEGA_ACTIVE_METHODS: str = "2,10,11,12,13"
PLATEGA_MIN_AMOUNT_KOPEKS: int = 10000
PLATEGA_MAX_AMOUNT_KOPEKS: int = 100000000
PLATEGA_WEBHOOK_PATH: str = "/platega-webhook"
PLATEGA_WEBHOOK_HOST: str = "0.0.0.0"
PLATEGA_WEBHOOK_PORT: int = 8086
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
WATA_WEBHOOK_PATH: str = "/wata-webhook"
WATA_WEBHOOK_HOST: str = "0.0.0.0"
WATA_WEBHOOK_PORT: int = 8085
WATA_PUBLIC_KEY_URL: Optional[str] = None
WATA_PUBLIC_KEY_CACHE_SECONDS: int = 3600
MAIN_MENU_MODE: str = "default"
CONNECT_BUTTON_MODE: str = "guide"
MINIAPP_CUSTOM_URL: str = ""
MINIAPP_STATIC_PATH: str = "miniapp"
MINIAPP_PURCHASE_URL: str = ""
MINIAPP_SERVICE_NAME_EN: str = "Bedolaga VPN"
MINIAPP_SERVICE_NAME_RU: str = "Bedolaga VPN"
@@ -247,6 +335,13 @@ class Settings(BaseSettings):
DEBUG: bool = False
WEBHOOK_URL: Optional[str] = None
WEBHOOK_PATH: str = "/webhook"
WEBHOOK_SECRET_TOKEN: Optional[str] = None
WEBHOOK_DROP_PENDING_UPDATES: bool = True
WEBHOOK_MAX_QUEUE_SIZE: int = 1024
WEBHOOK_WORKERS: int = 4
WEBHOOK_ENQUEUE_TIMEOUT: float = 0.1
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT: float = 30.0
BOT_RUN_MODE: str = "polling"
WEB_API_ENABLED: bool = False
WEB_API_HOST: str = "0.0.0.0"
@@ -283,6 +378,29 @@ class Settings(BaseSettings):
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:
@@ -424,6 +542,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()
@@ -455,6 +579,93 @@ class Settings(BaseSettings):
description = re.sub(r'\s+', ' ', description).strip()
return description
def format_remnawave_username(
self,
*,
full_name: str,
username: Optional[str],
telegram_id: int
) -> str:
template = self.REMNAWAVE_USER_USERNAME_TEMPLATE or "user_{telegram_id}"
username_clean = (username or "").lstrip("@")
full_name_value = full_name or ""
values = defaultdict(str, {
"full_name": full_name_value,
"username": username_clean,
"username_clean": username_clean,
"telegram_id": str(telegram_id),
})
raw_username = template.format_map(values).strip()
sanitized_username = re.sub(r"[^0-9A-Za-z._-]+", "_", raw_username)
sanitized_username = re.sub(r"_+", "_", sanitized_username).strip("._-")
if not sanitized_username:
sanitized_username = f"user_{telegram_id}"
return sanitized_username[:64]
@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:
@@ -475,6 +686,15 @@ class Settings(BaseSettings):
return normalized in {"1", "true", "yes", "on"}
return bool(value)
def is_auto_purchase_after_topup_enabled(self) -> bool:
value = getattr(self, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", False)
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:
@@ -491,8 +711,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:
@@ -538,6 +764,19 @@ class Settings(BaseSettings):
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):
@@ -616,9 +855,53 @@ class Settings(BaseSettings):
def is_traffic_fixed(self) -> bool:
return self.TRAFFIC_SELECTION_MODE.lower() == "fixed"
def get_fixed_traffic_limit(self) -> int:
return self.FIXED_TRAFFIC_LIMIT_GB
def is_devices_selection_enabled(self) -> bool:
return self.DEVICES_SELECTION_ENABLED
def get_devices_selection_disabled_amount(self) -> Optional[int]:
raw_value = self.DEVICES_SELECTION_DISABLED_AMOUNT
if raw_value in (None, ""):
return None
try:
value = int(raw_value)
except (TypeError, ValueError):
logger.warning(
"Некорректное значение DEVICES_SELECTION_DISABLED_AMOUNT: %s",
raw_value,
)
return None
if value < 0:
return 0
return value
def get_disabled_mode_device_limit(self) -> Optional[int]:
return self.get_devices_selection_disabled_amount()
def is_trial_paid_activation_enabled(self) -> bool:
return bool(self.TRIAL_PAYMENT_ENABLED)
def get_trial_activation_price(self) -> int:
try:
value = int(self.TRIAL_ACTIVATION_PRICE)
except (TypeError, ValueError):
logger.warning(
"Некорректное значение TRIAL_ACTIVATION_PRICE: %s",
self.TRIAL_ACTIVATION_PRICE,
)
return 0
if value < 0:
return 0
return value
def is_yookassa_enabled(self) -> bool:
return (self.YOOKASSA_ENABLED and
@@ -636,6 +919,13 @@ class Settings(BaseSettings):
return (self.CRYPTOBOT_ENABLED and
self.CRYPTOBOT_API_TOKEN is not None)
def is_heleket_enabled(self) -> bool:
return (
self.HELEKET_ENABLED
and self.HELEKET_MERCHANT_ID is not None
and self.HELEKET_API_KEY is not None
)
def is_mulenpay_enabled(self) -> bool:
return (
self.MULENPAY_ENABLED
@@ -644,6 +934,15 @@ class Settings(BaseSettings):
and self.MULENPAY_SHOP_ID is not None
)
def get_mulenpay_display_name(self) -> str:
name = (self.MULENPAY_DISPLAY_NAME or "").strip()
if not name:
return "Mulen Pay"
return name
def get_mulenpay_display_name_html(self) -> str:
return html.escape(self.get_mulenpay_display_name())
def is_pal24_enabled(self) -> bool:
return (
self.PAL24_ENABLED
@@ -651,6 +950,99 @@ class Settings(BaseSettings):
and self.PAL24_SHOP_ID is not None
)
def is_platega_enabled(self) -> bool:
return (
self.PLATEGA_ENABLED
and self.PLATEGA_MERCHANT_ID is not None
and self.PLATEGA_SECRET is not None
)
def get_platega_return_url(self) -> Optional[str]:
if self.PLATEGA_RETURN_URL:
return self.PLATEGA_RETURN_URL
if self.WEBHOOK_URL:
return f"{self.WEBHOOK_URL}/payment-success"
return None
def get_platega_failed_url(self) -> Optional[str]:
if self.PLATEGA_FAILED_URL:
return self.PLATEGA_FAILED_URL
if self.WEBHOOK_URL:
return f"{self.WEBHOOK_URL}/payment-failed"
return None
def get_platega_active_methods(self) -> List[int]:
raw_value = str(self.PLATEGA_ACTIVE_METHODS or "")
normalized = raw_value.replace(";", ",")
methods: list[int] = []
seen: set[int] = set()
for part in normalized.split(","):
part = part.strip()
if not part:
continue
try:
method_code = int(part)
except ValueError:
logger.warning("Некорректный код метода Platega: %s", part)
continue
if method_code in {2, 10, 11, 12, 13} and method_code not in seen:
methods.append(method_code)
seen.add(method_code)
if not methods:
return [2]
return methods
@staticmethod
def get_platega_method_definitions() -> Dict[int, Dict[str, str]]:
return {
2: {"name": "СБП (QR)", "title": "🏦 СБП (QR)"},
10: {"name": "Банковские карты (RUB)", "title": "💳 Карты (RUB)"},
11: {"name": "Банковские карты", "title": "💳 Банковские карты"},
12: {"name": "Международные карты", "title": "🌍 Международные карты"},
13: {"name": "Криптовалюта", "title": "🪙 Криптовалюта"},
}
def get_platega_method_display_name(self, method_code: int) -> str:
definitions = self.get_platega_method_definitions()
info = definitions.get(method_code)
if info and info.get("name"):
return info["name"]
return f"Метод {method_code}"
def get_platega_method_display_title(self, method_code: int) -> str:
definitions = self.get_platega_method_definitions()
info = definitions.get(method_code)
if not info:
return f"Platega {method_code}"
return info.get("title") or info.get("name") or f"Platega {method_code}"
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 is_payment_verification_auto_check_enabled(self) -> bool:
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
def get_payment_verification_auto_check_interval(self) -> int:
try:
minutes = int(self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES)
except (TypeError, ValueError): # pragma: no cover - защитная проверка конфигурации
minutes = 10
if minutes <= 0:
logger.warning(
"Некорректный интервал автопроверки платежей: %s. Используется значение по умолчанию 10 минут.",
self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES,
)
return 10
return minutes
def get_cryptobot_base_url(self) -> str:
if self.CRYPTOBOT_TESTNET:
return "https://testnet-pay.crypt.bot"
@@ -668,6 +1060,26 @@ class Settings(BaseSettings):
def get_cryptobot_invoice_expires_seconds(self) -> int:
return self.CRYPTOBOT_INVOICE_EXPIRES_HOURS * 3600
def get_heleket_markup_percent(self) -> float:
try:
return float(self.HELEKET_MARKUP_PERCENT)
except (TypeError, ValueError):
return 0.0
def get_heleket_lifetime(self) -> int:
try:
value = int(self.HELEKET_INVOICE_LIFETIME)
except (TypeError, ValueError):
value = 3600
return max(300, min(43200, value))
def get_heleket_callback_url(self) -> Optional[str]:
if self.HELEKET_CALLBACK_URL:
return self.HELEKET_CALLBACK_URL
if self.WEBHOOK_URL:
return f"{self.WEBHOOK_URL}{self.HELEKET_WEBHOOK_PATH}"
return None
def is_happ_cryptolink_mode(self) -> bool:
return self.CONNECT_BUTTON_MODE == "happ_cryptolink"
@@ -712,6 +1124,13 @@ class Settings(BaseSettings):
def get_maintenance_check_interval(self) -> int:
return self.MAINTENANCE_CHECK_INTERVAL
def get_maintenance_retry_attempts(self) -> int:
try:
attempts = int(self.MAINTENANCE_RETRY_ATTEMPTS)
except (TypeError, ValueError):
attempts = 1
return max(1, attempts)
def is_base_promo_group_period_discount_enabled(self) -> bool:
return self.BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED
@@ -754,6 +1173,9 @@ class Settings(BaseSettings):
def is_maintenance_auto_enable(self) -> bool:
return self.MAINTENANCE_AUTO_ENABLE
def is_maintenance_monitoring_enabled(self) -> bool:
return self.MAINTENANCE_MONITORING_ENABLED
def get_available_subscription_periods(self) -> List[int]:
try:
periods_str = self.AVAILABLE_SUBSCRIPTION_PERIODS
@@ -845,44 +1267,19 @@ 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": 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
@@ -1135,15 +1532,82 @@ class Settings(BaseSettings):
def is_support_contact_enabled(self) -> bool:
return self.get_support_system_mode() in {"contact", "both"}
def get_bot_run_mode(self) -> str:
mode = (self.BOT_RUN_MODE or "polling").strip().lower()
if mode not in {"polling", "webhook", "both"}:
return "polling"
return mode
def get_telegram_webhook_path(self) -> str:
raw_path = (self.WEBHOOK_PATH or "/webhook").strip()
if not raw_path:
raw_path = "/webhook"
if not raw_path.startswith("/"):
raw_path = "/" + raw_path
return raw_path
def get_webhook_queue_maxsize(self) -> int:
try:
size = int(self.WEBHOOK_MAX_QUEUE_SIZE)
except (TypeError, ValueError):
size = 1024
return max(1, size)
def get_webhook_worker_count(self) -> int:
try:
workers = int(self.WEBHOOK_WORKERS)
except (TypeError, ValueError):
workers = 1
return max(1, workers)
def get_webhook_enqueue_timeout(self) -> float:
try:
timeout = float(self.WEBHOOK_ENQUEUE_TIMEOUT)
except (TypeError, ValueError):
timeout = 0.0
return max(0.0, timeout)
def get_webhook_shutdown_timeout(self) -> float:
try:
timeout = float(self.WEBHOOK_WORKER_SHUTDOWN_TIMEOUT)
except (TypeError, ValueError):
timeout = 30.0
return max(1.0, timeout)
def get_telegram_webhook_url(self) -> Optional[str]:
base_url = (self.WEBHOOK_URL or "").strip()
if not base_url:
return None
path = self.get_telegram_webhook_path()
return f"{base_url.rstrip('/')}{path}"
def get_miniapp_static_path(self) -> Path:
raw_path = (self.MINIAPP_STATIC_PATH or "miniapp").strip()
if not raw_path:
raw_path = "miniapp"
return Path(raw_path)
model_config = {
"env_file": ".env",
"env_file_encoding": "utf-8",
"extra": "ignore"
"extra": "ignore"
}
@field_validator("TIMEZONE")
@classmethod
def validate_timezone(cls, value: str) -> str:
try:
ZoneInfo(value)
except Exception as exc: # pragma: no cover - defensive validation
raise ValueError(
f"Некорректный идентификатор часового пояса: {value}"
) from exc
return value
settings = Settings()
ENV_OVERRIDE_KEYS = set(settings.model_fields_set)
_PERIOD_PRICE_FIELDS: Dict[int, str] = {
14: "PRICE_14_DAYS",
+23
View File
@@ -0,0 +1,23 @@
"""Database package exports."""
from .database import (
DatabaseManager,
batch_ops,
close_db,
db_manager,
get_db,
get_db_read_only,
get_pool_metrics,
init_db,
)
__all__ = [
"DatabaseManager",
"batch_ops",
"close_db",
"db_manager",
"get_db",
"get_db_read_only",
"get_pool_metrics",
"init_db",
]
+176
View File
@@ -0,0 +1,176 @@
import logging
from datetime import datetime
from typing import Any, Dict, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import HeleketPayment
logger = logging.getLogger(__name__)
async def create_heleket_payment(
db: AsyncSession,
*,
user_id: int,
uuid: str,
order_id: str,
amount: str,
currency: str,
status: str,
payer_amount: Optional[str] = None,
payer_currency: Optional[str] = None,
exchange_rate: Optional[float] = None,
discount_percent: Optional[int] = None,
payment_url: Optional[str] = None,
expires_at: Optional[datetime] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> HeleketPayment:
payment = HeleketPayment(
user_id=user_id,
uuid=uuid,
order_id=order_id,
amount=amount,
currency=currency,
status=status,
payer_amount=payer_amount,
payer_currency=payer_currency,
exchange_rate=exchange_rate,
discount_percent=discount_percent,
payment_url=payment_url,
expires_at=expires_at,
metadata_json=metadata or {},
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(
"Создан Heleket платеж: uuid=%s order_id=%s amount=%s %s для пользователя %s",
uuid,
order_id,
amount,
currency,
user_id,
)
return payment
async def get_heleket_payment_by_uuid(
db: AsyncSession,
uuid: str,
) -> Optional[HeleketPayment]:
result = await db.execute(
select(HeleketPayment)
.options(selectinload(HeleketPayment.user))
.where(HeleketPayment.uuid == uuid)
)
return result.scalar_one_or_none()
async def get_heleket_payment_by_order_id(
db: AsyncSession,
order_id: str,
) -> Optional[HeleketPayment]:
result = await db.execute(
select(HeleketPayment)
.options(selectinload(HeleketPayment.user))
.where(HeleketPayment.order_id == order_id)
)
return result.scalar_one_or_none()
async def get_heleket_payment_by_id(
db: AsyncSession,
payment_id: int,
) -> Optional[HeleketPayment]:
result = await db.execute(
select(HeleketPayment)
.options(selectinload(HeleketPayment.user))
.where(HeleketPayment.id == payment_id)
)
return result.scalar_one_or_none()
async def update_heleket_payment(
db: AsyncSession,
uuid: str,
*,
status: Optional[str] = None,
payer_amount: Optional[str] = None,
payer_currency: Optional[str] = None,
exchange_rate: Optional[float] = None,
discount_percent: Optional[int] = None,
paid_at: Optional[datetime] = None,
payment_url: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> Optional[HeleketPayment]:
payment = await get_heleket_payment_by_uuid(db, uuid)
if not payment:
logger.error("Heleket платеж с uuid=%s не найден", uuid)
return None
if status is not None:
payment.status = status
if payer_amount is not None:
payment.payer_amount = payer_amount
if payer_currency is not None:
payment.payer_currency = payer_currency
if exchange_rate is not None:
payment.exchange_rate = exchange_rate
if discount_percent is not None:
payment.discount_percent = discount_percent
if payment_url is not None:
payment.payment_url = payment_url
if metadata is not None:
existing = dict(payment.metadata_json or {})
existing.update(metadata)
payment.metadata_json = existing
if paid_at is not None:
payment.paid_at = paid_at
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
logger.info(
"Обновлен Heleket платеж %s: статус=%s payer_amount=%s %s",
uuid,
payment.status,
payment.payer_amount,
payment.payer_currency,
)
return payment
async def link_heleket_payment_to_transaction(
db: AsyncSession,
uuid: str,
transaction_id: int,
) -> Optional[HeleketPayment]:
payment = await get_heleket_payment_by_uuid(db, uuid)
if not payment:
logger.error("Не найден Heleket платеж для связи с транзакцией: %s", uuid)
return None
payment.transaction_id = transaction_id
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
logger.info(
"Heleket платеж %s связан с транзакцией %s",
uuid,
transaction_id,
)
return payment
+3 -1
View File
@@ -5,6 +5,7 @@ from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import MulenPayPayment
logger = logging.getLogger(__name__)
@@ -40,7 +41,8 @@ async def create_mulenpay_payment(
await db.refresh(payment)
logger.info(
"Создан MulenPay платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s",
"Создан %s платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s",
settings.get_mulenpay_display_name(),
payment.mulen_payment_id,
uuid,
amount_kopeks,
+4
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, Dict, Optional
from sqlalchemy import select, update
@@ -87,6 +88,7 @@ async def update_pal24_payment_status(
status: str,
is_active: Optional[bool] = None,
is_paid: Optional[bool] = None,
paid_at: Optional[datetime] = None,
payment_id: Optional[str] = None,
payment_status: Optional[str] = None,
payment_method: Optional[str] = None,
@@ -103,6 +105,8 @@ async def update_pal24_payment_status(
update_values["is_active"] = is_active
if is_paid is not None:
update_values["is_paid"] = is_paid
if paid_at is not None:
update_values["paid_at"] = paid_at
if payment_id is not None:
update_values["payment_id"] = payment_id
if payment_status is not None:
+156
View File
@@ -0,0 +1,156 @@
"""CRUD-операции для платежей Platega."""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PlategaPayment
logger = logging.getLogger(__name__)
async def create_platega_payment(
db: AsyncSession,
*,
user_id: int,
amount_kopeks: int,
currency: str,
description: Optional[str],
status: str,
payment_method_code: int,
correlation_id: str,
platega_transaction_id: Optional[str],
redirect_url: Optional[str],
return_url: Optional[str],
failed_url: Optional[str],
payload: Optional[str],
metadata: Optional[dict[str, Any]] = None,
expires_at: Optional[datetime] = None,
) -> PlategaPayment:
payment = PlategaPayment(
user_id=user_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
status=status,
payment_method_code=payment_method_code,
correlation_id=correlation_id,
platega_transaction_id=platega_transaction_id,
redirect_url=redirect_url,
return_url=return_url,
failed_url=failed_url,
payload=payload,
metadata_json=metadata or {},
expires_at=expires_at,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(
"Создан Platega платеж #%s (tx=%s) на сумму %s копеек для пользователя %s",
payment.id,
platega_transaction_id,
amount_kopeks,
user_id,
)
return payment
async def get_platega_payment_by_id(
db: AsyncSession, payment_id: int
) -> Optional[PlategaPayment]:
result = await db.execute(
select(PlategaPayment).where(PlategaPayment.id == payment_id)
)
return result.scalar_one_or_none()
async def get_platega_payment_by_id_for_update(
db: AsyncSession, payment_id: int
) -> Optional[PlategaPayment]:
result = await db.execute(
select(PlategaPayment)
.where(PlategaPayment.id == payment_id)
.with_for_update()
)
return result.scalar_one_or_none()
async def get_platega_payment_by_transaction_id(
db: AsyncSession, transaction_id: str
) -> Optional[PlategaPayment]:
result = await db.execute(
select(PlategaPayment).where(
PlategaPayment.platega_transaction_id == transaction_id
)
)
return result.scalar_one_or_none()
async def get_platega_payment_by_correlation_id(
db: AsyncSession, correlation_id: str
) -> Optional[PlategaPayment]:
result = await db.execute(
select(PlategaPayment).where(
PlategaPayment.correlation_id == correlation_id
)
)
return result.scalar_one_or_none()
async def update_platega_payment(
db: AsyncSession,
*,
payment: PlategaPayment,
status: Optional[str] = None,
is_paid: Optional[bool] = None,
paid_at: Optional[datetime] = None,
platega_transaction_id: Optional[str] = None,
redirect_url: Optional[str] = None,
callback_payload: Optional[dict[str, Any]] = None,
metadata: Optional[dict[str, Any]] = None,
expires_at: Optional[datetime] = None,
) -> PlategaPayment:
if status is not None:
payment.status = status
if is_paid is not None:
payment.is_paid = is_paid
if paid_at is not None:
payment.paid_at = paid_at
if platega_transaction_id and not payment.platega_transaction_id:
payment.platega_transaction_id = platega_transaction_id
if redirect_url is not None:
payment.redirect_url = redirect_url
if callback_payload is not None:
payment.callback_payload = callback_payload
if metadata is not None:
payment.metadata_json = metadata
if expires_at is not None:
payment.expires_at = expires_at
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
return payment
async def link_platega_payment_to_transaction(
db: AsyncSession,
*,
payment: PlategaPayment,
transaction_id: int,
) -> PlategaPayment:
payment.transaction_id = transaction_id
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
return payment
+297
View File
@@ -0,0 +1,297 @@
import logging
from typing import Iterable, Sequence
from sqlalchemy import and_, delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import (
Poll,
PollAnswer,
PollOption,
PollQuestion,
PollResponse,
)
logger = logging.getLogger(__name__)
async def create_poll(
db: AsyncSession,
*,
title: str,
description: str | None,
reward_enabled: bool,
reward_amount_kopeks: int,
created_by: int | None,
questions: Sequence[dict[str, Iterable[str]]],
) -> Poll:
poll = Poll(
title=title,
description=description,
reward_enabled=reward_enabled,
reward_amount_kopeks=reward_amount_kopeks if reward_enabled else 0,
created_by=created_by,
)
db.add(poll)
await db.flush()
for order, question_data in enumerate(questions, start=1):
question_text = question_data.get("text", "").strip()
if not question_text:
continue
question = PollQuestion(
poll_id=poll.id,
text=question_text,
order=order,
)
db.add(question)
await db.flush()
for option_order, option_text in enumerate(question_data.get("options", []), start=1):
option_text = option_text.strip()
if not option_text:
continue
option = PollOption(
question_id=question.id,
text=option_text,
order=option_order,
)
db.add(option)
await db.commit()
await db.refresh(
poll,
attribute_names=["questions"],
)
return poll
async def list_polls(db: AsyncSession) -> list[Poll]:
result = await db.execute(
select(Poll)
.options(
selectinload(Poll.questions).options(selectinload(PollQuestion.options))
)
.order_by(Poll.created_at.desc())
)
return result.scalars().all()
async def get_poll_by_id(db: AsyncSession, poll_id: int) -> Poll | None:
result = await db.execute(
select(Poll)
.options(
selectinload(Poll.questions).options(selectinload(PollQuestion.options)),
selectinload(Poll.responses),
)
.where(Poll.id == poll_id)
)
return result.scalar_one_or_none()
async def delete_poll(db: AsyncSession, poll_id: int) -> bool:
poll = await db.get(Poll, poll_id)
if not poll:
return False
await db.delete(poll)
await db.commit()
logger.info("🗑️ Удалён опрос %s", poll_id)
return True
async def create_poll_response(
db: AsyncSession,
poll_id: int,
user_id: int,
) -> PollResponse:
result = await db.execute(
select(PollResponse)
.where(
and_(
PollResponse.poll_id == poll_id,
PollResponse.user_id == user_id,
)
)
)
response = result.scalar_one_or_none()
if response:
return response
response = PollResponse(
poll_id=poll_id,
user_id=user_id,
)
db.add(response)
await db.commit()
await db.refresh(response)
return response
async def get_poll_response_by_id(
db: AsyncSession,
response_id: int,
) -> PollResponse | None:
result = await db.execute(
select(PollResponse)
.options(
selectinload(PollResponse.poll)
.options(selectinload(Poll.questions).options(selectinload(PollQuestion.options))),
selectinload(PollResponse.answers),
selectinload(PollResponse.user),
)
.where(PollResponse.id == response_id)
)
return result.scalar_one_or_none()
async def record_poll_answer(
db: AsyncSession,
*,
response_id: int,
question_id: int,
option_id: int,
) -> PollAnswer:
result = await db.execute(
select(PollAnswer)
.where(
and_(
PollAnswer.response_id == response_id,
PollAnswer.question_id == question_id,
)
)
)
answer = result.scalar_one_or_none()
if answer:
answer.option_id = option_id
await db.commit()
await db.refresh(answer)
return answer
answer = PollAnswer(
response_id=response_id,
question_id=question_id,
option_id=option_id,
)
db.add(answer)
await db.commit()
await db.refresh(answer)
return answer
async def reset_poll_answers(db: AsyncSession, response_id: int) -> None:
await db.execute(
delete(PollAnswer).where(PollAnswer.response_id == response_id)
)
await db.commit()
async def get_poll_statistics(db: AsyncSession, poll_id: int) -> dict:
totals_result = await db.execute(
select(
func.count(PollResponse.id),
func.count(PollResponse.completed_at),
func.coalesce(func.sum(PollResponse.reward_amount_kopeks), 0),
).where(PollResponse.poll_id == poll_id)
)
total_responses, completed_responses, reward_sum = totals_result.one()
option_counts_result = await db.execute(
select(
PollQuestion.id,
PollQuestion.text,
PollQuestion.order,
PollOption.id,
PollOption.text,
PollOption.order,
func.count(PollAnswer.id),
)
.join(PollOption, PollOption.question_id == PollQuestion.id)
.outerjoin(
PollAnswer,
and_(
PollAnswer.question_id == PollQuestion.id,
PollAnswer.option_id == PollOption.id,
),
)
.where(PollQuestion.poll_id == poll_id)
.group_by(
PollQuestion.id,
PollQuestion.text,
PollQuestion.order,
PollOption.id,
PollOption.text,
PollOption.order,
)
.order_by(PollQuestion.order.asc(), PollOption.order.asc())
)
questions_map: dict[int, dict] = {}
for (
question_id,
question_text,
question_order,
option_id,
option_text,
option_order,
answer_count,
) in option_counts_result:
question_entry = questions_map.setdefault(
question_id,
{
"id": question_id,
"text": question_text,
"order": question_order,
"options": [],
},
)
question_entry["options"].append(
{
"id": option_id,
"text": option_text,
"count": answer_count,
}
)
questions = sorted(questions_map.values(), key=lambda item: item["order"])
return {
"total_responses": total_responses,
"completed_responses": completed_responses,
"reward_sum_kopeks": reward_sum,
"questions": questions,
}
async def get_poll_responses_with_answers(
db: AsyncSession,
poll_id: int,
*,
limit: int,
offset: int,
) -> tuple[list[PollResponse], int]:
total_result = await db.execute(
select(func.count()).select_from(PollResponse).where(PollResponse.poll_id == poll_id)
)
total = int(total_result.scalar_one() or 0)
if total == 0:
return [], 0
result = await db.execute(
select(PollResponse)
.options(
selectinload(PollResponse.user),
selectinload(PollResponse.answers).selectinload(PollAnswer.question),
selectinload(PollResponse.answers).selectinload(PollAnswer.option),
)
.where(PollResponse.poll_id == poll_id)
.order_by(PollResponse.sent_at.asc())
.offset(offset)
.limit(limit)
)
responses = result.scalars().unique().all()
return responses, total
+39 -3
View File
@@ -1,11 +1,11 @@
import logging
from typing import Dict, List, Optional, Tuple
from typing import Dict, List, Optional, Set, Tuple
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import PromoGroup, User
from app.database.models import PromoGroup, User, UserPromoGroup
def _normalize_period_discounts(period_discounts: Optional[Dict[int, int]]) -> Dict[int, int]:
@@ -38,7 +38,7 @@ async def get_promo_groups_with_counts(
select(PromoGroup, func.count(User.id))
.outerjoin(User, User.promo_group_id == PromoGroup.id)
.group_by(PromoGroup.id)
.order_by(PromoGroup.is_default.desc(), PromoGroup.name)
.order_by(PromoGroup.priority.desc(), PromoGroup.name)
)
if offset:
@@ -89,6 +89,7 @@ async def create_promo_group(
db: AsyncSession,
name: str,
*,
priority: int = 0,
server_discount_percent: int,
traffic_discount_percent: int,
device_discount_percent: int,
@@ -110,6 +111,7 @@ async def create_promo_group(
promo_group = PromoGroup(
name=name.strip(),
priority=max(0, priority),
server_discount_percent=max(0, min(100, server_discount_percent)),
traffic_discount_percent=max(0, min(100, traffic_discount_percent)),
device_discount_percent=max(0, min(100, device_discount_percent)),
@@ -152,6 +154,7 @@ async def update_promo_group(
group: PromoGroup,
*,
name: Optional[str] = None,
priority: Optional[int] = None,
server_discount_percent: Optional[int] = None,
traffic_discount_percent: Optional[int] = None,
device_discount_percent: Optional[int] = None,
@@ -162,6 +165,8 @@ async def update_promo_group(
) -> PromoGroup:
if name is not None:
group.name = name.strip()
if priority is not None:
group.priority = max(0, priority)
if server_discount_percent is not None:
group.server_discount_percent = max(0, min(100, server_discount_percent))
if traffic_discount_percent is not None:
@@ -228,11 +233,42 @@ async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool:
return False
# Получаем список пользователей, связанных с удаляемой промогруппой
affected_user_ids: Set[int] = set()
user_ids_result = await db.execute(
select(User.id).where(User.promo_group_id == group.id)
)
affected_user_ids.update(user_ids_result.scalars().all())
promo_group_links_result = await db.execute(
select(UserPromoGroup.user_id).where(UserPromoGroup.promo_group_id == group.id)
)
affected_user_ids.update(promo_group_links_result.scalars().all())
await db.execute(
update(User)
.where(User.promo_group_id == group.id)
.values(promo_group_id=default_group.id)
)
if affected_user_ids:
existing_defaults_result = await db.execute(
select(UserPromoGroup.user_id)
.where(UserPromoGroup.promo_group_id == default_group.id)
.where(UserPromoGroup.user_id.in_(affected_user_ids))
)
existing_default_user_ids = set(existing_defaults_result.scalars().all())
for user_id in affected_user_ids - existing_default_user_ids:
db.add(
UserPromoGroup(
user_id=user_id,
promo_group_id=default_group.id,
assigned_by="system",
)
)
await db.delete(group)
await db.commit()
+58 -12
View File
@@ -13,12 +13,50 @@ logger = logging.getLogger(__name__)
async def get_promocode_by_code(db: AsyncSession, code: str) -> Optional[PromoCode]:
result = await db.execute(
select(PromoCode)
.options(selectinload(PromoCode.uses))
.options(
selectinload(PromoCode.uses),
selectinload(PromoCode.promo_group)
)
.where(PromoCode.code == code.upper())
)
return result.scalar_one_or_none()
async def get_promocode_by_id(db: AsyncSession, promo_id: int) -> Optional[PromoCode]:
"""
Получает промокод по ID с eager loading всех связанных данных.
Используется для избежания lazy loading в async контексте.
"""
result = await db.execute(
select(PromoCode)
.options(
selectinload(PromoCode.uses),
selectinload(PromoCode.promo_group)
)
.where(PromoCode.id == promo_id)
)
return result.scalar_one_or_none()
async def check_promocode_validity(db: AsyncSession, code: str) -> dict:
"""
Проверяет существование и валидность промокода без активации.
Возвращает словарь с информацией о промокоде.
"""
promocode = await get_promocode_by_code(db, code)
if not promocode:
return {"valid": False, "error": "not_found", "promocode": None}
if not promocode.is_valid:
if promocode.current_uses >= promocode.max_uses:
return {"valid": False, "error": "used", "promocode": None}
else:
return {"valid": False, "error": "expired", "promocode": None}
return {"valid": True, "error": None, "promocode": promocode}
async def create_promocode(
db: AsyncSession,
code: str,
@@ -27,7 +65,8 @@ async def create_promocode(
subscription_days: int = 0,
max_uses: int = 1,
valid_until: Optional[datetime] = None,
created_by: Optional[int] = None
created_by: Optional[int] = None,
promo_group_id: Optional[int] = None
) -> PromoCode:
promocode = PromoCode(
@@ -37,14 +76,18 @@ async def create_promocode(
subscription_days=subscription_days,
max_uses=max_uses,
valid_until=valid_until,
created_by=created_by
created_by=created_by,
promo_group_id=promo_group_id
)
db.add(promocode)
await db.commit()
await db.refresh(promocode)
logger.info(f"✅ Создан промокод: {code}")
if promo_group_id:
logger.info(f"✅ Создан промокод: {code} с промогруппой ID {promo_group_id}")
else:
logger.info(f"✅ Создан промокод: {code}")
return promocode
@@ -53,9 +96,9 @@ async def use_promocode(
promocode_id: int,
user_id: int
) -> bool:
try:
promocode = await db.get(PromoCode, promocode_id)
promocode = await get_promocode_by_id(db, promocode_id)
if not promocode:
return False
@@ -143,14 +186,17 @@ async def get_promocodes_list(
limit: int = 50,
is_active: Optional[bool] = None
) -> List[PromoCode]:
query = select(PromoCode).options(selectinload(PromoCode.uses))
query = select(PromoCode).options(
selectinload(PromoCode.uses),
selectinload(PromoCode.promo_group)
)
if is_active is not None:
query = query.where(PromoCode.is_active == is_active)
query = query.order_by(PromoCode.created_at.desc()).offset(offset).limit(limit)
result = await db.execute(query)
return result.scalars().all()
+93 -7
View File
@@ -17,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__)
@@ -40,6 +47,7 @@ async def create_server_squad(
max_users: int = None,
is_available: bool = True,
is_trial_eligible: bool = False,
sort_order: int = 0,
promo_group_ids: Optional[Iterable[int]] = None,
) -> ServerSquad:
@@ -73,6 +81,7 @@ async def create_server_squad(
max_users=max_users,
is_available=is_available,
is_trial_eligible=is_trial_eligible,
sort_order=sort_order,
allowed_promo_groups=promo_groups,
)
@@ -160,6 +169,58 @@ async def get_available_server_squads(
return result.scalars().unique().all()
async def get_active_server_squads(db: AsyncSession) -> List[ServerSquad]:
"""Возвращает список активных серверов, доступных для подключения."""
squads = await get_available_server_squads(db)
if not squads:
return []
eligible: List[ServerSquad] = []
for squad in squads:
max_users = squad.max_users
current_users = squad.current_users or 0
if max_users is not None and current_users >= max_users:
continue
eligible.append(squad)
if eligible:
return eligible
return squads
async def choose_random_active_server_squad(
db: AsyncSession,
) -> Optional[ServerSquad]:
"""Возвращает случайный активный сервер."""
squads = await get_active_server_squads(db)
if not squads:
return None
return random.choice(squads)
async def get_random_active_squad_uuid(
db: AsyncSession,
fallback_uuid: Optional[str] = None,
) -> Optional[str]:
"""Возвращает UUID случайного активного сервера или запасной UUID."""
squad = await choose_random_active_server_squad(db)
if squad:
return squad.squad_uuid
return fallback_uuid
async def update_server_squad_promo_groups(
db: AsyncSession, server_id: int, promo_group_ids: Iterable[int]
) -> Optional[ServerSquad]:
@@ -201,8 +262,15 @@ async def update_server_squad(
) -> Optional[ServerSquad]:
valid_fields = {
'display_name', 'country_code', 'price_kopeks', 'description',
'max_users', 'is_available', 'sort_order', 'is_trial_eligible'
"display_name",
"original_name",
"country_code",
"price_kopeks",
"description",
"max_users",
"is_available",
"sort_order",
"is_trial_eligible",
}
filtered_updates = {k: v for k, v in updates.items() if k in valid_fields}
@@ -447,7 +515,6 @@ async def choose_random_trial_server_squad(
async def get_random_trial_squad_uuid(
db: AsyncSession,
fallback_uuid: Optional[str] = None,
) -> Optional[str]:
squad = await choose_random_trial_server_squad(db)
@@ -455,7 +522,7 @@ async def get_random_trial_squad_uuid(
if squad:
return squad.squad_uuid
return fallback_uuid
return None
def _generate_display_name(original_name: str) -> str:
@@ -495,10 +562,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)
@@ -537,6 +604,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]
+252 -33
View File
@@ -11,10 +11,12 @@ from app.database.models import (
User,
SubscriptionServer,
PromoGroup,
UserPromoGroup,
)
from app.database.crud.notification import clear_notifications
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
from app.config import settings
from app.utils.timezone import format_local_datetime
logger = logging.getLogger(__name__)
@@ -30,6 +32,7 @@ async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Optiona
subscription = result.scalar_one_or_none()
if subscription:
logger.info(f"🔍 Загружена подписка {subscription.id} для пользователя {user_id}, статус: {subscription.status}")
subscription = await check_and_update_subscription_status(db, subscription)
return subscription
@@ -40,21 +43,19 @@ async def create_trial_subscription(
user_id: int,
duration_days: int = None,
traffic_limit_gb: int = None,
device_limit: int = None,
device_limit: Optional[int] = None,
squad_uuid: str = None
) -> 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
if device_limit is None:
device_limit = settings.TRIAL_DEVICE_LIMIT
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,
)
squad_uuid = await get_random_trial_squad_uuid(db)
if squad_uuid:
logger.debug(
@@ -68,7 +69,6 @@ async def create_trial_subscription(
user_id,
error,
)
squad_uuid = settings.TRIAL_SQUAD_UUID
end_date = datetime.utcnow() + timedelta(days=duration_days)
@@ -125,13 +125,16 @@ async def create_paid_subscription(
user_id: int,
duration_days: int,
traffic_limit_gb: int = 0,
device_limit: int = 1,
device_limit: Optional[int] = None,
connected_squads: List[str] = None,
update_server_counters: bool = False,
) -> Subscription:
end_date = datetime.utcnow() + timedelta(days=duration_days)
if device_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
subscription = Subscription(
user_id=user_id,
status=SubscriptionStatus.ACTIVE.value,
@@ -149,7 +152,7 @@ async def create_paid_subscription(
await db.commit()
await db.refresh(subscription)
logger.info(f"💎 Создана платная подписка для пользователя {user_id}")
logger.info(f"💎 Создана платная подписка для пользователя {user_id}, ID: {subscription.id}, статус: {subscription.status}")
squad_uuids = list(connected_squads or [])
if update_server_counters and squad_uuids:
@@ -189,10 +192,27 @@ async def extend_subscription(
days: int
) -> Subscription:
current_time = datetime.utcnow()
logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней")
logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}")
# НОВОЕ: Вычисляем бонусные дни от триала ДО изменения end_date
bonus_days = 0
if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID:
# Вычисляем остаток триала
if subscription.end_date and subscription.end_date > current_time:
remaining = subscription.end_date - current_time
if remaining.total_seconds() > 0:
bonus_days = max(0, remaining.days)
logger.info(
"🎁 Обнаружен остаток триала: %s дней для подписки %s",
bonus_days,
subscription.id,
)
# Применяем продление с учетом бонусных дней
total_days = days + bonus_days
if days < 0:
subscription.end_date = subscription.end_date + timedelta(days=days)
logger.info(
@@ -201,31 +221,36 @@ async def extend_subscription(
subscription.end_date,
)
elif subscription.end_date > current_time:
subscription.end_date = subscription.end_date + timedelta(days=days)
logger.info(f"📅 Подписка активна, добавляем {days} дней к текущей дате окончания")
subscription.end_date = subscription.end_date + timedelta(days=total_days)
logger.info(f"📅 Подписка активна, добавляем {total_days} дней ({days} + {bonus_days} бонус) к текущей дате окончания")
else:
subscription.end_date = current_time + timedelta(days=days)
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания")
subscription.end_date = current_time + timedelta(days=total_days)
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания на {total_days} дней ({days} + {bonus_days} бонус)")
if subscription.is_trial:
start_date = subscription.start_date or current_time
total_duration = subscription.end_date - start_date
max_trial_duration = timedelta(days=settings.TRIAL_DURATION_DAYS)
# УДАЛЕНО: Автоматическая конвертация триала по длительности
# Теперь триал конвертируется ТОЛЬКО после успешного коммита продления
# и ТОЛЬКО вызывающей функцией (например, _auto_extend_subscription)
if total_duration > max_trial_duration:
subscription.is_trial = False
logger.info(
"🎯 Подписка %s автоматически переведена из триальной в платную после продления"
", итоговая длительность: %s дней",
subscription.id,
total_duration.days,
)
if subscription.user:
subscription.user.has_had_paid_subscription = True
if subscription.status == SubscriptionStatus.EXPIRED.value and days > 0:
# Логируем статус подписки перед проверкой
logger.info(f"🔄 Продление подписки {subscription.id}, текущий статус: {subscription.status}, дни: {days}")
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,
)
elif days > 0 and subscription.status == SubscriptionStatus.PENDING.value:
logger.warning(
"⚠️ Попытка продлить PENDING подписку %s, дни: %s",
subscription.id,
days
)
if settings.RESET_TRAFFIC_ON_PAYMENT:
subscription.traffic_used_gb = 0.0
@@ -892,7 +917,7 @@ async def get_subscription_renewal_cost(
result = await db.execute(
select(Subscription)
.options(
selectinload(Subscription.user).selectinload(User.promo_group),
selectinload(Subscription.user).selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
)
.where(Subscription.id == subscription_id)
)
@@ -1109,6 +1134,14 @@ async def check_and_update_subscription_status(
current_time = datetime.utcnow()
logger.info(
"🔍 Проверка статуса подписки %s, текущий статус: %s, дата окончания: %s, текущее время: %s",
subscription.id,
subscription.status,
format_local_datetime(subscription.end_date),
format_local_datetime(current_time),
)
if (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date <= current_time):
@@ -1119,9 +1152,68 @@ async def check_and_update_subscription_status(
await db.refresh(subscription)
logger.info(f"⏰ Статус подписки пользователя {subscription.user_id} изменен на 'expired'")
elif subscription.status == SubscriptionStatus.PENDING.value:
logger.info(f"ℹ️ Проверка PENDING подписки {subscription.id}, статус остается без изменений")
return subscription
async def create_subscription_no_commit(
db: AsyncSession,
user_id: int,
status: str = "trial",
is_trial: bool = True,
end_date: datetime = None,
traffic_limit_gb: int = 10,
traffic_used_gb: float = 0.0,
device_limit: int = 1,
connected_squads: list = None,
remnawave_short_uuid: str = None,
subscription_url: str = "",
subscription_crypto_link: str = "",
autopay_enabled: Optional[bool] = None,
autopay_days_before: Optional[int] = None,
) -> Subscription:
"""
Создает подписку без немедленного коммита для пакетной обработки
"""
if end_date is None:
end_date = datetime.utcnow() + timedelta(days=3)
if connected_squads is None:
connected_squads = []
subscription = Subscription(
user_id=user_id,
status=status,
is_trial=is_trial,
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
traffic_used_gb=traffic_used_gb,
device_limit=device_limit,
connected_squads=connected_squads,
remnawave_short_uuid=remnawave_short_uuid,
subscription_url=subscription_url,
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)
# Не коммитим сразу, оставляем для пакетной обработки
logger.info(f"✅ Подготовлена подписка для пользователя {user_id} (ожидает коммита)")
return subscription
async def create_subscription(
db: AsyncSession,
user_id: int,
@@ -1175,3 +1267,130 @@ async def create_subscription(
logger.info(f"✅ Создана подписка для пользователя {user_id}")
return subscription
async def create_pending_subscription(
db: AsyncSession,
user_id: int,
duration_days: int,
traffic_limit_gb: int = 0,
device_limit: int = 1,
connected_squads: List[str] = None,
payment_method: str = "pending",
total_price_kopeks: int = 0
) -> Subscription:
"""Creates a pending subscription that will be activated after payment."""
current_time = datetime.utcnow()
end_date = current_time + timedelta(days=duration_days)
existing_subscription = await get_subscription_by_user_id(db, user_id)
if existing_subscription:
if (
existing_subscription.status == SubscriptionStatus.ACTIVE.value
and existing_subscription.end_date > current_time
):
logger.warning(
"⚠️ Попытка создать pending подписку для активного пользователя %s. Возвращаем существующую запись.",
user_id,
)
return existing_subscription
existing_subscription.status = SubscriptionStatus.PENDING.value
existing_subscription.is_trial = False
existing_subscription.start_date = current_time
existing_subscription.end_date = end_date
existing_subscription.traffic_limit_gb = traffic_limit_gb
existing_subscription.device_limit = device_limit
existing_subscription.connected_squads = connected_squads or []
existing_subscription.traffic_used_gb = 0.0
existing_subscription.updated_at = current_time
await db.commit()
await db.refresh(existing_subscription)
logger.info(
"♻️ Обновлена ожидающая подписка пользователя %s, ID: %s, метод оплаты: %s",
user_id,
existing_subscription.id,
payment_method,
)
return existing_subscription
subscription = Subscription(
user_id=user_id,
status=SubscriptionStatus.PENDING.value,
is_trial=False,
start_date=current_time,
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=connected_squads or [],
autopay_enabled=settings.is_autopay_enabled_by_default(),
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
)
db.add(subscription)
await db.commit()
await db.refresh(subscription)
logger.info(
"💳 Создана ожидающая подписка для пользователя %s, ID: %s, метод оплаты: %s",
user_id,
subscription.id,
payment_method,
)
return subscription
async def activate_pending_subscription(
db: AsyncSession,
user_id: int,
period_days: int = None
) -> Optional[Subscription]:
"""Активирует pending подписку пользователя, меняя её статус на ACTIVE."""
from sqlalchemy import and_
logger.info(f"Активация pending подписки: пользователь {user_id}, период {period_days} дней")
# Находим pending подписку пользователя
result = await db.execute(
select(Subscription)
.where(
and_(
Subscription.user_id == user_id,
Subscription.status == SubscriptionStatus.PENDING.value
)
)
)
pending_subscription = result.scalar_one_or_none()
if not pending_subscription:
logger.warning(f"Не найдена pending подписка для пользователя {user_id}")
return None
logger.info(f"Найдена pending подписка {pending_subscription.id} для пользователя {user_id}, статус: {pending_subscription.status}")
# Обновляем статус подписки на ACTIVE
current_time = datetime.utcnow()
pending_subscription.status = SubscriptionStatus.ACTIVE.value
# Если указан период, обновляем дату окончания
if period_days is not None:
effective_start = pending_subscription.start_date or current_time
if effective_start < current_time:
effective_start = current_time
pending_subscription.end_date = effective_start + timedelta(days=period_days)
# Обновляем дату начала, если она не установлена или в прошлом
if not pending_subscription.start_date or pending_subscription.start_date < current_time:
pending_subscription.start_date = current_time
await db.commit()
await db.refresh(pending_subscription)
logger.info(f"Подписка пользователя {user_id} активирована, ID: {pending_subscription.id}")
return pending_subscription
+53 -5
View File
@@ -274,6 +274,30 @@ class TicketCRUD:
db, ticket_id, TicketStatus.CLOSED.value, datetime.utcnow()
)
@staticmethod
async def close_all_open_tickets(
db: AsyncSession,
) -> List[int]:
"""Закрыть все открытые тикеты. Возвращает список идентификаторов закрытых тикетов."""
open_statuses = [TicketStatus.OPEN.value, TicketStatus.ANSWERED.value]
result = await db.execute(
select(Ticket.id).where(Ticket.status.in_(open_statuses))
)
ticket_ids = result.scalars().all()
if not ticket_ids:
return []
now = datetime.utcnow()
await db.execute(
update(Ticket)
.where(Ticket.id.in_(ticket_ids))
.values(status=TicketStatus.CLOSED.value, closed_at=now, updated_at=now)
)
await db.commit()
return ticket_ids
@staticmethod
async def add_support_audit(
db: AsyncSession,
@@ -309,18 +333,42 @@ class TicketCRUD:
*,
limit: int = 50,
offset: int = 0,
action: Optional[str] = None,
) -> List[SupportAuditLog]:
from sqlalchemy import select, desc
result = await db.execute(
select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at)).offset(offset).limit(limit)
)
query = select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at))
if action:
query = query.where(SupportAuditLog.action == action)
result = await db.execute(query.offset(offset).limit(limit))
return result.scalars().all()
@staticmethod
async def count_support_audit(db: AsyncSession) -> int:
async def count_support_audit(db: AsyncSession, action: Optional[str] = None) -> int:
from sqlalchemy import select, func
result = await db.execute(select(func.count()).select_from(SupportAuditLog))
query = select(func.count()).select_from(SupportAuditLog)
if action:
query = query.where(SupportAuditLog.action == action)
result = await db.execute(query)
return int(result.scalar() or 0)
@staticmethod
async def list_support_audit_actions(db: AsyncSession) -> List[str]:
from sqlalchemy import select
result = await db.execute(
select(SupportAuditLog.action)
.where(SupportAuditLog.action.isnot(None))
.distinct()
.order_by(SupportAuditLog.action)
)
return [row[0] for row in result.fetchall()]
@staticmethod
async def get_open_tickets_count(db: AsyncSession) -> int:
+179 -20
View File
@@ -5,7 +5,7 @@ from datetime import datetime, timedelta
from typing import Optional, List, Dict
from sqlalchemy import select, and_, or_, func, case, nullslast, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import selectinload, joinedload
from sqlalchemy.exc import IntegrityError
from app.database.models import (
@@ -15,6 +15,7 @@ from app.database.models import (
SubscriptionStatus,
Transaction,
PromoGroup,
UserPromoGroup,
PaymentMethod,
TransactionType,
)
@@ -38,6 +39,8 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
select(User)
.options(
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
)
.where(User.id == user_id)
@@ -45,6 +48,7 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
user = result.scalar_one_or_none()
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return user
@@ -55,25 +59,64 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
select(User)
.options(
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
)
.where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return user
async def get_user_by_username(db: AsyncSession, username: str) -> Optional[User]:
if not username:
return None
normalized = username.lower()
result = await db.execute(
select(User)
.options(
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
)
.where(func.lower(User.username) == normalized)
)
user = result.scalar_one_or_none()
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return user
async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Optional[User]:
result = await db.execute(
select(User)
.options(selectinload(User.promo_group))
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.referrer),
)
.where(User.referral_code == referral_code)
)
return result.scalar_one_or_none()
user = result.scalar_one_or_none()
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return user
async def create_unique_referral_code(db: AsyncSession) -> str:
@@ -120,6 +163,57 @@ async def _get_or_create_default_promo_group(db: AsyncSession) -> PromoGroup:
return default_group
async def create_user_no_commit(
db: AsyncSession,
telegram_id: int,
username: str = None,
first_name: str = None,
last_name: str = None,
language: str = "ru",
referred_by_id: int = None,
referral_code: str = None
) -> User:
"""
Создает пользователя без немедленного коммита для пакетной обработки
"""
if not referral_code:
referral_code = await create_unique_referral_code(db)
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(user)
# Обязательно выполняем flush, чтобы получить присвоенный первичный ключ
await db.flush()
# Сохраняем ссылку на группу, чтобы дальнейшие операции могли её использовать
user.promo_group = default_group
# Не коммитим сразу, оставляем для пакетной обработки
logger.info(
f"✅ Подготовлен пользователь {telegram_id} с реферальным кодом {referral_code} (ожидает коммита)"
)
return user
async def create_user(
db: AsyncSession,
telegram_id: int,
@@ -217,7 +311,8 @@ async def add_user_balance(
amount_kopeks: int,
description: str = "Пополнение баланса",
create_transaction: bool = True,
bot = None
transaction_type: TransactionType = TransactionType.DEPOSIT,
bot = None
) -> bool:
try:
old_balance = user.balance_kopeks
@@ -226,12 +321,11 @@ async def add_user_balance(
if create_transaction:
from app.database.crud.transaction import create_transaction as create_trans
from app.database.models import TransactionType
await create_trans(
db=db,
user_id=user.id,
type=TransactionType.DEPOSIT,
type=transaction_type,
amount_kopeks=amount_kopeks,
description=description
)
@@ -251,9 +345,10 @@ async def add_user_balance(
async def add_user_balance_by_id(
db: AsyncSession,
telegram_id: int,
telegram_id: int,
amount_kopeks: int,
description: str = "Пополнение баланса"
description: str = "Пополнение баланса",
transaction_type: TransactionType = TransactionType.DEPOSIT,
) -> bool:
try:
user = await get_user_by_telegram_id(db, telegram_id)
@@ -261,7 +356,13 @@ async def add_user_balance_by_id(
logger.error(f"Пользователь с telegram_id {telegram_id} не найден")
return False
return await add_user_balance(db, user, amount_kopeks, description)
return await add_user_balance(
db,
user,
amount_kopeks,
description,
transaction_type=transaction_type,
)
except Exception as e:
logger.error(f"Ошибка пополнения баланса пользователя {telegram_id}: {e}")
@@ -491,7 +592,11 @@ async def get_users_list(
order_by_purchase_count: bool = False
) -> List[User]:
query = select(User).options(selectinload(User.subscription))
query = select(User).options(
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.referrer),
)
if status:
query = query.where(User.status == status.value)
@@ -505,7 +610,14 @@ async def get_users_list(
]
if search.isdigit():
conditions.append(User.telegram_id == int(search))
try:
search_int = int(search)
# Добавляем условие поиска по telegram_id, который является BigInteger
# и может содержать большие значения, в отличие от User.id (INTEGER)
conditions.append(User.telegram_id == search_int)
except ValueError:
# Если не удалось преобразовать в int, просто ищем по текстовым полям
pass
query = query.where(or_(*conditions))
@@ -579,7 +691,15 @@ async def get_users_list(
query = query.offset(offset).limit(limit)
result = await db.execute(query)
return result.scalars().all()
users = result.scalars().all()
# Загружаем дополнительные зависимости для всех пользователей
for user in users:
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return users
async def get_users_count(
@@ -602,7 +722,14 @@ async def get_users_count(
]
if search.isdigit():
conditions.append(User.telegram_id == int(search))
try:
search_int = int(search)
# Добавляем условие поиска по telegram_id, который является BigInteger
# и может содержать большие значения, в отличие от User.id (INTEGER)
conditions.append(User.telegram_id == search_int)
except ValueError:
# Если не удалось преобразовать в int, просто ищем по текстовым полям
pass
query = query.where(or_(*conditions))
@@ -671,12 +798,22 @@ async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
select(User)
.options(
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
)
.where(User.referred_by_id == user_id)
.order_by(User.created_at.desc())
)
return result.scalars().all()
users = result.scalars().all()
# Загружаем дополнительные зависимости для всех пользователей
for user in users:
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return users
async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[User]:
@@ -684,7 +821,11 @@ async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[Us
base_query = (
select(User)
.options(selectinload(User.subscription))
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.referrer),
)
.where(User.status == UserStatus.ACTIVE.value)
)
@@ -729,7 +870,15 @@ async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[Us
return []
result = await db.execute(query.order_by(User.id))
return result.scalars().unique().all()
users = result.scalars().unique().all()
# Загружаем дополнительные зависимости для всех пользователей
for user in users:
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return users
async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
@@ -739,6 +888,8 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
select(User)
.options(
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
)
.where(
@@ -748,7 +899,15 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
)
)
)
return result.scalars().all()
users = result.scalars().all()
# Загружаем дополнительные зависимости для всех пользователей
for user in users:
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return users
async def delete_user(db: AsyncSession, user: User) -> bool:
+311
View File
@@ -0,0 +1,311 @@
"""CRUD операции для связи пользователей с промогруппами (Many-to-Many)."""
import logging
from datetime import datetime
from typing import List, Optional
from sqlalchemy import select, and_, desc
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import UserPromoGroup, PromoGroup, User
logger = logging.getLogger(__name__)
async def _sync_user_primary_promo_group(
db: AsyncSession,
user_id: int,
) -> None:
"""Синхронизирует колонку users.promo_group_id с primary промогруппой."""
try:
result = await db.execute(
select(UserPromoGroup.promo_group_id)
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
.where(UserPromoGroup.user_id == user_id)
.order_by(desc(PromoGroup.priority), PromoGroup.id)
)
first = result.first()
new_primary_id = first[0] if first else None
user = await db.get(User, user_id)
if not user:
return
if user.promo_group_id != new_primary_id:
user.promo_group_id = new_primary_id
user.updated_at = datetime.utcnow()
except Exception as error:
logger.error(
"Ошибка синхронизации primary промогруппы пользователя %s: %s",
user_id,
error,
)
async def sync_user_primary_promo_group(
db: AsyncSession,
user_id: int,
) -> None:
"""Публичная обертка для синхронизации primary промогруппы пользователя."""
await _sync_user_primary_promo_group(db, user_id)
async def add_user_to_promo_group(
db: AsyncSession,
user_id: int,
promo_group_id: int,
assigned_by: str = "admin"
) -> Optional[UserPromoGroup]:
"""
Добавляет пользователю промогруппу.
Args:
db: Сессия БД
user_id: ID пользователя
promo_group_id: ID промогруппы
assigned_by: Кто назначил ('admin', 'system', 'auto', 'promocode')
Returns:
UserPromoGroup или None если уже существует
"""
try:
# Проверяем существование связи
existing = await has_user_promo_group(db, user_id, promo_group_id)
if existing:
logger.info(f"Пользователь {user_id} уже имеет промогруппу {promo_group_id}")
return None
# Создаем новую связь
user_promo_group = UserPromoGroup(
user_id=user_id,
promo_group_id=promo_group_id,
assigned_by=assigned_by,
)
db.add(user_promo_group)
await db.flush()
await _sync_user_primary_promo_group(db, user_id)
await db.commit()
await db.refresh(user_promo_group)
logger.info(f"Пользователю {user_id} добавлена промогруппа {promo_group_id} ({assigned_by})")
return user_promo_group
except Exception as error:
logger.error(f"Ошибка добавления промогруппы пользователю: {error}")
await db.rollback()
return None
async def remove_user_from_promo_group(
db: AsyncSession,
user_id: int,
promo_group_id: int
) -> bool:
"""
Удаляет промогруппу у пользователя.
Args:
db: Сессия БД
user_id: ID пользователя
promo_group_id: ID промогруппы
Returns:
True если удалено, False если связи не было
"""
try:
result = await db.execute(
select(UserPromoGroup).where(
and_(
UserPromoGroup.user_id == user_id,
UserPromoGroup.promo_group_id == promo_group_id
)
)
)
user_promo_group = result.scalar_one_or_none()
if not user_promo_group:
logger.warning(f"Связь пользователя {user_id} с промогруппой {promo_group_id} не найдена")
return False
await db.delete(user_promo_group)
await db.flush()
await _sync_user_primary_promo_group(db, user_id)
await db.commit()
logger.info(f"У пользователя {user_id} удалена промогруппа {promo_group_id}")
return True
except Exception as error:
logger.error(f"Ошибка удаления промогруппы у пользователя: {error}")
await db.rollback()
return False
async def get_user_promo_groups(
db: AsyncSession,
user_id: int
) -> List[UserPromoGroup]:
"""
Получает все промогруппы пользователя, отсортированные по приоритету.
Args:
db: Сессия БД
user_id: ID пользователя
Returns:
Список UserPromoGroup с загруженными PromoGroup, отсортированный по приоритету DESC
"""
try:
result = await db.execute(
select(UserPromoGroup)
.options(selectinload(UserPromoGroup.promo_group))
.where(UserPromoGroup.user_id == user_id)
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
.order_by(desc(PromoGroup.priority), PromoGroup.id)
)
return list(result.scalars().all())
except Exception as error:
logger.error(f"Ошибка получения промогрупп пользователя {user_id}: {error}")
return []
async def get_primary_user_promo_group(
db: AsyncSession,
user_id: int
) -> Optional[PromoGroup]:
"""
Получает промогруппу пользователя с максимальным приоритетом.
Args:
db: Сессия БД
user_id: ID пользователя
Returns:
PromoGroup с максимальным приоритетом или None
"""
try:
user_promo_groups = await get_user_promo_groups(db, user_id)
if not user_promo_groups:
return None
# Первая в списке имеет максимальный приоритет (список уже отсортирован)
return user_promo_groups[0].promo_group if user_promo_groups[0].promo_group else None
except Exception as error:
logger.error(f"Ошибка получения primary промогруппы пользователя {user_id}: {error}")
return None
async def has_user_promo_group(
db: AsyncSession,
user_id: int,
promo_group_id: int
) -> bool:
"""
Проверяет наличие промогруппы у пользователя.
Args:
db: Сессия БД
user_id: ID пользователя
promo_group_id: ID промогруппы
Returns:
True если пользователь уже имеет эту промогруппу
"""
try:
result = await db.execute(
select(UserPromoGroup).where(
and_(
UserPromoGroup.user_id == user_id,
UserPromoGroup.promo_group_id == promo_group_id
)
)
)
return result.scalar_one_or_none() is not None
except Exception as error:
logger.error(f"Ошибка проверки промогруппы пользователя: {error}")
return False
async def count_user_promo_groups(
db: AsyncSession,
user_id: int
) -> int:
"""
Подсчитывает количество промогрупп у пользователя.
Args:
db: Сессия БД
user_id: ID пользователя
Returns:
Количество промогрупп
"""
try:
result = await db.execute(
select(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
)
return len(list(result.scalars().all()))
except Exception as error:
logger.error(f"Ошибка подсчета промогрупп пользователя: {error}")
return 0
async def replace_user_promo_groups(
db: AsyncSession,
user_id: int,
promo_group_ids: List[int],
assigned_by: str = "admin"
) -> bool:
"""
Заменяет все промогруппы пользователя на новый список.
Args:
db: Сессия БД
user_id: ID пользователя
promo_group_ids: Список ID промогрупп
assigned_by: Кто назначил
Returns:
True если успешно
"""
try:
# Удаляем все текущие промогруппы
await db.execute(
select(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
)
result = await db.execute(
select(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
)
for upg in result.scalars().all():
await db.delete(upg)
# Добавляем новые
for promo_group_id in promo_group_ids:
user_promo_group = UserPromoGroup(
user_id=user_id,
promo_group_id=promo_group_id,
assigned_by=assigned_by
)
db.add(user_promo_group)
await db.commit()
logger.info(f"Промогруппы пользователя {user_id} заменены на {promo_group_ids}")
return True
except Exception as error:
logger.error(f"Ошибка замены промогрупп пользователя: {error}")
await db.rollback()
return False
+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
+360 -14
View File
@@ -1,31 +1,167 @@
import logging
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.pool import NullPool
from typing import AsyncGenerator, Optional
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import (
AsyncSession,
create_async_engine,
async_sessionmaker,
AsyncEngine
)
from sqlalchemy.pool import NullPool, AsyncAdaptedQueuePool
from sqlalchemy import event, text, bindparam, inspect
from sqlalchemy.engine import Engine
import time
from app.config import settings
from app.database.models import Base
logger = logging.getLogger(__name__)
# ============================================================================
# PRODUCTION-GRADE CONNECTION POOLING
# ============================================================================
if settings.get_database_url().startswith("sqlite"):
poolclass = NullPool
pool_kwargs = {}
else:
poolclass = AsyncAdaptedQueuePool
pool_kwargs = {
"pool_size": 20,
"max_overflow": 30,
"pool_timeout": 30,
"pool_recycle": 3600,
"pool_pre_ping": True,
# 🔥 Агрессивная очистка мертвых соединений
"pool_reset_on_return": "rollback",
}
# ============================================================================
# ENGINE WITH ADVANCED OPTIMIZATIONS
# ============================================================================
engine = create_async_engine(
settings.get_database_url(),
poolclass=NullPool,
poolclass=poolclass,
echo=settings.DEBUG,
future=True
future=True,
**pool_kwargs,
connect_args={
"server_settings": {
"application_name": "remnawave_bot",
"jit": "on",
"statement_timeout": "60000", # 60 секунд
"idle_in_transaction_session_timeout": "300000", # 5 минут
},
"command_timeout": 60,
"timeout": 10,
} if not settings.get_database_url().startswith("sqlite") else {},
execution_options={
"isolation_level": "READ COMMITTED", # Оптимальный для большинства случаев
"compiled_cache_size": 500, # Кеш скомпилированных запросов
}
)
# ============================================================================
# SESSION FACTORY WITH OPTIMIZATIONS
# ============================================================================
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=True,
autocommit=False
autoflush=False, # 🔥 Критично для производительности
autocommit=False,
)
# ============================================================================
# QUERY PERFORMANCE MONITORING
# ============================================================================
if settings.DEBUG:
@event.listens_for(Engine, "before_cursor_execute")
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
conn.info.setdefault("query_start_time", []).append(time.time())
logger.debug(f"🔍 Executing query: {statement[:100]}...")
@event.listens_for(Engine, "after_cursor_execute")
def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
total = time.time() - conn.info["query_start_time"].pop(-1)
if total > 0.1: # Логируем медленные запросы > 100ms
logger.warning(f"🐌 Slow query ({total:.3f}s): {statement[:100]}...")
else:
logger.debug(f"⚡ Query executed in {total:.3f}s")
# ============================================================================
# ADVANCED SESSION MANAGER WITH READ REPLICAS
# ============================================================================
class DatabaseManager:
"""Продвинутый менеджер БД с поддержкой реплик и кеширования"""
def __init__(self):
self.engine = engine
self.read_replica_engine: Optional[AsyncEngine] = None
if hasattr(settings, 'DATABASE_READ_REPLICA_URL') and settings.DATABASE_READ_REPLICA_URL:
self.read_replica_engine = create_async_engine(
settings.DATABASE_READ_REPLICA_URL,
poolclass=poolclass,
pool_size=30, # Больше для read операций
max_overflow=50,
pool_pre_ping=True,
echo=False,
)
@asynccontextmanager
async def session(self, read_only: bool = False):
target_engine = self.read_replica_engine if (read_only and self.read_replica_engine) else self.engine
async_session = async_sessionmaker(
bind=target_engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
async with async_session() as session:
try:
yield session
if not read_only:
await session.commit()
except Exception:
await session.rollback()
raise
async def health_check(self) -> dict:
pool = self.engine.pool
try:
async with AsyncSessionLocal() as session:
start = time.time()
await session.execute(text("SELECT 1"))
latency = (time.time() - start) * 1000
status = "healthy"
except Exception as e:
logger.error(f"❌ Database health check failed: {e}")
status = "unhealthy"
latency = None
return {
"status": status,
"latency_ms": round(latency, 2) if latency else None,
"pool": _collect_health_pool_metrics(pool),
}
db_manager = DatabaseManager()
# ============================================================================
# SESSION DEPENDENCY FOR FASTAPI/AIOGRAM
# ============================================================================
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""Стандартная dependency для FastAPI"""
async with AsyncSessionLocal() as session:
try:
yield session
@@ -33,19 +169,229 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def get_db_read_only() -> AsyncGenerator[AsyncSession, None]:
"""Read-only dependency для тяжелых SELECT запросов"""
async with db_manager.session(read_only=True) as session:
yield session
# ============================================================================
# BATCH OPERATIONS FOR PERFORMANCE
# ============================================================================
class BatchOperations:
"""Утилиты для массовых операций"""
@staticmethod
async def bulk_insert(session: AsyncSession, model, data: list[dict], chunk_size: int = 1000):
"""Массовая вставка с чанками"""
for i in range(0, len(data), chunk_size):
chunk = data[i:i + chunk_size]
session.add_all([model(**item) for item in chunk])
await session.flush()
await session.commit()
@staticmethod
async def bulk_update(session: AsyncSession, model, data: list[dict], chunk_size: int = 1000):
"""Массовое обновление с чанками"""
if not data:
return
primary_keys = [column.name for column in model.__table__.primary_key.columns]
if not primary_keys:
raise ValueError("Model must have a primary key for bulk_update")
updatable_columns = [
column.name
for column in model.__table__.columns
if column.name not in primary_keys
]
if not updatable_columns:
raise ValueError("No columns available for update in bulk_update")
stmt = (
model.__table__.update()
.where(
*[
getattr(model.__table__.c, pk) == bindparam(pk)
for pk in primary_keys
]
)
.values(
**{
column: bindparam(column, required=False)
for column in updatable_columns
}
)
)
for i in range(0, len(data), chunk_size):
chunk = data[i:i + chunk_size]
filtered_chunk = []
for item in chunk:
missing_keys = [pk for pk in primary_keys if pk not in item]
if missing_keys:
raise ValueError(
f"Missing primary key values {missing_keys} for bulk_update"
)
filtered_item = {
key: value
for key, value in item.items()
if key in primary_keys or key in updatable_columns
}
filtered_chunk.append(filtered_item)
await session.execute(stmt, filtered_chunk)
await session.commit()
batch_ops = BatchOperations()
# ============================================================================
# INITIALIZATION AND CLEANUP
# ============================================================================
async def init_db():
logger.info("Создание таблиц базы данных...")
"""Инициализация БД с оптимизациями"""
logger.info("🚀 Создание таблиц базы данных...")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
if not settings.get_database_url().startswith("sqlite"):
logger.info("📊 Создание индексов для оптимизации...")
async with engine.begin() as conn:
indexes = [
("users", "CREATE INDEX IF NOT EXISTS idx_users_telegram_id ON users(telegram_id)"),
(
"subscriptions",
"CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id)",
),
(
"subscriptions",
"CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status) WHERE status = 'active'",
),
(
"payments",
"CREATE INDEX IF NOT EXISTS idx_payments_created_at ON payments(created_at DESC)",
),
]
for table_name, index_sql in indexes:
table_exists = await conn.run_sync(lambda sync_conn: inspect(sync_conn).has_table(table_name))
if not table_exists:
logger.debug(
"Пропускаем создание индекса %s: таблица %s отсутствует",
index_sql,
table_name,
)
continue
try:
await conn.execute(text(index_sql))
except Exception as e:
logger.debug("Index creation skipped for %s: %s", table_name, e)
logger.info("✅ База данных успешно инициализирована")
health = await db_manager.health_check()
logger.info(f"📊 Database health: {health}")
async def close_db():
"""Корректное закрытие всех соединений"""
logger.info("🔄 Закрытие соединений с БД...")
await engine.dispose()
logger.info("✅ Подключение к базе данных закрыто")
if db_manager.read_replica_engine:
await db_manager.read_replica_engine.dispose()
logger.info("✅ Все подключения к базе данных закрыты")
# ============================================================================
# CONNECTION POOL METRICS (для мониторинга)
# ============================================================================
def _pool_counters(pool):
"""Return basic pool counters or ``None`` when unsupported."""
required_methods = ("size", "checkedin", "checkedout", "overflow")
for method_name in required_methods:
method = getattr(pool, method_name, None)
if method is None or not callable(method):
return None
size = pool.size()
checked_in = pool.checkedin()
checked_out = pool.checkedout()
overflow = pool.overflow()
total_connections = size + overflow
return {
"size": size,
"checked_in": checked_in,
"checked_out": checked_out,
"overflow": overflow,
"total_connections": total_connections,
"utilization_percent": (checked_out / total_connections * 100) if total_connections else 0.0,
}
def _collect_health_pool_metrics(pool) -> dict:
counters = _pool_counters(pool)
if counters is None:
return {
"metrics_available": False,
"size": 0,
"checked_in": 0,
"checked_out": 0,
"overflow": 0,
"total_connections": 0,
"utilization": "0.0%",
}
return {
"metrics_available": True,
"size": counters["size"],
"checked_in": counters["checked_in"],
"checked_out": counters["checked_out"],
"overflow": counters["overflow"],
"total_connections": counters["total_connections"],
"utilization": f"{counters['utilization_percent']:.1f}%",
}
async def get_pool_metrics() -> dict:
"""Детальные метрики пула для Prometheus/Grafana"""
pool = engine.pool
counters = _pool_counters(pool)
if counters is None:
return {
"metrics_available": False,
"pool_size": 0,
"checked_in_connections": 0,
"checked_out_connections": 0,
"overflow_connections": 0,
"total_connections": 0,
"max_possible_connections": 0,
"pool_utilization_percent": 0.0,
}
return {
"metrics_available": True,
"pool_size": counters["size"],
"checked_in_connections": counters["checked_in"],
"checked_out_connections": counters["checked_out"],
"overflow_connections": counters["overflow"],
"total_connections": counters["total_connections"],
"max_possible_connections": counters["total_connections"] + (getattr(pool, "_max_overflow", 0) or 0),
"pool_utilization_percent": round(counters["utilization_percent"], 2),
}
+340 -19
View File
@@ -1,5 +1,5 @@
from datetime import datetime, timedelta
from typing import Optional, List, Dict
from typing import Optional, List, Dict, Any
from enum import Enum
from sqlalchemy import (
@@ -54,20 +54,23 @@ class SubscriptionStatus(Enum):
ACTIVE = "active"
EXPIRED = "expired"
DISABLED = "disabled"
PENDING = "pending"
class TransactionType(Enum):
DEPOSIT = "deposit"
WITHDRAWAL = "withdrawal"
SUBSCRIPTION_PAYMENT = "subscription_payment"
REFUND = "refund"
REFERRAL_REWARD = "referral_reward"
DEPOSIT = "deposit"
WITHDRAWAL = "withdrawal"
SUBSCRIPTION_PAYMENT = "subscription_payment"
REFUND = "refund"
REFERRAL_REWARD = "referral_reward"
POLL_REWARD = "poll_reward"
class PromoCodeType(Enum):
BALANCE = "balance"
SUBSCRIPTION_DAYS = "subscription_days"
TRIAL_SUBSCRIPTION = "trial_subscription"
BALANCE = "balance"
SUBSCRIPTION_DAYS = "subscription_days"
TRIAL_SUBSCRIPTION = "trial_subscription"
PROMO_GROUP = "promo_group"
class PaymentMethod(Enum):
@@ -75,8 +78,11 @@ class PaymentMethod(Enum):
TRIBUTE = "tribute"
YOOKASSA = "yookassa"
CRYPTOBOT = "cryptobot"
HELEKET = "heleket"
MULENPAY = "mulenpay"
PAL24 = "pal24"
WATA = "wata"
PLATEGA = "platega"
MANUAL = "manual"
@@ -188,6 +194,72 @@ class CryptoBotPayment(Base):
return f"<CryptoBotPayment(id={self.id}, invoice_id={self.invoice_id}, amount={self.amount} {self.asset}, status={self.status})>"
class HeleketPayment(Base):
__tablename__ = "heleket_payments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
uuid = Column(String(255), unique=True, nullable=False, index=True)
order_id = Column(String(128), unique=True, nullable=False, index=True)
amount = Column(String(50), nullable=False)
currency = Column(String(10), nullable=False)
payer_amount = Column(String(50), nullable=True)
payer_currency = Column(String(10), nullable=True)
exchange_rate = Column(Float, nullable=True)
discount_percent = Column(Integer, nullable=True)
status = Column(String(50), nullable=False)
payment_url = Column(Text, nullable=True)
metadata_json = Column(JSON, nullable=True)
paid_at = Column(DateTime, nullable=True)
expires_at = Column(DateTime, nullable=True)
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
user = relationship("User", backref="heleket_payments")
transaction = relationship("Transaction", backref="heleket_payment")
@property
def amount_float(self) -> float:
try:
return float(self.amount)
except (TypeError, ValueError):
return 0.0
@property
def amount_kopeks(self) -> int:
return int(round(self.amount_float * 100))
@property
def payer_amount_float(self) -> float:
try:
return float(self.payer_amount) if self.payer_amount is not None else 0.0
except (TypeError, ValueError):
return 0.0
@property
def is_paid(self) -> bool:
return self.status in {"paid", "paid_over"}
def __repr__(self):
return (
"<HeleketPayment(id={id}, uuid={uuid}, order_id={order_id}, amount={amount}"
" {currency}, status={status})>"
).format(
id=self.id,
uuid=self.uuid,
order_id=self.order_id,
amount=self.amount,
currency=self.currency,
status=self.status,
)
class MulenPayPayment(Base):
__tablename__ = "mulenpay_payments"
@@ -293,11 +365,112 @@ 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 PlategaPayment(Base):
__tablename__ = "platega_payments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
platega_transaction_id = Column(String(255), unique=True, nullable=True, index=True)
correlation_id = Column(String(64), unique=True, nullable=False, index=True)
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default="RUB")
description = Column(Text, nullable=True)
payment_method_code = Column(Integer, nullable=False)
status = Column(String(50), nullable=False, default="PENDING")
is_paid = Column(Boolean, default=False)
paid_at = Column(DateTime, nullable=True)
redirect_url = Column(Text, nullable=True)
return_url = Column(Text, nullable=True)
failed_url = Column(Text, nullable=True)
payload = Column(String(255), 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="platega_payments")
transaction = relationship("Transaction", backref="platega_payment")
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
def __repr__(self) -> str: # pragma: no cover - debug helper
return (
"<PlategaPayment(id={0}, transaction_id={1}, amount={2}₽, status={3}, method={4})>".format(
self.id,
self.platega_transaction_id,
self.amount_rubles,
self.status,
self.payment_method_code,
)
)
class PromoGroup(Base):
__tablename__ = "promo_groups"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), unique=True, nullable=False)
priority = Column(Integer, nullable=False, default=0, index=True)
server_discount_percent = Column(Integer, nullable=False, default=0)
traffic_discount_percent = Column(Integer, nullable=False, default=0)
device_discount_percent = Column(Integer, nullable=False, default=0)
@@ -309,6 +482,7 @@ class PromoGroup(Base):
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
users = relationship("User", back_populates="promo_group")
user_promo_groups = relationship("UserPromoGroup", back_populates="promo_group", cascade="all, delete-orphan")
server_squads = relationship(
"ServerSquad",
secondary=server_squad_promo_groups,
@@ -372,6 +546,22 @@ class PromoGroup(Base):
return max(0, min(100, percent))
class UserPromoGroup(Base):
"""Таблица связи Many-to-Many между пользователями и промогруппами."""
__tablename__ = "user_promo_groups"
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="CASCADE"), primary_key=True)
assigned_at = Column(DateTime, default=func.now())
assigned_by = Column(String(50), default="system")
user = relationship("User", back_populates="user_promo_groups")
promo_group = relationship("PromoGroup", back_populates="user_promo_groups")
def __repr__(self):
return f"<UserPromoGroup(user_id={self.user_id}, promo_group_id={self.promo_group_id}, assigned_by='{self.assigned_by}')>"
class User(Base):
__tablename__ = "users"
@@ -409,22 +599,51 @@ class User(Base):
vless_uuid = Column(String(255), nullable=True)
ss_password = Column(String(255), nullable=True)
has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=True, index=True)
promo_group = relationship("PromoGroup", back_populates="users")
user_promo_groups = relationship("UserPromoGroup", back_populates="user", cascade="all, delete-orphan")
poll_responses = relationship("PollResponse", back_populates="user")
@property
def balance_rubles(self) -> float:
return self.balance_kopeks / 100
@property
def full_name(self) -> str:
parts = [self.first_name, self.last_name]
return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}"
def get_primary_promo_group(self):
"""Возвращает промогруппу с максимальным приоритетом."""
if not self.user_promo_groups:
return getattr(self, "promo_group", None)
try:
# Сортируем по приоритету группы (убывание), затем по ID группы
# Используем getattr для защиты от ленивой загрузки
sorted_groups = sorted(
self.user_promo_groups,
key=lambda upg: (
getattr(upg.promo_group, 'priority', 0) if upg.promo_group else 0,
upg.promo_group_id
),
reverse=True
)
if sorted_groups and sorted_groups[0].promo_group:
return sorted_groups[0].promo_group
except Exception:
# Если возникла ошибка (например, ленивая загрузка), fallback на старую связь
pass
# Fallback на старую связь если новая пустая или возникла ошибка
return getattr(self, "promo_group", None)
def get_promo_discount(self, category: str, period_days: Optional[int] = None) -> int:
if not self.promo_group:
primary_group = self.get_primary_promo_group()
if not primary_group:
return 0
return self.promo_group.get_discount_percent(category, period_days)
return primary_group.get_discount_percent(category, period_days)
def add_balance(self, kopeks: int) -> None:
self.balance_kopeks += kopeks
@@ -672,13 +891,15 @@ class PromoCode(Base):
valid_until = Column(DateTime, nullable=True)
is_active = Column(Boolean, default=True)
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="SET NULL"), nullable=True, index=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
uses = relationship("PromoCodeUse", back_populates="promocode")
promo_group = relationship("PromoGroup")
@property
def is_valid(self) -> bool:
@@ -942,9 +1163,9 @@ class PromoOfferLog(Base):
class BroadcastHistory(Base):
__tablename__ = "broadcast_history"
id = Column(Integer, primary_key=True, index=True)
target_type = Column(String(100), nullable=False)
target_type = Column(String(100), nullable=False)
message_text = Column(Text, nullable=False)
has_media = Column(Boolean, default=False)
media_type = Column(String(20), nullable=True)
@@ -960,6 +1181,106 @@ class BroadcastHistory(Base):
completed_at = Column(DateTime(timezone=True), nullable=True)
admin = relationship("User", back_populates="broadcasts")
class Poll(Base):
__tablename__ = "polls"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
reward_enabled = Column(Boolean, nullable=False, default=False)
reward_amount_kopeks = Column(Integer, nullable=False, default=0)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, default=func.now(), nullable=False)
updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), nullable=False)
creator = relationship("User", backref="created_polls", foreign_keys=[created_by])
questions = relationship(
"PollQuestion",
back_populates="poll",
cascade="all, delete-orphan",
order_by="PollQuestion.order",
)
responses = relationship(
"PollResponse",
back_populates="poll",
cascade="all, delete-orphan",
)
class PollQuestion(Base):
__tablename__ = "poll_questions"
id = Column(Integer, primary_key=True, index=True)
poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False, index=True)
text = Column(Text, nullable=False)
order = Column(Integer, nullable=False, default=0)
poll = relationship("Poll", back_populates="questions")
options = relationship(
"PollOption",
back_populates="question",
cascade="all, delete-orphan",
order_by="PollOption.order",
)
answers = relationship("PollAnswer", back_populates="question")
class PollOption(Base):
__tablename__ = "poll_options"
id = Column(Integer, primary_key=True, index=True)
question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False, index=True)
text = Column(Text, nullable=False)
order = Column(Integer, nullable=False, default=0)
question = relationship("PollQuestion", back_populates="options")
answers = relationship("PollAnswer", back_populates="option")
class PollResponse(Base):
__tablename__ = "poll_responses"
id = Column(Integer, primary_key=True, index=True)
poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
sent_at = Column(DateTime, default=func.now(), nullable=False)
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
reward_given = Column(Boolean, nullable=False, default=False)
reward_amount_kopeks = Column(Integer, nullable=False, default=0)
poll = relationship("Poll", back_populates="responses")
user = relationship("User", back_populates="poll_responses")
answers = relationship(
"PollAnswer",
back_populates="response",
cascade="all, delete-orphan",
)
__table_args__ = (
UniqueConstraint("poll_id", "user_id", name="uq_poll_user"),
)
class PollAnswer(Base):
__tablename__ = "poll_answers"
id = Column(Integer, primary_key=True, index=True)
response_id = Column(Integer, ForeignKey("poll_responses.id", ondelete="CASCADE"), nullable=False, index=True)
question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False, index=True)
option_id = Column(Integer, ForeignKey("poll_options.id", ondelete="CASCADE"), nullable=False, index=True)
created_at = Column(DateTime, default=func.now(), nullable=False)
response = relationship("PollResponse", back_populates="answers")
question = relationship("PollQuestion", back_populates="answers")
option = relationship("PollOption", back_populates="answers")
__table_args__ = (
UniqueConstraint("response_id", "question_id", name="uq_poll_answer_unique"),
)
class ServerSquad(Base):
__tablename__ = "server_squads"
+885 -6
View File
@@ -1,5 +1,6 @@
import logging
from datetime import datetime
from typing import List, Tuple
from sqlalchemy import inspect, select, text
from sqlalchemy.ext.asyncio import AsyncSession
@@ -268,6 +269,210 @@ async def check_index_exists(table_name: str, index_name: str) -> bool:
)
return False
async def fetch_duplicate_payment_links(conn) -> List[Tuple[str, int]]:
result = await conn.execute(
text(
"SELECT payment_link_id, COUNT(*) AS cnt "
"FROM wata_payments "
"WHERE payment_link_id IS NOT NULL AND payment_link_id <> '' "
"GROUP BY payment_link_id "
"HAVING COUNT(*) > 1"
)
)
return [(row[0], row[1]) for row in result.fetchall()]
def _build_dedup_suffix(base_suffix: str, record_id: int, max_length: int = 64) -> Tuple[str, int]:
suffix = f"{base_suffix}{record_id}"
trimmed_length = max_length - len(suffix)
if trimmed_length < 1:
# Fallback: use the record id only to stay within the limit.
suffix = f"dup-{record_id}"
trimmed_length = max_length - len(suffix)
return suffix, trimmed_length
async def resolve_duplicate_payment_links(conn, db_type: str) -> bool:
duplicates = await fetch_duplicate_payment_links(conn)
if not duplicates:
return True
logger.warning(
"Найдены дубликаты payment_link_id в wata_payments: %s",
", ".join(f"{link}×{count}" for link, count in duplicates[:5]),
)
for payment_link_id, _ in duplicates:
result = await conn.execute(
text(
"SELECT id, payment_link_id FROM wata_payments "
"WHERE payment_link_id = :payment_link_id "
"ORDER BY id"
),
{"payment_link_id": payment_link_id},
)
rows = result.fetchall()
if not rows:
continue
# Skip the first occurrence to preserve the original link value.
for duplicate_row in rows[1:]:
record_id = duplicate_row[0]
original_link = duplicate_row[1] or ""
suffix, trimmed_length = _build_dedup_suffix("-dup-", record_id)
new_base = original_link[:trimmed_length] if trimmed_length > 0 else ""
new_link = f"{new_base}{suffix}" if new_base else suffix
await conn.execute(
text(
"UPDATE wata_payments SET payment_link_id = :new_link "
"WHERE id = :record_id"
),
{"new_link": new_link, "record_id": record_id},
)
remaining_duplicates = await fetch_duplicate_payment_links(conn)
if remaining_duplicates:
logger.error(
"Не удалось устранить дубликаты payment_link_id: %s",
", ".join(f"{link}×{count}" for link, count in remaining_duplicates[:5]),
)
return False
logger.info("✅ Дубликаты payment_link_id устранены")
return True
async def enforce_wata_payment_link_constraints(
conn,
db_type: str,
unique_index_exists: bool,
legacy_index_exists: bool,
) -> Tuple[bool, bool]:
try:
if db_type == "sqlite":
await conn.execute(
text(
"UPDATE wata_payments "
"SET payment_link_id = 'legacy-' || id "
"WHERE payment_link_id IS NULL OR payment_link_id = ''"
)
)
if not await resolve_duplicate_payment_links(conn, db_type):
return unique_index_exists, legacy_index_exists
if not unique_index_exists:
await conn.execute(
text(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_wata_payment_link "
"ON wata_payments(payment_link_id)"
)
)
logger.info("✅ Создан уникальный индекс uq_wata_payment_link для payment_link_id")
unique_index_exists = True
else:
logger.info("ℹ️ Уникальный индекс для payment_link_id уже существует")
if legacy_index_exists and unique_index_exists:
await conn.execute(text("DROP INDEX IF EXISTS idx_wata_link_id"))
logger.info("ℹ️ Удалён устаревший индекс idx_wata_link_id")
legacy_index_exists = False
return unique_index_exists, legacy_index_exists
if db_type == "postgresql":
await conn.execute(
text(
"UPDATE wata_payments "
"SET payment_link_id = 'legacy-' || id::text "
"WHERE payment_link_id IS NULL OR payment_link_id = ''"
)
)
await conn.execute(
text(
"ALTER TABLE wata_payments "
"ALTER COLUMN payment_link_id SET NOT NULL"
)
)
logger.info("✅ Колонка payment_link_id теперь NOT NULL")
if not await resolve_duplicate_payment_links(conn, db_type):
return unique_index_exists, legacy_index_exists
if not unique_index_exists:
await conn.execute(
text(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_wata_payment_link "
"ON wata_payments(payment_link_id)"
)
)
logger.info("✅ Создан уникальный индекс uq_wata_payment_link для payment_link_id")
unique_index_exists = True
else:
logger.info("ℹ️ Уникальный индекс для payment_link_id уже существует")
if legacy_index_exists and unique_index_exists:
await conn.execute(text("DROP INDEX IF EXISTS idx_wata_link_id"))
logger.info("ℹ️ Удалён устаревший индекс idx_wata_link_id")
legacy_index_exists = False
return unique_index_exists, legacy_index_exists
if db_type == "mysql":
await conn.execute(
text(
"UPDATE wata_payments "
"SET payment_link_id = CONCAT('legacy-', id) "
"WHERE payment_link_id IS NULL OR payment_link_id = ''"
)
)
await conn.execute(
text(
"ALTER TABLE wata_payments "
"MODIFY COLUMN payment_link_id VARCHAR(64) NOT NULL"
)
)
logger.info("✅ Колонка payment_link_id теперь NOT NULL")
if not await resolve_duplicate_payment_links(conn, db_type):
return unique_index_exists, legacy_index_exists
if not unique_index_exists:
await conn.execute(
text(
"CREATE UNIQUE INDEX uq_wata_payment_link "
"ON wata_payments(payment_link_id)"
)
)
logger.info("✅ Создан уникальный индекс uq_wata_payment_link для payment_link_id")
unique_index_exists = True
else:
logger.info("ℹ️ Уникальный индекс для payment_link_id уже существует")
if legacy_index_exists and unique_index_exists:
await conn.execute(text("DROP INDEX idx_wata_link_id ON wata_payments"))
logger.info("ℹ️ Удалён устаревший индекс idx_wata_link_id")
legacy_index_exists = False
return unique_index_exists, legacy_index_exists
logger.warning(
"⚠️ Неизвестный тип БД %s — не удалось усилить ограничения payment_link_id", db_type
)
return unique_index_exists, legacy_index_exists
except Exception as e:
logger.error(f"Ошибка настройки ограничений payment_link_id: {e}")
return unique_index_exists, legacy_index_exists
async def create_cryptobot_payments_table():
table_exists = await check_table_exists('cryptobot_payments')
if table_exists:
@@ -371,6 +576,120 @@ async def create_cryptobot_payments_table():
return False
async def create_heleket_payments_table():
table_exists = await check_table_exists('heleket_payments')
if table_exists:
logger.info("Таблица heleket_payments уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
create_sql = """
CREATE TABLE heleket_payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
uuid VARCHAR(255) UNIQUE NOT NULL,
order_id VARCHAR(128) UNIQUE NOT NULL,
amount VARCHAR(50) NOT NULL,
currency VARCHAR(10) NOT NULL,
payer_amount VARCHAR(50) NULL,
payer_currency VARCHAR(10) NULL,
exchange_rate DOUBLE PRECISION NULL,
discount_percent INTEGER NULL,
status VARCHAR(50) NOT NULL,
payment_url TEXT NULL,
metadata_json JSON NULL,
paid_at DATETIME NULL,
expires_at DATETIME NULL,
transaction_id INTEGER NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
);
CREATE INDEX idx_heleket_payments_user_id ON heleket_payments(user_id);
CREATE INDEX idx_heleket_payments_uuid ON heleket_payments(uuid);
CREATE INDEX idx_heleket_payments_order_id ON heleket_payments(order_id);
CREATE INDEX idx_heleket_payments_status ON heleket_payments(status);
"""
elif db_type == 'postgresql':
create_sql = """
CREATE TABLE heleket_payments (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
uuid VARCHAR(255) UNIQUE NOT NULL,
order_id VARCHAR(128) UNIQUE NOT NULL,
amount VARCHAR(50) NOT NULL,
currency VARCHAR(10) NOT NULL,
payer_amount VARCHAR(50) NULL,
payer_currency VARCHAR(10) NULL,
exchange_rate DOUBLE PRECISION NULL,
discount_percent INTEGER NULL,
status VARCHAR(50) NOT NULL,
payment_url TEXT NULL,
metadata_json JSON NULL,
paid_at TIMESTAMP NULL,
expires_at TIMESTAMP NULL,
transaction_id INTEGER NULL REFERENCES transactions(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_heleket_payments_user_id ON heleket_payments(user_id);
CREATE INDEX idx_heleket_payments_uuid ON heleket_payments(uuid);
CREATE INDEX idx_heleket_payments_order_id ON heleket_payments(order_id);
CREATE INDEX idx_heleket_payments_status ON heleket_payments(status);
"""
elif db_type == 'mysql':
create_sql = """
CREATE TABLE heleket_payments (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
uuid VARCHAR(255) UNIQUE NOT NULL,
order_id VARCHAR(128) UNIQUE NOT NULL,
amount VARCHAR(50) NOT NULL,
currency VARCHAR(10) NOT NULL,
payer_amount VARCHAR(50) NULL,
payer_currency VARCHAR(10) NULL,
exchange_rate DOUBLE NULL,
discount_percent INT NULL,
status VARCHAR(50) NOT NULL,
payment_url TEXT NULL,
metadata_json JSON NULL,
paid_at DATETIME NULL,
expires_at DATETIME NULL,
transaction_id INT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
);
CREATE INDEX idx_heleket_payments_user_id ON heleket_payments(user_id);
CREATE INDEX idx_heleket_payments_uuid ON heleket_payments(uuid);
CREATE INDEX idx_heleket_payments_order_id ON heleket_payments(order_id);
CREATE INDEX idx_heleket_payments_status ON heleket_payments(status);
"""
else:
logger.error(f"Неподдерживаемый тип БД для таблицы heleket_payments: {db_type}")
return False
await conn.execute(text(create_sql))
logger.info("Таблица heleket_payments успешно создана")
return True
except Exception as e:
logger.error(f"Ошибка создания таблицы heleket_payments: {e}")
return False
async def create_mulenpay_payments_table():
table_exists = await check_table_exists('mulenpay_payments')
if table_exists:
@@ -706,6 +1025,270 @@ async def create_pal24_payments_table():
return False
async def create_wata_payments_table():
table_exists = await check_table_exists('wata_payments')
if table_exists:
logger.info("Таблица wata_payments уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
create_sql = """
CREATE TABLE wata_payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
payment_link_id VARCHAR(64) NOT NULL UNIQUE,
order_id VARCHAR(255) NULL,
amount_kopeks INTEGER NOT NULL,
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
description TEXT NULL,
type VARCHAR(50) NULL,
status VARCHAR(50) NOT NULL DEFAULT 'Opened',
is_paid BOOLEAN NOT NULL DEFAULT 0,
paid_at DATETIME NULL,
last_status VARCHAR(50) NULL,
terminal_public_id VARCHAR(64) NULL,
url TEXT NULL,
success_redirect_url TEXT NULL,
fail_redirect_url TEXT NULL,
metadata_json JSON NULL,
callback_payload JSON NULL,
expires_at DATETIME NULL,
transaction_id INTEGER NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
);
CREATE UNIQUE INDEX idx_wata_link_id ON wata_payments(payment_link_id);
CREATE INDEX idx_wata_order_id ON wata_payments(order_id);
"""
elif db_type == 'postgresql':
create_sql = """
CREATE TABLE wata_payments (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
payment_link_id VARCHAR(64) NOT NULL UNIQUE,
order_id VARCHAR(255) NULL,
amount_kopeks INTEGER NOT NULL,
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
description TEXT NULL,
type VARCHAR(50) NULL,
status VARCHAR(50) NOT NULL DEFAULT 'Opened',
is_paid BOOLEAN NOT NULL DEFAULT FALSE,
paid_at TIMESTAMP NULL,
last_status VARCHAR(50) NULL,
terminal_public_id VARCHAR(64) NULL,
url TEXT NULL,
success_redirect_url TEXT NULL,
fail_redirect_url TEXT NULL,
metadata_json JSON NULL,
callback_payload JSON NULL,
expires_at TIMESTAMP NULL,
transaction_id INTEGER NULL REFERENCES transactions(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX idx_wata_link_id ON wata_payments(payment_link_id);
CREATE INDEX idx_wata_order_id ON wata_payments(order_id);
"""
elif db_type == 'mysql':
create_sql = """
CREATE TABLE wata_payments (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
payment_link_id VARCHAR(64) NOT NULL UNIQUE,
order_id VARCHAR(255) NULL,
amount_kopeks INT NOT NULL,
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
description TEXT NULL,
type VARCHAR(50) NULL,
status VARCHAR(50) NOT NULL DEFAULT 'Opened',
is_paid BOOLEAN NOT NULL DEFAULT 0,
paid_at DATETIME NULL,
last_status VARCHAR(50) NULL,
terminal_public_id VARCHAR(64) NULL,
url TEXT NULL,
success_redirect_url TEXT NULL,
fail_redirect_url TEXT NULL,
metadata_json JSON NULL,
callback_payload JSON NULL,
expires_at DATETIME NULL,
transaction_id INT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
);
CREATE UNIQUE INDEX idx_wata_link_id ON wata_payments(payment_link_id);
CREATE INDEX idx_wata_order_id ON wata_payments(order_id);
"""
else:
logger.error(f"Неподдерживаемый тип БД для таблицы wata_payments: {db_type}")
return False
await conn.execute(text(create_sql))
logger.info("Таблица wata_payments успешно создана")
return True
except Exception as e:
logger.error(f"Ошибка создания таблицы wata_payments: {e}")
return False
async def ensure_wata_payment_schema() -> bool:
try:
table_exists = await check_table_exists("wata_payments")
if not table_exists:
logger.warning("⚠️ Таблица wata_payments отсутствует — создаём заново")
return await create_wata_payments_table()
db_type = await get_database_type()
legacy_link_index_exists = await check_index_exists(
"wata_payments", "idx_wata_link_id"
)
unique_link_index_exists = await check_index_exists(
"wata_payments", "uq_wata_payment_link"
)
builtin_unique_index_exists = await check_index_exists(
"wata_payments", "wata_payments_payment_link_id_key"
)
sqlite_auto_unique_exists = (
await check_index_exists("wata_payments", "sqlite_autoindex_wata_payments_1")
if db_type == "sqlite"
else False
)
order_index_exists = await check_index_exists("wata_payments", "idx_wata_order_id")
payment_link_column_exists = await check_column_exists(
"wata_payments", "payment_link_id"
)
order_id_column_exists = await check_column_exists("wata_payments", "order_id")
unique_index_exists = (
unique_link_index_exists
or builtin_unique_index_exists
or sqlite_auto_unique_exists
)
async with engine.begin() as conn:
if not payment_link_column_exists:
if db_type == "sqlite":
await conn.execute(
text(
"ALTER TABLE wata_payments "
"ADD COLUMN payment_link_id VARCHAR(64) NOT NULL DEFAULT ''"
)
)
payment_link_column_exists = True
unique_index_exists = False
elif db_type == "postgresql":
await conn.execute(
text(
"ALTER TABLE wata_payments "
"ADD COLUMN IF NOT EXISTS payment_link_id VARCHAR(64)"
)
)
payment_link_column_exists = True
elif db_type == "mysql":
await conn.execute(
text("ALTER TABLE wata_payments ADD COLUMN payment_link_id VARCHAR(64)")
)
payment_link_column_exists = True
else:
logger.warning(
"⚠️ Неизвестный тип БД %s — пропущено добавление payment_link_id",
db_type,
)
if payment_link_column_exists:
logger.info("✅ Добавлена колонка payment_link_id в wata_payments")
if payment_link_column_exists:
unique_index_exists, legacy_link_index_exists = (
await enforce_wata_payment_link_constraints(
conn,
db_type,
unique_index_exists,
legacy_link_index_exists,
)
)
if not order_id_column_exists:
if db_type == "sqlite":
await conn.execute(
text("ALTER TABLE wata_payments ADD COLUMN order_id VARCHAR(255)")
)
order_id_column_exists = True
elif db_type == "postgresql":
await conn.execute(
text(
"ALTER TABLE wata_payments "
"ADD COLUMN IF NOT EXISTS order_id VARCHAR(255)"
)
)
order_id_column_exists = True
elif db_type == "mysql":
await conn.execute(
text("ALTER TABLE wata_payments ADD COLUMN order_id VARCHAR(255)")
)
order_id_column_exists = True
else:
logger.warning(
"⚠️ Неизвестный тип БД %s — пропущено добавление order_id",
db_type,
)
if order_id_column_exists:
logger.info("✅ Добавлена колонка order_id в wata_payments")
if not order_index_exists:
if not order_id_column_exists:
logger.warning(
"⚠️ Пропущено создание индекса idx_wata_order_id — колонка order_id отсутствует"
)
else:
index_created = False
if db_type in {"sqlite", "postgresql"}:
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_wata_order_id ON wata_payments(order_id)"
)
)
index_created = True
elif db_type == "mysql":
await conn.execute(
text("CREATE INDEX idx_wata_order_id ON wata_payments(order_id)")
)
index_created = True
else:
logger.warning(
"⚠️ Неизвестный тип БД %s — пропущено создание индекса idx_wata_order_id",
db_type,
)
if index_created:
logger.info("✅ Создан индекс idx_wata_order_id")
else:
logger.info("️ Индекс idx_wata_order_id уже существует")
return True
except Exception as e:
logger.error(f"Ошибка обновления схемы wata_payments: {e}")
return False
async def create_discount_offers_table():
table_exists = await check_table_exists('discount_offers')
if table_exists:
@@ -1524,6 +2107,9 @@ async def ensure_promo_groups_setup():
addon_discount_column_exists = await check_column_exists(
"promo_groups", "apply_discounts_to_addons"
)
priority_column_exists = await check_column_exists(
"promo_groups", "priority"
)
if not addon_discount_column_exists:
if db_type == "sqlite":
@@ -1706,7 +2292,23 @@ async def ensure_promo_groups_setup():
"is_default": True,
}
if addon_discount_column_exists:
if priority_column_exists:
insert_params["priority"] = 0
if addon_discount_column_exists and priority_column_exists:
insert_sql = """
INSERT INTO promo_groups (
name,
priority,
server_discount_percent,
traffic_discount_percent,
device_discount_percent,
apply_discounts_to_addons,
is_default
) VALUES (:name, :priority, 0, 0, 0, :apply_discounts_to_addons, :is_default)
"""
insert_params["apply_discounts_to_addons"] = True
elif addon_discount_column_exists:
insert_sql = """
INSERT INTO promo_groups (
name,
@@ -1718,6 +2320,17 @@ async def ensure_promo_groups_setup():
) VALUES (:name, 0, 0, 0, :apply_discounts_to_addons, :is_default)
"""
insert_params["apply_discounts_to_addons"] = True
elif priority_column_exists:
insert_sql = """
INSERT INTO promo_groups (
name,
priority,
server_discount_percent,
traffic_discount_percent,
device_discount_percent,
is_default
) VALUES (:name, :priority, 0, 0, 0, :is_default)
"""
else:
insert_sql = """
INSERT INTO promo_groups (
@@ -2876,6 +3489,219 @@ async def ensure_default_web_api_token() -> bool:
return False
async def add_promo_group_priority_column() -> bool:
"""Добавляет колонку priority в таблицу promo_groups."""
column_exists = await check_column_exists('promo_groups', 'priority')
if column_exists:
logger.info("Колонка priority уже существует в promo_groups")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
column_def = 'INTEGER NOT NULL DEFAULT 0'
elif db_type == 'postgresql':
column_def = 'INTEGER NOT NULL DEFAULT 0'
else:
column_def = 'INT NOT NULL DEFAULT 0'
await conn.execute(
text(f"ALTER TABLE promo_groups ADD COLUMN priority {column_def}")
)
# Создаем индекс для оптимизации сортировки
if db_type == 'postgresql':
await conn.execute(
text("CREATE INDEX IF NOT EXISTS idx_promo_groups_priority ON promo_groups(priority DESC)")
)
elif db_type == 'sqlite':
await conn.execute(
text("CREATE INDEX IF NOT EXISTS idx_promo_groups_priority ON promo_groups(priority DESC)")
)
else: # MySQL
await conn.execute(
text("CREATE INDEX idx_promo_groups_priority ON promo_groups(priority DESC)")
)
logger.info("✅ Добавлена колонка priority в promo_groups с индексом")
return True
except Exception as error:
logger.error(f"Ошибка добавления колонки priority: {error}")
return False
async def create_user_promo_groups_table() -> bool:
"""Создает таблицу user_promo_groups для связи Many-to-Many между users и promo_groups."""
table_exists = await check_table_exists("user_promo_groups")
if table_exists:
logger.info("️ Таблица user_promo_groups уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "sqlite":
create_sql = """
CREATE TABLE user_promo_groups (
user_id INTEGER NOT NULL,
promo_group_id INTEGER NOT NULL,
assigned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
assigned_by VARCHAR(50) DEFAULT 'system',
PRIMARY KEY (user_id, promo_group_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE
);
"""
index_sql = "CREATE INDEX idx_user_promo_groups_user_id ON user_promo_groups(user_id);"
elif db_type == "postgresql":
create_sql = """
CREATE TABLE user_promo_groups (
user_id INTEGER NOT NULL,
promo_group_id INTEGER NOT NULL,
assigned_at TIMESTAMP DEFAULT NOW(),
assigned_by VARCHAR(50) DEFAULT 'system',
PRIMARY KEY (user_id, promo_group_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE
);
"""
index_sql = "CREATE INDEX idx_user_promo_groups_user_id ON user_promo_groups(user_id);"
else: # MySQL
create_sql = """
CREATE TABLE user_promo_groups (
user_id INT NOT NULL,
promo_group_id INT NOT NULL,
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
assigned_by VARCHAR(50) DEFAULT 'system',
PRIMARY KEY (user_id, promo_group_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE
);
"""
index_sql = "CREATE INDEX idx_user_promo_groups_user_id ON user_promo_groups(user_id);"
await conn.execute(text(create_sql))
await conn.execute(text(index_sql))
logger.info("✅ Таблица user_promo_groups создана с индексом")
return True
except Exception as error:
logger.error(f"❌ Ошибка создания таблицы user_promo_groups: {error}")
return False
async def migrate_existing_user_promo_groups_data() -> bool:
"""Переносит существующие связи users.promo_group_id в таблицу user_promo_groups."""
try:
table_exists = await check_table_exists("user_promo_groups")
if not table_exists:
logger.warning("⚠️ Таблица user_promo_groups не существует, пропускаем миграцию данных")
return False
column_exists = await check_column_exists('users', 'promo_group_id')
if not column_exists:
logger.warning("⚠️ Колонка users.promo_group_id не существует, пропускаем миграцию данных")
return True
async with engine.begin() as conn:
# Проверяем есть ли уже данные в user_promo_groups
result = await conn.execute(text("SELECT COUNT(*) FROM user_promo_groups"))
count = result.scalar()
if count > 0:
logger.info(f"ℹ️ В таблице user_promo_groups уже есть {count} записей, пропускаем миграцию")
return True
# Переносим данные из users.promo_group_id
db_type = await get_database_type()
if db_type == "sqlite":
migrate_sql = """
INSERT INTO user_promo_groups (user_id, promo_group_id, assigned_at, assigned_by)
SELECT id, promo_group_id, CURRENT_TIMESTAMP, 'system'
FROM users
WHERE promo_group_id IS NOT NULL
"""
else: # PostgreSQL and MySQL
migrate_sql = """
INSERT INTO user_promo_groups (user_id, promo_group_id, assigned_at, assigned_by)
SELECT id, promo_group_id, NOW(), 'system'
FROM users
WHERE promo_group_id IS NOT NULL
"""
result = await conn.execute(text(migrate_sql))
migrated_count = result.rowcount if hasattr(result, 'rowcount') else 0
logger.info(f"✅ Перенесено {migrated_count} связей пользователей с промогруппами")
return True
except Exception as error:
logger.error(f"❌ Ошибка миграции данных user_promo_groups: {error}")
return False
async def add_promocode_promo_group_column() -> bool:
"""Добавляет колонку promo_group_id в таблицу promocodes."""
column_exists = await check_column_exists('promocodes', 'promo_group_id')
if column_exists:
logger.info("Колонка promo_group_id уже существует в promocodes")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
# Add column
if db_type == 'sqlite':
await conn.execute(
text("ALTER TABLE promocodes ADD COLUMN promo_group_id INTEGER")
)
elif db_type == 'postgresql':
await conn.execute(
text("ALTER TABLE promocodes ADD COLUMN promo_group_id INTEGER")
)
# Add foreign key
await conn.execute(
text("""
ALTER TABLE promocodes
ADD CONSTRAINT fk_promocodes_promo_group
FOREIGN KEY (promo_group_id)
REFERENCES promo_groups(id)
ON DELETE SET NULL
""")
)
# Add index
await conn.execute(
text("CREATE INDEX IF NOT EXISTS idx_promocodes_promo_group_id ON promocodes(promo_group_id)")
)
elif db_type == 'mysql':
await conn.execute(
text("""
ALTER TABLE promocodes
ADD COLUMN promo_group_id INT,
ADD CONSTRAINT fk_promocodes_promo_group
FOREIGN KEY (promo_group_id)
REFERENCES promo_groups(id)
ON DELETE SET NULL
""")
)
await conn.execute(
text("CREATE INDEX idx_promocodes_promo_group_id ON promocodes(promo_group_id)")
)
logger.info("✅ Добавлена колонка promo_group_id в promocodes")
return True
except Exception as error:
logger.error(f"❌ Ошибка добавления promo_group_id в promocodes: {error}")
return False
async def run_universal_migration():
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
@@ -2958,18 +3784,26 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей CryptoBot payments")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ MULEN PAY ===")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ HELEKET ===")
heleket_created = await create_heleket_payments_table()
if heleket_created:
logger.info("✅ Таблица Heleket payments готова")
else:
logger.warning("⚠️ Проблемы с таблицей Heleket payments")
mulenpay_name = settings.get_mulenpay_display_name()
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ %s ===", mulenpay_name)
mulenpay_created = await create_mulenpay_payments_table()
if mulenpay_created:
logger.info("✅ Таблица Mulen Pay payments готова")
logger.info("✅ Таблица %s payments готова", mulenpay_name)
else:
logger.warning("⚠️ Проблемы с таблицей Mulen Pay payments")
logger.warning("⚠️ Проблемы с таблицей %s payments", mulenpay_name)
mulenpay_schema_ok = await ensure_mulenpay_payment_schema()
if mulenpay_schema_ok:
logger.info("✅ Схема Mulen Pay payments актуальна")
logger.info("✅ Схема %s payments актуальна", mulenpay_name)
else:
logger.warning("⚠️ Не удалось обновить схему Mulen Pay payments")
logger.warning("⚠️ Не удалось обновить схему %s payments", mulenpay_name)
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PAL24 ===")
pal24_created = await create_pal24_payments_table()
@@ -2978,6 +3812,19 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей Pal24 payments")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ WATA ===")
wata_created = await create_wata_payments_table()
if wata_created:
logger.info("✅ Таблица Wata payments готова")
else:
logger.warning("⚠️ Проблемы с таблицей Wata payments")
wata_schema_ok = await ensure_wata_payment_schema()
if wata_schema_ok:
logger.info("✅ Схема Wata payments актуальна")
else:
logger.warning("⚠️ Не удалось обновить схему Wata payments")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ DISCOUNT_OFFERS ===")
discount_created = await create_discount_offers_table()
if discount_created:
@@ -3016,6 +3863,34 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей promo_offer_templates")
logger.info("=== ДОБАВЛЕНИЕ ПРИОРИТЕТА В ПРОМОГРУППЫ ===")
priority_column_ready = await add_promo_group_priority_column()
if priority_column_ready:
logger.info("✅ Колонка priority в promo_groups готова")
else:
logger.warning("⚠️ Проблемы с добавлением priority в promo_groups")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_PROMO_GROUPS ===")
user_promo_groups_ready = await create_user_promo_groups_table()
if user_promo_groups_ready:
logger.info("✅ Таблица user_promo_groups готова")
else:
logger.warning("⚠️ Проблемы с таблицей user_promo_groups")
logger.info("=== МИГРАЦИЯ ДАННЫХ В USER_PROMO_GROUPS ===")
data_migrated = await migrate_existing_user_promo_groups_data()
if data_migrated:
logger.info("✅ Данные перенесены в user_promo_groups")
else:
logger.warning("⚠️ Проблемы с миграцией данных в user_promo_groups")
logger.info("=== ДОБАВЛЕНИЕ PROMO_GROUP_ID В PROMOCODES ===")
promocode_column_ready = await add_promocode_promo_group_column()
if promocode_column_ready:
logger.info("✅ Колонка promo_group_id в promocodes готова")
else:
logger.warning("⚠️ Проблемы с добавлением promo_group_id в promocodes")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ MAIN_MENU_BUTTONS ===")
main_menu_buttons_created = await create_main_menu_buttons_table()
if main_menu_buttons_created:
@@ -3212,6 +4087,7 @@ async def run_universal_migration():
logger.info("=== МИГРАЦИЯ ЗАВЕРШЕНА УСПЕШНО ===")
logger.info("✅ Реферальная система обновлена")
logger.info("✅ CryptoBot таблица готова")
logger.info("✅ Heleket таблица готова")
logger.info("✅ Таблица конверсий подписок создана")
logger.info("✅ Таблица welcome_texts с полем is_enabled готова")
logger.info("✅ Медиа поля в broadcast_history добавлены")
@@ -3229,6 +4105,7 @@ async def check_migration_status():
status = {
"has_made_first_topup_column": False,
"cryptobot_table": False,
"heleket_table": False,
"user_messages_table": False,
"welcome_texts_table": False,
"welcome_texts_is_enabled_column": False,
@@ -3262,6 +4139,7 @@ async def check_migration_status():
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
status["cryptobot_table"] = await check_table_exists('cryptobot_payments')
status["heleket_table"] = await check_table_exists('heleket_payments')
status["user_messages_table"] = await check_table_exists('user_messages')
status["welcome_texts_table"] = await check_table_exists('welcome_texts')
status["privacy_policies_table"] = await check_table_exists('privacy_policies')
@@ -3314,6 +4192,7 @@ async def check_migration_status():
check_names = {
"has_made_first_topup_column": "Колонка реферальной системы",
"cryptobot_table": "Таблица CryptoBot payments",
"heleket_table": "Таблица Heleket payments",
"user_messages_table": "Таблица пользовательских сообщений",
"welcome_texts_table": "Таблица приветственных текстов",
"privacy_policies_table": "Таблица политик конфиденциальности",
+34 -14
View File
@@ -19,10 +19,10 @@ class CryptoBotService:
self.webhook_secret = settings.CRYPTOBOT_WEBHOOK_SECRET
async def _make_request(
self,
method: str,
endpoint: str,
data: Optional[Dict] = None
self,
method: str,
endpoint: str,
data: Optional[Dict] = None,
) -> Optional[Dict[str, Any]]:
if not self.api_token:
@@ -37,11 +37,18 @@ class CryptoBotService:
try:
async with aiohttp.ClientSession() as session:
request_kwargs: Dict[str, Any] = {"headers": headers}
if method.upper() == "GET":
if data:
request_kwargs["params"] = data
elif data:
request_kwargs["json"] = data
async with session.request(
method,
url,
headers=headers,
json=data if data else None
method,
url,
**request_kwargs,
) as response:
response_data = await response.json()
@@ -95,21 +102,34 @@ class CryptoBotService:
asset: Optional[str] = None,
status: Optional[str] = None,
offset: int = 0,
count: int = 100
count: int = 100,
invoice_ids: Optional[list] = None,
) -> Optional[list]:
data = {
'offset': offset,
'count': count
}
if asset:
data['asset'] = asset
if status:
data['status'] = status
return await self._make_request('GET', 'getInvoices', data)
if invoice_ids:
data['invoice_ids'] = invoice_ids
result = await self._make_request('GET', 'getInvoices', data)
if isinstance(result, dict):
items = result.get('items')
return items if isinstance(items, list) else []
if isinstance(result, list):
return result
return []
async def get_balance(self) -> Optional[list]:
return await self._make_request('GET', 'getBalance')
+156
View File
@@ -0,0 +1,156 @@
"""HTTP client for Heleket payment API."""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import logging
from typing import Any, Dict, Optional
import aiohttp
from app.config import settings
logger = logging.getLogger(__name__)
class HeleketService:
"""Minimal wrapper around Heleket API endpoints."""
def __init__(self) -> None:
self.base_url = settings.HELEKET_BASE_URL.rstrip("/")
self.merchant_id = settings.HELEKET_MERCHANT_ID
self.api_key = settings.HELEKET_API_KEY
@property
def is_configured(self) -> bool:
return bool(self.merchant_id and self.api_key)
def _prepare_body(self, payload: Dict[str, Any]) -> str:
cleaned = {key: value for key, value in payload.items() if value is not None}
serialized = json.dumps(cleaned, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
if "/" in serialized:
serialized = serialized.replace("/", "\\/")
return serialized
def _generate_signature(self, body: str) -> str:
api_key = self.api_key or ""
encoded = base64.b64encode(body.encode("utf-8")).decode("utf-8")
raw = f"{encoded}{api_key}"
return hashlib.md5(raw.encode("utf-8")).hexdigest()
async def _request(
self,
endpoint: str,
payload: Dict[str, Any],
*,
params: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
if not self.is_configured:
logger.error("Heleket сервис не настроен: merchant или api_key отсутствуют")
return None
body = self._prepare_body(payload)
signature = self._generate_signature(body)
url = f"{self.base_url}/{endpoint.lstrip('/')}"
headers = {
"merchant": self.merchant_id or "",
"sign": signature,
"Content-Type": "application/json",
}
try:
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(
url,
data=body.encode("utf-8"),
headers=headers,
params=params,
) as response:
text = await response.text()
if response.content_type != "application/json":
logger.error("Ответ Heleket не JSON (%s): %s", response.content_type, text)
return None
try:
data = json.loads(text)
except json.JSONDecodeError:
logger.error("Ошибка парсинга Heleket JSON: %s", text)
return None
if response.status >= 400:
logger.error("Heleket API %s вернул статус %s: %s", endpoint, response.status, data)
return None
if isinstance(data, dict) and data.get("state") == 0:
return data
logger.error("Heleket API вернул ошибку: %s", data)
return None
except Exception as error: # pragma: no cover - defensive
logger.error("Ошибка запроса к Heleket API: %s", error)
return None
async def create_payment(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return await self._request("payment", payload)
async def get_payment_info(
self,
*,
uuid: Optional[str] = None,
order_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
if not uuid and not order_id:
raise ValueError("Нужно указать uuid или order_id для Heleket payment/info")
payload: Dict[str, Any] = {}
if uuid:
payload["uuid"] = uuid
if order_id:
payload["order_id"] = order_id
return await self._request("payment/info", payload)
async def list_payments(
self,
*,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
cursor: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
payload: Dict[str, Any] = {}
if date_from:
payload["date_from"] = date_from
if date_to:
payload["date_to"] = date_to
params = {"cursor": cursor} if cursor else None
return await self._request("payment/list", payload, params=params)
def verify_webhook_signature(self, payload: Dict[str, Any]) -> bool:
if not self.is_configured:
logger.warning("Heleket сервис не настроен, подпись пропускается")
return True
if not isinstance(payload, dict):
logger.error("Heleket webhook payload не dict: %s", payload)
return False
signature = payload.get("sign")
if not signature:
logger.error("Heleket webhook без подписи")
return False
data = {key: value for key, value in payload.items() if key != "sign"}
body = self._prepare_body(data)
expected = self._generate_signature(body)
is_valid = hmac.compare_digest(expected, str(signature))
if not is_valid:
logger.error("Неверная подпись Heleket webhook: ожидается %s, получено %s", expected, signature)
return is_valid
+111
View File
@@ -0,0 +1,111 @@
import asyncio
import json
import logging
from typing import Any, Dict, Optional
from aiohttp import web
from app.config import settings
from app.database.database import get_db
from app.external.heleket import HeleketService
from app.services.payment_service import PaymentService
logger = logging.getLogger(__name__)
class HeleketWebhookHandler:
def __init__(self, payment_service: PaymentService) -> None:
self.payment_service = payment_service
self.service = HeleketService()
async def handle(self, request: web.Request) -> web.Response:
if not settings.is_heleket_enabled():
logger.warning("Получен Heleket webhook, но сервис отключен")
return web.json_response({"status": "error", "reason": "disabled"}, status=503)
try:
payload: Dict[str, Any] = await request.json()
except json.JSONDecodeError:
logger.error("Некорректный JSON Heleket webhook")
return web.json_response({"status": "error", "reason": "invalid_json"}, status=400)
if not self.service.verify_webhook_signature(payload):
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
processed: Optional[bool] = None
async for db in get_db():
processed = await self.payment_service.process_heleket_webhook(db, payload)
if processed:
return web.json_response({"status": "ok"}, status=200)
return web.json_response({"status": "error", "reason": "not_processed"}, status=400)
async def health_check(self, _: web.Request) -> web.Response:
return web.json_response(
{
"status": "ok",
"service": "heleket_webhook",
"enabled": settings.is_heleket_enabled(),
"path": settings.HELEKET_WEBHOOK_PATH,
}
)
async def options_handler(self, _: web.Request) -> web.Response:
return web.Response(
status=200,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
)
def create_heleket_app(payment_service: PaymentService) -> web.Application:
handler = HeleketWebhookHandler(payment_service)
app = web.Application()
app.router.add_post(settings.HELEKET_WEBHOOK_PATH, handler.handle)
app.router.add_get("/heleket/health", handler.health_check)
app.router.add_get("/health", handler.health_check)
app.router.add_options(settings.HELEKET_WEBHOOK_PATH, handler.options_handler)
return app
async def start_heleket_webhook_server(payment_service: PaymentService) -> None:
if not settings.is_heleket_enabled():
logger.info("Heleket отключен, webhook сервер не запускается")
return
app = create_heleket_app(payment_service)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(
runner,
host=settings.HELEKET_WEBHOOK_HOST,
port=settings.HELEKET_WEBHOOK_PORT,
)
try:
await site.start()
logger.info(
"Heleket webhook сервер запущен на %s:%s",
settings.HELEKET_WEBHOOK_HOST,
settings.HELEKET_WEBHOOK_PORT,
)
logger.info(
"Heleket webhook URL: http://%s:%s%s",
settings.HELEKET_WEBHOOK_HOST,
settings.HELEKET_WEBHOOK_PORT,
settings.HELEKET_WEBHOOK_PATH,
)
while True:
await asyncio.sleep(1)
except asyncio.CancelledError:
logger.info("Heleket webhook сервер остановлен по запросу")
finally:
await site.stop()
await runner.cleanup()
logger.info("Heleket webhook сервер корректно остановлен")
+16 -9
View File
@@ -1,8 +1,9 @@
"""Flask webhook server for PayPalych postbacks."""
"""Flask webhook server for PayPalych callbacks."""
from __future__ import annotations
import asyncio
from concurrent.futures import TimeoutError as FuturesTimeoutError
import json
import logging
import threading
@@ -13,7 +14,7 @@ from flask import Flask, jsonify, request
from werkzeug.serving import make_server
from app.config import settings
from app.database.database import get_db
from app.database.database import AsyncSessionLocal
from app.services.pal24_service import Pal24Service, Pal24APIError
from app.services.payment_service import PaymentService
@@ -56,27 +57,33 @@ def create_pal24_flask_app(
logger.error("Pal24 webhook получен, но сервис не настроен")
return jsonify({"status": "error", "reason": "service_not_configured"}), 503
logger.debug("Получен Pal24 webhook: headers=%s", dict(request.headers))
payload = _normalize_payload()
if not payload:
logger.warning("Пустой Pal24 webhook")
return jsonify({"status": "error", "reason": "empty_payload"}), 400
try:
parsed_payload = pal24_service.parse_postback(payload)
parsed_payload = pal24_service.parse_callback(payload)
except Pal24APIError as error:
logger.error("Ошибка валидации Pal24 webhook: %s", error)
return jsonify({"status": "error", "reason": str(error)}), 400
async def process() -> bool:
async for db in get_db():
async with AsyncSessionLocal() as db:
try:
return await payment_service.process_pal24_postback(db, parsed_payload)
finally:
await db.close()
return await payment_service.process_pal24_callback(db, parsed_payload)
except Exception:
await db.rollback()
raise
try:
future = asyncio.run_coroutine_threadsafe(process(), loop)
processed = future.result()
processed = future.result(timeout=settings.PAL24_REQUEST_TIMEOUT)
except FuturesTimeoutError:
logger.error("Обработка Pal24 webhook превысила таймаут %sс", settings.PAL24_REQUEST_TIMEOUT)
return jsonify({"status": "error", "reason": "timeout"}), 504
except Exception as error: # pragma: no cover - defensive
logger.exception("Критическая ошибка обработки Pal24 webhook: %s", error)
return jsonify({"status": "error", "reason": "internal_error"}), 500
@@ -105,7 +112,7 @@ def create_pal24_flask_app(
class Pal24WebhookServer:
"""Threaded Flask server for Pal24 postbacks."""
"""Threaded Flask server for Pal24 callbacks."""
def __init__(self, payment_service: PaymentService, loop: AbstractEventLoop) -> None:
self.app = create_pal24_flask_app(payment_service, loop)
+1 -1
View File
@@ -163,7 +163,7 @@ class RemnaWaveAPI:
async def __aenter__(self):
conn_type = self._detect_connection_type()
logger.info(f"Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
logger.debug(f"Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
headers = self._prepare_auth_headers()
+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
}
+262
View File
@@ -0,0 +1,262 @@
from __future__ import annotations
import asyncio
import base64
import json
import logging
from datetime import datetime, timedelta
from typing import Any, Dict, Optional
import aiohttp
from aiohttp import web
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from app.config import settings
from app.database.database import get_db
from app.services.payment_service import PaymentService
logger = logging.getLogger(__name__)
class WataPublicKeyProvider:
"""Loads and caches the WATA public key used for webhook signature validation."""
def __init__(self, *, cache_seconds: Optional[int] = None) -> None:
self._cache_seconds = cache_seconds or int(settings.WATA_PUBLIC_KEY_CACHE_SECONDS)
self._cached_key: Optional[str] = None
self._expires_at: Optional[datetime] = None
self._lock = asyncio.Lock()
async def get_public_key(self) -> Optional[str]:
"""Returns a cached public key or fetches a new one from WATA."""
now = datetime.utcnow()
if self._cached_key and self._expires_at and now < self._expires_at:
return self._cached_key
async with self._lock:
now = datetime.utcnow()
if self._cached_key and self._expires_at and now < self._expires_at:
return self._cached_key
key = await self._fetch_public_key()
if key:
self._cached_key = key
if self._cache_seconds > 0:
self._expires_at = datetime.utcnow() + timedelta(seconds=self._cache_seconds)
else:
self._expires_at = None
logger.debug("Получен и закеширован публичный ключ WATA")
return self._cached_key
if self._cached_key:
logger.warning("Используем ранее закешированный публичный ключ WATA")
return self._cached_key
logger.error("Публичный ключ WATA недоступен")
return None
async def _fetch_public_key(self) -> Optional[str]:
url = settings.WATA_PUBLIC_KEY_URL or f"{settings.WATA_BASE_URL.rstrip('/')}/public-key"
timeout = aiohttp.ClientTimeout(total=settings.WATA_REQUEST_TIMEOUT)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url) as response:
text = await response.text()
if response.status >= 400:
logger.error(
"Ошибка получения публичного ключа WATA %s: %s",
response.status,
text,
)
return None
try:
payload = await response.json()
except aiohttp.ContentTypeError:
logger.error("Ответ WATA public-key не является JSON: %s", text)
return None
if isinstance(payload, dict):
value = payload.get("value")
if value:
return value
logger.error("Ответ WATA public-key не содержит ключ: %s", payload)
else:
logger.error("Неожиданный формат ответа WATA public-key: %s", payload)
except Exception as error:
logger.error("Ошибка запроса публичного ключа WATA: %s", error)
return None
class WataWebhookHandler:
"""Processes webhook callbacks coming from WATA."""
def __init__(
self,
payment_service: PaymentService,
*,
public_key_provider: Optional[WataPublicKeyProvider] = None,
) -> None:
self.payment_service = payment_service
self.public_key_provider = public_key_provider or WataPublicKeyProvider()
async def _verify_signature(self, raw_body: str, signature: str) -> bool:
signature = (signature or "").strip()
if not signature:
logger.error("WATA webhook без подписи")
return False
public_key_pem = await self.public_key_provider.get_public_key()
if not public_key_pem:
logger.error("Публичный ключ WATA отсутствует, проверка подписи невозможна")
return False
try:
signature_bytes = base64.b64decode(signature)
except (ValueError, TypeError):
logger.error("Некорректная подпись WATA (не Base64)")
return False
try:
public_key = serialization.load_pem_public_key(public_key_pem.encode("utf-8"))
except ValueError as error:
logger.error("Ошибка загрузки публичного ключа WATA: %s", error)
return False
try:
public_key.verify(
signature_bytes,
raw_body.encode("utf-8"),
padding.PKCS1v15(),
hashes.SHA512(),
)
return True
except InvalidSignature:
logger.warning("Подпись WATA webhook не прошла проверку")
return False
except Exception as error:
logger.error("Ошибка проверки подписи WATA: %s", error)
return False
async def handle_webhook(self, request: web.Request) -> web.Response:
if not settings.is_wata_enabled():
logger.warning("Получен WATA webhook, но сервис отключен")
return web.json_response({"status": "error", "reason": "wata_disabled"}, status=503)
raw_body = await request.text()
if not raw_body:
logger.warning("Получен пустой WATA webhook")
return web.json_response({"status": "error", "reason": "empty_body"}, status=400)
signature = request.headers.get("X-Signature")
if not await self._verify_signature(raw_body, signature or ""):
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
try:
payload: Dict[str, Any] = json.loads(raw_body)
except json.JSONDecodeError:
logger.error("Некорректный JSON WATA webhook")
return web.json_response({"status": "error", "reason": "invalid_json"}, status=400)
logger.info(
"Получен WATA webhook: order_id=%s, status=%s",
payload.get("orderId"),
payload.get("transactionStatus"),
)
processed: Optional[bool] = None
async for db in get_db():
processed = await self.payment_service.process_wata_webhook(db, payload)
# Allow the generator to finish naturally so it can commit/rollback.
# get_db() yields only once, so exiting the loop body without breaking
# triggers the generator cleanup logic on the next iteration attempt.
if processed is None:
logger.error("Не удалось обработать WATA webhook: нет сессии БД")
return web.json_response({"status": "error", "reason": "db_session_unavailable"}, status=500)
if processed:
return web.json_response({"status": "ok"}, status=200)
return web.json_response({"status": "error", "reason": "not_processed"}, status=400)
async def health_check(self, request: web.Request) -> web.Response:
return web.json_response(
{
"status": "ok",
"service": "wata_webhook",
"enabled": settings.is_wata_enabled(),
"path": settings.WATA_WEBHOOK_PATH,
}
)
async def options_handler(self, _: web.Request) -> web.Response:
return web.Response(
status=200,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, X-Signature",
},
)
def create_wata_webhook_app(payment_service: PaymentService) -> web.Application:
app = web.Application()
handler = WataWebhookHandler(payment_service)
app.router.add_post(settings.WATA_WEBHOOK_PATH, handler.handle_webhook)
app.router.add_get(settings.WATA_WEBHOOK_PATH, handler.health_check)
app.router.add_options(settings.WATA_WEBHOOK_PATH, handler.options_handler)
app.router.add_get("/health", handler.health_check)
logger.info(
"Настроен WATA webhook endpoint на %s",
settings.WATA_WEBHOOK_PATH,
)
return app
async def start_wata_webhook_server(payment_service: PaymentService) -> None:
if not settings.is_wata_enabled():
logger.info("WATA отключен, webhook сервер не запускается")
return
app = create_wata_webhook_app(payment_service)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(
runner,
host=settings.WATA_WEBHOOK_HOST,
port=settings.WATA_WEBHOOK_PORT,
)
try:
await site.start()
logger.info(
"WATA webhook сервер запущен на %s:%s",
settings.WATA_WEBHOOK_HOST,
settings.WATA_WEBHOOK_PORT,
)
logger.info(
"WATA webhook URL: http://%s:%s%s",
settings.WATA_WEBHOOK_HOST,
settings.WATA_WEBHOOK_PORT,
settings.WATA_WEBHOOK_PATH,
)
while True:
await asyncio.sleep(1)
except asyncio.CancelledError:
logger.info("WATA webhook сервер остановлен по запросу")
finally:
await site.stop()
await runner.cleanup()
logger.info("WATA webhook сервер корректно остановлен")
+44 -15
View File
@@ -48,7 +48,12 @@ class WebhookServer:
logger.info(f"Webhook сервер настроен:")
logger.info(f" - Tribute webhook: POST {settings.TRIBUTE_WEBHOOK_PATH}")
if settings.is_mulenpay_enabled():
logger.info(f" - Mulen Pay webhook: POST {settings.MULENPAY_WEBHOOK_PATH}")
mulenpay_name = settings.get_mulenpay_display_name()
logger.info(
" - %s webhook: POST %s",
mulenpay_name,
settings.MULENPAY_WEBHOOK_PATH,
)
if settings.is_cryptobot_enabled():
logger.info(f" - CryptoBot webhook: POST {settings.CRYPTOBOT_WEBHOOK_PATH}")
logger.info(f" - Health check: GET /health")
@@ -84,8 +89,10 @@ class WebhookServer:
settings.TRIBUTE_WEBHOOK_PATH,
)
if settings.is_mulenpay_enabled():
mulenpay_name = settings.get_mulenpay_display_name()
logger.info(
"Mulen Pay webhook URL: http://%s:%s%s",
"%s webhook URL: http://%s:%s%s",
mulenpay_name,
settings.TRIBUTE_WEBHOOK_HOST,
settings.TRIBUTE_WEBHOOK_PORT,
settings.MULENPAY_WEBHOOK_PATH,
@@ -128,24 +135,37 @@ class WebhookServer:
async def _mulenpay_webhook_handler(self, request: web.Request) -> web.Response:
try:
logger.info(f"Получен Mulen Pay webhook: {request.method} {request.path}")
logger.info(f"MulenPay webhook headers: {dict(request.headers)}")
mulenpay_name = settings.get_mulenpay_display_name()
logger.info(
"%s webhook: %s %s",
mulenpay_name,
request.method,
request.path,
)
logger.info(
"%s webhook headers: %s",
mulenpay_name,
dict(request.headers),
)
raw_body = await request.read()
if not raw_body:
logger.warning("Пустой Mulen Pay webhook")
logger.warning("Пустой %s webhook", mulenpay_name)
return web.json_response({"status": "error", "reason": "empty_body"}, status=400)
# Временно отключаем проверку подписи для отладки
# TODO: Включить обратно после настройки MulenPay
if not self._verify_mulenpay_signature(request, raw_body):
logger.warning("MulenPay webhook signature verification failed, but processing anyway for debugging")
logger.warning(
"%s webhook signature verification failed, but processing anyway for debugging",
mulenpay_name,
)
# return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
try:
payload = json.loads(raw_body.decode('utf-8'))
except json.JSONDecodeError as error:
logger.error(f"Ошибка парсинга Mulen Pay webhook: {error}")
logger.error(f"Ошибка парсинга {mulenpay_name} webhook: {error}")
return web.json_response({"status": "error", "reason": "invalid_json"}, status=400)
payment_service = PaymentService(self.bot)
@@ -160,7 +180,10 @@ class WebhookServer:
return web.json_response({"status": "ok"}, status=200)
return web.json_response({"status": "error", "reason": "processing_failed"}, status=400)
except Exception as error:
logger.error(f"Ошибка обработки Mulen Pay webhook: {error}", exc_info=True)
logger.error(
f"Ошибка обработки {mulenpay_name} webhook: {error}",
exc_info=True,
)
return web.json_response({"status": "error", "reason": "internal_error"}, status=500)
finally:
try:
@@ -169,7 +192,11 @@ class WebhookServer:
pass
except Exception as error:
logger.error(f"Критическая ошибка Mulen Pay webhook: {error}", exc_info=True)
mulenpay_name = settings.get_mulenpay_display_name()
logger.error(
f"Критическая ошибка {mulenpay_name} webhook: {error}",
exc_info=True,
)
return web.json_response({"status": "error", "reason": "internal_error", "message": str(error)}, status=500)
@staticmethod
@@ -183,12 +210,13 @@ class WebhookServer:
@staticmethod
def _verify_mulenpay_signature(request: web.Request, raw_body: bytes) -> bool:
secret_key = settings.MULENPAY_SECRET_KEY
display_name = settings.get_mulenpay_display_name()
if not secret_key:
logger.error("Mulen Pay secret key is not configured")
logger.error("%s secret key is not configured", display_name)
return False
# Логируем все заголовки для отладки
logger.info("MulenPay webhook headers for signature verification:")
logger.info("%s webhook headers for signature verification:", display_name)
for header_name, header_value in request.headers.items():
if any(keyword in header_name.lower() for keyword in ['signature', 'sign', 'token', 'auth']):
logger.info(f" {header_name}: {header_value}")
@@ -239,7 +267,7 @@ class WebhookServer:
if hmac.compare_digest(normalized_signature_no_padding, expected_urlsafe_base64_signature.rstrip('=')):
return True
logger.error("Неверная подпись Mulen Pay webhook")
logger.error("Неверная подпись %s webhook", display_name)
return False
authorization_header = request.headers.get('Authorization')
@@ -252,7 +280,7 @@ class WebhookServer:
if hmac.compare_digest(token, secret_key):
return True
logger.error("Неверный %s токен Mulen Pay webhook", scheme)
logger.error("Неверный %s токен %s webhook", scheme, display_name)
return False
if not value and hmac.compare_digest(token, secret_key):
@@ -270,11 +298,12 @@ class WebhookServer:
return True
logger.info(
"Mulen Pay webhook headers received: %s",
"%s webhook headers received: %s",
display_name,
{key: value for key, value in request.headers.items() if 'authorization' not in key.lower()}
)
logger.error("Отсутствует подпись Mulen Pay webhook")
logger.error("Отсутствует подпись %s webhook", display_name)
return False
async def _tribute_webhook_handler(self, request: web.Request) -> web.Response:
+268 -127
View File
@@ -1,12 +1,16 @@
import asyncio
import logging
import json
import hashlib
import hmac
import base64
from typing import Optional, Dict, Any
from ipaddress import (
IPv4Address,
IPv4Network,
IPv6Address,
IPv6Network,
ip_address,
ip_network,
)
from typing import Iterable, Optional, Dict, Any, List, Union, Tuple
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.services.payment_service import PaymentService
@@ -15,155 +19,292 @@ from app.database.database import get_db
logger = logging.getLogger(__name__)
IPAddress = Union[IPv4Address, IPv6Address]
IPNetwork = Union[IPv4Network, IPv6Network]
YOOKASSA_ALLOWED_IP_NETWORKS: tuple[IPNetwork, ...] = (
ip_network("185.71.76.0/27"),
ip_network("185.71.77.0/27"),
ip_network("77.75.153.0/25"),
ip_network("77.75.154.128/25"),
ip_network("77.75.156.11/32"),
ip_network("77.75.156.35/32"),
ip_network("2a02:5180::/32"),
)
CLOUDFLARE_TRUSTED_NETWORKS: tuple[IPNetwork, ...] = (
ip_network("173.245.48.0/20"),
ip_network("103.21.244.0/22"),
ip_network("103.22.200.0/22"),
ip_network("103.31.4.0/22"),
ip_network("141.101.64.0/18"),
ip_network("108.162.192.0/18"),
ip_network("190.93.240.0/20"),
ip_network("188.114.96.0/20"),
ip_network("197.234.240.0/22"),
ip_network("198.41.128.0/17"),
ip_network("162.158.0.0/15"),
ip_network("104.16.0.0/13"),
ip_network("104.24.0.0/14"),
ip_network("172.64.0.0/13"),
ip_network("131.0.72.0/22"),
ip_network("2400:cb00::/32"),
ip_network("2606:4700::/32"),
ip_network("2803:f800::/32"),
ip_network("2405:b500::/32"),
ip_network("2405:8100::/32"),
ip_network("2a06:98c0::/29"),
ip_network("2c0f:f248::/32"),
)
YOOKASSA_ALLOWED_EVENTS: tuple[str, ...] = (
"payment.succeeded",
"payment.waiting_for_capture",
"payment.canceled",
)
def collect_yookassa_ip_candidates(*values: Optional[str]) -> List[str]:
candidates: List[str] = []
for value in values:
if not value:
continue
for part in value.split(","):
normalized = part.strip()
if normalized:
candidates.append(normalized)
return candidates
def _parse_candidate_ip(candidate: str) -> Optional[IPAddress]:
value = candidate.strip()
if not value:
return None
if value.startswith("[") and "]" in value:
value = value[1:value.index("]")]
if "%" in value:
value = value.split("%", 1)[0]
if value.count(":") == 1 and "." in value:
host, _, port = value.rpartition(":")
if port.isdigit():
value = host
try:
return ip_address(value)
except ValueError:
return None
def _should_trust_forwarded_headers(remote_ip: Optional[IPAddress]) -> bool:
if remote_ip is None:
return True
if _is_trusted_proxy_ip(remote_ip):
return True
return any(
getattr(remote_ip, attribute)
for attribute in ("is_private", "is_loopback", "is_link_local", "is_reserved")
)
_TRUSTED_PROXY_NETWORKS_CACHE: Tuple[str, Tuple[IPNetwork, ...]] = ("", ())
def _get_trusted_proxy_networks() -> Tuple[IPNetwork, ...]:
global _TRUSTED_PROXY_NETWORKS_CACHE
raw_value = getattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "") or ""
cached_raw, cached_networks = _TRUSTED_PROXY_NETWORKS_CACHE
if raw_value == cached_raw:
return cached_networks
networks: List[IPNetwork] = []
for part in raw_value.split(","):
candidate = part.strip()
if not candidate:
continue
try:
networks.append(ip_network(candidate, strict=False))
except ValueError:
logger.warning("Неверная сеть доверенного прокси YooKassa: %s", candidate)
cached_networks = tuple(networks)
_TRUSTED_PROXY_NETWORKS_CACHE = (raw_value, cached_networks)
return cached_networks
def _is_trusted_proxy_ip(ip_object: IPAddress) -> bool:
if any(
getattr(ip_object, attribute)
for attribute in ("is_private", "is_loopback", "is_link_local", "is_reserved")
):
return True
if any(ip_object in network for network in CLOUDFLARE_TRUSTED_NETWORKS):
return True
return any(ip_object in network for network in _get_trusted_proxy_networks())
def resolve_yookassa_ip(
candidates: Iterable[str],
*,
remote: Optional[str] = None,
) -> Optional[IPAddress]:
remote_ip = _parse_candidate_ip(remote) if remote else None
if (
remote_ip is not None
and remote_ip.is_global
and not _is_trusted_proxy_ip(remote_ip)
):
return remote_ip
candidate_list = list(candidates)
if _should_trust_forwarded_headers(remote_ip):
last_hop = remote_ip
for candidate in reversed(candidate_list):
ip_object = _parse_candidate_ip(candidate)
if ip_object is not None:
if last_hop is None or _is_trusted_proxy_ip(last_hop):
if _is_trusted_proxy_ip(ip_object):
last_hop = ip_object
continue
return ip_object
break
if last_hop is not None and not _is_trusted_proxy_ip(last_hop):
return last_hop
return remote_ip if remote_ip is not None else next(
(ip for ip in (_parse_candidate_ip(value) for value in candidate_list) if ip is not None),
None,
)
def is_yookassa_ip_allowed(ip_object: IPAddress) -> bool:
return any(ip_object in network for network in YOOKASSA_ALLOWED_IP_NETWORKS)
class YooKassaWebhookHandler:
@staticmethod
def verify_webhook_signature(body: str, signature: str, secret: str) -> bool:
try:
signature_parts = signature.strip().split(' ')
if len(signature_parts) < 4:
logger.error(f"Неверный формат подписи YooKassa: {signature}")
return False
version = signature_parts[0]
payment_id = signature_parts[1]
timestamp = signature_parts[2]
received_signature = signature_parts[3]
if version != "v1":
logger.error(f"Неподдерживаемая версия подписи: {version}")
return False
logger.info(f"Проверка подписи v1 для платежа {payment_id}, timestamp: {timestamp}")
expected_signature_1 = hmac.new(
secret.encode('utf-8'),
body.encode('utf-8'),
hashlib.sha256
).digest()
expected_signature_1_b64 = base64.b64encode(expected_signature_1).decode('utf-8')
signed_payload_2 = f"{payment_id}.{timestamp}.{body}"
expected_signature_2 = hmac.new(
secret.encode('utf-8'),
signed_payload_2.encode('utf-8'),
hashlib.sha256
).digest()
expected_signature_2_b64 = base64.b64encode(expected_signature_2).decode('utf-8')
signed_payload_3 = f"{timestamp}.{body}"
expected_signature_3 = hmac.new(
secret.encode('utf-8'),
signed_payload_3.encode('utf-8'),
hashlib.sha256
).digest()
expected_signature_3_b64 = base64.b64encode(expected_signature_3).decode('utf-8')
logger.debug(f"Получена подпись: {received_signature}")
logger.debug(f"Ожидаемая подпись (вариант 1): {expected_signature_1_b64}")
logger.debug(f"Ожидаемая подпись (вариант 2): {expected_signature_2_b64}")
logger.debug(f"Ожидаемая подпись (вариант 3): {expected_signature_3_b64}")
is_valid = (
hmac.compare_digest(received_signature, expected_signature_1_b64) or
hmac.compare_digest(received_signature, expected_signature_2_b64) or
hmac.compare_digest(received_signature, expected_signature_3_b64)
)
if is_valid:
logger.info("✅ Подпись YooKassa webhook проверена успешно")
else:
logger.warning("⚠️ Подпись YooKassa webhook не совпадает ни с одним вариантом")
return is_valid
except Exception as e:
logger.error(f"Ошибка проверки подписи YooKassa: {e}")
return False
def __init__(self, payment_service: PaymentService):
self.payment_service = payment_service
async def handle_webhook(self, request: web.Request) -> web.Response:
try:
logger.info(f"📥 Получен YooKassa webhook: {request.method} {request.path}")
logger.info(f"📋 Headers: {dict(request.headers)}")
header_ip_candidates = collect_yookassa_ip_candidates(
request.headers.get("X-Forwarded-For"),
request.headers.get("X-Real-IP"),
request.headers.get("Cf-Connecting-Ip"),
)
client_ip = resolve_yookassa_ip(
header_ip_candidates,
remote=request.remote,
)
if client_ip is None:
logger.warning(
"🚫 Не удалось определить IP-адрес отправителя YooKassa webhook. Кандидаты: %s",
header_ip_candidates + ([request.remote] if request.remote else []),
)
return web.Response(status=403, text="Forbidden")
if not is_yookassa_ip_allowed(client_ip):
logger.warning(
"🚫 YooKassa webhook отклонён: IP %s не входит в доверенные диапазоны (%s)",
client_ip,
", ".join(str(network) for network in YOOKASSA_ALLOWED_IP_NETWORKS),
)
return web.Response(status=403, text="Forbidden")
logger.info("🌐 IP-адрес YooKassa подтверждён: %s", client_ip)
body = await request.text()
if not body:
logger.warning("⚠️ Получен пустой webhook от YooKassa")
return web.Response(status=400, text="Empty body")
logger.info(f"📄 Body: {body}")
signature = request.headers.get('Signature') or request.headers.get('X-YooKassa-Signature')
if settings.YOOKASSA_WEBHOOK_SECRET and signature:
logger.info(f"🔐 Получена подпись: {signature}")
if not YooKassaWebhookHandler.verify_webhook_signature(body, signature, settings.YOOKASSA_WEBHOOK_SECRET):
logger.warning("❌ Подпись не совпала, но продолжаем обработку (режим отладки)")
else:
logger.info("✅ Подпись webhook проверена успешно")
elif settings.YOOKASSA_WEBHOOK_SECRET and not signature:
logger.warning("⚠️ Webhook без подписи, но секрет настроен")
elif signature and not settings.YOOKASSA_WEBHOOK_SECRET:
logger.info("ℹ️ Подпись получена, но проверка отключена (YOOKASSA_WEBHOOK_SECRET не настроен)")
else:
logger.info("ℹ️ Проверка подписи отключена")
if signature:
logger.info("ℹ️ Получена подпись YooKassa: %s", signature)
try:
webhook_data = json.loads(body)
except json.JSONDecodeError as e:
logger.error(f"❌ Ошибка парсинга JSON webhook YooKassa: {e}")
return web.Response(status=400, text="Invalid JSON")
logger.info(f"📊 Обработка webhook YooKassa: {webhook_data.get('event', 'unknown_event')}")
logger.debug(f"🔍 Полные данные webhook: {webhook_data}")
# Извлекаем ID платежа из вебхука для предотвращения дублирования
yookassa_payment_id = webhook_data.get("object", {}).get("id")
if not yookassa_payment_id:
logger.warning("⚠️ Webhook YooKassa без ID платежа")
return web.Response(status=400, text="No payment ID")
event_type = webhook_data.get("event")
if not event_type:
logger.warning("⚠️ Webhook YooKassa без типа события")
return web.Response(status=400, text="No event type")
if event_type not in ["payment.succeeded", "payment.waiting_for_capture"]:
if event_type not in YOOKASSA_ALLOWED_EVENTS:
logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}")
return web.Response(status=200, text="OK")
async for db in get_db():
try:
success = await self.payment_service.process_yookassa_webhook(db, webhook_data)
# Проверяем, не обрабатывается ли этот платеж уже (защита от дублирования)
from app.database.models import PaymentMethod
from app.database.crud.transaction import get_transaction_by_external_id
existing_transaction = await get_transaction_by_external_id(db, yookassa_payment_id, PaymentMethod.YOOKASSA)
if existing_transaction and event_type == "payment.succeeded":
logger.info(f"️ Платеж YooKassa {yookassa_payment_id} уже был обработан. Пропускаем дублирующий вебхук.")
return web.Response(status=200, text="OK")
success = await self.payment_service.process_yookassa_webhook(db, webhook_data)
if success:
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type}")
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type} для платежа {yookassa_payment_id}")
return web.Response(status=200, text="OK")
else:
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type}")
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type} для платежа {yookassa_payment_id}")
return web.Response(status=500, text="Processing error")
finally:
await db.close()
except Exception as e:
logger.error(f"❌ Критическая ошибка обработки webhook YooKassa: {e}", exc_info=True)
return web.Response(status=500, text="Internal server error")
def setup_routes(self, app: web.Application) -> None:
webhook_path = settings.YOOKASSA_WEBHOOK_PATH
app.router.add_post(webhook_path, self.handle_webhook)
app.router.add_get(webhook_path, self._get_handler)
app.router.add_options(webhook_path, self._options_handler)
app.router.add_get(webhook_path, self._get_handler)
app.router.add_options(webhook_path, self._options_handler)
logger.info(f"✅ Настроен YooKassa webhook на пути: POST {webhook_path}")
async def _get_handler(self, request: web.Request) -> web.Response:
return web.json_response({
"status": "ok",
@@ -172,7 +313,7 @@ class YooKassaWebhookHandler:
"path": request.path,
"note": "Use POST method for actual webhooks"
})
async def _options_handler(self, request: web.Request) -> web.Response:
return web.Response(
status=200,
@@ -185,46 +326,46 @@ class YooKassaWebhookHandler:
def create_yookassa_webhook_app(payment_service: PaymentService) -> web.Application:
app = web.Application()
webhook_handler = YooKassaWebhookHandler(payment_service)
webhook_handler.setup_routes(app)
async def health_check(request):
return web.json_response({
"status": "ok",
"status": "ok",
"service": "yookassa_webhook",
"port": settings.YOOKASSA_WEBHOOK_PORT,
"path": settings.YOOKASSA_WEBHOOK_PATH,
"enabled": settings.is_yookassa_enabled()
})
app.router.add_get("/health", health_check)
return app
async def start_yookassa_webhook_server(payment_service: PaymentService) -> None:
if not settings.is_yookassa_enabled():
logger.info("ℹ️ YooKassa отключена, webhook сервер не запускается")
return
try:
app = create_yookassa_webhook_app(payment_service)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(
runner,
host=settings.YOOKASSA_WEBHOOK_HOST,
port=settings.YOOKASSA_WEBHOOK_PORT
)
await site.start()
logger.info(
"✅ YooKassa webhook сервер запущен на %s:%s",
settings.YOOKASSA_WEBHOOK_HOST,
@@ -236,7 +377,7 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None
settings.YOOKASSA_WEBHOOK_PORT,
settings.YOOKASSA_WEBHOOK_PATH,
)
try:
while True:
await asyncio.sleep(1)
@@ -246,7 +387,7 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None
await site.stop()
await runner.cleanup()
logger.info("✅ YooKassa webhook сервер остановлен")
except Exception as e:
logger.error(f"❌ Ошибка запуска YooKassa webhook сервера: {e}", exc_info=True)
raise
raise
+3
View File
@@ -0,0 +1,3 @@
from . import balance
__all__ = ["balance"]
+392 -94
View File
@@ -13,6 +13,11 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import SystemSetting, User
from app.database.crud.server_squad import (
get_all_server_squads,
get_server_squad_by_id,
get_server_squad_by_uuid,
)
from app.localization.texts import get_texts
from app.config import settings
from app.services.remnawave_service import RemnaWaveService
@@ -30,13 +35,23 @@ from app.external.telegram_stars import TelegramStarsService
CATEGORY_PAGE_SIZE = 10
SETTINGS_PAGE_SIZE = 8
SIMPLE_SUBSCRIPTION_SQUADS_PAGE_SIZE = 6
CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
"core": {
"title": "🤖 Основные",
"description": "Базовые настройки бота, обязательные каналы и ключевые сервисы.",
"icon": "🤖",
"categories": ("CORE", "CHANNEL"),
"categories": (
"CORE",
"CHANNEL",
"TIMEZONE",
"DATABASE",
"POSTGRES",
"SQLITE",
"REDIS",
"REMNAWAVE",
),
},
"support": {
"title": "💬 Поддержка",
@@ -46,15 +61,35 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
},
"payments": {
"title": "💳 Платежные системы",
"description": "YooKassa, CryptoBot, MulenPay, PAL24, Tribute и Telegram Stars.",
"description": "YooKassa, CryptoBot, Heleket, MulenPay, PAL24, Wata, Platega, Tribute и Telegram Stars.",
"icon": "💳",
"categories": ("PAYMENT", "YOOKASSA", "CRYPTOBOT", "MULENPAY", "PAL24", "TRIBUTE", "TELEGRAM"),
"categories": (
"PAYMENT",
"PAYMENT_VERIFICATION",
"YOOKASSA",
"CRYPTOBOT",
"HELEKET",
"MULENPAY",
"PAL24",
"WATA",
"PLATEGA",
"TRIBUTE",
"TELEGRAM",
),
},
"subscriptions": {
"title": "📅 Подписки и цены",
"description": "Тарифы, периоды, лимиты трафика и автопродление.",
"description": "Тарифы, простая покупка, периоды, лимиты трафика и автопродление.",
"icon": "📅",
"categories": ("SUBSCRIPTIONS_CORE", "PERIODS", "SUBSCRIPTION_PRICES", "TRAFFIC", "TRAFFIC_PACKAGES", "AUTOPAY"),
"categories": (
"SUBSCRIPTIONS_CORE",
"SIMPLE_SUBSCRIPTION",
"PERIODS",
"SUBSCRIPTION_PRICES",
"TRAFFIC",
"TRAFFIC_PACKAGES",
"AUTOPAY",
),
},
"trial": {
"title": "🎁 Пробный период",
@@ -76,21 +111,19 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
},
"interface": {
"title": "🎨 Интерфейс и брендинг",
"description": "Логотип, тексты, языки, miniapp и deep links.",
"description": "Логотип, тексты, языки, главное меню, miniapp и deep links.",
"icon": "🎨",
"categories": ("INTERFACE_BRANDING", "INTERFACE_SUBSCRIPTION", "CONNECT_BUTTON", "MINIAPP", "HAPP", "SKIP", "LOCALIZATION", "ADDITIONAL"),
},
"database": {
"title": "💾 База данных",
"description": "Режим базы, параметры PostgreSQL, SQLite и Redis.",
"icon": "💾",
"categories": ("DATABASE", "POSTGRES", "SQLITE", "REDIS"),
},
"remnawave": {
"title": "🌐 RemnaWave API",
"description": "Интеграция с RemnaWave: URL, ключи и способы авторизации.",
"icon": "🌐",
"categories": ("REMNAWAVE",),
"categories": (
"INTERFACE",
"INTERFACE_BRANDING",
"INTERFACE_SUBSCRIPTION",
"CONNECT_BUTTON",
"MINIAPP",
"HAPP",
"SKIP",
"LOCALIZATION",
"ADDITIONAL",
),
},
"server": {
"title": "📊 Статус серверов",
@@ -106,15 +139,16 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
},
"advanced": {
"title": "⚡ Расширенные",
"description": "Web API, webhook, логирование и режим отладки.",
"description": "Web API, webhook, логирование, модерация и режим отладки.",
"icon": "",
"categories": ("WEB_API", "WEBHOOK", "LOG", "DEBUG"),
},
"external_admin": {
"title": "🛡️ Внешняя админка",
"description": "Токен, по которому внешняя админка проверяет запросы.",
"icon": "🛡️",
"categories": ("EXTERNAL_ADMIN",),
"categories": (
"WEB_API",
"WEBHOOK",
"LOG",
"MODERATION",
"DEBUG",
"EXTERNAL_ADMIN",
),
},
}
@@ -127,12 +161,9 @@ CATEGORY_GROUP_ORDER: Tuple[str, ...] = (
"referral",
"notifications",
"interface",
"database",
"remnawave",
"server",
"maintenance",
"advanced",
"external_admin",
)
CATEGORY_GROUP_DEFINITIONS: Tuple[Tuple[str, str, Tuple[str, ...]], ...] = tuple(
@@ -223,6 +254,7 @@ def _get_group_status(group_key: str) -> Tuple[str, str]:
payment_statuses = {
"YooKassa": settings.is_yookassa_enabled(),
"CryptoBot": settings.is_cryptobot_enabled(),
"Platega": settings.is_platega_enabled(),
"MulenPay": settings.is_mulenpay_enabled(),
"PAL24": settings.is_pal24_enabled(),
"Tribute": settings.TRIBUTE_ENABLED,
@@ -279,7 +311,6 @@ def _get_group_status(group_key: str) -> Tuple[str, str]:
settings.REFERRAL_COMMISSION_PERCENT
or settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS
or settings.REFERRAL_INVITER_BONUS_KOPEKS
or settings.get_referred_user_reward_kopeks()
)
return ("🟢", "Программа активна") if active else ("", "Бонусы не заданы")
@@ -353,25 +384,22 @@ def _render_dashboard_overview() -> str:
)
lines: List[str] = [
"⚙️ <b>Панель управления ботом</b>",
"⚙️ <b>ПАНЕЛЬ УПРАВЛЕНИЯ БОТОМ</b>",
"",
f"Всего параметров: <b>{total_settings}</b> • Переопределено: <b>{total_overrides}</b>",
"",
"Выберите категорию ниже или используйте быстрые действия:",
"<b>Группы настроек</b>",
"",
]
for group_key, title, items in grouped:
status_icon, status_text = _get_group_status(group_key)
description = _get_group_description(group_key) if group_key != CATEGORY_FALLBACK_KEY else "Настройки без категории."
total = sum(count for _, _, count in items)
lines.append(f"{status_icon} <b>{title}</b> — {status_text}")
if description:
lines.append(f" {description}")
lines.append(f" Настроек: {total}")
lines.append(f"└ Настроек: {total}")
lines.append("")
lines.append("🔍 Кнопка поиска поможет найти параметр по названию, описанию или ключу.")
lines.append("🔍 Используйте поиск, чтобы быстро найти нужный параметр по ключу или названию.")
return "\n".join(lines).strip()
@@ -1109,8 +1137,8 @@ def _build_groups_keyboard() -> types.InlineKeyboardMarkup:
for group_key, title, items in grouped:
total = sum(count for _, _, count in items)
status_icon, _ = _get_group_status(group_key)
button_text = f"{status_icon} {title} ({total})"
status_icon, status_text = _get_group_status(group_key)
button_text = f"{status_icon} {title} {status_text}"
rows.append(
[
types.InlineKeyboardButton(
@@ -1162,7 +1190,7 @@ def _build_groups_keyboard() -> types.InlineKeyboardMarkup:
rows.append(
[
types.InlineKeyboardButton(
text="⬅️ Назад",
text="⬅️ Назад в админку",
callback_data="admin_submenu_settings",
)
]
@@ -1185,19 +1213,6 @@ def _build_categories_keyboard(
sliced = categories[start:end]
rows: list[list[types.InlineKeyboardButton]] = []
status_icon, _status_text = (
_get_group_status(group_key)
if group_key != CATEGORY_FALLBACK_KEY
else ("", "Прочие настройки")
)
rows.append(
[
types.InlineKeyboardButton(
text=f"{status_icon} {group_title}",
callback_data="botcfg_group:noop",
)
]
)
buttons: List[types.InlineKeyboardButton] = []
for category_key, label, count in sliced:
@@ -1205,7 +1220,7 @@ def _build_categories_keyboard(
for definition in bot_configuration_service.get_settings_for_category(category_key):
if bot_configuration_service.has_override(definition.key):
overrides += 1
badge = "✳️" if overrides else ""
badge = "✳️" if overrides else ""
button_text = f"{badge} {label} ({count})"
buttons.append(
types.InlineKeyboardButton(
@@ -1228,7 +1243,7 @@ def _build_categories_keyboard(
)
nav_row.append(
types.InlineKeyboardButton(
text=f"{page}/{total_pages}",
text=f"[{page}/{total_pages}]",
callback_data="botcfg_group:noop",
)
)
@@ -1300,8 +1315,14 @@ def _build_settings_keyboard(
label = texts.t("PAYMENT_CARD_TRIBUTE", "💳 Банковская карта (Tribute)")
test_payment_buttons.append([_test_button(f"{label} · тест", "tribute")])
elif category_key == "MULENPAY":
label = texts.t("PAYMENT_CARD_MULENPAY", "💳 Банковская карта (Mulen Pay)")
label = texts.t(
"PAYMENT_CARD_MULENPAY",
"💳 Банковская карта ({mulenpay_name})",
).format(mulenpay_name=settings.get_mulenpay_display_name())
test_payment_buttons.append([_test_button(f"{label} · тест", "mulenpay")])
elif category_key == "WATA":
label = texts.t("PAYMENT_CARD_WATA", "💳 Банковская карта (WATA)")
test_payment_buttons.append([_test_button(f"{label} · тест", "wata")])
elif category_key == "PAL24":
label = texts.t("PAYMENT_CARD_PAL24", "💳 Банковская карта (PayPalych)")
test_payment_buttons.append([_test_button(f"{label} · тест", "pal24")])
@@ -1350,7 +1371,7 @@ def _build_settings_keyboard(
)
nav_row.append(
types.InlineKeyboardButton(
text=f"{page}/{total_pages}", callback_data="botcfg_cat_page:noop"
text=f"[{page}/{total_pages}]", callback_data="botcfg_cat_page:noop"
)
)
if page < total_pages:
@@ -1408,6 +1429,16 @@ def _build_setting_keyboard(
for chunk in _chunk(choice_buttons, 2):
rows.append(list(chunk))
if key == "SIMPLE_SUBSCRIPTION_SQUAD_UUID" and not is_read_only:
rows.append([
types.InlineKeyboardButton(
text="🌍 Выбрать сквад",
callback_data=(
f"botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{callback_token}:1"
),
)
])
if definition.python_type is bool and not is_read_only:
rows.append([
types.InlineKeyboardButton(
@@ -1462,42 +1493,57 @@ def _render_setting_text(key: str) -> str:
summary = bot_configuration_service.get_setting_summary(key)
guidance = bot_configuration_service.get_setting_guidance(key)
definition = bot_configuration_service.get_definition(key)
description = guidance.get("description") or ""
format_hint = guidance.get("format") or ""
example = guidance.get("example") or ""
warning = guidance.get("warning") or ""
dependencies = guidance.get("dependencies") or ""
type_label = guidance.get("type") or summary.get("type") or definition.type_label
lines = [
f"🧩 <b>{summary['name']}</b>",
f"🔑 <b>Ключ:</b> <code>{summary['key']}</code>",
f"📁 <b>Категория:</b> {summary['category_label']}",
f"📝 <b>Тип:</b> {guidance['type']}",
f"📌 <b>Текущее:</b> {summary['current']}",
f"📦 <b>По умолчанию:</b> {summary['original']}",
f"✳️ <b>Переопределено:</b> {'Да' if summary['has_override'] else 'Нет'}",
*(
["🔒 <b>Режим:</b> Только для чтения (управляется автоматически)"]
if summary.get("is_read_only")
else []
),
"",
f"📘 <b>Описание:</b> {guidance['description']}",
f"📐 <b>Формат:</b> {guidance['format']}",
f"💡 <b>Пример:</b> {guidance['example']}",
f"⚠️ <b>Важно:</b> {guidance['warning']}",
f"🔗 <b>Связанные настройки:</b> {guidance['dependencies']}",
f"🔑 Ключ: <code>{summary['key']}</code>",
f"📁 Категория: {summary['category_label']}",
f"📝 Тип: {type_label}",
f"📌 Текущее: {summary['current']}",
]
original_value = summary.get("original")
if original_value not in {None, ""}:
lines.append(f"📦 По умолчанию: {original_value}")
lines.append(f"✳️ Переопределено: {'Да' if summary['has_override'] else 'Нет'}")
if summary.get("is_read_only"):
lines.append("🔒 Режим: Только для чтения (управляется автоматически)")
lines.append("")
if description:
lines.append(f"📘 Описание: {description}")
if format_hint:
lines.append(f"📐 Формат: {format_hint}")
if example:
lines.append(f"💡 Пример: {example}")
if warning:
lines.append(f"⚠️ Важно: {warning}")
if dependencies:
lines.append(f"🔗 Связанные: {dependencies}")
choices = bot_configuration_service.get_choice_options(key)
if choices:
current_raw = bot_configuration_service.get_current_value(key)
lines.append("")
lines.append("📋 <b>Доступные значения:</b>")
lines.append("📋 Доступные значения:")
for option in choices:
marker = "" if current_raw == option.value else ""
value_display = bot_configuration_service.format_value_human(key, option.value)
description = option.description or ""
base_line = f"{marker} {option.label} — <code>{value_display}</code>"
if description:
lines.append(
f"{marker} {option.label} — <code>{value_display}</code>\n {description}"
)
else:
lines.append(f"{marker} {option.label} — <code>{value_display}</code>")
base_line += f"\n{description}"
lines.append(base_line)
return "\n".join(lines)
@@ -1540,13 +1586,25 @@ async def show_bot_config_group(
keyboard = _build_categories_keyboard(group_key, group_title, items, page)
status_icon, status_text = _get_group_status(group_key)
description = _get_group_description(group_key)
lines = [f"{status_icon} <b>{group_title}</b>"]
if description:
lines.append(description)
icon = _get_group_icon(group_key)
raw_title = str(group_title).strip()
clean_title = raw_title
if icon and raw_title.startswith(icon):
clean_title = raw_title[len(icon) :].strip()
elif " " in raw_title:
possible_icon, remainder = raw_title.split(" ", 1)
if possible_icon:
icon = possible_icon
clean_title = remainder.strip()
lines = [f"{icon} <b>{clean_title}</b>"]
if status_text:
lines.append(f"Статус: {status_text}")
lines.append(f"Статус: {status_icon} {status_text}")
lines.append(f"🏠 → {clean_title}")
if description:
lines.append("")
lines.append(description)
lines.append("")
lines.append("📂 Выберите категорию настроек:")
lines.append("📂 Категории группы:")
await callback.message.edit_text(
"\n".join(lines),
reply_markup=keyboard,
@@ -1575,6 +1633,17 @@ async def show_bot_config_category(
category_description = bot_configuration_service.get_category_description(category_key)
group_meta = _get_group_meta(group_key)
group_title = str(group_meta.get("title", group_key))
group_icon = _get_group_icon(group_key)
raw_group_title = group_title.strip()
if group_icon and raw_group_title.startswith(group_icon):
group_plain_title = raw_group_title[len(group_icon) :].strip()
elif " " in raw_group_title:
possible_icon, remainder = raw_group_title.split(" ", 1)
group_plain_title = remainder.strip()
if possible_icon:
group_icon = possible_icon
else:
group_plain_title = raw_group_title
keyboard = _build_settings_keyboard(
category_key,
group_key,
@@ -1584,12 +1653,12 @@ async def show_bot_config_category(
)
text_lines = [
f"🗂 <b>{category_label}</b>",
f"Навигация: 🏠 Главное{group_title}{category_label}",
f"🏠{group_plain_title}{category_label}",
]
if category_description:
text_lines.append(category_description)
text_lines.append("")
text_lines.append("📋 Выберите настройку для просмотра или редактирования:")
text_lines.append("📋 Список настроек категории:")
await callback.message.edit_text(
"\n".join(text_lines),
reply_markup=keyboard,
@@ -1598,6 +1667,217 @@ async def show_bot_config_category(
await callback.answer()
@admin_required
@error_handler
async def show_simple_subscription_squad_selector(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
parts = callback.data.split(":", 5)
group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY
try:
category_page = max(1, int(parts[2])) if len(parts) > 2 else 1
except ValueError:
category_page = 1
try:
settings_page = max(1, int(parts[3])) if len(parts) > 3 else 1
except ValueError:
settings_page = 1
token = parts[4] if len(parts) > 4 else ""
try:
key = bot_configuration_service.resolve_callback_token(token)
except KeyError:
await callback.answer("Эта настройка больше недоступна", show_alert=True)
return
if key != "SIMPLE_SUBSCRIPTION_SQUAD_UUID":
await callback.answer("Эта настройка больше недоступна", show_alert=True)
return
try:
page = max(1, int(parts[5])) if len(parts) > 5 else 1
except ValueError:
page = 1
limit = SIMPLE_SUBSCRIPTION_SQUADS_PAGE_SIZE
squads, total_count = await get_all_server_squads(
db,
available_only=False,
page=page,
limit=limit,
)
total_count = total_count or 0
total_pages = max(1, math.ceil(total_count / limit)) if total_count else 1
if total_count and page > total_pages:
page = total_pages
squads, total_count = await get_all_server_squads(
db,
available_only=False,
page=page,
limit=limit,
)
current_uuid = bot_configuration_service.get_current_value(key) or ""
current_display = "Любой доступный"
if current_uuid:
selected_server = next((srv for srv in squads if srv.squad_uuid == current_uuid), None)
if not selected_server:
selected_server = await get_server_squad_by_uuid(db, current_uuid)
if selected_server:
current_display = selected_server.display_name
else:
current_display = current_uuid
lines = [
"🌍 <b>Выберите сквад для простой покупки</b>",
"",
f"Текущий выбор: {html.escape(current_display)}" if current_display else "Текущий выбор: —",
"",
]
if total_count == 0:
lines.append("❌ Доступные сервера не найдены.")
else:
lines.append("Выберите сервер из списка ниже.")
if total_pages > 1:
lines.append(f"Страница {page}/{total_pages}")
text = "\n".join(lines)
keyboard_rows: List[List[types.InlineKeyboardButton]] = []
for server in squads:
status_icon = (
"" if server.squad_uuid == current_uuid else ("🟢" if server.is_available else "🔒")
)
label_parts = [status_icon, server.display_name]
if server.country_code:
label_parts.append(f"({server.country_code.upper()})")
if isinstance(server.price_kopeks, int) and server.price_kopeks > 0:
try:
label_parts.append(f"{settings.format_price(server.price_kopeks)}")
except Exception:
pass
label = " ".join(label_parts)
keyboard_rows.append([
types.InlineKeyboardButton(
text=label,
callback_data=(
f"botcfg_simple_squad_select:{group_key}:{category_page}:{settings_page}:{token}:{server.id}:{page}"
),
)
])
if total_pages > 1:
nav_row: List[types.InlineKeyboardButton] = []
if page > 1:
nav_row.append(
types.InlineKeyboardButton(
text="⬅️",
callback_data=(
f"botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{token}:{page - 1}"
),
)
)
if page < total_pages:
nav_row.append(
types.InlineKeyboardButton(
text="➡️",
callback_data=(
f"botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{token}:{page + 1}"
),
)
)
if nav_row:
keyboard_rows.append(nav_row)
keyboard_rows.append([
types.InlineKeyboardButton(
text="⬅️ Назад",
callback_data=(
f"botcfg_setting:{group_key}:{category_page}:{settings_page}:{token}"
),
)
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
parse_mode="HTML",
)
await callback.answer()
@admin_required
@error_handler
async def select_simple_subscription_squad(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
parts = callback.data.split(":", 6)
group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY
try:
category_page = max(1, int(parts[2])) if len(parts) > 2 else 1
except ValueError:
category_page = 1
try:
settings_page = max(1, int(parts[3])) if len(parts) > 3 else 1
except ValueError:
settings_page = 1
token = parts[4] if len(parts) > 4 else ""
try:
server_id = int(parts[5]) if len(parts) > 5 else None
except ValueError:
server_id = None
if server_id is None:
await callback.answer("Не удалось определить сервер", show_alert=True)
return
try:
key = bot_configuration_service.resolve_callback_token(token)
except KeyError:
await callback.answer("Эта настройка больше недоступна", show_alert=True)
return
if bot_configuration_service.is_read_only(key):
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
return
server = await get_server_squad_by_id(db, server_id)
if not server:
await callback.answer("Сервер не найден", show_alert=True)
return
try:
await bot_configuration_service.set_value(db, key, server.squad_uuid)
except ReadOnlySettingError:
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
return
await db.commit()
text = _render_setting_text(key)
keyboard = _build_setting_keyboard(key, group_key, category_page, settings_page)
await callback.message.edit_text(text, reply_markup=keyboard)
await _store_setting_context(
state,
key=key,
group_key=group_key,
category_page=category_page,
settings_page=settings_page,
)
await callback.answer("Сквад выбран")
@admin_required
@error_handler
async def test_remnawave_connection(
@@ -1790,8 +2070,13 @@ async def test_payment_provider(
return
if method == "mulenpay":
mulenpay_name = settings.get_mulenpay_display_name()
mulenpay_name_html = settings.get_mulenpay_display_name_html()
if not settings.is_mulenpay_enabled():
await callback.answer("❌ MulenPay отключен", show_alert=True)
await callback.answer(
f"{mulenpay_name} отключен",
show_alert=True,
)
return
amount_kopeks = 1 * 100
@@ -1799,18 +2084,21 @@ async def test_payment_provider(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description="Тестовый платеж MulenPay (админ)",
description=f"Тестовый платеж {mulenpay_name} (админ)",
language=language,
)
if not payment_result or not payment_result.get("payment_url"):
await callback.answer("❌ Не удалось создать платеж MulenPay", show_alert=True)
await callback.answer(
f"❌ Не удалось создать платеж {mulenpay_name}",
show_alert=True,
)
await _refresh_markup()
return
payment_url = payment_result["payment_url"]
message_text = (
"🧪 <b>Тестовый платеж MulenPay</b>\n\n"
f"🧪 <b>Тестовый платеж {mulenpay_name_html}</b>\n\n"
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
f"🆔 ID: {payment_result['mulen_payment_id']}"
)
@@ -1831,7 +2119,10 @@ async def test_payment_provider(
]
)
await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode="HTML")
await callback.answer("✅ Ссылка на платеж MulenPay отправлена", show_alert=True)
await callback.answer(
f"✅ Ссылка на платеж {mulenpay_name} отправлена",
show_alert=True,
)
await _refresh_markup()
return
@@ -2461,6 +2752,14 @@ def register_handlers(dp: Dispatcher) -> None:
test_payment_provider,
F.data.startswith("botcfg_test_payment:"),
)
dp.callback_query.register(
select_simple_subscription_squad,
F.data.startswith("botcfg_simple_squad_select:"),
)
dp.callback_query.register(
show_simple_subscription_squad_selector,
F.data.startswith("botcfg_simple_squad:"),
)
dp.callback_query.register(
show_bot_config_setting,
F.data.startswith("botcfg_setting:"),
@@ -2499,4 +2798,3 @@ def register_handlers(dp: Dispatcher) -> None:
handle_import_message,
BotConfigStates.waiting_for_import_file,
)
+7 -2
View File
@@ -46,6 +46,9 @@ def _format_campaign_summary(campaign, texts) -> str:
bonus_info = f"💰 Бонус на баланс: <b>{bonus_text}</b>"
else:
traffic_text = texts.format_traffic(campaign.subscription_traffic_gb or 0)
device_limit = campaign.subscription_device_limit
if device_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
bonus_info = (
"📱 Подписка: <b>{days} д.</b>\n"
"🌐 Трафик: <b>{traffic}</b>\n"
@@ -53,7 +56,7 @@ def _format_campaign_summary(campaign, texts) -> str:
).format(
days=campaign.subscription_duration_days or 0,
traffic=traffic_text,
devices=campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT,
devices=device_limit,
)
return (
@@ -935,7 +938,9 @@ async def start_edit_campaign_subscription_devices(
campaign_edit_message_is_caption=is_caption,
)
current_devices = campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT
current_devices = campaign.subscription_device_limit
if current_devices is None:
current_devices = settings.DEFAULT_DEVICE_LIMIT
await callback.message.edit_text(
(
+4 -1
View File
@@ -207,6 +207,7 @@ async def show_support_audit(
'close_ticket': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET", "Закрытие тикета"),
'block_user_timed': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED", "Блокировка (время)"),
'block_user_perm': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM", "Блокировка (навсегда)"),
'close_all_tickets': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_ALL_TICKETS", "Массовое закрытие тикетов"),
'unblock_user': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK", "Снятие блока"),
}
action_text = action_map.get(log.action, log.action)
@@ -215,6 +216,8 @@ async def show_support_audit(
extra = ""
if log.action == 'block_user_timed' and 'minutes' in details:
extra = f" ({details['minutes']} мин)"
elif log.action == 'close_all_tickets' and 'count' in details:
extra = f" ({details['count']})"
lines.append(f"{ts}{role} <code>{log.actor_telegram_id}</code> — {action_text}{ticket_part}{extra}")
# keyboard with pagination
@@ -446,4 +449,4 @@ def register_handlers(dp: Dispatcher):
dp.message.register(
admin_commands_help,
Command("admin_help")
)
)
+6 -1
View File
@@ -74,6 +74,7 @@ async def show_maintenance_panel(
{status_emoji} <b>Режим техработ:</b> {status_text}
{api_emoji} <b>API Remnawave:</b> {api_text}
{monitoring_emoji} <b>Мониторинг:</b> {monitoring_text}
🛠 <b>Автозапуск мониторинга:</b> {'Включен' if status_info['monitoring_configured'] else 'Отключен'}
<b>Интервал проверки:</b> {status_info['check_interval']}с
🤖 <b>Автовключение:</b> {'Включено' if status_info['auto_enable_configured'] else 'Отключено'}
{panel_info}
@@ -233,7 +234,11 @@ async def check_panel_status(
f"👥 Пользователей онлайн: {status_data.get('users_online', 0)}",
f"🖥️ Нод онлайн: {status_data.get('nodes_online', 0)}/{status_data.get('total_nodes', 0)}"
]
attempts_used = status_data.get("attempts_used")
if attempts_used:
message_parts.append(f"🔁 Попыток проверки: {attempts_used}")
if status_data.get("api_error"):
message_parts.append(f"❌ Ошибка: {status_data['api_error'][:100]}")
+81 -43
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)
@@ -747,48 +764,69 @@ async def confirm_broadcast(
broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language)
for user in users:
try:
if has_media and media_file_id:
if media_type == "photo":
await callback.bot.send_photo(
# Ограничение на количество одновременных отправок
semaphore = asyncio.Semaphore(20)
async def send_single_broadcast(user):
"""Отправляет одно сообщение рассылки с семафором ограничения"""
async with semaphore:
try:
if has_media and media_file_id:
if media_type == "photo":
await callback.bot.send_photo(
chat_id=user.telegram_id,
photo=media_file_id,
caption=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
)
elif media_type == "video":
await callback.bot.send_video(
chat_id=user.telegram_id,
video=media_file_id,
caption=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
)
elif media_type == "document":
await callback.bot.send_document(
chat_id=user.telegram_id,
document=media_file_id,
caption=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
)
else:
await callback.bot.send_message(
chat_id=user.telegram_id,
photo=media_file_id,
caption=message_text,
text=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
)
elif media_type == "video":
await callback.bot.send_video(
chat_id=user.telegram_id,
video=media_file_id,
caption=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
)
elif media_type == "document":
await callback.bot.send_document(
chat_id=user.telegram_id,
document=media_file_id,
caption=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
)
else:
await callback.bot.send_message(
chat_id=user.telegram_id,
text=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
)
sent_count += 1
if sent_count % 20 == 0:
await asyncio.sleep(1)
except Exception as e:
failed_count += 1
logger.error(f"Ошибка отправки рассылки пользователю {user.telegram_id}: {e}")
return True, user.telegram_id
except Exception as e:
logger.error(f"Ошибка отправки рассылки пользователю {user.telegram_id}: {e}")
return False, user.telegram_id
# Отправляем сообщения пакетами для эффективности
batch_size = 100
for i in range(0, len(users), batch_size):
batch = users[i:i + batch_size]
tasks = [send_single_broadcast(user) for user in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, tuple): # (success, telegram_id)
success, _ = result
if success:
sent_count += 1
else:
failed_count += 1
elif isinstance(result, Exception):
failed_count += 1
# Небольшая задержка между пакетами для снижения нагрузки на API
await asyncio.sleep(0.1)
broadcast_history.sent_count = sent_count
broadcast_history.failed_count = failed_count
+15 -1
View File
@@ -1027,6 +1027,20 @@ async def process_notification_value_input(message: Message, state: FSMContext):
language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE
texts = get_texts(language)
# Добавляем дополнительные проверки диапазона значений
if (key == "expired_second_wave" and field == "percent") or (key == "expired_third_wave" and field == "percent"):
if value < 0 or value > 100:
await message.answer("❌ Процент скидки должен быть от 0 до 100.")
return
elif (key == "expired_second_wave" and field == "hours") or (key == "expired_third_wave" and field == "hours"):
if value < 1 or value > 168: # Максимум 168 часов (7 дней)
await message.answer("❌ Количество часов должно быть от 1 до 168.")
return
elif key == "expired_third_wave" and field == "trigger":
if value < 2: # Минимум 2 дня
await message.answer("❌ Количество дней должно быть не менее 2.")
return
success = False
if key == "expired_second_wave" and field == "percent":
success = NotificationSettingsService.set_second_wave_discount_percent(value)
@@ -1075,4 +1089,4 @@ async def process_notification_value_input(message: Message, state: FSMContext):
def register_handlers(dp):
dp.include_router(router)
dp.include_router(router)
+612
View File
@@ -0,0 +1,612 @@
from __future__ import annotations
import html
import math
from typing import Optional
from aiogram import Dispatcher, F, types
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, User
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.services.payment_verification_service import (
PendingPayment,
SUPPORTED_MANUAL_CHECK_METHODS,
get_payment_record,
list_recent_pending_payments,
run_manual_check,
)
from app.utils.decorators import admin_required, error_handler
from app.utils.formatters import format_datetime, format_time_ago, format_username
PAGE_SIZE = 6
def _method_display(method: PaymentMethod) -> str:
if method == PaymentMethod.MULENPAY:
return settings.get_mulenpay_display_name()
if method == PaymentMethod.PAL24:
return "PayPalych"
if method == PaymentMethod.WATA:
return "WATA"
if method == PaymentMethod.HELEKET:
return "Heleket"
if method == PaymentMethod.YOOKASSA:
return "YooKassa"
if method == PaymentMethod.PLATEGA:
return "Platega"
if method == PaymentMethod.CRYPTOBOT:
return "CryptoBot"
if method == PaymentMethod.TELEGRAM_STARS:
return "Telegram Stars"
return method.value
def _status_info(
record: PendingPayment,
*,
texts,
) -> tuple[str, str]:
status = (record.status or "").lower()
if record.is_paid:
return "", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
if record.method == PaymentMethod.PAL24:
mapping = {
"new": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"process": ("", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
"success": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"fail": ("", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
"canceled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"cancel": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.MULENPAY:
mapping = {
"created": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"processing": ("", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
"hold": ("🔒", texts.t("ADMIN_PAYMENT_STATUS_ON_HOLD", "🔒 Hold")),
"success": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"canceled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"cancel": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"error": ("⚠️", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.WATA:
mapping = {
"opened": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"pending": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"processing": ("", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
"paid": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"closed": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"declined": ("", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
"canceled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"expired": ("", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.PLATEGA:
mapping = {
"pending": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"inprogress": ("", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
"confirmed": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"failed": ("", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
"canceled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"cancelled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"expired": ("", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.HELEKET:
if status in {"pending", "created", "waiting", "check", "processing"}:
return "", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")
if status in {"paid", "paid_over"}:
return "", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
if status in {"cancel", "canceled", "fail", "failed", "expired"}:
return "", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")
return "", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")
if record.method == PaymentMethod.YOOKASSA:
mapping = {
"pending": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"waiting_for_capture": ("", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
"succeeded": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"canceled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.CRYPTOBOT:
mapping = {
"active": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"paid": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"expired": ("", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.TELEGRAM_STARS:
if record.is_paid:
return "", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
return "", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")
return "", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")
def _is_checkable(record: PendingPayment) -> bool:
if record.method not in SUPPORTED_MANUAL_CHECK_METHODS:
return False
if not record.is_recent():
return False
status = (record.status or "").lower()
if record.method == PaymentMethod.PAL24:
return status in {"new", "process"}
if record.method == PaymentMethod.MULENPAY:
return status in {"created", "processing", "hold"}
if record.method == PaymentMethod.WATA:
return status in {"opened", "pending", "processing", "inprogress", "in_progress"}
if record.method == PaymentMethod.PLATEGA:
return status in {"pending", "inprogress", "in_progress"}
if record.method == PaymentMethod.HELEKET:
return status not in {"paid", "paid_over", "cancel", "canceled", "fail", "failed", "expired"}
if record.method == PaymentMethod.YOOKASSA:
return status in {"pending", "waiting_for_capture"}
if record.method == PaymentMethod.CRYPTOBOT:
return status in {"active"}
return False
def _record_display_number(record: PendingPayment) -> str:
if record.identifier:
return str(record.identifier)
return str(record.local_id)
def _build_list_keyboard(
records: list[PendingPayment],
*,
page: int,
total_pages: int,
language: str,
) -> InlineKeyboardMarkup:
buttons: list[list[InlineKeyboardButton]] = []
texts = get_texts(language)
for record in records:
number = _record_display_number(record)
details_template = texts.t("ADMIN_PAYMENTS_ITEM_DETAILS", "📄 #{number}")
try:
button_text = details_template.format(number=number)
except Exception: # pragma: no cover - fallback for broken localization
button_text = f"📄 {number}"
buttons.append(
[
InlineKeyboardButton(
text=button_text,
callback_data=f"admin_payment_{record.method.value}_{record.local_id}",
)
]
)
if total_pages > 1:
navigation_row: list[InlineKeyboardButton] = []
if page > 1:
navigation_row.append(
InlineKeyboardButton(
text="⬅️",
callback_data=f"admin_payments_page_{page - 1}",
)
)
navigation_row.append(
InlineKeyboardButton(
text=f"{page}/{total_pages}",
callback_data="admin_payments_page_current",
)
)
if page < total_pages:
navigation_row.append(
InlineKeyboardButton(
text="➡️",
callback_data=f"admin_payments_page_{page + 1}",
)
)
buttons.append(navigation_row)
buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def _build_detail_keyboard(
record: PendingPayment,
*,
language: str,
) -> InlineKeyboardMarkup:
texts = get_texts(language)
rows: list[list[InlineKeyboardButton]] = []
payment = record.payment
payment_url = getattr(payment, "payment_url", None)
if record.method == PaymentMethod.PAL24:
payment_url = payment.link_url or payment.link_page_url or payment_url
elif record.method == PaymentMethod.WATA:
payment_url = payment.url or payment_url
elif record.method == PaymentMethod.YOOKASSA:
payment_url = getattr(payment, "confirmation_url", None) or payment_url
elif record.method == PaymentMethod.CRYPTOBOT:
payment_url = (
payment.bot_invoice_url
or payment.mini_app_invoice_url
or payment.web_app_invoice_url
or payment_url
)
if payment_url:
rows.append(
[
InlineKeyboardButton(
text=texts.t("ADMIN_PAYMENT_OPEN_LINK", "🔗 Open link"),
url=payment_url,
)
]
)
if _is_checkable(record):
rows.append(
[
InlineKeyboardButton(
text=texts.t("ADMIN_PAYMENT_CHECK_BUTTON", "🔁 Check status"),
callback_data=f"admin_payment_check_{record.method.value}_{record.local_id}",
)
]
)
rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_payments")])
return InlineKeyboardMarkup(inline_keyboard=rows)
def _format_user_line(user: User) -> str:
username = format_username(user.username, user.telegram_id, user.full_name)
return f"👤 {html.escape(username)} (<code>{user.telegram_id}</code>)"
def _build_record_lines(
record: PendingPayment,
*,
index: int,
texts,
language: str,
) -> list[str]:
amount = settings.format_price(record.amount_kopeks)
if record.method == PaymentMethod.CRYPTOBOT:
crypto_amount = getattr(record.payment, "amount", None)
crypto_asset = getattr(record.payment, "asset", None)
if crypto_amount and crypto_asset:
amount = f"{crypto_amount} {crypto_asset}"
method_name = _method_display(record.method)
emoji, status_text = _status_info(record, texts=texts)
created = format_datetime(record.created_at)
age = format_time_ago(record.created_at, language)
identifier = (
html.escape(str(record.identifier)) if record.identifier else ""
)
display_number = html.escape(_record_display_number(record))
lines = [
f"{index}. <b>{html.escape(method_name)}</b> — {amount}",
f" {emoji} {status_text}",
f" 🕒 {created} ({age})",
_format_user_line(record.user),
]
if identifier:
lines.append(f" 🆔 ID: <code>{identifier}</code>")
else:
lines.append(f" 🆔 ID: <code>{display_number}</code>")
return lines
def _build_payment_details_text(record: PendingPayment, *, texts, language: str) -> str:
method_name = _method_display(record.method)
emoji, status_text = _status_info(record, texts=texts)
amount = settings.format_price(record.amount_kopeks)
if record.method == PaymentMethod.CRYPTOBOT:
crypto_amount = getattr(record.payment, "amount", None)
crypto_asset = getattr(record.payment, "asset", None)
if crypto_amount and crypto_asset:
amount = f"{crypto_amount} {crypto_asset}"
created = format_datetime(record.created_at)
age = format_time_ago(record.created_at, language)
raw_identifier = record.identifier if record.identifier else record.local_id
identifier = html.escape(str(raw_identifier)) if raw_identifier is not None else ""
lines = [
texts.t("ADMIN_PAYMENT_DETAILS_TITLE", "💳 <b>Payment details</b>"),
"",
f"<b>{html.escape(method_name)}</b>",
f"{emoji} {status_text}",
"",
f"💰 {texts.t('ADMIN_PAYMENT_AMOUNT', 'Amount')}: {amount}",
f"🕒 {texts.t('ADMIN_PAYMENT_CREATED', 'Created')}: {created} ({age})",
f"🆔 ID: <code>{identifier}</code>",
_format_user_line(record.user),
]
if record.expires_at:
expires_at = format_datetime(record.expires_at)
lines.append(f"{texts.t('ADMIN_PAYMENT_EXPIRES', 'Expires')}: {expires_at}")
payment = record.payment
if record.method == PaymentMethod.PAL24:
if getattr(payment, "payment_status", None):
lines.append(
f"💳 {texts.t('ADMIN_PAYMENT_GATEWAY_STATUS', 'Gateway status')}: "
f"{html.escape(str(payment.payment_status))}"
)
if getattr(payment, "payment_method", None):
lines.append(
f"🏦 {texts.t('ADMIN_PAYMENT_GATEWAY_METHOD', 'Method')}: "
f"{html.escape(str(payment.payment_method))}"
)
if getattr(payment, "balance_amount", None):
lines.append(
f"💱 {texts.t('ADMIN_PAYMENT_GATEWAY_AMOUNT', 'Gateway amount')}: "
f"{html.escape(str(payment.balance_amount))}"
)
if getattr(payment, "payer_account", None):
lines.append(
f"👛 {texts.t('ADMIN_PAYMENT_GATEWAY_ACCOUNT', 'Payer account')}: "
f"{html.escape(str(payment.payer_account))}"
)
if record.method == PaymentMethod.MULENPAY:
if getattr(payment, "mulen_payment_id", None):
lines.append(
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
f"{html.escape(str(payment.mulen_payment_id))}"
)
if record.method == PaymentMethod.WATA:
if getattr(payment, "order_id", None):
lines.append(
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
f"{html.escape(str(payment.order_id))}"
)
if getattr(payment, "terminal_public_id", None):
lines.append(
f"🏦 Terminal: {html.escape(str(payment.terminal_public_id))}"
)
if record.method == PaymentMethod.HELEKET:
if getattr(payment, "order_id", None):
lines.append(
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
f"{html.escape(str(payment.order_id))}"
)
if getattr(payment, "payer_amount", None) and getattr(payment, "payer_currency", None):
lines.append(
f"🪙 {texts.t('ADMIN_PAYMENT_PAYER_AMOUNT', 'Paid amount')}: "
f"{html.escape(str(payment.payer_amount))} {html.escape(str(payment.payer_currency))}"
)
if record.method == PaymentMethod.YOOKASSA:
if getattr(payment, "payment_method_type", None):
lines.append(
f"💳 {texts.t('ADMIN_PAYMENT_GATEWAY_METHOD', 'Method')}: "
f"{html.escape(str(payment.payment_method_type))}"
)
if getattr(payment, "confirmation_url", None):
lines.append(texts.t("ADMIN_PAYMENT_HAS_LINK", "🔗 Payment link is available above."))
if record.method == PaymentMethod.CRYPTOBOT:
if getattr(payment, "amount", None) and getattr(payment, "asset", None):
lines.append(
f"🪙 {texts.t('ADMIN_PAYMENT_CRYPTO_AMOUNT', 'Crypto amount')}: "
f"{html.escape(str(payment.amount))} {html.escape(str(payment.asset))}"
)
if getattr(payment, "bot_invoice_url", None) or getattr(payment, "mini_app_invoice_url", None):
lines.append(
texts.t("ADMIN_PAYMENT_HAS_LINK", "🔗 Payment link is available above.")
)
if getattr(payment, "status", None):
lines.append(
f"📊 {texts.t('ADMIN_PAYMENT_GATEWAY_STATUS', 'Gateway status')}: "
f"{html.escape(str(payment.status))}"
)
if record.method == PaymentMethod.TELEGRAM_STARS:
description = getattr(payment, "description", "") or ""
if description:
lines.append(f"📝 {html.escape(description)}")
if getattr(payment, "external_id", None):
lines.append(
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
f"{html.escape(str(payment.external_id))}"
)
if _is_checkable(record):
lines.append("")
lines.append(texts.t("ADMIN_PAYMENT_CHECK_HINT", "️ You can trigger a manual status check."))
return "\n".join(lines)
def _parse_method_and_id(payload: str, *, prefix: str) -> Optional[tuple[PaymentMethod, int]]:
suffix = payload[len(prefix) :]
try:
method_str, identifier = suffix.rsplit("_", 1)
method = PaymentMethod(method_str)
payment_id = int(identifier)
return method, payment_id
except (ValueError, KeyError):
return None
@admin_required
@error_handler
async def show_payments_overview(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
) -> None:
texts = get_texts(db_user.language)
page = 1
if callback.data.startswith("admin_payments_page_"):
try:
page = int(callback.data.split("_")[-1])
except ValueError:
page = 1
records = await list_recent_pending_payments(db)
total = len(records)
total_pages = max(1, math.ceil(total / PAGE_SIZE))
if page < 1:
page = 1
if page > total_pages:
page = total_pages
start_index = (page - 1) * PAGE_SIZE
page_records = records[start_index : start_index + PAGE_SIZE]
header = texts.t("ADMIN_PAYMENTS_TITLE", "💳 <b>Top-up verification</b>")
description = texts.t(
"ADMIN_PAYMENTS_DESCRIPTION",
"Pending invoices created during the last 24 hours.",
)
notice = texts.t(
"ADMIN_PAYMENTS_NOTICE",
"Only invoices younger than 24 hours and waiting for payment can be checked.",
)
lines = [header, "", description]
if page_records:
for idx, record in enumerate(page_records, start=start_index + 1):
lines.extend(_build_record_lines(record, index=idx, texts=texts, language=db_user.language))
lines.append("")
lines.append(notice)
else:
empty_text = texts.t("ADMIN_PAYMENTS_EMPTY", "No pending top-ups in the last 24 hours.")
lines.append("")
lines.append(empty_text)
keyboard = _build_list_keyboard(
page_records,
page=page,
total_pages=total_pages,
language=db_user.language,
)
await callback.message.edit_text(
"\n".join(line for line in lines if line is not None),
parse_mode="HTML",
reply_markup=keyboard,
)
await callback.answer()
async def _render_payment_details(
callback: types.CallbackQuery,
db_user: User,
record: PendingPayment,
) -> None:
texts = get_texts(db_user.language)
text = _build_payment_details_text(record, texts=texts, language=db_user.language)
keyboard = _build_detail_keyboard(record, language=db_user.language)
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
@admin_required
@error_handler
async def show_payment_details(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
) -> None:
parsed = _parse_method_and_id(callback.data, prefix="admin_payment_")
if not parsed:
await callback.answer("❌ Invalid payment reference", show_alert=True)
return
method, payment_id = parsed
record = await get_payment_record(db, method, payment_id)
if not record:
await callback.answer("❌ Платеж не найден", show_alert=True)
return
await _render_payment_details(callback, db_user, record)
await callback.answer()
@admin_required
@error_handler
async def manual_check_payment(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
) -> None:
parsed = _parse_method_and_id(callback.data, prefix="admin_payment_check_")
if not parsed:
await callback.answer("❌ Invalid payment reference", show_alert=True)
return
method, payment_id = parsed
record = await get_payment_record(db, method, payment_id)
texts = get_texts(db_user.language)
if not record:
await callback.answer(texts.t("ADMIN_PAYMENT_NOT_FOUND", "Payment not found."), show_alert=True)
return
if not _is_checkable(record):
await callback.answer(
texts.t("ADMIN_PAYMENT_CHECK_NOT_AVAILABLE", "Manual check is not available for this invoice."),
show_alert=True,
)
return
payment_service = PaymentService(callback.bot)
updated = await run_manual_check(db, method, payment_id, payment_service)
if not updated:
await callback.answer(
texts.t("ADMIN_PAYMENT_CHECK_FAILED", "Failed to refresh the payment status."),
show_alert=True,
)
return
await _render_payment_details(callback, db_user, updated)
if updated.status != record.status or updated.is_paid != record.is_paid:
emoji, status_text = _status_info(updated, texts=texts)
message = texts.t(
"ADMIN_PAYMENT_CHECK_SUCCESS",
"Status updated: {status}",
).format(status=f"{emoji} {status_text}")
else:
message = texts.t(
"ADMIN_PAYMENT_CHECK_NO_CHANGES",
"Status is unchanged after the check.",
)
await callback.answer(message, show_alert=True)
def register_handlers(dp: Dispatcher) -> None:
dp.callback_query.register(manual_check_payment, F.data.startswith("admin_payment_check_"))
dp.callback_query.register(
show_payment_details,
F.data.startswith("admin_payment_") & ~F.data.startswith("admin_payment_check_"),
)
dp.callback_query.register(show_payments_overview, F.data.startswith("admin_payments_page_"))
dp.callback_query.register(show_payments_overview, F.data == "admin_payments")
File diff suppressed because it is too large Load Diff
+22 -10
View File
@@ -96,6 +96,24 @@ TRIAL_ENTRIES: Tuple[SettingEntry, ...] = (
label_en="📱 Device limit",
action="input",
),
SettingEntry(
key="TRIAL_PAYMENT_ENABLED",
section="trial",
label_ru="💳 Платная активация",
label_en="💳 Paid activation",
action="toggle",
description_ru="Если включено — за активацию триала будет списываться указанная сумма.",
description_en="When enabled, the configured amount is charged during trial activation.",
),
SettingEntry(
key="TRIAL_ACTIVATION_PRICE",
section="trial",
label_ru="💰 Стоимость активации",
label_en="💰 Activation price",
action="price",
description_ru="Указывается в копейках. 0 — бесплатная активация.",
description_en="Amount in kopeks. 0 — free activation.",
),
SettingEntry(
key="TRIAL_ADD_REMAINING_DAYS_TO_PAID",
section="trial",
@@ -105,15 +123,6 @@ TRIAL_ENTRIES: Tuple[SettingEntry, ...] = (
description_ru="Если включено — при покупке платной подписки оставшиеся дни триала будут добавлены к сроку.",
description_en="When enabled, remaining trial days are added to paid subscription duration.",
),
SettingEntry(
key="TRIAL_SQUAD_UUID",
section="trial",
label_ru="🆔 Squad UUID",
label_en="🆔 Squad UUID",
action="input",
description_ru="Можно оставить пустым, если не требуется назначать пробные подписки в конкретный Squad.",
description_en="Leave empty if trial subscriptions shouldn't be bound to a specific Squad.",
),
)
@@ -290,11 +299,14 @@ def _format_trial_summary(lang_code: str) -> str:
duration = settings.TRIAL_DURATION_DAYS
traffic = settings.TRIAL_TRAFFIC_LIMIT_GB
devices = settings.TRIAL_DEVICE_LIMIT
price_note = ""
if settings.is_trial_paid_activation_enabled():
price_note = f", 💳 {settings.format_price(settings.get_trial_activation_price())}"
traffic_label = _format_traffic_label(traffic, lang_code, short=True)
devices_label = f"{devices}📱" if lang_code == "ru" else f"{devices}📱"
days_suffix = "д" if lang_code == "ru" else "d"
return f"{duration}{days_suffix}, {traffic_label}, {devices_label}"
return f"{duration}{days_suffix}, {traffic_label}, {devices_label}{price_note}"
def _format_core_summary(lang_code: str) -> str:
+104 -1
View File
@@ -211,6 +211,14 @@ def _format_rubles(amount_kopeks: int) -> str:
return formatted.replace(",", " ")
def _format_priority_line(texts, group: PromoGroup) -> str:
priority = getattr(group, "priority", 0)
return texts.t(
"ADMIN_PROMO_GROUP_PRIORITY_LINE",
"🎯 Приоритет: {priority}",
).format(priority=priority)
def _format_auto_assign_line(texts, group: PromoGroup) -> str:
threshold = getattr(group, "auto_assign_total_spent_kopeks", 0) or 0
@@ -294,6 +302,7 @@ def _build_edit_menu_content(
lines = [header]
lines.extend(_format_discount_lines(texts, group))
lines.append(_format_addon_discounts_line(texts, group))
lines.append(_format_priority_line(texts, group))
lines.append(_format_auto_assign_line(texts, group))
period_lines = _format_period_discounts_lines(texts, group, language)
@@ -318,6 +327,15 @@ def _build_edit_menu_content(
callback_data=f"promo_group_edit_field_{group.id}_name",
)
],
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PROMO_GROUP_EDIT_FIELD_PRIORITY",
"🎯 Приоритет",
),
callback_data=f"promo_group_edit_field_{group.id}_priority",
)
],
[
types.InlineKeyboardButton(
text=texts.t(
@@ -640,6 +658,32 @@ async def process_create_group_name(message: types.Message, state: FSMContext):
return
await state.update_data(new_group_name=name)
await state.set_state(AdminStates.creating_promo_group_priority)
texts = get_texts((await state.get_data()).get("language", "ru"))
await message.answer(
texts.t(
"ADMIN_PROMO_GROUP_CREATE_PRIORITY_PROMPT",
"Введите приоритет группы (0 = базовая, чем больше - тем выше приоритет):",
)
)
async def process_create_group_priority(message: types.Message, state: FSMContext):
texts = get_texts((await state.get_data()).get("language", "ru"))
try:
priority = int(message.text)
if priority < 0:
raise ValueError
except (ValueError, TypeError):
await message.answer(
texts.t(
"ADMIN_PROMO_GROUP_INVALID_PRIORITY",
"❌ Приоритет должен быть неотрицательным целым числом",
)
)
return
await state.update_data(new_group_priority=priority)
await state.set_state(AdminStates.creating_promo_group_traffic_discount)
await _prompt_for_discount(
message,
@@ -772,6 +816,7 @@ async def process_create_group_auto_assign(
group = await create_promo_group(
db,
data["new_group_name"],
priority=data.get("new_group_priority", 0),
traffic_discount_percent=data["new_group_traffic"],
server_discount_percent=data["new_group_servers"],
device_discount_percent=data["new_group_devices"],
@@ -862,6 +907,12 @@ async def prompt_edit_promo_group_field(
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT",
"Введите новое название промогруппы (текущее: {name}):",
).format(name=group.name)
elif field == "priority":
await state.set_state(AdminStates.editing_promo_group_priority)
prompt = texts.t(
"ADMIN_PROMO_GROUP_EDIT_PRIORITY_PROMPT",
"Введите новый приоритет (текущий: {current}):",
).format(current=getattr(group, "priority", 0))
elif field == "traffic":
await state.set_state(AdminStates.editing_promo_group_traffic_discount)
prompt = texts.t(
@@ -935,6 +986,48 @@ async def process_edit_group_name(
)
@admin_required
@error_handler
async def process_edit_group_priority(
message: types.Message,
state: FSMContext,
db_user,
db: AsyncSession,
):
data = await state.get_data()
texts = get_texts(data.get("language", db_user.language))
try:
priority = int(message.text)
if priority < 0:
raise ValueError
except (ValueError, TypeError):
await message.answer(
texts.t(
"ADMIN_PROMO_GROUP_INVALID_PRIORITY",
"❌ Приоритет должен быть неотрицательным целым числом",
)
)
return
group = await get_promo_group_by_id(db, data.get("edit_group_id"))
if not group:
await message.answer("❌ Промогруппа не найдена")
await state.clear()
return
group = await update_promo_group(db, group, priority=priority)
await state.set_state(AdminStates.editing_promo_group_menu)
await _send_edit_menu_after_update(
message,
texts,
group,
data.get("language", db_user.language),
texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name),
)
@admin_required
@error_handler
async def process_edit_group_traffic(
@@ -1158,8 +1251,9 @@ async def show_promo_group_members(
lines = []
for index, user in enumerate(members, start=offset + 1):
username = f"@{user.username}" if user.username else ""
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
lines.append(
f"{index}. {user.full_name} (ID {user.id}, {username}, TG {user.telegram_id})"
f"{index}. {user_link} (ID {user.id}, {username}, TG {user.telegram_id})"
)
body = "\n".join(lines)
@@ -1181,6 +1275,7 @@ async def show_promo_group_members(
await callback.message.edit_text(
f"{title}\n\n{body}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML",
)
await callback.answer()
@@ -1323,6 +1418,10 @@ def register_handlers(dp: Dispatcher):
)
dp.message.register(process_create_group_name, AdminStates.creating_promo_group_name)
dp.message.register(
process_create_group_priority,
AdminStates.creating_promo_group_priority,
)
dp.message.register(
process_create_group_traffic,
AdminStates.creating_promo_group_traffic_discount,
@@ -1345,6 +1444,10 @@ def register_handlers(dp: Dispatcher):
)
dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name)
dp.message.register(
process_edit_group_priority,
AdminStates.editing_promo_group_priority,
)
dp.message.register(
process_edit_group_traffic,
AdminStates.editing_promo_group_traffic_discount,
+83 -48
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import html
import logging
import re
@@ -44,6 +45,7 @@ from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
from app.utils.subscription_utils import get_display_subscription_link
from app.utils.formatters import format_datetime, format_duration
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
logger = logging.getLogger(__name__)
@@ -1895,61 +1897,94 @@ async def _send_offer_to_users(
squad_name: Optional[str],
effect_type: str,
) -> Tuple[int, int]:
from app.database.database import AsyncSessionLocal
sent = 0
failed = 0
for user in users:
try:
offer_record = await upsert_discount_offer(
db,
user_id=user.id,
subscription_id=user.subscription.id if user.subscription else None,
notification_type=f"promo_template_{template.id}",
discount_percent=template.discount_percent,
bonus_amount_kopeks=0,
valid_hours=template.valid_hours,
effect_type=effect_type,
extra_data={
"template_id": template.id,
"offer_type": template.offer_type,
"test_duration_hours": template.test_duration_hours,
"test_squad_uuids": template.test_squad_uuids,
"active_discount_hours": template.active_discount_hours,
},
)
# Ограничение на количество одновременных отправок
semaphore = asyncio.Semaphore(20)
user_texts = get_texts(user.language or db_user.language)
keyboard_rows: List[List[InlineKeyboardButton]] = [
[InlineKeyboardButton(text=template.button_text, callback_data=f"claim_discount_{offer_record.id}")]
]
async def send_single_offer(user):
"""Отправляет одно предложение с семафором ограничения"""
async with semaphore:
try:
# Используем отдельную сессию для изоляции транзакции
async with AsyncSessionLocal() as new_db:
offer_record = await upsert_discount_offer(
new_db,
user_id=user.id,
subscription_id=getattr(user, "subscription", None).id if getattr(user, "subscription", None) else None,
notification_type=f"promo_template_{template.id}",
discount_percent=template.discount_percent,
bonus_amount_kopeks=0,
valid_hours=template.valid_hours,
effect_type=effect_type,
extra_data={
"template_id": template.id,
"offer_type": template.offer_type,
"test_duration_hours": template.test_duration_hours,
"test_squad_uuids": template.test_squad_uuids,
"active_discount_hours": template.active_discount_hours,
},
)
keyboard_rows.append([
InlineKeyboardButton(
text=user_texts.t("PROMO_OFFER_CLOSE", "❌ Закрыть"),
callback_data="promo_offer_close",
)
])
user_texts = get_texts(user.language or db_user.language)
keyboard_rows: List[List[InlineKeyboardButton]] = [
[
build_miniapp_or_callback_button(
text=template.button_text,
callback_data=f"claim_discount_{offer_record.id}",
)
]
]
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
keyboard_rows.append([
InlineKeyboardButton(
text=user_texts.t("PROMO_OFFER_CLOSE", "❌ Закрыть"),
callback_data="promo_offer_close",
)
])
message_text = _render_template_text(
template,
user.language or db_user.language,
server_name=squad_name,
)
await bot.send_message(
chat_id=user.telegram_id,
text=message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
sent += 1
except (TelegramForbiddenError, TelegramBadRequest) as exc:
logger.warning("Не удалось отправить предложение пользователю %s: %s", user.telegram_id, exc)
failed += 1
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Ошибка рассылки промо предложения пользователю %s: %s", user.telegram_id, exc)
failed += 1
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
message_text = _render_template_text(
template,
user.language or db_user.language,
server_name=squad_name,
)
await bot.send_message(
chat_id=user.telegram_id,
text=message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
return True
except (TelegramForbiddenError, TelegramBadRequest) as exc:
logger.warning("Не удалось отправить предложение пользователю %s: %s", user.telegram_id, exc)
return False
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Ошибка рассылки промо предложения пользователю %s: %s", user.telegram_id, exc)
return False
# Отправляем предложения пакетами для эффективности
batch_size = 100
for i in range(0, len(users), batch_size):
batch = users[i:i + batch_size]
tasks = [send_single_offer(user) for user in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, bool): # Успешно или неуспешно
if result:
sent += 1
else:
failed += 1
elif isinstance(result, Exception): # Ошибка выполнения задачи
failed += 1
# Небольшая задержка между пакетами для снижения нагрузки на API
await asyncio.sleep(0.1)
return sent, failed
+141 -42
View File
@@ -15,8 +15,9 @@ from app.localization.texts import get_texts
from app.database.crud.promocode import (
get_promocodes_list, get_promocodes_count, create_promocode,
get_promocode_statistics, get_promocode_by_code, update_promocode,
delete_promocode
delete_promocode, get_promocode_by_id
)
from app.database.crud.promo_group import get_promo_group_by_id, get_promo_groups_with_counts
from app.utils.decorators import admin_required, error_handler
from app.utils.formatters import format_datetime
@@ -81,16 +82,24 @@ async def show_promocodes_list(
for promo in promocodes:
status_emoji = "" if promo.is_active else ""
type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫")
type_emoji = {
"balance": "💰",
"subscription_days": "📅",
"trial_subscription": "🎁",
"promo_group": "🏷️"
}.get(promo.type, "🎫")
text += f"{status_emoji} {type_emoji} <code>{promo.code}</code>\n"
text += f"📊 Использований: {promo.current_uses}/{promo.max_uses}\n"
if promo.type == PromoCodeType.BALANCE.value:
text += f"💰 Бонус: {settings.format_price(promo.balance_bonus_kopeks)}\n"
elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value:
text += f"📅 Дней: {promo.subscription_days}\n"
elif promo.type == PromoCodeType.PROMO_GROUP.value:
if promo.promo_group:
text += f"🏷️ Промогруппа: {promo.promo_group.name}\n"
if promo.valid_until:
text += f"⏰ До: {format_datetime(promo.valid_until)}\n"
@@ -129,15 +138,20 @@ async def show_promocode_management(
db: AsyncSession
):
promo_id = int(callback.data.split('_')[-1])
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
status_emoji = "" if promo.is_active else ""
type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫")
type_emoji = {
"balance": "💰",
"subscription_days": "📅",
"trial_subscription": "🎁",
"promo_group": "🏷️"
}.get(promo.type, "🎫")
text = f"""
🎫 <b>Управление промокодом</b>
@@ -145,12 +159,17 @@ async def show_promocode_management(
{status_emoji} <b>Статус:</b> {'Активен' if promo.is_active else 'Неактивен'}
📊 <b>Использований:</b> {promo.current_uses}/{promo.max_uses}
"""
if promo.type == PromoCodeType.BALANCE.value:
text += f"💰 <b>Бонус:</b> {settings.format_price(promo.balance_bonus_kopeks)}\n"
elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value:
text += f"📅 <b>Дней:</b> {promo.subscription_days}\n"
elif promo.type == PromoCodeType.PROMO_GROUP.value:
if promo.promo_group:
text += f"🏷️ <b>Промогруппа:</b> {promo.promo_group.name} (приоритет: {promo.promo_group.priority})\n"
elif promo.promo_group_id:
text += f"🏷️ <b>Промогруппа ID:</b> {promo.promo_group_id} (не найдена)\n"
if promo.valid_until:
text += f"⏰ <b>Действует до:</b> {format_datetime(promo.valid_until)}\n"
@@ -200,8 +219,8 @@ async def show_promocode_edit_menu(
except (ValueError, IndexError):
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
return
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
@@ -445,13 +464,14 @@ async def select_promocode_type(
state: FSMContext
):
promo_type = callback.data.split('_')[-1]
type_names = {
"balance": "💰 Пополнение баланса",
"days": "📅 Дни подписки",
"trial": "🎁 Тестовая подписка"
"days": "📅 Дни подписки",
"trial": "🎁 Тестовая подписка",
"group": "🏷️ Промогруппа"
}
await state.update_data(promocode_type=promo_type)
await callback.message.edit_text(
@@ -509,6 +529,77 @@ async def process_promocode_code(
f"Введите количество дней тестовой подписки:"
)
await state.set_state(AdminStates.setting_promocode_value)
elif promo_type == "group":
# Show promo group selection
groups_with_counts = await get_promo_groups_with_counts(db, limit=50)
if not groups_with_counts:
await message.answer(
"❌ Промогруппы не найдены. Создайте хотя бы одну промогруппу.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")]
])
)
await state.clear()
return
keyboard = []
text = f"🏷️ <b>Промокод:</b> <code>{code}</code>\n\nВыберите промогруппу для назначения:\n\n"
for promo_group, user_count in groups_with_counts:
text += f"{promo_group.name} (приоритет: {promo_group.priority}, пользователей: {user_count})\n"
keyboard.append([
types.InlineKeyboardButton(
text=f"{promo_group.name} (↑{promo_group.priority})",
callback_data=f"promo_select_group_{promo_group.id}"
)
])
keyboard.append([
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_promocodes")
])
await message.answer(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await state.set_state(AdminStates.selecting_promo_group)
@admin_required
@error_handler
async def process_promo_group_selection(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession
):
"""Handle promo group selection for promocode"""
try:
promo_group_id = int(callback.data.split('_')[-1])
except (ValueError, IndexError):
await callback.answer("❌ Ошибка получения ID промогруппы", show_alert=True)
return
promo_group = await get_promo_group_by_id(db, promo_group_id)
if not promo_group:
await callback.answer("❌ Промогруппа не найдена", show_alert=True)
return
await state.update_data(
promo_group_id=promo_group_id,
promo_group_name=promo_group.name
)
await callback.message.edit_text(
f"🏷️ <b>Промокод для промогруппы</b>\n\n"
f"Промогруппа: {promo_group.name}\n"
f"Приоритет: {promo_group.priority}\n\n"
f"📊 Введите количество использований промокода (или 0 для безлимита):"
)
await state.set_state(AdminStates.setting_promocode_uses)
await callback.answer()
@admin_required
@@ -557,8 +648,8 @@ async def handle_edit_value(
data = await state.get_data()
promo_id = data.get('editing_promo_id')
edit_action = data.get('edit_action')
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await message.answer("❌ Промокод не найден")
await state.clear()
@@ -643,8 +734,8 @@ async def handle_edit_uses(
):
data = await state.get_data()
promo_id = data.get('editing_promo_id')
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await message.answer("❌ Промокод не найден")
await state.clear()
@@ -708,17 +799,20 @@ async def process_promocode_expiry(
promo_type = data.get('promocode_type')
value = data.get('promocode_value', 0)
max_uses = data.get('promocode_max_uses', 1)
promo_group_id = data.get('promo_group_id')
promo_group_name = data.get('promo_group_name')
valid_until = None
if expiry_days > 0:
valid_until = datetime.utcnow() + timedelta(days=expiry_days)
type_map = {
"balance": PromoCodeType.BALANCE,
"days": PromoCodeType.SUBSCRIPTION_DAYS,
"trial": PromoCodeType.TRIAL_SUBSCRIPTION
"trial": PromoCodeType.TRIAL_SUBSCRIPTION,
"group": PromoCodeType.PROMO_GROUP
}
promocode = await create_promocode(
db=db,
code=code,
@@ -727,27 +821,31 @@ async def process_promocode_expiry(
subscription_days=value if promo_type in ["days", "trial"] else 0,
max_uses=max_uses,
valid_until=valid_until,
created_by=db_user.id
created_by=db_user.id,
promo_group_id=promo_group_id if promo_type == "group" else None
)
type_names = {
"balance": "Пополнение баланса",
"days": "Дни подписки",
"trial": "Тестовая подписка"
"balance": "Пополнение баланса",
"days": "Дни подписки",
"trial": "Тестовая подписка",
"group": "Промогруппа"
}
summary_text = f"""
<b>Промокод создан!</b>
🎫 <b>Код:</b> <code>{promocode.code}</code>
📝 <b>Тип:</b> {type_names.get(promo_type)}
"""
if promo_type == "balance":
summary_text += f"💰 <b>Сумма:</b> {settings.format_price(promocode.balance_bonus_kopeks)}\n"
elif promo_type in ["days", "trial"]:
summary_text += f"📅 <b>Дней:</b> {promocode.subscription_days}\n"
elif promo_type == "group" and promo_group_name:
summary_text += f"🏷️ <b>Промогруппа:</b> {promo_group_name}\n"
summary_text += f"📊 <b>Использований:</b> {promocode.max_uses}\n"
if promocode.valid_until:
@@ -775,8 +873,8 @@ async def handle_edit_expiry(
):
data = await state.get_data()
promo_id = data.get('editing_promo_id')
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await message.answer("❌ Промокод не найден")
await state.clear()
@@ -822,8 +920,8 @@ async def toggle_promocode_status(
db: AsyncSession
):
promo_id = int(callback.data.split('_')[-1])
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
@@ -849,8 +947,8 @@ async def confirm_delete_promocode(
except (ValueError, IndexError):
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
return
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
@@ -897,8 +995,8 @@ async def delete_promocode_confirmed(
except (ValueError, IndexError):
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
return
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
@@ -921,8 +1019,8 @@ async def show_promocode_stats(
db: AsyncSession
):
promo_id = int(callback.data.split('_')[-1])
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
@@ -1007,6 +1105,7 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_promocodes_list, F.data == "admin_promo_list")
dp.callback_query.register(start_promocode_creation, F.data == "admin_promo_create")
dp.callback_query.register(select_promocode_type, F.data.startswith("promo_type_"))
dp.callback_query.register(process_promo_group_selection, F.data.startswith("promo_select_group_"))
dp.callback_query.register(show_promocode_management, F.data.startswith("promo_manage_"))
dp.callback_query.register(toggle_promocode_status, F.data.startswith("promo_toggle_"))
-3
View File
@@ -68,7 +68,6 @@ 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.get_referred_user_reward_kopeks())}
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
- Уведомления: {'✅ Включены' if settings.REFERRAL_NOTIFICATIONS_ENABLED else '❌ Отключены'}
@@ -105,7 +104,6 @@ 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.get_referred_user_reward_kopeks())}
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
<i>🕐 Время: {current_time}</i>
@@ -190,7 +188,6 @@ 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.get_referred_user_reward_kopeks())}
<b>Комиссионные:</b>
Процент с каждой покупки реферала: {settings.REFERRAL_COMMISSION_PERCENT}%
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -421,7 +421,8 @@ async def show_server_users(
lines = []
for index, user in enumerate(page_users, start=start_index + 1):
safe_user_name = html.escape(user.full_name)
lines.append(f"{index}. {safe_user_name}")
user_link = f'<a href="tg://user?id={user.telegram_id}">{safe_user_name}</a>'
lines.append(f"{index}. {user_link}")
text += "\n" + "\n".join(lines)
else:
+154 -38
View File
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, and_
from datetime import datetime, timedelta
import time
import html
from app.database.models import User, Ticket, TicketStatus
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
@@ -174,12 +175,24 @@ async def view_admin_ticket(
user_name = ticket.user.full_name if ticket.user else "Unknown"
telegram_id_display = ticket.user.telegram_id if ticket.user else ""
username_display = (ticket.user.username or "отсутствует") if ticket.user else "отсутствует"
username_value = ticket.user.username if ticket.user else None
ticket_text = f"🎫 Тикет #{ticket.id}\n\n"
ticket_text += f"👤 Пользователь: {user_name}\n"
ticket_text += f"🆔 Telegram ID: <code>{telegram_id_display}</code>\n"
ticket_text += f"📱 Username: @{username_display}\n"
if username_value:
safe_username = html.escape(username_value)
ticket_text += f"📱 Username: @{safe_username}\n"
ticket_text += (
f"🔗 ЛС: <a href=\"tg://resolve?domain={safe_username}\">"
f"tg://resolve?domain={safe_username}</a>\n"
)
else:
ticket_text += "📱 Username: отсутствует\n"
if ticket.user and ticket.user.telegram_id:
chat_link = f"tg://user?id={int(ticket.user.telegram_id)}"
ticket_text += f"🔗 Чат по ID: <a href=\"{chat_link}\">{chat_link}</a>\n"
ticket_text += "\n"
ticket_text += f"📝 Заголовок: {ticket.title}\n"
ticket_text += f"📊 Статус: {ticket.status_emoji} {status_text}\n"
ticket_text += f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n"
@@ -221,15 +234,11 @@ async def view_admin_ticket(
pass
# Кнопки ЛС и профиль
try:
if ticket.user and ticket.user.telegram_id:
if ticket.user and ticket.user.telegram_id and ticket.user.username:
safe_username = html.escape(ticket.user.username)
buttons_row = []
# DM: при наличии username используем tg://resolve, иначе fallback по ID
if ticket.user.username:
pm_url = f"tg://resolve?domain={ticket.user.username}"
else:
pm_url = f"tg://user?id={ticket.user.telegram_id}"
pm_url = f"tg://resolve?domain={safe_username}"
buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url))
# Профиль: по ID
profile_url = f"tg://user?id={ticket.user.telegram_id}"
buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url))
if buttons_row:
@@ -371,7 +380,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(
@@ -461,6 +470,81 @@ async def mark_ticket_as_answered(
)
async def close_all_open_admin_tickets(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Закрыть все открытые тикеты."""
if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)):
texts = get_texts(db_user.language)
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
return
texts = get_texts(db_user.language)
try:
closed_ticket_ids = await TicketCRUD.close_all_open_tickets(db)
except Exception as error:
logger.error("Error closing all open tickets: %s", error)
await callback.answer(
texts.t("TICKET_UPDATE_ERROR", "❌ Ошибка при обновлении тикета."),
show_alert=True
)
return
closed_count = len(closed_ticket_ids)
if closed_count == 0:
await callback.answer(
texts.t("ADMIN_CLOSE_ALL_OPEN_TICKETS_EMPTY", "ℹ️ Нет открытых тикетов для закрытия."),
show_alert=True
)
return
try:
is_moderator = (
not settings.is_admin(callback.from_user.id)
and SupportSettingsService.is_moderator(callback.from_user.id)
)
await TicketCRUD.add_support_audit(
db,
actor_user_id=db_user.id if db_user else None,
actor_telegram_id=callback.from_user.id,
is_moderator=is_moderator,
action="close_all_tickets",
ticket_id=None,
target_user_id=None,
details={
"count": closed_count,
"ticket_ids": closed_ticket_ids,
}
)
except Exception as audit_error:
logger.warning("Failed to add support audit for bulk close: %s", audit_error)
# Обновляем список тикетов
await show_admin_tickets(callback, db_user, db)
success_text = texts.t(
"ADMIN_CLOSE_ALL_OPEN_TICKETS_SUCCESS",
"✅ Закрыто открытых тикетов: {count}"
).format(count=closed_count)
notification_keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
)
try:
await callback.message.answer(success_text, reply_markup=notification_keyboard)
except Exception:
# Если не удалось отправить отдельное сообщение, пробуем ответить алертом
try:
await callback.answer(success_text, show_alert=True)
except Exception:
pass
async def close_admin_ticket(
callback: types.CallbackQuery,
db_user: User,
@@ -675,7 +759,21 @@ async def handle_admin_block_duration_input(
ticket_text += f"📝 Заголовок: {updated.title}\n"
ticket_text += f"📊 Статус: {updated.status_emoji} {status_text}\n"
ticket_text += f"📅 Создан: {updated.created_at.strftime('%d.%m.%Y %H:%M')}\n"
ticket_text += f"🔄 Обновлен: {updated.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
ticket_text += f"🔄 Обновлен: {updated.updated_at.strftime('%d.%m.%Y %H:%M')}\n"
if updated.user and updated.user.telegram_id:
ticket_text += f"🆔 Telegram ID: <code>{updated.user.telegram_id}</code>\n"
if updated.user.username:
safe_username = html.escape(updated.user.username)
ticket_text += f"📱 Username: @{safe_username}\n"
ticket_text += (
f"🔗 ЛС: <a href=\"tg://resolve?domain={safe_username}\">"
f"tg://resolve?domain={safe_username}</a>\n"
)
else:
ticket_text += "📱 Username: отсутствует\n"
chat_link = f"tg://user?id={int(updated.user.telegram_id)}"
ticket_text += f"🔗 Чат по ID: <a href=\"{chat_link}\">{chat_link}</a>\n"
ticket_text += "\n"
if updated.is_user_reply_blocked:
if updated.user_reply_block_permanent:
ticket_text += "🚫 Пользователь заблокирован навсегда для ответов в этом тикете\n"
@@ -703,12 +801,10 @@ async def handle_admin_block_duration_input(
pass
# Кнопки ЛС и профиль при обновлении карточки
try:
if updated.user and updated.user.telegram_id:
if updated.user and updated.user.telegram_id and updated.user.username:
safe_username = html.escape(updated.user.username)
buttons_row = []
if updated.user.username:
pm_url = f"tg://resolve?domain={updated.user.username}"
else:
pm_url = f"tg://user?id={updated.user.telegram_id}"
pm_url = f"tg://resolve?domain={safe_username}"
buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url))
profile_url = f"tg://user?id={updated.user.telegram_id}"
buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url))
@@ -859,52 +955,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}")
@@ -916,7 +1031,8 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets")
dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets_scope_open")
dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets_scope_closed")
dp.callback_query.register(close_all_open_admin_tickets, F.data == "admin_tickets_close_all_open")
dp.callback_query.register(view_admin_ticket, F.data.startswith("admin_view_ticket_"))
# Ответы на тикеты
File diff suppressed because it is too large Load Diff
+93
View File
@@ -1,4 +1,5 @@
import logging
import re
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
@@ -18,6 +19,92 @@ from app.database.crud.welcome_text import (
logger = logging.getLogger(__name__)
def validate_html_tags(text: str) -> tuple[bool, str]:
"""
Проверяет HTML-теги в тексте на соответствие требованиям Telegram API.
Args:
text: Текст для проверки
Returns:
Кортеж из (валидно ли, сообщение об ошибке или None)
"""
# Поддерживаемые теги в parse_mode="HTML" для Telegram API
allowed_tags = {
'b', 'strong', # жирный
'i', 'em', # курсив
'u', 'ins', # подчеркнуто
's', 'strike', 'del', # зачеркнуто
'code', # моноширинный для коротких фрагментов
'pre', # моноширинный блок кода
'a' # ссылки
}
# Убираем плейсхолдеры из строки перед проверкой тегов
# Плейсхолдеры имеют формат {ключ}, и не являются тегами
placeholder_pattern = r'\{[^{}]+\}'
clean_text = re.sub(placeholder_pattern, '', text)
# Находим все открывающие и закрывающие теги
tag_pattern = r'<(/?)([a-zA-Z]+)(\s[^>]*)?>'
tags_with_pos = [(m.group(1), m.group(2), m.group(3), m.start(), m.end()) for m in re.finditer(tag_pattern, clean_text)]
for closing, tag, attrs, start_pos, end_pos in tags_with_pos:
tag_lower = tag.lower()
# Проверяем, является ли тег поддерживаемым
if tag_lower not in allowed_tags:
return False, f"Неподдерживаемый HTML-тег: <{tag}>. Используйте только теги: {', '.join(sorted(allowed_tags))}"
# Проверяем атрибуты для тега <a>
if tag_lower == 'a':
if closing:
continue # Для закрывающего тега не нужно проверять атрибуты
if not attrs:
return False, "Тег <a> должен содержать атрибут href, например: <a href='URL'>ссылка</a>"
# Проверяем, что есть атрибут href
if 'href=' not in attrs.lower():
return False, "Тег <a> должен содержать атрибут href, например: <a href='URL'>ссылка</a>"
# Проверяем формат URL
href_match = re.search(r'href\s*=\s*[\'"]([^\'"]+)[\'"]', attrs, re.IGNORECASE)
if href_match:
url = href_match.group(1)
# Проверяем, что URL начинается с поддерживаемой схемы
if not re.match(r'^https?://|^tg://', url, re.IGNORECASE):
return False, f"URL в теге <a> должен начинаться с http://, https:// или tg://. Найдено: {url}"
else:
return False, "Не удалось извлечь URL из атрибута href тега <a>"
# Проверяем парность тегов с использованием стека
stack = []
for closing, tag, attrs, start_pos, end_pos in tags_with_pos:
tag_lower = tag.lower()
if tag_lower not in allowed_tags:
continue
if closing:
# Это закрывающий тег
if not stack:
return False, f"Лишний закрывающий тег: </{tag}>"
last_opening_tag = stack.pop()
if last_opening_tag.lower() != tag_lower:
return False, f"Тег </{tag}> не соответствует открывающему тегу <{last_opening_tag}>"
else:
# Это открывающий тег
stack.append(tag)
# Если остались незакрытые теги
if stack:
unclosed_tags = ", ".join([f"<{tag}>" for tag in stack])
return False, f"Незакрытые теги: {unclosed_tags}"
return True, None
def get_telegram_formatting_info() -> str:
return """
📝 <b>Поддерживаемые теги форматирования:</b>
@@ -202,6 +289,12 @@ async def process_welcome_text_edit(
await message.answer("❌ Текст слишком длинный! Максимум 4000 символов.")
return
# Проверяем HTML-теги на валидность
is_valid, error_msg = validate_html_tags(new_text)
if not is_valid:
await message.answer(f"❌ Ошибка в HTML-разметке:\n\n{error_msg}")
return
success = await set_welcome_text(db, new_text, db_user.id)
if success:
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, db_user)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await state.set_state(BalanceStates.waiting_for_amount)
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)
+258
View File
@@ -0,0 +1,258 @@
import logging
from typing import Optional
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
@error_handler
async def start_heleket_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
) -> None:
texts = get_texts(db_user.language)
if not settings.is_heleket_enabled():
await callback.answer("❌ Оплата через Heleket недоступна", show_alert=True)
return
markup = settings.get_heleket_markup_percent()
markup_text: Optional[str]
if markup > 0:
label = texts.t("PAYMENT_HELEKET_MARKUP_LABEL", "Наценка провайдера")
markup_text = f"{label}: {markup:.0f}%"
elif markup < 0:
label = texts.t("PAYMENT_HELEKET_DISCOUNT_LABEL", "Скидка провайдера")
markup_text = f"{label}: {abs(markup):.0f}%"
else:
markup_text = None
message_lines = [
"🪙 <b>Пополнение через Heleket</b>",
"\n",
"Введите сумму пополнения от 100 до 100,000 ₽:",
"",
"⚡ Мгновенное зачисление", "🔒 Безопасная оплата",
]
if markup_text:
message_lines.extend(["", markup_text])
keyboard = get_back_keyboard(db_user.language)
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_buttons:
keyboard.inline_keyboard = quick_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
"\n".join(filter(None, message_lines)),
reply_markup=keyboard,
parse_mode="HTML",
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="heleket")
await callback.answer()
@error_handler
async def process_heleket_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
) -> None:
texts = get_texts(db_user.language)
if not settings.is_heleket_enabled():
await message.answer("❌ Оплата через Heleket недоступна")
return
amount_rubles = amount_kopeks / 100
if amount_rubles < 100:
await message.answer("Минимальная сумма пополнения: 100 ₽")
return
if amount_rubles > 100000:
await message.answer("Максимальная сумма пополнения: 100,000 ₽")
return
payment_service = PaymentService(message.bot)
result = await payment_service.create_heleket_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=f"Пополнение баланса на {amount_rubles:.0f}",
language=db_user.language,
)
if not result:
await message.answer(
"❌ Не удалось создать счёт в Heleket. Попробуйте позже или обратитесь в поддержку."
)
await state.clear()
return
payment_url = result.get("payment_url")
if not payment_url:
await message.answer("❌ Не удалось получить ссылку для оплаты Heleket")
await state.clear()
return
payer_amount = result.get("payer_amount")
payer_currency = result.get("payer_currency")
exchange_rate = result.get("exchange_rate")
discount_percent = result.get("discount_percent")
details = [
"🪙 <b>Оплата через Heleket</b>",
"",
f"💰 Сумма к зачислению: {amount_rubles:.0f}",
]
if payer_amount and payer_currency:
details.append(f"🪙 К оплате: {payer_amount} {payer_currency}")
markup_percent: Optional[float] = None
if discount_percent is not None:
try:
discount_int = int(discount_percent)
markup_percent = -discount_int
except (TypeError, ValueError):
markup_percent = None
if markup_percent:
label_markup = texts.t("PAYMENT_HELEKET_MARKUP_LABEL", "Наценка провайдера")
label_discount = texts.t("PAYMENT_HELEKET_DISCOUNT_LABEL", "Скидка провайдера")
absolute = abs(markup_percent)
if markup_percent > 0:
details.append(f"📈 {label_markup}: +{absolute}%")
else:
details.append(f"📉 {label_discount}: {absolute}%")
if payer_amount and payer_currency:
try:
payer_amount_float = float(payer_amount)
if payer_amount_float > 0:
rub_per_currency = amount_rubles / payer_amount_float
details.append(
f"💱 Курс: 1 {payer_currency}{rub_per_currency:.2f}"
)
except (TypeError, ValueError, ZeroDivisionError):
pass
details.extend(
[
"",
"📱 Инструкция:",
"1. Нажмите кнопку 'Оплатить'",
"2. Перейдите на страницу Heleket",
"3. Оплатите указанную сумму",
"4. Баланс пополнится автоматически",
]
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text=texts.t("PAY_WITH_COINS_BUTTON", "🪙 Оплатить"), url=payment_url)],
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
callback_data=f"check_heleket_{result['local_payment_id']}"
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
])
await message.answer("\n".join(details), parse_mode="HTML", reply_markup=keyboard)
await state.clear()
@error_handler
async def check_heleket_payment_status(
callback: types.CallbackQuery,
db: AsyncSession,
) -> None:
try:
local_payment_id = int(callback.data.split("_")[-1])
except (ValueError, IndexError):
await callback.answer("Некорректный идентификатор платежа", show_alert=True)
return
from app.database.crud.heleket import get_heleket_payment_by_id
payment = await get_heleket_payment_by_id(db, local_payment_id)
if not payment:
await callback.answer("Платёж не найден", show_alert=True)
return
language = getattr(payment.user, "language", None) or settings.DEFAULT_LANGUAGE
texts = get_texts(language)
if payment.is_paid:
message = texts.t("HELEKET_PAYMENT_ALREADY_PAID", "✅ Платёж уже зачислен")
await callback.answer(message, show_alert=True)
return
payment_service = PaymentService(callback.bot)
updated_payment = await payment_service.sync_heleket_payment_status(
db,
local_payment_id=local_payment_id,
)
if updated_payment:
payment = updated_payment
if payment.is_paid:
message = texts.t("HELEKET_PAYMENT_SUCCESS", "✅ Платёж зачислен на баланс")
await callback.answer(message, show_alert=True)
return
status_normalized = (payment.status or "").lower()
status_messages = {
"check": texts.t("HELEKET_STATUS_CHECK", "⏳ Ожидание оплаты"),
"process": texts.t("HELEKET_STATUS_PROCESS", "⚙️ Платёж обрабатывается"),
"confirm_check": texts.t("HELEKET_STATUS_CONFIRM_CHECK", "⛓ Ожидание подтверждений сети"),
"wrong_amount": texts.t("HELEKET_STATUS_WRONG_AMOUNT", "❗️ Оплачена неверная сумма"),
"wrong_amount_waiting": texts.t(
"HELEKET_STATUS_WRONG_AMOUNT_WAITING",
"❗️ Недостаточная сумма, ожидаем доплату",
),
"paid_over": texts.t("HELEKET_STATUS_PAID_OVER", "✅ Платёж зачислен (с переплатой)"),
"paid": texts.t("HELEKET_STATUS_PAID", "✅ Платёж зачислен"),
"cancel": texts.t("HELEKET_STATUS_CANCEL", "🚫 Платёж отменён"),
"fail": texts.t("HELEKET_STATUS_FAIL", "❌ Ошибка при оплате"),
"system_fail": texts.t("HELEKET_STATUS_SYSTEM_FAIL", "❌ Системная ошибка Heleket"),
"refund_process": texts.t("HELEKET_STATUS_REFUND_PROCESS", "↩️ Возврат обрабатывается"),
"refund_fail": texts.t("HELEKET_STATUS_REFUND_FAIL", "⚠️ Ошибка возврата"),
"refund_paid": texts.t("HELEKET_STATUS_REFUND_PAID", "✅ Возврат выполнен"),
"locked": texts.t("HELEKET_STATUS_LOCKED", "🔒 Средства заблокированы"),
}
message = status_messages.get(status_normalized)
if message is None:
template = texts.t("HELEKET_STATUS_UNKNOWN", "️ Статус платежа: {status}")
status_value = payment.status or status_normalized or ""
try:
message = template.format(status=status_value)
except Exception: # pragma: no cover - defensive formatting
message = f"️ Статус платежа: {status_value}"
await callback.answer(message, show_alert=True)
+965
View File
@@ -0,0 +1,965 @@
import html
import logging
from aiogram import Dispatcher, types, F
from aiogram.exceptions import TelegramBadRequest
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.utils.price_display import calculate_user_price, format_price_button
from app.utils.pricing_utils import format_period_description
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, user: User) -> list:
"""
Generate quick amount buttons with user-specific pricing and discounts.
Args:
language: User's language for formatting
user: User object to calculate personalized discounts
Returns:
List of button rows for inline keyboard
"""
if not settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED or settings.DISABLE_TOPUP_BUTTONS:
return []
from app.localization.texts import get_texts
texts = get_texts(language)
buttons = []
periods = settings.get_available_subscription_periods()
periods = periods[:6] # Limit to 6 periods
for period in periods:
price_attr = f"PRICE_{period}_DAYS"
if hasattr(settings, price_attr):
base_price_kopeks = getattr(settings, price_attr)
# Calculate price with user's promo group discount using unified system
price_info = calculate_user_price(user, base_price_kopeks, period, "period")
callback_data = f"quick_amount_{price_info.final_price}"
# Format button text with discount display
period_label = f"{period} дней"
# For balance buttons, use simpler format without emoji and period label prefix
if price_info.has_discount:
button_text = (
f"{texts.format_price(price_info.base_price)}"
f"{texts.format_price(price_info.final_price)} "
f"(-{price_info.discount_percent}%) • {period_label}"
)
else:
button_text = f"{texts.format_price(price_info.final_price)}{period_label}"
buttons.append(
types.InlineKeyboardButton(
text=button_text,
callback_data=callback_data
)
)
keyboard_rows = []
for i in range(0, len(buttons), 2):
keyboard_rows.append(buttons[i:i + 2])
return keyboard_rows
@error_handler
async def show_balance_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
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,
db: AsyncSession,
state: FSMContext
):
from app.utils.payment_utils import get_payment_methods_text
from app.database.crud.subscription import get_subscription_by_user_id
from app.utils.pricing_utils import calculate_months_from_days, apply_percentage_discount
from app.config import settings
from app.services.subscription_service import SubscriptionService
texts = get_texts(db_user.language)
payment_text = get_payment_methods_text(db_user.language)
# Добавляем информацию о текущем тарифе пользователя
subscription = await get_subscription_by_user_id(db, db_user.id)
tariff_info = ""
if subscription and not subscription.is_trial:
# Рассчитываем приблизительную стоимость продления на 30 дней
duration_days = 30 # Берем для примера 30 дней
current_traffic = subscription.traffic_limit_gb
current_connected_squads = subscription.connected_squads or []
current_device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
try:
# Получаем цены для текущих параметров
from app.config import PERIOD_PRICES
base_price_original = PERIOD_PRICES.get(duration_days, 0)
period_discount_percent = db_user.get_promo_discount("period", duration_days)
base_price, base_discount_total = apply_percentage_discount(
base_price_original,
period_discount_percent,
)
# Рассчитываем стоимость серверов
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
servers_price_per_month, per_server_monthly_prices = await subscription_service.get_countries_price_by_uuids(
current_connected_squads,
db,
promo_group_id=db_user.promo_group_id,
)
servers_discount_percent = db_user.get_promo_discount("servers", duration_days)
total_servers_price = 0
for server_price in per_server_monthly_prices:
discounted_per_month, discount_per_month = apply_percentage_discount(
server_price,
servers_discount_percent,
)
total_servers_price += discounted_per_month
# Рассчитываем стоимость трафика
traffic_price_per_month = settings.get_traffic_price(current_traffic)
traffic_discount_percent = db_user.get_promo_discount("traffic", duration_days)
traffic_discounted_per_month, traffic_discount_per_month = apply_percentage_discount(
traffic_price_per_month,
traffic_discount_percent,
)
# Рассчитываем стоимость устройств
additional_devices = max(0, (current_device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_discount_percent = db_user.get_promo_discount("devices", duration_days)
devices_discounted_per_month, devices_discount_per_month = apply_percentage_discount(
devices_price_per_month,
devices_discount_percent,
)
# Общая стоимость
months_in_period = calculate_months_from_days(duration_days)
total_price = (
base_price +
total_servers_price * months_in_period +
traffic_discounted_per_month * months_in_period +
devices_discounted_per_month * months_in_period
)
traffic_value = current_traffic or 0
if traffic_value <= 0:
traffic_display = texts.t("TRAFFIC_UNLIMITED_SHORT", "Безлимит")
else:
traffic_display = texts.format_traffic(traffic_value)
current_tariff_desc = (
f"📱 Подписка: {len(current_connected_squads)} серверов, "
f"{traffic_display}, {current_device_limit} устр."
)
estimated_price_info = f"💰 Стоимость продления (примерно): {texts.format_price(total_price)} за {duration_days} дней"
tariff_info = f"\n\n📋 <b>Ваш текущий тариф:</b>\n{current_tariff_desc}\n{estimated_price_info}"
except Exception as e:
logger.warning(f"Не удалось рассчитать стоимость текущей подписки для пользователя {db_user.id}: {e}")
tariff_info = ""
full_text = payment_text + tariff_info
keyboard = get_payment_methods_keyboard(0, db_user.language)
try:
await callback.message.edit_text(
full_text,
reply_markup=keyboard,
parse_mode="HTML"
)
except TelegramBadRequest:
try:
await callback.message.edit_caption(
full_text,
reply_markup=keyboard,
parse_mode="HTML"
)
except TelegramBadRequest:
try:
await callback.message.delete()
except TelegramBadRequest:
pass
await callback.message.answer(
full_text,
reply_markup=keyboard,
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"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса.\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 == "platega":
from app.database.database import AsyncSessionLocal
from .platega import process_platega_payment_amount
async with AsyncSessionLocal() as db:
await process_platega_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)
elif payment_method == "heleket":
from app.database.database import AsyncSessionLocal
from .heleket import process_heleket_payment_amount
async with AsyncSessionLocal() as db:
await process_heleket_payment_amount(message, db_user, db, amount_kopeks, state)
else:
await message.answer("Неизвестный способ оплаты")
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 == "platega":
from app.database.database import AsyncSessionLocal
from .platega import process_platega_payment_amount
async with AsyncSessionLocal() as db:
await process_platega_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
)
elif payment_method == "cryptobot":
from app.database.database import AsyncSessionLocal
from .cryptobot import process_cryptobot_payment_amount
async with AsyncSessionLocal() as db:
await process_cryptobot_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "heleket":
from app.database.database import AsyncSessionLocal
from .heleket import process_heleket_payment_amount
async with AsyncSessionLocal() as db:
await process_heleket_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "stars":
from .stars import process_stars_payment_amount
await process_stars_payment_amount(
callback.message, db_user, amount_kopeks, state
)
else:
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
return
except ValueError:
await callback.answer("❌ Ошибка обработки суммы", show_alert=True)
except Exception as e:
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 == "platega":
from app.database.database import AsyncSessionLocal
from .platega import process_platega_payment_amount
async with AsyncSessionLocal() as db:
await process_platega_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 == "heleket":
from app.database.database import AsyncSessionLocal
from .heleket import process_heleket_payment_amount
async with AsyncSessionLocal() as db:
await process_heleket_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "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 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 .pal24 import handle_pal24_method_selection
dp.callback_query.register(
handle_pal24_method_selection,
F.data.startswith("pal24_method_"),
)
from .platega import start_platega_payment, handle_platega_method_selection
dp.callback_query.register(
start_platega_payment,
F.data == "topup_platega"
)
dp.callback_query.register(
handle_platega_method_selection,
F.data.startswith("platega_method_"),
)
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 .heleket import start_heleket_payment, check_heleket_payment_status
dp.callback_query.register(
start_heleket_payment,
F.data == "topup_heleket"
)
dp.callback_query.register(
check_heleket_payment_status,
F.data.startswith("check_heleket_")
)
from .mulenpay import check_mulenpay_payment_status
dp.callback_query.register(
check_mulenpay_payment_status,
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_")
)
from .platega import check_platega_payment_status
dp.callback_query.register(
check_platega_payment_status,
F.data.startswith("check_platega_")
)
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|")
)
+255
View File
@@ -0,0 +1,255 @@
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)
mulenpay_name = settings.get_mulenpay_display_name()
mulenpay_name_html = settings.get_mulenpay_display_name_html()
if not settings.is_mulenpay_enabled():
await callback.answer(
f"❌ Оплата через {mulenpay_name} временно недоступна",
show_alert=True,
)
return
message_template = texts.t(
"MULENPAY_TOPUP_PROMPT",
(
"💳 <b>Оплата через {mulenpay_name_html}</b>\n\n"
"Введите сумму для пополнения от 100 до 100 000 ₽.\n"
"Оплата происходит через защищенную платформу {mulenpay_name}."
),
)
message_text = message_template.format(
mulenpay_name=mulenpay_name,
mulenpay_name_html=mulenpay_name_html,
)
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, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
await state.set_state(BalanceStates.waiting_for_amount)
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)
mulenpay_name = settings.get_mulenpay_display_name()
mulenpay_name_html = settings.get_mulenpay_display_name_html()
if not settings.is_mulenpay_enabled():
await message.answer(f"❌ Оплата через {mulenpay_name} временно недоступна")
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",
"❌ Ошибка создания платежа {mulenpay_name}. Попробуйте позже или обратитесь в поддержку.",
).format(mulenpay_name=mulenpay_name)
)
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",
"💳 Оплатить через {mulenpay_name}",
).format(mulenpay_name=mulenpay_name),
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>Оплата через {mulenpay_name_html}</b>\n\n"
"💰 Сумма: {amount}\n"
"🆔 ID платежа: {payment_id}\n\n"
"📱 <b>Инструкция:</b>\n"
"1. Нажмите кнопку 'Оплатить через {mulenpay_name}'\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(),
mulenpay_name=mulenpay_name,
mulenpay_name_html=mulenpay_name_html,
)
await message.answer(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
await state.clear()
logger.info(
"Создан %s платеж для пользователя %s: %s₽, ID: %s",
mulenpay_name,
db_user.telegram_id,
amount_rubles,
payment_id_display,
)
except Exception as e:
logger.error(f"Ошибка создания {mulenpay_name} платежа: {e}")
await message.answer(
texts.t(
"MULENPAY_PAYMENT_ERROR",
"❌ Ошибка создания платежа {mulenpay_name}. Попробуйте позже или обратитесь в поддержку.",
).format(mulenpay_name=mulenpay_name)
)
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, ("", "Неизвестно"))
mulenpay_name = settings.get_mulenpay_display_name()
message_lines = [
f"💳 Статус платежа {mulenpay_name}:\n\n",
f"🆔 ID: {payment.mulen_payment_id or payment.id}\n",
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n",
f"📊 Статус: {emoji} {status_text}\n",
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"Ошибка проверки статуса {settings.get_mulenpay_display_name()}: {e}"
)
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
+596
View File
@@ -0,0 +1,596 @@
import html
import logging
from typing import Any, Optional
from aiogram import types
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.database import AsyncSessionLocal
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.utils.decorators import error_handler
from app.states import BalanceStates
logger = logging.getLogger(__name__)
def _get_available_pal24_methods() -> list[str]:
methods: list[str] = []
if settings.is_pal24_sbp_button_visible():
methods.append("sbp")
if settings.is_pal24_card_button_visible():
methods.append("card")
if not methods:
methods.append("sbp")
return methods
async def _send_pal24_payment_message(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
payment_method: str,
state: FSMContext,
) -> None:
texts = get_texts(db_user.language)
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,
payment_method=payment_method,
)
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, метод: %s",
db_user.telegram_id,
amount_kopeks / 100,
bill_id,
payment_method,
)
except Exception as error:
logger.error(f"Ошибка создания PayPalych платежа: {error}")
await message.answer(
texts.t(
"PAL24_PAYMENT_ERROR",
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
)
)
await state.clear()
@error_handler
async def start_pal24_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_pal24_enabled():
await callback.answer("❌ Оплата через PayPalych временно недоступна", show_alert=True)
return
# Формируем текст сообщения в зависимости от доступных способов оплаты
if settings.is_pal24_sbp_button_visible() and settings.is_pal24_card_button_visible():
payment_methods_text = "СБП и банковской картой"
elif settings.is_pal24_sbp_button_visible():
payment_methods_text = "СБП"
elif settings.is_pal24_card_button_visible():
payment_methods_text = "банковской картой"
else:
# Если обе кнопки отключены, используем общий текст
payment_methods_text = "доступными способами"
message_text = texts.t(
"PAL24_TOPUP_PROMPT",
(
f"🏦 <b>Оплата через PayPalych ({payment_methods_text})</b>\n\n"
"Введите сумму для пополнения от 100 до 1 000 000 ₽.\n"
f"Оплата проходит через PayPalych ({payment_methods_text})."
),
)
keyboard = get_back_keyboard(db_user.language)
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="pal24")
await callback.answer()
@error_handler
async def process_pal24_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_pal24_enabled():
await message.answer("❌ Оплата через PayPalych временно недоступна")
return
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
min_rubles = settings.PAL24_MIN_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Минимальная сумма для оплаты через PayPalych: {min_rubles:.0f}")
return
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
max_rubles = settings.PAL24_MAX_AMOUNT_KOPEKS / 100
await message.answer(
f"❌ Максимальная сумма для оплаты через PayPalych: {max_rubles:,.0f}".replace(',', ' ')
)
return
available_methods = _get_available_pal24_methods()
if len(available_methods) == 1:
await _send_pal24_payment_message(
message,
db_user,
db,
amount_kopeks,
available_methods[0],
state,
)
return
await state.update_data(pal24_amount_kopeks=amount_kopeks)
await state.set_state(BalanceStates.waiting_for_pal24_method)
method_buttons: list[list[types.InlineKeyboardButton]] = []
if "sbp" in available_methods:
method_buttons.append(
[
types.InlineKeyboardButton(
text=settings.get_pal24_sbp_button_text(
texts.t("PAL24_SBP_PAY_BUTTON", "🏦 Оплатить через PayPalych (СБП)")
),
callback_data="pal24_method_sbp",
)
]
)
if "card" in available_methods:
method_buttons.append(
[
types.InlineKeyboardButton(
text=settings.get_pal24_card_button_text(
texts.t("PAL24_CARD_PAY_BUTTON", "💳 Оплатить банковской картой (PayPalych)")
),
callback_data="pal24_method_card",
)
]
)
method_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")])
await message.answer(
texts.t(
"PAL24_SELECT_PAYMENT_METHOD",
"Выберите способ оплаты PayPalych:",
),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=method_buttons),
)
@error_handler
async def handle_pal24_method_selection(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
data = await state.get_data()
amount_kopeks = data.get("pal24_amount_kopeks")
if not amount_kopeks:
texts = get_texts(db_user.language)
await callback.answer(
texts.t(
"PAL24_PAYMENT_ERROR",
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
),
show_alert=True,
)
await state.clear()
return
method = "sbp" if callback.data.endswith("_sbp") else "card"
await callback.answer()
async with AsyncSessionLocal() as db:
await _send_pal24_payment_message(
callback.message,
db_user,
db,
int(amount_kopeks),
method,
state,
)
@error_handler
async def check_pal24_payment_status(
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 = {}
links_info = status_info.get("links") or {}
def _extract_link(source: Any, keys: tuple[str, ...]) -> Optional[str]:
stack: list[Any] = [source]
while stack:
current = stack.pop()
if isinstance(current, dict):
for key in keys:
value = current.get(key)
if value:
return str(value)
stack.extend(current.values())
elif isinstance(current, list):
stack.extend(current)
return None
raw_response = metadata.get("raw_response") if isinstance(metadata, dict) else None
remote_data = status_info.get("remote_data")
transfer_keys = (
"transfer_url",
"transferUrl",
"transfer_link",
"transferLink",
"transfer",
"sbp_url",
"sbpUrl",
"sbp_link",
"sbpLink",
)
card_keys = (
"link_url",
"linkUrl",
"link",
"card_url",
"cardUrl",
"card_link",
"cardLink",
"payment_url",
"paymentUrl",
"url",
)
extra_sbp_link = (
_extract_link(raw_response, transfer_keys)
if raw_response
else None
)
if not extra_sbp_link and remote_data:
extra_sbp_link = _extract_link(remote_data, transfer_keys)
extra_card_link = (
_extract_link(raw_response, card_keys)
if raw_response
else None
)
if not extra_card_link and remote_data:
extra_card_link = _extract_link(remote_data, card_keys)
sbp_link = (
links_info.get("sbp")
or links_meta.get("sbp")
or status_info.get("sbp_url")
or extra_sbp_link
or payment.link_url
)
card_link = (
links_info.get("card")
or links_meta.get("card")
or status_info.get("card_url")
or extra_card_link
)
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')
pay_rows: list[list[types.InlineKeyboardButton]] = []
if not payment.is_paid and payment.status in {"NEW", "PROCESS"}:
default_sbp_text = texts.t(
"PAL24_SBP_PAY_BUTTON",
"🏦 Оплатить через PayPalych (СБП)",
)
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
if sbp_link and settings.is_pal24_sbp_button_visible():
pay_rows.append(
[
types.InlineKeyboardButton(
text=sbp_button_text,
url=sbp_link,
)
]
)
default_card_text = texts.t(
"PAL24_CARD_PAY_BUTTON",
"💳 Оплатить банковской картой (PayPalych)",
)
card_button_text = settings.get_pal24_card_button_text(default_card_text)
if card_link and settings.is_pal24_card_button_visible():
if not pay_rows or pay_rows[-1][0].url != card_link:
pay_rows.append(
[
types.InlineKeyboardButton(
text=card_button_text,
url=card_link,
)
]
)
keyboard_rows = pay_rows + [
[
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)
await callback.answer()
try:
await callback.message.edit_text(
"\n".join(message_lines),
reply_markup=keyboard,
disable_web_page_preview=True,
)
except TelegramBadRequest as error:
if "message is not modified" in str(error).lower():
await callback.answer(texts.t("CHECK_STATUS_NO_CHANGES", "Статус не изменился"))
else:
raise
except Exception as e:
logger.error(f"Ошибка проверки статуса PayPalych: {e}")
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
+333
View File
@@ -0,0 +1,333 @@
"""Handlers for Platega balance interactions."""
import logging
from typing import List
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
def _get_active_methods() -> List[int]:
methods = settings.get_platega_active_methods()
return [code for code in methods if code in {2, 10, 11, 12, 13}]
async def _prompt_amount(
message: types.Message,
db_user: User,
state: FSMContext,
method_code: int,
) -> None:
texts = get_texts(db_user.language)
method_name = settings.get_platega_method_display_title(method_code)
min_amount_label = settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS)
max_amount_kopeks = settings.PLATEGA_MAX_AMOUNT_KOPEKS
max_amount_label = (
settings.format_price(max_amount_kopeks)
if max_amount_kopeks and max_amount_kopeks > 0
else ""
)
default_prompt_body = (
"Введите сумму для пополнения от {min_amount} до {max_amount}.\n"
if max_amount_kopeks and max_amount_kopeks > 0
else "Введите сумму для пополнения от {min_amount}.\n"
)
prompt_template = texts.t(
"PLATEGA_TOPUP_PROMPT",
(
"💳 <b>Оплата через Platega ({method_name})</b>\n\n"
f"{default_prompt_body}"
"Оплата происходит через Platega."
),
)
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, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await message.edit_text(
prompt_template.format(
method_name=method_name,
min_amount=min_amount_label,
max_amount=max_amount_label,
),
reply_markup=keyboard,
parse_mode="HTML",
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="platega", platega_method=method_code)
@error_handler
async def start_platega_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_platega_enabled():
await callback.answer(
texts.t(
"PLATEGA_TEMPORARILY_UNAVAILABLE",
"❌ Оплата через Platega временно недоступна",
),
show_alert=True,
)
return
active_methods = _get_active_methods()
if not active_methods:
await callback.answer(
texts.t(
"PLATEGA_METHODS_NOT_CONFIGURED",
"⚠️ На стороне Platega нет доступных методов оплаты",
),
show_alert=True,
)
return
await state.update_data(payment_method="platega")
if len(active_methods) == 1:
await _prompt_amount(callback.message, db_user, state, active_methods[0])
await callback.answer()
return
method_buttons: list[list[types.InlineKeyboardButton]] = []
for method_code in active_methods:
label = settings.get_platega_method_display_title(method_code)
method_buttons.append(
[
types.InlineKeyboardButton(
text=label,
callback_data=f"platega_method_{method_code}",
)
]
)
method_buttons.append(
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
)
await callback.message.edit_text(
texts.t(
"PLATEGA_SELECT_PAYMENT_METHOD",
"Выберите способ оплаты Platega:",
),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=method_buttons),
)
await state.set_state(BalanceStates.waiting_for_platega_method)
await callback.answer()
@error_handler
async def handle_platega_method_selection(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
try:
method_code = int(callback.data.rsplit("_", 1)[-1])
except ValueError:
await callback.answer("❌ Некорректный способ оплаты", show_alert=True)
return
if method_code not in _get_active_methods():
await callback.answer("⚠️ Этот способ сейчас недоступен", show_alert=True)
return
await _prompt_amount(callback.message, db_user, state, method_code)
await callback.answer()
@error_handler
async def process_platega_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_platega_enabled():
await message.answer(
texts.t(
"PLATEGA_TEMPORARILY_UNAVAILABLE",
"❌ Оплата через Platega временно недоступна",
)
)
return
data = await state.get_data()
method_code = int(data.get("platega_method", 0))
if method_code not in _get_active_methods():
await message.answer(
texts.t(
"PLATEGA_METHOD_SELECTION_REQUIRED",
"⚠️ Выберите способ оплаты Platega перед вводом суммы",
)
)
await state.set_state(BalanceStates.waiting_for_platega_method)
return
if amount_kopeks < settings.PLATEGA_MIN_AMOUNT_KOPEKS:
await message.answer(
texts.t(
"PLATEGA_AMOUNT_TOO_LOW",
"Минимальная сумма для оплаты через Platega: {amount}",
).format(amount=settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS))
)
return
if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS:
await message.answer(
texts.t(
"PLATEGA_AMOUNT_TOO_HIGH",
"Максимальная сумма для оплаты через Platega: {amount}",
).format(amount=settings.format_price(settings.PLATEGA_MAX_AMOUNT_KOPEKS))
)
return
try:
payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_platega_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
language=db_user.language,
payment_method_code=method_code,
)
except Exception as error:
logger.exception("Ошибка создания платежа Platega: %s", error)
payment_result = None
if not payment_result or not payment_result.get("redirect_url"):
await message.answer(
texts.t(
"PLATEGA_PAYMENT_ERROR",
"❌ Ошибка создания платежа Platega. Попробуйте позже или обратитесь в поддержку.",
)
)
await state.clear()
return
redirect_url = payment_result.get("redirect_url")
local_payment_id = payment_result.get("local_payment_id")
transaction_id = payment_result.get("transaction_id")
method_title = settings.get_platega_method_display_title(method_code)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"PLATEGA_PAY_BUTTON",
"💳 Оплатить через {method}",
).format(method=method_title),
url=redirect_url,
)
],
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
callback_data=f"check_platega_{local_payment_id}",
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
]
)
instructions_template = texts.t(
"PLATEGA_PAYMENT_INSTRUCTIONS",
(
"💳 <b>Оплата через Platega ({method})</b>\n\n"
"💰 Сумма: {amount}\n"
"🆔 ID транзакции: {transaction}\n\n"
"📱 <b>Инструкция:</b>\n"
"1. Нажмите кнопку «Оплатить»\n"
"2. Следуйте подсказкам платёжной системы\n"
"3. Подтвердите перевод\n"
"4. Средства зачислятся автоматически\n\n"
"❓ Если возникнут проблемы, обратитесь в {support}"
),
)
await message.answer(
instructions_template.format(
method=method_title,
amount=settings.format_price(amount_kopeks),
transaction=transaction_id or local_payment_id,
support=settings.get_support_contact_display_html(),
),
reply_markup=keyboard,
parse_mode="HTML",
)
await state.clear()
@error_handler
async def check_platega_payment_status(
callback: types.CallbackQuery,
db: AsyncSession,
):
try:
local_payment_id = int(callback.data.split("_")[-1])
except ValueError:
await callback.answer("❌ Некорректный идентификатор платежа", show_alert=True)
return
payment_service = PaymentService(callback.bot)
try:
status_info = await payment_service.get_platega_payment_status(db, local_payment_id)
except Exception as error:
logger.exception("Ошибка проверки статуса Platega: %s", error)
await callback.answer("⚠️ Ошибка проверки статуса", show_alert=True)
return
if not status_info:
await callback.answer("⚠️ Платёж не найден", show_alert=True)
return
payment = status_info.get("payment")
status = status_info.get("status")
is_paid = status_info.get("is_paid")
language = "ru"
user = getattr(payment, "user", None)
if user and getattr(user, "language", None):
language = user.language
texts = get_texts(language)
if is_paid:
await callback.answer(texts.t("PLATEGA_PAYMENT_ALREADY_CONFIRMED", "✅ Платёж уже зачислен"), show_alert=True)
else:
await callback.answer(
texts.t("PLATEGA_PAYMENT_STATUS", "Текущий статус платежа: {status}").format(status=status),
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, db_user)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard
)
await state.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, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
await state.set_state(BalanceStates.waiting_for_amount)
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()
+434
View File
@@ -0,0 +1,434 @@
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, db_user)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await state.set_state(BalanceStates.waiting_for_amount)
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, db_user)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await state.set_state(BalanceStates.waiting_for_amount)
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")
qr_confirmation_data = payment_result.get("qr_confirmation_data")
if not confirmation_url and not qr_confirmation_data:
await message.answer("❌ Ошибка получения данных для оплаты через СБП. Обратитесь в поддержку.")
await state.clear()
return
# Подготовим QR-код для вставки в основное сообщение
qr_photo = None
if qr_confirmation_data:
try:
# Импортируем необходимые модули для генерации QR-кода
import base64
from io import BytesIO
import qrcode
from aiogram.types import BufferedInputFile
# Создаем QR-код из полученных данных
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(qr_confirmation_data)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# Сохраняем изображение в байты
img_bytes = BytesIO()
img.save(img_bytes, format='PNG')
img_bytes.seek(0)
qr_photo = BufferedInputFile(img_bytes.getvalue(), filename="qrcode.png")
except ImportError:
logger.warning("qrcode библиотека не установлена, QR-код не будет сгенерирован")
except Exception as e:
logger.error(f"Ошибка генерации QR-кода: {e}")
# Если нет QR-данных из YooKassa, но есть URL, генерируем QR-код из URL
if not qr_photo and confirmation_url:
try:
# Импортируем необходимые модули для генерации QR-кода
import base64
from io import BytesIO
import qrcode
from aiogram.types import BufferedInputFile
# Создаем QR-код из URL
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(confirmation_url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# Сохраняем изображение в байты
img_bytes = BytesIO()
img.save(img_bytes, format='PNG')
img_bytes.seek(0)
qr_photo = BufferedInputFile(img_bytes.getvalue(), filename="qrcode.png")
except ImportError:
logger.warning("qrcode библиотека не установлена, QR-код не будет сгенерирован")
except Exception as e:
logger.error(f"Ошибка генерации QR-кода из URL: {e}")
# Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса
keyboard_buttons = []
# Добавляем кнопку оплаты, если доступна ссылка
if confirmation_url:
keyboard_buttons.append([types.InlineKeyboardButton(text="🔗 Перейти к оплате", url=confirmation_url)])
else:
# Если ссылка недоступна, предлагаем оплатить через ID платежа в приложении банка
keyboard_buttons.append([types.InlineKeyboardButton(text="📱 Оплатить в приложении банка", callback_data="temp_disabled")])
# Добавляем общие кнопки
keyboard_buttons.append([types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")])
keyboard_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")])
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
# Подготавливаем текст сообщения
message_text = (
f"🔗 <b>Оплата через СБП</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
)
# Добавляем инструкции в зависимости от доступных способов оплаты
if not confirmation_url:
message_text += (
f"📱 <b>Инструкция по оплате:</b>\n"
f"1. Откройте приложение вашего банка\n"
f"2. Найдите функцию оплаты по реквизитам или перевод по СБП\n"
f"3. Введите ID платежа: <code>{payment_result['yookassa_payment_id']}</code>\n"
f"4. Подтвердите платеж в приложении банка\n"
f"5. Деньги поступят на баланс автоматически\n\n"
)
message_text += (
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
f"✅ Принимаем СБП от всех банков-участников\n\n"
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}"
)
# Отправляем сообщение с инструкциями и клавиатурой
# Если есть QR-код, отправляем его как медиа-сообщение
if qr_photo:
# Используем метод отправки медиа-группы или фото с описанием
await message.answer_photo(
photo=qr_photo,
caption=message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
else:
# Если QR-код недоступен, отправляем обычное текстовое сообщение
await message.answer(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
logger.info(f"Создан платеж YooKassa СБП для пользователя {db_user.telegram_id}: "
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
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)
+99 -43
View File
@@ -30,6 +30,7 @@ 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,
@@ -37,6 +38,7 @@ from app.utils.promo_offer import (
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.timezone import format_local_datetime
from app.utils.pricing_utils import format_period_description
logger = logging.getLogger(__name__)
@@ -152,7 +154,7 @@ async def show_main_menu(
db_user.last_activity = datetime.utcnow()
await db.commit()
has_active_subscription = bool(db_user.subscription)
has_active_subscription = bool(db_user.subscription and db_user.subscription.is_active)
subscription_is_active = False
if db_user.subscription:
@@ -162,18 +164,27 @@ 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 = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
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,
@@ -188,14 +199,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,
@@ -864,7 +893,7 @@ async def handle_back_to_menu(
texts = get_texts(db_user.language)
has_active_subscription = db_user.subscription is not None
has_active_subscription = bool(db_user.subscription and db_user.subscription.is_active)
subscription_is_active = False
if db_user.subscription:
@@ -874,18 +903,27 @@ 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 = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
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,
@@ -900,71 +938,84 @@ 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()
def _get_subscription_status(user: User, texts) -> str:
if not user.subscription:
subscription = getattr(user, "subscription", None)
if not subscription:
return texts.t("SUB_STATUS_NONE", "❌ Отсутствует")
subscription = user.subscription
current_time = datetime.utcnow()
if subscription.end_date <= current_time:
actual_status = (subscription.actual_status or "").lower()
end_date = getattr(subscription, "end_date", None)
end_date_text = format_local_datetime(end_date, "%d.%m.%Y") if end_date else None
days_left = 0
if subscription.end_date > current_time:
days_left = (subscription.end_date - current_time).days
if actual_status == "pending":
return texts.t("SUBSCRIPTION_NONE", "❌ Нет активной подписки")
if actual_status == "disabled":
return texts.t("SUB_STATUS_DISABLED", "⚫ Отключена")
if actual_status == "expired":
return texts.t(
"SUB_STATUS_EXPIRED",
"🔴 Истекла\n📅 {end_date}",
).format(end_date=subscription.end_date.strftime('%d.%m.%Y'))
days_left = (subscription.end_date - current_time).days
if subscription.is_trial:
if days_left > 1:
).format(end_date=end_date_text or "")
if actual_status == "trial":
if days_left > 1 and end_date_text:
return texts.t(
"SUB_STATUS_TRIAL_ACTIVE",
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
).format(
end_date=subscription.end_date.strftime('%d.%m.%Y'),
end_date=end_date_text,
days=days_left,
)
elif days_left == 1:
if days_left == 1:
return texts.t(
"SUB_STATUS_TRIAL_TOMORROW",
"🎁 Тестовая подписка\n⚠️ истекает завтра!",
)
else:
return texts.t(
"SUB_STATUS_TRIAL_TODAY",
"🎁 Тестовая подписка\n⚠️ истекает сегодня!",
)
return texts.t(
"SUB_STATUS_TRIAL_TODAY",
"🎁 Тестовая подписка\n⚠️ истекает сегодня!",
)
else:
if days_left > 7:
if actual_status == "active":
if days_left > 7 and end_date_text:
return texts.t(
"SUB_STATUS_ACTIVE_LONG",
"💎 Активна\n📅 до {end_date} ({days} дн.)",
).format(
end_date=subscription.end_date.strftime('%d.%m.%Y'),
end_date=end_date_text,
days=days_left,
)
elif days_left > 1:
if days_left > 1:
return texts.t(
"SUB_STATUS_ACTIVE_FEW_DAYS",
"💎 Активна\n⚠️ истекает через {days} дн.",
).format(days=days_left)
elif days_left == 1:
if days_left == 1:
return texts.t(
"SUB_STATUS_ACTIVE_TOMORROW",
"💎 Активна\n⚠️ истекает завтра!",
)
else:
return texts.t(
"SUB_STATUS_ACTIVE_TODAY",
"💎 Активна\n⚠️ истекает сегодня!",
)
return texts.t(
"SUB_STATUS_ACTIVE_TODAY",
"💎 Активна\n⚠️ истекает сегодня!",
)
return texts.t("SUB_STATUS_UNKNOWN", "❓ Неизвестно")
def _insert_random_message(base_text: str, random_message: str, action_prompt: str) -> str:
@@ -1036,7 +1087,12 @@ 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"
+256
View File
@@ -0,0 +1,256 @@
import asyncio
import logging
from datetime import datetime
from aiogram import Dispatcher, F, types
from aiogram.exceptions import TelegramBadRequest
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.poll import (
get_poll_response_by_id,
record_poll_answer,
)
from app.database.models import PollQuestion, User
from app.localization.texts import get_texts
from app.services.poll_service import get_next_question, get_question_option, reward_user_for_poll
logger = logging.getLogger(__name__)
async def _delete_message_later(bot, chat_id: int, message_id: int, delay: int = 10) -> None:
try:
await asyncio.sleep(delay)
await bot.delete_message(chat_id, message_id)
except Exception as error: # pragma: no cover - cleanup best effort
logger.debug("Не удалось удалить сообщение опроса %s: %s", message_id, error)
async def _render_question_text(
poll_title: str,
question: PollQuestion,
current_index: int,
total: int,
language: str,
) -> str:
texts = get_texts(language)
header = texts.t("POLL_QUESTION_HEADER", "<b>Вопрос {current}/{total}</b>").format(
current=current_index,
total=total,
)
lines = [f"🗳️ <b>{poll_title}</b>", "", header, "", question.text]
return "\n".join(lines)
async def _update_poll_message(
message: types.Message,
text: str,
*,
reply_markup: types.InlineKeyboardMarkup | None = None,
parse_mode: str | None = "HTML",
) -> bool:
try:
await message.edit_text(
text,
reply_markup=reply_markup,
parse_mode=parse_mode,
)
return True
except TelegramBadRequest as error:
error_text = str(error).lower()
if "message is not modified" in error_text:
logger.debug(
"Опросное сообщение уже актуально, пропускаем обновление: %s",
error,
)
return True
logger.warning(
"Не удалось обновить сообщение опроса %s: %s",
message.message_id,
error,
)
except Exception as error: # pragma: no cover - defensive logging
logger.exception(
"Непредвиденная ошибка при обновлении сообщения опроса %s: %s",
message.message_id,
error,
)
return False
def _build_options_keyboard(response_id: int, question: PollQuestion) -> types.InlineKeyboardMarkup:
buttons: list[list[types.InlineKeyboardButton]] = []
for option in sorted(question.options, key=lambda o: o.order):
buttons.append(
[
types.InlineKeyboardButton(
text=option.text,
callback_data=f"poll_answer:{response_id}:{question.id}:{option.id}",
)
]
)
return types.InlineKeyboardMarkup(inline_keyboard=buttons)
async def handle_poll_start(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
try:
response_id = int(callback.data.split(":")[1])
except (IndexError, ValueError):
await callback.answer("❌ Опрос не найден", show_alert=True)
return
response = await get_poll_response_by_id(db, response_id)
if not response or response.user_id != db_user.id:
await callback.answer("❌ Опрос не найден", show_alert=True)
return
texts = get_texts(db_user.language)
if response.completed_at:
await callback.answer(texts.t("POLL_ALREADY_COMPLETED", "Вы уже прошли этот опрос."), show_alert=True)
return
if not response.poll or not response.poll.questions:
await callback.answer(texts.t("POLL_EMPTY", "Опрос пока недоступен."), show_alert=True)
return
if not response.started_at:
response.started_at = datetime.utcnow()
await db.commit()
index, question = await get_next_question(response)
if not question:
await callback.answer(texts.t("POLL_ERROR", "Не удалось загрузить вопросы."), show_alert=True)
return
question_text = await _render_question_text(
response.poll.title,
question,
index,
len(response.poll.questions),
db_user.language,
)
if not await _update_poll_message(
callback.message,
question_text,
reply_markup=_build_options_keyboard(response.id, question),
):
await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True)
return
await callback.answer()
async def handle_poll_answer(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
try:
_, response_id, question_id, option_id = callback.data.split(":", 3)
response_id = int(response_id)
question_id = int(question_id)
option_id = int(option_id)
except (ValueError, IndexError):
await callback.answer("❌ Некорректные данные", show_alert=True)
return
response = await get_poll_response_by_id(db, response_id)
texts = get_texts(db_user.language)
if not response or response.user_id != db_user.id:
await callback.answer("❌ Опрос не найден", show_alert=True)
return
if not response.poll:
await callback.answer(texts.t("POLL_ERROR", "Опрос недоступен."), show_alert=True)
return
if response.completed_at:
await callback.answer(texts.t("POLL_ALREADY_COMPLETED", "Вы уже прошли этот опрос."), show_alert=True)
return
question = next((q for q in response.poll.questions if q.id == question_id), None)
if not question:
await callback.answer(texts.t("POLL_ERROR", "Вопрос не найден."), show_alert=True)
return
option = await get_question_option(question, option_id)
if not option:
await callback.answer(texts.t("POLL_ERROR", "Вариант ответа не найден."), show_alert=True)
return
await record_poll_answer(
db,
response_id=response.id,
question_id=question.id,
option_id=option.id,
)
try:
await db.refresh(response, attribute_names=["answers"])
except Exception as error: # pragma: no cover - defensive cache busting
logger.debug(
"Не удалось обновить локальные ответы опроса %s: %s",
response.id,
error,
)
response = await get_poll_response_by_id(db, response.id)
if not response:
await callback.answer(texts.t("POLL_ERROR", "Опрос недоступен."), show_alert=True)
return
index, next_question = await get_next_question(response)
if next_question:
question_text = await _render_question_text(
response.poll.title,
next_question,
index,
len(response.poll.questions),
db_user.language,
)
if not await _update_poll_message(
callback.message,
question_text,
reply_markup=_build_options_keyboard(response.id, next_question),
):
await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True)
return
await callback.answer()
return
response.completed_at = datetime.utcnow()
await db.commit()
reward_amount = await reward_user_for_poll(db, response)
thanks_lines = [texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!")]
if reward_amount:
thanks_lines.append(
texts.t(
"POLL_REWARD_GRANTED",
"Награда {amount} зачислена на ваш баланс.",
).format(amount=settings.format_price(reward_amount))
)
if not await _update_poll_message(
callback.message,
"\n\n".join(thanks_lines),
):
await callback.answer(texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!"))
return
asyncio.create_task(
_delete_message_later(callback.bot, callback.message.chat.id, callback.message.message_id)
)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(handle_poll_start, F.data.startswith("poll_start:"))
dp.callback_query.register(handle_poll_answer, F.data.startswith("poll_answer:"))
+47 -26
View File
@@ -1,9 +1,8 @@
import logging
from aiogram import Dispatcher, types, F
from aiogram import Dispatcher, types, F, Bot
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.states import PromoCodeStates
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
@@ -32,6 +31,45 @@ async def show_promocode_menu(
await callback.answer()
async def activate_promocode_for_registration(
db: AsyncSession,
user_id: int,
code: str,
bot: Bot = None
) -> dict:
"""
Активирует промокод для пользователя во время регистрации.
Возвращает результат активации без отправки сообщений.
"""
promocode_service = PromoCodeService()
result = await promocode_service.activate_promocode(db, user_id, code)
if result["success"]:
logger.info(f"✅ Пользователь {user_id} активировал промокод {code} при регистрации")
# Отправляем уведомление админу, если бот доступен
if bot:
try:
from app.database.crud.user import get_user_by_id
user = await get_user_by_id(db, user_id)
if user:
notification_service = AdminNotificationService(bot)
await notification_service.send_promocode_activation_notification(
db,
user,
result.get("promocode", {"code": code}),
result["description"],
)
except Exception as notify_error:
logger.error(
"Ошибка отправки админ уведомления об активации промокода %s: %s",
code,
notify_error,
)
return result
@error_handler
async def process_promocode(
message: types.Message,
@@ -40,9 +78,9 @@ async def process_promocode(
db: AsyncSession
):
texts = get_texts(db_user.language)
code = message.text.strip()
if not code:
await message.answer(
texts.t(
@@ -52,31 +90,14 @@ async def process_promocode(
reply_markup=get_back_keyboard(db_user.language)
)
return
promocode_service = PromoCodeService()
result = await promocode_service.activate_promocode(db, db_user.id, code)
result = await activate_promocode_for_registration(db, db_user.id, code, message.bot)
if result["success"]:
await message.answer(
texts.PROMOCODE_SUCCESS.format(description=result["description"]),
reply_markup=get_back_keyboard(db_user.language)
)
logger.info(f"✅ Пользователь {db_user.telegram_id} активировал промокод {code}")
try:
notification_service = AdminNotificationService(message.bot)
await notification_service.send_promocode_activation_notification(
db,
db_user,
result.get("promocode", {"code": code}),
result["description"],
)
except Exception as notify_error:
logger.error(
"Ошибка отправки админ уведомления об активации промокода %s: %s",
code,
notify_error,
)
else:
error_messages = {
"not_found": texts.PROMOCODE_INVALID,
@@ -85,13 +106,13 @@ async def process_promocode(
"already_used_by_user": texts.PROMOCODE_USED,
"server_error": texts.ERROR
}
error_text = error_messages.get(result["error"], texts.PROMOCODE_INVALID)
await message.answer(
error_text,
reply_markup=get_back_keyboard(db_user.language)
)
await state.clear()
File diff suppressed because it is too large Load Diff
+14 -4
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
@@ -18,7 +20,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
try:
logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}")
allowed_prefixes = ("balance_", "admin_stars_test_")
allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_")
if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes):
logger.warning(f"Невалидный payload: {query.invoice_payload}")
@@ -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)
@@ -126,10 +130,14 @@ async def handle_successful_payment(
"⭐ Потрачено звезд: {stars_spent}\n"
"💰 Зачислено на баланс: {amount}\n"
"🆔 ID транзакции: {transaction_id}...\n\n"
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
"Обязательно активируйте подписку отдельно!\n\n"
"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
"подписка будет приобретена автоматически после пополнения баланса.\n\n"
"Спасибо за пополнение! 🚀",
).format(
stars_spent=payment.total_amount,
amount=int(rubles_amount),
amount=amount_text,
transaction_id=transaction_id_short,
),
parse_mode="HTML",
@@ -137,8 +145,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}")
+277 -111
View File
@@ -37,6 +37,7 @@ from app.utils.promo_offer import (
build_promo_offer_hint,
build_test_access_hint,
)
from app.utils.timezone import format_local_datetime
from app.database.crud.user_message import get_random_active_message
from app.database.crud.subscription import decrement_subscription_server_counts
@@ -44,6 +45,17 @@ from app.database.crud.subscription import decrement_subscription_server_counts
logger = logging.getLogger(__name__)
def _calculate_subscription_flags(subscription):
if not subscription:
return False, False
actual_status = getattr(subscription, "actual_status", None)
has_active_subscription = actual_status in {"active", "trial"}
subscription_is_active = bool(getattr(subscription, "is_active", False))
return has_active_subscription, subscription_is_active
async def _apply_campaign_bonus_if_needed(
db: AsyncSession,
user,
@@ -88,15 +100,15 @@ async def handle_potential_referral_code(
db: AsyncSession
):
current_state = await state.get_state()
logger.info(f"🔍 REFERRAL CHECK: Проверка сообщения '{message.text}' в состоянии {current_state}")
logger.info(f"🔍 REFERRAL/PROMO CHECK: Проверка сообщения '{message.text}' в состоянии {current_state}")
if current_state not in [
RegistrationStates.waiting_for_rules_accept.state,
RegistrationStates.waiting_for_referral_code.state,
None
None
]:
return False
user = await get_user_by_telegram_id(db, message.from_user.id)
if user and user.status == UserStatus.ACTIVE.value:
return False
@@ -113,37 +125,73 @@ async def handle_potential_referral_code(
if len(potential_code) < 4 or len(potential_code) > 20:
return False
# Сначала проверяем реферальный код
referrer = await get_user_by_referral_code(db, potential_code)
if not referrer:
await message.answer(texts.t(
"REFERRAL_CODE_INVALID_HELP",
"❌ Неверный реферальный код.\n\n"
"💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n"
"⏭️ Для продолжения регистрации без реферального кода используйте команду /start",
))
if referrer:
data['referral_code'] = potential_code
data['referrer_id'] = referrer.id
await state.set_data(data)
await message.answer(texts.t("REFERRAL_CODE_ACCEPTED", "Реферальный код принят!"))
logger.info(f"✅ Реферальный код {potential_code} применен для пользователя {message.from_user.id}")
if current_state != RegistrationStates.waiting_for_referral_code.state:
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
rules_text = await get_rules(language)
await message.answer(
rules_text,
reply_markup=get_rules_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_rules_accept)
logger.info("📋 Правила отправлены после ввода реферального кода")
else:
await complete_registration(message, state, db)
return True
data['referral_code'] = potential_code
data['referrer_id'] = referrer.id
await state.set_data(data)
# Если реферальный код не найден, проверяем промокод
from app.database.crud.promocode import check_promocode_validity
promocode_check = await check_promocode_validity(db, potential_code)
if promocode_check["valid"]:
# Промокод валиден - сохраняем его в state для активации после создания пользователя
data['promocode'] = potential_code
await state.set_data(data)
await message.answer(texts.t("REFERRAL_CODE_ACCEPTED", "✅ Реферальный код принят!"))
logger.info(f"✅ Реферальный код {potential_code} применен для пользователя {message.from_user.id}")
if current_state != RegistrationStates.waiting_for_referral_code.state:
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
rules_text = await get_rules(language)
await message.answer(
rules_text,
reply_markup=get_rules_keyboard(language)
texts.t(
"PROMOCODE_ACCEPTED_WILL_ACTIVATE",
"✅ Промокод принят! Он будет активирован после завершения регистрации."
)
)
await state.set_state(RegistrationStates.waiting_for_rules_accept)
logger.info("📋 Правила отправлены после ввода реферального кода")
else:
await complete_registration(message, state, db)
logger.info(f"✅ Промокод {potential_code} сохранен для активации для пользователя {message.from_user.id}")
if current_state != RegistrationStates.waiting_for_referral_code.state:
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
rules_text = await get_rules(language)
await message.answer(
rules_text,
reply_markup=get_rules_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_rules_accept)
logger.info("📋 Правила отправлены после принятия промокода")
else:
await complete_registration(message, state, db)
return True
# Ни реферальный код, ни промокод не найдены
await message.answer(texts.t(
"REFERRAL_OR_PROMO_CODE_INVALID_HELP",
"❌ Неверный реферальный код или промокод.\n\n"
"💡 Если у вас есть реферальный код или промокод, убедитесь что он введен правильно.\n"
"⏭️ Для продолжения регистрации без кода используйте команду /start",
))
return True
@@ -222,12 +270,32 @@ 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 {}
had_pending_payload = "pending_start_payload" in data
pending_start_payload = data.pop("pending_start_payload", None)
had_campaign_notification_flag = "campaign_notification_sent" in data
campaign_notification_sent = data.pop("campaign_notification_sent", False)
state_needs_update = had_pending_payload or had_campaign_notification_flag
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 state_needs_update:
await state.set_data(data)
if start_parameter:
campaign = await get_campaign_by_start_parameter(
db,
start_parameter,
@@ -250,7 +318,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id)
if campaign:
if campaign and not campaign_notification_sent:
try:
notification_service = AdminNotificationService(message.bot)
await notification_service.send_campaign_link_visit_notification(
@@ -322,11 +390,9 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
f"Ошибка отправки уведомления о рекламной кампании: {e}"
)
has_active_subscription = user.subscription is not None
subscription_is_active = False
if user.subscription:
subscription_is_active = user.subscription.is_active
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
user.subscription
)
menu_text = await get_main_menu_text(user, texts, db)
@@ -335,12 +401,14 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
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,
)
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,
@@ -660,31 +728,57 @@ async def process_rules_accept(
async def process_referral_code_input(
message: types.Message,
state: FSMContext,
message: types.Message,
state: FSMContext,
db: AsyncSession
):
logger.info(f"🎫 REFERRAL: Обработка реферального кода: {message.text}")
logger.info(f"🎫 REFERRAL/PROMO: Обработка кода: {message.text}")
data = await state.get_data() or {}
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
referral_code = message.text.strip()
code = message.text.strip()
referrer = await get_user_by_referral_code(db, referral_code)
# Сначала проверяем, является ли это реферальным кодом
referrer = await get_user_by_referral_code(db, code)
if referrer:
data['referrer_id'] = referrer.id
await state.set_data(data)
await message.answer(texts.t("REFERRAL_CODE_ACCEPTED", "✅ Реферальный код принят!"))
logger.info(f"✅ Реферальный код применен")
else:
await message.answer(texts.t("REFERRAL_CODE_INVALID", "❌ Неверный реферальный код"))
logger.info(f"❌ Неверный реферальный код")
logger.info(f"✅ Реферальный код применен: {code}")
await complete_registration(message, state, db)
return
await complete_registration(message, state, db)
# Если реферальный код не найден, проверяем промокод
from app.database.crud.promocode import check_promocode_validity
promocode_check = await check_promocode_validity(db, code)
if promocode_check["valid"]:
# Промокод валиден - сохраняем его в state для активации после создания пользователя
data['promocode'] = code
await state.set_data(data)
await message.answer(
texts.t(
"PROMOCODE_ACCEPTED_WILL_ACTIVATE",
"✅ Промокод принят! Он будет активирован после завершения регистрации."
)
)
logger.info(f"✅ Промокод сохранен для активации: {code}")
await complete_registration(message, state, db)
return
# Ни реферальный код, ни промокод не найдены
await message.answer(
texts.t(
"REFERRAL_OR_PROMO_CODE_INVALID",
"❌ Неверный реферальный код или промокод"
)
)
logger.info(f"❌ Неверный код (ни реферальный, ни промокод): {code}")
return
async def process_referral_code_skip(
@@ -743,11 +837,9 @@ async def complete_registration_from_callback(
await db.refresh(existing_user, ['subscription'])
has_active_subscription = existing_user.subscription is not None
subscription_is_active = False
if existing_user.subscription:
subscription_is_active = existing_user.subscription.is_active
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
existing_user.subscription
)
menu_text = await get_main_menu_text(existing_user, texts, db)
@@ -757,12 +849,14 @@ async def complete_registration_from_callback(
and SupportSettingsService.is_moderator(existing_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,
)
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(
@@ -923,11 +1017,9 @@ async def complete_registration_from_callback(
else:
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
has_active_subscription = bool(getattr(user, "subscription", None))
subscription_is_active = False
if getattr(user, "subscription", None):
subscription_is_active = user.subscription.is_active
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
getattr(user, "subscription", None)
)
menu_text = await get_main_menu_text(user, texts, db)
@@ -937,12 +1029,14 @@ async def complete_registration_from_callback(
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,
)
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(
@@ -997,11 +1091,9 @@ async def complete_registration(
await db.refresh(existing_user, ['subscription'])
has_active_subscription = existing_user.subscription is not None
subscription_is_active = False
if existing_user.subscription:
subscription_is_active = existing_user.subscription.is_active
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
existing_user.subscription
)
menu_text = await get_main_menu_text(existing_user, texts, db)
@@ -1011,12 +1103,14 @@ async def complete_registration(
and SupportSettingsService.is_moderator(existing_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,
)
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(
@@ -1125,6 +1219,29 @@ async def complete_registration(
except Exception as e:
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
# Активируем промокод если был сохранен в state
promocode_to_activate = data.get('promocode')
if promocode_to_activate:
try:
from app.handlers.promocode import activate_promocode_for_registration
promocode_result = await activate_promocode_for_registration(
db, user.id, promocode_to_activate, message.bot
)
if promocode_result["success"]:
await message.answer(
texts.t(
"PROMOCODE_ACTIVATED_AT_REGISTRATION",
"✅ Промокод активирован!\n\n{description}"
).format(description=promocode_result["description"])
)
logger.info(f"✅ Промокод {promocode_to_activate} активирован для пользователя {user.id}")
else:
logger.warning(f"⚠️ Не удалось активировать промокод {promocode_to_activate}: {promocode_result.get('error')}")
except Exception as e:
logger.error(f"❌ Ошибка при активации промокода {promocode_to_activate}: {e}")
campaign_message = await _apply_campaign_bonus_if_needed(db, user, data, texts)
try:
@@ -1177,11 +1294,9 @@ async def complete_registration(
else:
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
has_active_subscription = bool(getattr(user, "subscription", None))
subscription_is_active = False
if getattr(user, "subscription", None):
subscription_is_active = user.subscription.is_active
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
getattr(user, "subscription", None)
)
menu_text = await get_main_menu_text(user, texts, db)
@@ -1191,12 +1306,14 @@ async def complete_registration(
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,
)
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(
@@ -1232,30 +1349,43 @@ def _get_subscription_status(user, texts):
return texts.t("SUBSCRIPTION_NONE", "Нет активной подписки")
subscription = user.subscription
actual_status = getattr(subscription, "actual_status", None)
from datetime import datetime
end_date = getattr(subscription, "end_date", None)
end_date_display = format_local_datetime(end_date, "%d.%m.%Y") if end_date else None
current_time = datetime.utcnow()
if end_date and end_date <= current_time:
return texts.t(
"SUB_STATUS_EXPIRED",
"🔴 Истекла\n📅 {end_date}",
).format(end_date=end_date.strftime('%d.%m.%Y'))
if actual_status == "disabled":
return texts.t("SUB_STATUS_DISABLED", "⚫ Отключена")
if actual_status == "pending":
return texts.t("SUB_STATUS_PENDING", "⏳ Ожидает активации")
if actual_status == "expired" or (end_date and end_date <= current_time):
if end_date_display:
return texts.t(
"SUB_STATUS_EXPIRED",
"🔴 Истекла\n📅 {end_date}",
).format(end_date=end_date_display)
return texts.t("SUBSCRIPTION_STATUS_EXPIRED", "🔴 Истекла")
if not end_date:
return texts.t("SUBSCRIPTION_ACTIVE", "✅ Активна")
days_left = (end_date - current_time).days
is_trial = getattr(subscription, "is_trial", False)
is_trial = actual_status == "trial" or getattr(subscription, "is_trial", False)
if actual_status not in {"active", "trial", None} and not is_trial:
return texts.t("SUBSCRIPTION_STATUS_UNKNOWN", "❓ Статус неизвестен")
if is_trial:
if days_left > 1:
if days_left > 1 and end_date_display:
return texts.t(
"SUB_STATUS_TRIAL_ACTIVE",
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
).format(end_date=end_date.strftime('%d.%m.%Y'), days=days_left)
).format(end_date=end_date_display, days=days_left)
if days_left == 1:
return texts.t(
"SUB_STATUS_TRIAL_TOMORROW",
@@ -1266,11 +1396,11 @@ def _get_subscription_status(user, texts):
"🎁 Тестовая подписка\n⚠️ истекает сегодня!",
)
if days_left > 7:
if days_left > 7 and end_date_display:
return texts.t(
"SUB_STATUS_ACTIVE_LONG",
"💎 Активна\n📅 до {end_date} ({days} дн.)",
).format(end_date=end_date.strftime('%d.%m.%Y'), days=days_left)
).format(end_date=end_date_display, days=days_left)
if days_left > 1:
return texts.t(
"SUB_STATUS_ACTIVE_FEW_DAYS",
@@ -1399,6 +1529,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)
@@ -1460,8 +1625,9 @@ async def required_sub_channel_check(
logger.warning(f"Не удалось удалить сообщение: {e}")
if user and user.status != UserStatus.DELETED.value:
has_active_subscription = bool(user.subscription)
subscription_is_active = bool(user.subscription and user.subscription.is_active)
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
user.subscription
)
menu_text = await get_main_menu_text(user, texts, db)
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',
]
+284
View File
@@ -0,0 +1,284 @@
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.services.user_cart_service import user_cart_service
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, db_user),
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, db_user),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.selecting_period)
elif current_state == SubscriptionStates.selecting_devices.state:
await _show_previous_configuration_step(callback, state, db_user, texts, db)
elif current_state == SubscriptionStates.confirming_purchase.state:
if settings.is_devices_selection_enabled():
data = await state.get_data()
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
await callback.message.edit_text(
texts.SELECT_DEVICES,
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_devices)
else:
await _show_previous_configuration_step(callback, state, db_user, texts, db)
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)
# Удаляем сохраненную корзину, чтобы не показывать кнопку возврата
await user_cart_service.delete_user_cart(db_user.id)
from app.handlers.menu import show_main_menu
await show_main_menu(callback, db_user, db)
await callback.answer("❌ Покупка отменена")
async def _show_previous_configuration_step(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
texts,
db: AsyncSession,
):
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)
return
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)
return
await callback.message.edit_text(
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language, db_user),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.selecting_period)
+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 = user.get_primary_promo_group()
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"
)
]
])
+964
View File
@@ -0,0 +1,964 @@
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
from .summary import present_subscription_summary
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
if not settings.is_devices_selection_enabled():
if await present_subscription_summary(callback, state, db_user, texts):
await callback.answer()
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}")
+518
View File
@@ -0,0 +1,518 @@
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 app.utils.timezone import format_local_datetime
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_selection_enabled = settings.is_devices_selection_enabled()
forced_disabled_limit: Optional[int] = None
if devices_selection_enabled:
devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
else:
forced_disabled_limit = settings.get_disabled_mode_device_limit()
if forced_disabled_limit is None:
devices_selected = settings.DEFAULT_DEVICE_LIMIT
else:
devices_selected = forced_disabled_limit
summary_data['devices'] = devices_selected
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)} ГБ"
details_lines = []
# Добавляем строку базового периода только если цена не равна 0
if base_discount_total > 0 and base_price > 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)})"
)
details_lines.append(base_line)
elif base_price_original > 0:
base_line = f"- Базовый период: {texts.format_price(base_price_original)}"
details_lines.append(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 devices_selection_enabled and 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_lines = [
"📋 <b>Сводка заказа</b>",
"",
f"📅 <b>Период:</b> {period_display}",
f"📊 <b>Трафик:</b> {traffic_display}",
f"🌍 <b>Страны:</b> {', '.join(selected_countries_names)}",
]
if devices_selection_enabled:
summary_lines.append(f"📱 <b>Устройства:</b> {devices_selected}")
summary_lines.extend([
"",
"💰 <b>Детализация стоимости:</b>",
details_text,
"",
f"💎 <b>Общая стоимость:</b> {texts.format_price(total_price)}",
"",
"Подтверждаете покупку?",
])
summary_text = "\n".join(summary_lines)
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 = await _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)
device_limit = subscription.device_limit
if device_limit is None:
if settings.is_devices_selection_enabled():
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
forced_limit = settings.get_disabled_mode_device_limit()
if forced_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
device_limit = forced_limit
devices_cost = max(0, (device_limit or 0) - 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_selection_enabled = settings.is_devices_selection_enabled()
if devices_selection_enabled:
devices_used = await get_current_devices_count(db_user)
else:
devices_used = 0
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_template = texts.SUBSCRIPTION_INFO
if not devices_selection_enabled:
info_template = info_template.replace(
"\n📱 <b>Устройства:</b> {devices_used} / {devices_limit}",
"",
).replace(
"\n📱 <b>Devices:</b> {devices_used} / {devices_limit}",
"",
)
info_text = info_template.format(
status=status_text,
type=type_text,
end_date=format_local_datetime(subscription.end_date, "%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)
async def _build_promo_group_discount_text(
db_user: User,
periods: Optional[List[int]] = None,
texts=None,
) -> str:
promo_group = db_user.get_primary_promo_group()
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
+59
View File
@@ -0,0 +1,59 @@
import logging
from typing import Optional, TYPE_CHECKING
from aiogram import types
from aiogram.fsm.context import FSMContext
from app.localization.texts import get_texts
from app.services.subscription_checkout_service import save_subscription_checkout_draft
from app.states import SubscriptionStates
from app.keyboards.inline import get_subscription_confirm_keyboard
if TYPE_CHECKING: # pragma: no cover - only for type checking
from .pricing import _prepare_subscription_summary
logger = logging.getLogger(__name__)
async def present_subscription_summary(
callback: types.CallbackQuery,
state: FSMContext,
db_user,
texts: Optional = None,
) -> bool:
"""Render the subscription purchase summary and switch to the confirmation state.
Returns ``True`` when the summary is shown successfully and ``False`` if
calculation failed (an error is shown to the user in this case).
"""
if texts is None:
texts = get_texts(db_user.language)
data = await state.get_data()
from .pricing import _prepare_subscription_summary
try:
summary_text, prepared_data = await _prepare_subscription_summary(db_user, data, texts)
except ValueError as exc:
logger.error(
"Ошибка в расчете цены подписки для пользователя %s: %s",
db_user.telegram_id,
exc,
)
await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True)
return False
await state.set_data(prepared_data)
await save_subscription_checkout_draft(db_user.id, prepared_data)
await callback.message.edit_text(
summary_text,
reply_markup=get_subscription_confirm_keyboard(db_user.language),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.confirming_purchase)
return True
+741
View File
@@ -0,0 +1,741 @@
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
from .summary import present_subscription_summary
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)
await callback.answer()
return
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)
if settings.is_devices_selection_enabled():
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()
return
if await present_subscription_summary(callback, state, db_user, texts):
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()
+5 -1
View File
@@ -114,7 +114,11 @@ async def handle_successful_payment(message: types.Message):
)
await message.answer(
f"✅ Баланс успешно пополнен на {settings.format_price(amount_kopeks)}!"
f"✅ Баланс успешно пополнен на {settings.format_price(amount_kopeks)}!\n\n"
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса."
)
logger.info(f"✅ Обработан Stars платеж: {payment.telegram_payment_charge_id}")
+46 -3
View File
@@ -53,6 +53,12 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
callback_data="admin_submenu_system",
),
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_MAIN_PAYMENTS", "💳 Пополнения"),
callback_data="admin_payments",
)
],
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
])
@@ -101,6 +107,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_POLLS", "🗳️ Опросы"),
callback_data="admin_polls",
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_COMMUNICATIONS_PROMO_OFFERS", "🎯 Промо-предложения"),
@@ -674,6 +686,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", "📈 Трафик"),
@@ -742,6 +760,12 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
callback_data=f"admin_user_promo_group_{user_id}"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_USER_REFERRALS_BUTTON", "🤝 Рефералы"),
callback_data=f"admin_user_referrals_{user_id}"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_USER_STATISTICS", "📊 Статистика"),
@@ -756,6 +780,13 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
]
]
keyboard.append([
InlineKeyboardButton(
text=_t(texts, "ADMIN_USER_SEND_MESSAGE", "✉️ Отправить сообщение"),
callback_data=f"admin_user_send_message_{user_id}"
)
])
if user_status == "active":
keyboard.append([
InlineKeyboardButton(
@@ -796,20 +827,28 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
def get_user_promo_group_keyboard(
promo_groups: List[Tuple[Any, int]],
user_id: int,
current_group_id: Optional[int],
current_group_ids, # Can be Optional[int] or List[int]
language: str = "ru"
) -> InlineKeyboardMarkup:
texts = get_texts(language)
# Ensure current_group_ids is a list
if current_group_ids is None:
current_group_ids = []
elif isinstance(current_group_ids, int):
current_group_ids = [current_group_ids]
keyboard: List[List[InlineKeyboardButton]] = []
for group, members_count in promo_groups:
prefix = "" if current_group_id is not None and group.id == current_group_id else "👥"
# Check if user has this group
has_group = group.id in current_group_ids
prefix = "" if has_group else "👥"
count_text = f" ({members_count})" if members_count else ""
keyboard.append([
InlineKeyboardButton(
text=f"{prefix} {group.name}{count_text}",
callback_data=f"admin_user_promo_group_set_{user_id}_{group.id}"
callback_data=f"admin_user_promo_group_toggle_{user_id}_{group.id}"
)
])
@@ -856,6 +895,10 @@ def get_promocode_type_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
InlineKeyboardButton(
text=_t(texts, "ADMIN_PROMOCODE_TYPE_TRIAL", "🎁 Триал"),
callback_data="promo_type_trial"
),
InlineKeyboardButton(
text=_t(texts, "ADMIN_PROMOCODE_TYPE_PROMO_GROUP", "🏷️ Промогруппа"),
callback_data="promo_type_group"
)
],
[
+460 -163
View File
@@ -8,7 +8,12 @@ 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.pricing_utils import format_period_description, apply_percentage_discount
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.price_display import PriceInfo, format_price_button
from app.utils.subscription_utils import (
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
@@ -17,6 +22,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 +173,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,11 +235,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}")
@@ -136,7 +261,8 @@ def get_main_menu_keyboard(
"💰 Баланс: {balance}",
).format(balance=texts.format_price(balance_kopeks))
keyboard = []
keyboard: list[list[InlineKeyboardButton]] = []
paired_buttons: list[InlineKeyboardButton] = []
if has_active_subscription and subscription_is_active:
connect_mode = settings.CONNECT_BUTTON_MODE
@@ -191,22 +317,30 @@ def get_main_menu_keyboard(
happ_row = get_happ_download_button_row(texts)
if happ_row:
keyboard.append(happ_row)
keyboard.append([
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance"),
paired_buttons.append(
InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription")
])
else:
keyboard.append([
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")
])
)
keyboard.append([InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")])
show_trial = not has_had_paid_subscription and not has_active_subscription
show_buy = not has_active_subscription or not subscription_is_active
subscription_buttons = []
current_subscription = subscription
has_active_paid_subscription = bool(
current_subscription
and not getattr(current_subscription, "is_trial", False)
and getattr(current_subscription, "is_active", False)
)
simple_purchase_button = None
if settings.SIMPLE_SUBSCRIPTION_ENABLED:
simple_purchase_button = InlineKeyboardButton(
text=texts.MENU_SIMPLE_SUBSCRIPTION,
callback_data="simple_subscription_purchase",
)
subscription_buttons: list[InlineKeyboardButton] = []
if show_trial:
subscription_buttons.append(
InlineKeyboardButton(text=texts.MENU_TRIAL, callback_data="menu_trial")
@@ -218,30 +352,36 @@ def get_main_menu_keyboard(
)
if subscription_buttons:
if len(subscription_buttons) == 2:
keyboard.append(subscription_buttons)
else:
keyboard.append([subscription_buttons[0]])
paired_buttons.extend(subscription_buttons)
if simple_purchase_button:
paired_buttons.append(simple_purchase_button)
if show_resume_checkout:
keyboard.append([
if show_resume_checkout or has_saved_cart:
resume_callback = (
"return_to_saved_cart" if has_saved_cart else "subscription_resume_checkout"
)
paired_buttons.append(
InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout",
callback_data=resume_callback,
)
])
)
if custom_buttons:
for button in custom_buttons:
if isinstance(button, InlineKeyboardButton):
keyboard.append([button])
paired_buttons.append(button)
keyboard.extend([
[
InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode"),
# Добавляем кнопки промокода и рефералов, учитывая настройки
paired_buttons.append(
InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode")
)
# Добавляем кнопку рефералов только если программа включена
if settings.is_referral_program_enabled():
paired_buttons.append(
InlineKeyboardButton(text=texts.MENU_REFERRALS, callback_data="menu_referrals")
]
])
)
# Support button is configurable (runtime via service)
try:
@@ -250,21 +390,26 @@ def get_main_menu_keyboard(
except Exception:
support_enabled = settings.SUPPORT_MENU_ENABLED
if support_enabled:
keyboard.append([
paired_buttons.append(
InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support")
])
)
keyboard.append([
paired_buttons.append(
InlineKeyboardButton(
text=texts.t("MENU_INFO", "️ Инфо"),
callback_data="menu_info",
)
])
)
if settings.is_language_selection_enabled():
keyboard.append([
paired_buttons.append(
InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data="menu_language")
])
)
for i in range(0, len(paired_buttons), 2):
row = paired_buttons[i : i + 2]
keyboard.append(row)
if settings.DEBUG:
print(f"DEBUG KEYBOARD: is_admin={is_admin}, добавляем админ кнопку: {is_admin}")
@@ -504,6 +649,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)
@@ -524,14 +670,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="return_to_saved_cart",
)
]
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)
@@ -637,6 +792,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)
# Добавляем кнопку "Очистить корзину"
@@ -647,9 +803,18 @@ def get_payment_methods_keyboard_with_cart(
)
])
# Добавляем кнопку возврата к оформлению подписки
keyboard.inline_keyboard.insert(-1, [ # Вставляем перед кнопкой "назад"
InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="return_to_saved_cart"
)
])
return keyboard
def get_subscription_confirm_keyboard_with_cart(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text="✅ Подтвердить покупку",
@@ -660,8 +825,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" # Изменили на возврат к настройке
)]
])
@@ -669,11 +834,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,
[
@@ -696,34 +864,59 @@ def get_trial_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
])
def get_subscription_period_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
def get_subscription_period_keyboard(
language: str = DEFAULT_LANGUAGE,
user: Optional[User] = None
) -> InlineKeyboardMarkup:
"""
Generate subscription period selection keyboard with personalized pricing.
Args:
language: User's language code
user: User object for personalized discounts (None = default discounts)
Returns:
InlineKeyboardMarkup with period buttons showing personalized prices
"""
from app.utils.price_display import calculate_user_price
texts = get_texts(language)
keyboard = []
available_periods = settings.get_available_subscription_periods()
period_texts = {
14: texts.PERIOD_14_DAYS,
30: texts.PERIOD_30_DAYS,
60: texts.PERIOD_60_DAYS,
90: texts.PERIOD_90_DAYS,
180: texts.PERIOD_180_DAYS,
360: texts.PERIOD_360_DAYS
}
for days in available_periods:
if days in period_texts:
keyboard.append([
InlineKeyboardButton(
text=period_texts[days],
callback_data=f"period_{days}"
)
])
# Get base price for this period
base_price = PERIOD_PRICES.get(days, 0)
# Calculate personalized price with user's discounts
price_info = calculate_user_price(user, base_price, days, "period")
# Format period description
period_display = format_period_description(days, language)
# Format button text with discount display
button_text = format_price_button(
period_label=period_display,
price_info=price_info,
format_price_func=texts.format_price,
emphasize=False,
add_exclamation=False
)
keyboard.append([
InlineKeyboardButton(
text=button_text,
callback_data=f"period_{days}"
)
])
# Кнопка "Простая покупка" была убрана из выбора периода подписки
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
@@ -915,21 +1108,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(
@@ -939,13 +1132,25 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
])
if settings.is_mulenpay_enabled():
mulenpay_name = settings.get_mulenpay_display_name()
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_CARD_MULENPAY", "💳 Банковская карта (Mulen Pay)"),
text=texts.t(
"PAYMENT_CARD_MULENPAY",
"💳 Банковская карта ({mulenpay_name})",
).format(mulenpay_name=mulenpay_name),
callback_data=_build_callback("mulenpay")
)
])
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(
@@ -954,6 +1159,14 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
])
if settings.is_platega_enabled() and settings.get_platega_active_methods():
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_PLATEGA", "💳 Platega"),
callback_data=_build_callback("platega"),
)
])
if settings.is_cryptobot_enabled():
keyboard.append([
InlineKeyboardButton(
@@ -962,6 +1175,14 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
])
if settings.is_heleket_enabled():
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_HELEKET", "🪙 Криптовалюта (Heleket)"),
callback_data=_build_callback("heleket")
)
])
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_VIA_SUPPORT", "🛠️ Через поддержку"),
@@ -1013,17 +1234,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"
)
]
@@ -1031,23 +1252,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"
)
]
@@ -1198,7 +1419,7 @@ def get_autopay_days_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboar
for days in [1, 3, 7, 14]:
keyboard.append([
InlineKeyboardButton(
text=f"{days} дн{_get_days_suffix(days)}",
text=f"{days} {_get_days_word(days)}",
callback_data=f"autopay_days_{days}"
)
])
@@ -1210,37 +1431,17 @@ def get_autopay_days_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboar
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def _get_days_suffix(days: int) -> str:
if days == 1:
return "ь"
elif 2 <= days <= 4:
return "я"
else:
return "ей"
def _get_days_word(days: int) -> str:
if days % 10 == 1 and days % 100 != 11:
return "день"
if 2 <= days % 10 <= 4 and not (12 <= days % 100 <= 14):
return "дня"
return "дней"
def get_extend_subscription_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = []
periods = [
(14, f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}"),
(30, f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}"),
(60, f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}"),
(90, f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}")
]
for days, text in periods:
keyboard.append([
InlineKeyboardButton(text=text, callback_data=f"extend_period_{days}")
])
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
# Deprecated: get_extend_subscription_keyboard() was removed.
# Use get_extend_subscription_keyboard_with_prices() instead for personalized pricing.
def get_add_traffic_keyboard(
@@ -1547,7 +1748,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")
]
]
@@ -1564,42 +1766,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")
@@ -1652,43 +1892,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}")
@@ -1717,15 +1978,39 @@ def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) ->
if isinstance(price_info, dict):
final_price = price_info.get("final")
original_price = price_info.get("original", 0)
if final_price is None:
final_price = price_info.get("original", 0)
else:
final_price = price_info
original_price = price_info
period_display = format_period_description(days, language)
# Create PriceInfo from already calculated prices
# Note: original_price and final_price are calculated in the handler
discount_percent = 0
if original_price > final_price and original_price > 0:
discount_percent = ((original_price - final_price) * 100) // original_price
price_info_obj = PriceInfo(
base_price=original_price,
final_price=final_price,
discount_percent=discount_percent
)
# Format button using unified system
button_text = format_price_button(
period_label=period_display,
price_info=price_info_obj,
format_price_func=texts.format_price,
emphasize=False,
add_exclamation=False
)
keyboard.append([
InlineKeyboardButton(
text=f"📅 {period_display} - {texts.format_price(final_price)}",
text=button_text,
callback_data=f"extend_period_{days}"
)
])
@@ -1840,33 +2125,39 @@ def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE,
texts = get_texts(language)
keyboard = []
if show_countries_management:
keyboard.append([
InlineKeyboardButton(text=texts.t("ADD_COUNTRIES_BUTTON", "🌐 Добавить страны"), callback_data="subscription_add_countries")
])
keyboard.extend([
[
InlineKeyboardButton(text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"), callback_data="subscription_change_devices")
],
[
InlineKeyboardButton(text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"), callback_data="subscription_manage_devices")
]
])
if settings.is_traffic_selectable():
keyboard.insert(-2, [
InlineKeyboardButton(text=texts.t("SWITCH_TRAFFIC_BUTTON", "🔄 Переключить трафик"), callback_data="subscription_switch_traffic")
])
keyboard.insert(-2, [
keyboard.append([
InlineKeyboardButton(text=texts.t("RESET_TRAFFIC_BUTTON", "🔄 Сбросить трафик"), callback_data="subscription_reset_traffic")
])
keyboard.append([
InlineKeyboardButton(text=texts.t("SWITCH_TRAFFIC_BUTTON", "🔄 Переключить трафик"), callback_data="subscription_switch_traffic")
])
if settings.is_devices_selection_enabled():
keyboard.append([
InlineKeyboardButton(
text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"),
callback_data="subscription_change_devices"
)
])
keyboard.append([
InlineKeyboardButton(
text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"),
callback_data="subscription_manage_devices"
)
])
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
@@ -2082,6 +2373,12 @@ def get_admin_tickets_keyboard(
keyboard.append(switch_row)
if open_rows and scope in ("all", "open"):
keyboard.append([
InlineKeyboardButton(
text=texts.t("ADMIN_CLOSE_ALL_OPEN_TICKETS", "🔒 Закрыть все открытые"),
callback_data="admin_tickets_close_all_open"
)
])
keyboard.append([InlineKeyboardButton(text=texts.t("OPEN_TICKETS_HEADER", "Открытые тикеты"), callback_data="noop")])
keyboard.extend(open_rows)
if closed_rows and scope in ("all", "closed"):
+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
)
+68 -10
View File
@@ -1,8 +1,10 @@
from __future__ import annotations
import json
import os
import logging
import shutil
import tempfile
from functools import lru_cache
from pathlib import Path
from typing import Any, Dict
@@ -161,6 +163,41 @@ def _normalize_locale_dict(data: Dict[str, Any]) -> Dict[str, Any]:
return normalized
def _directory_is_writable(directory: Path) -> bool:
try:
current_user = f"{os.geteuid()}:{os.getegid()}"
user_hint = f" (running as UID:GID {current_user})"
except Exception: # pragma: no cover - best effort only
user_hint = ""
try:
with tempfile.NamedTemporaryFile(dir=directory, prefix=".locale_write_test_", delete=True):
pass
return True
except PermissionError as error:
_logger.warning(
"Locale directory %s is not writable%s. Ensure the mounted directory allows writes for the container user or configure LOCALES_PATH to a writable path. (%s)",
directory,
user_hint,
error,
)
except OSError as error:
_logger.warning(
"Unable to prepare locale directory %s for writing%s: %s. Configure LOCALES_PATH to a writable path.",
directory,
user_hint,
error,
)
except Exception as error: # pragma: no cover - defensive logging
_logger.warning(
"Unexpected error while checking locale directory %s%s: %s",
directory,
user_hint,
error,
)
return False
def ensure_locale_templates() -> None:
destination = _resolve_user_locales_dir()
try:
@@ -169,27 +206,48 @@ def ensure_locale_templates() -> None:
_logger.warning("Unable to create locales directory %s: %s", destination, error)
return
if any(destination.glob("*")):
return
if not _DEFAULT_LOCALES_DIR.exists():
_logger.debug("Default locales directory %s is missing", _DEFAULT_LOCALES_DIR)
return
for template in _DEFAULT_LOCALES_DIR.iterdir():
if not template.is_file():
continue
target_path = destination / template.name
if not _directory_is_writable(destination):
return
destination_has_files = any(destination.glob("*"))
def _copy_locale(source: Path, target: Path) -> None:
try:
shutil.copyfile(template, target_path)
shutil.copyfile(source, target)
except Exception as error:
_logger.warning(
"Failed to copy default locale %s to %s: %s",
template,
target_path,
source,
target,
error,
)
if not destination_has_files:
for template in _DEFAULT_LOCALES_DIR.iterdir():
if not template.is_file():
continue
_copy_locale(template, destination / template.name)
return
for locale_code in ("ru", "en"):
source_path = _DEFAULT_LOCALES_DIR / f"{locale_code}.json"
target_path = destination / f"{locale_code}.json"
if target_path.exists():
continue
if not source_path.exists():
_logger.debug(
"Default locale template %s is missing at %s", locale_code, source_path
)
continue
_copy_locale(source_path, target_path)
def _load_default_locale(language: str) -> Dict[str, Any]:
default_path = _DEFAULT_LOCALES_DIR / f"{language}.json"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-12
View File
@@ -30,12 +30,6 @@ def _build_dynamic_values(language: str) -> Dict[str, Any]:
if language_code == "ru":
return {
"PERIOD_14_DAYS": f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}",
"PERIOD_30_DAYS": f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}",
"PERIOD_60_DAYS": f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}",
"PERIOD_90_DAYS": f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}",
"PERIOD_180_DAYS": f"📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}",
"PERIOD_360_DAYS": f"📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}",
"TRAFFIC_5GB": f"📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_10GB": f"📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
"TRAFFIC_25GB": f"📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
@@ -55,12 +49,6 @@ def _build_dynamic_values(language: str) -> Dict[str, Any]:
if language_code == "en":
return {
"PERIOD_14_DAYS": f"📅 14 days - {settings.format_price(settings.PRICE_14_DAYS)}",
"PERIOD_30_DAYS": f"📅 30 days - {settings.format_price(settings.PRICE_30_DAYS)}",
"PERIOD_60_DAYS": f"📅 60 days - {settings.format_price(settings.PRICE_60_DAYS)}",
"PERIOD_90_DAYS": f"📅 90 days - {settings.format_price(settings.PRICE_90_DAYS)}",
"PERIOD_180_DAYS": f"📅 180 days - {settings.format_price(settings.PRICE_180_DAYS)}",
"PERIOD_360_DAYS": f"📅 360 days - {settings.format_price(settings.PRICE_360_DAYS)}",
"TRAFFIC_5GB": f"📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_10GB": f"📊 10 GB - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
"TRAFFIC_25GB": f"📊 25 GB - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
+108 -6
View File
@@ -1,6 +1,6 @@
import logging
from typing import Callable, Dict, Any, Awaitable
from aiogram import BaseMiddleware, Bot
from typing import Callable, Dict, Any, Awaitable, Optional
from aiogram import BaseMiddleware, Bot, types
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.types import TelegramObject, Update, Message, CallbackQuery
@@ -8,6 +8,7 @@ from aiogram.enums import ChatMemberStatus
from app.config import settings
from app.database.database import get_db
from app.database.crud.campaign import get_campaign_by_start_parameter
from app.database.crud.subscription import deactivate_subscription
from app.database.crud.user import get_user_by_telegram_id
from app.database.models import SubscriptionStatus
@@ -16,6 +17,7 @@ from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts
from app.utils.check_reg_process import is_registration_process
from app.services.subscription_service import SubscriptionService
from app.services.admin_notification_service import AdminNotificationService
logger = logging.getLogger(__name__)
@@ -86,16 +88,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 +106,8 @@ class ChannelCheckerMiddleware(BaseMiddleware):
if telegram_id:
await self._deactivate_trial_subscription(telegram_id)
await self._capture_start_payload(state, event, bot)
if isinstance(event, CallbackQuery) and event.data == "sub_channel_check":
await event.answer("❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.", show_alert=True)
return
@@ -111,10 +115,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, bot)
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, bot)
return await self._deny_message(event, bot, channel_link)
except TelegramBadRequest as e:
if "chat not found" in str(e).lower():
@@ -123,11 +129,107 @@ class ChannelCheckerMiddleware(BaseMiddleware):
logger.error(f"❌ Пользователь {telegram_id} не найден: {e}")
else:
logger.error(f"❌ Ошибка запроса к каналу {channel_id}: {e}")
await self._capture_start_payload(state, event, bot)
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,
bot: Optional[Bot] = None,
) -> 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:
data["pending_start_payload"] = payload
await state.set_data(data)
logger.debug("💾 Сохранен start payload %s для последующей обработки", payload)
if bot and message.from_user:
await self._try_send_campaign_visit_notification(
bot,
message.from_user,
state,
payload,
)
async def _try_send_campaign_visit_notification(
self,
bot: Bot,
telegram_user: types.User,
state: FSMContext,
payload: str,
) -> None:
try:
data = await state.get_data() or {}
except Exception as error:
logger.error(
"❌ Не удалось получить данные состояния для уведомления по кампании %s: %s",
payload,
error,
)
return
if data.get("campaign_notification_sent"):
return
async for db in get_db():
try:
campaign = await get_campaign_by_start_parameter(
db,
payload,
only_active=True,
)
if not campaign:
break
user = await get_user_by_telegram_id(db, telegram_user.id)
notification_service = AdminNotificationService(bot)
sent = await notification_service.send_campaign_link_visit_notification(
db,
telegram_user,
campaign,
user,
)
if sent:
await state.update_data(campaign_notification_sent=True)
break
except Exception as error:
logger.error(
"❌ Ошибка отправки уведомления о переходе по кампании %s: %s",
payload,
error,
)
finally:
break
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)

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