Compare commits

...

711 Commits

Author SHA1 Message Date
Egor c3c105619b Update Dockerfile 2025-11-21 07:02:11 +03:00
Egor 01e575676f Update docker-registry.yml 2025-11-21 07:01:57 +03:00
Egor b53a43fcc6 Update docker-hub.yml 2025-11-21 07:01:41 +03:00
Egor 6a8ff1c1b4 Merge pull request #1976 from BEDOLAGA-DEV/dev5
Dev5
2025-11-21 06:36:35 +03:00
Egor 451ab514cc Merge pull request #1975 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill-d1pv7w
Clean up CryptoBot prompt messages
2025-11-21 06:33:34 +03:00
Egor c8b6830704 Clean up CryptoBot amount prompts 2025-11-21 06:32:34 +03:00
Egor 74ee7149c5 Merge pull request #1974 from BEDOLAGA-DEV/revert-1972-bedolaga/remove-unnecessary-chat-messages-after-balance-refill-675m3k
Revert "Fix Tribute payment prompt formatting"
2025-11-21 06:32:23 +03:00
Egor 506b96dec4 Revert "Fix Tribute payment prompt formatting" 2025-11-21 06:32:13 +03:00
Egor 927d4230a2 Merge pull request #1973 from BEDOLAGA-DEV/dev5
Dev5
2025-11-21 06:08:36 +03:00
Egor 69ff737b72 Merge pull request #1972 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill-675m3k
Fix Tribute payment prompt formatting
2025-11-21 06:05:50 +03:00
Egor d0114a1f29 Fix Tribute payment prompt editing 2025-11-21 06:03:35 +03:00
Egor f92a0fd88b Merge pull request #1971 from BEDOLAGA-DEV/revert-1969-bedolaga/remove-unnecessary-chat-messages-after-balance-refill-xitbxf
Revert "Reject YooKassa webhooks lacking payment id"
2025-11-21 06:03:14 +03:00
Egor e7478cc1e1 Revert "Reject YooKassa webhooks lacking payment id" 2025-11-21 06:03:04 +03:00
Egor 5814f9757d Merge pull request #1970 from BEDOLAGA-DEV/dev5
Dev5
2025-11-21 05:40:12 +03:00
Egor cb7b9a9e22 Merge pull request #1969 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill-xitbxf
Reject YooKassa webhooks lacking payment id
2025-11-21 05:34:23 +03:00
Egor 6dc525dd72 Handle missing YooKassa payment ids gracefully 2025-11-21 05:26:42 +03:00
Egor 409c8c161b Merge pull request #1968 from BEDOLAGA-DEV/revert-1967-bedolaga/remove-unnecessary-chat-messages-after-balance-refill-skti1r
Revert "Handle YooKassa webhook payloads without payment ids"
2025-11-21 05:26:31 +03:00
Egor 78db3a1e77 Revert "Handle YooKassa webhook payloads without payment ids" 2025-11-21 05:26:21 +03:00
Egor d0c5ff19e1 Merge pull request #1967 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill-skti1r
Handle YooKassa webhook payloads without payment ids
2025-11-21 05:17:43 +03:00
Egor 6a29765ddf Handle YooKassa webhooks without payment ids 2025-11-21 05:17:09 +03:00
Egor 95147fbe03 Merge pull request #1964 from BEDOLAGA-DEV/revert-1963-bedolaga/remove-unnecessary-chat-messages-after-balance-refill-fbz95w
Revert "Clean up Platega and YooKassa invoices after payment"
2025-11-21 04:49:30 +03:00
Egor 0137321d24 Revert "Clean up Platega and YooKassa invoices after payment" 2025-11-21 04:49:17 +03:00
Egor 0521306699 Merge pull request #1963 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill-fbz95w
Clean up Platega and YooKassa invoices after payment
2025-11-21 04:18:23 +03:00
Egor 2a10e7d2c7 Clean up Platega and YooKassa invoices after payment 2025-11-21 04:18:03 +03:00
Egor e78e9e1a4d Merge pull request #1962 from BEDOLAGA-DEV/revert-1960-bedolaga/remove-unnecessary-chat-messages-after-balance-refill-v2ldtf
Revert "Clean up Platega and YooKassa prompts"
2025-11-21 04:17:22 +03:00
Egor 29d0b54b04 Revert "Clean up Platega and YooKassa prompts" 2025-11-21 04:17:13 +03:00
Egor 64fe3fdbb7 Merge pull request #1960 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill-v2ldtf
Clean up Platega and YooKassa prompts
2025-11-21 04:02:54 +03:00
Egor 04edca175e Merge pull request #1961 from BEDOLAGA-DEV/revert-1959-bedolaga/remove-unnecessary-chat-messages-after-balance-refill-s863sv
Revert "Clean up Telegram Stars payment flow"
2025-11-21 04:02:38 +03:00
Egor 840091cb05 Revert "Clean up Telegram Stars payment flow" 2025-11-21 04:02:28 +03:00
Egor 473c3704cf Clean up Platega and YooKassa prompts 2025-11-21 04:02:22 +03:00
Egor af630b22c9 Merge pull request #1959 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill-s863sv
Clean up Telegram Stars payment flow
2025-11-21 03:27:06 +03:00
Egor 29fe2f2016 Clean up Telegram Stars payment flow 2025-11-21 03:26:34 +03:00
Egor cd5691640e Merge pull request #1958 from BEDOLAGA-DEV/revert-1957-bedolaga/remove-unnecessary-chat-messages-after-balance-refill
Revert "Clean up Telegram Stars top-up messages"
2025-11-21 03:26:03 +03:00
Egor 77c217f9ad Revert "Clean up Telegram Stars top-up messages" 2025-11-21 03:25:55 +03:00
Egor 9e963569bc Merge pull request #1957 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill
Clean up Telegram Stars top-up messages
2025-11-21 03:24:32 +03:00
Egor c7114ec359 Clean up Telegram Stars top-up messages 2025-11-21 03:24:17 +03:00
Egor b9586ad72a Merge pull request #1956 from BEDOLAGA-DEV/revert-1955-bedolaga/remove-unnecessary-messages-after-balance-update-vvw9nt
Revert "Clean up Telegram Stars top-up messages"
2025-11-21 03:18:19 +03:00
Egor b6e333127c Revert "Clean up Telegram Stars top-up messages" 2025-11-21 03:18:10 +03:00
Egor 6ea2c2cceb Merge pull request #1955 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-messages-after-balance-update-vvw9nt
Clean up Telegram Stars top-up messages
2025-11-21 03:16:40 +03:00
Egor af42377c3b Clean up Telegram Stars top-up messages 2025-11-21 03:16:24 +03:00
Egor 82881a4c45 Merge pull request #1954 from BEDOLAGA-DEV/revert-1953-bedolaga/remove-unnecessary-messages-after-balance-update
Revert "Clean up Telegram Stars payment messages"
2025-11-21 03:16:12 +03:00
Egor 70d50e9c47 Revert "Clean up Telegram Stars payment messages" 2025-11-21 03:16:04 +03:00
Egor ddb5c01026 Merge pull request #1953 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-messages-after-balance-update
Clean up Telegram Stars payment messages
2025-11-21 03:14:41 +03:00
Egor e287adcb84 Clean up Telegram Stars payment messages 2025-11-21 03:14:25 +03:00
Egor 83d041c13b Merge pull request #1952 from BEDOLAGA-DEV/bedolaga/fix-auto-renewal-calculation-error
Fix autopay renewal pricing for promo groups
2025-11-20 23:13:12 +03:00
Egor 545c5fd749 Eager load promo groups for autopay renewals 2025-11-20 23:12:49 +03:00
Egor 7f0e2de7c4 Merge pull request #1951 from BEDOLAGA-DEV/bedolaga/fix-subscription-check-service-errors
Handle missing channel link in subscription check
2025-11-20 23:06:53 +03:00
Egor 1fdf1e49a3 Handle missing channel link in subscription check 2025-11-20 23:06:34 +03:00
Egor 08f82ee511 Merge pull request #1950 from BEDOLAGA-DEV/bedolaga-q76r4i
Fix trial reset cleanup
2025-11-20 22:58:04 +03:00
Egor fba217b87f Fix trial reset by clearing server links 2025-11-20 22:49:57 +03:00
Egor 6668a18ad3 Merge pull request #1949 from BEDOLAGA-DEV/revert-1948-bedolaga-uhav33
Revert "Add admin trial reset controls"
2025-11-20 22:46:40 +03:00
Egor 11c239f46c Revert "Add admin trial reset controls" 2025-11-20 22:46:30 +03:00
Egor ac62dee279 Merge pull request #1948 from BEDOLAGA-DEV/bedolaga-uhav33
Add admin trial reset controls
2025-11-20 22:34:27 +03:00
Egor 29a17a7876 Add admin trial reset controls 2025-11-20 22:33:43 +03:00
Egor 694c934612 Merge pull request #1947 from BEDOLAGA-DEV/dev5
Dev5
2025-11-20 22:11:23 +03:00
Egor c01d2bfcd7 Update README.md 2025-11-20 22:09:07 +03:00
Egor b3708f99c0 Merge pull request #1943 from BEDOLAGA-DEV/bedolaga/fix-subscription-check-error-on-double-click
Handle repeat device selection without redundant updates
2025-11-20 18:39:46 +03:00
Egor f651f921aa Handle repeat device selection without redundant updates 2025-11-20 18:39:25 +03:00
Egor ada6845036 Merge pull request #1938 from BEDOLAGA-DEV/dev5
Dev5
2025-11-20 17:58:46 +03:00
Egor d33b259537 Merge pull request #1936 from BEDOLAGA-DEV/bedolaga/fix-email-dispatch-limit-to-100000-users-yvuqli
Throttle broadcast sending for large campaigns
2025-11-20 17:31:23 +03:00
Egor 3c2ed147ca Merge pull request #1937 from BEDOLAGA-DEV/revert-1934-bedolaga/fix-email-dispatch-limit-to-100000-users
Revert "Remove broadcast recipient limit"
2025-11-20 17:31:07 +03:00
Egor d46e88c2b5 Revert "Remove broadcast recipient limit" 2025-11-20 17:30:56 +03:00
Egor 1477491e6e Throttle broadcast speed for large campaigns 2025-11-20 17:30:30 +03:00
Egor 647dc0bd7c Merge pull request #1935 from BEDOLAGA-DEV/dev5
Dev5
2025-11-20 15:47:45 +03:00
Egor f6a45993e9 Merge pull request #1934 from BEDOLAGA-DEV/bedolaga/fix-email-dispatch-limit-to-100000-users
Remove broadcast recipient limit
2025-11-20 15:47:24 +03:00
Egor 0766320411 Fetch all broadcast users in batches 2025-11-20 15:36:38 +03:00
Egor 5ebf113fd6 Merge pull request #1915 from BEDOLAGA-DEV/dev4
Dev4
2025-11-20 01:30:14 +03:00
Egor 2005d103f8 Merge pull request #1914 from BEDOLAGA-DEV/bedolaga/fix-balance-top-up-through-method-10
Handle Platega description byte limit
2025-11-20 01:20:05 +03:00
Egor 3935813336 Trim Platega descriptions by byte length 2025-11-20 01:16:25 +03:00
Egor ec71fa78c0 Merge pull request #1913 from BEDOLAGA-DEV/bedolaga/fix-subscription-channel-error
Handle duplicate channel subscription prompts
2025-11-20 01:12:44 +03:00
Egor cd2b0a2d67 Handle duplicate channel subscription messages 2025-11-20 01:10:28 +03:00
Egor 68d502804f Merge pull request #1911 from BEDOLAGA-DEV/dev4
Dev4
2025-11-18 01:17:55 +03:00
Egor afbd343442 Merge pull request #1910 from BEDOLAGA-DEV/bedolaga/fix-subscription-creation-api-error-uklgqk
Preserve autopay and device limits during subscription replacement
2025-11-18 01:17:37 +03:00
Egor 991e5a3112 Preserve zero device limit when replacing trials 2025-11-18 01:14:43 +03:00
Egor 7d9de6ca5d Merge pull request #1906 from BEDOLAGA-DEV/dev4
Dev4
2025-11-18 00:28:35 +03:00
Egor 562bb69082 Merge pull request #1905 from BEDOLAGA-DEV/bedolaga/expand-bot-api-functionality
Expand admin API for polls, logs, and tickets
2025-11-18 00:27:49 +03:00
Egor a02416c78b Add poll sending and ticket API enhancements 2025-11-18 00:22:46 +03:00
Egor 81f56008d4 Delete install_bot.sh 2025-11-16 12:04:21 +03:00
Egor 43a7039479 Merge pull request #1904 from Fr1ngg/dev4
Dev4
2025-11-15 01:46:45 +03:00
Egor a9f3904524 Merge pull request #1903 from Fr1ngg/bedolaga/fix-ru-cards-payment-processing-error
Limit Platega description length
2025-11-15 00:59:45 +03:00
Egor 9136c7cfe3 Limit Platega description length 2025-11-15 00:56:11 +03:00
Egor 5595846929 Merge pull request #1902 from Fr1ngg/dev4
Dev4
2025-11-14 01:32:49 +03:00
Egor 9ebe2971bf Merge pull request #1901 from Fr1ngg/bedolaga/fix-402-error-in-subscription-renewal-endpoint
Fix renewal mixed payment logic
2025-11-14 01:31:06 +03:00
Egor fadea75314 Fix renewal mixed payment logic 2025-11-14 01:30:31 +03:00
Egor a2009cae39 Merge pull request #1899 from Fr1ngg/dev4
Allow trial users to renew from miniapp
2025-11-14 01:17:04 +03:00
Egor 0187adb6f9 Merge pull request #1898 from Fr1ngg/bedolaga/fix-trial-subscription-error-in-miniapp
Enable trial subscriptions to renew via miniapp
2025-11-14 01:16:00 +03:00
Egor 302a028873 Allow trial users to renew from miniapp 2025-11-14 00:15:43 +03:00
Egor 77c9c19806 Merge pull request #1897 from Fr1ngg/dev4
Dev4
2025-11-13 23:37:04 +03:00
Egor 724da2545e Merge pull request #1896 from Fr1ngg/main
w
2025-11-13 23:36:46 +03:00
Egor 079af66d9f Merge pull request #1895 from Fr1ngg/bedolaga/fix-subscription-purchase-failure-with-platega
Fix Platega quick amount selection flow
2025-11-13 23:36:23 +03:00
Egor e701d234f0 Fix Platega quick amount selection flow 2025-11-13 23:33:07 +03:00
Egor 1b111975bb Update config.py 2025-11-12 16:41:37 +03:00
Egor c874425e0e Merge pull request #1890 from DOFER998/c0mrade/refactor-subtract-balance-logging
refactor: improve logging level for balance subtraction debug info
2025-11-12 16:28:09 +03:00
c0mrade c6c112fd17 refactor: improve logging level for balance subtraction debug info 2025-11-12 10:21:28 +03:00
Egor 57d924e3ba Merge pull request #1889 from Fr1ngg/dev4
Доп языки + фиксы
2025-11-12 05:38:34 +03:00
Egor 9d5b9ebf82 Add files via upload 2025-11-12 05:28:39 +03:00
Egor 354358e860 Create fail.html 2025-11-12 05:28:25 +03:00
Egor 327c9d52d2 Create succes.html 2025-11-12 05:27:59 +03:00
Egor c03051614d Merge pull request #1888 from Fr1ngg/revert-1887-bedolaga/activate-trial-automatically-after-payment-le7ksb
Revert "Enable automatic trial activation after balance top-up"
2025-11-12 05:24:24 +03:00
Egor ac9e5cd908 Revert "Enable automatic trial activation after balance top-up" 2025-11-12 05:24:14 +03:00
Egor 585bb56a99 Merge pull request #1887 from Fr1ngg/bedolaga/activate-trial-automatically-after-payment-le7ksb
Enable automatic trial activation after balance top-up
2025-11-12 04:45:20 +03:00
Egor 8f33eb0cc6 Enable automatic trial activation after balance top-up 2025-11-12 04:45:03 +03:00
Egor 71515a87de Merge pull request #1886 from Fr1ngg/revert-1885-bedolaga/activate-trial-automatically-after-payment
Revert "feat: auto activate paid trials after balance top-up"
2025-11-12 04:44:54 +03:00
Egor 0a4ede8f79 Revert "feat: auto activate paid trials after balance top-up" 2025-11-12 04:44:46 +03:00
Egor 040b33b541 Merge pull request #1885 from Fr1ngg/bedolaga/activate-trial-automatically-after-payment
feat: auto activate paid trials after balance top-up
2025-11-12 04:34:06 +03:00
Egor 5c3484ee28 feat: auto activate paid trials after balance top-up 2025-11-12 04:27:33 +03:00
Egor ef309c3101 Merge pull request #1884 from Fr1ngg/bedolaga/fix-trial-subscription-display-issue
Fix trial subscription status display in menu
2025-11-12 03:09:46 +03:00
Egor 51dad7936b Fix trial subscription status display in menu 2025-11-12 03:03:34 +03:00
Egor 097734b31d Merge pull request #1883 from Fr1ngg/bedolaga/fix-user-deletion-foreign-key-violation
Handle Platega payments when deleting users
2025-11-12 02:47:54 +03:00
Egor 7185fde9bc Handle Platega payments when deleting users 2025-11-12 02:47:37 +03:00
Egor 75f2cdb04b Update remnawave.py 2025-11-12 02:41:44 +03:00
Egor d3c445ed2d Merge pull request #1882 from Fr1ngg/bedolaga/fix-webhook-issue-with-heleket
Fix Heleket webhook signature verification
2025-11-12 02:40:34 +03:00
Egor 804f088435 Fix Heleket webhook signature verification 2025-11-11 16:41:58 +03:00
Egor 6c1b70e796 Merge pull request #1881 from Fr1ngg/bedolaga/fix-403-error-on-subscription-renewal-endpoint-n1nsij
Handle CryptoBot renewal payload fallbacks
2025-11-11 13:13:14 +03:00
Egor a3532e5878 Handle CryptoBot renewal payload fallbacks 2025-11-11 13:06:10 +03:00
Egor a8c1b32912 Merge pull request #1880 from Fr1ngg/revert-1877-bedolaga/fix-403-error-on-subscription-renewal-endpoint
Revert "Allow expired miniapp subscriptions to load renewal options"
2025-11-11 12:22:29 +03:00
Egor 20b0c8a0e2 Revert "Allow expired miniapp subscriptions to load renewal options" 2025-11-11 12:22:15 +03:00
Egor bc18692d75 Merge pull request #1877 from Fr1ngg/bedolaga/fix-403-error-on-subscription-renewal-endpoint
Allow expired miniapp subscriptions to load renewal options
2025-11-11 10:46:42 +03:00
Egor c05ecf1ecd Allow expired subscriptions to access renewal options 2025-11-11 10:42:33 +03:00
Egor 8381fc52f1 Merge pull request #1876 from Fr1ngg/bedolaga/add-default-localization-support-for-flags-cen8qi
Fix dynamic traffic price recalculation in localization
2025-11-11 10:33:33 +03:00
Egor a697ffa8f1 Fix dynamic traffic localization prices 2025-11-11 10:28:09 +03:00
Egor 4eb36fab6d Merge pull request #1875 from Fr1ngg/revert-1873-bedolaga/add-default-localization-support-for-flags-ocds8i
Revert "Add dynamic localization support for UA and ZH languages"
2025-11-11 10:27:19 +03:00
Egor 4b0780b549 Revert "Add dynamic localization support for UA and ZH languages" 2025-11-11 10:27:10 +03:00
Egor 1befc2982a Merge pull request #1873 from Fr1ngg/bedolaga/add-default-localization-support-for-flags-ocds8i
Add dynamic localization support for UA and ZH languages
2025-11-11 09:10:15 +03:00
Egor df1f225405 Populate dynamic prices and support text for new locales 2025-11-11 09:07:02 +03:00
Egor df6feca53c Add files via upload 2025-11-11 08:37:03 +03:00
Egor bd41efb974 Merge pull request #1869 from Fr1ngg/main
w
2025-11-11 08:34:18 +03:00
Egor a1216bface Update README.md 2025-11-11 08:32:07 +03:00
Egor e0969b315a Merge pull request #1868 from remnawave-contrib/ivan-nginx-readme-make
Added «make» installation command to Readme
2025-11-11 08:30:06 +03:00
Ivan.Nginx 395bccf4e5 Merge branch 'Fr1ngg:main' into main 2025-11-11 08:23:02 +03:00
Ivan.Nginx f6e5cfce54 Merge pull request #2 from remnawave-contrib/ivan-nginx-readme-add-make-installation
Update installation instructions for 'make' command
2025-11-11 08:22:16 +03:00
Ivan.Nginx a26e89147b Update installation instructions for 'make' command 2025-11-11 08:21:34 +03:00
Egor 4396020177 Merge pull request #1867 from Fr1ngg/dev4
[FEAT] Add integration metadata for miniapp payment methods
2025-11-11 08:16:20 +03:00
Egor e2c89ec19e Merge pull request #1866 from Fr1ngg/bedolaga/extend-api-for-payment-method-integration-types
[FEAT] Add integration metadata for miniapp payment methods
2025-11-11 08:15:06 +03:00
Egor 68c125ba14 feat: expose integration metadata for miniapp payments 2025-11-11 08:11:42 +03:00
Egor 8c794fc91c Update README.md 2025-11-11 07:42:15 +03:00
Egor efbe8475df Update README.md 2025-11-11 07:41:43 +03:00
Egor 7835e02d5c Merge pull request #1860 from Gy9vin/main
Апи поиск в боте по тгID
2025-11-11 02:08:32 +03:00
Mikhail 21a5499af8 Merge branch 'Fr1ngg:main' into main 2025-11-10 13:54:03 +03:00
gy9vin dd85db071a Апи поиск в боте по тгID 2025-11-10 13:53:21 +03:00
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
194 changed files with 31927 additions and 5368 deletions
+65 -6
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
# ===== ПЛАТНАЯ ПОДПИСКА =====
# Сколько устройств доступно по дефолту при покупке платной подписки
@@ -146,6 +155,10 @@ TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,
# Цена за дополнительное устройство (DEFAULT_DEVICE_LIMIT идет бесплатно!)
PRICE_PER_DEVICE=10000
# Включить выбор количества устройств при покупке и продлении
DEVICES_SELECTION_ENABLED=true
# Единое количество устройств для режима без выбора (0 — не назначать устройства)
DEVICES_SELECTION_DISABLED_AMOUNT=0
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
REFERRAL_PROGRAM_ENABLED=true
@@ -167,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
@@ -176,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)
@@ -228,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
@@ -240,8 +254,13 @@ 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=Пополнение баланса
@@ -256,6 +275,8 @@ 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
@@ -310,6 +331,22 @@ 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 =====
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
@@ -332,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
@@ -383,14 +421,18 @@ 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=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
# ===== ЛОКАЛИЗАЦИЯ =====
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en
AVAILABLE_LANGUAGES=ru,en,ua,zh
# Включить выбор языка при старте и отображение кнопки в меню
LANGUAGE_SELECTION_ENABLED=true
# Часовой пояс
TZ=Europe/Moscow # или UTC, America/New_York и т.д.
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
# Конфигурация приложений для гайда подключения
@@ -428,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.5.5-$(git rev-parse --short HEAD)"
VERSION="v2.7.0-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.5.5-dev-$(git rev-parse --short HEAD)"
VERSION="v2.7.0-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.5.5-pr-$(git rev-parse --short HEAD)"
VERSION="v2.7.0-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+3 -3
View File
@@ -49,13 +49,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.5.5-$(git rev-parse --short HEAD)"
VERSION="v2.7.0-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.5.5-dev-$(git rev-parse --short HEAD)"
VERSION="v2.7.0-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.5.5-pr-$(git rev-parse --short HEAD)"
VERSION="v2.7.0-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+1
View File
@@ -1,5 +1,6 @@
# Игнорируем все файлы и папки по умолчанию
*
docker-compose.override.yml
# Исключения: разрешаем только нужные файлы
!.dockerignore
+3 -3
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v2.5.5"
ARG VERSION="v2.7.0"
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 ""
+576 -542
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
+12 -5
View File
@@ -58,6 +58,8 @@ 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,
trials as admin_trials,
)
from app.handlers.stars_payments import register_stars_handlers
@@ -172,6 +174,8 @@ 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)
admin_trials.register_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
user_polls.register_handlers(dp)
@@ -180,11 +184,14 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
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("Бот успешно настроен")
+339 -46
View File
@@ -6,7 +6,9 @@ import re
import html
from collections import defaultdict
from datetime import time
from typing import List, Optional, Union, Dict
from typing import Dict, List, Optional, Union
from urllib.parse import urlparse
from zoneinfo import ZoneInfo
from pydantic_settings import BaseSettings
from pydantic import field_validator, Field
from pathlib import Path
@@ -18,6 +20,9 @@ DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS = [
]
logger = logging.getLogger(__name__)
class Settings(BaseSettings):
BOT_TOKEN: str
@@ -57,6 +62,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"
@@ -70,6 +77,7 @@ 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"
@@ -78,9 +86,10 @@ class Settings(BaseSettings):
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
@@ -120,10 +129,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 = ""
@@ -139,7 +150,6 @@ class Settings(BaseSettings):
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"
@@ -151,8 +161,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
@@ -177,12 +189,16 @@ 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
@@ -239,6 +255,7 @@ class Settings(BaseSettings):
MULENPAY_PAYMENT_MODE: int = 4
MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000
MULENPAY_MAX_AMOUNT_KOPEKS: int = 10000000
MULENPAY_IFRAME_EXPECTED_ORIGIN: Optional[str] = None
PAL24_ENABLED: bool = False
PAL24_API_TOKEN: Optional[str] = None
@@ -256,6 +273,20 @@ class Settings(BaseSettings):
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
@@ -277,6 +308,7 @@ class Settings(BaseSettings):
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"
@@ -305,6 +337,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"
@@ -543,6 +582,34 @@ 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:
@@ -621,17 +688,51 @@ 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]:
defaults = ["ru", "en", "ua", "zh"]
try:
langs = self.AVAILABLE_LANGUAGES
if isinstance(langs, str):
if not langs.strip():
return ["ru", "en"]
return [x.strip() for x in langs.split(',') if x.strip()]
return ["ru", "en"]
except AttributeError:
return ["ru", "en"]
return defaults
candidates: List[str]
if isinstance(langs, str):
if not langs.strip():
return defaults
candidates = [chunk.strip() for chunk in langs.split(',')]
elif isinstance(langs, (list, tuple, set)):
candidates = [str(item).strip() for item in langs]
else:
return defaults
cleaned: List[str] = []
seen: set[str] = set()
for code in candidates:
if not code:
continue
normalized = code.lower()
if normalized in seen:
continue
seen.add(normalized)
cleaned.append(code)
return cleaned or defaults
def is_language_selection_enabled(self) -> bool:
return bool(getattr(self, "LANGUAGE_SELECTION_ENABLED", True))
@@ -781,9 +882,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
@@ -825,6 +970,20 @@ class Settings(BaseSettings):
def get_mulenpay_display_name_html(self) -> str:
return html.escape(self.get_mulenpay_display_name())
def get_mulenpay_expected_origin(self) -> Optional[str]:
override = (self.MULENPAY_IFRAME_EXPECTED_ORIGIN or "").strip()
if override:
return override
base_url = (self.MULENPAY_BASE_URL or "").strip()
if not base_url:
return None
parsed = urlparse(base_url)
if parsed.scheme and parsed.netloc:
return f"{parsed.scheme}://{parsed.netloc}"
return None
def is_pal24_enabled(self) -> bool:
return (
self.PAL24_ENABLED
@@ -832,6 +991,74 @@ 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
@@ -839,6 +1066,24 @@ class Settings(BaseSettings):
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"
@@ -920,6 +1165,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
@@ -962,6 +1214,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
@@ -1053,34 +1308,7 @@ 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,
@@ -1088,8 +1316,6 @@ class Settings(BaseSettings):
"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:
@@ -1347,15 +1573,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",
]
+17
View File
@@ -3,6 +3,7 @@ from datetime import datetime
from typing import Optional
from sqlalchemy import select
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -90,6 +91,7 @@ async def update_mulenpay_payment_status(
paid_at: Optional[datetime] = None,
callback_payload: Optional[dict] = None,
mulen_payment_id: Optional[int] = None,
metadata: Optional[dict] = None,
) -> MulenPayPayment:
payment.status = status
if is_paid is not None:
@@ -100,6 +102,8 @@ async def update_mulenpay_payment_status(
payment.callback_payload = callback_payload
if mulen_payment_id is not None and not payment.mulen_payment_id:
payment.mulen_payment_id = mulen_payment_id
if metadata is not None:
payment.metadata_json = metadata
payment.updated_at = datetime.utcnow()
await db.commit()
@@ -107,6 +111,19 @@ async def update_mulenpay_payment_status(
return payment
async def update_mulenpay_payment_metadata(
db: AsyncSession,
*,
payment: MulenPayPayment,
metadata: dict,
) -> MulenPayPayment:
payment.metadata_json = metadata
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
return payment
async def link_mulenpay_payment_to_transaction(
db: AsyncSession,
*,
+3
View File
@@ -96,6 +96,7 @@ async def update_pal24_payment_status(
balance_currency: Optional[str] = None,
payer_account: Optional[str] = None,
callback_payload: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> Pal24Payment:
update_values: Dict[str, Any] = {
"status": status,
@@ -121,6 +122,8 @@ async def update_pal24_payment_status(
update_values["payer_account"] = payer_account
if callback_payload is not None:
update_values["callback_payload"] = callback_payload
if metadata is not None:
update_values["metadata_json"] = metadata
update_values["last_status"] = status
+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
+32
View File
@@ -263,3 +263,35 @@ async def get_poll_statistics(db: AsyncSession, poll_id: int) -> dict:
"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()
+12 -4
View File
@@ -47,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:
@@ -80,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,
)
@@ -260,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}
@@ -506,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)
@@ -514,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:
+293 -35
View File
@@ -1,7 +1,7 @@
import logging
from datetime import datetime, timedelta
from typing import Iterable, Optional, List, Tuple
from sqlalchemy import select, and_, func
from sqlalchemy import select, and_, func, delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -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__)
@@ -41,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(
@@ -69,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)
@@ -126,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,
@@ -184,16 +186,118 @@ async def create_paid_subscription(
return subscription
async def replace_subscription(
db: AsyncSession,
subscription: Subscription,
*,
duration_days: int,
traffic_limit_gb: int,
device_limit: int,
connected_squads: List[str],
is_trial: bool,
autopay_enabled: Optional[bool] = None,
autopay_days_before: Optional[int] = None,
update_server_counters: bool = False,
) -> Subscription:
"""Перезаписывает параметры существующей подписки пользователя."""
current_time = datetime.utcnow()
old_squads = set(subscription.connected_squads or [])
new_squads = set(connected_squads or [])
new_autopay_enabled = (
subscription.autopay_enabled
if autopay_enabled is None
else autopay_enabled
)
new_autopay_days_before = (
subscription.autopay_days_before
if autopay_days_before is None
else autopay_days_before
)
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.is_trial = is_trial
subscription.start_date = current_time
subscription.end_date = current_time + timedelta(days=duration_days)
subscription.traffic_limit_gb = traffic_limit_gb
subscription.traffic_used_gb = 0.0
subscription.device_limit = device_limit
subscription.connected_squads = list(new_squads)
subscription.subscription_url = None
subscription.subscription_crypto_link = None
subscription.remnawave_short_uuid = None
subscription.autopay_enabled = new_autopay_enabled
subscription.autopay_days_before = new_autopay_days_before
subscription.updated_at = current_time
await db.commit()
await db.refresh(subscription)
if update_server_counters:
try:
from app.database.crud.server_squad import (
add_user_to_servers,
get_server_ids_by_uuids,
remove_user_from_servers,
)
squads_to_remove = old_squads - new_squads
squads_to_add = new_squads - old_squads
if squads_to_remove:
server_ids = await get_server_ids_by_uuids(db, list(squads_to_remove))
if server_ids:
await remove_user_from_servers(db, sorted(server_ids))
if squads_to_add:
server_ids = await get_server_ids_by_uuids(db, list(squads_to_add))
if server_ids:
await add_user_to_servers(db, sorted(server_ids))
logger.info(
"♻️ Обновлены параметры подписки %s: удалено сквадов %s, добавлено %s",
subscription.id,
len(squads_to_remove),
len(squads_to_add),
)
except Exception as error:
logger.error(
"⚠️ Ошибка обновления счетчиков серверов при замене подписки %s: %s",
subscription.id,
error,
)
return subscription
async def extend_subscription(
db: AsyncSession,
subscription: 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(
@@ -202,27 +306,15 @@ 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)
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
# УДАЛЕНО: Автоматическая конвертация триала по длительности
# Теперь триал конвертируется ТОЛЬКО после успешного коммита продления
# и ТОЛЬКО вызывающей функцией (например, _auto_extend_subscription)
# Логируем статус подписки перед проверкой
logger.info(f"🔄 Продление подписки {subscription.id}, текущий статус: {subscription.status}, дни: {days}")
@@ -584,10 +676,113 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
"purchased_today": purchased_today,
"purchased_week": purchased_week,
"purchased_month": purchased_month,
"trial_to_paid_conversion": trial_to_paid_conversion,
"renewals_count": renewals_count
"trial_to_paid_conversion": trial_to_paid_conversion,
"renewals_count": renewals_count
}
async def get_trial_statistics(db: AsyncSession) -> dict:
now = datetime.utcnow()
total_trials_result = await db.execute(
select(func.count(Subscription.id)).where(Subscription.is_trial.is_(True))
)
total_trials = total_trials_result.scalar() or 0
active_trials_result = await db.execute(
select(func.count(Subscription.id)).where(
Subscription.is_trial.is_(True),
Subscription.end_date > now,
Subscription.status.in_(
[SubscriptionStatus.TRIAL.value, SubscriptionStatus.ACTIVE.value]
),
)
)
active_trials = active_trials_result.scalar() or 0
resettable_trials_result = await db.execute(
select(func.count(Subscription.id))
.join(User, Subscription.user_id == User.id)
.where(
Subscription.is_trial.is_(True),
Subscription.end_date <= now,
User.has_had_paid_subscription.is_(False),
)
)
resettable_trials = resettable_trials_result.scalar() or 0
return {
"used_trials": total_trials,
"active_trials": active_trials,
"resettable_trials": resettable_trials,
}
async def reset_trials_for_users_without_paid_subscription(db: AsyncSession) -> int:
now = datetime.utcnow()
result = await db.execute(
select(Subscription)
.options(
selectinload(Subscription.user),
selectinload(Subscription.subscription_servers),
)
.join(User, Subscription.user_id == User.id)
.where(
Subscription.is_trial.is_(True),
Subscription.end_date <= now,
User.has_had_paid_subscription.is_(False),
)
)
subscriptions = result.scalars().unique().all()
if not subscriptions:
return 0
reset_count = len(subscriptions)
for subscription in subscriptions:
try:
await decrement_subscription_server_counts(
db,
subscription,
subscription_servers=subscription.subscription_servers,
)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"Не удалось обновить счётчики серверов при сбросе триала %s: %s",
subscription.id,
error,
)
subscription_ids = [subscription.id for subscription in subscriptions]
if subscription_ids:
try:
await db.execute(
delete(SubscriptionServer).where(
SubscriptionServer.subscription_id.in_(subscription_ids)
)
)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"Ошибка удаления серверных связей триалов %s: %s",
subscription_ids,
error,
)
raise
await db.execute(delete(Subscription).where(Subscription.id.in_(subscription_ids)))
try:
await db.commit()
except Exception as error: # pragma: no cover - defensive logging
await db.rollback()
logger.error("Ошибка сохранения сброса триалов: %s", error)
raise
logger.info("♻️ Сброшено триальных подписок: %s", reset_count)
return reset_count
async def update_subscription_usage(
db: AsyncSession,
subscription: Subscription,
@@ -910,7 +1105,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)
)
@@ -1127,7 +1322,13 @@ async def check_and_update_subscription_status(
current_time = datetime.utcnow()
logger.info(f"🔍 Проверка статуса подписки {subscription.id}, текущий статус: {subscription.status}, дата окончания: {subscription.end_date}, текущее время: {current_time}")
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):
@@ -1144,6 +1345,63 @@ async def check_and_update_subscription_status(
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,
+29 -5
View File
@@ -333,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:
+170 -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,14 +39,16 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
select(User)
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
)
.where(User.id == user_id)
)
user = result.scalar_one_or_none()
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return user
@@ -56,26 +59,64 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
select(User)
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
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:
@@ -122,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,
@@ -287,11 +379,11 @@ async def subtract_user_balance(
*,
consume_promo_offer: bool = False,
) -> bool:
logger.error(f"💸 ОТЛАДКА subtract_user_balance:")
logger.error(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
logger.error(f" 💰 Баланс до списания: {user.balance_kopeks} копеек")
logger.error(f" 💸 Сумма к списанию: {amount_kopeks} копеек")
logger.error(f" 📝 Описание: {description}")
logger.info(f"💸 ОТЛАДКА subtract_user_balance:")
logger.info(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
logger.info(f" 💰 Баланс до списания: {user.balance_kopeks} копеек")
logger.info(f" 💸 Сумма к списанию: {amount_kopeks} копеек")
logger.info(f" 📝 Описание: {description}")
log_context: Optional[Dict[str, object]] = None
if consume_promo_offer:
@@ -500,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)
@@ -514,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))
@@ -588,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(
@@ -611,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))
@@ -680,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]:
@@ -693,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)
)
@@ -738,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]:
@@ -748,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(
@@ -757,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
+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),
}
+111 -11
View File
@@ -67,9 +67,10 @@ class TransactionType(Enum):
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):
@@ -81,6 +82,7 @@ class PaymentMethod(Enum):
MULENPAY = "mulenpay"
PAL24 = "pal24"
WATA = "wata"
PLATEGA = "platega"
MANUAL = "manual"
@@ -413,11 +415,62 @@ class WataPayment(Base):
)
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)
@@ -429,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,
@@ -492,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"
@@ -529,23 +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
@@ -793,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:
+272 -1
View File
@@ -2107,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":
@@ -2289,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,
@@ -2301,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 (
@@ -3459,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("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
@@ -3620,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:
+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')
+53 -8
View File
@@ -28,11 +28,28 @@ class HeleketService:
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)
def _prepare_body(
self,
payload: Dict[str, Any],
*,
ignore_none: bool,
sort_keys: bool,
) -> str:
if ignore_none:
cleaned = {key: value for key, value in payload.items() if value is not None}
else:
cleaned = dict(payload)
serialized = json.dumps(
cleaned,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=sort_keys,
)
if "/" in serialized:
serialized = serialized.replace("/", "\\/")
return serialized
def _generate_signature(self, body: str) -> str:
@@ -41,12 +58,18 @@ class HeleketService:
raw = f"{encoded}{api_key}"
return hashlib.md5(raw.encode("utf-8")).hexdigest()
async def _request(self, endpoint: str, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
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)
body = self._prepare_body(payload, ignore_none=True, sort_keys=True)
signature = self._generate_signature(body)
url = f"{self.base_url}/{endpoint.lstrip('/')}"
@@ -59,7 +82,12 @@ class HeleketService:
try:
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, data=body.encode("utf-8"), headers=headers) as response:
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)
@@ -104,6 +132,22 @@ class HeleketService:
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 сервис не настроен, подпись пропускается")
@@ -118,8 +162,9 @@ class HeleketService:
logger.error("Heleket webhook без подписи")
return False
data = {key: value for key, value in payload.items() if key != "sign"}
body = self._prepare_body(data)
data = dict(payload)
data.pop("sign", None)
body = self._prepare_body(data, ignore_none=False, sort_keys=False)
expected = self._generate_signature(body)
is_valid = hmac.compare_digest(expected, str(signature))
+4 -4
View File
@@ -1,4 +1,4 @@
"""Flask webhook server for PayPalych postbacks."""
"""Flask webhook server for PayPalych callbacks."""
from __future__ import annotations
@@ -65,7 +65,7 @@ def create_pal24_flask_app(
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
@@ -73,7 +73,7 @@ def create_pal24_flask_app(
async def process() -> bool:
async with AsyncSessionLocal() as db:
try:
return await payment_service.process_pal24_postback(db, parsed_payload)
return await payment_service.process_pal24_callback(db, parsed_payload)
except Exception:
await db.rollback()
raise
@@ -112,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()
+270 -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,294 @@ 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}")
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"]:
# Извлекаем 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")
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 = None
if yookassa_payment_id and hasattr(db, "execute"):
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 +315,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 +328,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 +379,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 +389,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
+22 -25
View File
@@ -42,7 +42,16 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
"title": "🤖 Основные",
"description": "Базовые настройки бота, обязательные каналы и ключевые сервисы.",
"icon": "🤖",
"categories": ("CORE", "CHANNEL"),
"categories": (
"CORE",
"CHANNEL",
"TIMEZONE",
"DATABASE",
"POSTGRES",
"SQLITE",
"REDIS",
"REMNAWAVE",
),
},
"support": {
"title": "💬 Поддержка",
@@ -52,16 +61,18 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
},
"payments": {
"title": "💳 Платежные системы",
"description": "YooKassa, CryptoBot, Heleket, MulenPay, PAL24, Wata, Tribute и Telegram Stars.",
"description": "YooKassa, CryptoBot, Heleket, MulenPay, PAL24, Wata, Platega, Tribute и Telegram Stars.",
"icon": "💳",
"categories": (
"PAYMENT",
"PAYMENT_VERIFICATION",
"YOOKASSA",
"CRYPTOBOT",
"HELEKET",
"MULENPAY",
"PAL24",
"WATA",
"PLATEGA",
"TRIBUTE",
"TELEGRAM",
),
@@ -114,18 +125,6 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
"ADDITIONAL",
),
},
"database": {
"title": "💾 База данных",
"description": "Режим базы, параметры PostgreSQL, SQLite и Redis.",
"icon": "💾",
"categories": ("DATABASE", "POSTGRES", "SQLITE", "REDIS"),
},
"remnawave": {
"title": "🌐 RemnaWave API",
"description": "Интеграция с RemnaWave: URL, ключи и способы авторизации.",
"icon": "🌐",
"categories": ("REMNAWAVE",),
},
"server": {
"title": "📊 Статус серверов",
"description": "Мониторинг серверов, SLA и внешние метрики.",
@@ -142,13 +141,14 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
"title": "⚡ Расширенные",
"description": "Web API, webhook, логирование, модерация и режим отладки.",
"icon": "",
"categories": ("WEB_API", "WEBHOOK", "LOG", "MODERATION", "DEBUG"),
},
"external_admin": {
"title": "🛡️ Внешняя админка",
"description": "Токен, по которому внешняя админка проверяет запросы.",
"icon": "🛡️",
"categories": ("EXTERNAL_ADMIN",),
"categories": (
"WEB_API",
"WEBHOOK",
"LOG",
"MODERATION",
"DEBUG",
"EXTERNAL_ADMIN",
),
},
}
@@ -161,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(
@@ -257,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,
@@ -313,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 ("", "Бонусы не заданы")
+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(
(
+1 -1
View File
@@ -449,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]}")
+104 -40
View File
@@ -3,6 +3,7 @@ import asyncio
from datetime import datetime, timedelta
from typing import Optional
from aiogram import Dispatcher, types, F
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, or_
@@ -764,48 +765,94 @@ 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(
chat_id=user.telegram_id,
photo=media_file_id,
caption=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
# Ограничение на количество одновременных отправок и базовая задержка между сообщениями,
# чтобы избежать перегрузки бота и лимитов Telegram при больших рассылках
max_concurrent_sends = 5
per_message_delay = 0.05
semaphore = asyncio.Semaphore(max_concurrent_sends)
async def send_single_broadcast(user):
"""Отправляет одно сообщение рассылки с семафором ограничения"""
async with semaphore:
for attempt in range(3):
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,
text=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
)
await asyncio.sleep(per_message_delay)
return True, user.telegram_id
except TelegramRetryAfter as e:
retry_delay = min(e.retry_after + 1, 30)
logger.warning(
f"Превышен лимит Telegram для {user.telegram_id}, ожидание {retry_delay} сек."
)
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
await asyncio.sleep(retry_delay)
except TelegramForbiddenError:
# Пользователь мог удалить бота или запретить сообщения
logger.info(f"Рассылка недоступна для пользователя {user.telegram_id}: Forbidden")
return False, user.telegram_id
except TelegramBadRequest as e:
logger.error(
f"Некорректный запрос при рассылке пользователю {user.telegram_id}: {e}"
)
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
return False, user.telegram_id
except Exception as e:
logger.error(
f"Ошибка отправки рассылки пользователю {user.telegram_id} (попытка {attempt + 1}/3): {e}"
)
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}")
await asyncio.sleep(0.5 * (attempt + 1))
return False, user.telegram_id
# Отправляем сообщения пакетами для эффективности
batch_size = 50
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.25)
broadcast_history.sent_count = sent_count
broadcast_history.failed_count = failed_count
@@ -847,7 +894,24 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
async def get_target_users(db: AsyncSession, target: str) -> list:
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
# Загружаем всех активных пользователей батчами, чтобы не ограничиваться 10к
users: list[User] = []
offset = 0
batch_size = 5000
while True:
batch = await get_users_list(
db,
offset=offset,
limit=batch_size,
status=UserStatus.ACTIVE,
)
if not batch:
break
users.extend(batch)
offset += batch_size
if target == "all":
return users
+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")
+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,
+81 -52
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import html
import logging
import re
@@ -1896,66 +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]] = [
[
build_miniapp_or_callback_button(
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}%
+1 -1
View File
@@ -45,7 +45,7 @@ MIGRATION_PAGE_SIZE = 8
def _format_duration(seconds: float) -> str:
if seconds < 1:
return "<1с"
return "менее 1с"
minutes, sec = divmod(int(seconds), 60)
if minutes:
+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:
+36 -15
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:
@@ -750,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"
@@ -778,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))
+86
View File
@@ -0,0 +1,86 @@
import logging
from aiogram import Dispatcher, F, types
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.subscription import (
get_trial_statistics,
reset_trials_for_users_without_paid_subscription,
)
from app.database.models import User
from app.keyboards.admin import get_admin_trials_keyboard
from app.localization.texts import get_texts
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
@admin_required
@error_handler
async def show_trials_panel(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
stats = await get_trial_statistics(db)
message = texts.t("ADMIN_TRIALS_TITLE", "🧪 Управление триалами") + "\n\n" + texts.t(
"ADMIN_TRIALS_STATS",
"• Использовано всего: {used}\n"
"• Активно сейчас: {active}\n"
"• Доступно к сбросу: {resettable}",
).format(
used=stats.get("used_trials", 0),
active=stats.get("active_trials", 0),
resettable=stats.get("resettable_trials", 0),
)
await callback.message.edit_text(
message,
reply_markup=get_admin_trials_keyboard(db_user.language),
)
await callback.answer()
@admin_required
@error_handler
async def reset_trials(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
reset_count = await reset_trials_for_users_without_paid_subscription(db)
stats = await get_trial_statistics(db)
message = texts.t(
"ADMIN_TRIALS_RESET_RESULT",
"♻️ Сбросили {reset_count} триалов.\n\n"
"• Использовано всего: {used}\n"
"• Активно сейчас: {active}\n"
"• Доступно к сбросу: {resettable}",
).format(
reset_count=reset_count,
used=stats.get("used_trials", 0),
active=stats.get("active_trials", 0),
resettable=stats.get("resettable_trials", 0),
)
await callback.message.edit_text(
message,
reply_markup=get_admin_trials_keyboard(db_user.language),
)
await callback.answer(texts.t("ADMIN_TRIALS_RESET_TOAST", "✅ Сброс завершен"))
def register_handlers(dp: Dispatcher) -> None:
dp.callback_query.register(
show_trials_panel,
F.data == "admin_trials",
)
dp.callback_query.register(
reset_trials,
F.data == "admin_trials_reset",
)
+596 -87
View File
@@ -1,6 +1,7 @@
import logging
import re
from datetime import datetime, timedelta
from typing import Optional
from typing import Optional, List, Tuple
from aiogram import Dispatcher, types, F
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
@@ -10,7 +11,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.states import AdminStates
from app.database.models import User, UserStatus, Subscription, SubscriptionStatus, TransactionType
from app.database.crud.user import get_user_by_id
from app.database.crud.user import (
get_user_by_id,
get_user_by_telegram_id,
get_user_by_username,
get_referrals,
)
from app.database.crud.campaign import (
get_campaign_registration_by_user,
get_campaign_statistics,
@@ -35,6 +41,9 @@ from app.database.crud.server_squad import (
get_server_ids_by_uuids,
)
from app.services.subscription_service import SubscriptionService
from app.utils.subscription_utils import (
resolve_hwid_device_limit_for_payload,
)
logger = logging.getLogger(__name__)
@@ -999,7 +1008,8 @@ async def _render_user_subscription_overview(
subscription = profile["subscription"]
text = "📱 <b>Подписка и настройки пользователя</b>\n\n"
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n\n"
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
text += f"👤 {user_link} (ID: <code>{user.telegram_id}</code>)\n\n"
keyboard = []
@@ -1159,7 +1169,8 @@ async def show_user_transactions(
transactions = await get_user_transactions(db, user_id, limit=10)
text = f"💳 <b>Транзакции пользователя</b>\n\n"
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n"
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
text += f"👤 {user_link} (ID: <code>{user.telegram_id}</code>)\n"
text += f"💰 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n"
if transactions:
@@ -1436,16 +1447,41 @@ async def show_user_management(
else:
sections.append(texts.ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE)
if user.promo_group:
promo_group = user.promo_group
# Display promo groups
primary_group = user.get_primary_promo_group()
if primary_group:
sections.append(
texts.t(
"ADMIN_USER_PROMO_GROUPS_PRIMARY",
"⭐ Основная: {name} (Priority: {priority})",
).format(name=primary_group.name, priority=getattr(primary_group, "priority", 0))
)
sections.append(
texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP.format(
name=promo_group.name,
server_discount=promo_group.server_discount_percent,
traffic_discount=promo_group.traffic_discount_percent,
device_discount=promo_group.device_discount_percent,
name=primary_group.name,
server_discount=primary_group.server_discount_percent,
traffic_discount=primary_group.traffic_discount_percent,
device_discount=primary_group.device_discount_percent,
)
)
# Show additional groups if any
if user.user_promo_groups and len(user.user_promo_groups) > 1:
additional_groups = [
upg.promo_group for upg in user.user_promo_groups
if upg.promo_group and upg.promo_group.id != primary_group.id
]
if additional_groups:
sections.append(
texts.t(
"ADMIN_USER_PROMO_GROUPS_ADDITIONAL",
"Дополнительные группы:",
)
)
for group in additional_groups:
sections.append(
f"{group.name} (Priority: {getattr(group, 'priority', 0)})"
)
else:
sections.append(texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE)
@@ -1486,6 +1522,396 @@ async def show_user_management(
await callback.answer()
async def _build_user_referrals_view(
db: AsyncSession,
language: str,
user_id: int,
limit: int = 30,
) -> Optional[Tuple[str, InlineKeyboardMarkup]]:
texts = get_texts(language)
user = await get_user_by_id(db, user_id)
if not user:
return None
referrals = await get_referrals(db, user_id)
header = texts.t(
"ADMIN_USER_REFERRALS_TITLE",
"🤝 <b>Рефералы пользователя</b>",
)
summary = texts.t(
"ADMIN_USER_REFERRALS_SUMMARY",
"👤 {name} (ID: <code>{telegram_id}</code>)\n👥 Всего рефералов: {count}",
).format(
name=user.full_name,
telegram_id=user.telegram_id,
count=len(referrals),
)
lines: List[str] = [header, summary]
if referrals:
lines.append(
texts.t(
"ADMIN_USER_REFERRALS_LIST_HEADER",
"<b>Список рефералов:</b>",
)
)
items = []
for referral in referrals[:limit]:
username_part = (
f", @{referral.username}"
if referral.username
else ""
)
referral_link = f'<a href="tg://user?id={referral.telegram_id}">{referral.full_name}</a>'
items.append(
texts.t(
"ADMIN_USER_REFERRALS_LIST_ITEM",
"{name} (ID: <code>{telegram_id}</code>{username_part})",
).format(
name=referral_link,
telegram_id=referral.telegram_id,
username_part=username_part,
)
)
lines.append("\n".join(items))
if len(referrals) > limit:
remaining = len(referrals) - limit
lines.append(
texts.t(
"ADMIN_USER_REFERRALS_LIST_TRUNCATED",
"• … и ещё {count} рефералов",
).format(count=remaining)
)
else:
lines.append(
texts.t(
"ADMIN_USER_REFERRALS_EMPTY",
"Рефералов пока нет.",
)
)
lines.append(
texts.t(
"ADMIN_USER_REFERRALS_EDIT_HINT",
"✏️ Чтобы изменить список, нажмите «✏️ Редактировать» ниже.",
)
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t(
"ADMIN_USER_REFERRALS_EDIT_BUTTON",
"✏️ Редактировать",
),
callback_data=f"admin_user_referrals_edit_{user_id}",
)
],
[
InlineKeyboardButton(
text=texts.BACK,
callback_data=f"admin_user_manage_{user_id}",
)
],
]
)
return "\n\n".join(lines), keyboard
@admin_required
@error_handler
async def show_user_referrals(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
user_id = int(callback.data.split('_')[-1])
current_state = await state.get_state()
if current_state == AdminStates.editing_user_referrals:
data = await state.get_data()
preserved_data = {
key: value
for key, value in data.items()
if key not in {"editing_referrals_user_id", "referrals_message_id"}
}
await state.clear()
if preserved_data:
await state.update_data(**preserved_data)
view = await _build_user_referrals_view(db, db_user.language, user_id)
if not view:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
text, keyboard = view
await callback.message.edit_text(
text,
reply_markup=keyboard,
)
await callback.answer()
@admin_required
@error_handler
async def start_edit_user_referrals(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
user_id = int(callback.data.split('_')[-1])
user = await get_user_by_id(db, user_id)
if not user:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
texts = get_texts(db_user.language)
prompt = texts.t(
"ADMIN_USER_REFERRALS_EDIT_PROMPT",
(
"✏️ <b>Редактирование рефералов</b>\n\n"
"Отправьте список рефералов для пользователя <b>{name}</b> (ID: <code>{telegram_id}</code>):\n"
"• Используйте TG ID или @username\n"
"• Значения можно указывать через запятую, пробел или с новой строки\n"
"• Чтобы очистить список, отправьте 0 или слово 'нет'\n\n"
"Или нажмите кнопку ниже, чтобы отменить."
),
).format(
name=user.full_name,
telegram_id=user.telegram_id,
)
await state.update_data(
editing_referrals_user_id=user_id,
referrals_message_id=callback.message.message_id,
)
await callback.message.edit_text(
prompt,
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.BACK,
callback_data=f"admin_user_referrals_{user_id}",
)
]
]
),
)
await state.set_state(AdminStates.editing_user_referrals)
await callback.answer()
@admin_required
@error_handler
async def process_edit_user_referrals(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
data = await state.get_data()
user_id = data.get("editing_referrals_user_id")
if not user_id:
await message.answer(
texts.t(
"ADMIN_USER_REFERRALS_STATE_LOST",
"❌ Не удалось определить пользователя. Попробуйте начать сначала.",
)
)
await state.clear()
return
raw_text = message.text.strip()
lower_text = raw_text.lower()
clear_keywords = {"0", "нет", "none", "пусто", "clear"}
clear_requested = lower_text in clear_keywords
tokens: List[str] = []
if not clear_requested:
parts = re.split(r"[,\n]+", raw_text)
for part in parts:
for token in part.split():
cleaned = token.strip()
if cleaned and cleaned not in tokens:
tokens.append(cleaned)
found_users: List[User] = []
not_found: List[str] = []
skipped_self: List[str] = []
duplicate_tokens: List[str] = []
seen_ids = set()
for token in tokens:
normalized = token.strip()
if not normalized:
continue
if normalized.startswith("@"):
normalized = normalized[1:]
user = None
if normalized.isdigit():
try:
user = await get_user_by_telegram_id(db, int(normalized))
except ValueError:
user = None
else:
user = await get_user_by_username(db, normalized)
if not user:
not_found.append(token)
continue
if user.id == user_id:
skipped_self.append(token)
continue
if user.id in seen_ids:
duplicate_tokens.append(token)
continue
seen_ids.add(user.id)
found_users.append(user)
if not found_users and not clear_requested:
error_lines = [
texts.t(
"ADMIN_USER_REFERRALS_NO_VALID",
"❌ Не удалось найти ни одного пользователя по введённым данным.",
)
]
if not_found:
error_lines.append(
texts.t(
"ADMIN_USER_REFERRALS_INVALID_ENTRIES",
"Не найдены: {values}",
).format(values=", ".join(not_found))
)
if skipped_self:
error_lines.append(
texts.t(
"ADMIN_USER_REFERRALS_SELF_SKIPPED",
"Пропущены значения пользователя: {values}",
).format(values=", ".join(skipped_self))
)
await message.answer("\n".join(error_lines))
return
user_service = UserService()
new_referral_ids = [user.id for user in found_users] if not clear_requested else []
success, details = await user_service.update_user_referrals(
db,
user_id,
new_referral_ids,
db_user.id,
)
if not success:
await message.answer(
texts.t(
"ADMIN_USER_REFERRALS_UPDATE_ERROR",
"❌ Не удалось обновить рефералов. Попробуйте позже.",
)
)
return
response_lines = [
texts.t(
"ADMIN_USER_REFERRALS_UPDATED",
"✅ Список рефералов обновлён.",
)
]
total_referrals = details.get("total", len(new_referral_ids))
added = details.get("added", 0)
removed = details.get("removed", 0)
response_lines.append(
texts.t(
"ADMIN_USER_REFERRALS_UPDATED_TOTAL",
"• Текущий список: {total}",
).format(total=total_referrals)
)
if added > 0:
response_lines.append(
texts.t(
"ADMIN_USER_REFERRALS_UPDATED_ADDED",
"• Добавлено: {count}",
).format(count=added)
)
if removed > 0:
response_lines.append(
texts.t(
"ADMIN_USER_REFERRALS_UPDATED_REMOVED",
"• Удалено: {count}",
).format(count=removed)
)
if not_found:
response_lines.append(
texts.t(
"ADMIN_USER_REFERRALS_INVALID_ENTRIES",
"Не найдены: {values}",
).format(values=", ".join(not_found))
)
if skipped_self:
response_lines.append(
texts.t(
"ADMIN_USER_REFERRALS_SELF_SKIPPED",
"Пропущены значения пользователя: {values}",
).format(values=", ".join(skipped_self))
)
if duplicate_tokens:
response_lines.append(
texts.t(
"ADMIN_USER_REFERRALS_DUPLICATES",
"Игнорированы дубли: {values}",
).format(values=", ".join(duplicate_tokens))
)
view = await _build_user_referrals_view(db, db_user.language, user_id)
message_id = data.get("referrals_message_id")
if view and message_id:
try:
await message.bot.edit_message_text(
view[0],
chat_id=message.chat.id,
message_id=message_id,
reply_markup=view[1],
)
except TelegramBadRequest:
await message.answer(view[0], reply_markup=view[1])
elif view:
await message.answer(view[0], reply_markup=view[1])
await message.answer("\n".join(response_lines))
await state.clear()
async def _render_user_promo_group(
message: types.Message,
language: str,
@@ -1494,20 +1920,43 @@ async def _render_user_promo_group(
) -> None:
texts = get_texts(language)
current_group = user.promo_group
# Get primary and all user groups
primary_group = user.get_primary_promo_group()
user_group_ids = [upg.promo_group_id for upg in user.user_promo_groups] if user.user_promo_groups else []
# Build current groups section
if primary_group:
current_line = texts.t(
"ADMIN_USER_PROMO_GROUPS_PRIMARY",
"⭐ Основная: {name} (Priority: {priority})",
).format(name=primary_group.name, priority=getattr(primary_group, "priority", 0))
if current_group:
current_line = texts.ADMIN_USER_PROMO_GROUP_CURRENT.format(name=current_group.name)
discount_line = texts.ADMIN_USER_PROMO_GROUP_DISCOUNTS.format(
servers=current_group.server_discount_percent,
traffic=current_group.traffic_discount_percent,
devices=current_group.device_discount_percent,
servers=primary_group.server_discount_percent,
traffic=primary_group.traffic_discount_percent,
devices=primary_group.device_discount_percent,
)
current_group_id = current_group.id
# Show additional groups if any
if len(user_group_ids) > 1:
additional_groups = [
upg.promo_group for upg in user.user_promo_groups
if upg.promo_group and upg.promo_group.id != primary_group.id
]
if additional_groups:
additional_line = "\n" + texts.t(
"ADMIN_USER_PROMO_GROUPS_ADDITIONAL",
"Дополнительные группы:",
) + "\n"
for group in additional_groups:
additional_line += f"{group.name} (Priority: {getattr(group, 'priority', 0)})\n"
discount_line += additional_line
else:
current_line = texts.ADMIN_USER_PROMO_GROUP_CURRENT_NONE
discount_line = texts.ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE
current_group_id = None
current_line = texts.t(
"ADMIN_USER_PROMO_GROUPS_NONE",
"У пользователя нет промогрупп",
)
discount_line = ""
text = (
f"{texts.ADMIN_USER_PROMO_GROUP_TITLE}\n\n"
@@ -1521,7 +1970,7 @@ async def _render_user_promo_group(
reply_markup=get_user_promo_group_keyboard(
promo_groups,
user.id,
current_group_id,
user_group_ids, # Pass list of all group IDs
language
)
)
@@ -1559,6 +2008,13 @@ async def set_user_promo_group(
db_user: User,
db: AsyncSession
):
from app.database.crud.user_promo_group import (
has_user_promo_group,
add_user_to_promo_group,
remove_user_from_promo_group,
count_user_promo_groups
)
from app.database.crud.promo_group import get_promo_group_by_id
parts = callback.data.split('_')
user_id = int(parts[-2])
@@ -1571,49 +2027,52 @@ async def set_user_promo_group(
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
if user.promo_group_id == group_id:
await callback.answer(texts.ADMIN_USER_PROMO_GROUP_ALREADY, show_alert=True)
return
# Check if user already has this group
has_group = await has_user_promo_group(db, user_id, group_id)
user_service = UserService()
success, updated_user, new_group, old_group = await user_service.update_user_promo_group(
db,
user_id,
group_id
)
if has_group:
# Remove group
# Check if it's the last group
groups_count = await count_user_promo_groups(db, user_id)
if groups_count <= 1:
await callback.answer(
texts.t(
"ADMIN_USER_PROMO_GROUP_CANNOT_REMOVE_LAST",
"❌ Нельзя удалить последнюю промогруппу",
),
show_alert=True
)
return
if not success or not updated_user or not new_group:
await callback.answer(texts.ADMIN_USER_PROMO_GROUP_ERROR, show_alert=True)
return
group = await get_promo_group_by_id(db, group_id)
await remove_user_from_promo_group(db, user_id, group_id)
await callback.answer(
texts.t(
"ADMIN_USER_PROMO_GROUP_REMOVED",
"🗑 Группа «{name}» удалена",
).format(name=group.name if group else ""),
show_alert=True
)
else:
# Add group
group = await get_promo_group_by_id(db, group_id)
if not group:
await callback.answer(texts.ADMIN_USER_PROMO_GROUP_ERROR, show_alert=True)
return
await add_user_to_promo_group(db, user_id, group_id, assigned_by="admin")
await callback.answer(
texts.t(
"ADMIN_USER_PROMO_GROUP_ADDED",
"✅ Группа «{name}» добавлена",
).format(name=group.name),
show_alert=True
)
# Refresh user data and show updated list
user = await get_user_by_id(db, user_id)
promo_groups = await get_promo_groups_with_counts(db)
await _render_user_promo_group(callback.message, db_user.language, updated_user, promo_groups)
await callback.answer(
texts.ADMIN_USER_PROMO_GROUP_UPDATED.format(name=new_group.name),
show_alert=True
)
try:
notification_service = AdminNotificationService(callback.bot)
reason = (
f"Назначено администратором {db_user.full_name} (ID: {db_user.telegram_id})"
)
await notification_service.send_user_promo_group_change_notification(
db,
updated_user,
old_group,
new_group,
reason=reason,
initiator=db_user,
automatic=False,
)
except Exception as notify_error:
logger.error(
"Ошибка отправки уведомления о смене промогруппы пользователя %s: %s",
updated_user.telegram_id,
notify_error,
)
await _render_user_promo_group(callback.message, db_user.language, user, promo_groups)
@@ -1882,9 +2341,10 @@ async def show_inactive_users(
text = f"🗑️ <b>Неактивные пользователи</b>\n"
text += f"Без активности более {settings.INACTIVE_USER_DELETE_MONTHS} месяцев: {len(inactive_users)}\n\n"
for user in inactive_users[:10]:
text += f"👤 {user.full_name}\n"
for user in inactive_users[:10]:
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
text += f"👤 {user_link}\n"
text += f"🆔 <code>{user.telegram_id}</code>\n"
last_activity_display = (
format_time_ago(user.last_activity, db_user.language)
@@ -1986,7 +2446,8 @@ async def show_user_statistics(
campaign_stats = await get_campaign_statistics(db, campaign_registration.campaign_id)
text = f"📊 <b>Статистика пользователя</b>\n\n"
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n\n"
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
text += f"👤 {user_link} (ID: <code>{user.telegram_id}</code>)\n\n"
text += f"<b>Основная информация:</b>\n"
text += f"• Дней с регистрации: {profile['registration_days']}\n"
@@ -3338,7 +3799,15 @@ async def _grant_trial_subscription(db: AsyncSession, user_id: int, admin_id: in
logger.error(f"У пользователя {user_id} уже есть подписка")
return False
subscription = await create_trial_subscription(db, user_id)
forced_devices = None
if not settings.is_devices_selection_enabled():
forced_devices = settings.get_disabled_mode_device_limit()
subscription = await create_trial_subscription(
db,
user_id,
device_limit=forced_devices,
)
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(db, subscription)
@@ -3367,10 +3836,7 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
trial_uuid = await get_random_trial_squad_uuid(
db,
getattr(settings, "TRIAL_SQUAD_UUID", None),
)
trial_uuid = await get_random_trial_squad_uuid(db)
if trial_uuid:
trial_squads = [trial_uuid]
except Exception as error:
@@ -3379,15 +3845,21 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad
admin_id,
error,
)
if getattr(settings, "TRIAL_SQUAD_UUID", None):
trial_squads = [settings.TRIAL_SQUAD_UUID]
forced_devices = None
if not settings.is_devices_selection_enabled():
forced_devices = settings.get_disabled_mode_device_limit()
device_limit = settings.DEFAULT_DEVICE_LIMIT
if forced_devices is not None:
device_limit = forced_devices
subscription = await create_paid_subscription(
db=db,
user_id=user_id,
duration_days=days,
traffic_limit_gb=settings.DEFAULT_TRAFFIC_LIMIT_GB,
device_limit=settings.DEFAULT_DEVICE_LIMIT,
device_limit=device_limit,
connected_squads=trial_squads,
update_server_counters=True,
)
@@ -3591,10 +4063,13 @@ async def admin_buy_subscription(
])
text = f"💳 <b>Покупка подписки для пользователя</b>\n\n"
text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n"
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
text += f"👤 {target_user_link} (ID: {target_user.telegram_id})\n"
text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n"
traffic_text = "Безлимит" if (subscription.traffic_limit_gb or 0) <= 0 else f"{subscription.traffic_limit_gb} ГБ"
devices_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
devices_limit = subscription.device_limit
if devices_limit is None:
devices_limit = settings.DEFAULT_DEVICE_LIMIT
servers_count = len(subscription.connected_squads or [])
text += f"📶 Трафик: {traffic_text}\n"
text += f"📱 Устройства: {devices_limit}\n"
@@ -3680,12 +4155,15 @@ async def admin_buy_subscription_confirm(
return
text = f"💳 <b>Подтверждение покупки подписки</b>\n\n"
text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n"
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
text += f"👤 {target_user_link} (ID: {target_user.telegram_id})\n"
text += f"📅 Период подписки: {period_days} дней\n"
text += f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n"
traffic_text = "Безлимит" if (subscription.traffic_limit_gb or 0) <= 0 else f"{subscription.traffic_limit_gb} ГБ"
devices_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
devices_limit = subscription.device_limit
if devices_limit is None:
devices_limit = settings.DEFAULT_DEVICE_LIMIT
servers_count = len(subscription.connected_squads or [])
text += f"📶 Трафик: {traffic_text}\n"
text += f"📱 Устройства: {devices_limit}\n"
@@ -3832,40 +4310,54 @@ async def admin_buy_subscription_execute(
from app.external.remnawave_api import UserStatus, TrafficLimitStrategy
remnawave_service = RemnaWaveService()
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
if target_user.remnawave_uuid:
async with remnawave_service.get_api_client() as api:
remnawave_user = await api.update_user(
update_kwargs = dict(
uuid=target_user.remnawave_uuid,
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
expire_at=subscription.end_date,
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
full_name=target_user.full_name,
username=target_user.username,
telegram_id=target_user.telegram_id
),
active_internal_squads=subscription.connected_squads
active_internal_squads=subscription.connected_squads,
)
if hwid_limit is not None:
update_kwargs['hwid_device_limit'] = hwid_limit
remnawave_user = await api.update_user(**update_kwargs)
else:
username = f"user_{target_user.telegram_id}"
username = settings.format_remnawave_username(
full_name=target_user.full_name,
username=target_user.username,
telegram_id=target_user.telegram_id,
)
async with remnawave_service.get_api_client() as api:
remnawave_user = await api.create_user(
create_kwargs = dict(
username=username,
expire_at=subscription.end_date,
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
telegram_id=target_user.telegram_id,
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
full_name=target_user.full_name,
username=target_user.username,
telegram_id=target_user.telegram_id
),
active_internal_squads=subscription.connected_squads
active_internal_squads=subscription.connected_squads,
)
if hwid_limit is not None:
create_kwargs['hwid_device_limit'] = hwid_limit
remnawave_user = await api.create_user(**create_kwargs)
if remnawave_user and hasattr(remnawave_user, 'uuid'):
target_user.remnawave_uuid = remnawave_user.uuid
@@ -3882,9 +4374,10 @@ async def admin_buy_subscription_execute(
else:
message = "❌ Ошибка: у пользователя нет существующей подписки"
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
await callback.message.edit_text(
f"{message}\n\n"
f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n"
f"👤 {target_user_link} (ID: {target_user.telegram_id})\n"
f"💰 Списано: {settings.format_price(price_kopeks)}\n"
f"📅 Подписка действительна до: {format_datetime(subscription.end_date)}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
@@ -3892,7 +4385,8 @@ async def admin_buy_subscription_execute(
text="⬅️ Назад к подписке",
callback_data=f"admin_user_subscription_{user_id}"
)]
])
]),
parse_mode="HTML"
)
try:
@@ -4104,12 +4598,12 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_user_promo_group,
F.data.startswith("admin_user_promo_group_") & ~F.data.contains("_set_")
F.data.startswith("admin_user_promo_group_") & ~F.data.contains("_set_") & ~F.data.contains("_toggle_")
)
dp.callback_query.register(
set_user_promo_group,
F.data.startswith("admin_user_promo_group_set_")
F.data.startswith("admin_user_promo_group_toggle_")
)
dp.callback_query.register(
@@ -4122,6 +4616,21 @@ def register_handlers(dp: Dispatcher):
AdminStates.editing_user_balance
)
dp.callback_query.register(
show_user_referrals,
F.data.startswith("admin_user_referrals_") & ~F.data.contains("_edit")
)
dp.callback_query.register(
start_edit_user_referrals,
F.data.startswith("admin_user_referrals_edit_")
)
dp.message.register(
process_edit_user_referrals,
AdminStates.editing_user_referrals
)
dp.callback_query.register(
start_send_user_message,
F.data.startswith("admin_user_send_message_")
+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:
+36 -5
View File
@@ -67,7 +67,7 @@ async def start_cryptobot_payment(
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
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)
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
@@ -79,7 +79,12 @@ async def start_cryptobot_payment(
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="cryptobot", current_rate=current_rate)
await state.update_data(
payment_method="cryptobot",
current_rate=current_rate,
cryptobot_prompt_message_id=callback.message.message_id,
cryptobot_prompt_chat_id=callback.message.chat.id,
)
await callback.answer()
@@ -158,8 +163,29 @@ async def process_cryptobot_payment_amount(
[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(
state_data = await state.get_data()
prompt_message_id = state_data.get("cryptobot_prompt_message_id")
prompt_chat_id = state_data.get("cryptobot_prompt_chat_id", message.chat.id)
try:
await message.delete()
except Exception as delete_error: # pragma: no cover - depends on bot rights
logger.warning(
"Не удалось удалить сообщение с суммой CryptoBot: %s",
delete_error,
)
if prompt_message_id:
try:
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
except Exception as delete_error: # pragma: no cover - diagnostics
logger.warning(
"Не удалось удалить сообщение с запросом суммы CryptoBot: %s",
delete_error,
)
invoice_message = await message.answer(
f"🪙 <b>Оплата криптовалютой</b>\n\n"
f"💰 Сумма к зачислению: {amount_rubles:.0f}\n"
f"💵 К оплате: {amount_usd:.2f} USD\n"
@@ -177,7 +203,12 @@ async def process_cryptobot_payment_amount(
reply_markup=keyboard,
parse_mode="HTML"
)
await state.update_data(
cryptobot_invoice_message_id=invoice_message.message_id,
cryptobot_invoice_chat_id=invoice_message.chat.id,
)
await state.clear()
logger.info(f"Создан CryptoBot платеж для пользователя {db_user.telegram_id}: "
+54 -3
View File
@@ -1,8 +1,10 @@
import logging
from datetime import datetime
from typing import Optional
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -55,7 +57,7 @@ async def start_heleket_payment(
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)
quick_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_buttons:
keyboard.inline_keyboard = quick_buttons + keyboard.inline_keyboard
@@ -66,7 +68,11 @@ async def start_heleket_payment(
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="heleket")
await state.update_data(
payment_method="heleket",
heleket_prompt_message_id=callback.message.message_id,
heleket_prompt_chat_id=callback.message.chat.id,
)
await callback.answer()
@@ -181,7 +187,52 @@ async def process_heleket_payment_amount(
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
])
await message.answer("\n".join(details), parse_mode="HTML", reply_markup=keyboard)
state_data = await state.get_data()
prompt_message_id = state_data.get("heleket_prompt_message_id")
prompt_chat_id = state_data.get("heleket_prompt_chat_id", message.chat.id)
try:
await message.delete()
except Exception as delete_error: # pragma: no cover - depends on bot rights
logger.warning("Не удалось удалить сообщение с суммой Heleket: %s", delete_error)
if prompt_message_id:
try:
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
except Exception as delete_error: # pragma: no cover - diagnostic
logger.warning(
"Не удалось удалить сообщение с запросом суммы Heleket: %s",
delete_error,
)
invoice_message = await message.answer(
"\n".join(details), parse_mode="HTML", reply_markup=keyboard
)
try:
from app.services import payment_service as payment_module
payment = await payment_module.get_heleket_payment_by_id(db, result["local_payment_id"])
if payment:
metadata = dict(getattr(payment, "metadata_json", {}) or {})
metadata["invoice_message"] = {
"chat_id": invoice_message.chat.id,
"message_id": invoice_message.message_id,
}
await db.execute(
update(payment.__class__)
.where(payment.__class__.id == payment.id)
.values(metadata_json=metadata, updated_at=datetime.utcnow())
)
await db.commit()
except Exception as error: # pragma: no cover - diagnostics
logger.warning("Не удалось сохранить сообщение Heleket: %s", error)
await state.update_data(
heleket_invoice_message_id=invoice_message.message_id,
heleket_invoice_chat_id=invoice_message.chat.id,
)
await state.clear()
+217 -21
View File
@@ -1,12 +1,15 @@
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
@@ -26,34 +29,61 @@ logger = logging.getLogger(__name__)
TRANSACTIONS_PER_PAGE = 10
def get_quick_amount_buttons(language: str) -> list:
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]
periods = periods[:6] # Limit to 6 periods
for period in periods:
price_attr = f"PRICE_{period}_DAYS"
if hasattr(settings, price_attr):
price_kopeks = getattr(settings, price_attr)
price_rubles = price_kopeks // 100
callback_data = f"quick_amount_{price_kopeks}"
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=f"{price_rubles} ₽ ({period} дней)",
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
@@ -179,18 +209,126 @@ async def handle_balance_history_pagination(
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)
await callback.message.edit_text(
payment_text,
reply_markup=get_payment_methods_keyboard(0, db_user.language),
parse_mode="HTML"
)
# Добавляем информацию о текущем тарифе пользователя
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()
@@ -257,6 +395,10 @@ async def handle_successful_topup_with_cart(
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"Хотите продолжить оформление?"
@@ -390,6 +532,14 @@ async def process_topup_amount(
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
@@ -501,6 +651,14 @@ async def handle_quick_amount_selection(
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
@@ -588,6 +746,21 @@ async def handle_topup_amount_callback(
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, start_platega_payment
data = await state.get_data()
method_code = int(data.get("platega_method", 0)) if data else 0
if method_code > 0:
async with AsyncSessionLocal() as db:
await process_platega_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
else:
await state.update_data(platega_pending_amount=amount_kopeks)
await start_platega_payment(callback, db_user, state)
elif method == "pal24":
from app.database.database import AsyncSessionLocal
from .pal24 import process_pal24_payment_amount
@@ -609,6 +782,13 @@ async def handle_topup_amount_callback(
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(
@@ -692,6 +872,16 @@ def register_balance_handlers(dp: Dispatcher):
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,
@@ -760,6 +950,12 @@ def register_balance_handlers(dp: Dispatcher):
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"
@@ -774,4 +970,4 @@ def register_balance_handlers(dp: Dispatcher):
dp.callback_query.register(
handle_topup_amount_callback,
F.data.startswith("topup_amount|")
)
)
+54 -3
View File
@@ -48,7 +48,7 @@ async def start_mulenpay_payment(
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)
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
@@ -59,7 +59,11 @@ async def start_mulenpay_payment(
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="mulenpay")
await state.update_data(
payment_method="mulenpay",
mulenpay_prompt_message_id=callback.message.message_id,
mulenpay_prompt_chat_id=callback.message.chat.id,
)
await callback.answer()
@@ -93,6 +97,26 @@ async def process_mulenpay_payment_amount(
amount_rubles = amount_kopeks / 100
state_data = await state.get_data()
prompt_message_id = state_data.get("mulenpay_prompt_message_id")
prompt_chat_id = state_data.get("mulenpay_prompt_chat_id", message.chat.id)
try:
await message.delete()
except Exception as delete_error: # pragma: no cover - depends on bot permissions
logger.warning(
"Не удалось удалить сообщение с суммой MulenPay: %s", delete_error
)
if prompt_message_id:
try:
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
except Exception as delete_error: # pragma: no cover - diagnostic
logger.warning(
"Не удалось удалить сообщение с запросом суммы MulenPay: %s",
delete_error,
)
try:
payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_mulenpay_payment(
@@ -163,12 +187,39 @@ async def process_mulenpay_payment_amount(
mulenpay_name_html=mulenpay_name_html,
)
await message.answer(
invoice_message = await message.answer(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
try:
from app.services import payment_service as payment_module
payment = await payment_module.get_mulenpay_payment_by_local_id(
db, local_payment_id
)
if payment:
payment_metadata = dict(
getattr(payment, "metadata_json", {}) or {}
)
payment_metadata["invoice_message"] = {
"chat_id": invoice_message.chat.id,
"message_id": invoice_message.message_id,
}
await payment_module.update_mulenpay_payment_metadata(
db,
payment=payment,
metadata=payment_metadata,
)
except Exception as error: # pragma: no cover - diagnostic logging only
logger.warning("Не удалось сохранить данные сообщения MulenPay: %s", error)
await state.update_data(
mulenpay_invoice_message_id=invoice_message.message_id,
mulenpay_invoice_chat_id=invoice_message.chat.id,
)
await state.clear()
logger.info(
+171 -9
View File
@@ -1,8 +1,12 @@
import html
import logging
from datetime import datetime
from typing import Any, Optional
from aiogram import types
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -202,12 +206,36 @@ async def _send_pal24_payment_message(
support=settings.get_support_contact_display_html(),
)
await message.answer(
invoice_message = await message.answer(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
try:
from app.services import payment_service as payment_module
payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id)
if payment:
metadata = dict(getattr(payment, "metadata_json", {}) or {})
metadata["invoice_message"] = {
"chat_id": invoice_message.chat.id,
"message_id": invoice_message.message_id,
}
await db.execute(
update(payment.__class__)
.where(payment.__class__.id == payment.id)
.values(metadata_json=metadata, updated_at=datetime.utcnow())
)
await db.commit()
except Exception as error: # pragma: no cover - diagnostics
logger.warning("Не удалось сохранить сообщение PayPalych: %s", error)
await state.update_data(
pal24_invoice_message_id=invoice_message.message_id,
pal24_invoice_chat_id=invoice_message.chat.id,
)
await state.clear()
logger.info(
@@ -264,7 +292,7 @@ async def start_pal24_payment(
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)
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
@@ -275,7 +303,11 @@ async def start_pal24_payment(
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="pal24")
await state.update_data(
payment_method="pal24",
pal24_prompt_message_id=callback.message.message_id,
pal24_prompt_chat_id=callback.message.chat.id,
)
await callback.answer()
@@ -307,6 +339,24 @@ async def process_pal24_payment_amount(
available_methods = _get_available_pal24_methods()
state_data = await state.get_data()
prompt_message_id = state_data.get("pal24_prompt_message_id")
prompt_chat_id = state_data.get("pal24_prompt_chat_id", message.chat.id)
try:
await message.delete()
except Exception as delete_error: # pragma: no cover - depends on bot rights
logger.warning("Не удалось удалить сообщение с суммой PayPalych: %s", delete_error)
if prompt_message_id:
try:
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
except Exception as delete_error: # pragma: no cover - diagnostic
logger.warning(
"Не удалось удалить сообщение с запросом суммы PayPalych: %s",
delete_error,
)
if len(available_methods) == 1:
await _send_pal24_payment_message(
message,
@@ -423,8 +473,77 @@ async def check_pal24_payment_status(
if not isinstance(links_meta, dict):
links_meta = {}
sbp_link = links_meta.get("sbp") or payment.link_url
card_link = links_meta.get("card")
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
@@ -459,10 +578,53 @@ async def check_pal24_payment_status(
db_user = getattr(callback, 'db_user', None)
texts = get_texts(db_user.language if db_user else 'ru') if db_user else get_texts('ru')
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), callback_data=f"check_pal24_{local_payment_id}")],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
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:
+403
View File
@@ -0,0 +1,403 @@
"""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)
# Всегда фиксируем выбранный метод для последующей обработки
await state.update_data(payment_method="platega", platega_method=method_code)
data = await state.get_data()
pending_amount = int(data.get("platega_pending_amount") or 0)
if pending_amount > 0:
# Если сумма уже известна (например, после быстрого выбора),
# сразу создаём платеж и сбрасываем временное значение.
await state.update_data(platega_pending_amount=None)
from app.database.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
await process_platega_payment_amount(
message,
db_user,
db,
pending_amount,
state,
)
return
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(
platega_prompt_message_id=message.message_id,
platega_prompt_chat_id=message.chat.id,
)
@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")
data = await state.get_data()
has_pending_amount = bool(int(data.get("platega_pending_amount") or 0))
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),
)
if not has_pending_amount:
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}"
),
)
state_data = await state.get_data()
prompt_message_id = state_data.get("platega_prompt_message_id")
prompt_chat_id = state_data.get("platega_prompt_chat_id", message.chat.id)
try:
await message.delete()
except Exception as delete_error: # pragma: no cover - зависит от прав бота
logger.warning("Не удалось удалить сообщение с суммой Platega: %s", delete_error)
if prompt_message_id:
try:
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
except Exception as delete_error: # pragma: no cover - диагностический лог
logger.warning(
"Не удалось удалить сообщение с запросом суммы Platega: %s",
delete_error,
)
invoice_message = 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",
)
try:
from app.services import payment_service as payment_module
payment = await payment_module.get_platega_payment_by_id(db, local_payment_id)
if payment:
payment_metadata = dict(getattr(payment, "metadata_json", {}) or {})
payment_metadata["invoice_message"] = {
"chat_id": invoice_message.chat.id,
"message_id": invoice_message.message_id,
}
await payment_module.update_platega_payment(
db,
payment=payment,
metadata=payment_metadata,
)
except Exception as error: # pragma: no cover - диагностический лог
logger.warning("Не удалось сохранить данные сообщения Platega: %s", error)
await state.update_data(
platega_invoice_message_id=invoice_message.message_id,
platega_invoice_chat_id=invoice_message.chat.id,
)
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,
)
+48 -20
View File
@@ -1,11 +1,10 @@
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.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
@@ -22,11 +21,11 @@ async def start_stars_payment(
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 = (
@@ -35,23 +34,28 @@ async def start_stars_payment(
)
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)
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.update_data(
stars_prompt_message_id=callback.message.message_id,
stars_prompt_chat_id=callback.message.chat.id,
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="stars")
await callback.answer()
@@ -65,29 +69,48 @@ async def process_stars_payment_amount(
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()
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(
state_data = await state.get_data()
prompt_message_id = state_data.get("stars_prompt_message_id")
prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id)
try:
await message.delete()
except Exception as delete_error: # pragma: no cover - зависит от прав бота
logger.warning("Не удалось удалить сообщение с суммой Stars: %s", delete_error)
if prompt_message_id:
try:
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
except Exception as delete_error: # pragma: no cover - диагностический лог
logger.warning(
"Не удалось удалить сообщение с запросом суммы Stars: %s",
delete_error,
)
invoice_message = await message.answer(
f"⭐ <b>Оплата через Telegram Stars</b>\n\n"
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
f"К оплате: {stars_amount} звезд\n"
@@ -96,9 +119,14 @@ async def process_stars_payment_amount(
reply_markup=keyboard,
parse_mode="HTML"
)
await state.clear()
await state.update_data(
stars_invoice_message_id=invoice_message.message_id,
stars_invoice_chat_id=invoice_message.chat.id,
)
await state.set_state(None)
except Exception as e:
logger.error(f"Ошибка создания Stars invoice: {e}")
await message.answer("⚠️ Ошибка создания платежа")
await message.answer("⚠️ Ошибка создания платежа")
+37 -25
View File
@@ -13,47 +13,59 @@ logger = logging.getLogger(__name__)
@error_handler
async def start_tribute_payment(
callback: types.CallbackQuery,
db_user: User
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"
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"
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
]
)
message_text = (
"💳 <b>Пополнение банковской картой</b>\n\n"
"• Введите любую сумму от 100₽\n"
"• Безопасная оплата через Tribute\n"
"• Мгновенное зачисление на баланс\n"
"• Принимаем карты Visa, MasterCard, МИР\n\n"
"• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n"
"Нажмите кнопку для перехода к оплате:"
)
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
TributeService.remember_invoice_message(
db_user.telegram_id,
callback.message.chat.id,
callback.message.message_id,
)
except Exception as e:
logger.error(f"Ошибка создания Tribute платежа: {e}")
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
await callback.answer()
await callback.answer()
+51 -3
View File
@@ -1,8 +1,10 @@
import logging
from datetime import datetime
from typing import Dict
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -45,7 +47,7 @@ async def start_wata_payment(
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)
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
@@ -56,7 +58,11 @@ async def start_wata_payment(
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="wata")
await state.update_data(
payment_method="wata",
wata_prompt_message_id=callback.message.message_id,
wata_prompt_chat_id=callback.message.chat.id,
)
await callback.answer()
@@ -159,12 +165,54 @@ async def process_wata_payment_amount(
support=settings.get_support_contact_display_html(),
)
await message.answer(
state_data = await state.get_data()
prompt_message_id = state_data.get("wata_prompt_message_id")
prompt_chat_id = state_data.get("wata_prompt_chat_id", message.chat.id)
try:
await message.delete()
except Exception as delete_error: # pragma: no cover - depends on bot rights
logger.warning("Не удалось удалить сообщение с суммой WATA: %s", delete_error)
if prompt_message_id:
try:
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
except Exception as delete_error: # pragma: no cover - diagnostic
logger.warning(
"Не удалось удалить сообщение с запросом суммы WATA: %s",
delete_error,
)
invoice_message = await message.answer(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
try:
from app.services import payment_service as payment_module
payment = await payment_module.get_wata_payment_by_local_id(db, local_payment_id)
if payment:
metadata = dict(getattr(payment, "metadata_json", {}) or {})
metadata["invoice_message"] = {
"chat_id": invoice_message.chat.id,
"message_id": invoice_message.message_id,
}
await db.execute(
update(payment.__class__)
.where(payment.__class__.id == payment.id)
.values(metadata_json=metadata, updated_at=datetime.utcnow())
)
await db.commit()
except Exception as error: # pragma: no cover - diagnostics
logger.warning("Не удалось сохранить сообщение WATA: %s", error)
await state.update_data(
wata_invoice_message_id=invoice_message.message_id,
wata_invoice_chat_id=invoice_message.chat.id,
)
await state.clear()
logger.info(
+116 -17
View File
@@ -1,6 +1,9 @@
import logging
from datetime import datetime
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -48,7 +51,7 @@ async def start_yookassa_payment(
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
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)
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
@@ -58,9 +61,13 @@ async def start_yookassa_payment(
reply_markup=keyboard,
parse_mode="HTML"
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="yookassa")
await state.update_data(
yookassa_prompt_message_id=callback.message.message_id,
yookassa_prompt_chat_id=callback.message.chat.id,
)
await callback.answer()
@@ -98,7 +105,7 @@ async def start_yookassa_sbp_payment(
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
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)
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
@@ -108,9 +115,13 @@ async def start_yookassa_sbp_payment(
reply_markup=keyboard,
parse_mode="HTML"
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="yookassa_sbp")
await state.update_data(
yookassa_prompt_message_id=callback.message.message_id,
yookassa_prompt_chat_id=callback.message.chat.id,
)
await callback.answer()
@@ -172,7 +183,25 @@ async def process_yookassa_payment_amount(
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await message.answer(
state_data = await state.get_data()
prompt_message_id = state_data.get("yookassa_prompt_message_id")
prompt_chat_id = state_data.get("yookassa_prompt_chat_id", message.chat.id)
try:
await message.delete()
except Exception as delete_error: # pragma: no cover - зависит от прав бота
logger.warning("Не удалось удалить сообщение с суммой YooKassa: %s", delete_error)
if prompt_message_id:
try:
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
except Exception as delete_error: # pragma: no cover - диагностический лог
logger.warning(
"Не удалось удалить сообщение с запросом суммы YooKassa: %s",
delete_error,
)
invoice_message = 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"
@@ -187,9 +216,34 @@ async def process_yookassa_payment_amount(
reply_markup=keyboard,
parse_mode="HTML"
)
try:
from app.services import payment_service as payment_module
payment = await payment_module.get_yookassa_payment_by_local_id(
db, payment_result["local_payment_id"]
)
if payment:
metadata = dict(getattr(payment, "metadata_json", {}) or {})
metadata["invoice_message"] = {
"chat_id": invoice_message.chat.id,
"message_id": invoice_message.message_id,
}
await db.execute(
update(payment.__class__)
.where(payment.__class__.id == payment.id)
.values(metadata_json=metadata, updated_at=datetime.utcnow())
)
await db.commit()
except Exception as error: # pragma: no cover - диагностический лог
logger.warning("Не удалось сохранить сообщение YooKassa: %s", error)
await state.update_data(
yookassa_invoice_message_id=invoice_message.message_id,
yookassa_invoice_chat_id=invoice_message.chat.id,
)
await state.clear()
logger.info(f"Создан платеж YooKassa для пользователя {db_user.telegram_id}: "
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
@@ -310,27 +364,45 @@ async def process_yookassa_sbp_payment_amount(
# Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса
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)
state_data = await state.get_data()
prompt_message_id = state_data.get("yookassa_prompt_message_id")
prompt_chat_id = state_data.get("yookassa_prompt_chat_id", message.chat.id)
try:
await message.delete()
except Exception as delete_error: # pragma: no cover - зависит от прав бота
logger.warning("Не удалось удалить сообщение с суммой YooKassa (СБП): %s", delete_error)
if prompt_message_id:
try:
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
except Exception as delete_error: # pragma: no cover - диагностический лог
logger.warning(
"Не удалось удалить сообщение с запросом суммы YooKassa (СБП): %s",
delete_error,
)
# Подготавливаем текст сообщения
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 += (
@@ -341,18 +413,18 @@ async def process_yookassa_sbp_payment_amount(
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(
invoice_message = await message.answer_photo(
photo=qr_photo,
caption=message_text,
reply_markup=keyboard,
@@ -360,12 +432,39 @@ async def process_yookassa_sbp_payment_amount(
)
else:
# Если QR-код недоступен, отправляем обычное текстовое сообщение
await message.answer(
invoice_message = await message.answer(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
try:
from app.services import payment_service as payment_module
payment = await payment_module.get_yookassa_payment_by_local_id(
db, payment_result["local_payment_id"]
)
if payment:
metadata = dict(getattr(payment, "metadata_json", {}) or {})
metadata["invoice_message"] = {
"chat_id": invoice_message.chat.id,
"message_id": invoice_message.message_id,
}
await db.execute(
update(payment.__class__)
.where(payment.__class__.id == payment.id)
.values(metadata_json=metadata, updated_at=datetime.utcnow())
)
await db.commit()
except Exception as error: # pragma: no cover - диагностический лог
logger.warning("Не удалось сохранить сообщение YooKassa (СБП): %s", error)
await state.update_data(
yookassa_invoice_message_id=invoice_message.message_id,
yookassa_invoice_chat_id=invoice_message.chat.id,
)
await state.clear()
logger.info(f"Создан платеж YooKassa СБП для пользователя {db_user.telegram_id}: "
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
+13 -5
View File
@@ -38,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__)
@@ -952,7 +953,8 @@ def _get_subscription_status(user: User, texts) -> str:
current_time = datetime.utcnow()
actual_status = (subscription.actual_status or "").lower()
end_date_text = subscription.end_date.strftime("%d.%m.%Y")
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:
@@ -968,10 +970,16 @@ def _get_subscription_status(user: User, texts) -> str:
return texts.t(
"SUB_STATUS_EXPIRED",
"🔴 Истекла\n📅 {end_date}",
).format(end_date=end_date_text)
).format(end_date=end_date_text or "")
if actual_status == "trial":
if days_left > 1:
is_trial_subscription = getattr(subscription, "is_trial", False)
is_trial_like_status = actual_status == "trial" or (
is_trial_subscription and actual_status in {"active", "trial"}
)
if is_trial_like_status:
if days_left > 1 and end_date_text:
return texts.t(
"SUB_STATUS_TRIAL_ACTIVE",
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
@@ -990,7 +998,7 @@ def _get_subscription_status(user: User, texts) -> str:
)
if actual_status == "active":
if days_left > 7:
if days_left > 7 and end_date_text:
return texts.t(
"SUB_STATUS_ACTIVE_LONG",
"💎 Активна\n📅 до {end_date} ({days} дн.)",
+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()
+546 -80
View File
@@ -16,7 +16,10 @@ from app.services.payment_service import PaymentService
from app.services.subscription_purchase_service import SubscriptionPurchaseService
from app.utils.decorators import error_handler
from app.states import SubscriptionStates
from app.utils.subscription_utils import get_display_subscription_link
from app.utils.subscription_utils import (
get_display_subscription_link,
resolve_simple_subscription_device_limit,
)
from app.utils.pricing_utils import compute_simple_subscription_price
logger = logging.getLogger(__name__)
@@ -35,15 +38,17 @@ async def start_simple_subscription_purchase(
if not settings.SIMPLE_SUBSCRIPTION_ENABLED:
await callback.answer("❌ Простая покупка подписки временно недоступна", show_alert=True)
return
# Проверяем, есть ли у пользователя подписка (информируем, но не блокируем покупку)
# Проверяем, есть ли у пользователя подписка
from app.database.crud.subscription import get_subscription_by_user_id
current_subscription = await get_subscription_by_user_id(db, db_user.id)
device_limit = resolve_simple_subscription_device_limit()
# Подготовим параметры простой подписки
subscription_params = {
"period_days": settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS,
"device_limit": settings.SIMPLE_SUBSCRIPTION_DEVICE_LIMIT,
"device_limit": device_limit,
"traffic_limit_gb": settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
"squad_uuid": settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID
}
@@ -94,63 +99,115 @@ async def start_simple_subscription_purchase(
can_pay_from_balance,
)
# Проверяем, является ли у пользователя текущая подписка активной платной подпиской
has_active_paid_subscription = False
trial_notice = ""
if current_subscription and getattr(current_subscription, "is_trial", False):
try:
days_left = max(0, (current_subscription.end_date - datetime.utcnow()).days)
except Exception:
days_left = 0
key = "SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_ACTIVE" if current_subscription.is_active else "SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_TRIAL"
trial_notice = texts.t(
key,
"ℹ️ У вас уже есть триальная подписка. Она истекает через {days} дн.",
).format(days=days_left)
if current_subscription:
if not getattr(current_subscription, "is_trial", False) and current_subscription.is_active:
# Это платная активная подписка - требуем подтверждение
has_active_paid_subscription = True
elif getattr(current_subscription, "is_trial", False):
# Это тестовая подписка
try:
days_left = max(0, (current_subscription.end_date - datetime.utcnow()).days)
except Exception:
days_left = 0
key = "SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_ACTIVE" if current_subscription.is_active else "SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_TRIAL"
trial_notice = texts.t(
key,
"ℹ️ У вас уже есть триальная подписка. Она истекает через {days} дн.",
).format(days=days_left)
server_label = _get_simple_subscription_server_label(
texts,
subscription_params,
resolved_squad_uuid,
)
message_text = (
f"⚡ <b>Простая покупка подписки</b>\n\n"
f"📅 Период: {subscription_params['period_days']} дней\n"
f"📱 Устройства: {subscription_params['device_limit']}\n"
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
f"🌍 Сервер: {server_label}\n\n"
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}\n\n"
+ (
"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты."
if can_pay_from_balance
else "Баланс пока недостаточный для мгновенной оплаты. Выберите подходящий способ оплаты:"
show_devices = settings.is_devices_selection_enabled()
message_lines = [
"⚡ <b>Простая покупка подписки</b>",
"",
f"📅 Период: {subscription_params['period_days']} дней",
]
if show_devices:
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
message_lines.extend([
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
f"🌍 Сервер: {server_label}",
"",
f"💰 Стоимость: {settings.format_price(price_kopeks)}",
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}",
"",
])
# Если у пользователя уже есть активная платная подписка, требуем подтверждение
if has_active_paid_subscription:
# У пользователя уже есть активная платная подписка
message_lines.append(
"⚠️ У вас уже есть активная платная подписка. "
"Покупка простой подписки изменит параметры вашей текущей подписки. "
"Требуется подтверждение."
)
)
message_text = "\n".join(message_lines)
if trial_notice:
message_text = f"{trial_notice}\n\n{message_text}"
methods_keyboard = _get_simple_subscription_payment_keyboard(db_user.language)
keyboard_rows = []
if can_pay_from_balance:
keyboard_rows.append([
types.InlineKeyboardButton(
text="✅ Оплатить с баланса",
callback_data="simple_subscription_pay_with_balance",
# Клавиатура с подтверждением
keyboard_rows = [
[types.InlineKeyboardButton(
text="✅ Подтвердить покупку",
callback_data="simple_subscription_confirm_purchase"
)],
[types.InlineKeyboardButton(
text=texts.BACK,
callback_data="subscription_purchase"
)]
]
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
else:
# У пользователя нет активной платной подписки (или есть только пробная)
# Показываем стандартный выбор метода оплаты
if can_pay_from_balance:
message_lines.append(
"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты."
)
])
else:
message_lines.append(
"Баланс пока недостаточный для мгновенной оплаты. Выберите подходящий способ оплаты:"
)
message_text = "\n".join(message_lines)
if trial_notice:
message_text = f"{trial_notice}\n\n{message_text}"
keyboard_rows.extend(methods_keyboard.inline_keyboard)
methods_keyboard = _get_simple_subscription_payment_keyboard(db_user.language)
keyboard_rows = []
if can_pay_from_balance:
keyboard_rows.append([
types.InlineKeyboardButton(
text="✅ Оплатить с баланса",
callback_data="simple_subscription_pay_with_balance",
)
])
keyboard_rows.extend(methods_keyboard.inline_keyboard)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await state.set_state(SubscriptionStates.waiting_for_simple_subscription_payment_method)
# Устанавливаем соответствующее состояние
if has_active_paid_subscription:
await state.set_state(SubscriptionStates.waiting_for_simple_subscription_confirmation)
else:
await state.set_state(SubscriptionStates.waiting_for_simple_subscription_payment_method)
await callback.answer()
@@ -318,6 +375,15 @@ async def handle_simple_subscription_pay_with_balance(
await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True)
return
# Проверяем, имеет ли пользователь активную платную подписку
from app.database.crud.subscription import get_subscription_by_user_id
current_subscription = await get_subscription_by_user_id(db, db_user.id)
if current_subscription and not getattr(current_subscription, "is_trial", False) and current_subscription.is_active:
# У пользователя есть активная платная подписка - требуем подтверждение
await callback.answer("⚠️ У вас уже есть активная платная подписка. Пожалуйста, подтвердите покупку.", show_alert=True)
return
resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid(
db,
state,
@@ -375,7 +441,10 @@ async def handle_simple_subscription_pay_with_balance(
existing_subscription = await get_subscription_by_user_id(db, db_user.id)
if existing_subscription:
# Если подписка уже существует, продлеваем её
# Если подписка уже существует (платная или тестовая), продлеваем её
# Сохраняем информацию о текущей подписке, особенно является ли она пробной
was_trial = getattr(existing_subscription, "is_trial", False)
subscription = await extend_subscription(
db=db,
subscription=existing_subscription,
@@ -384,6 +453,16 @@ async def handle_simple_subscription_pay_with_balance(
# Обновляем параметры подписки
subscription.traffic_limit_gb = subscription_params["traffic_limit_gb"]
subscription.device_limit = subscription_params["device_limit"]
# Если текущая подписка была пробной, и мы обновляем её
# нужно изменить статус подписки
if was_trial:
from app.database.models import SubscriptionStatus
# Переводим подписку из пробной в активную платную
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.is_trial = False
# Устанавливаем новый выбранный сквад
if resolved_squad_uuid:
subscription.connected_squads = [resolved_squad_uuid]
@@ -433,16 +512,28 @@ async def handle_simple_subscription_pay_with_balance(
subscription_params,
resolved_squad_uuid,
)
success_message = (
f"✅ <b>Подписка успешно активирована!</b>\n\n"
f"📅 Период: {subscription_params['period_days']} дней\n"
f"📱 Устройства: {subscription_params['device_limit']}\n"
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
f"🌍 Сервер: {server_label}\n\n"
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}\n"
f"💳 Ваш баланс: {settings.format_price(db_user.balance_kopeks)}\n\n"
f"🔗 Для подключения перейдите в раздел 'Подключиться'"
)
show_devices = settings.is_devices_selection_enabled()
success_lines = [
"✅ <b>Подписка успешно активирована!</b>",
"",
f"📅 Период: {subscription_params['period_days']} дней",
]
if show_devices:
success_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
success_lines.extend([
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
f"🌍 Сервер: {server_label}",
"",
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}",
f"💳 Ваш баланс: {settings.format_price(db_user.balance_kopeks)}",
"",
"🔗 Для подключения перейдите в раздел 'Подключиться'",
])
success_message = "\n".join(success_lines)
connect_mode = settings.CONNECT_BUTTON_MODE
subscription_link = get_display_subscription_link(subscription)
@@ -619,19 +710,31 @@ async def handle_simple_subscription_other_payment_methods(
subscription_params,
resolved_squad_uuid,
)
message_text = (
f"💳 <b>Оплата подписки</b>\n\n"
f"📅 Период: {subscription_params['period_days']} дней\n"
f"📱 Устройства: {subscription_params['device_limit']}\n"
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
f"🌍 Сервер: {server_label}\n\n"
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n\n"
+ (
show_devices = settings.is_devices_selection_enabled()
message_lines = [
"💳 <b>Оплата подписки</b>",
"",
f"📅 Период: {subscription_params['period_days']} дней",
]
if show_devices:
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
message_lines.extend([
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
f"🌍 Сервер: {server_label}",
"",
f"💰 Стоимость: {settings.format_price(price_kopeks)}",
"",
(
"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты:"
if can_pay_from_balance
else "Выберите подходящий способ оплаты:"
)
)
),
])
message_text = "\n".join(message_lines)
base_keyboard = _get_simple_subscription_payment_keyboard(db_user.language)
keyboard_rows = []
@@ -673,10 +776,20 @@ async def handle_simple_subscription_payment_method(
await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True)
return
# Проверяем, имеет ли пользователь активную платную подписку
from app.database.crud.subscription import get_subscription_by_user_id
current_subscription = await get_subscription_by_user_id(db, db_user.id)
if current_subscription and not getattr(current_subscription, "is_trial", False) and current_subscription.is_active:
# У пользователя есть активная платная подписка - показываем сообщение
await callback.answer("⚠️ У вас уже есть активная платная подписка. Пожалуйста, подтвердите покупку через главное меню.", show_alert=True)
return
payment_method = callback.data.replace("simple_subscription_", "")
try:
payment_service = PaymentService(callback.bot)
purchase_service = SubscriptionPurchaseService()
resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid(
db,
@@ -696,8 +809,23 @@ async def handle_simple_subscription_payment_method(
if payment_method == "stars":
# Оплата через Telegram Stars
order = await purchase_service.create_subscription_order(
db=db,
user_id=db_user.id,
period_days=subscription_params["period_days"],
device_limit=subscription_params["device_limit"],
traffic_limit_gb=subscription_params["traffic_limit_gb"],
squad_uuid=resolved_squad_uuid,
payment_method="telegram_stars",
total_price_kopeks=price_kopeks,
)
if not order:
await callback.answer("❌ Не удалось подготовить заказ. Попробуйте позже.", show_alert=True)
return
stars_count = settings.rubles_to_stars(settings.kopeks_to_rubles(price_kopeks))
await callback.bot.send_invoice(
chat_id=callback.from_user.id,
title=f"Подписка на {subscription_params['period_days']} дней",
@@ -707,7 +835,9 @@ async def handle_simple_subscription_payment_method(
f"Устройства: {subscription_params['device_limit']}\n"
f"Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}"
),
payload=f"simple_sub_{db_user.id}_{subscription_params['period_days']}",
payload=(
f"simple_sub_{db_user.id}_{order.id}_{subscription_params['period_days']}"
),
provider_token="", # Пустой токен для Telegram Stars
currency="XTR", # Telegram Stars
prices=[types.LabeledPrice(label="Подписка", amount=stars_count)]
@@ -727,8 +857,6 @@ async def handle_simple_subscription_payment_method(
return
# Создаем заказ на подписку
purchase_service = SubscriptionPurchaseService()
order = await purchase_service.create_subscription_order(
db=db,
user_id=db_user.id,
@@ -838,14 +966,25 @@ async def handle_simple_subscription_payment_method(
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
# Подготавливаем текст сообщения
message_text = (
f"💳 <b>Оплата подписки через YooKassa</b>\n\n"
f"📅 Период: {subscription_params['period_days']} дней\n"
f"📱 Устройства: {subscription_params['device_limit']}\n"
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
f"💰 Сумма: {settings.format_price(price_kopeks)}\n"
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
)
show_devices = settings.is_devices_selection_enabled()
message_lines = [
"💳 <b>Оплата подписки через YooKassa</b>",
"",
f"📅 Период: {subscription_params['period_days']} дней",
]
if show_devices:
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
message_lines.extend([
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
f"💰 Сумма: {settings.format_price(price_kopeks)}",
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...",
"",
])
message_text = "\n".join(message_lines)
# Добавляем инструкции в зависимости от доступных способов оплаты
if not confirmation_url:
@@ -1516,8 +1655,18 @@ async def check_simple_pal24_payment_status(
if not isinstance(links_meta, dict):
links_meta = {}
sbp_link = links_meta.get("sbp") or payment.link_url
card_link = links_meta.get("card")
links_info = status_info.get("links") or {}
sbp_link = (
links_info.get("sbp")
or links_meta.get("sbp")
or status_info.get("sbp_url")
or payment.link_url
)
card_link = (
links_info.get("card")
or links_meta.get("card")
or status_info.get("card_url")
)
if not card_link and payment.link_page_url and payment.link_page_url != sbp_link:
card_link = payment.link_page_url
@@ -1550,8 +1699,45 @@ async def check_simple_pal24_payment_status(
f"❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}",
]
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 = types.InlineKeyboardMarkup(
inline_keyboard=[
inline_keyboard=pay_rows
+ [
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
@@ -1877,6 +2063,281 @@ async def check_simple_wata_payment_status(
parse_mode="HTML",
)
@error_handler
async def confirm_simple_subscription_purchase(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
"""Обрабатывает подтверждение простой покупки подписки при наличии активной платной подписки."""
texts = get_texts(db_user.language)
data = await state.get_data()
subscription_params = data.get("subscription_params", {})
if not subscription_params:
await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True)
return
resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid(
db,
state,
subscription_params,
user_id=db_user.id,
state_data=data,
)
# Рассчитываем цену подписки
price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
db,
subscription_params,
user=db_user,
resolved_squad_uuid=resolved_squad_uuid,
)
total_required = price_kopeks
logger.warning(
"SIMPLE_SUBSCRIPTION_DEBUG_CONFIRM | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total_required=%s | balance=%s",
db_user.id,
subscription_params["period_days"],
price_breakdown.get("base_price", 0),
price_breakdown.get("traffic_price", 0),
price_breakdown.get("devices_price", 0),
price_breakdown.get("servers_price", 0),
price_breakdown.get("total_discount", 0),
total_required,
getattr(db_user, "balance_kopeks", 0),
)
# Проверяем баланс пользователя
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
if user_balance_kopeks < total_required:
await callback.answer("❌ Недостаточно средств на балансе для оплаты подписки", show_alert=True)
return
try:
# Списываем средства с баланса пользователя
from app.database.crud.user import subtract_user_balance
success = await subtract_user_balance(
db,
db_user,
price_kopeks,
f"Оплата подписки на {subscription_params['period_days']} дней",
consume_promo_offer=False,
)
if not success:
await callback.answer("❌ Ошибка списания средств с баланса", show_alert=True)
return
# Проверяем, есть ли у пользователя уже подписка
from app.database.crud.subscription import get_subscription_by_user_id, extend_subscription
existing_subscription = await get_subscription_by_user_id(db, db_user.id)
if existing_subscription:
# Если подписка уже существует, продлеваем её
# Сохраняем информацию о текущей подписке, особенно является ли она пробной
was_trial = getattr(existing_subscription, "is_trial", False)
subscription = await extend_subscription(
db=db,
subscription=existing_subscription,
days=subscription_params["period_days"]
)
# Обновляем параметры подписки
subscription.traffic_limit_gb = subscription_params["traffic_limit_gb"]
subscription.device_limit = subscription_params["device_limit"]
# Если текущая подписка была пробной, и мы обновляем её
# нужно изменить статус подписки
if was_trial:
from app.database.models import SubscriptionStatus
# Переводим подписку из пробной в активную платную
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.is_trial = False
# Устанавливаем новый выбранный сквад
if resolved_squad_uuid:
subscription.connected_squads = [resolved_squad_uuid]
await db.commit()
await db.refresh(subscription)
else:
# Если подписки нет, создаём новую
from app.database.crud.subscription import create_paid_subscription
subscription = await create_paid_subscription(
db=db,
user_id=db_user.id,
duration_days=subscription_params["period_days"],
traffic_limit_gb=subscription_params["traffic_limit_gb"],
device_limit=subscription_params["device_limit"],
connected_squads=[resolved_squad_uuid] if resolved_squad_uuid else [],
update_server_counters=True,
)
if not subscription:
# Возвращаем средства на баланс в случае ошибки
from app.services.payment_service import add_user_balance
await add_user_balance(
db,
db_user.id,
price_kopeks,
f"Возврат средств за неудавшуюся подписку на {subscription_params['period_days']} дней",
)
await callback.answer("❌ Ошибка создания подписки. Средства возвращены на баланс.", show_alert=True)
return
# Обновляем баланс пользователя
await db.refresh(db_user)
# Обновляем или создаём ссылку подписки в RemnaWave
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
remnawave_user = await subscription_service.create_remnawave_user(db, subscription)
if remnawave_user:
await db.refresh(subscription)
except Exception as sync_error:
logger.error(f"Ошибка синхронизации подписки с RemnaWave для пользователя {db_user.id}: {sync_error}", exc_info=True)
# Отправляем уведомление об успешной покупке
server_label = _get_simple_subscription_server_label(
texts,
subscription_params,
resolved_squad_uuid,
)
show_devices = settings.is_devices_selection_enabled()
success_lines = [
"✅ <b>Подписка успешно активирована!</b>",
"",
f"📅 Период: {subscription_params['period_days']} дней",
]
if show_devices:
success_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
success_lines.extend([
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
f"🌍 Сервер: {server_label}",
"",
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}",
f"💳 Ваш баланс: {settings.format_price(db_user.balance_kopeks)}",
"",
"🔗 Для подключения перейдите в раздел 'Подключиться'",
])
success_message = "\n".join(success_lines)
connect_mode = settings.CONNECT_BUTTON_MODE
subscription_link = get_display_subscription_link(subscription)
connect_button_text = texts.t("CONNECT_BUTTON", "🔗 Подключиться")
def _fallback_connect_button() -> types.InlineKeyboardButton:
return types.InlineKeyboardButton(
text=connect_button_text,
callback_data="subscription_connect",
)
if connect_mode == "miniapp_subscription":
if subscription_link:
connect_row = [
types.InlineKeyboardButton(
text=connect_button_text,
web_app=types.WebAppInfo(url=subscription_link),
)
]
else:
connect_row = [_fallback_connect_button()]
elif connect_mode == "miniapp_custom":
custom_url = settings.MINIAPP_CUSTOM_URL
if custom_url:
connect_row = [
types.InlineKeyboardButton(
text=connect_button_text,
web_app=types.WebAppInfo(url=custom_url),
)
]
else:
connect_row = [_fallback_connect_button()]
elif connect_mode == "link":
if subscription_link:
connect_row = [
types.InlineKeyboardButton(
text=connect_button_text,
url=subscription_link,
)
]
else:
connect_row = [_fallback_connect_button()]
elif connect_mode == "happ_cryptolink":
if subscription_link:
connect_row = [
types.InlineKeyboardButton(
text=connect_button_text,
callback_data="open_subscription_link",
)
]
else:
connect_row = [_fallback_connect_button()]
else:
connect_row = [_fallback_connect_button()]
keyboard_rows = [connect_row]
happ_row = get_happ_download_button_row(texts)
if happ_row:
keyboard_rows.append(happ_row)
keyboard_rows.append(
[types.InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
await callback.message.edit_text(
success_message,
reply_markup=keyboard,
parse_mode="HTML"
)
# Отправляем уведомление админам
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_purchase_notification(
db,
db_user,
subscription,
None, # transaction
subscription_params["period_days"],
False, # was_trial_conversion
amount_kopeks=price_kopeks,
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления админам о покупке: {e}")
await state.clear()
await callback.answer()
logger.info(f"Пользователь {db_user.telegram_id} успешно купил подписку с баланса на {price_kopeks/100}")
except Exception as error:
logger.error(
"Ошибка подтверждения простой подписки с баланса для пользователя %s: %s",
db_user.id,
error,
exc_info=True,
)
await callback.answer(
"❌ Ошибка оплаты подписки. Попробуйте позже или обратитесь в поддержку.",
show_alert=True,
)
await state.clear()
def register_simple_subscription_handlers(dp):
"""Регистрирует обработчики простой покупки подписки."""
@@ -1885,6 +2346,11 @@ def register_simple_subscription_handlers(dp):
F.data == "simple_subscription_purchase"
)
dp.callback_query.register(
confirm_simple_subscription_purchase,
F.data == "simple_subscription_confirm_purchase"
)
dp.callback_query.register(
handle_simple_subscription_pay_with_balance,
F.data == "simple_subscription_pay_with_balance"
+44 -8
View File
@@ -1,10 +1,10 @@
import logging
from decimal import Decimal, ROUND_HALF_UP
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.services.payment_service import PaymentService
from app.external.telegram_stars import TelegramStarsService
from app.database.crud.user import get_user_by_telegram_id
@@ -18,9 +18,11 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
texts = get_texts(DEFAULT_LANGUAGE)
try:
logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}")
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}")
@@ -35,6 +37,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
try:
from app.database.database import get_db
async for db in get_db():
user = await get_user_by_telegram_id(db, query.from_user.id)
if not user:
@@ -77,6 +80,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
async def handle_successful_payment(
message: types.Message,
db: AsyncSession,
state: FSMContext,
**kwargs
):
texts = get_texts(DEFAULT_LANGUAGE)
@@ -106,6 +110,27 @@ async def handle_successful_payment(
return
payment_service = PaymentService(message.bot)
state_data = await state.get_data()
prompt_message_id = state_data.get("stars_prompt_message_id")
prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id)
invoice_message_id = state_data.get("stars_invoice_message_id")
invoice_chat_id = state_data.get("stars_invoice_chat_id", message.chat.id)
for chat_id, message_id, label in [
(prompt_chat_id, prompt_message_id, "запрос суммы"),
(invoice_chat_id, invoice_message_id, "инвойс Stars"),
]:
if message_id:
try:
await message.bot.delete_message(chat_id, message_id)
except Exception as delete_error: # pragma: no cover - зависит от прав бота
logger.warning(
"Не удалось удалить сообщение %s после оплаты Stars: %s",
label,
delete_error,
)
success = await payment_service.process_stars_payment(
db=db,
user_id=user.id,
@@ -113,7 +138,14 @@ async def handle_successful_payment(
payload=payment.invoice_payload,
telegram_payment_charge_id=payment.telegram_payment_charge_id
)
await state.update_data(
stars_prompt_message_id=None,
stars_prompt_chat_id=None,
stars_invoice_message_id=None,
stars_invoice_chat_id=None,
)
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))
@@ -130,6 +162,10 @@ 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,
@@ -168,15 +204,15 @@ async def handle_successful_payment(
def register_stars_handlers(dp: Dispatcher):
dp.pre_checkout_query.register(
handle_pre_checkout_query,
F.currency == "XTR"
F.currency == "XTR"
)
dp.message.register(
handle_successful_payment,
F.successful_payment
)
logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей")
+186 -81
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
@@ -224,7 +272,11 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
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
@@ -240,7 +292,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
pending_start_payload,
)
if pending_start_payload is not None:
if state_needs_update:
await state.set_data(data)
if start_parameter:
@@ -266,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(
@@ -338,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)
@@ -678,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(
@@ -761,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)
@@ -943,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)
@@ -1019,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)
@@ -1149,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:
@@ -1201,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)
@@ -1258,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",
@@ -1292,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",
@@ -1521,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)
+51 -32
View File
@@ -56,6 +56,7 @@ from app.services.subscription_checkout_service import (
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
@@ -186,7 +187,7 @@ async def handle_subscription_config_back(
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),
reply_markup=get_subscription_period_keyboard(db_user.language, db_user),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.selecting_period)
@@ -201,45 +202,26 @@ async def handle_subscription_config_back(
else:
await callback.message.edit_text(
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language),
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:
if await _should_show_countries_management(db_user):
countries = await _get_available_countries(db_user.promo_group_id)
data = await state.get_data()
selected_countries = data.get('countries', [])
await callback.message.edit_text(
texts.SELECT_COUNTRIES,
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_countries)
elif settings.is_traffic_selectable():
await callback.message.edit_text(
texts.SELECT_TRAFFIC,
reply_markup=get_traffic_packages_keyboard(db_user.language)
)
await state.set_state(SubscriptionStates.selecting_traffic)
else:
await callback.message.edit_text(
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.selecting_period)
await _show_previous_configuration_step(callback, state, db_user, texts, db)
elif current_state == SubscriptionStates.confirming_purchase.state:
data = await state.get_data()
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
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)
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
@@ -259,7 +241,44 @@ async def handle_subscription_cancel(
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)
+1 -1
View File
@@ -107,7 +107,7 @@ def _get_addon_discount_percent_for_user(
if user is None:
return 0
promo_group = getattr(user, "promo_group", None)
promo_group = user.get_primary_promo_group()
if promo_group is None:
return 0
+6
View File
@@ -79,6 +79,7 @@ from app.utils.promo_offer import (
)
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,
@@ -588,6 +589,11 @@ async def countries_continue(
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(
+28
View File
@@ -183,6 +183,13 @@ async def handle_change_devices(
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
show_alert=True,
)
return
if not subscription or subscription.is_trial:
await callback.answer(
texts.t("PAID_FEATURE_ONLY", "⚠️ Эта функция доступна только для платных подписок"),
@@ -233,6 +240,13 @@ async def confirm_change_devices(
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
show_alert=True,
)
return
current_devices = subscription.device_limit
if new_devices_count == current_devices:
@@ -379,6 +393,13 @@ async def execute_change_devices(
subscription = db_user.subscription
current_devices = subscription.device_limit
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
show_alert=True,
)
return
try:
if price > 0:
success = await subtract_user_balance(
@@ -863,6 +884,13 @@ async def confirm_add_devices(
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
show_alert=True,
)
return
resume_callback = None
new_total_devices = subscription.device_limit + devices_count
+75 -22
View File
@@ -77,6 +77,7 @@ 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
@@ -158,7 +159,18 @@ async def _prepare_subscription_summary(
total_servers_discount += total_discount_for_server
selected_server_prices.append(total_price_for_server)
devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
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(
@@ -241,17 +253,20 @@ async def _prepare_subscription_summary(
else:
traffic_display = f"{summary_data.get('traffic_gb', 0)} ГБ"
if base_discount_total > 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)})"
)
else:
details_lines.append(base_line)
elif base_price_original > 0:
base_line = f"- Базовый период: {texts.format_price(base_price_original)}"
details_lines = [base_line]
details_lines.append(base_line)
if total_traffic_price > 0:
traffic_line = (
@@ -275,7 +290,7 @@ async def _prepare_subscription_summary(
f" -{texts.format_price(total_servers_discount)})"
)
details_lines.append(servers_line)
if total_devices_price > 0:
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)}"
@@ -300,17 +315,28 @@ async def _prepare_subscription_summary(
details_text = "\n".join(details_lines)
summary_text = (
"📋 <b>Сводка заказа</b>\n\n"
f"📅 <b>Период:</b> {period_display}\n"
f"📊 <b>Трафик:</b> {traffic_display}\n"
f"🌍 <b>Страны:</b> {', '.join(selected_countries_names)}\n"
f"📱 <b>Устройства:</b> {devices_selected}\n\n"
"💰 <b>Детализация стоимости:</b>\n"
f"{details_text}\n\n"
f"💎 <b>Общая стоимость:</b> {texts.format_price(total_price)}\n\n"
"Подтверждаете покупку?"
)
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
@@ -327,7 +353,7 @@ async def _build_subscription_period_prompt(
if promo_offer_hint:
lines.extend(["", promo_offer_hint])
promo_text = _build_promo_group_discount_text(
promo_text = await _build_promo_group_discount_text(
db_user,
settings.get_available_subscription_periods(),
texts=texts,
@@ -382,7 +408,18 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
)
traffic_cost = settings.get_traffic_price(subscription.traffic_limit_gb)
devices_cost = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
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
@@ -410,7 +447,12 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
return 0
async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSession):
devices_used = await get_current_devices_count(db_user)
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 "Нет"
@@ -439,10 +481,21 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
subscription_cost = await get_subscription_cost(subscription, db)
info_text = texts.SUBSCRIPTION_INFO.format(
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=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
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,
+2 -2
View File
@@ -88,12 +88,12 @@ async def _get_promo_offer_hint(
) -> Optional[str]:
return await build_promo_offer_hint(db, db_user, texts, percent)
def _build_promo_group_discount_text(
async def _build_promo_group_discount_text(
db_user: User,
periods: Optional[List[int]] = None,
texts=None,
) -> str:
promo_group = getattr(db_user, "promo_group", None)
promo_group = db_user.get_primary_promo_group()
if not promo_group:
return ""
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
+13 -6
View File
@@ -80,6 +80,7 @@ from app.utils.promo_offer import (
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,
@@ -352,12 +353,15 @@ async def select_traffic(
reply_markup=get_countries_keyboard(countries, [], db_user.language)
)
await state.set_state(SubscriptionStates.selecting_countries)
else:
countries = await _get_available_countries(db_user.promo_group_id)
available_countries = [c for c in countries if c.get('is_available', True)]
data['countries'] = [available_countries[0]['uuid']] if available_countries else []
await state.set_data(data)
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(
@@ -365,8 +369,11 @@ async def select_traffic(
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_devices)
await callback.answer()
return
await callback.answer()
if await present_subscription_summary(callback, state, db_user, texts):
await callback.answer()
async def add_traffic(
callback: types.CallbackQuery,
+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}")
+45 -3
View File
@@ -53,6 +53,16 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
callback_data="admin_submenu_system",
),
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_MAIN_TRIALS", "🧪 Триалы"),
callback_data="admin_trials",
),
InlineKeyboardButton(
text=_t(texts, "ADMIN_MAIN_PAYMENTS", "💳 Пополнения"),
callback_data="admin_payments",
)
],
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
])
@@ -235,6 +245,20 @@ def get_admin_system_submenu_keyboard(language: str = "ru") -> InlineKeyboardMar
])
def get_admin_trials_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_TRIALS_RESET_BUTTON", "♻️ Сбросить все триалы"),
callback_data="admin_trials_reset",
)
],
[InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")],
])
def get_admin_reports_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
@@ -754,6 +778,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", "📊 Статистика"),
@@ -815,20 +845,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}"
)
])
@@ -875,6 +913,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"
)
],
[
+163 -74
View File
@@ -9,7 +9,11 @@ from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES
from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.utils.pricing_utils import format_period_description, apply_percentage_discount
from app.utils.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,
@@ -78,7 +82,40 @@ def _build_additional_buttons(additional_section, language: str) -> List[InlineK
_LANGUAGE_DISPLAY_NAMES = {
"ru": "🇷🇺 Русский",
"ru-ru": "🇷🇺 Русский",
"en": "🇬🇧 English",
"en-us": "🇺🇸 English",
"en-gb": "🇬🇧 English",
"ua": "🇺🇦 Українська",
"uk": "🇺🇦 Українська",
"uk-ua": "🇺🇦 Українська",
"kk": "🇰🇿 Қазақша",
"kk-kz": "🇰🇿 Қазақша",
"kz": "🇰🇿 Қазақша",
"uz": "🇺🇿 Oʻzbekcha",
"uz-uz": "🇺🇿 Oʻzbekcha",
"tr": "🇹🇷 Türkçe",
"tr-tr": "🇹🇷 Türkçe",
"pl": "🇵🇱 Polski",
"pl-pl": "🇵🇱 Polski",
"de": "🇩🇪 Deutsch",
"de-de": "🇩🇪 Deutsch",
"fr": "🇫🇷 Français",
"fr-fr": "🇫🇷 Français",
"es": "🇪🇸 Español",
"es-es": "🇪🇸 Español",
"it": "🇮🇹 Italiano",
"it-it": "🇮🇹 Italiano",
"pt": "🇵🇹 Português",
"pt-pt": "🇵🇹 Português",
"pt-br": "🇧🇷 Português",
"zh": "🇨🇳 中文",
"zh-cn": "🇨🇳 中文 (简体)",
"zh-hans": "🇨🇳 中文 (简体)",
"zh-tw": "🇹🇼 中文 (繁體)",
"zh-hant": "🇹🇼 中文 (繁體)",
"vi": "🇻🇳 Tiếng Việt",
"vi-vn": "🇻🇳 Tiếng Việt",
}
def get_rules_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
@@ -91,27 +128,34 @@ def get_rules_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup
])
def get_channel_sub_keyboard(
channel_link: str,
channel_link: Optional[str],
language: str = DEFAULT_LANGUAGE,
) -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(
inline_keyboard=[
buttons: List[List[InlineKeyboardButton]] = []
if channel_link:
buttons.append(
[
InlineKeyboardButton(
text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"),
url=channel_link,
)
],
[
InlineKeyboardButton(
text=texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался"),
callback_data="sub_channel_check",
)
],
]
)
buttons.append(
[
InlineKeyboardButton(
text=texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался"),
callback_data="sub_channel_check",
)
]
)
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_post_registration_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language)
@@ -353,10 +397,13 @@ def get_main_menu_keyboard(
paired_buttons.append(simple_purchase_button)
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,
)
)
@@ -668,7 +715,7 @@ def get_insufficient_balance_keyboard(
return_row = [
InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout",
callback_data="return_to_saved_cart",
)
]
insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard)
@@ -800,7 +847,7 @@ def get_payment_methods_keyboard_with_cart(
keyboard.inline_keyboard.insert(-1, [ # Вставляем перед кнопкой "назад"
InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout"
callback_data="return_to_saved_cart"
)
])
@@ -857,36 +904,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)
@@ -1129,6 +1199,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(
@@ -1402,27 +1480,8 @@ def _get_days_word(days: int) -> str:
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(
@@ -1959,15 +2018,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}"
)
])
@@ -2082,33 +2165,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)
+94 -11
View File
@@ -130,6 +130,8 @@
"ADMIN_MAIN_SETTINGS": "⚙️ Settings",
"ADMIN_MAIN_SUPPORT": "🛟 Support",
"ADMIN_MAIN_SYSTEM": "🛠️ System",
"ADMIN_MAIN_TRIALS": "🧪 Trials",
"ADMIN_MAIN_PAYMENTS": "💳 Top-ups",
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions",
"ADMIN_MESSAGES": "📨 Broadcasts",
"ADMIN_MESSAGES_ALL_USERS": "📨 All users",
@@ -164,6 +166,44 @@
"ADMIN_MONITORING_STOP": "⏸️ Stop",
"ADMIN_MONITORING_STOP_HARD": "⏹️ Stop",
"ADMIN_MONITORING_TEST_NOTIFICATIONS": "🧪 Test notifications",
"ADMIN_PAYMENTS_TITLE": "💳 <b>Top-up verification</b>",
"ADMIN_PAYMENTS_DESCRIPTION": "Pending top-up invoices created during the last 24 hours.",
"ADMIN_PAYMENTS_NOTICE": "Only invoices younger than 24 hours and waiting for payment can be checked.",
"ADMIN_TRIALS_TITLE": "🧪 Trial management",
"ADMIN_TRIALS_STATS": "• Total trials used: {used}\n• Active now: {active}\n• Eligible for reset: {resettable}",
"ADMIN_TRIALS_RESET_BUTTON": "♻️ Reset all trials",
"ADMIN_TRIALS_RESET_RESULT": "♻️ Reset {reset_count} trials.\n\n• Total trials used: {used}\n• Active now: {active}\n• Eligible for reset: {resettable}",
"ADMIN_TRIALS_RESET_TOAST": "✅ Reset completed",
"ADMIN_PAYMENTS_EMPTY": "No pending top-up invoices found in the last 24 hours.",
"ADMIN_PAYMENTS_ITEM_DETAILS": "📄 #{number}",
"ADMIN_PAYMENT_STATUS_PENDING": "Pending",
"ADMIN_PAYMENT_STATUS_PROCESSING": "Processing",
"ADMIN_PAYMENT_STATUS_PAID": "Paid",
"ADMIN_PAYMENT_STATUS_FAILED": "Failed",
"ADMIN_PAYMENT_STATUS_CANCELED": "Cancelled",
"ADMIN_PAYMENT_STATUS_UNKNOWN": "Unknown status",
"ADMIN_PAYMENT_STATUS_ON_HOLD": "On hold",
"ADMIN_PAYMENT_STATUS_EXPIRED": "Expired",
"ADMIN_PAYMENT_DETAILS_TITLE": "💳 <b>Payment details</b>",
"ADMIN_PAYMENT_AMOUNT": "Amount",
"ADMIN_PAYMENT_CREATED": "Created",
"ADMIN_PAYMENT_EXPIRES": "Expires",
"ADMIN_PAYMENT_GATEWAY_STATUS": "Gateway status",
"ADMIN_PAYMENT_GATEWAY_METHOD": "Method",
"ADMIN_PAYMENT_GATEWAY_AMOUNT": "Gateway amount",
"ADMIN_PAYMENT_GATEWAY_ACCOUNT": "Payer account",
"ADMIN_PAYMENT_GATEWAY_ID": "Gateway ID",
"ADMIN_PAYMENT_PAYER_AMOUNT": "Paid amount",
"ADMIN_PAYMENT_CRYPTO_AMOUNT": "Crypto amount",
"ADMIN_PAYMENT_HAS_LINK": "🔗 A payment link is available via the button above.",
"ADMIN_PAYMENT_OPEN_LINK": "🔗 Open link",
"ADMIN_PAYMENT_CHECK_BUTTON": "🔁 Check status",
"ADMIN_PAYMENT_CHECK_HINT": "️ You can trigger a manual status check.",
"ADMIN_PAYMENT_CHECK_NOT_AVAILABLE": "Manual status check is not available for this invoice.",
"ADMIN_PAYMENT_CHECK_FAILED": "Failed to refresh the payment status.",
"ADMIN_PAYMENT_CHECK_SUCCESS": "Status updated: {status}",
"ADMIN_PAYMENT_CHECK_NO_CHANGES": "Status did not change after the check.",
"ADMIN_PAYMENT_NOT_FOUND": "Payment not found.",
"ADMIN_NODE_DISABLE": "⏸️ Disable",
"ADMIN_NODE_ENABLE": "▶️ Enable",
"ADMIN_NODE_RESTART": "🔄 Restart",
@@ -280,6 +320,7 @@
"ADMIN_PROMOCODE_TYPE_BALANCE": "💰 Balance",
"ADMIN_PROMOCODE_TYPE_DAYS": "📅 Subscription days",
"ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Trial",
"ADMIN_PROMOCODE_TYPE_PROMO_GROUP": "🏷️ Promo Group",
"ADMIN_PROMO_GROUPS": "💳 Promo groups",
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)",
"ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.",
@@ -336,7 +377,18 @@
"ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:",
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts",
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts",
"ADMIN_PROMO_GROUP_UPDATED": "Promo group {name} updated.",
"ADMIN_PROMO_GROUP_UPDATED": "Promo group \"{name}\" updated.",
"ADMIN_PROMO_GROUP_PRIORITY_LINE": "🎯 Priority: {priority}",
"ADMIN_PROMO_GROUP_CREATE_PRIORITY_PROMPT": "Enter group priority (0 = base, higher = higher priority):",
"ADMIN_PROMO_GROUP_EDIT_PRIORITY_PROMPT": "Enter new priority (current: {current}):",
"ADMIN_PROMO_GROUP_EDIT_FIELD_PRIORITY": "🎯 Priority",
"ADMIN_PROMO_GROUP_INVALID_PRIORITY": "❌ Priority must be a non-negative integer",
"ADMIN_USER_PROMO_GROUPS_PRIMARY": "⭐ Primary: {name} (Priority: {priority})",
"ADMIN_USER_PROMO_GROUPS_ADDITIONAL": "Additional groups:",
"ADMIN_USER_PROMO_GROUPS_NONE": "User has no promo groups",
"ADMIN_USER_PROMO_GROUP_ADDED": "✅ Group «{name}» added",
"ADMIN_USER_PROMO_GROUP_REMOVED": "🗑 Group «{name}» removed",
"ADMIN_USER_PROMO_GROUP_CANNOT_REMOVE_LAST": "❌ Cannot remove the last promo group",
"ADMIN_PROMO_OFFERS_TITLE": "🎯 <b>Promo offers</b>\n\nSelect a template to configure:",
"ADMIN_PROMO_OFFER_ACTIVE_DURATION": "After activation the discount lasts for {hours} h.",
"ADMIN_PROMO_OFFER_ALLOWED": "Available segments:",
@@ -746,6 +798,10 @@
"BALANCE_SUPPORT_REQUEST": "🛠️ Request via support",
"BALANCE_TOPUP": "💳 Top up balance",
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\\n💡 <b>Balance top-up required</b>\\n\\nYour cart contains items totaling {total_amount}, but your current balance is insufficient.\\n\\n💳 <b>Top up your balance</b> to complete the purchase.\\n\\nChoose a top-up method:",
"AUTO_PURCHASE_SUBSCRIPTION_SUCCESS": "✅ Your {period} subscription was purchased automatically after topping up your balance.",
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED": "✅ Subscription automatically extended for {period}.",
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS": "⏰ New expiration date: {date}.",
"AUTO_PURCHASE_SUBSCRIPTION_HINT": "Open the My subscription section to access your link and setup instructions.",
"BALANCE_TOP_UP": "💳 Top up",
"BLOCK_BY_TIME": "⏳ Temporary block",
"BLOCK_FOREVER": "🚫 Block permanently",
@@ -818,6 +874,9 @@
"DEVICE_CHANGE_NO_REFUND": "Payments are not refunded",
"DEVICE_CHANGE_NO_REFUND_INFO": "️ Payments are not refunded",
"DEVICE_CHANGE_RESULT_LINE": "📱 Was: {old} → Now: {new}\n",
"DEVICES_INVALID_REQUEST": "❌ Invalid request",
"DEVICES_INVALID_COUNT": "❌ Invalid device count",
"DEVICES_SELECTION_DISABLED": "⚠️ Device selection is unavailable",
"DEVICE_CONNECTION_HELP": "❓ How to reconnect a device?",
"DEVICE_FETCH_ERROR": "❌ Failed to load devices",
"DEVICE_FETCH_INFO_ERROR": "❌ Failed to load device information",
@@ -964,7 +1023,6 @@
"PAL24_INSTRUCTION_FOLLOW": "{step}. Follow the payment page instructions",
"PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.",
"PAL24_PAYMENT_INSTRUCTIONS": "🏦 <b>PayPalych (SBP) payment</b>\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 <b>How to pay:</b>\n1. Press Pay with PayPalych (SBP)\n2. Follow the system prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
"PAL24_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)",
"PAL24_SBP_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)",
"PAL24_SELECT_PAYMENT_METHOD": "Choose a PayPalych payment method:",
"PAL24_TOPUP_PROMPT": "🏦 <b>PayPalych (SBP) payment</b>\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed via the PayPalych Faster Payments System.",
@@ -974,6 +1032,7 @@
"PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)",
"PAYMENT_CARD_WATA": "💳 Bank card (WATA)",
"PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)",
"PAYMENT_PLATEGA": "💳 Platega",
"PAYMENT_CHARGE_ERROR": "⚠️ Failed to charge the payment",
"PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)",
"PAYMENT_HELEKET": "🪙 Cryptocurrency (Heleket)",
@@ -1001,20 +1060,16 @@
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.",
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot",
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Cryptocurrency</b>",
"PAYMENT_METHOD_HELEKET_DESCRIPTION": "via Heleket",
"PAYMENT_METHOD_HELEKET_NAME": "🪙 <b>Cryptocurrency (Heleket)</b>",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via {mulenpay_name}",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card ({mulenpay_name})</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System",
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>SBP (PayPalych)</b>",
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "via Platega (cards + SBP)",
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Bank card (Platega)</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options",
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Support team</b>",
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute",
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Bank card</b>",
"PAYMENT_METHOD_WATA_DESCRIPTION": "via WATA",
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Bank card (WATA)</b>",
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa",
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "via YooKassa Fast Payment System",
@@ -1025,6 +1080,18 @@
"PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)",
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
"PAYMENT_VIA_SUPPORT": "🛠️ Via support",
"PLATEGA_TOPUP_PROMPT": "💳 <b>Payment via Platega ({method_name})</b>\n\nEnter an amount from {min_amount} to {max_amount}.\nPayment is processed by Platega.",
"PLATEGA_SELECT_PAYMENT_METHOD": "Choose a Platega payment method:",
"PLATEGA_TEMPORARILY_UNAVAILABLE": "❌ Platega payments are temporarily unavailable",
"PLATEGA_METHODS_NOT_CONFIGURED": "⚠️ No active Platega methods configured",
"PLATEGA_METHOD_SELECTION_REQUIRED": "⚠️ Select a Platega payment method before entering the amount",
"PLATEGA_AMOUNT_TOO_LOW": "Minimum amount for Platega: {amount}",
"PLATEGA_AMOUNT_TOO_HIGH": "Maximum amount for Platega: {amount}",
"PLATEGA_PAYMENT_ERROR": "❌ Failed to create Platega payment. Please try again later or contact support.",
"PLATEGA_PAYMENT_INSTRUCTIONS": "💳 <b>Payment via Platega ({method})</b>\n\n💰 Amount: {amount}\n🆔 Transaction ID: {transaction}\n\n📱 <b>Instructions:</b>\n1. Tap the Pay button\n2. Follow the payment provider instructions\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ If you have issues, contact {support}",
"PLATEGA_PAY_BUTTON": "💳 Pay via {method}",
"PLATEGA_PAYMENT_ALREADY_CONFIRMED": "✅ Payment already credited",
"PLATEGA_PAYMENT_STATUS": "Current payment status: {status}",
"PAY_NOW_BUTTON": "💳 Pay",
"PAY_WITH_COINS_BUTTON": "🪙 Pay",
"PENDING_CANCEL_BUTTON": "⌛ Cancel",
@@ -1080,7 +1147,11 @@
"REFERRAL_CODE_APPLIED": "🎁 Referral code applied! You will receive a bonus after the first purchase.",
"REFERRAL_CODE_INVALID": "❌ Invalid referral code",
"REFERRAL_CODE_INVALID_HELP": "❌ Invalid referral code.\n\n💡 If you have a referral code, please double-check the spelling.\n⏭️ To continue without a referral code, use the /start command.",
"REFERRAL_CODE_QUESTION": "\n🤝 <b>Do you have a friend's referral code?</b>\n\nIf you have a promo code or referral link, enter it now to receive a bonus!\n\nSend the code or tap \"Skip\":\n",
"REFERRAL_CODE_QUESTION": "\n🤝 <b>Do you have a referral code or promo code?</b>\n\nIf you have a promo code or referral link from a friend, enter it now to receive a bonus!\n\nSend the code or tap \"Skip\":\n",
"REFERRAL_OR_PROMO_CODE_INVALID": "❌ Invalid referral code or promo code",
"REFERRAL_OR_PROMO_CODE_INVALID_HELP": "❌ Invalid referral code or promo code.\n\n💡 If you have a referral code or promo code, please double-check the spelling.\n⏭️ To continue registration without a code, use the /start command.",
"PROMOCODE_ACTIVATED_AT_REGISTRATION": "✅ Promo code activated!\n\n{description}",
"PROMOCODE_ACCEPTED_WILL_ACTIVATE": "✅ Promo code accepted! It will be activated after registration is complete.",
"REFERRAL_CODE_SKIP": "⏭️ Skip",
"REFERRAL_CODE_TITLE": "🆔 <b>Your code:</b> <code>{code}</code>",
"REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 <b>Earnings by type:</b>",
@@ -1172,7 +1243,7 @@
"SKIP_BUTTON": "Skip ➡️",
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Failed to credit funds. Please contact support; the payment will be verified manually.",
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Technical error processing the payment. Please contact support for assistance.",
"STARS_PAYMENT_SUCCESS": "🎉 <b>Payment processed successfully!</b>\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\nThank you for topping up! 🚀",
"STARS_PAYMENT_SUCCESS": "🎉 <b>Payment processed successfully!</b>\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\n⚠️ <b>Important:</b> Balance top-up does not automatically activate your subscription. Please activate your subscription separately!\n\n🔄 If you have a saved subscription cart and auto-purchase is enabled, your subscription will be automatically purchased after balance top-up.\n\nThank you for topping up! 🚀",
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Error: user not found. Please contact support.",
"STARS_PRECHECK_INVALID_PAYLOAD": "Payment validation error. Please try again.",
"STARS_PRECHECK_TECHNICAL_ERROR": "Technical error. Please try again later.",
@@ -1267,6 +1338,8 @@
"SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠️ expires today!",
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Active\n⚠️ expires tomorrow!",
"SUB_STATUS_EXPIRED": "🔴 Expired\n📅 {end_date}",
"SUB_STATUS_DISABLED": "⚫ Disabled",
"SUB_STATUS_PENDING": "⏳ Pending activation",
"SUB_STATUS_NONE": "❌ Not available",
"SUB_STATUS_TRIAL_ACTIVE": "🎁 Trial subscription\n📅 until {end_date} ({days} days)",
"SUB_STATUS_TRIAL_TODAY": "🎁 Trial subscription\n⚠️ expires today!",
@@ -1337,10 +1410,19 @@
"TRAFFIC_NO_CHANGE": "️ Traffic limit was not changed",
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured",
"TRAFFIC_UNLIMITED": "📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
"TRAFFIC_UNLIMITED_SHORT": "Unlimited",
"TRIAL_ACTIVATED": "🎉 Trial subscription activated!",
"TRIAL_ACTIVATE_BUTTON": "🎁 Activate",
"TRIAL_ALREADY_USED": "❌ The trial subscription has already been used",
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a free trial plan:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic} GB\n📱 <b>Devices:</b> {devices} pcs\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a trial plan{price_line}:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic}{devices_line}\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
"TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 <b>Devices:</b> {devices} pcs",
"TRIAL_PAYMENT_PRICE_LINE": "\n💳 <b>Activation price:</b> {price}",
"TRIAL_PAYMENT_INSUFFICIENT_FUNDS": "⚠️ Not enough funds to activate the trial.\nRequired: {required}\nOn balance: {balance}\nMissing: {missing}\n\nTop up your balance and try again.",
"TRIAL_PAYMENT_FAILED": "We couldn't charge your balance to activate the trial. Please try again later.",
"TRIAL_PROVISIONING_FAILED": "We couldn't finish setting up the trial. Any charge has been refunded. Please try again later.",
"TRIAL_ROLLBACK_FAILED": "We couldn't cancel the trial activation after a payment error. Please contact support and try again later.",
"TRIAL_REFUND_FAILED": "We couldn't refund the trial activation charge. Please contact support immediately.",
"TRIAL_PAYMENT_CHARGED_NOTE": "💳 {amount} has been deducted from your balance.",
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Access paused</b>\n\nWe couldn't find your subscription to our channel, so the trial plan has been disabled.\n\nJoin the channel and tap “{check_button}” to restore access.",
"TRIAL_ENDING_SOON": "\n🎁 <b>The trial subscription is ending soon!</b>\n\nYour trial expires in a few hours.\n\n💎 <b>Don't want to lose VPN access?</b>\nSwitch to the full subscription!\n\n🔥 <b>Special offer:</b>\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n",
"TRIAL_INACTIVE_1H": "⏳ <b>An hour has passed and we haven't seen any traffic yet</b>\n\nOpen the connection guide and follow the steps. We're always ready to help!",
@@ -1392,6 +1474,7 @@
"ADMIN_POLLS_CREATION_TITLE_PROMPT": "🗳️ <b>Create poll</b>\n\nEnter poll title:",
"ADMIN_POLLS_CREATION_CANCELLED": "❌ Poll creation cancelled.",
"ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT": "Enter poll description. HTML is allowed.\nSend /skip to omit.",
"ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED": "Description omitted.",
"ADMIN_POLLS_CREATION_INVALID_HTML": "❌ HTML error: {error}",
"ADMIN_POLLS_CREATION_REWARD_PROMPT": "Enter reward amount in RUB. Send 0 to disable reward.",
"ADMIN_POLLS_CREATION_REWARD_INVALID": "❌ Invalid amount. Try again.",
+113 -10
View File
@@ -130,6 +130,8 @@
"ADMIN_MAIN_SETTINGS": "⚙️ Настройки",
"ADMIN_MAIN_SUPPORT": "🛟 Поддержка",
"ADMIN_MAIN_SYSTEM": "🛠️ Система",
"ADMIN_MAIN_TRIALS": "🧪 Триалы",
"ADMIN_MAIN_PAYMENTS": "💳 Пополнения",
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки",
"ADMIN_MESSAGES": "📨 Рассылки",
"ADMIN_MESSAGES_ALL_USERS": "📨 Всем пользователям",
@@ -164,6 +166,44 @@
"ADMIN_MONITORING_STOP": "⏸️ Остановить",
"ADMIN_MONITORING_STOP_HARD": "⏹️ Остановить",
"ADMIN_MONITORING_TEST_NOTIFICATIONS": "🧪 Тест уведомлений",
"ADMIN_PAYMENTS_TITLE": "💳 <b>Проверка пополнений</b>",
"ADMIN_PAYMENTS_DESCRIPTION": "Список счетов на пополнение, созданных за последние 24 часа и ожидающих оплаты.",
"ADMIN_PAYMENTS_NOTICE": "Проверять можно только счета моложе 24 часов и со статусом ожидания.",
"ADMIN_TRIALS_TITLE": "🧪 Управление триалами",
"ADMIN_TRIALS_STATS": "• Использовано всего: {used}\n• Активно сейчас: {active}\n• Доступно к сбросу: {resettable}",
"ADMIN_TRIALS_RESET_BUTTON": "♻️ Сбросить все триалы",
"ADMIN_TRIALS_RESET_RESULT": "♻️ Сбросили {reset_count} триалов.\n\n• Использовано всего: {used}\n• Активно сейчас: {active}\n• Доступно к сбросу: {resettable}",
"ADMIN_TRIALS_RESET_TOAST": "✅ Сброс завершен",
"ADMIN_PAYMENTS_EMPTY": "За последние 24 часа не найдено счетов на пополнение в ожидании.",
"ADMIN_PAYMENTS_ITEM_DETAILS": "📄 №{number}",
"ADMIN_PAYMENT_STATUS_PENDING": "Ожидает оплаты",
"ADMIN_PAYMENT_STATUS_PROCESSING": "Обрабатывается",
"ADMIN_PAYMENT_STATUS_PAID": "Оплачен",
"ADMIN_PAYMENT_STATUS_FAILED": "Ошибка",
"ADMIN_PAYMENT_STATUS_CANCELED": "Отменён",
"ADMIN_PAYMENT_STATUS_UNKNOWN": "Статус неизвестен",
"ADMIN_PAYMENT_STATUS_ON_HOLD": "На удержании",
"ADMIN_PAYMENT_STATUS_EXPIRED": "Просрочен",
"ADMIN_PAYMENT_DETAILS_TITLE": "💳 <b>Детали платежа</b>",
"ADMIN_PAYMENT_AMOUNT": "Сумма",
"ADMIN_PAYMENT_CREATED": "Создан",
"ADMIN_PAYMENT_EXPIRES": "Истекает",
"ADMIN_PAYMENT_GATEWAY_STATUS": "Статус в платёжке",
"ADMIN_PAYMENT_GATEWAY_METHOD": "Метод оплаты",
"ADMIN_PAYMENT_GATEWAY_AMOUNT": "Сумма в платёжке",
"ADMIN_PAYMENT_GATEWAY_ACCOUNT": "Счёт плательщика",
"ADMIN_PAYMENT_GATEWAY_ID": "ID в платёжке",
"ADMIN_PAYMENT_PAYER_AMOUNT": "Оплачено",
"ADMIN_PAYMENT_CRYPTO_AMOUNT": "Сумма в криптовалюте",
"ADMIN_PAYMENT_HAS_LINK": "🔗 Ссылка на оплату доступна в кнопке выше.",
"ADMIN_PAYMENT_OPEN_LINK": "🔗 Открыть ссылку",
"ADMIN_PAYMENT_CHECK_BUTTON": "🔁 Проверить статус",
"ADMIN_PAYMENT_CHECK_HINT": "ℹ️ Можно запустить ручную проверку статуса.",
"ADMIN_PAYMENT_CHECK_NOT_AVAILABLE": "Для этого счёта ручная проверка недоступна.",
"ADMIN_PAYMENT_CHECK_FAILED": "Не удалось обновить статус платежа.",
"ADMIN_PAYMENT_CHECK_SUCCESS": "Статус обновлён: {status}",
"ADMIN_PAYMENT_CHECK_NO_CHANGES": "Статус не изменился после проверки.",
"ADMIN_PAYMENT_NOT_FOUND": "Платёж не найден.",
"ADMIN_NODE_DISABLE": "⏸️ Отключить",
"ADMIN_NODE_ENABLE": "▶️ Включить",
"ADMIN_NODE_RESTART": "🔄 Перезагрузить",
@@ -280,6 +320,7 @@
"ADMIN_PROMOCODE_TYPE_BALANCE": "💰 Баланс",
"ADMIN_PROMOCODE_TYPE_DAYS": "📅 Дни подписки",
"ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Триал",
"ADMIN_PROMOCODE_TYPE_PROMO_GROUP": "🏷️ Промогруппа",
"ADMIN_PROMO_GROUPS": "💳 Промогруппы",
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)",
"ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.",
@@ -337,6 +378,17 @@
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги",
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги",
"ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.",
"ADMIN_PROMO_GROUP_PRIORITY_LINE": "🎯 Приоритет: {priority}",
"ADMIN_PROMO_GROUP_CREATE_PRIORITY_PROMPT": "Введите приоритет группы (0 = базовая, чем больше - тем выше приоритет):",
"ADMIN_PROMO_GROUP_EDIT_PRIORITY_PROMPT": "Введите новый приоритет (текущий: {current}):",
"ADMIN_PROMO_GROUP_EDIT_FIELD_PRIORITY": "🎯 Приоритет",
"ADMIN_PROMO_GROUP_INVALID_PRIORITY": "❌ Приоритет должен быть неотрицательным целым числом",
"ADMIN_USER_PROMO_GROUPS_PRIMARY": "⭐ Основная: {name} (Priority: {priority})",
"ADMIN_USER_PROMO_GROUPS_ADDITIONAL": "Дополнительные группы:",
"ADMIN_USER_PROMO_GROUPS_NONE": "У пользователя нет промогрупп",
"ADMIN_USER_PROMO_GROUP_ADDED": "✅ Группа «{name}» добавлена",
"ADMIN_USER_PROMO_GROUP_REMOVED": "🗑 Группа «{name}» удалена",
"ADMIN_USER_PROMO_GROUP_CANNOT_REMOVE_LAST": "❌ Нельзя удалить последнюю промогруппу",
"ADMIN_PROMO_OFFERS_TITLE": "🎯 <b>Промо-предложения</b>\n\nВыберите предложение для настройки:",
"ADMIN_PROMO_OFFER_ACTIVE_DURATION": "Скидка после активации действует {hours} ч.",
"ADMIN_PROMO_OFFER_ALLOWED": "Доступные категории:",
@@ -679,6 +731,26 @@
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.",
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю",
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа",
"ADMIN_USER_REFERRALS_BUTTON": "🤝 Рефералы",
"ADMIN_USER_REFERRALS_TITLE": "🤝 <b>Рефералы пользователя</b>",
"ADMIN_USER_REFERRALS_SUMMARY": "👤 {name} (ID: <code>{telegram_id}</code>)\n👥 Всего рефералов: {count}",
"ADMIN_USER_REFERRALS_LIST_HEADER": "<b>Список рефералов:</b>",
"ADMIN_USER_REFERRALS_LIST_ITEM": "• {name} (ID: <code>{telegram_id}</code>{username_part})",
"ADMIN_USER_REFERRALS_LIST_TRUNCATED": "• … и ещё {count} рефералов",
"ADMIN_USER_REFERRALS_EMPTY": "Рефералов пока нет.",
"ADMIN_USER_REFERRALS_EDIT_HINT": "✏️ Чтобы изменить список, нажмите «✏️ Редактировать» ниже.",
"ADMIN_USER_REFERRALS_EDIT_BUTTON": "✏️ Редактировать",
"ADMIN_USER_REFERRALS_EDIT_PROMPT": "✏️ <b>Редактирование рефералов</b>\n\nОтправьте список рефералов для пользователя <b>{name}</b> (ID: <code>{telegram_id}</code>):\n• Используйте TG ID или @username\n• Значения можно указывать через запятую, пробел или с новой строки\n• Чтобы очистить список, отправьте 0 или слово 'нет'\n\nИли нажмите кнопку ниже, чтобы отменить.",
"ADMIN_USER_REFERRALS_STATE_LOST": "❌ Не удалось определить пользователя. Попробуйте начать сначала.",
"ADMIN_USER_REFERRALS_NO_VALID": "❌ Не удалось найти ни одного пользователя по введённым данным.",
"ADMIN_USER_REFERRALS_INVALID_ENTRIES": "Не найдены: {values}",
"ADMIN_USER_REFERRALS_SELF_SKIPPED": "Пропущены значения пользователя: {values}",
"ADMIN_USER_REFERRALS_DUPLICATES": "Игнорированы дубли: {values}",
"ADMIN_USER_REFERRALS_UPDATE_ERROR": "❌ Не удалось обновить рефералов. Попробуйте позже.",
"ADMIN_USER_REFERRALS_UPDATED": "✅ Список рефералов обновлён.",
"ADMIN_USER_REFERRALS_UPDATED_TOTAL": "• Текущий список: {total}",
"ADMIN_USER_REFERRALS_UPDATED_ADDED": "• Добавлено: {count}",
"ADMIN_USER_REFERRALS_UPDATED_REMOVED": "• Удалено: {count}",
"ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}",
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена",
"ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
@@ -746,6 +818,10 @@
"BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку",
"BALANCE_TOPUP": "💳 Пополнить баланс",
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\n💡 <b>Требуется пополнение баланса</b>\n\nВ вашей корзине находятся товары на общую сумму {total_amount}, но на балансе недостаточно средств.\n\n💳 <b>Пополните баланс</b>, чтобы завершить покупку.\n\nВыберите способ пополнения:",
"AUTO_PURCHASE_SUBSCRIPTION_SUCCESS": "✅ Подписка на {period} автоматически оформлена после пополнения баланса.",
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED": "✅ Подписка автоматически продлена на {period}.",
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS": "⏰ Новая дата окончания: {date}.",
"AUTO_PURCHASE_SUBSCRIPTION_HINT": "Перейдите в раздел «Моя подписка», чтобы получить ссылку и инструкции.",
"BALANCE_TOP_UP": "💳 Пополнить",
"BLOCK_BY_TIME": "⏳ Блокировка по времени",
"BLOCK_FOREVER": "🚫 Заблокировать",
@@ -818,6 +894,9 @@
"DEVICE_CHANGE_NO_REFUND": "Возврат средств не производится",
"DEVICE_CHANGE_NO_REFUND_INFO": "ℹ️ Возврат средств не производится",
"DEVICE_CHANGE_RESULT_LINE": "📱 Было: {old} → Стало: {new}\n",
"DEVICES_INVALID_REQUEST": "❌ Некорректный запрос",
"DEVICES_INVALID_COUNT": "❌ Некорректное количество устройств",
"DEVICES_SELECTION_DISABLED": "⚠️ Выбор количества устройств недоступен",
"DEVICE_CONNECTION_HELP": "❓ Как подключить устройство заново?",
"DEVICE_FETCH_ERROR": "❌ Ошибка получения устройств",
"DEVICE_FETCH_INFO_ERROR": "❌ Ошибка получения информации об устройствах",
@@ -964,7 +1043,6 @@
"PAL24_INSTRUCTION_FOLLOW": "{step}. Следуйте подсказкам платёжной системы",
"PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
"PAL24_PAYMENT_INSTRUCTIONS": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку ‘Оплатить через PayPalych (СБП)’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
"PAL24_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
"PAL24_SBP_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
"PAL24_SELECT_PAYMENT_METHOD": "Выберите способ оплаты PayPalych:",
"PAL24_TOPUP_PROMPT": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.",
@@ -974,6 +1052,7 @@
"PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)",
"PAYMENT_CARD_WATA": "💳 Банковская карта (WATA)",
"PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)",
"PAYMENT_PLATEGA": "💳 Platega",
"PAYMENT_CHARGE_ERROR": "⚠️ Ошибка списания средств",
"PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)",
"PAYMENT_HELEKET": "🪙 Криптовалюта (Heleket)",
@@ -1001,20 +1080,16 @@
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.",
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot",
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Криптовалюта</b>",
"PAYMENT_METHOD_HELEKET_DESCRIPTION": "через Heleket",
"PAYMENT_METHOD_HELEKET_NAME": "🪙 <b>Криптовалюта (Heleket)</b>",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через {mulenpay_name}",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта ({mulenpay_name})</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей",
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>СБП (PayPalych)</b>",
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (карты + СБП)",
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банковская карта (Platega)</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы",
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>",
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Банковская карта</b>",
"PAYMENT_METHOD_WATA_DESCRIPTION": "через WATA",
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Банковская карта (WATA)</b>",
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa",
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему быстрых платежей YooKassa",
@@ -1025,6 +1100,18 @@
"PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)",
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
"PAYMENT_VIA_SUPPORT": "🛠️ Через поддержку",
"PLATEGA_TOPUP_PROMPT": "💳 <b>Оплата через Platega ({method_name})</b>\n\nВведите сумму для пополнения от {min_amount} до {max_amount}.\nОплата происходит через Platega.",
"PLATEGA_SELECT_PAYMENT_METHOD": "Выберите способ оплаты Platega:",
"PLATEGA_TEMPORARILY_UNAVAILABLE": "❌ Оплата через Platega временно недоступна",
"PLATEGA_METHODS_NOT_CONFIGURED": "⚠️ На стороне Platega нет доступных методов оплаты",
"PLATEGA_METHOD_SELECTION_REQUIRED": "⚠️ Выберите способ оплаты Platega перед вводом суммы",
"PLATEGA_AMOUNT_TOO_LOW": "Минимальная сумма для оплаты через Platega: {amount}",
"PLATEGA_AMOUNT_TOO_HIGH": "Максимальная сумма для оплаты через Platega: {amount}",
"PLATEGA_PAYMENT_ERROR": "❌ Ошибка создания платежа Platega. Попробуйте позже или обратитесь в поддержку.",
"PLATEGA_PAYMENT_INSTRUCTIONS": "💳 <b>Оплата через Platega ({method})</b>\n\n💰 Сумма: {amount}\n🆔 ID транзакции: {transaction}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку «Оплатить»\n2. Следуйте подсказкам платёжной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
"PLATEGA_PAY_BUTTON": "💳 Оплатить через {method}",
"PLATEGA_PAYMENT_ALREADY_CONFIRMED": "✅ Платёж уже зачислен",
"PLATEGA_PAYMENT_STATUS": "Текущий статус платежа: {status}",
"PAY_NOW_BUTTON": "💳 Оплатить",
"PAY_WITH_COINS_BUTTON": "🪙 Оплатить",
"PENDING_CANCEL_BUTTON": "⌛ Отмена",
@@ -1080,7 +1167,11 @@
"REFERRAL_CODE_APPLIED": "🎁 Реферальный код применен! Вы получите бонус после первой покупки.",
"REFERRAL_CODE_INVALID": "❌ Неверный реферальный код",
"REFERRAL_CODE_INVALID_HELP": "❌ Неверный реферальный код.\n\n💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n⏭️ Для продолжения регистрации без реферального кода используйте команду /start",
"REFERRAL_CODE_QUESTION": "\n🤝 <b>У вас есть реферальный код от друга?</b>\n\nЕсли у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!\n\nВведите код или нажмите \"Пропустить\":\n",
"REFERRAL_CODE_QUESTION": "\n🤝 <b>У вас есть реферальный код или промокод?</b>\n\nЕсли у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!\n\nВведите код или нажмите \"Пропустить\":\n",
"REFERRAL_OR_PROMO_CODE_INVALID": "❌ Неверный реферальный код или промокод",
"REFERRAL_OR_PROMO_CODE_INVALID_HELP": "❌ Неверный реферальный код или промокод.\n\n💡 Если у вас есть реферальный код или промокод, убедитесь что он введен правильно.\n⏭️ Для продолжения регистрации без кода используйте команду /start",
"PROMOCODE_ACTIVATED_AT_REGISTRATION": "✅ Промокод активирован!\n\n{description}",
"PROMOCODE_ACCEPTED_WILL_ACTIVATE": "✅ Промокод принят! Он будет активирован после завершения регистрации.",
"REFERRAL_CODE_SKIP": "⏭️ Пропустить",
"REFERRAL_CODE_TITLE": "🆔 <b>Ваш код:</b> <code>{code}</code>",
"REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 <b>Доходы по типам:</b>",
@@ -1172,7 +1263,7 @@
"SKIP_BUTTON": "⏭️ Пропустить",
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Произошла ошибка при зачислении средств. Обратитесь в поддержку, платеж будет проверен вручную.",
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Техническая ошибка при обработке платежа. Обратитесь в поддержку для решения проблемы.",
"STARS_PAYMENT_SUCCESS": "🎉 <b>Платеж успешно обработан!</b>\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n🆔 ID транзакции: {transaction_id}...\n\nСпасибо за пополнение! 🚀",
"STARS_PAYMENT_SUCCESS": "🎉 <b>Платеж успешно обработан!</b>\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n\n🆔 ID транзакции: {transaction_id}...\n\n⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. Обязательно активируйте подписку отдельно!\n\n🔄 При наличии сохранённой корзины подписки и включенной автопокупке, подписка будет приобретена автоматически после пополнения баланса.\n\nСпасибо за пополнение! 🚀",
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Ошибка: пользователь не найден. Обратитесь в поддержку.",
"STARS_PRECHECK_INVALID_PAYLOAD": "Ошибка валидации платежа. Попробуйте еще раз.",
"STARS_PRECHECK_TECHNICAL_ERROR": "Техническая ошибка. Попробуйте позже.",
@@ -1267,6 +1358,8 @@
"SUB_STATUS_ACTIVE_TODAY": "💎 Активна\n⚠️ истекает сегодня!",
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Активна\n⚠️ истекает завтра!",
"SUB_STATUS_EXPIRED": "🔴 Истекла\n📅 {end_date}",
"SUB_STATUS_DISABLED": "⚫ Отключена",
"SUB_STATUS_PENDING": "⏳ Ожидает активации",
"SUB_STATUS_NONE": "❌ Отсутствует",
"SUB_STATUS_TRIAL_ACTIVE": "🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
"SUB_STATUS_TRIAL_TODAY": "🎁 Тестовая подписка\n⚠️ истекает сегодня!",
@@ -1337,10 +1430,19 @@
"TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился",
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакеты трафика не настроены",
"TRAFFIC_UNLIMITED": "📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
"TRAFFIC_UNLIMITED_SHORT": "Безлимит",
"TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!",
"TRIAL_ACTIVATE_BUTTON": "🎁 Активировать",
"TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована",
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic} ГБ\n📱 <b>Устройства:</b> {devices} шт.\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить тестовую подписку{price_line}:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic}{devices_line}\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
"TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 <b>Устройства:</b> {devices} шт.",
"TRIAL_PAYMENT_PRICE_LINE": "\n💳 <b>Стоимость активации:</b> {price}",
"TRIAL_PAYMENT_INSUFFICIENT_FUNDS": "⚠️ Недостаточно средств для активации триала.\nНеобходимо: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nПополните баланс и попробуйте снова.",
"TRIAL_PAYMENT_FAILED": "Не удалось списать средства для активации триала. Попробуйте позже.",
"TRIAL_PROVISIONING_FAILED": "Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.",
"TRIAL_ROLLBACK_FAILED": "Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.",
"TRIAL_REFUND_FAILED": "Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.",
"TRIAL_PAYMENT_CHARGED_NOTE": "💳 С вашего баланса списано {amount}.",
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Доступ приостановлен</b>\n\nМы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\nПодпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.",
"TRIAL_ENDING_SOON": "\n🎁 <b>Тестовая подписка скоро закончится!</b>\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 <b>Не хотите остаться без VPN?</b>\nПереходите на полную подписку!\n\n🔥 <b>Специальное предложение:</b>\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n",
"TRIAL_INACTIVE_1H": "⏳ <b>Прошёл час, а подключение не выполнено</b>\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!",
@@ -1392,6 +1494,7 @@
"ADMIN_POLLS_CREATION_TITLE_PROMPT": "🗳️ <b>Создание опроса</b>\n\nВведите заголовок опроса:",
"ADMIN_POLLS_CREATION_CANCELLED": "❌ Создание опроса отменено.",
"ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT": "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.",
"ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED": "Описание пропущено.",
"ADMIN_POLLS_CREATION_INVALID_HTML": "❌ Ошибка в HTML: {error}",
"ADMIN_POLLS_CREATION_REWARD_PROMPT": "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.",
"ADMIN_POLLS_CREATION_REWARD_INVALID": "❌ Некорректная сумма. Попробуйте ещё раз.",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+97 -49
View File
@@ -16,6 +16,79 @@ _logger = logging.getLogger(__name__)
_cached_rules: Dict[str, str] = {}
_LANGUAGE_ALIASES = {
"uk": "ua",
}
_DYNAMIC_LANGUAGE_CONFIGS = {
"ru": {
"traffic_pattern": "📊 {size} ГБ - {price}",
"unlimited_pattern": "📊 Безлимит - {price}",
"support_info": (
"\n🛟 <b>Поддержка</b>\n\n"
"Это центр тикетов: создавайте обращения, просматривайте ответы и историю.\n\n"
"• 🎫 Создать тикет — опишите проблему или вопрос\n"
"• 📋 Мои тикеты — статус и переписка\n"
"• 💬 Связаться — написать напрямую (если нужно)\n\n"
"Старайтесь использовать тикеты — так мы быстрее поможем и ничего не потеряется.\n"
),
},
"en": {
"traffic_pattern": "📊 {size} GB - {price}",
"unlimited_pattern": "📊 Unlimited - {price}",
"support_info": (
"\n🛟 <b>RemnaWave Support</b>\n\n"
"This is the ticket center: create requests, view replies and history.\n\n"
"• 🎫 Create ticket — describe your issue or question\n"
"• 📋 My tickets — status and conversation\n"
"• 💬 Contact — message directly if needed\n\n"
"Prefer tickets — it helps us respond faster and keep context.\n"
),
},
"ua": {
"traffic_pattern": "📊 {size} ГБ - {price}",
"unlimited_pattern": "📊 Безліміт - {price}",
"support_info": (
"\n🛠️ <b>Технічна підтримка</b>\n\n"
"З усіх питань звертайтеся до нашої підтримки:\n\n"
"👤 {support_username}\n\n"
"Ми допоможемо з:\n"
"• Налаштуванням підключення\n"
"• Вирішенням технічних проблем\n"
"• Питаннями щодо оплати\n"
"• Іншими питаннями\n\n"
"⏰ Час відповіді: зазвичай протягом 1-2 годин\n"
),
},
"zh": {
"traffic_pattern": "📊{size}GB-{price}",
"unlimited_pattern": "📊无限-{price}",
"support_info": (
"\n🛠️ <b>技术支持</b>\n\n"
"如有任何问题,请联系我们的支持团队:\n\n"
"👤 {support_username}\n\n"
"我们将帮助您:\n"
"• 设置连接\n"
"• 解决技术问题\n"
"• 付款问题\n"
"• 其他问题\n\n"
"⏰ 响应时间:通常在 1-2 小时内\n"
),
},
}
_TRAFFIC_TIERS = (
("TRAFFIC_5GB", "5", "PRICE_TRAFFIC_5GB"),
("TRAFFIC_10GB", "10", "PRICE_TRAFFIC_10GB"),
("TRAFFIC_25GB", "25", "PRICE_TRAFFIC_25GB"),
("TRAFFIC_50GB", "50", "PRICE_TRAFFIC_50GB"),
("TRAFFIC_100GB", "100", "PRICE_TRAFFIC_100GB"),
("TRAFFIC_250GB", "250", "PRICE_TRAFFIC_250GB"),
)
def _get_cached_rules_value(language: str) -> str:
if language in _cached_rules:
return _cached_rules[language]
@@ -28,57 +101,32 @@ def _get_cached_rules_value(language: str) -> str:
def _build_dynamic_values(language: str) -> Dict[str, Any]:
language_code = (language or DEFAULT_LANGUAGE).split("-")[0].lower()
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)}",
"TRAFFIC_50GB": f"📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
"TRAFFIC_100GB": f"📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
"TRAFFIC_250GB": f"📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
"TRAFFIC_UNLIMITED": f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
"SUPPORT_INFO": (
"\n🛟 <b>Поддержка</b>\n\n"
"Это центр тикетов: создавайте обращения, просматривайте ответы и историю.\n\n"
"• 🎫 Создать тикет — опишите проблему или вопрос\n"
"• 📋 Мои тикеты — статус и переписка\n"
"• 💬 Связаться — написать напрямую (если нужно)\n\n"
"Старайтесь использовать тикеты — так мы быстрее поможем и ничего не потеряется.\n"
),
}
language_code = _LANGUAGE_ALIASES.get(language_code, language_code)
config = _DYNAMIC_LANGUAGE_CONFIGS.get(language_code)
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)}",
"TRAFFIC_50GB": f"📊 50 GB - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
"TRAFFIC_100GB": f"📊 100 GB - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
"TRAFFIC_250GB": f"📊 250 GB - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
"TRAFFIC_UNLIMITED": f"📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
"SUPPORT_INFO": (
"\n🛟 <b>RemnaWave Support</b>\n\n"
"This is the ticket center: create requests, view replies and history.\n\n"
"• 🎫 Create ticket — describe your issue or question\n"
"• 📋 My tickets — status and conversation\n"
"• 💬 Contact — message directly if needed\n\n"
"Prefer tickets — it helps us respond faster and keep context.\n"
),
}
if not config:
return {}
return {}
values: Dict[str, Any] = {}
traffic_pattern = config["traffic_pattern"]
for key, size, price_attr in _TRAFFIC_TIERS:
price_value = getattr(settings, price_attr)
values[key] = traffic_pattern.format(
size=size,
price=settings.format_price(price_value),
)
values["TRAFFIC_UNLIMITED"] = config["unlimited_pattern"].format(
price=settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)
)
support_template = config.get("support_info")
if support_template:
values["SUPPORT_INFO"] = support_template.format(
support_username=settings.SUPPORT_USERNAME
)
return values
class Texts:
+125 -18
View File
@@ -1,6 +1,6 @@
import logging
from typing import Callable, Dict, Any, Awaitable, Optional
from aiogram import BaseMiddleware, Bot
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__)
@@ -80,7 +82,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
bot: Bot = data["bot"]
channel_id = settings.CHANNEL_SUB_ID
if not channel_id:
logger.warning("⚠️ CHANNEL_SUB_ID не установлен, пропускаем проверку")
return await handler(event, data)
@@ -91,7 +93,12 @@ class ChannelCheckerMiddleware(BaseMiddleware):
logger.debug("⚠️ Обязательная подписка отключена, пропускаем проверку")
return await handler(event, data)
channel_link = settings.CHANNEL_LINK
channel_link = self._normalize_channel_link(settings.CHANNEL_LINK, channel_id)
if not channel_link:
logger.warning(
"⚠️ CHANNEL_LINK не задан или невалиден, кнопка подписки будет скрыта"
)
try:
member = await bot.get_chat_member(chat_id=channel_id, user_id=telegram_id)
@@ -104,22 +111,22 @@ class ChannelCheckerMiddleware(BaseMiddleware):
if telegram_id:
await self._deactivate_trial_subscription(telegram_id)
await self._capture_start_payload(state, event)
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
return await self._deny_message(event, bot, channel_link)
return await self._deny_message(event, bot, channel_link, channel_id)
else:
logger.warning(f"⚠️ Неожиданный статус пользователя {telegram_id}: {member.status}")
await self._capture_start_payload(state, event)
return await self._deny_message(event, bot, channel_link)
await self._capture_start_payload(state, event, bot)
return await self._deny_message(event, bot, channel_link, channel_id)
except TelegramForbiddenError as e:
logger.error(f"❌ Бот заблокирован в канале {channel_id}: {e}")
await self._capture_start_payload(state, event)
return await self._deny_message(event, bot, channel_link)
await self._capture_start_payload(state, event, bot)
return await self._deny_message(event, bot, channel_link, channel_id)
except TelegramBadRequest as e:
if "chat not found" in str(e).lower():
logger.error(f"❌ Канал {channel_id} не найден: {e}")
@@ -127,13 +134,36 @@ class ChannelCheckerMiddleware(BaseMiddleware):
logger.error(f"❌ Пользователь {telegram_id} не найден: {e}")
else:
logger.error(f"❌ Ошибка запроса к каналу {channel_id}: {e}")
await self._capture_start_payload(state, event)
return await self._deny_message(event, bot, channel_link)
await self._capture_start_payload(state, event, bot)
return await self._deny_message(event, bot, channel_link, channel_id)
except Exception as e:
logger.error(f"❌ Неожиданная ошибка при проверке подписки: {e}")
return await handler(event, data)
async def _capture_start_payload(self, state: Optional[FSMContext], event: TelegramObject) -> None:
@staticmethod
def _normalize_channel_link(channel_link: Optional[str], channel_id: Optional[str]) -> Optional[str]:
link = (channel_link or "").strip()
if link.startswith("@"): # raw username
return f"https://t.me/{link.lstrip('@')}"
if link and not link.lower().startswith(("http://", "https://", "tg://")):
return f"https://{link}"
if link:
return link
if channel_id and str(channel_id).startswith("@"):
return f"https://t.me/{str(channel_id).lstrip('@')}"
return None
async def _capture_start_payload(
self,
state: Optional[FSMContext],
event: TelegramObject,
bot: Optional[Bot] = None,
) -> None:
if not state:
return
@@ -159,12 +189,69 @@ class ChannelCheckerMiddleware(BaseMiddleware):
payload = parts[1]
data = await state.get_data() or {}
if data.get("pending_start_payload") == payload:
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
data["pending_start_payload"] = payload
await state.set_data(data)
logger.debug("💾 Сохранен start payload %s для последующей обработки", payload)
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():
@@ -214,7 +301,12 @@ class ChannelCheckerMiddleware(BaseMiddleware):
break
@staticmethod
async def _deny_message(event: TelegramObject, bot: Bot, channel_link: str):
async def _deny_message(
event: TelegramObject,
bot: Bot,
channel_link: Optional[str],
channel_id: Optional[str],
):
logger.debug("🚫 Отправляем сообщение о необходимости подписки")
user = None
@@ -237,11 +329,26 @@ class ChannelCheckerMiddleware(BaseMiddleware):
"🔒 Для использования бота подпишитесь на новостной канал, чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!",
)
if not channel_link and channel_id:
channel_hint = None
if str(channel_id).startswith("@"): # username-based channel id
channel_hint = f"@{str(channel_id).lstrip('@')}"
if channel_hint:
text = f"{text}\n\n{channel_hint}"
try:
if isinstance(event, Message):
return await event.answer(text, reply_markup=channel_sub_kb)
elif isinstance(event, CallbackQuery):
return await event.message.edit_text(text, reply_markup=channel_sub_kb)
try:
return await event.message.edit_text(text, reply_markup=channel_sub_kb)
except TelegramBadRequest as e:
if "message is not modified" in str(e).lower():
logger.debug("ℹ️ Сообщение уже содержит текст проверки подписки, пропускаем редактирование")
return await event.answer(text, show_alert=True)
raise
elif isinstance(event, Update) and event.message:
return await bot.send_message(event.message.chat.id, text, reply_markup=channel_sub_kb)
except Exception as e:
+91 -44
View File
@@ -19,6 +19,7 @@ from app.database.models import (
TransactionType,
User,
)
from app.utils.timezone import format_local_datetime
logger = logging.getLogger(__name__)
@@ -77,6 +78,20 @@ class AdminNotificationService:
)
return None
def _get_user_display(self, user: User) -> str:
first_name = getattr(user, "first_name", "") or ""
if first_name:
return first_name
username = getattr(user, "username", "") or ""
if username:
return username
telegram_id = getattr(user, "telegram_id", None)
if telegram_id is None:
return "IDUnknown"
return f"ID{telegram_id}"
def _format_promo_group_discounts(self, promo_group: PromoGroup) -> List[str]:
discount_lines: List[str] = []
@@ -175,7 +190,9 @@ class AdminNotificationService:
self,
db: AsyncSession,
user: User,
subscription: Subscription
subscription: Subscription,
*,
charged_amount_kopeks: Optional[int] = None,
) -> bool:
if not self._is_enabled():
return False
@@ -185,26 +202,42 @@ class AdminNotificationService:
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
user_display = self._get_user_display(user)
trial_device_limit = subscription.device_limit
if trial_device_limit is None:
fallback_forced_limit = settings.get_disabled_mode_device_limit()
if fallback_forced_limit is not None:
trial_device_limit = fallback_forced_limit
else:
trial_device_limit = settings.TRIAL_DEVICE_LIMIT
payment_block = ""
if charged_amount_kopeks and charged_amount_kopeks > 0:
payment_block = (
f"\n💳 <b>Оплата за активацию:</b> {settings.format_price(charged_amount_kopeks)}"
)
message = f"""🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>
👤 <b>Пользователь:</b> {user.full_name}
👤 <b>Пользователь:</b> {user_display}
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
📱 <b>Username:</b> @{user.username or 'отсутствует'}
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
👥 <b>Статус:</b> {user_status}
{promo_block}
<b>Параметры триала:</b>
📅 Период: {settings.TRIAL_DURATION_DAYS} дней
📊 Трафик: {settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ
📱 Устройства: {settings.TRIAL_DEVICE_LIMIT}
📊 Трафик: {self._format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB)}
📱 Устройства: {trial_device_limit}
🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else 'По умолчанию'}
{payment_block}
📆 <b>Действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}
🔗 <b>Реферер:</b> {referrer_info}
<i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
<i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
return await self._send_message(message)
@@ -227,28 +260,29 @@ class AdminNotificationService:
try:
event_type = "🔄 КОНВЕРСИЯ ИЗ ТРИАЛА" if was_trial_conversion else "💎 ПОКУПКА ПОДПИСКИ"
if was_trial_conversion:
user_status = "🎯 Конверсия из триала"
elif user.has_had_paid_subscription:
user_status = "🔄 Продление/Обновление"
else:
user_status = "🆕 Первая покупка"
servers_info = await self._get_servers_info(subscription.connected_squads)
payment_method = self._get_payment_method_display(transaction.payment_method) if transaction else "Баланс"
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
user_display = self._get_user_display(user)
total_amount = amount_kopeks if amount_kopeks is not None else (transaction.amount_kopeks if transaction else 0)
transaction_id = transaction.id if transaction else ""
message = f"""💎 <b>{event_type}</b>
👤 <b>Пользователь:</b> {user.full_name}
👤 <b>Пользователь:</b> {user_display}
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
📱 <b>Username:</b> @{user.username or 'отсутствует'}
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
👥 <b>Статус:</b> {user_status}
{promo_block}
@@ -264,11 +298,11 @@ class AdminNotificationService:
📱 Устройства: {subscription.device_limit}
🌐 Серверы: {servers_info}
📆 <b>Действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}
💰 <b>Баланс после покупки:</b> {settings.format_price(user.balance_kopeks)}
🔗 <b>Реферер:</b> {referrer_info}
<i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
<i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
return await self._send_message(message)
@@ -315,7 +349,7 @@ class AdminNotificationService:
Для обновления перезапустите контейнер с новым тегом или обновите код из репозитория.
<i>Автоматическая проверка обновлений {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
<i>Автоматическая проверка обновлений {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
return await self._send_message(message)
@@ -340,7 +374,7 @@ class AdminNotificationService:
🔄 Следующая попытка через час.
Проверьте доступность GitHub API и настройки сети.
<i>Система автоматических обновлений {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
<i>Система автоматических обновлений {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
return await self._send_message(message)
@@ -363,13 +397,14 @@ class AdminNotificationService:
balance_change = user.balance_kopeks - old_balance
subscription_status = self._get_subscription_status(subscription)
promo_block = self._format_promo_group_block(promo_group)
timestamp = datetime.now().strftime('%d.%m.%Y %H:%M:%S')
timestamp = format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')
user_display = self._get_user_display(user)
return f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
👤 <b>Пользователь:</b> {user.full_name}
👤 <b>Пользователь:</b> {user_display}
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
📱 <b>Username:</b> @{user.username or 'отсутствует'}
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
💳 <b>Статус:</b> {topup_status}
{promo_block}
@@ -540,15 +575,16 @@ class AdminNotificationService:
servers_info = await self._get_servers_info(subscription.connected_squads)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
user_display = self._get_user_display(user)
current_end_date = new_end_date or subscription.end_date
current_balance = balance_after if balance_after is not None else user.balance_kopeks
message = f"""⏰ <b>ПРОДЛЕНИЕ ПОДПИСКИ</b>
👤 <b>Пользователь:</b> {user.full_name}
👤 <b>Пользователь:</b> {user_display}
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
📱 <b>Username:</b> @{user.username or 'отсутствует'}
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
{promo_block}
@@ -559,8 +595,8 @@ class AdminNotificationService:
📅 <b>Продление:</b>
Добавлено дней: {extended_days}
📆 Было до: {old_end_date.strftime('%d.%m.%Y %H:%M')}
📆 Стало до: {current_end_date.strftime('%d.%m.%Y %H:%M')}
📆 Было до: {format_local_datetime(old_end_date, '%d.%m.%Y %H:%M')}
📆 Стало до: {format_local_datetime(current_end_date, '%d.%m.%Y %H:%M')}
📱 <b>Текущие параметры:</b>
📊 Трафик: {self._format_traffic(subscription.traffic_limit_gb)}
@@ -569,7 +605,7 @@ class AdminNotificationService:
💰 <b>Баланс после операции:</b> {settings.format_price(current_balance)}
<i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
<i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
return await self._send_message(message)
@@ -592,13 +628,14 @@ class AdminNotificationService:
promo_block = self._format_promo_group_block(promo_group)
type_display = self._get_promocode_type_display(promocode_data.get("type"))
usage_info = f"{promocode_data.get('current_uses', 0)}/{promocode_data.get('max_uses', 0)}"
user_display = self._get_user_display(user)
message_lines = [
"🎫 <b>АКТИВАЦИЯ ПРОМОКОДА</b>",
"",
f"👤 <b>Пользователь:</b> {user.full_name}",
f"👤 <b>Пользователь:</b> {user_display}",
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
f"📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}",
"",
promo_block,
"",
@@ -621,7 +658,7 @@ class AdminNotificationService:
valid_until = promocode_data.get("valid_until")
if valid_until:
message_lines.append(
f"⏳ Действует до: {valid_until.strftime('%d.%m.%Y %H:%M')}"
f"⏳ Действует до: {format_local_datetime(valid_until, '%d.%m.%Y %H:%M')}"
if isinstance(valid_until, datetime)
else f"⏳ Действует до: {valid_until}"
)
@@ -632,7 +669,7 @@ class AdminNotificationService:
"📝 <b>Эффект:</b>",
effect_description.strip() or "✅ Промокод активирован",
"",
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
]
)
@@ -686,7 +723,7 @@ class AdminNotificationService:
message_lines.extend(
[
"",
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
]
)
@@ -719,13 +756,14 @@ class AdminNotificationService:
)
elif automatic:
initiator_line = "🤖 Автоматическое назначение"
user_display = self._get_user_display(user)
message_lines = [
f"{title}",
"",
f"👤 <b>Пользователь:</b> {user.full_name}",
f"👤 <b>Пользователь:</b> {user_display}",
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
f"📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}",
"",
self._format_promo_group_block(new_group, title="Новая промогруппа", icon="🏆"),
]
@@ -750,7 +788,7 @@ class AdminNotificationService:
[
"",
f"💰 Баланс пользователя: {settings.format_price(user.balance_kopeks)}",
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
]
)
@@ -828,9 +866,9 @@ class AdminNotificationService:
return "❌ Нет подписки"
if subscription.is_trial:
return f"🎯 Триал (до {subscription.end_date.strftime('%d.%m')})"
return f"🎯 Триал (до {format_local_datetime(subscription.end_date, '%d.%m')})"
elif subscription.is_active:
return f"✅ Активна (до {subscription.end_date.strftime('%d.%m')})"
return f"✅ Активна (до {format_local_datetime(subscription.end_date, '%d.%m')})"
else:
return "❌ Неактивна"
@@ -901,7 +939,9 @@ class AdminNotificationService:
if isinstance(enabled_at, str):
from datetime import datetime
enabled_at = datetime.fromisoformat(enabled_at)
message_parts.append(f"🕐 <b>Время включения:</b> {enabled_at.strftime('%d.%m.%Y %H:%M:%S')}")
message_parts.append(
f"🕐 <b>Время включения:</b> {format_local_datetime(enabled_at, '%d.%m.%Y %H:%M:%S')}"
)
message_parts.append(f"🤖 <b>Автоматически:</b> {'Да' if details.get('auto_enabled', False) else 'Нет'}")
message_parts.append("")
@@ -913,7 +953,9 @@ class AdminNotificationService:
if isinstance(disabled_at, str):
from datetime import datetime
disabled_at = datetime.fromisoformat(disabled_at)
message_parts.append(f"🕐 <b>Время отключения:</b> {disabled_at.strftime('%d.%m.%Y %H:%M:%S')}")
message_parts.append(
f"🕐 <b>Время отключения:</b> {format_local_datetime(disabled_at, '%d.%m.%Y %H:%M:%S')}"
)
if details.get("duration"):
duration = details["duration"]
@@ -972,9 +1014,10 @@ class AdminNotificationService:
else:
message_parts.append("Автоматический мониторинг API остановлен.")
from datetime import datetime
message_parts.append("")
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
message_parts.append(
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"
)
message = "\n".join(message_parts)
@@ -1020,7 +1063,9 @@ class AdminNotificationService:
if isinstance(last_check, str):
from datetime import datetime
last_check = datetime.fromisoformat(last_check)
message_parts.append(f"🕐 <b>Последняя проверка:</b> {last_check.strftime('%H:%M:%S')}")
message_parts.append(
f"🕐 <b>Последняя проверка:</b> {format_local_datetime(last_check, '%H:%M:%S')}"
)
if status == "online":
if details.get("uptime"):
@@ -1066,9 +1111,10 @@ class AdminNotificationService:
message_parts.append("")
message_parts.append("Панель временно недоступна для обслуживания.")
from datetime import datetime
message_parts.append("")
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
message_parts.append(
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"
)
message = "\n".join(message_parts)
@@ -1095,6 +1141,7 @@ class AdminNotificationService:
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
user_display = self._get_user_display(user)
update_types = {
"traffic": ("📊 ИЗМЕНЕНИЕ ТРАФИКА", "трафик"),
@@ -1107,9 +1154,9 @@ class AdminNotificationService:
message_lines = [
f"{title}",
"",
f"👤 <b>Пользователь:</b> {user.full_name}",
f"👤 <b>Пользователь:</b> {user_display}",
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
f"📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}",
"",
promo_block,
"",
@@ -1142,11 +1189,11 @@ class AdminNotificationService:
message_lines.extend(
[
"",
f"📅 <b>Подписка действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}",
f"📅 <b>Подписка действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}",
f"💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}",
f"🔗 <b>Рефер:</b> {referrer_info}",
"",
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
]
)
File diff suppressed because it is too large Load Diff
+38 -18
View File
@@ -137,30 +137,50 @@ class BroadcastService:
keyboard = self._build_keyboard(config.selected_buttons)
for index, user in enumerate(recipients, start=1):
# Ограничение на количество одновременных отправок
semaphore = asyncio.Semaphore(20)
async def send_single_message(user):
"""Отправляет одно сообщение с семафором ограничения"""
async with semaphore:
if cancel_event.is_set():
return False
telegram_id = getattr(user, "telegram_id", None)
if telegram_id is None:
return False
try:
await self._deliver_message(telegram_id, config, keyboard)
return True
except Exception as exc: # noqa: BLE001
logger.error(
"Ошибка отправки рассылки %s пользователю %s: %s",
broadcast_id,
telegram_id,
exc,
)
return False
# Отправляем сообщения пакетами для эффективности
batch_size = 100
for i in range(0, len(recipients), batch_size):
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return
telegram_id = getattr(user, "telegram_id", None)
if telegram_id is None:
failed_count += 1
continue
batch = recipients[i:i + batch_size]
tasks = [send_single_message(user) for user in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
try:
await self._deliver_message(telegram_id, config, keyboard)
sent_count += 1
except Exception as exc: # noqa: BLE001
failed_count += 1
logger.error(
"Ошибка отправки рассылки %s пользователю %s: %s",
broadcast_id,
telegram_id,
exc,
)
for result in results:
if result is True:
sent_count += 1
else:
failed_count += 1
if index % 20 == 0:
await asyncio.sleep(1)
# Небольшая задержка между пакетами для снижения нагрузки на API
await asyncio.sleep(0.1)
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
+4 -9
View File
@@ -120,19 +120,16 @@ class AdvertisingCampaignService:
return CampaignBonusResult(success=False)
traffic_limit = campaign.subscription_traffic_gb
device_limit = (
campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT
)
device_limit = campaign.subscription_device_limit
if device_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
squads = list(campaign.subscription_squads or [])
if not squads:
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
trial_uuid = await get_random_trial_squad_uuid(
db,
getattr(settings, "TRIAL_SQUAD_UUID", None),
)
trial_uuid = await get_random_trial_squad_uuid(db)
if trial_uuid:
squads = [trial_uuid]
except Exception as error:
@@ -141,8 +138,6 @@ class AdvertisingCampaignService:
campaign.id,
error,
)
if getattr(settings, "TRIAL_SQUAD_UUID", None):
squads = [settings.TRIAL_SQUAD_UUID]
new_subscription = await create_paid_subscription(
db=db,
+135 -56
View File
@@ -7,6 +7,7 @@ from dataclasses import dataclass
from app.config import settings
from app.external.remnawave_api import RemnaWaveAPI, test_api_connection
from app.utils.cache import cache
from app.utils.timezone import format_local_datetime
logger = logging.getLogger(__name__)
@@ -45,6 +46,9 @@ class MaintenanceService:
def get_maintenance_message(self) -> str:
if self._status.auto_enabled:
last_check_display = format_local_datetime(
self._status.last_check, "%H:%M:%S", "неизвестно"
)
return f"""
🔧 Технические работы!
@@ -52,7 +56,7 @@ class MaintenanceService:
Мы работаем над восстановлением. Попробуйте через несколько минут.
🔄 Последняя проверка: {self._status.last_check.strftime('%H:%M:%S') if self._status.last_check else 'неизвестно'}
🔄 Последняя проверка: {last_check_display}
"""
else:
return settings.get_maintenance_message()
@@ -79,7 +83,12 @@ class MaintenanceService:
}
emoji = emoji_map.get(alert_type, "")
formatted_message = f"{emoji} <b>ТЕХНИЧЕСКИЕ РАБОТЫ</b>\n\n{message}\n\n⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"
timestamp = format_local_datetime(
datetime.utcnow(), "%d.%m.%Y %H:%M:%S %Z"
)
formatted_message = (
f"{emoji} <b>ТЕХНИЧЕСКИЕ РАБОТЫ</b>\n\n{message}\n\n⏰ <i>{timestamp}</i>"
)
return await notification_service._send_message(formatted_message)
@@ -152,11 +161,14 @@ class MaintenanceService:
await self._save_status_to_cache()
enabled_time = format_local_datetime(
self._status.enabled_at, "%d.%m.%Y %H:%M:%S %Z"
)
notification_msg = f"""Режим технических работ ВКЛЮЧЕН
📋 <b>Причина:</b> {self._status.reason}
🤖 <b>Автоматически:</b> {'Да' if auto else 'Нет'}
🕐 <b>Время:</b> {self._status.enabled_at.strftime('%d.%m.%Y %H:%M:%S')}
🕐 <b>Время:</b> {enabled_time}
Обычные пользователи временно не смогут использовать бота."""
@@ -197,10 +209,13 @@ class MaintenanceService:
else:
duration_str = f"\n⏱️ <b>Длительность:</b> {minutes}мин"
notification_time = format_local_datetime(
datetime.utcnow(), "%d.%m.%Y %H:%M:%S %Z"
)
notification_msg = f"""Режим технических работ ВЫКЛЮЧЕН
🤖 <b>Автоматически:</b> {'Да' if was_auto else 'Нет'}
🕐 <b>Время:</b> {datetime.utcnow().strftime('%d.%m.%Y %H:%M:%S')}
🕐 <b>Время:</b> {notification_time}
{duration_str}
Сервис снова доступен для пользователей."""
@@ -223,15 +238,23 @@ class MaintenanceService:
await self._load_status_from_cache()
self._check_task = asyncio.create_task(self._monitoring_loop())
logger.info(f"🔄 Запущен мониторинг API Remnawave (интервал: {settings.get_maintenance_check_interval()}с)")
await self._notify_admins(f"""Мониторинг технических работ запущен
logger.info(
"🔄 Запущен мониторинг API Remnawave (интервал: %sс, попыток: %s)",
settings.get_maintenance_check_interval(),
settings.get_maintenance_retry_attempts(),
)
await self._notify_admins(
f"""Мониторинг технических работ запущен
🔄 <b>Интервал проверки:</b> {settings.get_maintenance_check_interval()} секунд
🤖 <b>Автовключение:</b> {'Включено' if settings.is_maintenance_auto_enable() else 'Отключено'}
🎯 <b>Порог ошибок:</b> {self._max_consecutive_failures}
🔁 <b>Повторных попыток:</b> {settings.get_maintenance_retry_attempts()}
Система будет следить за доступностью API.""", "info")
Система будет следить за доступностью API.""",
"info",
)
return True
@@ -260,75 +283,130 @@ class MaintenanceService:
try:
if self._is_checking:
return self._status.api_status
self._is_checking = True
self._status.last_check = datetime.utcnow()
auth_params = settings.get_remnawave_auth_params()
base_url = (auth_params.get("base_url") or "").strip()
api_key = (auth_params.get("api_key") or "").strip()
secret_key = (auth_params.get("secret_key") or "").strip() or None
username = (auth_params.get("username") or "").strip() or None
password = (auth_params.get("password") or "").strip() or None
if not base_url:
logger.error("REMNAWAVE_API_URL не настроен, пропускаем проверку API")
self._status.api_status = False
self._status.consecutive_failures = 0
return False
if not api_key:
logger.error("REMNAWAVE_API_KEY не настроен, пропускаем проверку API")
self._status.api_status = False
self._status.consecutive_failures = 0
return False
api = RemnaWaveAPI(
base_url=auth_params["base_url"],
api_key=auth_params["api_key"],
secret_key=auth_params["secret_key"],
username=auth_params["username"],
password=auth_params["password"]
base_url=base_url,
api_key=api_key,
secret_key=secret_key,
username=username,
password=password
)
attempts = settings.get_maintenance_retry_attempts()
async with api:
is_connected = await test_api_connection(api)
if is_connected:
if not self._status.api_status:
await self._notify_admins(f"""API Remnawave восстановлено!
for attempt in range(1, attempts + 1):
is_connected = await test_api_connection(api)
if is_connected:
if attempt > 1:
logger.info(
"API Remnawave ответило с %s попытки", attempt
)
if not self._status.api_status:
recovery_time = format_local_datetime(
self._status.last_check, "%H:%M:%S %Z"
)
await self._notify_admins(
f"""API Remnawave восстановлено!
<b>Статус:</b> Доступно
🕐 <b>Время восстановления:</b> {self._status.last_check.strftime('%H:%M:%S')}
🕐 <b>Время восстановления:</b> {recovery_time}
🔄 <b>Неудачных попыток было:</b> {self._status.consecutive_failures}
API снова отвечает на запросы.""", "success")
self._status.api_status = True
self._status.consecutive_failures = 0
if self._status.is_active and self._status.auto_enabled:
await self.disable_maintenance()
logger.info("✅ API восстановился, режим техработ автоматически отключен")
return True
else:
was_available = self._status.api_status
self._status.api_status = False
self._status.consecutive_failures += 1
if was_available:
await self._notify_admins(f"""API Remnawave недоступно!
API снова отвечает на запросы.""",
"success",
)
self._status.api_status = True
self._status.consecutive_failures = 0
if self._status.is_active and self._status.auto_enabled:
await self.disable_maintenance()
logger.info("✅ API восстановился, режим техработ автоматически отключен")
return True
if attempt < attempts:
logger.warning(
"API Remnawave недоступно (попытка %s/%s)",
attempt,
attempts,
)
await asyncio.sleep(1)
was_available = self._status.api_status
self._status.api_status = False
self._status.consecutive_failures += 1
if was_available:
detection_time = format_local_datetime(
self._status.last_check, "%H:%M:%S %Z"
)
await self._notify_admins(
f"""API Remnawave недоступно!
<b>Статус:</b> Недоступно
🕐 <b>Время обнаружения:</b> {self._status.last_check.strftime('%H:%M:%S')}
🕐 <b>Время обнаружения:</b> {detection_time}
🔄 <b>Попытка:</b> {self._status.consecutive_failures}
Началась серия неудачных проверок API.""", "error")
if (self._status.consecutive_failures >= self._max_consecutive_failures and
not self._status.is_active and
settings.is_maintenance_auto_enable()):
await self.enable_maintenance(
reason=f"Автоматическое включение после {self._status.consecutive_failures} неудачных проверок API",
auto=True
)
return False
Началась серия неудачных проверок API.""",
"error",
)
if (
self._status.consecutive_failures >= self._max_consecutive_failures
and not self._status.is_active
and settings.is_maintenance_auto_enable()
):
await self.enable_maintenance(
reason=(
f"Автоматическое включение после {self._status.consecutive_failures} "
"неудачных проверок API"
),
auto=True
)
return False
except Exception as e:
logger.error(f"Ошибка проверки API: {e}")
if self._status.api_status:
await self._notify_admins(f"""Ошибка при проверке API Remnawave
error_time = format_local_datetime(datetime.utcnow(), "%H:%M:%S %Z")
await self._notify_admins(
f"""Ошибка при проверке API Remnawave
<b>Ошибка:</b> {str(e)}
🕐 <b>Время:</b> {datetime.utcnow().strftime('%H:%M:%S')}
🕐 <b>Время:</b> {error_time}
Не удалось выполнить проверку доступности API.""", "error")
Не удалось выполнить проверку доступности API.""",
"error",
)
self._status.api_status = False
self._status.consecutive_failures += 1
@@ -398,6 +476,7 @@ API снова отвечает на запросы.""", "success")
"api_status": self._status.api_status,
"consecutive_failures": self._status.consecutive_failures,
"monitoring_active": self._check_task is not None and not self._check_task.done(),
"monitoring_configured": settings.is_maintenance_monitoring_enabled(),
"auto_enable_configured": settings.is_maintenance_auto_enable(),
"check_interval": settings.get_maintenance_check_interval(),
"bot_connected": self._bot is not None
+140 -26
View File
@@ -38,7 +38,19 @@ from app.database.crud.user import (
subtract_user_balance,
cleanup_expired_promo_offer_discounts,
)
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus
from app.utils.timezone import format_local_datetime
from app.utils.subscription_utils import (
resolve_hwid_device_limit_for_payload,
)
from app.database.models import (
MonitoringLog,
SubscriptionStatus,
Subscription,
User,
Ticket,
TicketStatus,
UserPromoGroup,
)
from app.localization.texts import get_texts
from app.services.notification_settings_service import NotificationSettingsService
from app.services.payment_service import PaymentService
@@ -241,9 +253,6 @@ class MonitoringService:
await expire_subscription(db, subscription)
user = await get_user_by_id(db, subscription.user_id)
if user and user.remnawave_uuid:
await self.subscription_service.disable_remnawave_user(user.remnawave_uuid)
if user and self.bot:
await self._send_subscription_expired_notification(user)
@@ -282,21 +291,34 @@ class MonitoringService:
is_active = False
logger.info(f"📝 Статус подписки {subscription.id} обновлен на 'expired'")
async with self.api as api:
updated_user = await api.update_user(
if not self.subscription_service.is_configured:
logger.warning(
"RemnaWave API не настроен. Пропускаем обновление пользователя %s",
subscription.user_id,
)
return None
async with self.subscription_service.get_api_client() as api:
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
update_kwargs = dict(
uuid=user.remnawave_uuid,
status=UserStatus.ACTIVE if is_active else UserStatus.EXPIRED,
expire_at=subscription.end_date,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id
),
active_internal_squads=subscription.connected_squads
active_internal_squads=subscription.connected_squads,
)
if hwid_limit is not None:
update_kwargs['hwid_device_limit'] = hwid_limit
updated_user = await api.update_user(**update_kwargs)
subscription.subscription_url = updated_user.subscription_url
subscription.subscription_crypto_link = updated_user.happ_crypto_link
@@ -372,7 +394,12 @@ class MonitoringService:
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.options(
selectinload(Subscription.user).selectinload(User.promo_group),
selectinload(Subscription.user)
.selectinload(User.user_promo_groups)
.selectinload(UserPromoGroup.promo_group),
)
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
@@ -554,7 +581,11 @@ class MonitoringService:
)
continue
if subscription.status == SubscriptionStatus.ACTIVE.value and not is_member:
if (
subscription.status == SubscriptionStatus.ACTIVE.value
and subscription.is_trial
and not is_member
):
subscription = await deactivate_subscription(db, subscription)
disabled_count += 1
logger.info(
@@ -588,7 +619,11 @@ class MonitoringService:
subscription.id,
"trial_channel_unsubscribed",
)
elif subscription.status == SubscriptionStatus.DISABLED.value and is_member:
elif (
subscription.status == SubscriptionStatus.DISABLED.value
and subscription.is_trial
and is_member
):
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.updated_at = datetime.utcnow()
await db.commit()
@@ -877,12 +912,17 @@ class MonitoringService:
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.options(
selectinload(Subscription.user).options(
selectinload(User.promo_group),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
)
)
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.autopay_enabled == True,
Subscription.is_trial == False
Subscription.is_trial == False
)
)
)
@@ -902,7 +942,10 @@ class MonitoringService:
if not user:
continue
renewal_cost = settings.PRICE_30_DAYS
# Правильный расчет стоимости продления с учетом всех параметров подписки
renewal_cost = await self.subscription_service.calculate_renewal_price(
subscription, 30, db, user=user
)
promo_discount_percent = self._get_user_promo_offer_discount_percent(user)
charge_amount = renewal_cost
promo_discount_value = 0
@@ -1026,7 +1069,7 @@ class MonitoringService:
message = f"""
<b>Подписка истекает через {days_text}!</b>
Ваша платная подписка истекает {subscription.end_date.strftime("%d.%m.%Y %H:%M")}.
Ваша платная подписка истекает {format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M")}.
💳 <b>Автоплатеж:</b> {autopay_status}
@@ -1070,6 +1113,56 @@ class MonitoringService:
try:
texts = get_texts(user.language)
# Рассчитываем минимальную цену за подписку с минимальной конфигурацией
from app.config import settings, PERIOD_PRICES
from app.utils.pricing_utils import apply_percentage_discount
# Базовая цена за 30 дней
base_price_original = PERIOD_PRICES.get(30, settings.PRICE_30_DAYS)
# Применяем скидку промогруппы для категории "period"
promo_group_discount = user.get_promo_discount("period", 30) if user else 0
# Применяем пользовательскую промо-скидку (если есть)
user_discount_percent = self._get_user_promo_offer_discount_percent(user)
# Общая скидка - максимальная из промогруппы и пользовательской
total_discount_percent = max(promo_group_discount, user_discount_percent)
base_price, _ = apply_percentage_discount(base_price_original, total_discount_percent)
# Добавляем цену за трафик (если фиксированный трафик включён)
if settings.is_traffic_fixed():
traffic_price = settings.get_traffic_price(settings.get_fixed_traffic_limit())
# Применяем скидки на трафик
traffic_discount = user.get_promo_discount("traffic", 30) if user else 0
traffic_price, _ = apply_percentage_discount(traffic_price, traffic_discount)
else:
traffic_price = 0 # Трафик не фиксирован, цена включена в базовую
# Добавляем цену за серверы (предполагаем минимум 1 сервер по минимальной цене)
# Вместо сложного запроса к БД, используем настройки
# Для минимальной конфигурации - один сервер с минимальной ценой
min_server_price = getattr(settings, 'MIN_SERVER_PRICE', 0) or 0
if min_server_price == 0:
# Если нет явной минимальной цены, используем базовую цену
# В реальных условиях цена сервера будет определяться в ходе оформления подписки
min_server_price = 0
# Добавляем цену за устройства (если больше базового лимита)
# В минимальной конфигурации - базовый лимит, без доп. устройств
device_limit = settings.DEFAULT_DEVICE_LIMIT
additional_devices = max(0, device_limit - settings.DEFAULT_DEVICE_LIMIT)
devices_price = additional_devices * settings.PRICE_PER_DEVICE
# Для простоты и правильной работы без обращения к БД, рассчитываем минимальную цену как:
# базовая цена + минимальная цена за трафик (если есть фиксированный)
min_server_price = 0 # для минимальной конфигурации с 1 сервером используем 0 или минимальную известную
# Попробуем получить минимальную цену сервера из настроек или используем подходящее значение
# Находим минимальную возможную цену из возможных цен серверов
# В упрощенном варианте используем базовую конфигурацию: базовая цена + трафик
min_total_price = base_price + traffic_price
message = f"""
🎁 <b>Тестовая подписка скоро закончится!</b>
@@ -1079,7 +1172,7 @@ class MonitoringService:
Переходите на полную подписку!
🔥 <b>Специальное предложение:</b>
30 дней всего за {settings.format_price(settings.PRICE_30_DAYS)}
30 дней всего за {settings.format_price(min_total_price)}
Безлимитный трафик
Все серверы доступны
Скорость до 1ГБит/сек
@@ -1142,7 +1235,7 @@ class MonitoringService:
message = template.format(
price=settings.format_price(settings.PRICE_30_DAYS),
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"),
)
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
@@ -1258,7 +1351,7 @@ class MonitoringService:
),
)
message = template.format(
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"),
price=settings.format_price(settings.PRICE_30_DAYS),
)
@@ -1335,7 +1428,7 @@ class MonitoringService:
message = template.format(
percent=percent,
expires_at=expires_at.strftime("%d.%m.%Y %H:%M"),
expires_at=format_local_datetime(expires_at, "%d.%m.%Y %H:%M"),
trigger_days=trigger_days or "",
)
@@ -1473,7 +1566,11 @@ class MonitoringService:
if now.minute != 0:
return
async with self.subscription_service.api as api:
if not self.subscription_service.is_configured:
logger.warning("RemnaWave API не настроен. Пропускаем синхронизацию")
return
async with self.subscription_service.get_api_client() as api:
system_stats = await api.get_system_stats()
await self._log_monitoring_event(
@@ -1759,26 +1856,43 @@ class MonitoringService:
return []
async def get_monitoring_logs_count(
self,
self,
db: AsyncSession,
event_type: Optional[str] = None
) -> int:
try:
from sqlalchemy import select, func
query = select(func.count(MonitoringLog.id))
if event_type:
query = query.where(MonitoringLog.event_type == event_type)
result = await db.execute(query)
count = result.scalar()
return count or 0
except Exception as e:
logger.error(f"Ошибка получения количества логов: {e}")
return 0
async def get_monitoring_event_types(self, db: AsyncSession) -> List[str]:
try:
from sqlalchemy import select
result = await db.execute(
select(MonitoringLog.event_type)
.where(MonitoringLog.event_type.isnot(None))
.distinct()
.order_by(MonitoringLog.event_type)
)
return [row[0] for row in result.fetchall() if row[0]]
except Exception as e:
logger.error(f"Ошибка получения списка типов событий мониторинга: {e}")
return []
async def cleanup_old_logs(self, db: AsyncSession, days: int = 30) -> int:
try:
+131 -23
View File
@@ -1,6 +1,8 @@
import asyncio
import hashlib
import json
import logging
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, Tuple
import aiohttp
@@ -17,6 +19,10 @@ class MulenPayService:
self.shop_id = settings.MULENPAY_SHOP_ID
self.secret_key = settings.MULENPAY_SECRET_KEY
self.base_url = settings.MULENPAY_BASE_URL.rstrip("/")
self._timeout = aiohttp.ClientTimeout(total=30, connect=10, sock_read=25)
self._max_retries = 3
self._retry_delay = 0.5
self._retryable_statuses = {500, 502, 503, 504}
@property
def is_configured(self) -> bool:
@@ -45,31 +51,115 @@ class MulenPayService:
"Content-Type": "application/json",
}
try:
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.request(
last_error: Optional[BaseException] = None
for attempt in range(1, self._max_retries + 1):
try:
async with aiohttp.ClientSession(timeout=self._timeout) as session:
async with session.request(
method,
url,
headers=headers,
json=json_data,
params=params,
) as response:
data, raw_text = await self._deserialize_response(response)
if response.status >= 400:
logger.error(
"MulenPay API error %s %s: %s",
response.status,
endpoint,
raw_text,
)
if (
response.status in self._retryable_statuses
and attempt < self._max_retries
):
await self._sleep_with_backoff(attempt)
continue
return None
if data is None:
if raw_text:
logger.warning(
"MulenPay returned unexpected payload for %s: %s",
endpoint,
raw_text,
)
return None
return data
except asyncio.CancelledError:
logger.debug("MulenPay request cancelled: %s %s", method, endpoint)
raise
except asyncio.TimeoutError as error:
last_error = error
logger.warning(
"MulenPay request timeout (%s %s) attempt %s/%s",
method,
url,
headers=headers,
json=json_data,
params=params,
) as response:
data = await response.json(content_type=None)
endpoint,
attempt,
self._max_retries,
)
except aiohttp.ClientError as error:
last_error = error
logger.warning(
"MulenPay client error (%s %s) attempt %s/%s: %s",
method,
endpoint,
attempt,
self._max_retries,
error,
)
except Exception as error: # pragma: no cover - safety
logger.error("Unexpected MulenPay error: %s", error, exc_info=True)
return None
if response.status >= 400:
logger.error(
"MulenPay API error %s %s: %s", response.status, endpoint, data
)
return None
if attempt < self._max_retries:
await self._sleep_with_backoff(attempt)
return data
except aiohttp.ClientError as error:
logger.error("MulenPay API request error: %s", error)
return None
except Exception as error: # pragma: no cover - safety
logger.error("Unexpected MulenPay error: %s", error, exc_info=True)
return None
if isinstance(last_error, asyncio.TimeoutError):
logger.error(
"MulenPay request timed out after %s attempts: %s %s",
self._max_retries,
method,
endpoint,
)
elif last_error is not None:
logger.error(
"MulenPay request failed after %s attempts (%s %s): %s",
self._max_retries,
method,
endpoint,
last_error,
)
return None
async def _sleep_with_backoff(self, attempt: int) -> None:
await asyncio.sleep(self._retry_delay * attempt)
async def _deserialize_response(
self, response: aiohttp.ClientResponse
) -> Tuple[Optional[Dict[str, Any]], str]:
raw_text = await response.text()
if not raw_text:
return None, ""
content_type = response.headers.get("Content-Type", "")
if "json" in content_type.lower() or not content_type:
try:
return json.loads(raw_text), raw_text
except json.JSONDecodeError as error:
logger.error(
"Failed to decode MulenPay JSON response %s: %s",
response.url,
error,
)
return None, raw_text
return None, raw_text
@staticmethod
def _format_amount(amount_kopeks: int) -> str:
@@ -124,3 +214,21 @@ class MulenPayService:
async def get_payment(self, payment_id: int) -> Optional[Dict[str, Any]]:
return await self._request("GET", f"/v2/payments/{payment_id}")
async def list_payments(
self,
*,
offset: int = 0,
limit: int = 100,
uuid: Optional[str] = None,
status: Optional[int] = None,
) -> Optional[Dict[str, Any]]:
params = {
"offset": max(0, offset),
"limit": max(1, min(limit, 1000)),
}
if uuid:
params["uuid"] = uuid
if status is not None:
params["status"] = status
return await self._request("GET", "/v2/payments", params=params)
+10 -4
View File
@@ -83,22 +83,28 @@ class Pal24Service:
logger.debug("Запрашиваем статус Pal24 платежа %s", payment_id)
return await self.client.get_payment_status(payment_id)
async def get_bill_payments(self, bill_id: str) -> Dict[str, Any]:
"""Возвращает список платежей, связанных со счетом."""
logger.debug("Запрашиваем платежи Pal24 счёта %s", bill_id)
return await self.client.get_bill_payments(bill_id)
@staticmethod
def parse_postback(payload: Dict[str, Any]) -> Dict[str, Any]:
def parse_callback(payload: Dict[str, Any]) -> Dict[str, Any]:
required_fields = ["InvId", "OutSum", "Status", "SignatureValue"]
missing = [field for field in required_fields if field not in payload]
if missing:
raise Pal24APIError(f"Pal24 postback missing fields: {', '.join(missing)}")
raise Pal24APIError(f"Pal24 callback missing fields: {', '.join(missing)}")
inv_id = str(payload["InvId"])
out_sum = str(payload["OutSum"])
signature = str(payload["SignatureValue"])
if not Pal24Client.verify_signature(out_sum, inv_id, signature):
raise Pal24APIError("Pal24 postback signature mismatch")
raise Pal24APIError("Pal24 callback signature mismatch")
logger.info(
"Получен Pal24 postback: InvId=%s, Status=%s, TrsId=%s",
"Получен Pal24 callback: InvId=%s, Status=%s, TrsId=%s",
inv_id,
payload.get("Status"),
payload.get("TrsId"),
+2
View File
@@ -12,6 +12,7 @@ from .cryptobot import CryptoBotPaymentMixin
from .heleket import HeleketPaymentMixin
from .mulenpay import MulenPayPaymentMixin
from .pal24 import Pal24PaymentMixin
from .platega import PlategaPaymentMixin
from .wata import WataPaymentMixin
__all__ = [
@@ -23,5 +24,6 @@ __all__ = [
"HeleketPaymentMixin",
"MulenPayPaymentMixin",
"Pal24PaymentMixin",
"PlategaPaymentMixin",
"WataPaymentMixin",
]
+38 -5
View File
@@ -23,6 +23,7 @@ from app.services.subscription_checkout_service import (
has_subscription_checkout_draft,
should_offer_checkout_resume,
)
from app.services.user_cart_service import user_cart_service
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
logger = logging.getLogger(__name__)
@@ -41,6 +42,7 @@ class PaymentCommonMixin:
user and user.subscription and not user.subscription.is_trial and user.subscription.is_active
)
# Создаем основную кнопку: если есть активная подписка - продлить, иначе купить
first_button = build_miniapp_or_callback_button(
text=(
texts.MENU_EXTEND_SUBSCRIPTION
@@ -52,18 +54,45 @@ class PaymentCommonMixin:
),
)
keyboard_rows: list[list[InlineKeyboardButton]] = [[first_button]]
# Кнопка активации подписки (всегда отображается)
activate_subscription_button = build_miniapp_or_callback_button(
text="🚀 Активировать подписку",
callback_data="menu_buy" # Используем ту же callback_data что и "Купить подписку"
)
keyboard_rows: list[list[InlineKeyboardButton]] = [
[first_button],
[activate_subscription_button]
]
# Если для пользователя есть незавершённый checkout, предлагаем вернуться к нему.
if user:
draft_exists = await has_subscription_checkout_draft(user.id)
if should_offer_checkout_resume(user, draft_exists):
try:
has_saved_cart = await user_cart_service.has_user_cart(user.id)
except Exception as cart_error:
logger.warning(
"Не удалось проверить наличие сохраненной корзины у пользователя %s: %s",
user.id,
cart_error,
)
has_saved_cart = False
if has_saved_cart:
keyboard_rows.append([
build_miniapp_or_callback_button(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout",
callback_data="return_to_saved_cart",
)
])
else:
draft_exists = await has_subscription_checkout_draft(user.id)
if should_offer_checkout_resume(user, draft_exists):
keyboard_rows.append([
build_miniapp_or_callback_button(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout",
)
])
# Стандартные кнопки быстрого доступа к балансу и главному меню.
keyboard_rows.append([
@@ -109,7 +138,11 @@ class PaymentCommonMixin:
"✅ <b>Платеж успешно завершен!</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"💳 Способ: {payment_method}\n\n"
"Средства зачислены на ваш баланс!"
"Средства зачислены на ваш баланс!\n\n"
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса."
)
await self.bot.send_message(
+505 -62
View File
@@ -1,8 +1,9 @@
"""Mixin с логикой обработки платежей CryptoBot."""
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from datetime import datetime
from importlib import import_module
from typing import Any, Dict, Optional
@@ -10,13 +11,56 @@ from typing import Any, Dict, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.database import AsyncSessionLocal
from app.database.models import PaymentMethod, TransactionType
from app.services.subscription_auto_purchase_service import (
auto_purchase_saved_cart_after_topup,
)
from app.services.subscription_renewal_service import (
SubscriptionRenewalChargeError,
SubscriptionRenewalPricing,
SubscriptionRenewalService,
RenewalPaymentDescriptor,
build_renewal_period_id,
decode_payment_payload,
parse_payment_metadata,
)
from app.utils.currency_converter import currency_converter
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
renewal_service = SubscriptionRenewalService()
@dataclass(slots=True)
class _AdminNotificationContext:
user_id: int
transaction_id: int
old_balance: int
topup_status: str
referrer_info: str
@dataclass(slots=True)
class _UserNotificationPayload:
telegram_id: int
text: str
parse_mode: Optional[str]
reply_markup: Any
amount_rubles: float
asset: str
@dataclass(slots=True)
class _SavedCartNotificationPayload:
telegram_id: int
text: str
reply_markup: Any
user_id: int
class CryptoBotPaymentMixin:
"""Mixin, отвечающий за генерацию инвойсов CryptoBot и обработку webhook."""
@@ -141,19 +185,51 @@ class CryptoBotPaymentMixin:
db, invoice_id, status, paid_at
)
descriptor = decode_payment_payload(
getattr(updated_payment, "payload", "") or "",
expected_user_id=updated_payment.user_id,
)
if descriptor is None:
inline_payload = payload.get("payload")
if isinstance(inline_payload, str) and inline_payload:
descriptor = decode_payment_payload(
inline_payload,
expected_user_id=updated_payment.user_id,
)
if descriptor is None:
metadata = payload.get("metadata")
if isinstance(metadata, dict) and metadata:
descriptor = parse_payment_metadata(
metadata,
expected_user_id=updated_payment.user_id,
)
if descriptor:
renewal_handled = await self._process_subscription_renewal_payment(
db,
updated_payment,
descriptor,
cryptobot_crud,
)
if renewal_handled:
return True
if not updated_payment.transaction_id:
amount_usd = updated_payment.amount_float
try:
amount_rubles = await currency_converter.usd_to_rub(amount_usd)
amount_kopeks = int(amount_rubles * 100)
amount_rubles_rounded = math.ceil(amount_rubles)
amount_kopeks = int(amount_rubles_rounded * 100)
conversion_rate = (
amount_rubles / amount_usd if amount_usd > 0 else 0
)
logger.info(
"Конвертация USD->RUB: $%s -> %s₽ (курс: %.2f)",
"Конвертация USD->RUB: $%s -> %s₽ (округлено до %s₽, курс: %.2f)",
amount_usd,
amount_rubles,
amount_rubles_rounded,
conversion_rate,
)
except Exception as error:
@@ -163,7 +239,8 @@ class CryptoBotPaymentMixin:
error,
)
amount_rubles = amount_usd
amount_kopeks = int(amount_usd * 100)
amount_rubles_rounded = math.ceil(amount_rubles)
amount_kopeks = int(amount_rubles_rounded * 100)
conversion_rate = 1.0
if amount_kopeks <= 0:
@@ -182,7 +259,7 @@ class CryptoBotPaymentMixin:
amount_kopeks=amount_kopeks,
description=(
"Пополнение через CryptoBot "
f"({updated_payment.amount} {updated_payment.asset}{amount_rubles:.2f}₽)"
f"({updated_payment.amount} {updated_payment.asset}{amount_rubles_rounded:.2f}₽)"
),
payment_method=PaymentMethod.CRYPTOBOT,
external_id=invoice_id,
@@ -208,8 +285,6 @@ class CryptoBotPaymentMixin:
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = getattr(user, "promo_group", None)
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = (
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
@@ -238,55 +313,41 @@ class CryptoBotPaymentMixin:
await db.refresh(user)
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import (
AdminNotificationService,
)
admin_notification: Optional[_AdminNotificationContext] = None
user_notification: Optional[_UserNotificationPayload] = None
saved_cart_notification: Optional[_SavedCartNotificationPayload] = None
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о пополнении CryptoBot: %s",
error,
)
bot_instance = getattr(self, "bot", None)
if bot_instance:
admin_notification = _AdminNotificationContext(
user_id=user.id,
transaction_id=transaction.id,
old_balance=old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
)
if getattr(self, "bot", None):
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"🪙 Платеж: {updated_payment.amount} {updated_payment.asset}\n"
f"💱 Курс: 1 USD = {conversion_rate:.2f}\n"
f"🆔 Транзакция: {invoice_id[:8]}...\n\n"
"Баланс пополнен автоматически!"
),
message_text = (
"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"🪙 Платеж: {updated_payment.amount} {updated_payment.asset}\n"
f"💱 Курс: 1 USD = {conversion_rate:.2f}\n"
f"🆔 Транзакция: {invoice_id[:8]}...\n\n"
"Баланс пополнен автоматически!"
)
user_notification = _UserNotificationPayload(
telegram_id=user.telegram_id,
text=message_text,
parse_mode="HTML",
reply_markup=keyboard,
)
logger.info(
"✅ Отправлено уведомление пользователю %s о пополнении на %s₽ (%s)",
user.telegram_id,
f"{amount_rubles:.2f}",
updated_payment.asset,
amount_rubles=amount_rubles_rounded,
asset=updated_payment.asset,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о пополнении CryptoBot: %s",
"Ошибка подготовки уведомления о пополнении CryptoBot: %s",
error,
)
@@ -294,22 +355,39 @@ class CryptoBotPaymentMixin:
try:
from app.services.user_cart_service import user_cart_service
from aiogram import types
has_saved_cart = await user_cart_service.has_user_cart(user.id)
if has_saved_cart and getattr(self, "bot", None):
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
auto_purchase_success = False
if has_saved_cart:
try:
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
db,
user,
bot=bot_instance,
)
except Exception as auto_error:
logger.error(
"Ошибка автоматической покупки подписки для пользователя %s: %s",
user.id,
auto_error,
exc_info=True,
)
if auto_purchase_success:
has_saved_cart = False
if has_saved_cart and bot_instance:
from app.localization.texts import get_texts
texts = get_texts(user.language)
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
total_amount=settings.format_price(payment.amount_kopeks)
)
# Создаем клавиатуру с кнопками
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout"
callback_data="return_to_saved_cart"
)],
[types.InlineKeyboardButton(
text="💰 Мой баланс",
@@ -320,15 +398,35 @@ class CryptoBotPaymentMixin:
callback_data="back_to_menu"
)]
])
await self.bot.send_message(
chat_id=user.telegram_id,
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
reply_markup=keyboard
saved_cart_notification = _SavedCartNotificationPayload(
telegram_id=user.telegram_id,
text=(
f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}"
),
reply_markup=keyboard,
user_id=user.id,
)
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
except Exception as e:
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
except Exception as error:
logger.error(
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
user.id,
error,
exc_info=True,
)
if admin_notification:
await self._deliver_admin_topup_notification(admin_notification)
if user_notification and bot_instance:
await self._deliver_user_topup_notification(user_notification)
if saved_cart_notification and bot_instance:
await self._deliver_saved_cart_reminder(saved_cart_notification)
return True
@@ -337,3 +435,348 @@ class CryptoBotPaymentMixin:
"Ошибка обработки CryptoBot webhook: %s", error, exc_info=True
)
return False
async def _process_subscription_renewal_payment(
self,
db: AsyncSession,
payment: Any,
descriptor: RenewalPaymentDescriptor,
cryptobot_crud: Any,
) -> bool:
try:
payment_service_module = import_module("app.services.payment_service")
user = await payment_service_module.get_user_by_id(db, payment.user_id)
except Exception as error:
logger.error(
"Не удалось загрузить пользователя %s для продления через CryptoBot: %s",
getattr(payment, "user_id", None),
error,
)
return False
if not user:
logger.error(
"Пользователь %s не найден при обработке продления через CryptoBot",
getattr(payment, "user_id", None),
)
return False
subscription = getattr(user, "subscription", None)
if not subscription or subscription.id != descriptor.subscription_id:
logger.warning(
"Продление через CryptoBot отклонено: подписка %s не совпадает с ожидаемой %s",
getattr(subscription, "id", None),
descriptor.subscription_id,
)
return False
pricing_model: Optional[SubscriptionRenewalPricing] = None
if descriptor.pricing_snapshot:
try:
pricing_model = SubscriptionRenewalPricing.from_payload(
descriptor.pricing_snapshot
)
except Exception as error:
logger.warning(
"Не удалось восстановить сохраненную стоимость продления из payload %s: %s",
payment.invoice_id,
error,
)
if pricing_model is None:
try:
pricing_model = await renewal_service.calculate_pricing(
db,
user,
subscription,
descriptor.period_days,
)
except Exception as error:
logger.error(
"Не удалось пересчитать стоимость продления для CryptoBot %s: %s",
payment.invoice_id,
error,
)
return False
if pricing_model.final_total != descriptor.total_amount_kopeks:
logger.warning(
"Сумма продления через CryptoBot %s изменилась (ожидалось %s, получено %s)",
payment.invoice_id,
descriptor.total_amount_kopeks,
pricing_model.final_total,
)
pricing_model.final_total = descriptor.total_amount_kopeks
pricing_model.per_month = (
descriptor.total_amount_kopeks // pricing_model.months
if pricing_model.months
else descriptor.total_amount_kopeks
)
pricing_model.period_days = descriptor.period_days
pricing_model.period_id = build_renewal_period_id(descriptor.period_days)
required_balance = max(
0,
min(
pricing_model.final_total,
descriptor.balance_component_kopeks,
),
)
current_balance = getattr(user, "balance_kopeks", 0)
if current_balance < required_balance:
logger.warning(
"Недостаточно средств на балансе пользователя %s для завершения продления: нужно %s, доступно %s",
user.id,
required_balance,
current_balance,
)
return False
description = f"Продление подписки на {descriptor.period_days} дней"
try:
result = await renewal_service.finalize(
db,
user,
subscription,
pricing_model,
charge_balance_amount=required_balance,
description=description,
payment_method=PaymentMethod.CRYPTOBOT,
)
except SubscriptionRenewalChargeError as error:
logger.error(
"Списание баланса не выполнено при продлении через CryptoBot %s: %s",
payment.invoice_id,
error,
)
return False
except Exception as error:
logger.error(
"Ошибка завершения продления через CryptoBot %s: %s",
payment.invoice_id,
error,
exc_info=True,
)
return False
transaction = result.transaction
if transaction:
try:
await cryptobot_crud.link_cryptobot_payment_to_transaction(
db,
payment.invoice_id,
transaction.id,
)
except Exception as error:
logger.warning(
"Не удалось связать платеж CryptoBot %s с транзакцией %s: %s",
payment.invoice_id,
transaction.id,
error,
)
external_amount_label = settings.format_price(descriptor.missing_amount_kopeks)
balance_amount_label = settings.format_price(required_balance)
logger.info(
"Подписка %s продлена через CryptoBot invoice %s (внешний платеж %s, списано с баланса %s)",
subscription.id,
payment.invoice_id,
external_amount_label,
balance_amount_label,
)
return True
async def _deliver_admin_topup_notification(
self, context: _AdminNotificationContext
) -> None:
bot_instance = getattr(self, "bot", None)
if not bot_instance:
return
try:
from app.services.admin_notification_service import AdminNotificationService
from app.database.crud.user import get_user_by_id
from app.database.crud.transaction import get_transaction_by_id
except Exception as error:
logger.error(
"Не удалось импортировать зависимости для админ-уведомления CryptoBot: %s",
error,
exc_info=True,
)
return
async with AsyncSessionLocal() as session:
try:
user = await get_user_by_id(session, context.user_id)
transaction = await get_transaction_by_id(session, context.transaction_id)
except Exception as error:
logger.error(
"Ошибка загрузки данных для админ-уведомления CryptoBot: %s",
error,
exc_info=True,
)
await session.rollback()
return
if not user or not transaction:
logger.warning(
"Пропущена отправка админ-уведомления CryptoBot: user=%s transaction=%s",
bool(user),
bool(transaction),
)
return
notification_service = AdminNotificationService(bot_instance)
try:
await notification_service.send_balance_topup_notification(
user,
transaction,
context.old_balance,
topup_status=context.topup_status,
referrer_info=context.referrer_info,
subscription=getattr(user, "subscription", None),
promo_group=getattr(user, "promo_group", None),
db=session,
)
except Exception as error:
logger.error(
"Ошибка отправки админ-уведомления о пополнении CryptoBot: %s",
error,
exc_info=True,
)
async def _deliver_user_topup_notification(
self, payload: _UserNotificationPayload
) -> None:
bot_instance = getattr(self, "bot", None)
if not bot_instance:
return
try:
await bot_instance.send_message(
payload.telegram_id,
payload.text,
parse_mode=payload.parse_mode,
reply_markup=payload.reply_markup,
)
logger.info(
"✅ Отправлено уведомление пользователю %s о пополнении на %s₽ (%s)",
payload.telegram_id,
f"{payload.amount_rubles:.2f}",
payload.asset,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о пополнении CryptoBot: %s",
error,
)
async def _deliver_saved_cart_reminder(
self, payload: _SavedCartNotificationPayload
) -> None:
bot_instance = getattr(self, "bot", None)
if not bot_instance:
return
try:
await bot_instance.send_message(
chat_id=payload.telegram_id,
text=payload.text,
reply_markup=payload.reply_markup,
)
logger.info(
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
payload.user_id,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о сохраненной корзине для пользователя %s: %s",
payload.user_id,
error,
exc_info=True,
)
async def get_cryptobot_payment_status(
self,
db: AsyncSession,
local_payment_id: int,
) -> Optional[Dict[str, Any]]:
"""Запрашивает актуальный статус CryptoBot invoice и синхронизирует его."""
cryptobot_crud = import_module("app.database.crud.cryptobot")
payment = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id)
if not payment:
logger.warning("CryptoBot платеж %s не найден", local_payment_id)
return None
if not self.cryptobot_service:
logger.warning("CryptoBot сервис не инициализирован для ручной проверки")
return {"payment": payment}
invoice_id = payment.invoice_id
try:
invoices = await self.cryptobot_service.get_invoices(
invoice_ids=[invoice_id]
)
except Exception as error: # pragma: no cover - network errors
logger.error(
"Ошибка запроса статуса CryptoBot invoice %s: %s",
invoice_id,
error,
)
return {"payment": payment}
remote_invoice: Optional[Dict[str, Any]] = None
if invoices:
for item in invoices:
if str(item.get("invoice_id")) == str(invoice_id):
remote_invoice = item
break
if not remote_invoice:
logger.info(
"CryptoBot invoice %s не найден через API при ручной проверке",
invoice_id,
)
refreshed = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id)
return {"payment": refreshed or payment}
status = (remote_invoice.get("status") or "").lower()
paid_at_str = remote_invoice.get("paid_at")
paid_at = None
if paid_at_str:
try:
paid_at = datetime.fromisoformat(paid_at_str.replace("Z", "+00:00")).replace(
tzinfo=None
)
except Exception: # pragma: no cover - defensive parsing
paid_at = None
if status == "paid":
webhook_payload = {
"update_type": "invoice_paid",
"payload": {
"invoice_id": remote_invoice.get("invoice_id") or invoice_id,
"amount": remote_invoice.get("amount") or payment.amount,
"asset": remote_invoice.get("asset") or payment.asset,
"paid_at": paid_at_str,
"payload": remote_invoice.get("payload") or payment.payload,
},
}
await self.process_cryptobot_webhook(db, webhook_payload)
else:
if status and status != (payment.status or "").lower():
await cryptobot_crud.update_cryptobot_payment_status(
db,
invoice_id,
status,
paid_at,
)
refreshed = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id)
return {"payment": refreshed or payment}
+121 -14
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import logging
import secrets
import time
from datetime import datetime
from datetime import datetime, timezone, timedelta
from importlib import import_module
from typing import Any, Dict, Optional
@@ -236,6 +236,9 @@ class HeleketPaymentMixin:
except (ValueError, TypeError):
paid_at = None
if paid_at and paid_at.tzinfo is not None:
paid_at = paid_at.astimezone(timezone.utc).replace(tzinfo=None)
updated_payment = await heleket_crud.update_heleket_payment(
db,
payment.uuid,
@@ -252,6 +255,42 @@ class HeleketPaymentMixin:
if updated_payment is None:
return None
metadata = dict(getattr(updated_payment, "metadata_json", {}) or {})
invoice_message = metadata.get("invoice_message") or {}
invoice_message_removed = False
if getattr(self, "bot", None) and invoice_message:
chat_id = invoice_message.get("chat_id")
message_id = invoice_message.get("message_id")
if chat_id and message_id:
try:
await self.bot.delete_message(chat_id, message_id)
except Exception as delete_error: # pragma: no cover - depends on rights
logger.warning(
"Не удалось удалить счёт Heleket %s: %s",
message_id,
delete_error,
)
else:
metadata.pop("invoice_message", None)
invoice_message_removed = True
if invoice_message_removed:
try:
from app.database.crud import heleket as heleket_crud
await heleket_crud.update_heleket_payment(
db,
updated_payment.uuid,
metadata=metadata,
)
updated_payment.metadata_json = metadata
except Exception as error: # pragma: no cover - diagnostics
logger.warning(
"Не удалось обновить метаданные Heleket после удаления счёта: %s",
error,
)
if updated_payment.transaction_id:
logger.info(
"Heleket платеж %s уже связан с транзакцией %s",
@@ -332,7 +371,7 @@ class HeleketPaymentMixin:
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
referrer_info = format_referrer_info(user)
subscription = getattr(user, "subscription", None)
promo_group = getattr(user, "promo_group", None)
promo_group = user.get_primary_promo_group()
try:
from app.services.admin_notification_service import AdminNotificationService
@@ -414,27 +453,40 @@ class HeleketPaymentMixin:
logger.error("Heleket платеж с id=%s не найден", local_payment_id)
return None
payload: Optional[Dict[str, Any]] = None
try:
response = await self.heleket_service.get_payment_info( # type: ignore[union-attr]
uuid=payment.uuid,
order_id=payment.order_id,
)
except Exception as error: # pragma: no cover - defensive
logger.exception("Ошибка получения статуса Heleket платежа %s: %s", payment.uuid, error)
return payment
if not response:
logger.warning(
"Heleket API вернул пустой ответ при проверке платежа %s", payment.uuid
logger.exception(
"Ошибка получения статуса Heleket платежа %s: %s",
payment.uuid,
error,
)
return payment
else:
if response:
result = response.get("result") if isinstance(response, dict) else None
if isinstance(result, dict):
payload = dict(result)
else:
logger.error(
"Некорректный ответ Heleket API при проверке платежа %s: %s",
payment.uuid,
response,
)
result = response.get("result") if isinstance(response, dict) else None
if not isinstance(result, dict):
logger.error("Некорректный ответ Heleket API при проверке платежа %s: %s", payment.uuid, response)
return payment
if payload is None:
fallback = await self._lookup_heleket_payment_history(payment)
if not fallback:
logger.warning(
"Heleket API не вернул информацию по платежу %s",
payment.uuid,
)
return payment
payload = dict(fallback)
payload: Dict[str, Any] = dict(result)
payload.setdefault("uuid", payment.uuid)
payload.setdefault("order_id", payment.order_id)
@@ -445,3 +497,58 @@ class HeleketPaymentMixin:
)
return updated_payment or payment
async def _lookup_heleket_payment_history(
self,
payment: "HeleketPayment",
) -> Optional[Dict[str, Any]]:
service = getattr(self, "heleket_service", None)
if not service:
return None
created_at = getattr(payment, "created_at", None)
date_from_str: Optional[str] = None
date_to_str: Optional[str] = None
if isinstance(created_at, datetime):
start = created_at - timedelta(days=2)
end = created_at + timedelta(days=2)
date_from_str = start.strftime("%Y-%m-%d %H:%M:%S")
date_to_str = end.strftime("%Y-%m-%d %H:%M:%S")
cursor: Optional[str] = None
for _ in range(10):
response = await service.list_payments(
date_from=date_from_str,
date_to=date_to_str,
cursor=cursor,
)
if not response or not isinstance(response, dict):
return None
result = response.get("result")
if not isinstance(result, dict):
return None
items = result.get("items")
if isinstance(items, list):
for item in items:
if not isinstance(item, dict):
continue
uuid = str(item.get("uuid") or "").strip()
order_id = str(item.get("order_id") or "").strip()
if uuid and uuid == str(payment.uuid):
return item
if order_id and order_id == str(payment.order_id):
return item
paginate = result.get("paginate")
cursor = None
if isinstance(paginate, dict):
next_cursor = paginate.get("nextCursor")
if isinstance(next_cursor, str) and next_cursor:
cursor = next_cursor
if not cursor:
break
return None
+135 -34
View File
@@ -11,6 +11,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.subscription_auto_purchase_service import (
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
@@ -179,7 +182,43 @@ class MulenPayPaymentMixin:
)
return False
metadata = dict(getattr(payment, "metadata_json", {}) or {})
invoice_message = metadata.get("invoice_message") or {}
invoice_message_removed = False
if getattr(self, "bot", None):
chat_id = invoice_message.get("chat_id")
message_id = invoice_message.get("message_id")
if chat_id and message_id:
try:
await self.bot.delete_message(chat_id, message_id)
except Exception as delete_error: # pragma: no cover - depends on bot rights
logger.warning(
"Не удалось удалить %s счёт %s: %s",
display_name,
message_id,
delete_error,
)
else:
metadata.pop("invoice_message", None)
invoice_message_removed = True
if payment.is_paid:
if invoice_message_removed:
try:
await payment_module.update_mulenpay_payment_metadata(
db,
payment=payment,
metadata=metadata,
)
except Exception as error: # pragma: no cover - diagnostics
logger.warning(
"Не удалось обновить метаданные %s после удаления счёта: %s",
display_name,
error,
)
logger.info(
"%s платеж %s уже обработан, игнорируем повторный callback",
display_name,
@@ -194,6 +233,7 @@ class MulenPayPaymentMixin:
status="success",
callback_payload=callback_data,
mulen_payment_id=mulen_payment_id_int,
metadata=metadata,
)
if payment.transaction_id:
@@ -244,6 +284,7 @@ class MulenPayPaymentMixin:
user,
payment.amount_kopeks,
f"Пополнение {display_name}: {payment.amount_kopeks // 100}",
create_transaction=False,
)
try:
@@ -266,9 +307,18 @@ class MulenPayPaymentMixin:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
# После коммита отношения пользователя могли быть сброшены, поэтому
# повторно загружаем пользователя с предзагрузкой зависимостей
user = await payment_module.get_user_by_id(db, user.id)
if not user:
logger.error(
"Пользователь %s не найден при повторной загрузке после %s",
payment.user_id,
display_name,
)
return False
promo_group = getattr(user, "promo_group", None)
promo_group = user.get_primary_promo_group()
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = (
@@ -325,9 +375,29 @@ class MulenPayPaymentMixin:
try:
from app.services.user_cart_service import user_cart_service
from aiogram import types
has_saved_cart = await user_cart_service.has_user_cart(user.id)
auto_purchase_success = False
if has_saved_cart:
try:
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
db,
user,
bot=getattr(self, "bot", None),
)
except Exception as auto_error:
logger.error(
"Ошибка автоматической покупки подписки для пользователя %s: %s",
user.id,
auto_error,
exc_info=True,
)
if auto_purchase_success:
has_saved_cart = False
if has_saved_cart and getattr(self, "bot", None):
# Если у пользователя есть сохраненная корзина,
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
from app.localization.texts import get_texts
@@ -342,7 +412,7 @@ class MulenPayPaymentMixin:
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout"
callback_data="return_to_saved_cart"
)],
[types.InlineKeyboardButton(
text="💰 Мой баланс",
@@ -356,10 +426,17 @@ class MulenPayPaymentMixin:
await self.bot.send_message(
chat_id=user.telegram_id,
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}",
reply_markup=keyboard
)
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
logger.info(
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
user.id,
)
except Exception as e:
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
@@ -440,35 +517,59 @@ class MulenPayPaymentMixin:
response = await self.mulenpay_service.get_payment(
payment.mulen_payment_id
)
if response and response.get("success"):
remote_data = response.get("payment")
if isinstance(remote_data, dict):
remote_status_code = remote_data.get("status")
mapped_status = self._map_mulenpay_status(remote_status_code)
if response:
if isinstance(response, dict) and response.get("success"):
remote_data = response.get("payment")
elif isinstance(response, dict) and "status" in response and "id" in response:
remote_data = response
if not remote_data and getattr(self, "mulenpay_service", None):
list_response = await self.mulenpay_service.list_payments(
limit=100,
uuid=payment.uuid,
)
items = []
if isinstance(list_response, dict):
items = list_response.get("items") or []
if items:
for candidate in items:
if not isinstance(candidate, dict):
continue
candidate_id = candidate.get("id")
candidate_uuid = candidate.get("uuid")
if (
(candidate_id is not None and candidate_id == payment.mulen_payment_id)
or (candidate_uuid and candidate_uuid == payment.uuid)
):
remote_data = candidate
break
if mapped_status == "success" and not payment.is_paid:
await self.process_mulenpay_callback(
db,
{
"uuid": payment.uuid,
"payment_status": "success",
"id": remote_data.get("id"),
"amount": remote_data.get("amount"),
},
)
payment = await payment_module.get_mulenpay_payment_by_local_id(
db, local_payment_id
)
elif mapped_status and mapped_status != payment.status:
await payment_module.update_mulenpay_payment_status(
db,
payment=payment,
status=mapped_status,
mulen_payment_id=remote_data.get("id"),
)
payment = await payment_module.get_mulenpay_payment_by_local_id(
db, local_payment_id
)
if isinstance(remote_data, dict):
remote_status_code = remote_data.get("status")
mapped_status = self._map_mulenpay_status(remote_status_code)
if mapped_status == "success" and not payment.is_paid:
await self.process_mulenpay_callback(
db,
{
"uuid": payment.uuid,
"payment_status": "success",
"id": remote_data.get("id"),
"amount": remote_data.get("amount"),
},
)
payment = await payment_module.get_mulenpay_payment_by_local_id(
db, local_payment_id
)
elif mapped_status and mapped_status != payment.status:
await payment_module.update_mulenpay_payment_status(
db,
payment=payment,
status=mapped_status,
mulen_payment_id=remote_data.get("id"),
)
payment = await payment_module.get_mulenpay_payment_by_local_id(
db, local_payment_id
)
return {
"payment": payment,
+439 -89
View File
@@ -6,20 +6,23 @@ import logging
from datetime import datetime
from importlib import import_module
import uuid
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.pal24_service import Pal24APIError
from app.services.subscription_auto_purchase_service import (
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
class Pal24PaymentMixin:
"""Mixin с созданием счетов Pal24, обработкой postback и запросом статуса."""
"""Mixin с созданием счетов Pal24, обработкой callback и запросом статуса."""
async def create_pal24_payment(
self,
@@ -194,12 +197,12 @@ class Pal24PaymentMixin:
"payment_url": primary_link,
}
async def process_pal24_postback(
async def process_pal24_callback(
self,
db: AsyncSession,
postback: Dict[str, Any],
callback: Dict[str, Any],
) -> bool:
"""Обрабатывает postback от Pal24 и начисляет баланс при успехе."""
"""Обрабатывает callback от Pal24 и начисляет баланс при успехе."""
try:
payment_module = import_module("app.services.payment_service")
@@ -210,26 +213,26 @@ class Pal24PaymentMixin:
return None
payment_id = _first_non_empty(
postback.get("id"),
postback.get("TrsId"),
postback.get("TrsID"),
callback.get("id"),
callback.get("TrsId"),
callback.get("TrsID"),
)
bill_id = _first_non_empty(
postback.get("bill_id"),
postback.get("billId"),
postback.get("BillId"),
postback.get("BillID"),
callback.get("bill_id"),
callback.get("billId"),
callback.get("BillId"),
callback.get("BillID"),
)
order_id = _first_non_empty(
postback.get("order_id"),
postback.get("orderId"),
postback.get("InvId"),
postback.get("InvID"),
callback.get("order_id"),
callback.get("orderId"),
callback.get("InvId"),
callback.get("InvID"),
)
status = (postback.get("status") or postback.get("Status") or "").upper()
status = (callback.get("status") or callback.get("Status") or "").upper()
if not bill_id and not order_id:
logger.error("Pal24 postback без идентификаторов: %s", postback)
logger.error("Pal24 callback без идентификаторов: %s", callback)
return False
payment = None
@@ -247,57 +250,65 @@ class Pal24PaymentMixin:
return True
if status in {"PAID", "SUCCESS", "OVERPAID"}:
metadata = getattr(payment, "metadata_json", {}) or {}
if not isinstance(metadata, dict):
metadata = {}
payment = await payment_module.update_pal24_payment_status(
db,
payment,
status=status,
is_paid=True,
paid_at=datetime.utcnow(),
postback_payload=postback,
callback_payload=callback,
payment_id=payment_id,
payment_status=postback.get("Status") or status,
payment_status=callback.get("Status") or status,
payment_method=(
postback.get("payment_method")
or postback.get("PaymentMethod")
or (payment.metadata_json or {}).get("selected_method")
callback.get("payment_method")
or callback.get("PaymentMethod")
or metadata.get("selected_method")
or getattr(payment, "payment_method", None)
),
balance_amount=postback.get("BalanceAmount")
or postback.get("balance_amount"),
balance_currency=postback.get("BalanceCurrency")
or postback.get("balance_currency"),
payer_account=postback.get("AccountNumber")
or postback.get("account")
or postback.get("Account"),
balance_amount=callback.get("BalanceAmount")
or callback.get("balance_amount"),
balance_currency=callback.get("BalanceCurrency")
or callback.get("balance_currency"),
payer_account=callback.get("AccountNumber")
or callback.get("account")
or callback.get("Account"),
)
return await self._finalize_pal24_payment(
db,
payment,
payment_id=payment_id,
trigger="postback",
trigger="callback",
)
metadata = getattr(payment, "metadata_json", {}) or {}
if not isinstance(metadata, dict):
metadata = {}
await payment_module.update_pal24_payment_status(
db,
payment,
status=status or "UNKNOWN",
is_paid=False,
postback_payload=postback,
callback_payload=callback,
payment_id=payment_id,
payment_status=postback.get("Status") or status,
payment_status=callback.get("Status") or status,
payment_method=(
postback.get("payment_method")
or postback.get("PaymentMethod")
callback.get("payment_method")
or callback.get("PaymentMethod")
or getattr(payment, "payment_method", None)
),
balance_amount=postback.get("BalanceAmount")
or postback.get("balance_amount"),
balance_currency=postback.get("BalanceCurrency")
or postback.get("balance_currency"),
payer_account=postback.get("AccountNumber")
or postback.get("account")
or postback.get("Account"),
balance_amount=callback.get("BalanceAmount")
or callback.get("balance_amount"),
balance_currency=callback.get("BalanceCurrency")
or callback.get("balance_currency"),
payer_account=callback.get("AccountNumber")
or callback.get("account")
or callback.get("Account"),
)
logger.info(
"Обновили Pal24 платеж %s до статуса %s",
@@ -307,7 +318,7 @@ class Pal24PaymentMixin:
return True
except Exception as error:
logger.error("Ошибка обработки Pal24 postback: %s", error, exc_info=True)
logger.error("Ошибка обработки Pal24 callback: %s", error, exc_info=True)
return False
async def _finalize_pal24_payment(
@@ -322,6 +333,41 @@ class Pal24PaymentMixin:
payment_module = import_module("app.services.payment_service")
metadata = dict(getattr(payment, "metadata_json", {}) or {})
invoice_message = metadata.get("invoice_message") or {}
invoice_message_removed = False
if getattr(self, "bot", None) and invoice_message:
chat_id = invoice_message.get("chat_id")
message_id = invoice_message.get("message_id")
if chat_id and message_id:
try:
await self.bot.delete_message(chat_id, message_id)
except Exception as delete_error: # pragma: no cover - depends on rights
logger.warning(
"Не удалось удалить счёт PayPalych %s: %s",
message_id,
delete_error,
)
else:
metadata.pop("invoice_message", None)
invoice_message_removed = True
if invoice_message_removed:
try:
await payment_module.update_pal24_payment_status(
db,
payment,
status=payment.status,
metadata=metadata,
)
payment.metadata_json = metadata
except Exception as error: # pragma: no cover - diagnostics
logger.warning(
"Не удалось обновить метаданные PayPalych после удаления счёта: %s",
error,
)
if payment.transaction_id:
logger.info(
"Pal24 платеж %s уже привязан к транзакции (trigger=%s)",
@@ -359,7 +405,7 @@ class Pal24PaymentMixin:
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = getattr(user, "promo_group", None)
promo_group = user.get_primary_promo_group()
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
@@ -434,6 +480,25 @@ class Pal24PaymentMixin:
from aiogram import types
has_saved_cart = await user_cart_service.has_user_cart(user.id)
auto_purchase_success = False
if has_saved_cart:
try:
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
db,
user,
bot=getattr(self, "bot", None),
)
except Exception as auto_error:
logger.error(
"Ошибка автоматической покупки подписки для пользователя %s: %s",
user.id,
auto_error,
exc_info=True,
)
if auto_purchase_success:
has_saved_cart = False
if has_saved_cart and getattr(self, "bot", None):
from app.localization.texts import get_texts
@@ -451,7 +516,7 @@ class Pal24PaymentMixin:
"BALANCE_TOPUP_CART_BUTTON",
"🛒 Продолжить оформление",
),
callback_data="subscription_resume_checkout",
callback_data="return_to_saved_cart",
)
],
[
@@ -467,7 +532,11 @@ class Pal24PaymentMixin:
chat_id=user.telegram_id,
text=(
"✅ Баланс пополнен на "
f"{settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}"
f"{settings.format_price(payment.amount_kopeks)}!\n\n"
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}"
),
reply_markup=keyboard,
)
@@ -475,6 +544,11 @@ class Pal24PaymentMixin:
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
user.id,
)
else:
logger.info(
"У пользователя %s нет сохраненной корзины или автопокупка выполнена",
user.id,
)
except Exception as error:
logger.error(
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
@@ -507,53 +581,135 @@ class Pal24PaymentMixin:
return None
remote_status: Optional[str] = None
remote_data: Optional[Dict[str, Any]] = None
remote_payloads: Dict[str, Any] = {}
payment_info_candidates: List[Dict[str, Optional[str]]] = []
service = getattr(self, "pal24_service", None)
if service and payment.bill_id:
bill_id_str = str(payment.bill_id)
try:
response = await service.get_bill_status(payment.bill_id)
remote_data = response
remote_status = response.get("status") or response.get("bill", {}).get("status")
payment_info = self._extract_remote_payment_info(response)
if remote_status:
normalized_remote = str(remote_status).upper()
update_kwargs: Dict[str, Any] = {
"status": normalized_remote,
"payment_status": payment_info.get("status") or remote_status,
}
if payment_info.get("id"):
update_kwargs["payment_id"] = payment_info["id"]
if payment_info.get("method"):
update_kwargs["payment_method"] = payment_info["method"]
if payment_info.get("balance_amount"):
update_kwargs["balance_amount"] = payment_info["balance_amount"]
if payment_info.get("balance_currency"):
update_kwargs["balance_currency"] = payment_info["balance_currency"]
if payment_info.get("account"):
update_kwargs["payer_account"] = payment_info["account"]
if normalized_remote in getattr(service, "BILL_SUCCESS_STATES", {"SUCCESS"}):
update_kwargs["is_paid"] = True
if not payment.paid_at:
update_kwargs["paid_at"] = datetime.utcnow()
elif normalized_remote in getattr(service, "BILL_FAILED_STATES", {"FAIL"}):
update_kwargs["is_paid"] = False
elif normalized_remote in getattr(service, "BILL_PENDING_STATES", {"NEW", "PROCESS"}):
update_kwargs.setdefault("is_paid", False)
payment = await payment_module.update_pal24_payment_status(
db,
payment,
**update_kwargs,
)
response = await service.get_bill_status(bill_id_str)
except Pal24APIError as error:
logger.error(
"Ошибка Pal24 API при получении статуса: %s", error
)
logger.error("Ошибка Pal24 API при получении статуса счёта: %s", error)
else:
if response:
remote_payloads["bill_status"] = response
status_value = response.get("status") or (response.get("bill") or {}).get("status")
if status_value:
remote_status = str(status_value).upper()
extracted = self._extract_remote_payment_info(response)
if extracted:
payment_info_candidates.append(extracted)
if payment.payment_id:
payment_id_str = str(payment.payment_id)
try:
payment_response = await service.get_payment_status(payment_id_str)
except Pal24APIError as error:
logger.error("Ошибка Pal24 API при получении статуса платежа: %s", error)
else:
if payment_response:
remote_payloads["payment_status"] = payment_response
extracted = self._extract_remote_payment_info(payment_response)
if extracted:
payment_info_candidates.append(extracted)
try:
payments_response = await service.get_bill_payments(bill_id_str)
except Pal24APIError as error:
logger.error("Ошибка Pal24 API при получении списка платежей: %s", error)
else:
if payments_response:
remote_payloads["bill_payments"] = payments_response
for candidate in self._collect_payment_candidates(payments_response):
extracted = self._extract_remote_payment_info(candidate)
if extracted:
payment_info_candidates.append(extracted)
payment_info = self._select_best_payment_info(payment, payment_info_candidates)
if payment_info:
remote_payloads.setdefault("selected_payment", payment_info)
bill_success = getattr(service, "BILL_SUCCESS_STATES", {"SUCCESS"}) if service else {"SUCCESS"}
bill_failed = getattr(service, "BILL_FAILED_STATES", {"FAIL"}) if service else {"FAIL"}
bill_pending = getattr(service, "BILL_PENDING_STATES", {"NEW", "PROCESS"}) if service else {"NEW", "PROCESS"}
update_status = payment.status or "NEW"
update_kwargs: Dict[str, Any] = {}
is_paid_update: Optional[bool] = None
if remote_status:
update_status = remote_status
if remote_status in bill_success:
is_paid_update = True
elif remote_status in bill_failed:
is_paid_update = False
elif remote_status in bill_pending and is_paid_update is None:
is_paid_update = False
payment_status_code: Optional[str] = None
if payment_info:
payment_status_code = (payment_info.get("status") or "").upper() or None
if payment_status_code:
existing_status = (getattr(payment, "payment_status", "") or "").upper()
if payment_status_code != existing_status:
update_kwargs["payment_status"] = payment_status_code
payment_id_value = payment_info.get("id")
if payment_id_value and payment_id_value != (payment.payment_id or ""):
update_kwargs["payment_id"] = payment_id_value
method_value = payment_info.get("method")
if method_value:
normalized_method = self._normalize_payment_method(method_value)
if normalized_method != (payment.payment_method or ""):
update_kwargs["payment_method"] = normalized_method
balance_amount = payment_info.get("balance_amount")
if balance_amount and balance_amount != (payment.balance_amount or ""):
update_kwargs["balance_amount"] = balance_amount
balance_currency = payment_info.get("balance_currency")
if balance_currency and balance_currency != (payment.balance_currency or ""):
update_kwargs["balance_currency"] = balance_currency
payer_account = payment_info.get("account")
if payer_account and payer_account != (payment.payer_account or ""):
update_kwargs["payer_account"] = payer_account
if payment_status_code:
success_states = {"SUCCESS", "OVERPAID"}
failed_states = {"FAIL"}
pending_states = {"NEW", "PROCESS", "UNDERPAID"}
if payment_status_code in success_states:
is_paid_update = True
elif payment_status_code in failed_states and is_paid_update is not True:
is_paid_update = False
elif payment_status_code in pending_states and is_paid_update is None:
is_paid_update = False
if not remote_status and payment_status_code:
update_status = payment_status_code
if is_paid_update is not None and is_paid_update != bool(payment.is_paid):
update_kwargs["is_paid"] = is_paid_update
if is_paid_update and not payment.paid_at:
update_kwargs.setdefault("paid_at", datetime.utcnow())
current_status = payment.status or ""
effective_status = update_status or current_status or "NEW"
needs_update = bool(update_kwargs) or effective_status != current_status
if needs_update:
payment = await payment_module.update_pal24_payment_status(
db,
payment,
status=effective_status,
**update_kwargs,
)
remote_status_for_return = remote_status or payment_status_code
remote_data = remote_payloads or None
if payment.is_paid and not payment.transaction_id:
try:
@@ -572,12 +728,34 @@ class Pal24PaymentMixin:
exc_info=True,
)
links_map, selected_method = self._build_links_map(payment, remote_payloads)
primary_url = (
links_map.get(selected_method)
or links_map.get("sbp")
or links_map.get("page")
or links_map.get("card")
)
secondary_url = (
links_map.get("page")
or links_map.get("card")
or links_map.get("sbp")
)
return {
"payment": payment,
"status": payment.status,
"is_paid": payment.is_paid,
"remote_status": remote_status,
"remote_status": remote_status_for_return,
"remote_data": remote_data,
"links": links_map or None,
"primary_url": primary_url,
"secondary_url": secondary_url,
"sbp_url": links_map.get("sbp"),
"card_url": links_map.get("card"),
"link_page_url": links_map.get("page")
or getattr(payment, "link_page_url", None),
"link_url": getattr(payment, "link_url", None),
"selected_method": selected_method,
}
except Exception as error:
@@ -621,11 +799,26 @@ class Pal24PaymentMixin:
or candidate.get("payer_account")
or candidate.get("AccountNumber")
),
"bill_id": _stringify(
candidate.get("bill_id")
or candidate.get("BillId")
or candidate.get("billId")
),
}
if not isinstance(remote_data, dict):
return {}
lower_keys = {str(key).lower() for key in remote_data.keys()}
has_status = any(key in lower_keys for key in ("status", "payment_status"))
has_identifier = any(
key in lower_keys
for key in ("payment_id", "from_card", "account_amount", "id")
) or "bill_id" in lower_keys
if has_status and has_identifier and "bill" not in lower_keys:
return _normalize(remote_data)
search_spaces = [remote_data]
bill_section = remote_data.get("bill") or remote_data.get("Bill")
if isinstance(bill_section, dict):
@@ -641,8 +834,59 @@ class Pal24PaymentMixin:
if candidate:
return _normalize(candidate)
data_section = remote_data.get("data") or remote_data.get("Data")
candidate = _pick_candidate(data_section)
if candidate:
return _normalize(candidate)
return {}
@staticmethod
def _collect_payment_candidates(remote_data: Any) -> List[Dict[str, Any]]:
candidates: List[Dict[str, Any]] = []
def _visit(value: Any) -> None:
if isinstance(value, dict):
lower_keys = {str(key).lower() for key in value.keys()}
has_status = any(key in lower_keys for key in ("status", "payment_status"))
has_identifier = any(
key in lower_keys
for key in ("id", "payment_id", "bill_id", "from_card", "account_amount")
)
if has_status and has_identifier and value not in candidates:
candidates.append(value)
for nested in value.values():
_visit(nested)
elif isinstance(value, list):
for item in value:
_visit(item)
_visit(remote_data)
return candidates
@staticmethod
def _select_best_payment_info(
payment: Any,
candidates: List[Dict[str, Optional[str]]],
) -> Dict[str, Optional[str]]:
if not candidates:
return {}
payment_id = str(getattr(payment, "payment_id", "") or "")
bill_id = str(getattr(payment, "bill_id", "") or "")
for candidate in candidates:
candidate_id = str(candidate.get("id") or "")
if payment_id and candidate_id == payment_id:
return candidate
for candidate in candidates:
candidate_bill = str(candidate.get("bill_id") or "")
if bill_id and candidate_bill == bill_id:
return candidate
return candidates[0]
@staticmethod
def _normalize_payment_method(payment_method: Optional[str]) -> str:
mapping = {
@@ -662,6 +906,112 @@ class Pal24PaymentMixin:
normalized = payment_method.strip().lower()
return mapping.get(normalized, "sbp")
@staticmethod
def _pick_first(mapping: Dict[str, Any], *keys: str) -> Optional[str]:
for key in keys:
value = mapping.get(key)
if value:
return str(value)
return None
@classmethod
def _build_links_map(
cls,
payment: Any,
remote_payloads: Dict[str, Any],
) -> tuple[Dict[str, str], str]:
links: Dict[str, str] = {}
metadata = getattr(payment, "metadata_json", {}) or {}
if not isinstance(metadata, dict):
metadata = {}
if metadata:
links_meta = metadata.get("links")
if isinstance(links_meta, dict):
for key, value in links_meta.items():
if value:
links[key] = str(value)
selected_method = cls._normalize_payment_method(
(metadata.get("selected_method") if isinstance(metadata, dict) else None)
or getattr(payment, "payment_method", None)
)
def _visit(value: Any) -> List[Dict[str, Any]]:
stack: List[Any] = [value]
result: List[Dict[str, Any]] = []
while stack:
current = stack.pop()
if isinstance(current, dict):
result.append(current)
stack.extend(current.values())
elif isinstance(current, list):
stack.extend(current)
return result
payload_sources: List[Any] = []
if metadata:
payload_sources.append(metadata.get("raw_response"))
payload_sources.append(getattr(payment, "callback_payload", None))
payload_sources.extend(remote_payloads.values())
sbp_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",
)
page_keys = (
"link_page_url",
"linkPageUrl",
"page_url",
"pageUrl",
)
for source in payload_sources:
if not source:
continue
for candidate in _visit(source):
sbp_url = cls._pick_first(candidate, *sbp_keys)
if sbp_url and "sbp" not in links:
links["sbp"] = sbp_url
card_url = cls._pick_first(candidate, *card_keys)
if card_url and "card" not in links:
links["card"] = card_url
page_url = cls._pick_first(candidate, *page_keys)
if page_url and "page" not in links:
links["page"] = page_url
if getattr(payment, "link_page_url", None):
links.setdefault("page", str(payment.link_page_url))
if getattr(payment, "link_url", None):
link_url_value = str(payment.link_url)
if selected_method == "card":
links.setdefault("card", link_url_value)
else:
links.setdefault("sbp", link_url_value)
return links, selected_method
@staticmethod
def _map_api_payment_method(normalized_payment_method: str) -> Optional[str]:
"""Преобразует нормализованный метод оплаты в значение для Pal24 API."""
+552
View File
@@ -0,0 +1,552 @@
"""Mixin для интеграции платежей Platega."""
from __future__ import annotations
import logging
import uuid
from datetime import datetime
from importlib import import_module
from typing import Any, Dict, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.platega_service import PlategaService
from app.services.subscription_auto_purchase_service import (
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
class PlategaPaymentMixin:
"""Логика создания и обработки платежей Platega."""
_SUCCESS_STATUSES = {"CONFIRMED"}
_FAILED_STATUSES = {"FAILED", "CANCELED", "EXPIRED"}
_PENDING_STATUSES = {"PENDING", "INPROGRESS"}
async def create_platega_payment(
self,
db: AsyncSession,
*,
user_id: int,
amount_kopeks: int,
description: str,
language: str,
payment_method_code: int,
) -> Optional[Dict[str, Any]]:
service: Optional[PlategaService] = getattr(self, "platega_service", None)
if not service or not service.is_configured:
logger.error("Platega сервис не инициализирован")
return None
if amount_kopeks < settings.PLATEGA_MIN_AMOUNT_KOPEKS:
logger.warning(
"Сумма Platega меньше минимальной: %s < %s",
amount_kopeks,
settings.PLATEGA_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS:
logger.warning(
"Сумма Platega больше максимальной: %s > %s",
amount_kopeks,
settings.PLATEGA_MAX_AMOUNT_KOPEKS,
)
return None
correlation_id = uuid.uuid4().hex
payload_token = f"platega:{correlation_id}"
amount_value = amount_kopeks / 100
try:
response = await service.create_payment(
payment_method=payment_method_code,
amount=amount_value,
currency=settings.PLATEGA_CURRENCY,
description=description,
return_url=settings.get_platega_return_url(),
failed_url=settings.get_platega_failed_url(),
payload=payload_token,
)
except Exception as error: # pragma: no cover - network errors
logger.exception("Ошибка Platega при создании платежа: %s", error)
return None
if not response:
logger.error("Platega вернул пустой ответ при создании платежа")
return None
transaction_id = response.get("transactionId") or response.get("id")
redirect_url = response.get("redirect")
status = str(response.get("status") or "PENDING").upper()
expires_at = PlategaService.parse_expires_at(response.get("expiresIn"))
metadata = {
"raw_response": response,
"language": language,
"selected_method": payment_method_code,
}
payment_module = import_module("app.services.payment_service")
payment = await payment_module.create_platega_payment(
db,
user_id=user_id,
amount_kopeks=amount_kopeks,
currency=settings.PLATEGA_CURRENCY,
description=description,
status=status,
payment_method_code=payment_method_code,
correlation_id=correlation_id,
platega_transaction_id=transaction_id,
redirect_url=redirect_url,
return_url=settings.get_platega_return_url(),
failed_url=settings.get_platega_failed_url(),
payload=payload_token,
metadata=metadata,
expires_at=expires_at,
)
logger.info(
"Создан Platega платеж %s для пользователя %s (метод %s, сумма %s₽)",
transaction_id or payment.id,
user_id,
payment_method_code,
amount_value,
)
return {
"local_payment_id": payment.id,
"transaction_id": transaction_id,
"redirect_url": redirect_url,
"status": status,
"expires_at": expires_at,
"correlation_id": correlation_id,
}
async def process_platega_webhook(
self,
db: AsyncSession,
payload: Dict[str, Any],
) -> bool:
payment_module = import_module("app.services.payment_service")
transaction_id = str(payload.get("id") or "").strip()
payload_token = payload.get("payload")
payment = None
if transaction_id:
payment = await payment_module.get_platega_payment_by_transaction_id(
db, transaction_id
)
if not payment and payload_token:
payment = await payment_module.get_platega_payment_by_correlation_id(
db, str(payload_token).replace("platega:", "")
)
if not payment:
logger.warning("Platega webhook: платеж не найден (id=%s)", transaction_id)
return False
status_raw = str(payload.get("status") or "").upper()
if not status_raw:
logger.warning("Platega webhook без статуса для платежа %s", payment.id)
return False
update_kwargs = {
"status": status_raw,
"callback_payload": payload,
}
if transaction_id:
update_kwargs["platega_transaction_id"] = transaction_id
if status_raw in self._SUCCESS_STATUSES:
if payment.is_paid:
logger.info(
"Platega платеж %s уже помечен как оплачен", payment.correlation_id
)
await payment_module.update_platega_payment(
db,
payment=payment,
**update_kwargs,
is_paid=True,
)
return True
payment = await payment_module.update_platega_payment(
db,
payment=payment,
**update_kwargs,
)
await self._finalize_platega_payment(db, payment, payload)
return True
if status_raw in self._FAILED_STATUSES:
await payment_module.update_platega_payment(
db,
payment=payment,
**update_kwargs,
is_paid=False,
)
logger.info(
"Platega платеж %s перешёл в статус %s", payment.correlation_id, status_raw
)
return True
await payment_module.update_platega_payment(
db,
payment=payment,
**update_kwargs,
)
return True
async def get_platega_payment_status(
self,
db: AsyncSession,
local_payment_id: int,
) -> Optional[Dict[str, Any]]:
payment_module = import_module("app.services.payment_service")
payment = await payment_module.get_platega_payment_by_id(db, local_payment_id)
if not payment:
return None
service: Optional[PlategaService] = getattr(self, "platega_service", None)
remote_status: Optional[str] = None
remote_payload: Optional[Dict[str, Any]] = None
if service and payment.platega_transaction_id:
try:
remote_payload = await service.get_transaction(
payment.platega_transaction_id
)
except Exception as error: # pragma: no cover - network errors
logger.error(
"Ошибка Platega при получении транзакции %s: %s",
payment.platega_transaction_id,
error,
)
if remote_payload:
remote_status = str(remote_payload.get("status") or "").upper()
if remote_status and remote_status != payment.status:
await payment_module.update_platega_payment(
db,
payment=payment,
status=remote_status,
metadata={
**(getattr(payment, "metadata_json", {}) or {}),
"remote_status": remote_payload,
},
)
payment = await payment_module.get_platega_payment_by_id(db, local_payment_id)
if (
remote_status in self._SUCCESS_STATUSES
and not payment.is_paid
):
payment = await payment_module.update_platega_payment(
db,
payment=payment,
status=remote_status,
callback_payload=remote_payload,
)
await self._finalize_platega_payment(db, payment, remote_payload)
return {
"payment": payment,
"status": payment.status,
"is_paid": payment.is_paid,
"remote": remote_payload,
}
async def _finalize_platega_payment(
self,
db: AsyncSession,
payment: Any,
payload: Optional[Dict[str, Any]],
) -> Any:
payment_module = import_module("app.services.payment_service")
metadata = dict(getattr(payment, "metadata_json", {}) or {})
if payload is not None:
metadata["webhook"] = payload
paid_at = None
if isinstance(payload, dict):
paid_at_raw = payload.get("paidAt") or payload.get("confirmedAt")
if paid_at_raw:
try:
paid_at = datetime.fromisoformat(str(paid_at_raw))
except ValueError:
paid_at = None
payment = await payment_module.update_platega_payment(
db,
payment=payment,
status="CONFIRMED",
is_paid=True,
paid_at=paid_at,
metadata=metadata,
callback_payload=payload,
)
locked_payment = await payment_module.get_platega_payment_by_id_for_update(
db, payment.id
)
if locked_payment:
payment = locked_payment
metadata = dict(getattr(payment, "metadata_json", {}) or {})
balance_already_credited = bool(metadata.get("balance_credited"))
invoice_message = metadata.get("invoice_message") or {}
if getattr(self, "bot", None):
chat_id = invoice_message.get("chat_id")
message_id = invoice_message.get("message_id")
if chat_id and message_id:
try:
await self.bot.delete_message(chat_id, message_id)
except Exception as delete_error: # pragma: no cover - depends on bot rights
logger.warning(
"Не удалось удалить Platega счёт %s: %s",
message_id,
delete_error,
)
else:
metadata.pop("invoice_message", None)
if payment.transaction_id:
logger.info(
"Platega платеж %s уже связан с транзакцией %s",
payment.correlation_id,
payment.transaction_id,
)
return payment
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error("Пользователь %s не найден для Platega", payment.user_id)
return payment
transaction_external_id = (
str(payload.get("id"))
if isinstance(payload, dict) and payload.get("id")
else payment.platega_transaction_id
)
existing_transaction = None
if transaction_external_id:
existing_transaction = await payment_module.get_transaction_by_external_id(
db,
transaction_external_id,
PaymentMethod.PLATEGA,
)
method_display = settings.get_platega_method_display_name(payment.payment_method_code)
description = (
f"Пополнение через Platega ({method_display})"
if method_display
else "Пополнение через Platega"
)
transaction = existing_transaction
created_transaction = False
if not transaction:
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=description,
payment_method=PaymentMethod.PLATEGA,
external_id=transaction_external_id or payment.correlation_id,
is_completed=True,
)
created_transaction = True
await payment_module.link_platega_payment_to_transaction(
db, payment=payment, transaction_id=transaction.id
)
should_credit_balance = created_transaction or not balance_already_credited
if not should_credit_balance:
logger.info(
"Platega платеж %s уже зачислил баланс ранее",
payment.correlation_id,
)
return payment
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
promo_group = user.get_primary_promo_group()
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
payment.amount_kopeks,
getattr(self, "bot", None),
)
except Exception as error:
logger.error("Ошибка обработки реферального пополнения Platega: %s", error)
if was_first_topup and not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error("Ошибка отправки админ уведомления Platega: %s", error)
method_title = settings.get_platega_method_display_title(payment.payment_method_code)
if getattr(self, "bot", None):
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
f"🦊 Способ: {method_title}\n"
f"🆔 Транзакция: {transaction.id}\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
reply_markup=keyboard,
)
except Exception as error:
logger.error("Ошибка отправки уведомления пользователю Platega: %s", error)
try:
from app.services.user_cart_service import user_cart_service
from aiogram import types
has_saved_cart = await user_cart_service.has_user_cart(user.id)
auto_purchase_success = False
if has_saved_cart:
try:
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
db,
user,
bot=getattr(self, "bot", None),
)
except Exception as auto_error:
logger.error(
"Ошибка автоматической покупки подписки для пользователя %s: %s",
user.id,
auto_error,
exc_info=True,
)
if auto_purchase_success:
has_saved_cart = False
if has_saved_cart and getattr(self, "bot", None):
from app.localization.texts import get_texts
texts = get_texts(user.language)
cart_message = texts.t(
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
"🛒 У вас есть неоформленный заказ.\n\n"
"Вы можете продолжить оформление с теми же параметрами.",
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="return_to_saved_cart",
)
],
[
types.InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance",
)
],
[
types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu",
)
],
]
)
await self.bot.send_message(
chat_id=user.telegram_id,
text=(
f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса.\n\n"
f"{cart_message}"
),
reply_markup=keyboard,
)
except Exception as error:
logger.error(
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
payment.user_id,
error,
exc_info=True,
)
metadata["balance_change"] = {
"old_balance": old_balance,
"new_balance": user.balance_kopeks,
"credited_at": datetime.utcnow().isoformat(),
}
metadata["balance_credited"] = True
await payment_module.update_platega_payment(
db,
payment=payment,
metadata=metadata,
)
logger.info(
"✅ Обработан Platega платеж %s для пользователя %s",
payment.correlation_id,
payment.user_id,
)
return payment
+481 -171
View File
@@ -7,6 +7,7 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal, ROUND_FLOOR, ROUND_HALF_UP
from typing import Optional
@@ -19,11 +20,22 @@ from app.database.crud.transaction import create_transaction
from app.database.crud.user import get_user_by_id
from app.database.models import PaymentMethod, TransactionType
from app.external.telegram_stars import TelegramStarsService
from app.services.subscription_auto_purchase_service import (
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class _SimpleSubscriptionPayload:
"""Данные для простой подписки, извлечённые из payload звёздного платежа."""
subscription_id: Optional[int]
period_days: Optional[int]
class TelegramStarsMixin:
"""Mixin с операциями создания и обработки платежей через Telegram Stars."""
@@ -85,7 +97,6 @@ class TelegramStarsMixin:
telegram_payment_charge_id: str,
) -> bool:
"""Финализирует платеж, пришедший из Telegram Stars, и обновляет баланс пользователя."""
del payload # payload пока не используется, но оставляем аргумент для совместимости.
try:
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(
stars_amount
@@ -96,12 +107,28 @@ class TelegramStarsMixin:
)
)
simple_payload = self._parse_simple_subscription_payload(
payload,
user_id,
)
transaction_description = (
f"Оплата подписки через Telegram Stars ({stars_amount} ⭐)"
if simple_payload
else f"Пополнение через Telegram Stars ({stars_amount} ⭐)"
)
transaction_type = (
TransactionType.SUBSCRIPTION_PAYMENT
if simple_payload
else TransactionType.DEPOSIT
)
transaction = await create_transaction(
db=db,
user_id=user_id,
type=TransactionType.DEPOSIT,
type=transaction_type,
amount_kopeks=amount_kopeks,
description=f"Пополнение через Telegram Stars ({stars_amount} ⭐)",
description=transaction_description,
payment_method=PaymentMethod.TELEGRAM_STARS,
external_id=telegram_payment_charge_id,
is_completed=True,
@@ -115,178 +142,461 @@ class TelegramStarsMixin:
)
return False
# Запоминаем старые значения, чтобы корректно построить уведомления.
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
# Обновляем баланс в БД.
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = getattr(user, "promo_group", None)
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = (
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
)
await db.commit()
description_for_referral = (
f"Пополнение Stars: {settings.format_price(amount_kopeks)} ({stars_amount} ⭐)"
)
logger.info(
"🔍 Проверка реферальной логики для описания: '%s'",
description_for_referral,
)
lower_description = description_for_referral.lower()
contains_allowed_keywords = any(
word in lower_description
for word in ["пополнение", "stars", "yookassa", "topup"]
)
contains_forbidden_keywords = any(
word in lower_description for word in ["комиссия", "бонус"]
)
allow_referral = contains_allowed_keywords and not contains_forbidden_keywords
if allow_referral:
logger.info(
"🔞 Вызов process_referral_topup для пользователя %s",
user_id,
)
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db, user_id, amount_kopeks, getattr(self, "bot", None)
)
except Exception as error:
logger.error(
"Ошибка обработки реферального пополнения: %s", error
)
else:
logger.info(
"❌ Описание '%s' не подходит для реферальной логики",
description_for_referral,
if simple_payload:
return await self._finalize_simple_subscription_stars_payment(
db=db,
user=user,
transaction=transaction,
amount_kopeks=amount_kopeks,
stars_amount=stars_amount,
payload_data=simple_payload,
telegram_payment_charge_id=telegram_payment_charge_id,
)
if was_first_topup and not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
logger.info(
"💰 Баланс пользователя %s изменен: %s%s (Δ +%s)",
user.telegram_id,
old_balance,
user.balance_kopeks,
amount_kopeks,
return await self._finalize_stars_balance_topup(
db=db,
user=user,
transaction=transaction,
amount_kopeks=amount_kopeks,
stars_amount=stars_amount,
telegram_payment_charge_id=telegram_payment_charge_id,
)
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import (
AdminNotificationService,
)
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о пополнении Stars: %s",
error,
exc_info=True
)
if getattr(self, "bot", None):
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
"✅ <b>Пополнение успешно!</b>\n\n"
f"⭐ Звезд: {stars_amount}\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
"🦊 Способ: Telegram Stars\n"
f"🆔 Транзакция: {telegram_payment_charge_id[:8]}...\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
reply_markup=keyboard,
)
logger.info(
"✅ Отправлено уведомление пользователю %s о пополнении на %s",
user.telegram_id,
settings.format_price(amount_kopeks),
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о пополнении Stars: %s",
error,
)
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
try:
from app.services.user_cart_service import user_cart_service
from aiogram import types
has_saved_cart = await user_cart_service.has_user_cart(user.id)
if has_saved_cart and getattr(self, "bot", None):
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
from app.localization.texts import get_texts
texts = get_texts(user.language)
cart_message = texts.t(
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
"🛒 У вас есть неоформленный заказ.\n\n"
"Вы можете продолжить оформление с теми же параметрами."
)
# Создаем клавиатуру с кнопками
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout"
)],
[types.InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance"
)],
[types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu"
)]
])
await self.bot.send_message(
chat_id=user.telegram_id,
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n{cart_message}",
reply_markup=keyboard
)
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
except Exception as e:
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
logger.info(
"✅ Обработан Stars платеж: пользователь %s, %s звезд → %s",
user_id,
stars_amount,
settings.format_price(amount_kopeks),
)
return True
except Exception as error:
logger.error("Ошибка обработки Stars платежа: %s", error, exc_info=True)
return False
@staticmethod
def _parse_simple_subscription_payload(
payload: str,
expected_user_id: int,
) -> Optional[_SimpleSubscriptionPayload]:
"""Пытается извлечь параметры простой подписки из payload звёздного платежа."""
prefix = "simple_sub_"
if not payload or not payload.startswith(prefix):
return None
tail = payload[len(prefix) :]
parts = tail.split("_", 2)
if len(parts) < 3:
logger.warning(
"Payload Stars simple subscription имеет некорректный формат: %s",
payload,
)
return None
user_part, subscription_part, period_part = parts
try:
payload_user_id = int(user_part)
except ValueError:
logger.warning(
"Не удалось разобрать user_id в payload Stars simple subscription: %s",
payload,
)
return None
if payload_user_id != expected_user_id:
logger.warning(
"Получен payload Stars simple subscription с чужим user_id: %s (ожидался %s)",
payload_user_id,
expected_user_id,
)
return None
try:
subscription_id = int(subscription_part)
except ValueError:
logger.warning(
"Не удалось разобрать subscription_id в payload Stars simple subscription: %s",
payload,
)
return None
period_days: Optional[int] = None
try:
period_days = int(period_part)
except ValueError:
logger.warning(
"Не удалось разобрать период в payload Stars simple subscription: %s",
payload,
)
return _SimpleSubscriptionPayload(
subscription_id=subscription_id,
period_days=period_days,
)
async def _finalize_simple_subscription_stars_payment(
self,
db: AsyncSession,
user,
transaction,
amount_kopeks: int,
stars_amount: int,
payload_data: _SimpleSubscriptionPayload,
telegram_payment_charge_id: str,
) -> bool:
"""Активация простой подписки, оплаченной через Telegram Stars."""
period_days = payload_data.period_days or settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS
pending_subscription = None
if payload_data.subscription_id is not None:
try:
from sqlalchemy import select
from app.database.models import Subscription
result = await db.execute(
select(Subscription).where(
Subscription.id == payload_data.subscription_id,
Subscription.user_id == user.id,
)
)
pending_subscription = result.scalar_one_or_none()
except Exception as lookup_error: # pragma: no cover - диагностический лог
logger.error(
"Ошибка поиска pending подписки %s для пользователя %s: %s",
payload_data.subscription_id,
user.id,
lookup_error,
exc_info=True,
)
pending_subscription = None
if not pending_subscription:
logger.error(
"Не найдена pending подписка %s для пользователя %s",
payload_data.subscription_id,
user.id,
)
return False
if payload_data.period_days is None:
start_point = pending_subscription.start_date or datetime.utcnow()
end_point = pending_subscription.end_date or start_point
computed_days = max(1, (end_point - start_point).days or 0)
period_days = max(period_days, computed_days)
try:
from app.database.crud.subscription import activate_pending_subscription
subscription = await activate_pending_subscription(
db=db,
user_id=user.id,
period_days=period_days,
)
except Exception as error:
logger.error(
"Ошибка активации pending подписки для пользователя %s: %s",
user.id,
error,
exc_info=True,
)
return False
if not subscription:
logger.error(
"Не удалось активировать pending подписку пользователя %s",
user.id,
)
return False
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
remnawave_user = await subscription_service.create_remnawave_user(
db,
subscription,
)
if remnawave_user:
await db.refresh(subscription)
except Exception as sync_error: # pragma: no cover - диагностический лог
logger.error(
"Ошибка синхронизации подписки с RemnaWave для пользователя %s: %s",
user.id,
sync_error,
exc_info=True,
)
period_display = period_days
if not period_display and getattr(subscription, "start_date", None) and getattr(
subscription, "end_date", None
):
period_display = max(1, (subscription.end_date - subscription.start_date).days or 0)
if not period_display:
period_display = settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS
if getattr(self, "bot", None):
try:
from aiogram import types
from app.localization.texts import get_texts
texts = get_texts(user.language)
traffic_limit = getattr(subscription, "traffic_limit_gb", 0) or 0
traffic_label = (
"Безлимит" if traffic_limit == 0 else f"{int(traffic_limit)} ГБ"
)
success_message = (
"✅ <b>Подписка успешно активирована!</b>\n\n"
f"📅 Период: {period_display} дней\n"
f"📱 Устройства: {getattr(subscription, 'device_limit', 1)}\n"
f"📊 Трафик: {traffic_label}\n"
f"⭐ Оплата: {stars_amount} ⭐ ({settings.format_price(amount_kopeks)})\n\n"
"🔗 Для подключения перейдите в раздел 'Моя подписка'"
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text="📱 Моя подписка",
callback_data="menu_subscription",
)
],
[
types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu",
)
],
]
)
await self.bot.send_message(
chat_id=user.telegram_id,
text=success_message,
reply_markup=keyboard,
parse_mode="HTML",
)
logger.info(
"✅ Пользователь %s получил уведомление об оплате подписки через Stars",
user.telegram_id,
)
except Exception as error: # pragma: no cover - диагностический лог
logger.error(
"Ошибка отправки уведомления о подписке через Stars: %s",
error,
exc_info=True,
)
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_subscription_purchase_notification(
db,
user,
subscription,
transaction,
period_display,
was_trial_conversion=False,
)
except Exception as admin_error: # pragma: no cover - диагностический лог
logger.error(
"Ошибка уведомления администраторов о подписке через Stars: %s",
admin_error,
exc_info=True,
)
logger.info(
"✅ Обработан Stars платеж как покупка подписки: пользователь %s, %s звезд → %s",
user.id,
stars_amount,
settings.format_price(amount_kopeks),
)
return True
async def _finalize_stars_balance_topup(
self,
db: AsyncSession,
user,
transaction,
amount_kopeks: int,
stars_amount: int,
telegram_payment_charge_id: str,
) -> bool:
"""Начисляет баланс пользователю после оплаты Stars и запускает автопокупку."""
# Запоминаем старые значения, чтобы корректно построить уведомления.
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
# Обновляем баланс в БД.
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = user.get_primary_promo_group()
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
await db.commit()
description_for_referral = (
f"Пополнение Stars: {settings.format_price(amount_kopeks)} ({stars_amount} ⭐)"
)
logger.info(
"🔍 Проверка реферальной логики для описания: '%s'",
description_for_referral,
)
lower_description = description_for_referral.lower()
contains_allowed_keywords = any(
word in lower_description for word in ["пополнение", "stars", "yookassa", "topup"]
)
contains_forbidden_keywords = any(
word in lower_description for word in ["комиссия", "бонус"]
)
allow_referral = contains_allowed_keywords and not contains_forbidden_keywords
if allow_referral:
logger.info(
"🔞 Вызов process_referral_topup для пользователя %s",
user.id,
)
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
amount_kopeks,
getattr(self, "bot", None),
)
except Exception as error: # pragma: no cover - диагностический лог
logger.error(
"Ошибка обработки реферального пополнения: %s",
error,
)
else:
logger.info(
"❌ Описание '%s' не подходит для реферальной логики",
description_for_referral,
)
if was_first_topup and not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
logger.info(
"💰 Баланс пользователя %s изменен: %s%s (Δ +%s)",
user.telegram_id,
old_balance,
user.balance_kopeks,
amount_kopeks,
)
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error: # pragma: no cover - диагностический лог
logger.error(
"Ошибка отправки уведомления о пополнении Stars: %s",
error,
exc_info=True,
)
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
try:
from aiogram import types
from app.localization.texts import get_texts
from app.services.user_cart_service import user_cart_service
has_saved_cart = await user_cart_service.has_user_cart(user.id)
auto_purchase_success = False
if has_saved_cart:
try:
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
db,
user,
bot=getattr(self, "bot", None),
)
except Exception as auto_error: # pragma: no cover - диагностический лог
logger.error(
"Ошибка автоматической покупки подписки для пользователя %s: %s",
user.id,
auto_error,
exc_info=True,
)
if auto_purchase_success:
has_saved_cart = False
if has_saved_cart and getattr(self, "bot", None):
texts = get_texts(user.language)
cart_message = texts.t(
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
"🛒 У вас есть неоформленный заказ.\n\n"
"Вы можете продолжить оформление с теми же параметрами.",
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="return_to_saved_cart",
)
],
[
types.InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance",
)
],
[
types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu",
)
],
]
)
await self.bot.send_message(
chat_id=user.telegram_id,
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n"
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}",
reply_markup=keyboard,
)
logger.info(
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
user.id,
)
except Exception as error: # pragma: no cover - диагностический лог
logger.error(
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
user.id,
error,
exc_info=True,
)
logger.info(
"✅ Обработан Stars платеж: пользователь %s, %s звезд → %s",
user.id,
stars_amount,
settings.format_price(amount_kopeks),
)
return True
+179 -17
View File
@@ -12,12 +12,61 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.subscription_auto_purchase_service import (
auto_purchase_saved_cart_after_topup,
)
from app.services.wata_service import WataAPIError, WataService
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
def _extract_transaction_id(payment: Any, remote_link: Optional[Dict[str, Any]] = None) -> Optional[str]:
"""Try to find the remote WATA transaction identifier from stored payloads."""
def _from_mapping(mapping: Any) -> Optional[str]:
if isinstance(mapping, str):
try:
import json
mapping = json.loads(mapping)
except Exception: # pragma: no cover - defensive parsing
return None
if not isinstance(mapping, dict):
return None
for key in ("id", "transaction_id", "transactionId"):
value = mapping.get(key)
if not value:
continue
value_str = str(value)
if "-" in value_str:
return value_str
return None
candidate = None
if hasattr(payment, "callback_payload"):
candidate = _from_mapping(getattr(payment, "callback_payload"))
if candidate:
return candidate
metadata = getattr(payment, "metadata_json", None)
if isinstance(metadata, dict):
if "transaction" in metadata:
candidate = _from_mapping(metadata.get("transaction"))
if candidate:
return candidate
candidate = _from_mapping(metadata)
if candidate:
return candidate
candidate = _from_mapping(remote_link)
if candidate:
return candidate
return None
class WataPaymentMixin:
"""Encapsulates creation and status handling for WATA payment links."""
@@ -226,6 +275,7 @@ class WataPaymentMixin:
remote_link: Optional[Dict[str, Any]] = None
transaction_payload: Optional[Dict[str, Any]] = None
transaction_id: Optional[str] = None
if getattr(self, "wata_service", None) and payment.payment_link_id:
try:
@@ -253,29 +303,84 @@ class WataPaymentMixin:
remote_status_normalized = (remote_status or "").lower()
if remote_status_normalized in {"closed", "paid"} and not payment.is_paid:
transaction_id = _extract_transaction_id(payment, remote_link)
if transaction_id:
try:
transaction_payload = await self.wata_service.get_transaction( # type: ignore[union-attr]
transaction_id
)
except WataAPIError as error:
logger.error(
"Ошибка получения WATA транзакции %s: %s",
transaction_id,
error,
)
except Exception as error: # pragma: no cover - safety net
logger.exception(
"Непредвиденная ошибка при запросе WATA транзакции %s: %s",
transaction_id,
error,
)
if not transaction_payload:
try:
tx_response = await self.wata_service.search_transactions( # type: ignore[union-attr]
order_id=payment.order_id,
payment_link_id=payment.payment_link_id,
status="Paid",
limit=5,
)
items = tx_response.get("items") or []
for item in items:
if (item or {}).get("status") == "Paid":
transaction_payload = item
break
except WataAPIError as error:
logger.error(
"Ошибка поиска WATA транзакций для %s: %s",
payment.payment_link_id,
error,
)
except Exception as error: # pragma: no cover - safety net
logger.exception("Непредвиденная ошибка при поиске WATA транзакции: %s", error)
if (
not transaction_payload
and not payment.is_paid
and getattr(self, "wata_service", None)
):
fallback_transaction_id = transaction_id or _extract_transaction_id(payment)
if fallback_transaction_id:
try:
tx_response = await self.wata_service.search_transactions( # type: ignore[union-attr]
order_id=payment.order_id,
payment_link_id=payment.payment_link_id,
status="Paid",
limit=5,
transaction_payload = await self.wata_service.get_transaction( # type: ignore[union-attr]
fallback_transaction_id
)
items = tx_response.get("items") or []
for item in items:
if (item or {}).get("status") == "Paid":
transaction_payload = item
break
except WataAPIError as error:
logger.error(
"Ошибка поиска WATA транзакций для %s: %s",
payment.payment_link_id,
"Ошибка повторного запроса WATA транзакции %s: %s",
fallback_transaction_id,
error,
)
except Exception as error: # pragma: no cover - safety net
logger.exception("Непредвиденная ошибка при поиске WATA транзакции: %s", error)
logger.exception(
"Непредвиденная ошибка при повторном запросе WATA транзакции %s: %s",
fallback_transaction_id,
error,
)
if transaction_payload and not payment.is_paid:
payment = await self._finalize_wata_payment(db, payment, transaction_payload)
normalized_status = None
if isinstance(transaction_payload, dict):
raw_status = transaction_payload.get("status") or transaction_payload.get("statusName")
if raw_status:
normalized_status = str(raw_status).lower()
if normalized_status == "paid":
payment = await self._finalize_wata_payment(db, payment, transaction_payload)
else:
logger.debug(
"WATA транзакция %s в статусе %s, повторная обработка не требуется",
transaction_id or getattr(payment, "payment_link_id", ""),
normalized_status or "unknown",
)
return {
"payment": payment,
@@ -293,8 +398,42 @@ class WataPaymentMixin:
) -> Any:
payment_module = import_module("app.services.payment_service")
paid_at = WataService._parse_datetime(transaction_payload.get("paymentTime"))
if isinstance(transaction_payload, dict):
paid_status = transaction_payload.get("status") or transaction_payload.get("statusName")
else:
paid_status = None
if paid_status and str(paid_status).lower() not in {"paid", "declined", "pending"}:
logger.debug(
"Неизвестный статус WATA транзакции %s: %s",
getattr(payment, "payment_link_id", ""),
paid_status,
)
paid_at = None
if isinstance(transaction_payload, dict):
paid_at = WataService._parse_datetime(transaction_payload.get("paymentTime"))
if not paid_at and getattr(payment, "paid_at", None):
paid_at = payment.paid_at
existing_metadata = dict(getattr(payment, "metadata_json", {}) or {})
invoice_message = existing_metadata.get("invoice_message") or {}
invoice_message_removed = False
if getattr(self, "bot", None) and invoice_message:
chat_id = invoice_message.get("chat_id")
message_id = invoice_message.get("message_id")
if chat_id and message_id:
try:
await self.bot.delete_message(chat_id, message_id)
except Exception as delete_error: # pragma: no cover - depends on rights
logger.warning(
"Не удалось удалить счёт WATA %s: %s",
message_id,
delete_error,
)
else:
invoice_message_removed = True
existing_metadata.pop("invoice_message", None)
existing_metadata["transaction"] = transaction_payload
await payment_module.update_wata_payment_status(
@@ -344,7 +483,7 @@ class WataPaymentMixin:
await db.commit()
await db.refresh(user)
promo_group = getattr(user, "promo_group", None)
promo_group = user.get_primary_promo_group()
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
@@ -394,6 +533,10 @@ class WataPaymentMixin:
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
"🦊 Способ: WATA\n"
f"🆔 Транзакция: {transaction.id}\n\n"
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
"Обязательно активируйте подписку отдельно!\n\n"
"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
"подписка будет приобретена автоматически после пополнения баланса.\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
@@ -407,6 +550,25 @@ class WataPaymentMixin:
from aiogram import types
has_saved_cart = await user_cart_service.has_user_cart(user.id)
auto_purchase_success = False
if has_saved_cart:
try:
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
db,
user,
bot=getattr(self, "bot", None),
)
except Exception as auto_error:
logger.error(
"Ошибка автоматической покупки подписки для пользователя %s: %s",
user.id,
auto_error,
exc_info=True,
)
if auto_purchase_success:
has_saved_cart = False
if has_saved_cart and getattr(self, "bot", None):
from app.localization.texts import get_texts
@@ -422,7 +584,7 @@ class WataPaymentMixin:
[
types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout",
callback_data="return_to_saved_cart",
)
],
[

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