Compare commits

...

140 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
72 changed files with 8035 additions and 2160 deletions
+1 -1
View File
@@ -428,7 +428,7 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
# ===== ЛОКАЛИЗАЦИЯ ===== # ===== ЛОКАЛИЗАЦИЯ =====
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru. # Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
DEFAULT_LANGUAGE=ru DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en AVAILABLE_LANGUAGES=ru,en,ua,zh
# Включить выбор языка при старте и отображение кнопки в меню # Включить выбор языка при старте и отображение кнопки в меню
LANGUAGE_SELECTION_ENABLED=true LANGUAGE_SELECTION_ENABLED=true
# Часовой пояс # Часовой пояс
+3 -3
View File
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION" echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.6.2-$(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}" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION" echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.6.2-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}" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION" echo "🧪 Собираем dev версию: $VERSION"
else else
VERSION="v2.6.2-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)" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION" echo "🔀 Собираем PR версию: $VERSION"
fi fi
+3 -3
View File
@@ -49,13 +49,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/} VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION" echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.6.2-$(git rev-parse --short HEAD)" VERSION="v2.7.0-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION" echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.6.2-dev-$(git rev-parse --short HEAD)" VERSION="v2.7.0-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION" echo "🧪 Building dev version: $VERSION"
else else
VERSION="v2.6.2-pr-$(git rev-parse --short HEAD)" VERSION="v2.7.0-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION" echo "🔀 Building PR version: $VERSION"
fi fi
echo "version=$VERSION" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim FROM python:3.13-slim
ARG VERSION="v2.6.2" ARG VERSION="v2.7.0"
ARG BUILD_DATE ARG BUILD_DATE
ARG VCS_REF ARG VCS_REF
+320 -130
View File
@@ -2,8 +2,7 @@
<div align="center"> <div align="center">
<img width="1024" height="1024" alt="ChatGPT Image 23 окт 2025 г , 13_18_33" src="https://github.com/user-attachments/assets/17ad0128-231d-4553-9f4b-ce0644da796c" /> <img width="1024" height="1024" alt="ChatGPT Image 23 окт 2025 г , 13_18_33" src="https://github.com/user-attachments/assets/17ad0128-231d-4553-9f4b-ce0644da796c" />
**🤖 Современный Telegram-бот для управления VPN подписками через Remnawave API** **🤖 Современный Telegram-бот для управления VPN подписками через Remnawave API**
@@ -14,7 +13,7 @@
[![License](https://img.shields.io/badge/License-MIT-green)](LICENSE) [![License](https://img.shields.io/badge/License-MIT-green)](LICENSE)
[![GitHub Stars](https://img.shields.io/github/stars/Fr1ngg/remnawave-bedolaga-telegram-bot?style=social)](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/stargazers) [![GitHub Stars](https://img.shields.io/github/stars/Fr1ngg/remnawave-bedolaga-telegram-bot?style=social)](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/stargazers)
[🚀 Быстрый старт](#-быстрый-старт) • [📖 Функционал](#-функционал) • [🐳 Docker](#-docker-развертывание) • [💻 Локальная разработка](#-локальная-разработка) • [💬 Поддержка](#-поддержка-и-сообщество) [🚀 Быстрый старт](#-быстрый-старт) • [📖 Функционал](#-функционал) • [🳠Docker](#-docker-развертывание) • [💻 Локальная разработка](#-локальная-разработка) • [💬 Поддержка](#-поддержка-и-сообщество)
</div> </div>
@@ -32,18 +31,18 @@
Он просто делает вашу работу вместо вас, принимает оплату, выдаёт подписки, интегрируется с Remnawave и тихо ненавидит всех, кто ещё не подключил его. Он просто делает вашу работу вместо вас, принимает оплату, выдаёт подписки, интегрируется с Remnawave и тихо ненавидит всех, кто ещё не подключил его.
Вы хотите продавать VPN — Бедолага позволит это делать. Вы хотите продавать VPN — Бедолага позволит это делать.
Вы хотите спать — он позволит и это. Вы хотите спать — он позволит и это...
### ⚡ **Полная автоматизация VPN бизнеса** ### ⚡ **Полная автоматизация VPN бизнеса**
- 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня - 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + Heleket + YooKassa (СБП + карты) + MulenPay + PayPalych (СБП + карты) + Platega (карты + СБП) + WATA - 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + Heleket + YooKassa (СБП + карты) + MulenPay + PayPalych (СБП + карты) + Platega (карты + СБП) + WATA
- 🔄 **Автоматизация 99%** - от регистрации до продления подписок - 🔄 **Автоматизация 99%** - от регистрации до продления подписок
- - 📱 **MiniApp лк** - личный кабинет с возможностью покупки/продления подписки - 📱 **MiniApp лк** - личный кабинет с возможностью покупки/продления подписки
- 📊 **Детальная аналитика** - полная картина вашего бизнеса - 📊 **Детальная аналитика** - полная картина вашего бизнеса
- 💬 **Уведомления в топики** об: Активация триала 💎 Покупка подписки 🔄 Конверсия из триала в платную ⏰ Продление подписки 💰 Пополнение баланса 🚧 Включении тех работ ♻️ Появлении новой версии бота - 💬 **Уведомления в топики** об: Активация триала 💎 Покупка подписки 🔄 Конверсия из триала в платную ⏰ Продление подписки 💰 Пополнение баланса 🚧 Включении тех работ ♻️ Появлении новой версии бота
### 🎛️ **Гибкость конфигурации** ### 🎛️ **Гибкость конфигурации**
- 🌍 **Умный выбор серверов** - автоматический пропуск при одном сервере, мультивыбор - 🌐 **Умный выбор серверов** - автоматический пропуск при одном сервере, мультивыбор
- 📱 **Управление устройствами** - от 1 до неограниченного количества - 📱 **Управление устройствами** - от 1 до неограниченного количества
- 📊 **Режимы продажи трафика** - фиксированный лимит или выбор пакетов - 📊 **Режимы продажи трафика** - фиксированный лимит или выбор пакетов
- 🎁 **Промо-система** - коды на деньги, дни подписки, триал-периоды - 🎁 **Промо-система** - коды на деньги, дни подписки, триал-периоды
@@ -51,13 +50,13 @@
- 🛒 **Умная корзина** - сохранение параметров подписки при недостатке баланса - 🛒 **Умная корзина** - сохранение параметров подписки при недостатке баланса
### 💪 **Enterprise готовность** ### 💪 **Enterprise готовность**
- 🏗 **Современная архитектура** - AsyncIO, PostgreSQL, Redis, модульная структура - 🗃 **Современная архитектура** - AsyncIO, PostgreSQL, Redis, модульная структура
- 🔒 **Безопасность** - интеграция с системой защиты панели через куки-аутентификацию - 🔒 **Безопасность** - интеграция с системой защиты панели через куки-аутентификацию
- 📈 **Масштабируемость** - от стартапа до крупного бизнеса - 📈 **Масштабируемость** - от стартапа до крупного бизнеса
- 🔧 **Мониторинг** - автоматическое управление режимом тех. работ - 🔧 **Мониторинг** - автоматическое управление режимом тех. работ
- 🛡️ **Защита панели** - поддержка [remnawave-reverse-proxy](https://github.com/eGamesAPI/remnawave-reverse-proxy) - 🛡️ **Защита панели** - поддержка [remnawave-reverse-proxy](https://github.com/eGamesAPI/remnawave-reverse-proxy)
- 🗄️ **Бекапы/Восстановление** - автобекапы и восстановление бд прямо в боте с уведомлениями в топики - 🗄️ **Бекапы/Восстановление** - автобекапы и восстановление бд прямо в боте с уведомлениями в топики
- **Проверка на подписку** - проверяет подписку на канал - **Проверка на подписку** - проверяет подписку на канал
- 🔄 **Автосинхронизация** - фоновая синхронизация подписок и серверов(сквадов) с Remnawave по расписанию - 🔄 **Автосинхронизация** - фоновая синхронизация подписок и серверов(сквадов) с Remnawave по расписанию
### 📚 Поддерживаемые методы авторизации ### 📚 Поддерживаемые методы авторизации
@@ -83,19 +82,22 @@ mkdir -p ./logs ./data ./data/backups ./data/referral_qr
chmod -R 755 ./logs ./data chmod -R 755 ./logs ./data
sudo chown -R 1000:1000 ./logs ./data sudo chown -R 1000:1000 ./logs ./data
# 3.Установи «Docker» командой:
sudo curl -fsSL https://get.docker.com | sh
# 3. make up # Поднять контейнеры # 4.Установи «make» следующей командой:
apt install make -y
make up # Поднять контейнеры
make help # Показать все команды make help # Показать все команды
make down # Остановить контейнеры make down # Остановить контейнеры
make reload # Перезапустить make reload # Перезапустить
make reload-follow # Перезапустить с логами make reload-follow # Перезапустить с логами
make test # Запустить тесты make test # Запустить тесты
``` ```
### 🐳 Ручной Docker запуск ### 🳠Ручной Docker запуск
Если не хочется пользоваться мастером, можно настроить всё вручную: Если не хочется пользоваться мастером, можно настроить всё вручную:
@@ -122,21 +124,9 @@ docker compose logs
--- ---
## 🌐 Настройка webhook-режима и обратного прокси ## 🌐 Настройка веб-сервера и обратного проксирования
> Встроенный FastAPI-сервер теперь обслуживает Telegram webhook, платежные webhooks, административное API и статические файлы миниапки на **одном порту 8080**. Снаружи вы публикуете только HTTPS-прокси, которое проксирует весь трафик на этот порт. > Встроенный FastAPI-сервер обслуживает Telegram webhook, платежные webhooks, административное API и статические файлы миниапки на **одном порту 8080**. Снаружи вы публикуете только HTTPS-прокси, которое проксирует весь трафик на этот порт.
### ♻️ Миграция со старой схемы (несколько портов)
Если бот уже развернут в режиме polling или с отдельными контейнерами для `payments-webhook`, выполните переход на единую схему:
1. **Обновите `.env`:** установите `BOT_RUN_MODE=webhook` (или `both` для гибридного режима), задайте `WEBHOOK_URL`, `WEBHOOK_PATH` и обязательно сгенерируйте собственный `WEBHOOK_SECRET_TOKEN` командой `openssl rand -hex 32`.
2. **Проверьте `docker-compose.*`:** оставьте публикацию только порта `8080` у сервиса `remnawave_bot`. Все значения `*_WEBHOOK_PORT` теперь используются лишь для обратной совместимости и могут быть удалены.
3. **Обновите прокси:** перенаправляйте *все* пути (`/webhook`, `/yookassa-webhook`, `/cryptobot-webhook`, `/miniapp/static`, `/app-config.json` и т.д.) на один upstream `remnawave_bot:8080`. Примеры Caddy/nginx ниже можно адаптировать к текущей инфраструктуре.
4. **Перезапустите сервисы:** `docker compose up -d --force-recreate bot` и затем перезагрузите прокси. После запуска бот автоматически зарегистрирует новый webhook.
5. **Проверьте здоровье:** `curl http://localhost:8080/health/unified` (или `/health`, если административное API отключено) и `curl http://localhost:8080/health/telegram-webhook`. Убедитесь, что в логах нет ошибок регистрации webhook.
После миграции старые контейнеры/сервисы для отдельных вебхуков можно удалить.
### 1. Выбор режима запуска ### 1. Выбор режима запуска
@@ -144,9 +134,41 @@ docker compose logs
|----------------|------------|---------------------| |----------------|------------|---------------------|
| `polling` | Бот опрашивает Telegram через long polling. HTTP-сервер можно не поднимать. | Локальная отладка или отсутствие внешнего HTTPS. | | `polling` | Бот опрашивает Telegram через long polling. HTTP-сервер можно не поднимать. | Локальная отладка или отсутствие внешнего HTTPS. |
| `webhook` | Aiogram получает апдейты только через вебхук. | Продакшн и серверы за HTTPS-прокси. | | `webhook` | Aiogram получает апдейты только через вебхук. | Продакшн и серверы за HTTPS-прокси. |
| `both` | Одновременно работают polling и webhook. | Миграция с polling на webhook или повышенная отказоустойчивость. | | `both` | Одновременно работают polling и webhook. | Тестирование или повышенная отказоустойчивость. |
### 2. Переменные окружения для webhook ### 2. Минимальные настройки для webhook
Для запуска бота в режиме webhook минимально необходимы следующие параметры в `.env`:
```env
# Обязательные базовые настройки
BOT_TOKEN=1234567890:AABBCCdd...
ADMIN_IDS=123456789,987654321
# Настройки webhook
BOT_RUN_MODE=webhook
WEBHOOK_URL=https://hooks.domain.com
WEBHOOK_PATH=/webhook
WEBHOOK_SECRET_TOKEN=super-secret-token
# Настройки Web API
WEB_API_ENABLED=true
WEB_API_PORT=8080
WEB_API_ALLOWED_ORIGINS=https://miniapp.example.com
WEB_API_DEFAULT_TOKEN=super-secret-token
# Настройки Remnawave
REMNAWAVE_API_URL=https://your-panel.com
REMNAWAVE_API_KEY=your_api_key
```
**Важно:**
- `WEBHOOK_SECRET_TOKEN` - сгенерируйте командой `openssl rand -hex 32`
- `WEB_API_DEFAULT_TOKEN` - сгенерируйте командой `openssl rand -hex 32`
- `WEBHOOK_URL` должен быть доступен извне по HTTPS
- `WEB_API_ALLOWED_ORIGINS` - укажите домен, где будет размещена миниапка
### 3. Полные настройки webhook (опционально)
```env ```env
BOT_RUN_MODE=webhook BOT_RUN_MODE=webhook
@@ -173,47 +195,107 @@ MINIAPP_CUSTOM_URL=https://bot.example.com/miniapp
После изменения `.env` перезапустите сервис: `docker compose up -d remnawave_bot`. После изменения `.env` перезапустите сервис: `docker compose up -d remnawave_bot`.
### 3. Подготовка Docker-сети ### 4. Настройка Docker-сетей
`docker-compose.yml` создаёт сеть `bot_network`. Прокси должен находиться в той же сети, чтобы обращаться к контейнеру по имени `remnawave_bot`. #### Вариант 1: Бот на отдельном сервере
```bash Если бот запускается на отдельном сервере (не там, где панель Remnawave), используйте стандартную конфигурацию:
docker network ls | grep bot_network || docker network create bot_network
docker network connect bot_network <proxy_container_name>
```
Если прокси стартует отдельным compose-файлом, объявите сеть внешней:
**docker-compose.yml бота:**
```yaml ```yaml
services:
postgres:
image: postgres:15-alpine
container_name: remnawave_bot_db
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot}
POSTGRES_USER: ${POSTGRES_USER:-remnawave_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123}
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- bot_network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
redis:
image: redis:7-alpine
container_name: remnawave_bot_redis
restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
networks:
- bot_network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
bot:
build: .
container_name: remnawave_bot
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
env_file:
- .env
environment:
DOCKER_ENV: "true"
DATABASE_MODE: "auto"
POSTGRES_HOST: "postgres"
POSTGRES_PORT: "5432"
POSTGRES_DB: "${POSTGRES_DB:-remnawave_bot}"
POSTGRES_USER: "${POSTGRES_USER:-remnawave_user}"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-secure_password_123}"
REDIS_URL: "redis://redis:6379/0"
TZ: "Europe/Moscow"
LOCALES_PATH: "${LOCALES_PATH:-/app/locales}"
volumes:
- ./logs:/app/logs:rw
- ./data:/app/data:rw
- ./locales:/app/locales:rw
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
- ./vpn_logo.png:/app/vpn_logo.png:ro
ports:
- "${WEB_API_PORT:-8080}:8080"
networks:
- bot_network
healthcheck:
test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8080/health\", timeout=5)' || exit 1"]
interval: 60s
timeout: 10s
retries: 3
start_period: 30s
volumes:
postgres_data:
driver: local
redis_data:
driver: local
networks: networks:
bot_network: bot_network:
external: true name: remnawave_bot_network
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
gateway: 172.20.0.1
``` ```
### 4. Проверка здоровья **docker-compose.yml для Caddy (отдельный файл):**
Статические файлы миниапки автоматически монтируются из каталога `MINIAPP_STATIC_PATH` (по умолчанию `miniapp/`) и доступны по пути `/miniapp/static`.
Проверьте, что единый сервер отвечает:
```bash
curl -s https://bot.example.com/health/unified | jq
```
Полезные диагностические endpoints:
- `/health/unified` — агрегированный статус (режим бота, очередь Telegram, наличие миниапки и платежей). Когда административное API отключено, тот же статус доступен по `/health`.
- `/health/telegram-webhook` — состояние очереди Telegram webhook.
- `/health/payment-webhooks` — какие платёжные интеграции активированы.
### 5. Swagger и документация
- Включите `WEB_API_DOCS_ENABLED=true`, если нужно открыть Swagger UI и OpenAPI. После перезапуска сервиса станут доступны эндпоинты `/docs`, `/doc` (редирект для обратной совместимости), `/redoc` и `/openapi.json`.
- Не забудьте проксировать эти пути через внешний HTTPS-прокси вместе с остальными эндпоинтами бота.
- В продакшене держите `WEB_API_DOCS_ENABLED=false`, чтобы документация не была публичной. При необходимости включайте временно или защищайте прокси базовой авторизацией/IP-фильтрацией.
### 6. Пример Caddy-конфига
```yaml ```yaml
services: services:
caddy: caddy:
@@ -236,9 +318,143 @@ volumes:
networks: networks:
bot_network: bot_network:
name: remnawave_bot_network
external: true external: true
``` ```
#### Вариант 2: Бот на одном сервере с панелью Remnawave
Если бот и панель Remnawave запускаются на одном сервере, подключите бота к сети панели:
**docker-compose.yml бота:**
```yaml
services:
postgres:
image: postgres:15-alpine
container_name: remnawave_bot_db
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot}
POSTGRES_USER: ${POSTGRES_USER:-remnawave_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123}
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- bot_network
- remnawave-network # Подключаем к сети панели
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
redis:
image: redis:7-alpine
container_name: remnawave_bot_redis
restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
networks:
- bot_network
- remnawave-network # Подключаем к сети панели
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
bot:
build: .
container_name: remnawave_bot
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
env_file:
- .env
environment:
DOCKER_ENV: "true"
DATABASE_MODE: "auto"
POSTGRES_HOST: "postgres"
POSTGRES_PORT: "5432"
POSTGRES_DB: "${POSTGRES_DB:-remnawave_bot}"
POSTGRES_USER: "${POSTGRES_USER:-remnawave_user}"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-secure_password_123}"
REDIS_URL: "redis://redis:6379/0"
TZ: "Europe/Moscow"
LOCALES_PATH: "${LOCALES_PATH:-/app/locales}"
volumes:
- ./logs:/app/logs:rw
- ./data:/app/data:rw
- ./locales:/app/locales:rw
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
- ./vpn_logo.png:/app/vpn_logo.png:ro
ports:
- "${WEB_API_PORT:-8080}:8080"
networks:
- bot_network
- remnawave-network # Подключаем к сети панели
healthcheck:
test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8080/health\", timeout=5)' || exit 1"]
interval: 60s
timeout: 10s
retries: 3
start_period: 30s
volumes:
postgres_data:
driver: local
redis_data:
driver: local
networks:
bot_network:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
gateway: 172.20.0.1
remnawave-network:
name: remnawave-network
external: true # Используем существующую сеть панели
```
**Важно:**
- Сеть `remnawave-network` должна быть создана панелью Remnawave
- В `.env` укажите `REMNAWAVE_API_URL=http://remnawave:3000` (обращение внутри Docker-сети)
- Бот сможет напрямую общаться с панелью без внешних запросов
### 5. Проверка здоровья
Статические файлы миниапки автоматически монтируются из каталога `MINIAPP_STATIC_PATH` (по умолчанию `miniapp/`) и доступны по пути `/miniapp/static`.
Проверьте, что единый сервер отвечает:
```bash
curl -s https://bot.example.com/health/unified | jq
```
Полезные диагностические endpoints:
- `/health/unified` — агрегированный статус (режим бота, очередь Telegram, наличие миниапки и платежей). Когда административное API отключено, тот же статус доступен по `/health`.
- `/health/telegram-webhook` — состояние очереди Telegram webhook.
- `/health/payment-webhooks` — какие платёжные интеграции активированы.
### 6. Swagger и документация
- Включите `WEB_API_DOCS_ENABLED=true`, если нужно открыть Swagger UI и OpenAPI. После перезапуска сервиса станут доступны эндпоинты `/docs`, `/doc` (редирект для обратной совместимости), `/redoc` и `/openapi.json`.
- Не забудьте проксировать эти пути через внешний HTTPS-прокси вместе с остальными эндпоинтами бота.
- В продакшене держите `WEB_API_DOCS_ENABLED=false`, чтобы документация не была публичной. При необходимости включайте временно или защищайте прокси базовой авторизацией/IP-фильтрацией.
### 7. Пример Caddy-конфига
`Caddyfile`: `Caddyfile`:
```caddy ```caddy
@@ -249,7 +465,7 @@ api.domain.com {
@config path /app-config.json @config path /app-config.json
header @config Access-Control-Allow-Origin "*" header @config Access-Control-Allow-Origin "*"
reverse_proxy localhost:8080 { reverse_proxy remnawave_bot:8080 {
header_up Host {host} header_up Host {host}
header_up X-Real-IP {remote_host} header_up X-Real-IP {remote_host}
transport http { transport http {
@@ -264,7 +480,7 @@ podpiska.domain.com {
# API эндпоинты /miniapp/* в приложение # API эндпоинты /miniapp/* в приложение
handle /miniapp/* { handle /miniapp/* {
reverse_proxy localhost:8080 { reverse_proxy remnawave_bot:8080 {
header_up Host {host} header_up Host {host}
header_up X-Real-IP {remote_host} header_up X-Real-IP {remote_host}
transport http { transport http {
@@ -276,7 +492,7 @@ podpiska.domain.com {
# app-config.json с CORS # app-config.json с CORS
handle /app-config.json { handle /app-config.json {
header Access-Control-Allow-Origin "*" header Access-Control-Allow-Origin "*"
reverse_proxy localhost:8080 { reverse_proxy remnawave_bot:8080 {
header_up Host {host} header_up Host {host}
header_up X-Real-IP {remote_host} header_up X-Real-IP {remote_host}
transport http { transport http {
@@ -294,31 +510,9 @@ podpiska.domain.com {
} }
``` ```
#### **Примечание:** Если прокси запускается на том же сервере, замените `remnawave_bot:8080` на `localhost:8080`
Либо вместо `remnawave_bot:8080` используйте `localhost:8080` ### 8. Пример nginx-конфига
### 6. Пример nginx-конфига
```yaml
services:
nginx:
image: nginx:1.25-alpine
container_name: remnawave_nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
networks:
- bot_network
networks:
bot_network:
external: true
```
`nginx.conf`: `nginx.conf`:
@@ -354,7 +548,7 @@ http {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# Все остальное # Всё остальное
location / { location / {
proxy_pass http://remnawave_bot_unified; proxy_pass http://remnawave_bot_unified;
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -415,13 +609,11 @@ http {
} }
``` ```
#### **Примечание:** Если прокси запускается на том же сервере, замените `remnawave_bot:8080` на `localhost:8080`
Либо вместо `remnawave_bot:8080` используйте `localhost:8080`
Рекомендации: Рекомендации:
- Откройте входящие 80/443 в фаерволе. - Откройте входящие 80/443 в файерволе.
- Если используете Cloudflare/анти-DDoS, разрешите методы `POST` и заголовок `X-Telegram-Bot-Api-Secret-Token`. - Если используете Cloudflare/анти-DDoS, разрешите методы `POST` и заголовок `X-Telegram-Bot-Api-Secret-Token`.
- После развёртывания перезапустите бот (`make reload`), чтобы он заново зарегистрировал webhook. - После развёртывания перезапустите бот (`make reload`), чтобы он заново зарегистрировал webhook.
@@ -448,7 +640,7 @@ http {
- `BOT_RUN_MODE` — определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима. - `BOT_RUN_MODE` — определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима.
- `WEBHOOK_SECRET_TOKEN` — секрет для проверки заголовка `X-Telegram-Bot-Api-Secret-Token` при работе через вебхуки. - `WEBHOOK_SECRET_TOKEN` — секрет для проверки заголовка `X-Telegram-Bot-Api-Secret-Token` при работе через вебхуки.
- `WEBHOOK_DROP_PENDING_UPDATES` — управляет очисткой очереди сообщений при установке вебхука. - `WEBHOOK_DROP_PENDING_UPDATES` — управляет очисткой очереди сообщений при установке вебхука.
- `WEBHOOK_MAX_QUEUE_SIZE` — ограничивает длину очереди входящих обновлений, чтобы защищаться от перегрузок. - `WEBHOOK_MAX_QUEUE_SIZE` — ограничивает длину очереди входящих обновлений, чтобы защащаться от перегрузок.
- `WEBHOOK_WORKERS` — количество фоновых воркеров, параллельно обрабатывающих обновления Telegram. - `WEBHOOK_WORKERS` — количество фоновых воркеров, параллельно обрабатывающих обновления Telegram.
- `WEBHOOK_ENQUEUE_TIMEOUT` — сколько секунд ждать свободного места в очереди перед отказом (0 — немедленный отказ). - `WEBHOOK_ENQUEUE_TIMEOUT` — сколько секунд ждать свободного места в очереди перед отказом (0 — немедленный отказ).
- `WEBHOOK_WORKER_SHUTDOWN_TIMEOUT` — таймаут корректного завершения воркеров при остановке приложения. - `WEBHOOK_WORKER_SHUTDOWN_TIMEOUT` — таймаут корректного завершения воркеров при остановке приложения.
@@ -464,7 +656,7 @@ http {
| Переменная | Описание | Пример | | Переменная | Описание | Пример |
|------------|----------|--------| |------------|----------|--------|
| `SERVER_STATUS_MODE` | Режим работы кнопки: `disabled`, `external_link`, `external_link_miniapp` или `xray` | `xray` | | `SERVER_STATUS_MODE` | Режим работы кнопки: `disabled`, `external_link`, `external_link_miniapp` или `xray` | `xray` |
| `SERVER_STATUS_EXTERNAL_URL` | Прямая ссылка на внешний мониторинг | `https://status.example.com` | | `SERVER_STATUS_EXTERNAL_URL` | Прямаяссылка на внешний мониторинг | `https://status.example.com` |
| `SERVER_STATUS_METRICS_URL` | URL страницы метрик XrayChecker | `https://sub.example.com/metrics` | | `SERVER_STATUS_METRICS_URL` | URL страницы метрик XrayChecker | `https://sub.example.com/metrics` |
### 🛡️ Защита панели Remnawave ### 🛡️ Защита панели Remnawave
@@ -551,7 +743,7 @@ DEFAULT_DEVICE_LIMIT=3
MAX_DEVICES_LIMIT=15 MAX_DEVICES_LIMIT=15
``` ```
### 👥 Реферальная система ### 💥 Реферальная система
```env ```env
# Включение/выключение реферальной программы # Включение/выключение реферальной программы
@@ -609,7 +801,7 @@ REDIS_URL=redis://redis:6379/0
<tr> <tr>
<td width="50%" valign="top"> <td width="50%" valign="top">
### 👤 **Для пользователей** ### 💤 **Для пользователей**
🧭 **Онбординг и доступ** 🧭 **Онбординг и доступ**
- 🌐 Выбор языка интерфейса (RU/EN), динамическая локализация - 🌐 Выбор языка интерфейса (RU/EN), динамическая локализация
@@ -618,9 +810,9 @@ REDIS_URL=redis://redis:6379/0
- 🔗 Deeplink-инвайты, UTM-кампании и реферальные коды - 🔗 Deeplink-инвайты, UTM-кампании и реферальные коды
🛒 **Умная покупка подписок** 🛒 **Умная покупка подписок**
- 📅 Гибкие периоды (14360 дней) со скидками - 📅 Гибкие периоды (14360 дней) со скидками
- 📊 Выбор трафика: фиксированный лимит, пакеты или безлимит - 📊 Выбор трафика: фиксированный лимит, пакеты или безлимит
- 🌍 Автоматический выбор сервера или мультивыбор - 🌐 Автоматический выбор сервера или мультивыбор
- 📱 Настройка количества устройств и серверов - 📱 Настройка количества устройств и серверов
- 🧾 Динамический калькулятор стоимости - 🧾 Динамический калькулятор стоимости
- 💾 **Сквозная корзина** - сохранение параметров при недостатке баланса - 💾 **Сквозная корзина** - сохранение параметров при недостатке баланса
@@ -640,10 +832,10 @@ REDIS_URL=redis://redis:6379/0
- 💰 CryptoBot (USDT, TON, BTC, ETH и др.) - 💰 CryptoBot (USDT, TON, BTC, ETH и др.)
- 🪙 Heleket (криптовалюта с наценкой) - 🪙 Heleket (криптовалюта с наценкой)
- 💳 MulenPay (СБП) - 💳 MulenPay (СБП)
- 🏦 PayPalych/Pal24 (СБП + карты) - 🦐 PayPalych/Pal24 (СБП + карты)
- 💳 Platega (СБП + банковские карты) - 💳 Platega (СБП + банковские карты)
- 💳 **WATA** - 💳 **WATA**
- 📥 Автогенерация счетов и webhook-уведомления - 🔥 Автогенерация счетов и webhook-уведомления
- 💼 История операций - 💼 История операций
- 🔄 Автоплатёж с настройкой дня списания - 🔄 Автоплатёж с настройкой дня списания
- 🎁 Реферальные и промо-бонусы - 🎁 Реферальные и промо-бонусы
@@ -651,7 +843,7 @@ REDIS_URL=redis://redis:6379/0
📱 **Управление подписками** 📱 **Управление подписками**
- 📈 Реальный трафик, устройства и серверы - 📈 Реальный трафик, устройства и серверы
- 🌍 Переключение серверов и стран - 🌐 Переключение серверов и стран
- 📱 Сброс HWID - 📱 Сброс HWID
- 🧩 Смена языка, промогруппы и параметров - 🧩 Смена языка, промогруппы и параметров
- 🧾 Просмотр активных услуг и статуса - 🧾 Просмотр активных услуг и статуса
@@ -667,7 +859,7 @@ REDIS_URL=redis://redis:6379/0
- 🎁 **Персональные промо-предложения** от админов - 🎁 **Персональные промо-предложения** от админов
- 💰 **Тестовый доступ к серверам** через промо-акции - 💰 **Тестовый доступ к серверам** через промо-акции
- 💸 **Автоматические скидки** при оплате и автопродлении - 💸 **Автоматические скидки** при оплате и автопродлении
- 👥 Реферальная программа с комиссиями и бонусами - 💥 Реферальная программа с комиссиями и бонусами
- 📊 Аналитика доходов и конверсии рефералов - 📊 Аналитика доходов и конверсии рефералов
- 🔗 Генерация реферальных ссылок и QR кодов - 🔗 Генерация реферальных ссылок и QR кодов
@@ -682,9 +874,9 @@ REDIS_URL=redis://redis:6379/0
- 🖥️ **Полноценный личный кабинет** в Telegram WebApp - 🖥️ **Полноценный личный кабинет** в Telegram WebApp
- 📊 Управление подпиской и параметрами - 📊 Управление подпиской и параметрами
- 💳 Интегрированные платежи - 💳 Интегрированные платежи
- 🎁 Активация промо-оферов и промокодов - 🎁 Активация промо-офферов и промокодов
- 📱 Управление устройствами - 📱 Управление устройствами
- 👥 Реферальная статистика - 💥 Реферальная статистика
- 📋 FAQ и юридические документы - 📋 FAQ и юридические документы
- 📥 Библиотека загрузочных ссылок для клиентов - 📥 Библиотека загрузочных ссылок для клиентов
- 🛰️ Web API для внешних интеграций - 🛰️ Web API для внешних интеграций
@@ -702,7 +894,7 @@ REDIS_URL=redis://redis:6379/0
- 🎯 **Статистика по промо-группам** и скидочным уровням - 🎯 **Статистика по промо-группам** и скидочным уровням
- 📊 **Расширенная фильтрация** пользователей (баланс, траты, активность) - 📊 **Расширенная фильтрация** пользователей (баланс, траты, активность)
👥 **Управление пользователями** 💥 **Управление пользователями**
- 🔍 Поиск по ID, имени, юзернейму, Telegram ID и фильтры - 🔍 Поиск по ID, имени, юзернейму, Telegram ID и фильтры
- 💰 Ручное изменение баланса - 💰 Ручное изменение баланса
- 📱 Изменение лимитов устройств, трафика, серверов - 📱 Изменение лимитов устройств, трафика, серверов
@@ -722,7 +914,7 @@ REDIS_URL=redis://redis:6379/0
- 📣 **Рекламные кампании** с deeplink и бонусами (Автовыдача подписки / баланса при переходе) - 📣 **Рекламные кампании** с deeplink и бонусами (Автовыдача подписки / баланса при переходе)
- 📨 Рассылки по сегментам с медиа и кнопка - 📨 Рассылки по сегментам с медиа и кнопка
- 🎨 **Кастомные кнопки** для рассылок (подключение, подписка, поддержка, партнертка и тд) - 🎨 **Кастомные кнопки** для рассылок (подключение, подписка, поддержка, партнертка и тд)
- 🔘 Настройка главного меню и приветственных экранов - 📘 Настройка главного меню и приветственных экранов
🛟 **Поддержка и модерация** 🛟 **Поддержка и модерация**
- 🎫 **Центр тикетов** с приоритетами и статусами - 🎫 **Центр тикетов** с приоритетами и статусами
@@ -749,18 +941,18 @@ REDIS_URL=redis://redis:6379/0
- 🗓️ **Умные автобекапы** с гибким расписанием - 🗓️ **Умные автобекапы** с гибким расписанием
- 📦 Ручные бекапы с выбором содержимого - 📦 Ручные бекапы с выбором содержимого
- 📤 Отправка архивов в выделенный чат/топик - 📤 Отправка архивов в выделенный чат/топик
- 🔁 Восстановление без остановки бота - 🔓 Восстановление без остановки бота
- ✅ Автоматическая синхронизация sequences после восстановления - ✅ Автоматическая синхронизация sequences после восстановления
💳 **Биллинг и настройки** 💳 **Биллинг и настройки**
- ⚙️ **Управление ценами** без перезапуска бота - ⚙️ **Управление ценами** без перезапуска бота
- 🔘 **Управление пакетами трафика** (включение/отключение) - 📘 **Управление пакетами трафика** (включение/отключение)
- 🧪 Тестовые платежи для каждого провайдера - 🧪 Тестовые платежи для каждого провайдера
- 🪝 Управление вебхуками всех платёжных систем - 🪙 Управление вебхуками всех платёжных систем
🏗 **REST API для интеграций** 🗃 **REST API для интеграций**
- 🔌 **FastAPI Web API** с полной документацией - 🔌 **FastAPI Web API** с полной документацией
- 🔑 Управление API-ключами и токенами - 🔒 Управление API-ключами и токенами
- 📊 Эндпоинты для подписок, пользователей, транзакций - 📊 Эндпоинты для подписок, пользователей, транзакций
- 🎁 API промо-системы и рассылок - 🎁 API промо-системы и рассылок
- 📋 API управления контентом и настройками - 📋 API управления контентом и настройками
@@ -775,22 +967,22 @@ REDIS_URL=redis://redis:6379/0
- 🔄 **Автосинхронизация серверов** - фоновая синхронизация по расписанию - 🔄 **Автосинхронизация серверов** - фоновая синхронизация по расписанию
- 🛒 **Умная корзина** - сохранение параметров подписки в Redis при недостатке баланса - 🛒 **Умная корзина** - сохранение параметров подписки в Redis при недостатке баланса
- 🛡️ **Антифрод** - валидация подписки на канал - 🛡️ **Антифрод** - валидация подписки на канал
- 🚫 **Защита от блокировок** - автоблокировка подозрительных никнеймов и имитации фишинг аккаунтов - 🚫 **Защита от блокировок** - автоблокировка подозрительных никнеймов и имитация фишинг аккаунтов
- 🧠 **Асинхронная архитектура** - aiogram 3, PostgreSQL/SQLite, Redis и очереди задач - 🧠 **Асинхронная архитектура** - aiogram 3, PostgreSQL/SQLite, Redis и очереди задач
- 🌐 **Мультиязычность** - локализации RU/EN, быстрый выбор языка - 🌐 **Мультиязычность** - локализации RU/EN, быстрый выбор языка
- 📦 **Интеграция с Remnawave API** - автоматическое создание пользователей и синхронизация - 📦 **Интеграция с Remnawave API** - автоматическое создание пользователей и синхронизация
- 🔄 **Миграция сквадов** - массовый перенос пользователей между сквадами - 🔄 **Миграция сквадов** - массовый перенос пользователей между сквадами
- 🧾 **История операций** - хранение всех транзакций и действий для аудита - 🧾 **История операций** - хранение всех транзакций и действий для аудита
- 💸 **Сервис автопрвоерки транзакций** - автоматическая проверка транзакций в статусе "В ожидании оплаты" за последние 24ч - 💸 **Сервис автопрворки транзакций** - автоматическая проверка транзакций в статусе "В ожидании оплаты" за последние 24ч
### 🌐 Веб-API и мини-приложение ### 🌐 Веб-API и мини-приложение
- ⚙️ **FastAPI Web API** с эндпоинтами для управления всеми аспектами бота - ⚙️ **FastAPI Web API** с эндпоинтами для управления всеми аспектами бота
- 🔑 **Управление API-ключами** - выпуск, отзыв, реактивация токенов - 🔒 **Управление API-ключами** - выпуск, отзыв, реактивация токенов
- 🛰️ **Mini App** - полноценный личный кабинет внутри Telegram - 🛰️ **Mini App** - полноценный личный кабинет внутри Telegram
- 💳 **Интегрированные платежи** в Mini App (Stars, Pal24, YooKassa, WATA) - 💳 **Интегрированные платежи** в Mini App (Stars, Pal24, YooKassa, WATA)
- 🧭 **Единый стандартный app-config.json** - централизованная раздача ссылок на клиенты - 🧭 **Единый стандартный app-config.json** - централизованная раздача ссылок на клиенты
- 🪝 **Платёжные вебхуки** - встроенные серверы для всех платёжных систем - 🪙 **Платёжные вебхуки** - встроенные серверы для всех платёжных систем
- 📡 **Мониторинг серверов** - REST-эндпоинты для просмотра нод и статистики - 📡 **Мониторинг серверов** - REST-эндпоинты для просмотра нод и статистики
## 🚀 Производительность ## 🚀 Производительность
@@ -804,14 +996,14 @@ REDIS_URL=redis://redis:6379/0
--- ---
## 🏗️ Технологический стек ## 🗃️ Технологический стек
### 💪 Современные технологии ### 💪 Современные технологии
- **🐍 Python 3.13+** с AsyncIO - максимальная производительность - **🐍 Python 3.13+** с AsyncIO - максимальная производительность
- **🗄️ PostgreSQL 15+** - надежное хранение данных - **🗄️ PostgreSQL 15+** - надежное хранение данных
- **⚡ Redis** - быстрое кеширование и сессии (для корзины) - **⚡ Redis** - быстрое кеширование и сессии (для корзины)
- **🐳 Docker** - простое развертывание в любой среде - **🳠Docker** - простое развертывание в любой среде
- **🔗 SQLAlchemy ORM** - безопасная работа с БД - **🔗 SQLAlchemy ORM** - безопасная работа с БД
- **🚀 aiogram 3** - современная Telegram Bot API - **🚀 aiogram 3** - современная Telegram Bot API
- **⚡ FastAPI** - высокопроизводительный REST API - **⚡ FastAPI** - высокопроизводительный REST API
@@ -828,7 +1020,7 @@ REDIS_URL=redis://redis:6379/0
- Нажми **Синхронизация** и дождись завершения - Нажми **Синхронизация** и дождись завершения
- Без этого пользователи не смогут выбирать страны! - Без этого пользователи не смогут выбирать страны!
2. **👥 Синхронизация пользователей** (если есть база) 2. **💥 Синхронизация пользователей** (если есть база)
- **Админ панель** → **Remnawave****Синхронизация** - **Админ панель** → **Remnawave****Синхронизация**
- **Синхронизировать всех** → дождись импорта - **Синхронизировать всех** → дождись импорта
@@ -880,9 +1072,9 @@ ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # ID топика для тикет
--- ---
## 🐛 Устранение неполадок ## 🛠 Устранение неполадок
### 🏥 Health Checks ### 🥼 Health Checks
- **Unified сервер**: `http://localhost:8080/health/unified` (или `/health`, если административное API отключено) - **Unified сервер**: `http://localhost:8080/health/unified` (или `/health`, если административное API отключено)
- **Telegram webhook**: `http://localhost:8080/health/telegram-webhook` - **Telegram webhook**: `http://localhost:8080/health/telegram-webhook`
- **Платёжные webhooks**: `http://localhost:8080/health/payment-webhooks` - **Платёжные webhooks**: `http://localhost:8080/health/payment-webhooks`
@@ -929,7 +1121,7 @@ docker system prune
## 💡 Использование ## 💡 Использование
### 👤 **Для пользователей** ### 💤 **Для пользователей**
1. **🚀 Старт** → Найди бота и нажми `/start` 1. **🚀 Старт** → Найди бота и нажми `/start`
2. **🌐 Язык** → Выбери язык интерфейса (RU/EN) 2. **🌐 Язык** → Выбери язык интерфейса (RU/EN)
@@ -937,21 +1129,21 @@ docker system prune
4. **💰 Баланс** → Пополни через любой удобный способ 4. **💰 Баланс** → Пополни через любой удобный способ
5. **🛒 Подписка** → Выбери тариф и параметры 5. **🛒 Подписка** → Выбери тариф и параметры
6. **📱 Подключение** → Получи ссылку или конфиг 6. **📱 Подключение** → Получи ссылку или конфиг
7. **👥 Партнерка** → Поделись ссылкой и получай бонусы 7. **💥 Партнерка** → Поделись ссылкой и получай бонусы
### ⚙️ **Для администраторов** ### ⚙️ **Для администраторов**
Доступ через **"⚙️ Админ панель"**: Доступ через **"⚙️ Админ панель"**:
- **📦 Подписки** → настройка серверов, цен, синхронизация - **📦 Подписки** → настройка серверов, цен, синхронизация
- **👥 Пользователи** → поиск, редактирование, блокировка - **💥 Пользователи** → поиск, редактирование, блокировка
- **💎 Промо-группы** → управление скидочными группами и уровнями - **💎 Промо-группы** → управление скидочными группами и уровнями
- **🎁 Промокоды** → создание и статистика - **🎁 Промокоды** → создание и статистика
- **🎯 Промо-предложения** → персональные акции и скидки - **🎯 Промо-предложения** → персональные акции и скидки
- **📨 Рассылки** → уведомления по сегментам - **📨 Рассылки** → уведомления по сегментам
- **📣 Кампании** → управление рекламными кампаниями - **📣 Кампании** → управление рекламными кампаниями
- **🎫 Тикеты** → система поддержки - **🎫 Тикеты** → система поддержки
- **📄 Контент** → политика, оферта, FAQ - **🔄 Контент** → политика, оферта, FAQ
- **🖥 Remnawave** → мониторинг, синхронизация - **🖥 Remnawave** → мониторинг, синхронизация
- **📊 Статистика** → детальная аналитика - **📊 Статистика** → детальная аналитика
@@ -959,7 +1151,7 @@ docker system prune
## 🛡️ Безопасность ## 🛡️ Безопасность
### 🔐 Защита панели Remnawave ### 🔍 Защита панели Remnawave
Бот поддерживает интеграцию с системой защиты панели: Бот поддерживает интеграцию с системой защиты панели:
@@ -985,11 +1177,11 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
## 🤝 Как помочь проекту ## 🤝 Как помочь проекту
- 🔍 [**Сообщай о багах**](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/issues) с подробным описанием - 🐛 [**Сообщай о багах**](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/issues) с подробным описанием
- 💡 [**Предлагай идеи**](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/discussions) для улучшения - 💡 [**Предлагай идеи**](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/discussions) для улучшения
-**Ставь звезды** проекту - это мотивирует разработку! -**Ставь звезды** проекту - это мотивирует разработку!
- 📢 **Рассказывай друзьям** о проекте - 📢 **Рассказывай друзьям** о проекте
- 💝 **[Поддержи разработку](https://t.me/tribute/app?startapp=duUO)** - помоги проекту расти - 👍 **[Поддержи разработку](https://t.me/tribute/app?startapp=duUO)** - помоги проекту расти
- 🔧 **Отправляй Pull Requests** - внеси свой вклад в код - 🔧 **Отправляй Pull Requests** - внеси свой вклад в код
--- ---
@@ -1366,8 +1558,6 @@ docker compose up -d
**Made with ❤️ by [@fringg](https://t.me/fringg) and amazing [contributors](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/graphs/contributors)** **Made with ❤️ by [@fringg](https://t.me/fringg) and amazing [contributors](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/graphs/contributors)**
**Версия:** v2.5.2 | **Последнее обновление:** 2024
<div align="center"> <div align="center">
### ⭐ Не забудь поставить звезду проекту! ### ⭐ Не забудь поставить звезду проекту!
+2
View File
@@ -59,6 +59,7 @@ from app.handlers.admin import (
public_offer as admin_public_offer, public_offer as admin_public_offer,
faq as admin_faq, faq as admin_faq,
payments as admin_payments, payments as admin_payments,
trials as admin_trials,
) )
from app.handlers.stars_payments import register_stars_handlers from app.handlers.stars_payments import register_stars_handlers
@@ -174,6 +175,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_public_offer.register_handlers(dp) admin_public_offer.register_handlers(dp)
admin_faq.register_handlers(dp) admin_faq.register_handlers(dp)
admin_payments.register_handlers(dp) admin_payments.register_handlers(dp)
admin_trials.register_handlers(dp)
common.register_handlers(dp) common.register_handlers(dp)
register_stars_handlers(dp) register_stars_handlers(dp)
user_polls.register_handlers(dp) user_polls.register_handlers(dp)
+48 -7
View File
@@ -6,7 +6,8 @@ import re
import html import html
from collections import defaultdict from collections import defaultdict
from datetime import time 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 zoneinfo import ZoneInfo
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from pydantic import field_validator, Field from pydantic import field_validator, Field
@@ -254,6 +255,7 @@ class Settings(BaseSettings):
MULENPAY_PAYMENT_MODE: int = 4 MULENPAY_PAYMENT_MODE: int = 4
MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000 MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000
MULENPAY_MAX_AMOUNT_KOPEKS: int = 10000000 MULENPAY_MAX_AMOUNT_KOPEKS: int = 10000000
MULENPAY_IFRAME_EXPECTED_ORIGIN: Optional[str] = None
PAL24_ENABLED: bool = False PAL24_ENABLED: bool = False
PAL24_API_TOKEN: Optional[str] = None PAL24_API_TOKEN: Optional[str] = None
@@ -697,15 +699,40 @@ class Settings(BaseSettings):
return bool(value) return bool(value)
def get_available_languages(self) -> List[str]: def get_available_languages(self) -> List[str]:
defaults = ["ru", "en", "ua", "zh"]
try: try:
langs = self.AVAILABLE_LANGUAGES 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: 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: def is_language_selection_enabled(self) -> bool:
return bool(getattr(self, "LANGUAGE_SELECTION_ENABLED", True)) return bool(getattr(self, "LANGUAGE_SELECTION_ENABLED", True))
@@ -943,6 +970,20 @@ class Settings(BaseSettings):
def get_mulenpay_display_name_html(self) -> str: def get_mulenpay_display_name_html(self) -> str:
return html.escape(self.get_mulenpay_display_name()) 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: def is_pal24_enabled(self) -> bool:
return ( return (
self.PAL24_ENABLED self.PAL24_ENABLED
+17
View File
@@ -3,6 +3,7 @@ from datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy import select from sqlalchemy import select
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
@@ -90,6 +91,7 @@ async def update_mulenpay_payment_status(
paid_at: Optional[datetime] = None, paid_at: Optional[datetime] = None,
callback_payload: Optional[dict] = None, callback_payload: Optional[dict] = None,
mulen_payment_id: Optional[int] = None, mulen_payment_id: Optional[int] = None,
metadata: Optional[dict] = None,
) -> MulenPayPayment: ) -> MulenPayPayment:
payment.status = status payment.status = status
if is_paid is not None: if is_paid is not None:
@@ -100,6 +102,8 @@ async def update_mulenpay_payment_status(
payment.callback_payload = callback_payload payment.callback_payload = callback_payload
if mulen_payment_id is not None and not payment.mulen_payment_id: if mulen_payment_id is not None and not payment.mulen_payment_id:
payment.mulen_payment_id = mulen_payment_id payment.mulen_payment_id = mulen_payment_id
if metadata is not None:
payment.metadata_json = metadata
payment.updated_at = datetime.utcnow() payment.updated_at = datetime.utcnow()
await db.commit() await db.commit()
@@ -107,6 +111,19 @@ async def update_mulenpay_payment_status(
return payment 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( async def link_mulenpay_payment_to_transaction(
db: AsyncSession, db: AsyncSession,
*, *,
+3
View File
@@ -96,6 +96,7 @@ async def update_pal24_payment_status(
balance_currency: Optional[str] = None, balance_currency: Optional[str] = None,
payer_account: Optional[str] = None, payer_account: Optional[str] = None,
callback_payload: Optional[Dict[str, Any]] = None, callback_payload: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> Pal24Payment: ) -> Pal24Payment:
update_values: Dict[str, Any] = { update_values: Dict[str, Any] = {
"status": status, "status": status,
@@ -121,6 +122,8 @@ async def update_pal24_payment_status(
update_values["payer_account"] = payer_account update_values["payer_account"] = payer_account
if callback_payload is not None: if callback_payload is not None:
update_values["callback_payload"] = callback_payload update_values["callback_payload"] = callback_payload
if metadata is not None:
update_values["metadata_json"] = metadata
update_values["last_status"] = status update_values["last_status"] = status
+192 -4
View File
@@ -1,7 +1,7 @@
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Iterable, Optional, List, Tuple 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.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -129,7 +129,7 @@ async def create_paid_subscription(
connected_squads: List[str] = None, connected_squads: List[str] = None,
update_server_counters: bool = False, update_server_counters: bool = False,
) -> Subscription: ) -> Subscription:
end_date = datetime.utcnow() + timedelta(days=duration_days) end_date = datetime.utcnow() + timedelta(days=duration_days)
if device_limit is None: if device_limit is None:
@@ -186,6 +186,91 @@ async def create_paid_subscription(
return 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( async def extend_subscription(
db: AsyncSession, db: AsyncSession,
subscription: Subscription, subscription: Subscription,
@@ -591,10 +676,113 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
"purchased_today": purchased_today, "purchased_today": purchased_today,
"purchased_week": purchased_week, "purchased_week": purchased_week,
"purchased_month": purchased_month, "purchased_month": purchased_month,
"trial_to_paid_conversion": trial_to_paid_conversion, "trial_to_paid_conversion": trial_to_paid_conversion,
"renewals_count": renewals_count "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( async def update_subscription_usage(
db: AsyncSession, db: AsyncSession,
subscription: Subscription, subscription: Subscription,
+5 -5
View File
@@ -379,11 +379,11 @@ async def subtract_user_balance(
*, *,
consume_promo_offer: bool = False, consume_promo_offer: bool = False,
) -> bool: ) -> bool:
logger.error(f"💸 ОТЛАДКА subtract_user_balance:") logger.info(f"💸 ОТЛАДКА subtract_user_balance:")
logger.error(f" 👤 User ID: {user.id} (TG: {user.telegram_id})") logger.info(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
logger.error(f" 💰 Баланс до списания: {user.balance_kopeks} копеек") logger.info(f" 💰 Баланс до списания: {user.balance_kopeks} копеек")
logger.error(f" 💸 Сумма к списанию: {amount_kopeks} копеек") logger.info(f" 💸 Сумма к списанию: {amount_kopeks} копеек")
logger.error(f" 📝 Описание: {description}") logger.info(f" 📝 Описание: {description}")
log_context: Optional[Dict[str, object]] = None log_context: Optional[Dict[str, object]] = None
if consume_promo_offer: if consume_promo_offer:
+24 -6
View File
@@ -28,11 +28,28 @@ class HeleketService:
def is_configured(self) -> bool: def is_configured(self) -> bool:
return bool(self.merchant_id and self.api_key) return bool(self.merchant_id and self.api_key)
def _prepare_body(self, payload: Dict[str, Any]) -> str: def _prepare_body(
cleaned = {key: value for key, value in payload.items() if value is not None} self,
serialized = json.dumps(cleaned, ensure_ascii=False, separators=(",", ":"), sort_keys=True) 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: if "/" in serialized:
serialized = serialized.replace("/", "\\/") serialized = serialized.replace("/", "\\/")
return serialized return serialized
def _generate_signature(self, body: str) -> str: def _generate_signature(self, body: str) -> str:
@@ -52,7 +69,7 @@ class HeleketService:
logger.error("Heleket сервис не настроен: merchant или api_key отсутствуют") logger.error("Heleket сервис не настроен: merchant или api_key отсутствуют")
return None return None
body = self._prepare_body(payload) body = self._prepare_body(payload, ignore_none=True, sort_keys=True)
signature = self._generate_signature(body) signature = self._generate_signature(body)
url = f"{self.base_url}/{endpoint.lstrip('/')}" url = f"{self.base_url}/{endpoint.lstrip('/')}"
@@ -145,8 +162,9 @@ class HeleketService:
logger.error("Heleket webhook без подписи") logger.error("Heleket webhook без подписи")
return False return False
data = {key: value for key, value in payload.items() if key != "sign"} data = dict(payload)
body = self._prepare_body(data) data.pop("sign", None)
body = self._prepare_body(data, ignore_none=False, sort_keys=False)
expected = self._generate_signature(body) expected = self._generate_signature(body)
is_valid = hmac.compare_digest(expected, str(signature)) is_valid = hmac.compare_digest(expected, str(signature))
+10 -8
View File
@@ -254,17 +254,17 @@ class YooKassaWebhookHandler:
logger.info(f"📊 Обработка webhook YooKassa: {webhook_data.get('event', 'unknown_event')}") logger.info(f"📊 Обработка webhook YooKassa: {webhook_data.get('event', 'unknown_event')}")
logger.debug(f"🔍 Полные данные webhook: {webhook_data}") logger.debug(f"🔍 Полные данные webhook: {webhook_data}")
# Извлекаем ID платежа из вебхука для предотвращения дублирования
yookassa_payment_id = webhook_data.get("object", {}).get("id")
if not yookassa_payment_id:
logger.warning("⚠️ Webhook YooKassa без ID платежа")
return web.Response(status=400, text="No payment ID")
event_type = webhook_data.get("event") event_type = webhook_data.get("event")
if not event_type: if not event_type:
logger.warning("⚠️ Webhook YooKassa без типа события") logger.warning("⚠️ Webhook YooKassa без типа события")
return web.Response(status=400, text="No event type") return web.Response(status=400, text="No event type")
# Извлекаем 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: if event_type not in YOOKASSA_ALLOWED_EVENTS:
logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}") logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}")
return web.Response(status=200, text="OK") return web.Response(status=200, text="OK")
@@ -274,8 +274,10 @@ class YooKassaWebhookHandler:
# Проверяем, не обрабатывается ли этот платеж уже (защита от дублирования) # Проверяем, не обрабатывается ли этот платеж уже (защита от дублирования)
from app.database.models import PaymentMethod from app.database.models import PaymentMethod
from app.database.crud.transaction import get_transaction_by_external_id from app.database.crud.transaction import get_transaction_by_external_id
existing_transaction = await get_transaction_by_external_id(db, yookassa_payment_id, PaymentMethod.YOOKASSA) 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": if existing_transaction and event_type == "payment.succeeded":
logger.info(f"️ Платеж YooKassa {yookassa_payment_id} уже был обработан. Пропускаем дублирующий вебхук.") logger.info(f"️ Платеж YooKassa {yookassa_payment_id} уже был обработан. Пропускаем дублирующий вебхук.")
return web.Response(status=200, text="OK") return web.Response(status=200, text="OK")
+80 -37
View File
@@ -3,6 +3,7 @@ import asyncio
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
from aiogram import Dispatcher, types, F from aiogram import Dispatcher, types, F
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, or_ from sqlalchemy import select, func, and_, or_
@@ -764,52 +765,77 @@ async def confirm_broadcast(
broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language) broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language)
# Ограничение на количество одновременных отправок # Ограничение на количество одновременных отправок и базовая задержка между сообщениями,
semaphore = asyncio.Semaphore(20) # чтобы избежать перегрузки бота и лимитов Telegram при больших рассылках
max_concurrent_sends = 5
per_message_delay = 0.05
semaphore = asyncio.Semaphore(max_concurrent_sends)
async def send_single_broadcast(user): async def send_single_broadcast(user):
"""Отправляет одно сообщение рассылки с семафором ограничения""" """Отправляет одно сообщение рассылки с семафором ограничения"""
async with semaphore: async with semaphore:
try: for attempt in range(3):
if has_media and media_file_id: try:
if media_type == "photo": if has_media and media_file_id:
await callback.bot.send_photo( 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, chat_id=user.telegram_id,
photo=media_file_id, text=message_text,
caption=message_text,
parse_mode="HTML", parse_mode="HTML",
reply_markup=broadcast_keyboard reply_markup=broadcast_keyboard
) )
elif media_type == "video":
await callback.bot.send_video( await asyncio.sleep(per_message_delay)
chat_id=user.telegram_id, return True, user.telegram_id
video=media_file_id, except TelegramRetryAfter as e:
caption=message_text, retry_delay = min(e.retry_after + 1, 30)
parse_mode="HTML", logger.warning(
reply_markup=broadcast_keyboard f"Превышен лимит Telegram для {user.telegram_id}, ожидание {retry_delay} сек."
)
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
) )
return True, user.telegram_id await asyncio.sleep(retry_delay)
except Exception as e: except TelegramForbiddenError:
logger.error(f"Ошибка отправки рассылки пользователю {user.telegram_id}: {e}") # Пользователь мог удалить бота или запретить сообщения
return False, user.telegram_id logger.info(f"Рассылка недоступна для пользователя {user.telegram_id}: Forbidden")
return False, user.telegram_id
except TelegramBadRequest as e:
logger.error(
f"Некорректный запрос при рассылке пользователю {user.telegram_id}: {e}"
)
return False, user.telegram_id
except Exception as e:
logger.error(
f"Ошибка отправки рассылки пользователю {user.telegram_id} (попытка {attempt + 1}/3): {e}"
)
await asyncio.sleep(0.5 * (attempt + 1))
return False, user.telegram_id
# Отправляем сообщения пакетами для эффективности # Отправляем сообщения пакетами для эффективности
batch_size = 100 batch_size = 50
for i in range(0, len(users), batch_size): for i in range(0, len(users), batch_size):
batch = users[i:i + batch_size] batch = users[i:i + batch_size]
tasks = [send_single_broadcast(user) for user in batch] tasks = [send_single_broadcast(user) for user in batch]
@@ -826,7 +852,7 @@ async def confirm_broadcast(
failed_count += 1 failed_count += 1
# Небольшая задержка между пакетами для снижения нагрузки на API # Небольшая задержка между пакетами для снижения нагрузки на API
await asyncio.sleep(0.1) await asyncio.sleep(0.25)
broadcast_history.sent_count = sent_count broadcast_history.sent_count = sent_count
broadcast_history.failed_count = failed_count broadcast_history.failed_count = failed_count
@@ -868,7 +894,24 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
async def get_target_users(db: AsyncSession, target: str) -> list: 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": if target == "all":
return users return users
+1 -1
View File
@@ -45,7 +45,7 @@ MIGRATION_PAGE_SIZE = 8
def _format_duration(seconds: float) -> str: def _format_duration(seconds: float) -> str:
if seconds < 1: if seconds < 1:
return "<1с" return "менее 1с"
minutes, sec = divmod(int(seconds), 60) minutes, sec = divmod(int(seconds), 60)
if minutes: if minutes:
+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",
)
+35 -4
View File
@@ -79,7 +79,12 @@ async def start_cryptobot_payment(
) )
await state.set_state(BalanceStates.waiting_for_amount) 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() 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="📊 Проверить статус", callback_data=f"check_cryptobot_{payment_result['local_payment_id']}")],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] [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"🪙 <b>Оплата криптовалютой</b>\n\n"
f"💰 Сумма к зачислению: {amount_rubles:.0f}\n" f"💰 Сумма к зачислению: {amount_rubles:.0f}\n"
f"💵 К оплате: {amount_usd:.2f} USD\n" f"💵 К оплате: {amount_usd:.2f} USD\n"
@@ -177,7 +203,12 @@ async def process_cryptobot_payment_amount(
reply_markup=keyboard, reply_markup=keyboard,
parse_mode="HTML" 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() await state.clear()
logger.info(f"Создан CryptoBot платеж для пользователя {db_user.telegram_id}: " logger.info(f"Создан CryptoBot платеж для пользователя {db_user.telegram_id}: "
+53 -2
View File
@@ -1,8 +1,10 @@
import logging import logging
from datetime import datetime
from typing import Optional from typing import Optional
from aiogram import types from aiogram import types
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
@@ -66,7 +68,11 @@ async def start_heleket_payment(
) )
await state.set_state(BalanceStates.waiting_for_amount) 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() await callback.answer()
@@ -181,7 +187,52 @@ async def process_heleket_payment_amount(
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")], [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() await state.clear()
+13 -5
View File
@@ -748,11 +748,19 @@ async def handle_topup_amount_callback(
) )
elif method == "platega": elif method == "platega":
from app.database.database import AsyncSessionLocal from app.database.database import AsyncSessionLocal
from .platega import process_platega_payment_amount from .platega import process_platega_payment_amount, start_platega_payment
async with AsyncSessionLocal() as db:
await process_platega_payment_amount( data = await state.get_data()
callback.message, db_user, db, amount_kopeks, state 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": elif method == "pal24":
from app.database.database import AsyncSessionLocal from app.database.database import AsyncSessionLocal
from .pal24 import process_pal24_payment_amount from .pal24 import process_pal24_payment_amount
+53 -2
View File
@@ -59,7 +59,11 @@ async def start_mulenpay_payment(
) )
await state.set_state(BalanceStates.waiting_for_amount) 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() await callback.answer()
@@ -93,6 +97,26 @@ async def process_mulenpay_payment_amount(
amount_rubles = amount_kopeks / 100 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: try:
payment_service = PaymentService(message.bot) payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_mulenpay_payment( payment_result = await payment_service.create_mulenpay_payment(
@@ -163,12 +187,39 @@ async def process_mulenpay_payment_amount(
mulenpay_name_html=mulenpay_name_html, mulenpay_name_html=mulenpay_name_html,
) )
await message.answer( invoice_message = await message.answer(
message_text, message_text,
reply_markup=keyboard, reply_markup=keyboard,
parse_mode="HTML", 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() await state.clear()
logger.info( logger.info(
+50 -2
View File
@@ -1,10 +1,12 @@
import html import html
import logging import logging
from datetime import datetime
from typing import Any, Optional from typing import Any, Optional
from aiogram import types from aiogram import types
from aiogram.exceptions import TelegramBadRequest from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
@@ -204,12 +206,36 @@ async def _send_pal24_payment_message(
support=settings.get_support_contact_display_html(), support=settings.get_support_contact_display_html(),
) )
await message.answer( invoice_message = await message.answer(
message_text, message_text,
reply_markup=keyboard, reply_markup=keyboard,
parse_mode="HTML", 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() await state.clear()
logger.info( logger.info(
@@ -277,7 +303,11 @@ async def start_pal24_payment(
) )
await state.set_state(BalanceStates.waiting_for_amount) 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() await callback.answer()
@@ -309,6 +339,24 @@ async def process_pal24_payment_amount(
available_methods = _get_available_pal24_methods() 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: if len(available_methods) == 1:
await _send_pal24_payment_message( await _send_pal24_payment_message(
message, message,
+73 -3
View File
@@ -32,6 +32,29 @@ async def _prompt_amount(
texts = get_texts(db_user.language) texts = get_texts(db_user.language)
method_name = settings.get_platega_method_display_title(method_code) 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) min_amount_label = settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS)
max_amount_kopeks = settings.PLATEGA_MAX_AMOUNT_KOPEKS max_amount_kopeks = settings.PLATEGA_MAX_AMOUNT_KOPEKS
max_amount_label = ( max_amount_label = (
@@ -75,7 +98,10 @@ async def _prompt_amount(
) )
await state.set_state(BalanceStates.waiting_for_amount) await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="platega", platega_method=method_code) await state.update_data(
platega_prompt_message_id=message.message_id,
platega_prompt_chat_id=message.chat.id,
)
@error_handler @error_handler
@@ -108,6 +134,8 @@ async def start_platega_payment(
return return
await state.update_data(payment_method="platega") 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: if len(active_methods) == 1:
await _prompt_amount(callback.message, db_user, state, active_methods[0]) await _prompt_amount(callback.message, db_user, state, active_methods[0])
@@ -137,7 +165,8 @@ async def start_platega_payment(
), ),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=method_buttons), reply_markup=types.InlineKeyboardMarkup(inline_keyboard=method_buttons),
) )
await state.set_state(BalanceStates.waiting_for_platega_method) if not has_pending_amount:
await state.set_state(BalanceStates.waiting_for_platega_method)
await callback.answer() await callback.answer()
@@ -275,7 +304,25 @@ async def process_platega_payment_amount(
), ),
) )
await message.answer( 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( instructions_template.format(
method=method_title, method=method_title,
amount=settings.format_price(amount_kopeks), amount=settings.format_price(amount_kopeks),
@@ -286,6 +333,29 @@ async def process_platega_payment_amount(
parse_mode="HTML", 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() await state.clear()
+47 -19
View File
@@ -1,11 +1,10 @@
import logging import logging
from aiogram import types from aiogram import types
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
from app.database.models import User 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.localization.texts import get_texts
from app.services.payment_service import PaymentService from app.services.payment_service import PaymentService
from app.states import BalanceStates from app.states import BalanceStates
@@ -22,11 +21,11 @@ async def start_stars_payment(
state: FSMContext state: FSMContext
): ):
texts = get_texts(db_user.language) texts = get_texts(db_user.language)
if not settings.TELEGRAM_STARS_ENABLED: if not settings.TELEGRAM_STARS_ENABLED:
await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True) await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True)
return return
# Формируем текст сообщения в зависимости от настройки # Формируем текст сообщения в зависимости от настройки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
message_text = ( message_text = (
@@ -35,10 +34,10 @@ async def start_stars_payment(
) )
else: else:
message_text = texts.TOP_UP_AMOUNT message_text = texts.TOP_UP_AMOUNT
# Создаем клавиатуру # Создаем клавиатуру
keyboard = get_back_keyboard(db_user.language) keyboard = get_back_keyboard(db_user.language)
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки # Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons from .main import get_quick_amount_buttons
@@ -46,12 +45,17 @@ async def start_stars_payment(
if quick_amount_buttons: if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад" # Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text( await callback.message.edit_text(
message_text, message_text,
reply_markup=keyboard 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.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="stars") await state.update_data(payment_method="stars")
await callback.answer() await callback.answer()
@@ -65,29 +69,48 @@ async def process_stars_payment_amount(
state: FSMContext state: FSMContext
): ):
texts = get_texts(db_user.language) texts = get_texts(db_user.language)
if not settings.TELEGRAM_STARS_ENABLED: if not settings.TELEGRAM_STARS_ENABLED:
await message.answer("⚠️ Оплата Stars временно недоступна") await message.answer("⚠️ Оплата Stars временно недоступна")
return return
try: try:
amount_rubles = amount_kopeks / 100 amount_rubles = amount_kopeks / 100
stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles) 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) payment_service = PaymentService(message.bot)
invoice_link = await payment_service.create_stars_invoice( invoice_link = await payment_service.create_stars_invoice(
amount_kopeks=amount_kopeks, amount_kopeks=amount_kopeks,
description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}", description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}",
payload=f"balance_{db_user.id}_{amount_kopeks}" payload=f"balance_{db_user.id}_{amount_kopeks}"
) )
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)], [types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] [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"⭐ <b>Оплата через Telegram Stars</b>\n\n"
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n" f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
f"К оплате: {stars_amount} звезд\n" f"К оплате: {stars_amount} звезд\n"
@@ -96,9 +119,14 @@ async def process_stars_payment_amount(
reply_markup=keyboard, reply_markup=keyboard,
parse_mode="HTML" 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: except Exception as e:
logger.error(f"Ошибка создания Stars invoice: {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 @error_handler
async def start_tribute_payment( async def start_tribute_payment(
callback: types.CallbackQuery, callback: types.CallbackQuery,
db_user: User db_user: User,
): ):
texts = get_texts(db_user.language) texts = get_texts(db_user.language)
if not settings.TRIBUTE_ENABLED: if not settings.TRIBUTE_ENABLED:
await callback.answer("❌ Оплата картой временно недоступна", show_alert=True) await callback.answer("❌ Оплата картой временно недоступна", show_alert=True)
return return
try: try:
from app.services.tribute_service import TributeService from app.services.tribute_service import TributeService
tribute_service = TributeService(callback.bot) tribute_service = TributeService(callback.bot)
payment_url = await tribute_service.create_payment_link( payment_url = await tribute_service.create_payment_link(
user_id=db_user.telegram_id, user_id=db_user.telegram_id,
amount_kopeks=0, amount_kopeks=0,
description="Пополнение баланса VPN" description="Пополнение баланса VPN",
) )
if not payment_url: if not payment_url:
await callback.answer("❌ Ошибка создания платежа", show_alert=True) await callback.answer("❌ Ошибка создания платежа", show_alert=True)
return return
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ keyboard = types.InlineKeyboardMarkup(
[types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)], inline_keyboard=[
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] [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"
) )
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: except Exception as e:
logger.error(f"Ошибка создания Tribute платежа: {e}") logger.error(f"Ошибка создания Tribute платежа: {e}")
await callback.answer("❌ Ошибка создания платежа", show_alert=True) await callback.answer("❌ Ошибка создания платежа", show_alert=True)
await callback.answer() await callback.answer()
+50 -2
View File
@@ -1,8 +1,10 @@
import logging import logging
from datetime import datetime
from typing import Dict from typing import Dict
from aiogram import types from aiogram import types
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
@@ -56,7 +58,11 @@ async def start_wata_payment(
) )
await state.set_state(BalanceStates.waiting_for_amount) 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() await callback.answer()
@@ -159,12 +165,54 @@ async def process_wata_payment_amount(
support=settings.get_support_contact_display_html(), 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, message_text,
reply_markup=keyboard, reply_markup=keyboard,
parse_mode="HTML", 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() await state.clear()
logger.info( logger.info(
+114 -15
View File
@@ -1,6 +1,9 @@
import logging import logging
from datetime import datetime
from aiogram import types from aiogram import types
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
@@ -58,9 +61,13 @@ async def start_yookassa_payment(
reply_markup=keyboard, reply_markup=keyboard,
parse_mode="HTML" parse_mode="HTML"
) )
await state.set_state(BalanceStates.waiting_for_amount) await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="yookassa") 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() await callback.answer()
@@ -108,9 +115,13 @@ async def start_yookassa_sbp_payment(
reply_markup=keyboard, reply_markup=keyboard,
parse_mode="HTML" parse_mode="HTML"
) )
await state.set_state(BalanceStates.waiting_for_amount) await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="yookassa_sbp") 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() await callback.answer()
@@ -172,7 +183,25 @@ async def process_yookassa_payment_amount(
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] [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"💳 <b>Оплата банковской картой</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n" f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
@@ -187,9 +216,34 @@ async def process_yookassa_payment_amount(
reply_markup=keyboard, reply_markup=keyboard,
parse_mode="HTML" 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() await state.clear()
logger.info(f"Создан платеж YooKassa для пользователя {db_user.telegram_id}: " logger.info(f"Создан платеж YooKassa для пользователя {db_user.telegram_id}: "
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}") f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
@@ -310,27 +364,45 @@ async def process_yookassa_sbp_payment_amount(
# Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса # Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса
keyboard_buttons = [] keyboard_buttons = []
# Добавляем кнопку оплаты, если доступна ссылка # Добавляем кнопку оплаты, если доступна ссылка
if confirmation_url: if confirmation_url:
keyboard_buttons.append([types.InlineKeyboardButton(text="🔗 Перейти к оплате", url=confirmation_url)]) keyboard_buttons.append([types.InlineKeyboardButton(text="🔗 Перейти к оплате", url=confirmation_url)])
else: else:
# Если ссылка недоступна, предлагаем оплатить через ID платежа в приложении банка # Если ссылка недоступна, предлагаем оплатить через ID платежа в приложении банка
keyboard_buttons.append([types.InlineKeyboardButton(text="📱 Оплатить в приложении банка", callback_data="temp_disabled")]) 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="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")])
keyboard_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]) keyboard_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")])
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) 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 = ( message_text = (
f"🔗 <b>Оплата через СБП</b>\n\n" f"🔗 <b>Оплата через СБП</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n" f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
) )
# Добавляем инструкции в зависимости от доступных способов оплаты # Добавляем инструкции в зависимости от доступных способов оплаты
if not confirmation_url: if not confirmation_url:
message_text += ( message_text += (
@@ -341,18 +413,18 @@ async def process_yookassa_sbp_payment_amount(
f"4. Подтвердите платеж в приложении банка\n" f"4. Подтвердите платеж в приложении банка\n"
f"5. Деньги поступят на баланс автоматически\n\n" f"5. Деньги поступят на баланс автоматически\n\n"
) )
message_text += ( message_text += (
f"🔒 Оплата происходит через защищенную систему YooKassa\n" f"🔒 Оплата происходит через защищенную систему YooKassa\n"
f"✅ Принимаем СБП от всех банков-участников\n\n" f"✅ Принимаем СБП от всех банков-участников\n\n"
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}" f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}"
) )
# Отправляем сообщение с инструкциями и клавиатурой # Отправляем сообщение с инструкциями и клавиатурой
# Если есть QR-код, отправляем его как медиа-сообщение # Если есть QR-код, отправляем его как медиа-сообщение
if qr_photo: if qr_photo:
# Используем метод отправки медиа-группы или фото с описанием # Используем метод отправки медиа-группы или фото с описанием
await message.answer_photo( invoice_message = await message.answer_photo(
photo=qr_photo, photo=qr_photo,
caption=message_text, caption=message_text,
reply_markup=keyboard, reply_markup=keyboard,
@@ -360,12 +432,39 @@ async def process_yookassa_sbp_payment_amount(
) )
else: else:
# Если QR-код недоступен, отправляем обычное текстовое сообщение # Если QR-код недоступен, отправляем обычное текстовое сообщение
await message.answer( invoice_message = await message.answer(
message_text, message_text,
reply_markup=keyboard, reply_markup=keyboard,
parse_mode="HTML" 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}: " logger.info(f"Создан платеж YooKassa СБП для пользователя {db_user.telegram_id}: "
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}") f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
+7 -1
View File
@@ -972,7 +972,13 @@ def _get_subscription_status(user: User, texts) -> str:
"🔴 Истекла\n📅 {end_date}", "🔴 Истекла\n📅 {end_date}",
).format(end_date=end_date_text or "") ).format(end_date=end_date_text or "")
if actual_status == "trial": 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: if days_left > 1 and end_date_text:
return texts.t( return texts.t(
"SUB_STATUS_TRIAL_ACTIVE", "SUB_STATUS_TRIAL_ACTIVE",
+39 -7
View File
@@ -1,10 +1,10 @@
import logging import logging
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
from aiogram import Dispatcher, types, F from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
from app.database.models import User
from app.services.payment_service import PaymentService from app.services.payment_service import PaymentService
from app.external.telegram_stars import TelegramStarsService from app.external.telegram_stars import TelegramStarsService
from app.database.crud.user import get_user_by_telegram_id from app.database.crud.user import get_user_by_telegram_id
@@ -18,7 +18,9 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
texts = get_texts(DEFAULT_LANGUAGE) texts = get_texts(DEFAULT_LANGUAGE)
try: 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_", "simple_sub_") allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_")
@@ -35,6 +37,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
try: try:
from app.database.database import get_db from app.database.database import get_db
async for db in get_db(): async for db in get_db():
user = await get_user_by_telegram_id(db, query.from_user.id) user = await get_user_by_telegram_id(db, query.from_user.id)
if not user: if not user:
@@ -77,6 +80,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
async def handle_successful_payment( async def handle_successful_payment(
message: types.Message, message: types.Message,
db: AsyncSession, db: AsyncSession,
state: FSMContext,
**kwargs **kwargs
): ):
texts = get_texts(DEFAULT_LANGUAGE) texts = get_texts(DEFAULT_LANGUAGE)
@@ -106,6 +110,27 @@ async def handle_successful_payment(
return return
payment_service = PaymentService(message.bot) 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( success = await payment_service.process_stars_payment(
db=db, db=db,
user_id=user.id, user_id=user.id,
@@ -113,7 +138,14 @@ async def handle_successful_payment(
payload=payment.invoice_payload, payload=payment.invoice_payload,
telegram_payment_charge_id=payment.telegram_payment_charge_id 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: if success:
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount) rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount)
amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP)) amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP))
@@ -172,15 +204,15 @@ async def handle_successful_payment(
def register_stars_handlers(dp: Dispatcher): def register_stars_handlers(dp: Dispatcher):
dp.pre_checkout_query.register( dp.pre_checkout_query.register(
handle_pre_checkout_query, handle_pre_checkout_query,
F.currency == "XTR" F.currency == "XTR"
) )
dp.message.register( dp.message.register(
handle_successful_payment, handle_successful_payment,
F.successful_payment F.successful_payment
) )
logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей") logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей")
+15 -3
View File
@@ -1767,13 +1767,25 @@ async def select_devices(
devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
previous_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
data['devices'] = devices data['devices'] = devices
data['total_price'] = base_price + countries_price + devices_price data['total_price'] = base_price + countries_price + devices_price
await state.set_data(data) await state.set_data(data)
await callback.message.edit_reply_markup( if devices != previous_devices:
reply_markup=get_devices_keyboard(devices, db_user.language) try:
) await callback.message.edit_reply_markup(
reply_markup=get_devices_keyboard(devices, db_user.language)
)
except TelegramBadRequest as error:
if "message is not modified" in str(error).lower():
logger.debug(
"ℹ️ Пропускаем обновление клавиатуры устройств: содержимое не изменилось"
)
else:
raise
await callback.answer() await callback.answer()
async def devices_continue( async def devices_continue(
+18
View File
@@ -54,6 +54,10 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
), ),
], ],
[ [
InlineKeyboardButton(
text=_t(texts, "ADMIN_MAIN_TRIALS", "🧪 Триалы"),
callback_data="admin_trials",
),
InlineKeyboardButton( InlineKeyboardButton(
text=_t(texts, "ADMIN_MAIN_PAYMENTS", "💳 Пополнения"), text=_t(texts, "ADMIN_MAIN_PAYMENTS", "💳 Пополнения"),
callback_data="admin_payments", callback_data="admin_payments",
@@ -241,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: def get_admin_reports_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language) texts = get_texts(language)
+50 -10
View File
@@ -82,7 +82,40 @@ def _build_additional_buttons(additional_section, language: str) -> List[InlineK
_LANGUAGE_DISPLAY_NAMES = { _LANGUAGE_DISPLAY_NAMES = {
"ru": "🇷🇺 Русский", "ru": "🇷🇺 Русский",
"ru-ru": "🇷🇺 Русский",
"en": "🇬🇧 English", "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: def get_rules_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
@@ -95,27 +128,34 @@ def get_rules_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup
]) ])
def get_channel_sub_keyboard( def get_channel_sub_keyboard(
channel_link: str, channel_link: Optional[str],
language: str = DEFAULT_LANGUAGE, language: str = DEFAULT_LANGUAGE,
) -> InlineKeyboardMarkup: ) -> InlineKeyboardMarkup:
texts = get_texts(language) texts = get_texts(language)
return InlineKeyboardMarkup(
inline_keyboard=[ buttons: List[List[InlineKeyboardButton]] = []
if channel_link:
buttons.append(
[ [
InlineKeyboardButton( InlineKeyboardButton(
text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"), text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"),
url=channel_link, url=channel_link,
) )
], ]
[ )
InlineKeyboardButton(
text=texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался"), buttons.append(
callback_data="sub_channel_check", [
) 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: def get_post_registration_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language) texts = get_texts(language)
+6 -7
View File
@@ -130,6 +130,7 @@
"ADMIN_MAIN_SETTINGS": "⚙️ Settings", "ADMIN_MAIN_SETTINGS": "⚙️ Settings",
"ADMIN_MAIN_SUPPORT": "🛟 Support", "ADMIN_MAIN_SUPPORT": "🛟 Support",
"ADMIN_MAIN_SYSTEM": "🛠️ System", "ADMIN_MAIN_SYSTEM": "🛠️ System",
"ADMIN_MAIN_TRIALS": "🧪 Trials",
"ADMIN_MAIN_PAYMENTS": "💳 Top-ups", "ADMIN_MAIN_PAYMENTS": "💳 Top-ups",
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions",
"ADMIN_MESSAGES": "📨 Broadcasts", "ADMIN_MESSAGES": "📨 Broadcasts",
@@ -168,6 +169,11 @@
"ADMIN_PAYMENTS_TITLE": "💳 <b>Top-up verification</b>", "ADMIN_PAYMENTS_TITLE": "💳 <b>Top-up verification</b>",
"ADMIN_PAYMENTS_DESCRIPTION": "Pending top-up invoices created during the last 24 hours.", "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_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_EMPTY": "No pending top-up invoices found in the last 24 hours.",
"ADMIN_PAYMENTS_ITEM_DETAILS": "📄 #{number}", "ADMIN_PAYMENTS_ITEM_DETAILS": "📄 #{number}",
"ADMIN_PAYMENT_STATUS_PENDING": "Pending", "ADMIN_PAYMENT_STATUS_PENDING": "Pending",
@@ -1017,7 +1023,6 @@
"PAL24_INSTRUCTION_FOLLOW": "{step}. Follow the payment page instructions", "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_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_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_SBP_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)",
"PAL24_SELECT_PAYMENT_METHOD": "Choose a PayPalych payment method:", "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.", "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.",
@@ -1055,12 +1060,8 @@
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.", "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_DESCRIPTION": "via CryptoBot",
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Cryptocurrency</b>", "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_DESCRIPTION": "via {mulenpay_name}",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card ({mulenpay_name})</b>", "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_DESCRIPTION": "via Platega (cards + SBP)",
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Bank card (Platega)</b>", "PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Bank card (Platega)</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient", "PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
@@ -1069,8 +1070,6 @@
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Support team</b>", "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Support team</b>",
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute", "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute",
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Bank card</b>", "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_DESCRIPTION": "via YooKassa",
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>", "PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "via YooKassa Fast Payment System", "PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "via YooKassa Fast Payment System",
+6 -7
View File
@@ -130,6 +130,7 @@
"ADMIN_MAIN_SETTINGS": "⚙️ Настройки", "ADMIN_MAIN_SETTINGS": "⚙️ Настройки",
"ADMIN_MAIN_SUPPORT": "🛟 Поддержка", "ADMIN_MAIN_SUPPORT": "🛟 Поддержка",
"ADMIN_MAIN_SYSTEM": "🛠️ Система", "ADMIN_MAIN_SYSTEM": "🛠️ Система",
"ADMIN_MAIN_TRIALS": "🧪 Триалы",
"ADMIN_MAIN_PAYMENTS": "💳 Пополнения", "ADMIN_MAIN_PAYMENTS": "💳 Пополнения",
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки",
"ADMIN_MESSAGES": "📨 Рассылки", "ADMIN_MESSAGES": "📨 Рассылки",
@@ -168,6 +169,11 @@
"ADMIN_PAYMENTS_TITLE": "💳 <b>Проверка пополнений</b>", "ADMIN_PAYMENTS_TITLE": "💳 <b>Проверка пополнений</b>",
"ADMIN_PAYMENTS_DESCRIPTION": "Список счетов на пополнение, созданных за последние 24 часа и ожидающих оплаты.", "ADMIN_PAYMENTS_DESCRIPTION": "Список счетов на пополнение, созданных за последние 24 часа и ожидающих оплаты.",
"ADMIN_PAYMENTS_NOTICE": "Проверять можно только счета моложе 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_EMPTY": "За последние 24 часа не найдено счетов на пополнение в ожидании.",
"ADMIN_PAYMENTS_ITEM_DETAILS": "📄 №{number}", "ADMIN_PAYMENTS_ITEM_DETAILS": "📄 №{number}",
"ADMIN_PAYMENT_STATUS_PENDING": "Ожидает оплаты", "ADMIN_PAYMENT_STATUS_PENDING": "Ожидает оплаты",
@@ -1037,7 +1043,6 @@
"PAL24_INSTRUCTION_FOLLOW": "{step}. Следуйте подсказкам платёжной системы", "PAL24_INSTRUCTION_FOLLOW": "{step}. Следуйте подсказкам платёжной системы",
"PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.", "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_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_SBP_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
"PAL24_SELECT_PAYMENT_METHOD": "Выберите способ оплаты PayPalych:", "PAL24_SELECT_PAYMENT_METHOD": "Выберите способ оплаты PayPalych:",
"PAL24_TOPUP_PROMPT": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.", "PAL24_TOPUP_PROMPT": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.",
@@ -1075,12 +1080,8 @@
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.", "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.",
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot", "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot",
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Криптовалюта</b>", "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_DESCRIPTION": "через {mulenpay_name}",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта ({mulenpay_name})</b>", "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_DESCRIPTION": "через Platega (карты + СБП)",
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банковская карта (Platega)</b>", "PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банковская карта (Platega)</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно", "PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
@@ -1089,8 +1090,6 @@
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>", "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>",
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute", "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Банковская карта</b>", "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_DESCRIPTION": "через YooKassa",
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>", "PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему быстрых платежей YooKassa", "PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему быстрых платежей YooKassa",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+97 -37
View File
@@ -16,6 +16,79 @@ _logger = logging.getLogger(__name__)
_cached_rules: Dict[str, str] = {} _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: def _get_cached_rules_value(language: str) -> str:
if language in _cached_rules: if language in _cached_rules:
return _cached_rules[language] return _cached_rules[language]
@@ -28,45 +101,32 @@ def _get_cached_rules_value(language: str) -> str:
def _build_dynamic_values(language: str) -> Dict[str, Any]: def _build_dynamic_values(language: str) -> Dict[str, Any]:
language_code = (language or DEFAULT_LANGUAGE).split("-")[0].lower() language_code = (language or DEFAULT_LANGUAGE).split("-")[0].lower()
if language_code == "ru": language_code = _LANGUAGE_ALIASES.get(language_code, language_code)
return { config = _DYNAMIC_LANGUAGE_CONFIGS.get(language_code)
"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"
),
}
if language_code == "en": if not config:
return { return {}
"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"
),
}
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: class Texts:
+51 -8
View File
@@ -82,7 +82,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
bot: Bot = data["bot"] bot: Bot = data["bot"]
channel_id = settings.CHANNEL_SUB_ID channel_id = settings.CHANNEL_SUB_ID
if not channel_id: if not channel_id:
logger.warning("⚠️ CHANNEL_SUB_ID не установлен, пропускаем проверку") logger.warning("⚠️ CHANNEL_SUB_ID не установлен, пропускаем проверку")
return await handler(event, data) return await handler(event, data)
@@ -93,7 +93,12 @@ class ChannelCheckerMiddleware(BaseMiddleware):
logger.debug("⚠️ Обязательная подписка отключена, пропускаем проверку") logger.debug("⚠️ Обязательная подписка отключена, пропускаем проверку")
return await handler(event, data) 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: try:
member = await bot.get_chat_member(chat_id=channel_id, user_id=telegram_id) member = await bot.get_chat_member(chat_id=channel_id, user_id=telegram_id)
@@ -112,16 +117,16 @@ class ChannelCheckerMiddleware(BaseMiddleware):
await event.answer("❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.", show_alert=True) await event.answer("❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.", show_alert=True)
return return
return await self._deny_message(event, bot, channel_link) return await self._deny_message(event, bot, channel_link, channel_id)
else: else:
logger.warning(f"⚠️ Неожиданный статус пользователя {telegram_id}: {member.status}") logger.warning(f"⚠️ Неожиданный статус пользователя {telegram_id}: {member.status}")
await self._capture_start_payload(state, event, bot) await self._capture_start_payload(state, event, bot)
return await self._deny_message(event, bot, channel_link) return await self._deny_message(event, bot, channel_link, channel_id)
except TelegramForbiddenError as e: except TelegramForbiddenError as e:
logger.error(f"❌ Бот заблокирован в канале {channel_id}: {e}") logger.error(f"❌ Бот заблокирован в канале {channel_id}: {e}")
await self._capture_start_payload(state, event, bot) await self._capture_start_payload(state, event, bot)
return await self._deny_message(event, bot, channel_link) return await self._deny_message(event, bot, channel_link, channel_id)
except TelegramBadRequest as e: except TelegramBadRequest as e:
if "chat not found" in str(e).lower(): if "chat not found" in str(e).lower():
logger.error(f"❌ Канал {channel_id} не найден: {e}") logger.error(f"❌ Канал {channel_id} не найден: {e}")
@@ -130,11 +135,29 @@ class ChannelCheckerMiddleware(BaseMiddleware):
else: else:
logger.error(f"❌ Ошибка запроса к каналу {channel_id}: {e}") logger.error(f"❌ Ошибка запроса к каналу {channel_id}: {e}")
await self._capture_start_payload(state, event, bot) await self._capture_start_payload(state, event, bot)
return await self._deny_message(event, bot, channel_link) return await self._deny_message(event, bot, channel_link, channel_id)
except Exception as e: except Exception as e:
logger.error(f"❌ Неожиданная ошибка при проверке подписки: {e}") logger.error(f"❌ Неожиданная ошибка при проверке подписки: {e}")
return await handler(event, data) return await handler(event, data)
@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( async def _capture_start_payload(
self, self,
state: Optional[FSMContext], state: Optional[FSMContext],
@@ -278,7 +301,12 @@ class ChannelCheckerMiddleware(BaseMiddleware):
break break
@staticmethod @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("🚫 Отправляем сообщение о необходимости подписки") logger.debug("🚫 Отправляем сообщение о необходимости подписки")
user = None user = None
@@ -301,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: try:
if isinstance(event, Message): if isinstance(event, Message):
return await event.answer(text, reply_markup=channel_sub_kb) return await event.answer(text, reply_markup=channel_sub_kb)
elif isinstance(event, CallbackQuery): 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: elif isinstance(event, Update) and event.message:
return await bot.send_message(event.message.chat.id, text, reply_markup=channel_sub_kb) return await bot.send_message(event.message.chat.id, text, reply_markup=channel_sub_kb)
except Exception as e: except Exception as e:
+7 -2
View File
@@ -912,12 +912,17 @@ class MonitoringService:
result = await db.execute( result = await db.execute(
select(Subscription) 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( .where(
and_( and_(
Subscription.status == SubscriptionStatus.ACTIVE.value, Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.autopay_enabled == True, Subscription.autopay_enabled == True,
Subscription.is_trial == False Subscription.is_trial == False
) )
) )
) )
+197
View File
@@ -16,12 +16,24 @@ from app.database.models import PaymentMethod, TransactionType
from app.services.subscription_auto_purchase_service import ( from app.services.subscription_auto_purchase_service import (
auto_purchase_saved_cart_after_topup, 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.currency_converter import currency_converter
from app.utils.user_utils import format_referrer_info from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
renewal_service = SubscriptionRenewalService()
@dataclass(slots=True) @dataclass(slots=True)
class _AdminNotificationContext: class _AdminNotificationContext:
user_id: int user_id: int
@@ -173,6 +185,36 @@ class CryptoBotPaymentMixin:
db, invoice_id, status, paid_at 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: if not updated_payment.transaction_id:
amount_usd = updated_payment.amount_float amount_usd = updated_payment.amount_float
@@ -394,6 +436,161 @@ class CryptoBotPaymentMixin:
) )
return False 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( async def _deliver_admin_topup_notification(
self, context: _AdminNotificationContext self, context: _AdminNotificationContext
) -> None: ) -> None:
+36
View File
@@ -255,6 +255,42 @@ class HeleketPaymentMixin:
if updated_payment is None: if updated_payment is None:
return 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: if updated_payment.transaction_id:
logger.info( logger.info(
"Heleket платеж %s уже связан с транзакцией %s", "Heleket платеж %s уже связан с транзакцией %s",
+37
View File
@@ -182,7 +182,43 @@ class MulenPayPaymentMixin:
) )
return False 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 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( logger.info(
"%s платеж %s уже обработан, игнорируем повторный callback", "%s платеж %s уже обработан, игнорируем повторный callback",
display_name, display_name,
@@ -197,6 +233,7 @@ class MulenPayPaymentMixin:
status="success", status="success",
callback_payload=callback_data, callback_payload=callback_data,
mulen_payment_id=mulen_payment_id_int, mulen_payment_id=mulen_payment_id_int,
metadata=metadata,
) )
if payment.transaction_id: if payment.transaction_id:
+35
View File
@@ -333,6 +333,41 @@ class Pal24PaymentMixin:
payment_module = import_module("app.services.payment_service") 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: if payment.transaction_id:
logger.info( logger.info(
"Pal24 платеж %s уже привязан к транзакции (trigger=%s)", "Pal24 платеж %s уже привязан к транзакции (trigger=%s)",
+16
View File
@@ -306,6 +306,22 @@ class PlategaPaymentMixin:
metadata = dict(getattr(payment, "metadata_json", {}) or {}) metadata = dict(getattr(payment, "metadata_json", {}) or {})
balance_already_credited = bool(metadata.get("balance_credited")) 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: if payment.transaction_id:
logger.info( logger.info(
"Platega платеж %s уже связан с транзакцией %s", "Platega платеж %s уже связан с транзакцией %s",
-30
View File
@@ -515,36 +515,6 @@ class TelegramStarsMixin:
exc_info=True, exc_info=True,
) )
if getattr(self, "bot", None):
try:
keyboard = await self.build_topup_success_keyboard(user)
charge_id_short = (telegram_payment_charge_id or getattr(transaction, "external_id", ""))[:8]
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"🆔 Транзакция: {charge_id_short}...\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
reply_markup=keyboard,
)
logger.info(
"✅ Отправлено уведомление пользователю %s о пополнении на %s",
user.telegram_id,
settings.format_price(amount_kopeks),
)
except Exception as error: # pragma: no cover - диагностический лог
logger.error(
"Ошибка отправки уведомления о пополнении Stars: %s",
error,
)
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки # Проверяем наличие сохраненной корзины для возврата к оформлению подписки
try: try:
from aiogram import types from aiogram import types
+19
View File
@@ -415,6 +415,25 @@ class WataPaymentMixin:
if not paid_at and getattr(payment, "paid_at", None): if not paid_at and getattr(payment, "paid_at", None):
paid_at = payment.paid_at paid_at = payment.paid_at
existing_metadata = dict(getattr(payment, "metadata_json", {}) or {}) 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 existing_metadata["transaction"] = transaction_payload
await payment_module.update_wata_payment_status( await payment_module.update_wata_payment_status(
+41 -8
View File
@@ -383,11 +383,19 @@ class YooKassaPaymentMixin:
payment_module = import_module("app.services.payment_service") payment_module = import_module("app.services.payment_service")
# Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования) # Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования)
existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined] get_transaction_by_external_id = getattr(
db, payment_module, "get_transaction_by_external_id", None
payment.yookassa_payment_id,
PaymentMethod.YOOKASSA,
) )
existing_transaction = None
if get_transaction_by_external_id:
try:
existing_transaction = await get_transaction_by_external_id( # type: ignore[attr-defined]
db,
payment.yookassa_payment_id,
PaymentMethod.YOOKASSA,
)
except AttributeError:
logger.debug("get_transaction_by_external_id недоступен, пропускаем проверку дубликатов")
if existing_transaction: if existing_transaction:
# Если транзакция уже существует, просто завершаем обработку # Если транзакция уже существует, просто завершаем обработку
@@ -437,6 +445,22 @@ class YooKassaPaymentMixin:
except Exception as parse_error: except Exception as parse_error:
logger.error(f"Ошибка парсинга метаданных платежа: {parse_error}") logger.error(f"Ошибка парсинга метаданных платежа: {parse_error}")
invoice_message = payment_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(
"Не удалось удалить сообщение YooKassa %s: %s",
message_id,
delete_error,
)
else:
payment_metadata.pop("invoice_message", None)
processing_completed = bool(payment_metadata.get("processing_completed")) processing_completed = bool(payment_metadata.get("processing_completed"))
transaction = None transaction = None
@@ -472,11 +496,20 @@ class YooKassaPaymentMixin:
) )
if transaction is None: if transaction is None:
existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined] get_transaction_by_external_id = getattr(
db, payment_module, "get_transaction_by_external_id", None
payment.yookassa_payment_id,
PaymentMethod.YOOKASSA,
) )
existing_transaction = None
if get_transaction_by_external_id:
try:
existing_transaction = await get_transaction_by_external_id( # type: ignore[attr-defined]
db,
payment.yookassa_payment_id,
PaymentMethod.YOOKASSA,
)
except AttributeError:
logger.debug("get_transaction_by_external_id недоступен, пропускаем проверку дубликатов")
if existing_transaction: if existing_transaction:
# Если транзакция уже существует, пропускаем обработку # Если транзакция уже существует, пропускаем обработку
+5
View File
@@ -112,6 +112,11 @@ async def update_mulenpay_payment_status(*args, **kwargs):
return await mulenpay_crud.update_mulenpay_payment_status(*args, **kwargs) return await mulenpay_crud.update_mulenpay_payment_status(*args, **kwargs)
async def update_mulenpay_payment_metadata(*args, **kwargs):
mulenpay_crud = import_module("app.database.crud.mulenpay")
return await mulenpay_crud.update_mulenpay_payment_metadata(*args, **kwargs)
async def link_mulenpay_payment_to_transaction(*args, **kwargs): async def link_mulenpay_payment_to_transaction(*args, **kwargs):
mulenpay_crud = import_module("app.database.crud.mulenpay") mulenpay_crud = import_module("app.database.crud.mulenpay")
return await mulenpay_crud.link_mulenpay_payment_to_transaction(*args, **kwargs) return await mulenpay_crud.link_mulenpay_payment_to_transaction(*args, **kwargs)
+31 -1
View File
@@ -26,6 +26,7 @@ class PlategaService:
self._max_retries = 3 self._max_retries = 3
self._retry_delay = 0.5 self._retry_delay = 0.5
self._retryable_statuses = {500, 502, 503, 504} self._retryable_statuses = {500, 502, 503, 504}
self._description_max_length = 64
@property @property
def is_configured(self) -> bool: def is_configured(self) -> bool:
@@ -51,7 +52,10 @@ class PlategaService:
} }
if description: if description:
body["description"] = description sanitized_description = self._sanitize_description(
description, self._description_max_length
)
body["description"] = sanitized_description
if return_url: if return_url:
body["return"] = return_url body["return"] = return_url
if failed_url: if failed_url:
@@ -176,6 +180,32 @@ class PlategaService:
return None, raw_text return None, raw_text
@staticmethod
def _sanitize_description(description: str, max_bytes: int) -> str:
"""Обрезает описание с учётом байтового лимита Platega."""
cleaned = (description or "").strip()
if not max_bytes:
return cleaned
encoded = cleaned.encode("utf-8")
if len(encoded) <= max_bytes:
return cleaned
logger.debug(
"Platega description trimmed from %s to %s bytes",
len(encoded),
max_bytes,
)
trimmed_bytes = encoded[:max_bytes]
while True:
try:
return trimmed_bytes.decode("utf-8")
except UnicodeDecodeError:
trimmed_bytes = trimmed_bytes[:-1]
@staticmethod @staticmethod
def parse_expires_at(expires_in: Optional[str]) -> Optional[datetime]: def parse_expires_at(expires_in: Optional[str]) -> Optional[datetime]:
if not expires_in: if not expires_in:
@@ -0,0 +1,564 @@
from __future__ import annotations
import base64
import json
import logging
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Awaitable, Callable, Dict, List, Optional
from uuid import uuid4
from aiogram import Bot
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.server_squad import get_server_ids_by_uuids
from app.database.crud.subscription import (
add_subscription_servers,
calculate_subscription_total_cost,
extend_subscription,
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import PaymentMethod, Subscription, Transaction, TransactionType, User
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveConfigurationError
from app.services.subscription_service import SubscriptionService
from app.utils.pricing_utils import (
apply_percentage_discount,
calculate_months_from_days,
format_period_description,
validate_pricing_calculation,
)
logger = logging.getLogger(__name__)
class SubscriptionRenewalError(Exception):
"""Base class for subscription renewal related errors."""
class SubscriptionRenewalChargeError(SubscriptionRenewalError):
"""Raised when the balance charge step fails."""
@dataclass(slots=True)
class SubscriptionRenewalPricing:
period_days: int
period_id: str
months: int
base_original_total: int
discounted_total: int
final_total: int
promo_discount_value: int
promo_discount_percent: int
overall_discount_percent: int
per_month: int
server_ids: List[int]
details: Dict[str, Any]
def to_payload(self) -> Dict[str, Any]:
return {
"period_id": self.period_id,
"period_days": self.period_days,
"months": self.months,
"base_original_total": self.base_original_total,
"discounted_total": self.discounted_total,
"final_total": self.final_total,
"promo_discount_value": self.promo_discount_value,
"promo_discount_percent": self.promo_discount_percent,
"overall_discount_percent": self.overall_discount_percent,
"per_month": self.per_month,
"server_ids": list(self.server_ids),
"details": dict(self.details),
}
@classmethod
def from_payload(cls, payload: Dict[str, Any]) -> "SubscriptionRenewalPricing":
return cls(
period_days=int(payload.get("period_days", 0) or 0),
period_id=str(payload.get("period_id") or build_renewal_period_id(int(payload.get("period_days", 0) or 0))),
months=int(payload.get("months", 0) or 0),
base_original_total=int(payload.get("base_original_total", 0) or 0),
discounted_total=int(payload.get("discounted_total", 0) or 0),
final_total=int(payload.get("final_total", 0) or 0),
promo_discount_value=int(payload.get("promo_discount_value", 0) or 0),
promo_discount_percent=int(payload.get("promo_discount_percent", 0) or 0),
overall_discount_percent=int(payload.get("overall_discount_percent", 0) or 0),
per_month=int(payload.get("per_month", 0) or 0),
server_ids=list(payload.get("server_ids", []) or []),
details=dict(payload.get("details", {}) or {}),
)
@dataclass(slots=True)
class SubscriptionRenewalResult:
subscription: Subscription
transaction: Optional[Transaction]
total_amount_kopeks: int
charged_from_balance_kopeks: int
old_end_date: Optional[datetime]
@dataclass(slots=True)
class RenewalPaymentDescriptor:
user_id: int
subscription_id: int
period_days: int
total_amount_kopeks: int
missing_amount_kopeks: int
payload_id: str
pricing_snapshot: Optional[Dict[str, Any]] = None
@property
def balance_component_kopeks(self) -> int:
remaining = self.total_amount_kopeks - self.missing_amount_kopeks
return max(0, remaining)
_PAYLOAD_PREFIX = "subscription_renewal"
def build_renewal_period_id(period_days: int) -> str:
return f"days:{period_days}"
def build_payment_descriptor(
user_id: int,
subscription_id: int,
period_days: int,
total_amount_kopeks: int,
missing_amount_kopeks: int,
*,
pricing_snapshot: Optional[Dict[str, Any]] = None,
) -> RenewalPaymentDescriptor:
return RenewalPaymentDescriptor(
user_id=user_id,
subscription_id=subscription_id,
period_days=period_days,
total_amount_kopeks=max(0, int(total_amount_kopeks)),
missing_amount_kopeks=max(0, int(missing_amount_kopeks)),
payload_id=uuid4().hex[:8],
pricing_snapshot=pricing_snapshot or None,
)
def encode_payment_payload(descriptor: RenewalPaymentDescriptor) -> str:
snapshot_segment = ""
if descriptor.pricing_snapshot:
try:
raw_snapshot = json.dumps(
descriptor.pricing_snapshot,
separators=(",", ":"),
ensure_ascii=False,
).encode("utf-8")
snapshot_segment = base64.urlsafe_b64encode(raw_snapshot).decode("ascii").rstrip("=")
except (TypeError, ValueError):
snapshot_segment = ""
payload = (
f"{_PAYLOAD_PREFIX}|{descriptor.user_id}|{descriptor.subscription_id}|"
f"{descriptor.period_days}|{descriptor.total_amount_kopeks}|"
f"{descriptor.missing_amount_kopeks}|{descriptor.payload_id}"
)
if snapshot_segment:
payload = f"{payload}|{snapshot_segment}"
return payload
def decode_payment_payload(payload: str, expected_user_id: Optional[int] = None) -> Optional[RenewalPaymentDescriptor]:
if not payload or not payload.startswith(f"{_PAYLOAD_PREFIX}|"):
return None
parts = payload.split("|")
if len(parts) < 7:
return None
try:
(
_,
user_id_raw,
subscription_raw,
period_raw,
total_raw,
missing_raw,
payload_id,
*snapshot_parts,
) = parts
user_id = int(user_id_raw)
subscription_id = int(subscription_raw)
period_days = int(period_raw)
total_amount = int(total_raw)
missing_amount = int(missing_raw)
except (TypeError, ValueError):
return None
pricing_snapshot: Optional[Dict[str, Any]] = None
if snapshot_parts:
encoded_snapshot = snapshot_parts[0]
if encoded_snapshot:
padding = "=" * (-len(encoded_snapshot) % 4)
try:
decoded = base64.urlsafe_b64decode((encoded_snapshot + padding).encode("ascii"))
snapshot_data = json.loads(decoded.decode("utf-8"))
if isinstance(snapshot_data, dict):
pricing_snapshot = snapshot_data
except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
logger.warning("Failed to decode renewal pricing snapshot from payload")
if expected_user_id is not None and user_id != expected_user_id:
return None
return RenewalPaymentDescriptor(
user_id=user_id,
subscription_id=subscription_id,
period_days=period_days,
total_amount_kopeks=max(0, total_amount),
missing_amount_kopeks=max(0, missing_amount),
payload_id=payload_id,
pricing_snapshot=pricing_snapshot,
)
def build_payment_metadata(descriptor: RenewalPaymentDescriptor) -> Dict[str, Any]:
return {
"payment_purpose": _PAYLOAD_PREFIX,
"subscription_id": str(descriptor.subscription_id),
"period_days": str(descriptor.period_days),
"total_amount_kopeks": str(descriptor.total_amount_kopeks),
"missing_amount_kopeks": str(descriptor.missing_amount_kopeks),
"payload_id": descriptor.payload_id,
"pricing_snapshot": descriptor.pricing_snapshot or {},
}
def parse_payment_metadata(
metadata: Optional[Dict[str, Any]],
*,
expected_user_id: Optional[int] = None,
) -> Optional[RenewalPaymentDescriptor]:
if not metadata:
return None
if metadata.get("payment_purpose") != _PAYLOAD_PREFIX:
return None
try:
subscription_id = int(metadata.get("subscription_id"))
period_days = int(metadata.get("period_days"))
total_amount = int(metadata.get("total_amount_kopeks"))
missing_amount = int(metadata.get("missing_amount_kopeks"))
except (TypeError, ValueError):
return None
payload_id = str(metadata.get("payload_id") or "")
user_id = metadata.get("user_id")
if user_id is not None:
try:
user_id_int = int(user_id)
except (TypeError, ValueError):
user_id_int = None
else:
user_id_int = None
if expected_user_id is not None and user_id_int is not None and user_id_int != expected_user_id:
return None
pricing_snapshot = metadata.get("pricing_snapshot")
if isinstance(pricing_snapshot, dict):
snapshot_dict = pricing_snapshot
else:
snapshot_dict = None
return RenewalPaymentDescriptor(
user_id=user_id_int or expected_user_id or 0,
subscription_id=subscription_id,
period_days=period_days,
total_amount_kopeks=max(0, total_amount),
missing_amount_kopeks=max(0, missing_amount),
payload_id=payload_id,
pricing_snapshot=snapshot_dict,
)
async def with_admin_notification_service(
handler: Callable[[AdminNotificationService], Awaitable[Any]],
) -> None:
if not getattr(settings, "ADMIN_NOTIFICATIONS_ENABLED", False):
return
if not settings.BOT_TOKEN:
logger.debug("Skipping admin notification: bot token is not configured")
return
bot: Bot | None = None
try:
bot = Bot(token=settings.BOT_TOKEN)
service = AdminNotificationService(bot)
await handler(service)
except Exception as error: # pragma: no cover - defensive logging
logger.error("Failed to send admin notification from renewal service: %s", error)
finally:
if bot is not None:
await bot.session.close()
class SubscriptionRenewalService:
"""Shared helpers for subscription renewal pricing and processing."""
async def calculate_pricing(
self,
db: AsyncSession,
user: User,
subscription: Subscription,
period_days: int,
) -> SubscriptionRenewalPricing:
connected_uuids = [str(uuid) for uuid in list(subscription.connected_squads or [])]
server_ids: List[int] = []
if connected_uuids:
server_ids = await get_server_ids_by_uuids(db, connected_uuids)
traffic_limit = subscription.traffic_limit_gb
if traffic_limit is None:
traffic_limit = settings.DEFAULT_TRAFFIC_LIMIT_GB
devices_limit = subscription.device_limit
if devices_limit is None:
devices_limit = settings.DEFAULT_DEVICE_LIMIT
total_cost, details = await calculate_subscription_total_cost(
db,
period_days,
int(traffic_limit or 0),
server_ids,
int(devices_limit or 0),
user=user,
)
months = details.get("months_in_period") or calculate_months_from_days(period_days)
base_original_total = (
details.get("base_price_original", 0)
+ details.get("traffic_price_per_month", 0) * months
+ details.get("servers_price_per_month", 0) * months
+ details.get("devices_price_per_month", 0) * months
)
discounted_total = total_cost
monthly_additions = 0
if months > 0:
monthly_additions = (
details.get("total_servers_price", 0) // months
+ details.get("total_devices_price", 0) // months
+ details.get("total_traffic_price", 0) // months
)
if not validate_pricing_calculation(
details.get("base_price", 0),
monthly_additions,
months,
discounted_total,
):
logger.warning(
"Renewal pricing validation failed for subscription %s (period %s)",
subscription.id,
period_days,
)
from app.utils.promo_offer import get_user_active_promo_discount_percent
promo_percent = get_user_active_promo_discount_percent(user)
final_total = discounted_total
promo_discount_value = 0
if promo_percent > 0 and discounted_total > 0:
final_total, promo_discount_value = apply_percentage_discount(
discounted_total,
promo_percent,
)
overall_discount_value = max(0, base_original_total - final_total)
overall_discount_percent = 0
if base_original_total > 0 and overall_discount_value > 0:
overall_discount_percent = int(
round(overall_discount_value * 100 / base_original_total)
)
per_month = final_total // months if months else final_total
return SubscriptionRenewalPricing(
period_days=period_days,
period_id=build_renewal_period_id(period_days),
months=months,
base_original_total=base_original_total,
discounted_total=discounted_total,
final_total=final_total,
promo_discount_value=promo_discount_value,
promo_discount_percent=promo_percent if promo_discount_value else 0,
overall_discount_percent=overall_discount_percent,
per_month=per_month,
server_ids=list(server_ids),
details=details,
)
async def finalize(
self,
db: AsyncSession,
user: User,
subscription: Subscription,
pricing: SubscriptionRenewalPricing,
*,
charge_balance_amount: Optional[int] = None,
description: Optional[str] = None,
payment_method: Optional[PaymentMethod] = None,
) -> SubscriptionRenewalResult:
final_total = int(pricing.final_total)
if final_total < 0:
final_total = 0
period_days = int(pricing.period_days)
charge_from_balance = charge_balance_amount
if charge_from_balance is None:
charge_from_balance = final_total
charge_from_balance = max(0, min(charge_from_balance, final_total))
consume_promo_offer = bool(pricing.promo_discount_value)
description_text = description or f"Продление подписки на {period_days} дней"
if charge_from_balance > 0 or consume_promo_offer:
success = await subtract_user_balance(
db,
user,
charge_from_balance,
description_text,
consume_promo_offer=consume_promo_offer,
)
if not success:
raise SubscriptionRenewalChargeError("Failed to charge balance")
await db.refresh(user)
subscription_before = subscription
old_end_date = subscription_before.end_date
subscription_after = await extend_subscription(db, subscription_before, period_days)
server_ids = pricing.server_ids or []
server_prices_for_period = pricing.details.get("servers_individual_prices", [])
if server_ids:
try:
await add_subscription_servers(
db,
subscription_after,
server_ids,
server_prices_for_period,
)
except Exception as error: # pragma: no cover - defensive logging
logger.warning(
"Failed to record renewal server prices for subscription %s: %s",
subscription_after.id,
error,
)
subscription_service = SubscriptionService()
try:
await subscription_service.update_remnawave_user(
db,
subscription_after,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason="subscription renewal",
)
except RemnaWaveConfigurationError as error: # pragma: no cover - configuration issues
logger.warning("RemnaWave update skipped: %s", error)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"Failed to update RemnaWave user for subscription %s: %s",
subscription_after.id,
error,
)
transaction: Optional[Transaction] = None
try:
transaction = await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=final_total,
description=description_text,
payment_method=payment_method,
)
except Exception as error: # pragma: no cover - defensive logging
logger.warning(
"Failed to create renewal transaction for subscription %s: %s",
subscription_after.id,
error,
)
await db.refresh(user)
await db.refresh(subscription_after)
if transaction and old_end_date and subscription_after.end_date:
await with_admin_notification_service(
lambda service: service.send_subscription_extension_notification(
db,
user,
subscription_after,
transaction,
period_days,
old_end_date,
new_end_date=subscription_after.end_date,
balance_after=user.balance_kopeks,
)
)
return SubscriptionRenewalResult(
subscription=subscription_after,
transaction=transaction,
total_amount_kopeks=final_total,
charged_from_balance_kopeks=charge_from_balance,
old_end_date=old_end_date,
)
def build_option_payload(
self,
pricing: SubscriptionRenewalPricing,
*,
language: str,
) -> Dict[str, Any]:
label = format_period_description(pricing.period_days, language)
price_label = settings.format_price(pricing.final_total)
original_label = None
if (
pricing.base_original_total
and pricing.base_original_total != pricing.final_total
):
original_label = settings.format_price(pricing.base_original_total)
per_month_label = settings.format_price(pricing.per_month)
payload = {
"id": pricing.period_id,
"days": pricing.period_days,
"months": pricing.months,
"price_kopeks": pricing.final_total,
"price_label": price_label,
"original_price_kopeks": pricing.base_original_total,
"original_price_label": original_label,
"discount_percent": pricing.overall_discount_percent,
"price_per_month_kopeks": pricing.per_month,
"price_per_month_label": per_month_label,
"title": label,
}
return payload
def calculate_missing_amount(balance_kopeks: int, total_kopeks: int) -> int:
if total_kopeks <= 0:
return 0
if balance_kopeks <= 0:
return total_kopeks
return max(0, total_kopeks - min(balance_kopeks, total_kopeks))
+22 -1
View File
@@ -23,10 +23,30 @@ logger = logging.getLogger(__name__)
class TributeService: class TributeService:
_invoice_messages: Dict[int, Dict[str, int]] = {}
def __init__(self, bot: Bot): def __init__(self, bot: Bot):
self.bot = bot self.bot = bot
self.tribute_api = TributeAPI() self.tribute_api = TributeAPI()
@classmethod
def remember_invoice_message(cls, user_id: int, chat_id: int, message_id: int) -> None:
cls._invoice_messages[user_id] = {"chat_id": chat_id, "message_id": message_id}
async def _cleanup_invoice_message(self, user_id: int) -> None:
invoice_message = self._invoice_messages.pop(user_id, None)
if not invoice_message or not getattr(self, "bot", None):
return
chat_id = invoice_message.get("chat_id")
message_id = invoice_message.get("message_id")
if not chat_id or not message_id:
return
try:
await self.bot.delete_message(chat_id, message_id)
except Exception as error: # pragma: no cover - depends on bot rights
logger.warning("Не удалось удалить Tribute счёт %s: %s", message_id, error)
async def create_payment_link( async def create_payment_link(
self, self,
@@ -174,7 +194,8 @@ class TributeService:
) )
except Exception as e: except Exception as e:
logger.error(f"Ошибка отправки уведомления о Tribute пополнении: {e}") logger.error(f"Ошибка отправки уведомления о Tribute пополнении: {e}")
await self._cleanup_invoice_message(user_telegram_id)
await self._send_success_notification(user_telegram_id, amount_kopeks) await self._send_success_notification(user_telegram_id, amount_kopeks)
logger.info(f"🎉 Успешно обработан Tribute платеж: {amount_kopeks/100}₽ для пользователя {user_telegram_id}") logger.info(f"🎉 Успешно обработан Tribute платеж: {amount_kopeks/100}₽ для пользователя {user_telegram_id}")
+22 -1
View File
@@ -20,7 +20,7 @@ from app.database.crud.subscription import (
from app.database.models import ( from app.database.models import (
User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse, User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse,
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory, ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText, CryptoBotPayment, PlategaPayment, SubscriptionConversion, UserMessage, WelcomeText,
SentNotification, PromoGroup, MulenPayPayment, Pal24Payment, HeleketPayment, SentNotification, PromoGroup, MulenPayPayment, Pal24Payment, HeleketPayment,
AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod, AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod,
TransactionType TransactionType
@@ -732,6 +732,27 @@ class UserService:
except Exception as e: except Exception as e:
logger.error(f"❌ Ошибка удаления CryptoBot платежей: {e}") logger.error(f"❌ Ошибка удаления CryptoBot платежей: {e}")
try:
platega_result = await db.execute(
select(PlategaPayment).where(PlategaPayment.user_id == user_id)
)
platega_payments = platega_result.scalars().all()
if platega_payments:
logger.info(f"🔄 Удаляем {len(platega_payments)} Platega платежей")
await db.execute(
update(PlategaPayment)
.where(PlategaPayment.user_id == user_id)
.values(transaction_id=None)
)
await db.flush()
await db.execute(
delete(PlategaPayment).where(PlategaPayment.user_id == user_id)
)
await db.flush()
except Exception as e:
logger.error(f"❌ Ошибка удаления Platega платежей: {e}")
try: try:
mulenpay_result = await db.execute( mulenpay_result = await db.execute(
select(MulenPayPayment).where(MulenPayPayment.user_id == user_id) select(MulenPayPayment).where(MulenPayPayment.user_id == user_id)
+28
View File
@@ -24,6 +24,7 @@ from ..schemas.logs import (
SupportAuditLogEntry, SupportAuditLogEntry,
SupportAuditLogListResponse, SupportAuditLogListResponse,
SystemLogPreviewResponse, SystemLogPreviewResponse,
SystemLogFullResponse,
) )
router = APIRouter() router = APIRouter()
@@ -141,6 +142,33 @@ async def download_system_log(
raise HTTPException(status_code=500, detail="Не удалось отправить лог-файл") from error raise HTTPException(status_code=500, detail="Не удалось отправить лог-файл") from error
@router.get("/system/full", response_model=SystemLogFullResponse)
async def get_system_log_full(
_: Any = Security(require_api_token),
) -> SystemLogFullResponse:
"""Получить полный системный лог-файл бота."""
log_path = _resolve_system_log_path()
if not log_path.exists() or not log_path.is_file():
raise HTTPException(status_code=404, detail="Лог-файл не найден")
try:
content, size_bytes, mtime = await _read_system_log(log_path)
except Exception as error: # pragma: no cover - защита от неожиданных ошибок чтения
logger.error("Ошибка чтения лог-файла %s: %s", log_path, error)
raise HTTPException(status_code=500, detail="Не удалось прочитать лог-файл") from error
return SystemLogFullResponse(
path=str(log_path),
exists=True,
updated_at=_format_timestamp(mtime),
size_bytes=size_bytes,
size_chars=len(content),
content=content,
)
@router.get("/monitoring", response_model=MonitoringLogListResponse) @router.get("/monitoring", response_model=MonitoringLogListResponse)
async def list_monitoring_logs( async def list_monitoring_logs(
_: Any = Security(require_api_token), _: Any = Security(require_api_token),
+360 -267
View File
@@ -3,13 +3,14 @@ from __future__ import annotations
import logging import logging
import re import re
import math import math
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP, ROUND_FLOOR from decimal import Decimal, InvalidOperation, ROUND_HALF_UP, ROUND_FLOOR, ROUND_UP
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from uuid import uuid4 from uuid import uuid4
from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple, Union from typing import Any, Callable, Collection, Dict, List, Optional, Tuple, Union
from aiogram import Bot from aiogram import Bot
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import ValidationError
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -27,13 +28,11 @@ from app.database.crud.promo_offer_template import get_promo_offer_template_by_i
from app.database.crud.server_squad import ( from app.database.crud.server_squad import (
add_user_to_servers, add_user_to_servers,
get_available_server_squads, get_available_server_squads,
get_server_ids_by_uuids,
get_server_squad_by_uuid, get_server_squad_by_uuid,
remove_user_from_servers, remove_user_from_servers,
) )
from app.database.crud.subscription import ( from app.database.crud.subscription import (
add_subscription_servers, add_subscription_servers,
calculate_subscription_total_cost,
create_trial_subscription, create_trial_subscription,
extend_subscription, extend_subscription,
remove_subscription_servers, remove_subscription_servers,
@@ -54,7 +53,6 @@ from app.database.models import (
PaymentMethod, PaymentMethod,
User, User,
) )
from app.services.admin_notification_service import AdminNotificationService
from app.services.faq_service import FaqService from app.services.faq_service import FaqService
from app.services.privacy_policy_service import PrivacyPolicyService from app.services.privacy_policy_service import PrivacyPolicyService
from app.services.public_offer_service import PublicOfferService from app.services.public_offer_service import PublicOfferService
@@ -67,6 +65,16 @@ from app.services.payment_service import PaymentService, get_wata_payment_by_lin
from app.services.promo_offer_service import promo_offer_service from app.services.promo_offer_service import promo_offer_service
from app.services.promocode_service import PromoCodeService from app.services.promocode_service import PromoCodeService
from app.services.subscription_service import SubscriptionService from app.services.subscription_service import SubscriptionService
from app.services.subscription_renewal_service import (
SubscriptionRenewalChargeError,
SubscriptionRenewalService,
build_payment_descriptor,
build_renewal_period_id,
decode_payment_payload,
calculate_missing_amount,
encode_payment_payload,
with_admin_notification_service,
)
from app.services.trial_activation_service import ( from app.services.trial_activation_service import (
TrialPaymentChargeFailed, TrialPaymentChargeFailed,
TrialPaymentInsufficientFunds, TrialPaymentInsufficientFunds,
@@ -93,11 +101,9 @@ from app.utils.user_utils import (
) )
from app.utils.pricing_utils import ( from app.utils.pricing_utils import (
apply_percentage_discount, apply_percentage_discount,
calculate_months_from_days,
calculate_prorated_price, calculate_prorated_price,
format_period_description, format_period_description,
get_remaining_months, get_remaining_months,
validate_pricing_calculation,
) )
from app.utils.promo_offer import get_user_active_promo_discount_percent from app.utils.promo_offer import get_user_active_promo_discount_percent
@@ -113,6 +119,8 @@ from ..schemas.miniapp import (
MiniAppLegalDocuments, MiniAppLegalDocuments,
MiniAppPaymentCreateRequest, MiniAppPaymentCreateRequest,
MiniAppPaymentCreateResponse, MiniAppPaymentCreateResponse,
MiniAppPaymentIframeConfig,
MiniAppPaymentIntegrationType,
MiniAppPaymentMethod, MiniAppPaymentMethod,
MiniAppPaymentMethodsRequest, MiniAppPaymentMethodsRequest,
MiniAppPaymentMethodsResponse, MiniAppPaymentMethodsResponse,
@@ -177,27 +185,7 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
promo_code_service = PromoCodeService() promo_code_service = PromoCodeService()
renewal_service = SubscriptionRenewalService()
async def _with_admin_notification_service(
handler: Callable[[AdminNotificationService], Awaitable[Any]],
) -> None:
if not getattr(settings, "ADMIN_NOTIFICATIONS_ENABLED", False):
return
if not settings.BOT_TOKEN:
logger.debug("Skipping admin notification: bot token is not configured")
return
bot: Bot | None = None
try:
bot = Bot(token=settings.BOT_TOKEN)
service = AdminNotificationService(bot)
await handler(service)
except Exception as error: # pragma: no cover - defensive logging
logger.error("Failed to send admin notification from miniapp: %s", error)
finally:
if bot:
await bot.session.close()
_CRYPTOBOT_MIN_USD = 1.0 _CRYPTOBOT_MIN_USD = 1.0
@@ -624,6 +612,18 @@ def _normalize_amount_kopeks(
return normalized if normalized >= 0 else None return normalized if normalized >= 0 else None
def _build_mulenpay_iframe_config() -> Optional[MiniAppPaymentIframeConfig]:
expected_origin = settings.get_mulenpay_expected_origin()
if not expected_origin:
return None
try:
return MiniAppPaymentIframeConfig(expected_origin=expected_origin)
except ValidationError as error: # pragma: no cover - defensive logging
logger.error("Invalid MulenPay expected origin '%s': %s", expected_origin, error)
return None
@router.post( @router.post(
"/payments/methods", "/payments/methods",
response_model=MiniAppPaymentMethodsResponse, response_model=MiniAppPaymentMethodsResponse,
@@ -646,6 +646,7 @@ async def get_payment_methods(
currency="RUB", currency="RUB",
min_amount_kopeks=stars_min_amount, min_amount_kopeks=stars_min_amount,
amount_step_kopeks=stars_min_amount, amount_step_kopeks=stars_min_amount,
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
) )
) )
@@ -659,6 +660,7 @@ async def get_payment_methods(
currency="RUB", currency="RUB",
min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS, min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS,
max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS, max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS,
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
) )
) )
@@ -670,10 +672,17 @@ async def get_payment_methods(
currency="RUB", currency="RUB",
min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS, min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS,
max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS, max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS,
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
) )
) )
if settings.is_mulenpay_enabled(): if settings.is_mulenpay_enabled():
mulenpay_iframe_config = _build_mulenpay_iframe_config()
mulenpay_integration = (
MiniAppPaymentIntegrationType.IFRAME
if mulenpay_iframe_config
else MiniAppPaymentIntegrationType.REDIRECT
)
methods.append( methods.append(
MiniAppPaymentMethod( MiniAppPaymentMethod(
id="mulenpay", id="mulenpay",
@@ -683,6 +692,8 @@ async def get_payment_methods(
currency="RUB", currency="RUB",
min_amount_kopeks=settings.MULENPAY_MIN_AMOUNT_KOPEKS, min_amount_kopeks=settings.MULENPAY_MIN_AMOUNT_KOPEKS,
max_amount_kopeks=settings.MULENPAY_MAX_AMOUNT_KOPEKS, max_amount_kopeks=settings.MULENPAY_MAX_AMOUNT_KOPEKS,
integration_type=mulenpay_integration,
iframe_config=mulenpay_iframe_config,
) )
) )
@@ -695,6 +706,7 @@ async def get_payment_methods(
currency="RUB", currency="RUB",
min_amount_kopeks=settings.PAL24_MIN_AMOUNT_KOPEKS, min_amount_kopeks=settings.PAL24_MIN_AMOUNT_KOPEKS,
max_amount_kopeks=settings.PAL24_MAX_AMOUNT_KOPEKS, max_amount_kopeks=settings.PAL24_MAX_AMOUNT_KOPEKS,
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
) )
) )
@@ -707,6 +719,7 @@ async def get_payment_methods(
currency="RUB", currency="RUB",
min_amount_kopeks=settings.WATA_MIN_AMOUNT_KOPEKS, min_amount_kopeks=settings.WATA_MIN_AMOUNT_KOPEKS,
max_amount_kopeks=settings.WATA_MAX_AMOUNT_KOPEKS, max_amount_kopeks=settings.WATA_MAX_AMOUNT_KOPEKS,
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
) )
) )
@@ -721,6 +734,7 @@ async def get_payment_methods(
currency="RUB", currency="RUB",
min_amount_kopeks=min_amount_kopeks, min_amount_kopeks=min_amount_kopeks,
max_amount_kopeks=max_amount_kopeks, max_amount_kopeks=max_amount_kopeks,
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
) )
) )
@@ -733,6 +747,7 @@ async def get_payment_methods(
currency="RUB", currency="RUB",
min_amount_kopeks=100 * 100, min_amount_kopeks=100 * 100,
max_amount_kopeks=100_000 * 100, max_amount_kopeks=100_000 * 100,
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
) )
) )
@@ -743,6 +758,7 @@ async def get_payment_methods(
icon="💎", icon="💎",
requires_amount=False, requires_amount=False,
currency="RUB", currency="RUB",
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
) )
) )
@@ -1613,6 +1629,9 @@ async def _resolve_cryptobot_payment_status(
except (InvalidOperation, TypeError): except (InvalidOperation, TypeError):
amount_kopeks = None amount_kopeks = None
descriptor = decode_payment_payload(getattr(payment, "payload", "") or "", expected_user_id=user.id)
purpose = "subscription_renewal" if descriptor else "balance_topup"
return MiniAppPaymentStatusResult( return MiniAppPaymentStatusResult(
method="cryptobot", method="cryptobot",
status=status, status=status,
@@ -1629,6 +1648,9 @@ async def _resolve_cryptobot_payment_status(
"invoice_id": payment.invoice_id, "invoice_id": payment.invoice_id,
"payload": query.payload, "payload": query.payload,
"started_at": query.started_at, "started_at": query.started_at,
"purpose": purpose,
"subscription_id": descriptor.subscription_id if descriptor else None,
"period_days": descriptor.period_days if descriptor else None,
}, },
) )
@@ -3337,7 +3359,7 @@ async def activate_subscription_trial_endpoint(
else: else:
message = f"{message}\n\n💳 {charged_amount_label} has been deducted from your balance." message = f"{message}\n\n💳 {charged_amount_label} has been deducted from your balance."
await _with_admin_notification_service( await with_admin_notification_service(
lambda service: service.send_trial_activation_notification( lambda service: service.send_trial_activation_notification(
db, db,
user, user,
@@ -3772,10 +3794,93 @@ def _build_promo_offer_payload(user: Optional[User]) -> Optional[Dict[str, Any]]
return payload return payload
def _build_renewal_period_id(period_days: int) -> str: def _format_payment_method_title(method: str) -> str:
return f"days:{period_days}" mapping = {
"cryptobot": "CryptoBot",
"yookassa": "YooKassa",
"yookassa_sbp": "YooKassa СБП",
"mulenpay": "MulenPay",
"pal24": "Pal24",
"wata": "WataPay",
"heleket": "Heleket",
"tribute": "Tribute",
"stars": "Telegram Stars",
}
key = (method or "").lower()
return mapping.get(key, method.title() if method else "")
def _build_renewal_success_message(
user: User,
subscription: Subscription,
charged_amount: int,
promo_discount_value: int = 0,
) -> str:
language_code = _normalize_language_code(user)
amount_label = settings.format_price(max(0, charged_amount))
date_label = (
format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M")
if subscription.end_date
else ""
)
if language_code == "ru":
if charged_amount > 0:
message = (
f"Подписка продлена до {date_label}. " if date_label else "Подписка продлена. "
) + f"Списано {amount_label}."
else:
message = (
f"Подписка продлена до {date_label}."
if date_label
else "Подписка успешно продлена."
)
else:
if charged_amount > 0:
message = (
f"Subscription renewed until {date_label}. " if date_label else "Subscription renewed. "
) + f"Charged {amount_label}."
else:
message = (
f"Subscription renewed until {date_label}."
if date_label
else "Subscription renewed successfully."
)
if promo_discount_value > 0:
discount_label = settings.format_price(promo_discount_value)
if language_code == "ru":
message += f" Применена дополнительная скидка {discount_label}."
else:
message += f" Promo discount applied: {discount_label}."
return message
def _build_renewal_pending_message(
user: User,
missing_amount: int,
method: str,
) -> str:
language_code = _normalize_language_code(user)
amount_label = settings.format_price(max(0, missing_amount))
method_title = _format_payment_method_title(method)
if language_code == "ru":
if method_title:
return (
f"Недостаточно средств на балансе. Доплатите {amount_label} через {method_title}, "
"чтобы завершить продление."
)
return (
f"Недостаточно средств на балансе. Доплатите {amount_label}, чтобы завершить продление."
)
if method_title:
return (
f"Not enough balance. Pay the remaining {amount_label} via {method_title} to finish the renewal."
)
return f"Not enough balance. Pay the remaining {amount_label} to finish the renewal."
def _parse_period_identifier(identifier: Optional[str]) -> Optional[int]: def _parse_period_identifier(identifier: Optional[str]) -> Optional[int]:
if not identifier: if not identifier:
return None return None
@@ -3795,95 +3900,14 @@ async def _calculate_subscription_renewal_pricing(
user: User, user: User,
subscription: Subscription, subscription: Subscription,
period_days: int, period_days: int,
) -> Dict[str, Any]: ):
connected_uuids = [str(uuid) for uuid in list(subscription.connected_squads or [])] return await renewal_service.calculate_pricing(
server_ids: List[int] = []
if connected_uuids:
server_ids = await get_server_ids_by_uuids(db, connected_uuids)
traffic_limit = subscription.traffic_limit_gb
if traffic_limit is None:
traffic_limit = settings.DEFAULT_TRAFFIC_LIMIT_GB
devices_limit = subscription.device_limit
if devices_limit is None:
devices_limit = settings.DEFAULT_DEVICE_LIMIT
total_cost, details = await calculate_subscription_total_cost(
db, db,
user,
subscription,
period_days, period_days,
int(traffic_limit or 0),
server_ids,
int(devices_limit or 0),
user=user,
) )
months = details.get("months_in_period") or calculate_months_from_days(period_days)
base_original_total = (
details.get("base_price_original", 0)
+ details.get("traffic_price_per_month", 0) * months
+ details.get("servers_price_per_month", 0) * months
+ details.get("devices_price_per_month", 0) * months
)
discounted_total = total_cost
monthly_additions = 0
if months > 0:
monthly_additions = (
details.get("total_servers_price", 0) // months
+ details.get("total_devices_price", 0) // months
+ details.get("total_traffic_price", 0) // months
)
if not validate_pricing_calculation(
details.get("base_price", 0),
monthly_additions,
months,
discounted_total,
):
logger.warning(
"Renewal pricing validation failed for subscription %s (period %s)",
subscription.id,
period_days,
)
promo_percent = get_user_active_promo_discount_percent(user)
final_total = discounted_total
promo_discount_value = 0
if promo_percent > 0 and discounted_total > 0:
final_total, promo_discount_value = apply_percentage_discount(
discounted_total,
promo_percent,
)
overall_discount_value = max(0, base_original_total - final_total)
overall_discount_percent = 0
if base_original_total > 0 and overall_discount_value > 0:
overall_discount_percent = int(
round(overall_discount_value * 100 / base_original_total)
)
per_month = final_total // months if months else final_total
pricing_payload: Dict[str, Any] = {
"period_id": _build_renewal_period_id(period_days),
"period_days": period_days,
"months": months,
"base_original_total": base_original_total,
"discounted_total": discounted_total,
"final_total": final_total,
"promo_discount_value": promo_discount_value,
"promo_discount_percent": promo_percent if promo_discount_value else 0,
"overall_discount_percent": overall_discount_percent,
"per_month": per_month,
"server_ids": list(server_ids),
"details": details,
}
return pricing_payload
async def _prepare_subscription_renewal_options( async def _prepare_subscription_renewal_options(
db: AsyncSession, db: AsyncSession,
@@ -3898,12 +3922,13 @@ async def _prepare_subscription_renewal_options(
for period_days in available_periods: for period_days in available_periods:
try: try:
pricing = await _calculate_subscription_renewal_pricing( pricing_model = await _calculate_subscription_renewal_pricing(
db, db,
user, user,
subscription, subscription,
period_days, period_days,
) )
pricing = pricing_model.to_payload()
except Exception as error: # pragma: no cover - defensive logging except Exception as error: # pragma: no cover - defensive logging
logger.warning( logger.warning(
"Failed to calculate renewal pricing for subscription %s (period %s): %s", "Failed to calculate renewal pricing for subscription %s (period %s): %s",
@@ -4077,7 +4102,11 @@ async def _authorize_miniapp_user(
return user return user
def _ensure_paid_subscription(user: User) -> Subscription: def _ensure_paid_subscription(
user: User,
*,
allowed_statuses: Optional[Collection[str]] = None,
) -> Subscription:
subscription = getattr(user, "subscription", None) subscription = getattr(user, "subscription", None)
if not subscription: if not subscription:
raise HTTPException( raise HTTPException(
@@ -4085,7 +4114,9 @@ def _ensure_paid_subscription(user: User) -> Subscription:
detail={"code": "subscription_not_found", "message": "Subscription not found"}, detail={"code": "subscription_not_found", "message": "Subscription not found"},
) )
if getattr(subscription, "is_trial", False): normalized_allowed_statuses = set(allowed_statuses or {"active"})
if getattr(subscription, "is_trial", False) and "trial" not in normalized_allowed_statuses:
raise HTTPException( raise HTTPException(
status.HTTP_403_FORBIDDEN, status.HTTP_403_FORBIDDEN,
detail={ detail={
@@ -4094,7 +4125,28 @@ def _ensure_paid_subscription(user: User) -> Subscription:
}, },
) )
if not getattr(subscription, "is_active", False): actual_status = getattr(subscription, "actual_status", None) or ""
if actual_status not in normalized_allowed_statuses:
if actual_status == "trial":
detail = {
"code": "paid_subscription_required",
"message": "This action is available only for paid subscriptions",
}
elif actual_status == "disabled":
detail = {
"code": "subscription_disabled",
"message": "Subscription is disabled",
}
else:
detail = {
"code": "subscription_inactive",
"message": "Subscription must be active to manage settings",
}
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=detail)
if not getattr(subscription, "is_active", False) and "expired" not in normalized_allowed_statuses:
raise HTTPException( raise HTTPException(
status.HTTP_403_FORBIDDEN, status.HTTP_403_FORBIDDEN,
detail={ detail={
@@ -4367,7 +4419,10 @@ async def get_subscription_renewal_options_endpoint(
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
) -> MiniAppSubscriptionRenewalOptionsResponse: ) -> MiniAppSubscriptionRenewalOptionsResponse:
user = await _authorize_miniapp_user(payload.init_data, db) user = await _authorize_miniapp_user(payload.init_data, db)
subscription = _ensure_paid_subscription(user) subscription = _ensure_paid_subscription(
user,
allowed_statuses={"active", "trial", "expired"},
)
_validate_subscription_id(payload.subscription_id, subscription) _validate_subscription_id(payload.subscription_id, subscription)
periods, pricing_map, default_period_id = await _prepare_subscription_renewal_options( periods, pricing_map, default_period_id = await _prepare_subscription_renewal_options(
@@ -4446,7 +4501,10 @@ async def submit_subscription_renewal_endpoint(
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
) -> MiniAppSubscriptionRenewalResponse: ) -> MiniAppSubscriptionRenewalResponse:
user = await _authorize_miniapp_user(payload.init_data, db) user = await _authorize_miniapp_user(payload.init_data, db)
subscription = _ensure_paid_subscription(user) subscription = _ensure_paid_subscription(
user,
allowed_statuses={"active", "trial", "expired"},
)
_validate_subscription_id(payload.subscription_id, subscription) _validate_subscription_id(payload.subscription_id, subscription)
period_days: Optional[int] = None period_days: Optional[int] = None
@@ -4477,8 +4535,10 @@ async def submit_subscription_renewal_endpoint(
detail={"code": "period_unavailable", "message": "Selected renewal period is not available"}, detail={"code": "period_unavailable", "message": "Selected renewal period is not available"},
) )
method = (payload.method or "").strip().lower()
try: try:
pricing = await _calculate_subscription_renewal_pricing( pricing_model = await _calculate_subscription_renewal_pricing(
db, db,
user, user,
subscription, subscription,
@@ -4498,156 +4558,177 @@ async def submit_subscription_renewal_endpoint(
detail={"code": "pricing_failed", "message": "Failed to calculate renewal pricing"}, detail={"code": "pricing_failed", "message": "Failed to calculate renewal pricing"},
) from error ) from error
final_total = int(pricing.get("final_total") or 0) pricing = pricing_model.to_payload()
final_total = int(pricing_model.final_total)
balance_kopeks = getattr(user, "balance_kopeks", 0) balance_kopeks = getattr(user, "balance_kopeks", 0)
missing_amount = calculate_missing_amount(balance_kopeks, final_total)
if final_total > 0 and balance_kopeks < final_total:
missing = final_total - balance_kopeks
raise HTTPException(
status.HTTP_402_PAYMENT_REQUIRED,
detail={
"code": "insufficient_funds",
"message": "Not enough funds to renew the subscription",
"missing_amount_kopeks": missing,
},
)
consume_promo_offer = bool(pricing.get("promo_discount_value"))
description = f"Продление подписки на {period_days} дней" description = f"Продление подписки на {period_days} дней"
old_end_date = subscription.end_date
if final_total > 0 or consume_promo_offer: if missing_amount <= 0:
success = await subtract_user_balance(
db,
user,
final_total,
description,
consume_promo_offer=consume_promo_offer,
)
if not success:
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"code": "charge_failed", "message": "Failed to charge balance"},
)
await db.refresh(user)
subscription = await extend_subscription(db, subscription, period_days)
server_ids = pricing.get("server_ids") or []
server_prices_for_period = pricing.get("details", {}).get(
"servers_individual_prices",
[],
)
if server_ids:
try: try:
await add_subscription_servers( result = await renewal_service.finalize(
db,
subscription,
server_ids,
server_prices_for_period,
)
except Exception as error: # pragma: no cover - defensive logging
logger.warning(
"Failed to record renewal server prices for subscription %s: %s",
subscription.id,
error,
)
subscription_service = SubscriptionService()
try:
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason="subscription renewal",
)
except RemnaWaveConfigurationError as error: # pragma: no cover - configuration issues
logger.warning("RemnaWave update skipped: %s", error)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"Failed to update RemnaWave user for subscription %s: %s",
subscription.id,
error,
)
transaction: Optional[Transaction] = None
try:
transaction = await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=final_total,
description=description,
)
except Exception as error: # pragma: no cover - defensive logging
logger.warning(
"Failed to create renewal transaction for subscription %s: %s",
subscription.id,
error,
)
await db.refresh(user)
await db.refresh(subscription)
if transaction and old_end_date and subscription.end_date:
await _with_admin_notification_service(
lambda service: service.send_subscription_extension_notification(
db, db,
user, user,
subscription, subscription,
transaction, pricing_model,
period_days, description=description,
old_end_date,
new_end_date=subscription.end_date,
balance_after=user.balance_kopeks,
) )
except SubscriptionRenewalChargeError as error:
logger.error(
"Failed to charge balance for subscription renewal %s: %s",
subscription.id,
error,
)
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"code": "charge_failed", "message": "Failed to charge balance"},
) from error
updated_subscription = result.subscription
message = _build_renewal_success_message(
user,
updated_subscription,
result.total_amount_kopeks,
pricing_model.promo_discount_value,
) )
language_code = _normalize_language_code(user) return MiniAppSubscriptionRenewalResponse(
amount_label = settings.format_price(final_total) message=message,
date_label = ( balance_kopeks=user.balance_kopeks,
format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M") balance_label=settings.format_price(user.balance_kopeks),
if subscription.end_date subscription_id=updated_subscription.id,
else "" renewed_until=updated_subscription.end_date,
) )
if language_code == "ru": if not method:
if final_total > 0: if final_total > 0 and balance_kopeks < final_total:
message = ( missing = final_total - balance_kopeks
f"Подписка продлена до {date_label}. " if date_label else "Подписка продлена. " raise HTTPException(
) + f"Списано {amount_label}." status.HTTP_402_PAYMENT_REQUIRED,
else: detail={
message = ( "code": "insufficient_funds",
f"Подписка продлена до {date_label}." "message": "Not enough funds to renew the subscription",
if date_label "missing_amount_kopeks": missing,
else "Подписка успешно продлена." },
)
else:
if final_total > 0:
message = (
f"Subscription renewed until {date_label}. " if date_label else "Subscription renewed. "
) + f"Charged {amount_label}."
else:
message = (
f"Subscription renewed until {date_label}."
if date_label
else "Subscription renewed successfully."
) )
promo_discount_value = pricing.get("promo_discount_value") or 0 raise HTTPException(
if consume_promo_offer and promo_discount_value > 0: status.HTTP_400_BAD_REQUEST,
discount_label = settings.format_price(promo_discount_value) detail={
if language_code == "ru": "code": "payment_method_required",
message += f" Применена дополнительная скидка {discount_label}." "message": "Payment method is required when balance is insufficient",
else: },
message += f" Promo discount applied: {discount_label}." )
return MiniAppSubscriptionRenewalResponse( supported_methods = {"cryptobot"}
message=message, if method not in supported_methods:
balance_kopeks=user.balance_kopeks, raise HTTPException(
balance_label=settings.format_price(user.balance_kopeks), status.HTTP_400_BAD_REQUEST,
subscription_id=subscription.id, detail={"code": "unsupported_method", "message": "Payment method is not supported for renewal"},
renewed_until=subscription.end_date, )
if method == "cryptobot":
if not settings.is_cryptobot_enabled():
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
rate = await _get_usd_to_rub_rate()
min_amount_kopeks, max_amount_kopeks = _compute_cryptobot_limits(rate)
if missing_amount < min_amount_kopeks:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail={
"code": "amount_below_minimum",
"message": f"Amount is below minimum ({min_amount_kopeks / 100:.2f} RUB)",
},
)
if missing_amount > max_amount_kopeks:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail={
"code": "amount_above_maximum",
"message": f"Amount exceeds maximum ({max_amount_kopeks / 100:.2f} RUB)",
},
)
try:
decimal_amount = (Decimal(missing_amount) / Decimal(100) / Decimal(str(rate)))
amount_usd = float(
decimal_amount.quantize(Decimal("0.01"), rounding=ROUND_UP)
)
except (InvalidOperation, ValueError) as error:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail={"code": "conversion_failed", "message": "Unable to convert amount to USD"},
) from error
if amount_usd <= 0:
amount_usd = float(
decimal_amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
)
descriptor = build_payment_descriptor(
user.id,
subscription.id,
period_days,
final_total,
missing_amount,
pricing_snapshot=pricing,
)
payload_value = encode_payment_payload(descriptor)
payment_service = PaymentService()
result = await payment_service.create_cryptobot_payment(
db=db,
user_id=user.id,
amount_usd=amount_usd,
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
description=description,
payload=payload_value,
)
if not result:
raise HTTPException(
status.HTTP_502_BAD_GATEWAY,
detail={"code": "payment_creation_failed", "message": "Failed to create payment"},
)
payment_url = (
result.get("mini_app_invoice_url")
or result.get("bot_invoice_url")
or result.get("web_app_invoice_url")
)
if not payment_url:
raise HTTPException(
status.HTTP_502_BAD_GATEWAY,
detail={"code": "payment_url_missing", "message": "Failed to obtain payment url"},
)
extra_payload = {
"bot_invoice_url": result.get("bot_invoice_url"),
"mini_app_invoice_url": result.get("mini_app_invoice_url"),
"web_app_invoice_url": result.get("web_app_invoice_url"),
}
message = _build_renewal_pending_message(user, missing_amount, method)
return MiniAppSubscriptionRenewalResponse(
success=False,
message=message,
balance_kopeks=user.balance_kopeks,
balance_label=settings.format_price(user.balance_kopeks),
subscription_id=subscription.id,
requires_payment=True,
payment_method=method,
payment_url=payment_url,
payment_amount_kopeks=missing_amount,
payment_id=result.get("local_payment_id"),
invoice_id=result.get("invoice_id"),
payment_payload=payload_value,
payment_extra={key: value for key, value in extra_payload.items() if value},
)
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail={"code": "unsupported_method", "message": "Payment method is not supported for renewal"},
) )
@@ -4760,7 +4841,7 @@ async def subscription_purchase_endpoint(
pass pass
if subscription and transaction and period_days: if subscription and transaction and period_days:
await _with_admin_notification_service( await with_admin_notification_service(
lambda service: service.send_subscription_purchase_notification( lambda service: service.send_subscription_purchase_notification(
db, db,
user, user,
@@ -4790,7 +4871,10 @@ async def get_subscription_settings_endpoint(
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
) -> MiniAppSubscriptionSettingsResponse: ) -> MiniAppSubscriptionSettingsResponse:
user = await _authorize_miniapp_user(payload.init_data, db) user = await _authorize_miniapp_user(payload.init_data, db)
subscription = _ensure_paid_subscription(user) subscription = _ensure_paid_subscription(
user,
allowed_statuses={"active", "trial"},
)
_validate_subscription_id(payload.subscription_id, subscription) _validate_subscription_id(payload.subscription_id, subscription)
settings_payload = await _build_subscription_settings(db, user, subscription) settings_payload = await _build_subscription_settings(db, user, subscription)
@@ -4807,7 +4891,10 @@ async def update_subscription_servers_endpoint(
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
) -> MiniAppSubscriptionUpdateResponse: ) -> MiniAppSubscriptionUpdateResponse:
user = await _authorize_miniapp_user(payload.init_data, db) user = await _authorize_miniapp_user(payload.init_data, db)
subscription = _ensure_paid_subscription(user) subscription = _ensure_paid_subscription(
user,
allowed_statuses={"active", "trial"},
)
_validate_subscription_id(payload.subscription_id, subscription) _validate_subscription_id(payload.subscription_id, subscription)
old_servers = list(getattr(subscription, "connected_squads", []) or []) old_servers = list(getattr(subscription, "connected_squads", []) or [])
@@ -4989,7 +5076,7 @@ async def update_subscription_servers_endpoint(
service = SubscriptionService() service = SubscriptionService()
await service.update_remnawave_user(db, subscription) await service.update_remnawave_user(db, subscription)
await _with_admin_notification_service( await with_admin_notification_service(
lambda service: service.send_subscription_update_notification( lambda service: service.send_subscription_update_notification(
db, db,
user, user,
@@ -5013,7 +5100,10 @@ async def update_subscription_traffic_endpoint(
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
) -> MiniAppSubscriptionUpdateResponse: ) -> MiniAppSubscriptionUpdateResponse:
user = await _authorize_miniapp_user(payload.init_data, db) user = await _authorize_miniapp_user(payload.init_data, db)
subscription = _ensure_paid_subscription(user) subscription = _ensure_paid_subscription(
user,
allowed_statuses={"active", "trial"},
)
_validate_subscription_id(payload.subscription_id, subscription) _validate_subscription_id(payload.subscription_id, subscription)
old_traffic = subscription.traffic_limit_gb old_traffic = subscription.traffic_limit_gb
@@ -5153,7 +5243,7 @@ async def update_subscription_traffic_endpoint(
service = SubscriptionService() service = SubscriptionService()
await service.update_remnawave_user(db, subscription) await service.update_remnawave_user(db, subscription)
await _with_admin_notification_service( await with_admin_notification_service(
lambda service: service.send_subscription_update_notification( lambda service: service.send_subscription_update_notification(
db, db,
user, user,
@@ -5177,7 +5267,10 @@ async def update_subscription_devices_endpoint(
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
) -> MiniAppSubscriptionUpdateResponse: ) -> MiniAppSubscriptionUpdateResponse:
user = await _authorize_miniapp_user(payload.init_data, db) user = await _authorize_miniapp_user(payload.init_data, db)
subscription = _ensure_paid_subscription(user) subscription = _ensure_paid_subscription(
user,
allowed_statuses={"active", "trial"},
)
_validate_subscription_id(payload.subscription_id, subscription) _validate_subscription_id(payload.subscription_id, subscription)
raw_value = payload.devices if payload.devices is not None else payload.device_limit raw_value = payload.devices if payload.devices is not None else payload.device_limit
@@ -5304,7 +5397,7 @@ async def update_subscription_devices_endpoint(
service = SubscriptionService() service = SubscriptionService()
await service.update_remnawave_user(db, subscription) await service.update_remnawave_user(db, subscription)
await _with_admin_notification_service( await with_admin_notification_service(
lambda service: service.send_subscription_update_notification( lambda service: service.send_subscription_update_notification(
db, db,
user, user,
+59
View File
@@ -2,6 +2,9 @@ from __future__ import annotations
from typing import Any from typing import Any
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import ( from fastapi import (
APIRouter, APIRouter,
Depends, Depends,
@@ -23,6 +26,8 @@ from app.database.crud.poll import (
get_poll_statistics, get_poll_statistics,
) )
from app.database.models import Poll, PollAnswer, PollOption, PollQuestion, PollResponse from app.database.models import Poll, PollAnswer, PollOption, PollQuestion, PollResponse
from app.handlers.admin.messages import get_custom_users, get_target_users
from app.services.poll_service import send_poll_to_users
from ..dependencies import get_db_session, require_api_token from ..dependencies import get_db_session, require_api_token
from ..schemas.polls import ( from ..schemas.polls import (
@@ -38,6 +43,8 @@ from ..schemas.polls import (
PollStatisticsResponse, PollStatisticsResponse,
PollSummaryResponse, PollSummaryResponse,
PollUserResponse, PollUserResponse,
PollSendRequest,
PollSendResponse,
) )
router = APIRouter() router = APIRouter()
@@ -306,3 +313,55 @@ async def get_poll_responses(
limit=limit, limit=limit,
offset=offset, offset=offset,
) )
@router.post("/{poll_id}/send", response_model=PollSendResponse)
async def send_poll(
poll_id: int,
payload: PollSendRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PollSendResponse:
poll = await get_poll_by_id(db, poll_id)
if not poll:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found")
target = payload.target.strip()
if not target:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Target must not be empty")
if target.startswith("custom_"):
users = await get_custom_users(db, target.replace("custom_", ""))
else:
users = await get_target_users(db, target)
if not users:
return PollSendResponse(
poll_id=poll_id,
target=target,
sent=0,
failed=0,
skipped=0,
total=0,
)
from app.config import settings
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
try:
result = await send_poll_to_users(bot, db, poll, users)
finally:
await bot.session.close()
return PollSendResponse(
poll_id=poll_id,
target=target,
sent=result.get("sent", 0),
failed=result.get("failed", 0),
skipped=result.get("skipped", 0),
total=result.get("total", 0),
)
+78 -18
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import Any, Optional from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
@@ -8,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.config import settings from app.config import settings
from app.database.crud.server_squad import get_random_trial_squad_uuid
from app.database.crud.subscription import ( from app.database.crud.subscription import (
add_subscription_devices, add_subscription_devices,
add_subscription_squad, add_subscription_squad,
@@ -16,6 +18,7 @@ from app.database.crud.subscription import (
create_trial_subscription, create_trial_subscription,
extend_subscription, extend_subscription,
get_subscription_by_user_id, get_subscription_by_user_id,
replace_subscription,
remove_subscription_squad, remove_subscription_squad,
) )
from app.database.models import Subscription, SubscriptionStatus from app.database.models import Subscription, SubscriptionStatus
@@ -30,6 +33,8 @@ from ..schemas.subscriptions import (
SubscriptionTrafficRequest, SubscriptionTrafficRequest,
) )
logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -55,6 +60,28 @@ def _serialize_subscription(subscription: Subscription) -> SubscriptionResponse:
) )
async def _choose_trial_squads(
db: AsyncSession, requested_squad_uuid: Optional[str], fallback_squads: list[str]
) -> list[str]:
if requested_squad_uuid:
return [requested_squad_uuid]
if fallback_squads:
return fallback_squads
try:
squad_uuid = await get_random_trial_squad_uuid(db)
except Exception as error:
logger.error("Failed to select trial squad: %s", error)
squad_uuid = None
if not squad_uuid:
return []
logger.debug("Selected trial squad %s for subscription replacement", squad_uuid)
return [squad_uuid]
async def _get_subscription(db: AsyncSession, subscription_id: int) -> Subscription: async def _get_subscription(db: AsyncSession, subscription_id: int) -> Subscription:
result = await db.execute( result = await db.execute(
select(Subscription) select(Subscription)
@@ -109,7 +136,7 @@ async def create_subscription(
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
) -> SubscriptionResponse: ) -> SubscriptionResponse:
existing = await get_subscription_by_user_id(db, payload.user_id) existing = await get_subscription_by_user_id(db, payload.user_id)
if existing: if existing and not payload.replace_existing:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "User already has a subscription") raise HTTPException(status.HTTP_400_BAD_REQUEST, "User already has a subscription")
forced_devices = None forced_devices = None
@@ -120,15 +147,36 @@ async def create_subscription(
trial_device_limit = payload.device_limit trial_device_limit = payload.device_limit
if trial_device_limit is None: if trial_device_limit is None:
trial_device_limit = forced_devices trial_device_limit = forced_devices
duration_days = payload.duration_days or settings.TRIAL_DURATION_DAYS
traffic_limit_gb = payload.traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB
subscription = await create_trial_subscription( if existing:
db, connected_squads = await _choose_trial_squads(
user_id=payload.user_id, db, payload.squad_uuid, list(existing.connected_squads or [])
duration_days=payload.duration_days, )
traffic_limit_gb=payload.traffic_limit_gb, subscription = await replace_subscription(
device_limit=trial_device_limit, db,
squad_uuid=payload.squad_uuid, existing,
) duration_days=duration_days,
traffic_limit_gb=traffic_limit_gb,
device_limit=(
trial_device_limit
if trial_device_limit is not None
else settings.TRIAL_DEVICE_LIMIT
),
connected_squads=connected_squads,
is_trial=True,
update_server_counters=True,
)
else:
subscription = await create_trial_subscription(
db,
user_id=payload.user_id,
duration_days=duration_days,
traffic_limit_gb=traffic_limit_gb,
device_limit=trial_device_limit,
squad_uuid=payload.squad_uuid,
)
else: else:
if payload.duration_days is None: if payload.duration_days is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "duration_days is required for paid subscriptions") raise HTTPException(status.HTTP_400_BAD_REQUEST, "duration_days is required for paid subscriptions")
@@ -138,15 +186,27 @@ async def create_subscription(
device_limit = forced_devices device_limit = forced_devices
else: else:
device_limit = settings.DEFAULT_DEVICE_LIMIT device_limit = settings.DEFAULT_DEVICE_LIMIT
subscription = await create_paid_subscription( if existing:
db, subscription = await replace_subscription(
user_id=payload.user_id, db,
duration_days=payload.duration_days, existing,
traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB, duration_days=payload.duration_days,
device_limit=device_limit, traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB,
connected_squads=payload.connected_squads or [], device_limit=device_limit,
update_server_counters=True, connected_squads=payload.connected_squads or [],
) is_trial=False,
update_server_counters=True,
)
else:
subscription = await create_paid_subscription(
db,
user_id=payload.user_id,
duration_days=payload.duration_days,
traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB,
device_limit=device_limit,
connected_squads=payload.connected_squads or [],
update_server_counters=True,
)
subscription = await _get_subscription(db, subscription.id) subscription = await _get_subscription(db, subscription.id)
return _serialize_subscription(subscription) return _serialize_subscription(subscription)
+87 -1
View File
@@ -6,7 +6,12 @@ from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.ticket import TicketCRUD from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from app.config import settings
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
from app.database.models import Ticket, TicketMessage, TicketStatus from app.database.models import Ticket, TicketMessage, TicketStatus
from ..dependencies import get_db_session, require_api_token from ..dependencies import get_db_session, require_api_token
@@ -14,7 +19,10 @@ from ..schemas.tickets import (
TicketMessageResponse, TicketMessageResponse,
TicketPriorityUpdateRequest, TicketPriorityUpdateRequest,
TicketReplyBlockRequest, TicketReplyBlockRequest,
TicketReplyRequest,
TicketReplyResponse,
TicketResponse, TicketResponse,
TicketMediaResponse,
TicketStatusUpdateRequest, TicketStatusUpdateRequest,
) )
@@ -29,6 +37,7 @@ def _serialize_message(message: TicketMessage) -> TicketMessageResponse:
is_from_admin=message.is_from_admin, is_from_admin=message.is_from_admin,
has_media=message.has_media, has_media=message.has_media,
media_type=message.media_type, media_type=message.media_type,
media_file_id=message.media_file_id,
media_caption=message.media_caption, media_caption=message.media_caption,
created_at=message.created_at, created_at=message.created_at,
) )
@@ -183,3 +192,80 @@ async def clear_reply_block(
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False) ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
return _serialize_ticket(ticket, include_messages=True) return _serialize_ticket(ticket, include_messages=True)
@router.post("/{ticket_id}/reply", response_model=TicketReplyResponse, status_code=status.HTTP_201_CREATED)
async def reply_to_ticket(
ticket_id: int,
payload: TicketReplyRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> TicketReplyResponse:
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False, load_user=True)
if not ticket:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found")
message_text = (payload.message_text or "").strip()
if not message_text and not payload.media_file_id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Message text or media is required")
final_message_text = message_text or (payload.media_caption or "").strip() or "[media]"
message = await TicketMessageCRUD.add_message(
db,
ticket_id=ticket_id,
user_id=ticket.user_id,
message_text=final_message_text,
is_from_admin=True,
media_type=payload.media_type,
media_file_id=payload.media_file_id,
media_caption=payload.media_caption,
)
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
try:
from app.handlers.admin.tickets import notify_user_about_ticket_reply
await notify_user_about_ticket_reply(bot, ticket, final_message_text, db)
finally:
await bot.session.close()
ticket_with_messages = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
return TicketReplyResponse(
ticket=_serialize_ticket(ticket_with_messages, include_messages=True),
message=_serialize_message(message),
)
@router.get(
"/{ticket_id}/messages/{message_id}/media",
response_model=TicketMediaResponse,
)
async def get_ticket_message_media(
ticket_id: int,
message_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> TicketMediaResponse:
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
if not ticket:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found")
message = next((m for m in ticket.messages if m.id == message_id), None)
if not message:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Message not found")
if not message.has_media or not message.media_file_id or not message.media_type:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Media not found for this message")
return TicketMediaResponse(
id=message.id,
ticket_id=ticket.id,
media_type=message.media_type,
media_file_id=message.media_file_id,
media_caption=message.media_caption,
)
+16
View File
@@ -171,6 +171,22 @@ async def get_user(
return _serialize_user(user) return _serialize_user(user)
@router.get("/by-telegram-id/{telegram_id}", response_model=UserResponse)
async def get_user_by_telegram_id_endpoint(
telegram_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> UserResponse:
"""
Get user by Telegram ID
"""
user = await get_user_by_telegram_id(db, telegram_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
return _serialize_user(user)
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED) @router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user_endpoint( async def create_user_endpoint(
payload: UserCreateRequest, payload: UserCreateRequest,
+11
View File
@@ -88,3 +88,14 @@ class SystemLogPreviewResponse(BaseModel):
default=None, default=None,
description="Относительный путь до endpoint для скачивания лог-файла", description="Относительный путь до endpoint для скачивания лог-файла",
) )
class SystemLogFullResponse(BaseModel):
"""Полное содержимое системного лог-файла."""
path: str
exists: bool
updated_at: Optional[datetime] = None
size_bytes: int
size_chars: int
content: str
+47 -1
View File
@@ -1,9 +1,11 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from enum import Enum
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from urllib.parse import urlparse
from pydantic import BaseModel, Field, ConfigDict, model_validator from pydantic import BaseModel, ConfigDict, Field, model_validator
class MiniAppBranding(BaseModel): class MiniAppBranding(BaseModel):
@@ -200,6 +202,7 @@ class MiniAppSubscriptionRenewalRequest(BaseModel):
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
period_id: Optional[str] = Field(default=None, alias="periodId") period_id: Optional[str] = Field(default=None, alias="periodId")
period_days: Optional[int] = Field(default=None, alias="periodDays") period_days: Optional[int] = Field(default=None, alias="periodDays")
method: Optional[str] = None
model_config = ConfigDict(populate_by_name=True) model_config = ConfigDict(populate_by_name=True)
@@ -211,6 +214,14 @@ class MiniAppSubscriptionRenewalResponse(BaseModel):
balance_label: Optional[str] = Field(default=None, alias="balanceLabel") balance_label: Optional[str] = Field(default=None, alias="balanceLabel")
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
renewed_until: Optional[datetime] = Field(default=None, alias="renewedUntil") renewed_until: Optional[datetime] = Field(default=None, alias="renewedUntil")
requires_payment: bool = Field(default=False, alias="requiresPayment")
payment_method: Optional[str] = Field(default=None, alias="paymentMethod")
payment_url: Optional[str] = Field(default=None, alias="paymentUrl")
payment_amount_kopeks: Optional[int] = Field(default=None, alias="paymentAmountKopeks")
payment_id: Optional[int] = Field(default=None, alias="paymentId")
invoice_id: Optional[str] = Field(default=None, alias="invoiceId")
payment_payload: Optional[str] = Field(default=None, alias="paymentPayload")
payment_extra: Optional[Dict[str, Any]] = Field(default=None, alias="paymentExtra")
model_config = ConfigDict(populate_by_name=True) model_config = ConfigDict(populate_by_name=True)
@@ -357,6 +368,30 @@ class MiniAppPaymentMethodsRequest(BaseModel):
init_data: str = Field(..., alias="initData") init_data: str = Field(..., alias="initData")
class MiniAppPaymentIntegrationType(str, Enum):
IFRAME = "iframe"
REDIRECT = "redirect"
class MiniAppPaymentIframeConfig(BaseModel):
expected_origin: str
@model_validator(mode="after")
def _normalize_expected_origin(
cls, values: "MiniAppPaymentIframeConfig"
) -> "MiniAppPaymentIframeConfig":
origin = (values.expected_origin or "").strip()
if not origin:
raise ValueError("expected_origin must not be empty")
parsed = urlparse(origin)
if not parsed.scheme or not parsed.netloc:
raise ValueError("expected_origin must include scheme and host")
values.expected_origin = f"{parsed.scheme}://{parsed.netloc}"
return values
class MiniAppPaymentMethod(BaseModel): class MiniAppPaymentMethod(BaseModel):
id: str id: str
name: Optional[str] = None name: Optional[str] = None
@@ -366,6 +401,17 @@ class MiniAppPaymentMethod(BaseModel):
min_amount_kopeks: Optional[int] = None min_amount_kopeks: Optional[int] = None
max_amount_kopeks: Optional[int] = None max_amount_kopeks: Optional[int] = None
amount_step_kopeks: Optional[int] = None amount_step_kopeks: Optional[int] = None
integration_type: MiniAppPaymentIntegrationType
iframe_config: Optional[MiniAppPaymentIframeConfig] = None
@model_validator(mode="after")
def _ensure_iframe_config(cls, values: "MiniAppPaymentMethod") -> "MiniAppPaymentMethod":
if (
values.integration_type == MiniAppPaymentIntegrationType.IFRAME
and values.iframe_config is None
):
raise ValueError("iframe_config is required when integration_type is 'iframe'")
return values
class MiniAppPaymentMethodsResponse(BaseModel): class MiniAppPaymentMethodsResponse(BaseModel):
+20
View File
@@ -169,3 +169,23 @@ class PollResponsesListResponse(BaseModel):
total: int total: int
limit: int limit: int
offset: int offset: int
class PollSendRequest(BaseModel):
target: str = Field(
...,
description=(
"Аудитория для отправки опроса (например: all, active, trial, "
"custom_today и т.д.)"
),
max_length=100,
)
class PollSendResponse(BaseModel):
poll_id: int
target: str
sent: int
failed: int
skipped: int
total: int
+1
View File
@@ -34,6 +34,7 @@ class SubscriptionCreateRequest(BaseModel):
device_limit: Optional[int] = None device_limit: Optional[int] = None
squad_uuid: Optional[str] = None squad_uuid: Optional[str] = None
connected_squads: Optional[List[str]] = None connected_squads: Optional[List[str]] = None
replace_existing: bool = False
class SubscriptionExtendRequest(BaseModel): class SubscriptionExtendRequest(BaseModel):
+25
View File
@@ -13,6 +13,7 @@ class TicketMessageResponse(BaseModel):
is_from_admin: bool is_from_admin: bool
has_media: bool has_media: bool
media_type: Optional[str] = None media_type: Optional[str] = None
media_file_id: Optional[str] = None
media_caption: Optional[str] = None media_caption: Optional[str] = None
created_at: datetime created_at: datetime
@@ -42,3 +43,27 @@ class TicketPriorityUpdateRequest(BaseModel):
class TicketReplyBlockRequest(BaseModel): class TicketReplyBlockRequest(BaseModel):
permanent: bool = False permanent: bool = False
until: Optional[datetime] = None until: Optional[datetime] = None
class TicketReplyRequest(BaseModel):
message_text: Optional[str] = Field(default=None, max_length=4000)
media_type: Optional[str] = Field(
default=None,
description="Тип медиа (photo, video, document, voice и т.д.)",
max_length=32,
)
media_file_id: Optional[str] = Field(default=None, max_length=255)
media_caption: Optional[str] = Field(default=None, max_length=4000)
class TicketReplyResponse(BaseModel):
ticket: TicketResponse
message: TicketMessageResponse
class TicketMediaResponse(BaseModel):
id: int
ticket_id: int
media_type: str
media_file_id: str
media_caption: Optional[str] = None
+32
View File
@@ -151,4 +151,36 @@ Caddy автоматически выпустит сертификаты чер
| Mini App не открывается в Telegram | URL не соответствует HTTPS или отсутствует сертификат. | Обновите сертификаты и убедитесь, что домен доступен по HTTPS. | | Mini App не открывается в Telegram | URL не соответствует HTTPS или отсутствует сертификат. | Обновите сертификаты и убедитесь, что домен доступен по HTTPS. |
| Нет ссылок подписки | Не настроена интеграция с RemnaWave или у пользователя нет активной подписки. | Проверьте `REMNAWAVE_API_URL/KEY` и статус подписки пользователя. | | Нет ссылок подписки | Не настроена интеграция с RemnaWave или у пользователя нет активной подписки. | Проверьте `REMNAWAVE_API_URL/KEY` и статус подписки пользователя. |
## 11. Формат ответа `/miniapp/payments/methods`
Эндпоинт `POST /miniapp/payments/methods` возвращает список доступных способов оплаты. Каждый элемент массива `methods` содержит базовые поля (`id`, `title`, `description`, ограничения по сумме) и дополнительные атрибуты, управляющие интеграцией во фронтенде:
- `integration_type` — тип интеграции, обязательное поле. Возможные значения:
- `iframe` — форму оплаты нужно отображать внутри мини-приложения в `<iframe>`.
- `redirect` — необходимо выполнить переход на внешнюю страницу.
- `iframe_config` — объект с настройками, присутствует только при `integration_type = "iframe"`.
- `expected_origin` — origin страницы платёжного провайдера. Значение используется при проверке `event.origin` для сообщений, полученных через `postMessage` от платежного iframe.
Пример фрагмента ответа:
```json
{
"methods": [
{
"id": "mulenpay",
"title": "Банковская карта",
"integration_type": "iframe",
"iframe_config": {
"expected_origin": "https://checkout.example"
}
},
{
"id": "cryptobot",
"title": "CryptoBot",
"integration_type": "redirect"
}
]
}
```
После настройки вы сможете использовать мини-приложение в бот-меню и отправлять ссылку пользователям напрямую. После настройки вы сможете использовать мини-приложение в бот-меню и отправлять ссылку пользователям напрямую.
-1451
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -13955,7 +13955,7 @@
function ensureSubscriptionRenewalData(options = {}) { function ensureSubscriptionRenewalData(options = {}) {
const { force = false } = options; const { force = false } = options;
if (!hasPaidSubscription()) { if (!hasActiveSubscription()) {
resetSubscriptionRenewalState(null); resetSubscriptionRenewalState(null);
renderSubscriptionRenewalCard(); renderSubscriptionRenewalCard();
return Promise.resolve(null); return Promise.resolve(null);
@@ -14484,7 +14484,7 @@
return; return;
} }
const shouldShow = hasPaidSubscription(); const shouldShow = hasActiveSubscription();
card.classList.toggle('hidden', !shouldShow); card.classList.toggle('hidden', !shouldShow);
if (!shouldShow) { if (!shouldShow) {
return; return;
@@ -14601,7 +14601,7 @@
return; return;
} }
if (!hasPaidSubscription()) { if (!hasActiveSubscription()) {
handleSubscriptionRenewalError(createError('Subscription renewal', t('subscription_renewal.error.validation'))); handleSubscriptionRenewalError(createError('Subscription renewal', t('subscription_renewal.error.validation')));
return; return;
} }
@@ -14784,7 +14784,7 @@
function ensureSubscriptionSettingsLoaded(options = {}) { function ensureSubscriptionSettingsLoaded(options = {}) {
const { force = false } = options; const { force = false } = options;
if (!hasPaidSubscription()) { if (!hasActiveSubscription()) {
subscriptionSettingsData = null; subscriptionSettingsData = null;
subscriptionSettingsPromise = null; subscriptionSettingsPromise = null;
subscriptionSettingsError = null; subscriptionSettingsError = null;
@@ -14912,7 +14912,7 @@
return; return;
} }
const shouldShow = hasPaidSubscription(); const shouldShow = hasActiveSubscription();
card.classList.toggle('hidden', !shouldShow); card.classList.toggle('hidden', !shouldShow);
if (!shouldShow) { if (!shouldShow) {
return; return;
+282
View File
@@ -0,0 +1,282 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Оплата не прошла</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@700&display=swap');
:root {
--cyber-grid-color: rgba(0, 200, 255, 0.1);
--cyber-line-color: rgba(0, 255, 200, 0.1);
--brand-neon-color: #00aaff;
--error-neon-color: #ff4d4d;
--bg-color: #1a1a2e;
--container-bg-color: rgba(20, 20, 35, 0.85);
--text-primary: #ffffff;
--text-secondary: #c0c0d0;
}
body {
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background-color: var(--bg-color);
margin: 0;
/* [!] ИСПРАВЛЕНИЕ: Уменьшен горизонтальный отступ для body */
padding: 20px 15px;
display: flex;
justify-content: center;
align-items: center;
/* [!] ИСПРАВЛЕНИЕ: height -> min-height для лучшей совместимости */
min-height: 100vh;
box-sizing: border-box;
text-align: center;
color: var(--text-secondary);
overflow: hidden;
position: relative;
}
body::before,
body::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: -1;
}
body::before {
background:
linear-gradient(to right, var(--cyber-grid-color) 1px, transparent 1px),
linear-gradient(to bottom, var(--cyber-grid-color) 1px, transparent 1px);
background-size: 50px 50px;
animation: gridScroll 20s linear infinite;
}
@keyframes gridScroll {
from { background-position: 0 0; }
to { background-position: -50px -50px; }
}
body::after {
background:
linear-gradient(45deg, transparent 49%, var(--cyber-line-color) 50%, transparent 51%),
linear-gradient(-45deg, transparent 49%, var(--cyber-line-color) 50%, transparent 51%);
background-size: 100% 100%;
animation: cyberScan 10s linear infinite alternate;
opacity: 0.3;
}
@keyframes cyberScan {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
.container {
background-color: var(--container-bg-color);
border-radius: 16px;
/* [!] ИСПРАВЛЕНИЕ: Уменьшен горизонтальный отступ для .container */
padding: 30px 25px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
max-width: 400px;
width: 100%;
transform: scale(0.95);
opacity: 0;
animation: fadeInScale 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.2s forwards;
position: relative;
z-index: 1;
border: 1px solid rgba(0, 200, 255, 0.2);
backdrop-filter: blur(5px);
}
@keyframes fadeInScale {
to {
transform: scale(1);
opacity: 1;
}
}
.logo-container {
margin: 0 auto 25px auto;
line-height: 0;
overflow: hidden;
position: relative;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
.logo-text {
font-family: 'Orbitron', sans-serif;
font-size: 48px;
color: var(--brand-neon-color);
text-shadow: 0 0 10px var(--brand-neon-color), 0 0 20px var(--brand-neon-color);
opacity: 0;
transform: translateY(-20px);
animation: logoSlideIn 0.8s ease-out 0.4s forwards, logoPulse 2s infinite ease-in-out 1.5s forwards;
white-space: nowrap;
letter-spacing: 2px;
}
@keyframes logoSlideIn {
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes logoPulse {
0% { transform: scale(1); text-shadow: 0 0 10px var(--brand-neon-color), 0 0 20px var(--brand-neon-color); }
50% { transform: scale(1.02); text-shadow: 0 0 15px var(--brand-neon-color), 0 0 25px var(--brand-neon-color), 0 0 35px var(--brand-neon-color); }
100% { transform: scale(1); text-shadow: 0 0 10px var(--brand-neon-color), 0 0 20px var(--brand-neon-color); }
}
.error-icon {
width: 80px;
height: 80px;
margin: 0 auto 20px auto;
}
.error-icon svg {
width: 100%;
height: 100%;
filter: drop-shadow(0 0 8px rgba(255, 77, 77, 0.7));
}
.error-circle {
stroke-dasharray: 166;
stroke-dashoffset: 166;
stroke-width: 3;
stroke-miterlimit: 10;
stroke: var(--error-neon-color);
fill: none;
animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) 0.5s forwards;
}
.error-line {
transform-origin: 50% 50%;
stroke-dasharray: 48;
stroke-dashoffset: 48;
stroke-width: 4;
stroke: var(--error-neon-color);
fill: none;
animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 1s forwards;
}
@keyframes stroke {
100% {
stroke-dashoffset: 0;
}
}
h1 {
font-size: 24px;
font-weight: 700;
margin-bottom: 15px;
color: var(--text-primary);
text-shadow: none;
}
.info-text, .instruction-text {
font-size: 16px;
line-height: 1.6;
color: var(--text-secondary);
margin-bottom: 15px;
text-shadow: none;
}
.instruction-text {
margin-bottom: 30px;
}
.cta-button {
display: inline-block;
width: 100%;
padding: 14px;
font-size: 16px;
font-weight: 700;
color: var(--bg-color);
background-color: var(--brand-neon-color);
border: none;
border-radius: 10px;
text-decoration: none;
cursor: pointer;
transition: all 0.3s ease;
box-sizing: border-box;
box-shadow: 0 0 15px rgba(0, 170, 255, 0.6);
}
.cta-button:hover {
background-color: #00c0ff;
transform: translateY(-2px);
box-shadow: 0 0 20px rgba(0, 170, 255, 0.8), 0 0 30px rgba(0, 170, 255, 0.4);
}
.cta-button:active {
transform: translateY(0);
box-shadow: 0 0 10px rgba(0, 170, 255, 0.5);
}
</style>
</head>
<body>
<div class="container">
<div class="logo-container">
<span class="logo-text">Bedolaga</span>
</div>
<div class="error-icon">
<svg class="error-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52">
<circle class="error-circle" cx="26" cy="26" r="25" fill="none"/>
<path class="error-line" fill="none" d="M16 16 36 36"/>
<path class="error-line" fill="none" d="M36 16 16 36"/>
</svg>
</div>
<h1>Оплата не прошла</h1>
<p class="info-text">
К сожалению, не удалось обработать ваш платеж. Средства не были списаны.
</p>
<p class="instruction-text">
Пожалуйста, вернитесь в бота, проверьте данные и попробуйте еще раз.
</p>
<button id="close-btn" class="cta-button">
Вернуться в бота
</button>
</div>
<script>
try {
if (window.Telegram && window.Telegram.WebApp) {
Telegram.WebApp.ready();
var closeButton = document.getElementById('close-btn');
closeButton.addEventListener('click', function() {
Telegram.WebApp.close();
});
} else {
console.warn('Telegram WebApp JS not loaded or not in TWA environment.');
}
} catch (e) {
console.error(e);
}
</script>
</body>
</html>
Binary file not shown.
+295
View File
@@ -0,0 +1,295 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Оплата прошла успешно</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@700&display=swap');
:root {
--cyber-grid-color: rgba(0, 200, 255, 0.1);
--cyber-line-color: rgba(0, 255, 200, 0.1);
--brand-neon-color: #00aaff;
--success-neon-color: #00e676;
--bg-color: #1a1a2e;
--container-bg-color: rgba(20, 20, 35, 0.85);
--text-primary: #ffffff;
--text-secondary: #c0c0d0;
}
body {
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background-color: var(--bg-color);
margin: 0;
padding: 20px 15px; /* Исправлено для мобильных */
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh; /* Исправлено для мобильных */
box-sizing: border-box;
text-align: center;
color: var(--text-secondary);
overflow: hidden;
position: relative;
}
body::before,
body::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: -1;
}
body::before {
background:
linear-gradient(to right, var(--cyber-grid-color) 1px, transparent 1px),
linear-gradient(to bottom, var(--cyber-grid-color) 1px, transparent 1px);
background-size: 50px 50px;
animation: gridScroll 20s linear infinite;
}
@keyframes gridScroll {
from { background-position: 0 0; }
to { background-position: -50px -50px; }
}
body::after {
background:
linear-gradient(45deg, transparent 49%, var(--cyber-line-color) 50%, transparent 51%),
linear-gradient(-45deg, transparent 49%, var(--cyber-line-color) 50%, transparent 51%);
background-size: 100% 100%;
animation: cyberScan 10s linear infinite alternate;
opacity: 0.3;
}
@keyframes cyberScan {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
.container {
background-color: var(--container-bg-color);
border-radius: 16px;
padding: 30px 25px; /* Исправлено для мобильных */
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
max-width: 400px;
width: 100%;
transform: scale(0.95);
opacity: 0;
animation: fadeInScale 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.2s forwards;
position: relative;
z-index: 1;
border: 1px solid rgba(0, 200, 255, 0.2);
backdrop-filter: blur(5px);
}
@keyframes fadeInScale {
to {
transform: scale(1);
opacity: 1;
}
}
.logo-container {
margin: 0 auto 25px auto;
line-height: 0;
overflow: hidden;
position: relative;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
.logo-text {
font-family: 'Orbitron', sans-serif;
font-size: 48px;
color: var(--brand-neon-color);
text-shadow: 0 0 10px var(--brand-neon-color), 0 0 20px var(--brand-neon-color);
opacity: 0;
transform: translateY(-20px);
animation: logoSlideIn 0.8s ease-out 0.4s forwards, logoPulse 2s infinite ease-in-out 1.5s forwards;
white-space: nowrap;
letter-spacing: 2px;
}
@keyframes logoSlideIn {
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes logoPulse {
0% { transform: scale(1); text-shadow: 0 0 10px var(--brand-neon-color), 0 0 20px var(--brand-neon-color); }
50% { transform: scale(1.02); text-shadow: 0 0 15px var(--brand-neon-color), 0 0 25px var(--brand-neon-color), 0 0 35px var(--brand-neon-color); }
100% { transform: scale(1); text-shadow: 0 0 10px var(--brand-neon-color), 0 0 20px var(--brand-neon-color); }
}
.success-icon {
width: 80px;
height: 80px;
margin: 0 auto 20px auto;
}
.success-icon svg {
width: 100%;
height: 100%;
filter: drop-shadow(0 0 8px rgba(0, 230, 118, 0.7));
}
.checkmark__circle {
stroke-dasharray: 166;
stroke-dashoffset: 166;
stroke-width: 3;
stroke-miterlimit: 10;
stroke: var(--success-neon-color);
fill: none;
/* [!] Анимация галочки (круга) начинается с задержкой 0.5с */
animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) 0.5s forwards;
}
.checkmark__check {
transform-origin: 50% 50%;
stroke-dasharray: 48;
stroke-dashoffset: 48;
stroke-width: 4;
stroke: var(--success-neon-color);
fill: none;
animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 1s forwards;
}
@keyframes stroke {
100% {
stroke-dashoffset: 0;
}
}
h1 {
font-size: 24px;
font-weight: 700;
margin-bottom: 15px;
color: var(--text-primary);
text-shadow: none;
}
.info-text, .instruction-text {
font-size: 16px;
line-height: 1.6;
color: var(--text-secondary);
margin-bottom: 15px;
text-shadow: none;
}
.instruction-text {
margin-bottom: 30px;
}
.cta-button {
display: inline-block;
width: 100%;
padding: 14px;
font-size: 16px;
font-weight: 700;
color: var(--bg-color);
background-color: var(--brand-neon-color);
border: none;
border-radius: 10px;
text-decoration: none;
cursor: pointer;
transition: all 0.3s ease;
box-sizing: border-box;
box-shadow: 0 0 15px rgba(0, 170, 255, 0.6);
}
.cta-button:hover {
background-color: #00c0ff;
transform: translateY(-2px);
box-shadow: 0 0 20px rgba(0, 170, 255, 0.8), 0 0 30px rgba(0, 170, 255, 0.4);
}
.cta-button:active {
transform: translateY(0);
box-shadow: 0 0 10px rgba(0, 170, 255, 0.5);
}
</style>
</head>
<body>
<audio id="success-sound" src="/sound.mp3" preload="auto"></audio>
<div class="container">
<div class="logo-container">
<span class="logo-text">Bedolaga</span>
</div>
<div class="success-icon">
<svg class="checkmark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52">
<circle class="checkmark__circle" cx="26" cy="26" r="25" fill="none"/>
<path class="checkmark__check" fill="none" d="M14.1 27.2l7.1 7.2 16.7-16.8"/>
</svg>
</div>
<h1>Оплата прошла успешно!</h1>
<p class="info-text">
Ваша подписка на VPN активирована. Наслаждайтесь безопасным и анонимным доступом в Интернет.
</p>
<p class="instruction-text">
Теперь вы можете вернуться в бота, чтобы получить доступ к вашим настройкам и управлять подпиской.
</p>
<button id="close-btn" class="cta-button">
Вернуться в бота
</button>
</div>
<script>
try {
if (window.Telegram && window.Telegram.WebApp) {
Telegram.WebApp.ready();
var closeButton = document.getElementById('close-btn');
closeButton.addEventListener('click', function() {
Telegram.WebApp.close();
});
} else {
console.warn('Telegram WebApp JS not loaded or not in TWA environment.');
}
// --- [!] 2. КОД ДЛЯ ВОСПРОИЗВЕДЕНИЯ ЗВУКА [!] ---
// Находим аудиофайл
const audio = document.getElementById('success-sound');
// Устанавливаем таймер. Анимация круга (.checkmark__circle)
// имеет задержку 0.5с (500мс). Мы запускаем звук в тот же момент.
setTimeout(() => {
// Пытаемся проиграть звук
var playPromise = audio.play();
if (playPromise !== undefined) {
playPromise.catch(error => {
// Блокировка авто-воспроизведения - это нормально.
console.warn('Audio autoplay blocked:', error);
});
}
}, 500); // 500 миллисекунд = 0.5с
} catch (e) {
console.error(e);
}
</script>
</body>
</html>
+9 -9
View File
@@ -134,9 +134,9 @@ async def test_handle_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None:
status = response.status status = response.status
text = await response.text() text = await response.text()
assert status == 200 assert status == 400
assert text == "OK" assert text == "No payment id"
process_mock.assert_awaited_once() process_mock.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -160,9 +160,9 @@ async def test_handle_webhook_trusts_cf_connecting_ip(monkeypatch: pytest.Monkey
status = response.status status = response.status
text = await response.text() text = await response.text()
assert status == 200 assert status == 400
assert text == "OK" assert text == "No payment id"
process_mock.assert_awaited_once() process_mock.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -184,9 +184,9 @@ async def test_handle_webhook_with_optional_signature(monkeypatch: pytest.Monkey
status = response.status status = response.status
text = await response.text() text = await response.text()
assert status == 200 assert status == 400
assert text == "OK" assert text == "No payment id"
process_mock.assert_awaited_once() process_mock.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
+25
View File
@@ -0,0 +1,25 @@
import logging
import pytest
from app.services.platega_service import PlategaService
def test_sanitize_description_limits_utf8_bytes(caplog: pytest.LogCaptureFixture) -> None:
caplog.set_level(logging.DEBUG)
original = "Интернет-сервис - Пополнение баланса на 50 ₽ и ещё чуть-чуть"
trimmed = PlategaService._sanitize_description(original, 64)
assert len(trimmed.encode("utf-8")) <= 64
assert trimmed != original
assert any("trimmed" in record.message for record in caplog.records)
def test_sanitize_description_returns_clean_value() -> None:
original = " Обычное описание "
trimmed = PlategaService._sanitize_description(original, 64)
assert trimmed == "Обычное описание"
assert len(trimmed.encode("utf-8")) <= 64
+30
View File
@@ -0,0 +1,30 @@
from datetime import datetime, timedelta
from unittest.mock import MagicMock
from app.handlers.menu import _get_subscription_status
class DummyTexts:
def t(self, key: str, default: str): # pragma: no cover - simple stub
return default
def _build_user_with_subscription(actual_status: str, is_trial: bool, days_left: int):
subscription = MagicMock()
subscription.actual_status = actual_status
subscription.is_trial = is_trial
subscription.end_date = datetime.utcnow() + timedelta(days=days_left, hours=1)
user = MagicMock()
user.subscription = subscription
return user
def test_get_subscription_status_marks_trial_as_trial():
texts = DummyTexts()
user = _build_user_with_subscription(actual_status="active", is_trial=True, days_left=5)
status_text = _get_subscription_status(user, texts)
assert "Тестовая подписка" in status_text
assert "Активна" not in status_text
+604
View File
@@ -19,10 +19,20 @@ os.environ.setdefault('BOT_TOKEN', 'test-token')
from app.config import settings from app.config import settings
from app.webapi.routes import miniapp from app.webapi.routes import miniapp
from app.database.models import PaymentMethod from app.database.models import PaymentMethod
from app.services.subscription_renewal_service import (
SubscriptionRenewalPricing,
SubscriptionRenewalResult,
build_payment_descriptor,
decode_payment_payload,
encode_payment_payload,
)
from app.services.payment.cryptobot import CryptoBotPaymentMixin
from app.webapi.schemas.miniapp import ( from app.webapi.schemas.miniapp import (
MiniAppPaymentCreateRequest, MiniAppPaymentCreateRequest,
MiniAppPaymentIntegrationType,
MiniAppPaymentMethodsRequest, MiniAppPaymentMethodsRequest,
MiniAppPaymentStatusQuery, MiniAppPaymentStatusQuery,
MiniAppSubscriptionRenewalRequest,
) )
@@ -43,6 +53,572 @@ def test_compute_cryptobot_limits_scale_with_rate():
assert high_rate_max > low_rate_max assert high_rate_max > low_rate_max
def test_encode_decode_renewal_payload_preserves_snapshot():
pricing_model = SubscriptionRenewalPricing(
period_days=30,
period_id='days:30',
months=1,
base_original_total=12000,
discounted_total=10000,
final_total=9000,
promo_discount_value=1000,
promo_discount_percent=10,
overall_discount_percent=25,
per_month=9000,
server_ids=[1, 2],
details={'servers_price_per_month': 1000},
)
descriptor = build_payment_descriptor(
user_id=1,
subscription_id=42,
period_days=30,
total_amount_kopeks=pricing_model.final_total,
missing_amount_kopeks=1000,
pricing_snapshot=pricing_model.to_payload(),
)
payload_value = encode_payment_payload(descriptor)
decoded = decode_payment_payload(payload_value, expected_user_id=1)
assert decoded is not None
assert decoded.total_amount_kopeks == 9000
assert decoded.missing_amount_kopeks == 1000
assert decoded.pricing_snapshot is not None
assert decoded.pricing_snapshot.get('server_ids') == [1, 2]
@pytest.mark.anyio("asyncio")
async def test_submit_subscription_renewal_uses_balance_when_sufficient(monkeypatch):
monkeypatch.setattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False, raising=False)
monkeypatch.setattr(settings, 'BOT_TOKEN', 'token', raising=False)
monkeypatch.setattr(settings, 'RESET_TRAFFIC_ON_PAYMENT', False, raising=False)
monkeypatch.setattr(settings, 'DEFAULT_LANGUAGE', 'ru', raising=False)
monkeypatch.setattr(settings, 'CRYPTOBOT_ENABLED', False, raising=False)
monkeypatch.setattr(settings, 'CRYPTOBOT_API_TOKEN', None, raising=False)
monkeypatch.setattr(type(settings), 'get_available_renewal_periods', lambda self: [30], raising=False)
user = types.SimpleNamespace(id=10, balance_kopeks=10000, language='ru')
subscription = types.SimpleNamespace(
id=77,
connected_squads=[],
traffic_limit_gb=100,
device_limit=5,
end_date=datetime.utcnow(),
)
pricing_model = SubscriptionRenewalPricing(
period_days=30,
period_id='days:30',
months=1,
base_original_total=10000,
discounted_total=10000,
final_total=10000,
promo_discount_value=0,
promo_discount_percent=0,
overall_discount_percent=0,
per_month=10000,
server_ids=[],
details={},
)
async def fake_authorize(init_data, db): # noqa: ARG001
return user
def fake_ensure(subscription_user, allowed_statuses=None): # noqa: ARG001
return subscription
async def fake_calculate(db, u, sub, period): # noqa: ARG001
return pricing_model
captured: dict[str, Any] = {}
async def fake_finalize(db, u, sub, pricing, *, charge_balance_amount=None, description=None, payment_method=None): # noqa: ARG001
charge = charge_balance_amount if charge_balance_amount is not None else pricing.final_total
captured['charge'] = charge
captured['description'] = description
return SubscriptionRenewalResult(
subscription=types.SimpleNamespace(id=sub.id, end_date=datetime.utcnow()),
transaction=types.SimpleNamespace(id=501),
total_amount_kopeks=pricing.final_total,
charged_from_balance_kopeks=charge,
old_end_date=sub.end_date,
)
monkeypatch.setattr(miniapp, '_authorize_miniapp_user', fake_authorize)
monkeypatch.setattr(miniapp, '_ensure_paid_subscription', fake_ensure)
monkeypatch.setattr(miniapp, '_validate_subscription_id', lambda *args, **kwargs: None)
monkeypatch.setattr(miniapp, '_calculate_subscription_renewal_pricing', fake_calculate)
monkeypatch.setattr(miniapp.renewal_service, 'finalize', fake_finalize)
payload = MiniAppSubscriptionRenewalRequest(
initData='init',
subscriptionId=77,
periodId='days:30',
)
response = await miniapp.submit_subscription_renewal_endpoint(payload, db=types.SimpleNamespace())
assert response.success is True
assert response.requires_payment is False
assert response.subscription_id == 77
assert response.renewed_until is not None
assert 'Подписка' in (response.message or '')
assert captured['charge'] == 10000
@pytest.mark.anyio("asyncio")
async def test_submit_subscription_renewal_returns_cryptobot_invoice(monkeypatch):
monkeypatch.setattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False, raising=False)
monkeypatch.setattr(settings, 'BOT_TOKEN', 'token', raising=False)
monkeypatch.setattr(settings, 'RESET_TRAFFIC_ON_PAYMENT', False, raising=False)
monkeypatch.setattr(settings, 'DEFAULT_LANGUAGE', 'ru', raising=False)
monkeypatch.setattr(settings, 'CRYPTOBOT_ENABLED', True, raising=False)
monkeypatch.setattr(settings, 'CRYPTOBOT_API_TOKEN', 'token', raising=False)
monkeypatch.setattr(settings, 'CRYPTOBOT_DEFAULT_ASSET', 'USDT', raising=False)
monkeypatch.setattr(type(settings), 'get_available_renewal_periods', lambda self: [30], raising=False)
user = types.SimpleNamespace(id=15, balance_kopeks=5000, language='ru')
subscription = types.SimpleNamespace(
id=88,
connected_squads=[],
traffic_limit_gb=100,
device_limit=5,
end_date=datetime.utcnow(),
)
pricing_model = SubscriptionRenewalPricing(
period_days=30,
period_id='days:30',
months=1,
base_original_total=20000,
discounted_total=20000,
final_total=20000,
promo_discount_value=0,
promo_discount_percent=0,
overall_discount_percent=0,
per_month=20000,
server_ids=[],
details={},
)
async def fake_authorize(init_data, db): # noqa: ARG001
return user
def fake_ensure(subscription_user, allowed_statuses=None): # noqa: ARG001
return subscription
async def fake_calculate(db, u, sub, period): # noqa: ARG001
return pricing_model
created_calls: dict[str, Any] = {}
class DummyPaymentService:
def __init__(self, *args, **kwargs):
pass
async def create_cryptobot_payment(self, db, **kwargs):
created_calls.update(kwargs)
return {
'local_payment_id': 321,
'invoice_id': 'inv_123',
'bot_invoice_url': 'https://t.me/invoice',
'mini_app_invoice_url': 'https://mini.app/pay',
'web_app_invoice_url': None,
}
async def fake_rate():
return 100.0
monkeypatch.setattr(miniapp, '_authorize_miniapp_user', fake_authorize)
monkeypatch.setattr(miniapp, '_ensure_paid_subscription', fake_ensure)
monkeypatch.setattr(miniapp, '_validate_subscription_id', lambda *args, **kwargs: None)
monkeypatch.setattr(miniapp, '_calculate_subscription_renewal_pricing', fake_calculate)
monkeypatch.setattr(miniapp, 'PaymentService', lambda *args, **kwargs: DummyPaymentService())
monkeypatch.setattr(miniapp, '_get_usd_to_rub_rate', fake_rate)
payload = MiniAppSubscriptionRenewalRequest(
initData='init',
subscriptionId=88,
periodId='days:30',
method='cryptobot',
)
response = await miniapp.submit_subscription_renewal_endpoint(payload, db=types.SimpleNamespace())
assert response.success is False
assert response.requires_payment is True
assert response.payment_method == 'cryptobot'
assert response.payment_amount_kopeks == 15000
assert response.payment_url == 'https://mini.app/pay'
assert response.invoice_id == 'inv_123'
assert response.payment_id == 321
assert response.payment_payload and response.payment_payload.startswith('subscription_renewal')
assert created_calls.get('amount_usd') == pytest.approx(1.5)
assert created_calls.get('description') == 'Продление подписки на 30 дней'
@pytest.mark.anyio("asyncio")
async def test_submit_subscription_renewal_rounds_up_cryptobot_amount(monkeypatch):
monkeypatch.setattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False, raising=False)
monkeypatch.setattr(settings, 'BOT_TOKEN', 'token', raising=False)
monkeypatch.setattr(settings, 'RESET_TRAFFIC_ON_PAYMENT', False, raising=False)
monkeypatch.setattr(settings, 'DEFAULT_LANGUAGE', 'ru', raising=False)
monkeypatch.setattr(settings, 'CRYPTOBOT_ENABLED', True, raising=False)
monkeypatch.setattr(settings, 'CRYPTOBOT_API_TOKEN', 'token', raising=False)
monkeypatch.setattr(settings, 'CRYPTOBOT_DEFAULT_ASSET', 'USDT', raising=False)
monkeypatch.setattr(type(settings), 'get_available_renewal_periods', lambda self: [30], raising=False)
user = types.SimpleNamespace(id=42, balance_kopeks=0, language='ru')
subscription = types.SimpleNamespace(
id=99,
connected_squads=[],
traffic_limit_gb=100,
device_limit=5,
end_date=datetime.utcnow(),
)
pricing_model = SubscriptionRenewalPricing(
period_days=30,
period_id='days:30',
months=1,
base_original_total=9512,
discounted_total=9512,
final_total=9512,
promo_discount_value=0,
promo_discount_percent=0,
overall_discount_percent=0,
per_month=9512,
server_ids=[],
details={},
)
async def fake_authorize(init_data, db): # noqa: ARG001
return user
def fake_ensure(subscription_user, allowed_statuses=None): # noqa: ARG001
return subscription
async def fake_calculate(db, u, sub, period): # noqa: ARG001
return pricing_model
captured: dict[str, Any] = {}
class DummyPaymentService:
def __init__(self, *args, **kwargs):
pass
async def create_cryptobot_payment(self, db, **kwargs):
captured.update(kwargs)
return {
'local_payment_id': 654,
'invoice_id': 'inv_round',
'bot_invoice_url': 'https://t.me/pay',
'mini_app_invoice_url': 'https://mini.app/pay-round',
'web_app_invoice_url': None,
}
async def fake_rate():
return 95.0
monkeypatch.setattr(miniapp, '_authorize_miniapp_user', fake_authorize)
monkeypatch.setattr(miniapp, '_ensure_paid_subscription', fake_ensure)
monkeypatch.setattr(miniapp, '_validate_subscription_id', lambda *args, **kwargs: None)
monkeypatch.setattr(miniapp, '_calculate_subscription_renewal_pricing', fake_calculate)
monkeypatch.setattr(miniapp, 'PaymentService', lambda *args, **kwargs: DummyPaymentService())
monkeypatch.setattr(miniapp, '_get_usd_to_rub_rate', fake_rate)
payload = MiniAppSubscriptionRenewalRequest(
initData='init',
subscriptionId=99,
periodId='days:30',
method='cryptobot',
)
response = await miniapp.submit_subscription_renewal_endpoint(payload, db=types.SimpleNamespace())
assert response.requires_payment is True
assert captured.get('amount_usd') == pytest.approx(1.01)
assert response.payment_amount_kopeks == 9512
@pytest.mark.anyio("asyncio")
async def test_cryptobot_renewal_uses_pricing_snapshot(monkeypatch):
module = sys.modules['app.services.payment.cryptobot']
mixin = CryptoBotPaymentMixin()
subscription = types.SimpleNamespace(id=77, connected_squads=[], traffic_limit_gb=100, device_limit=5)
user = types.SimpleNamespace(id=5, balance_kopeks=7000, subscription=subscription)
pricing_model = SubscriptionRenewalPricing(
period_days=30,
period_id='days:30',
months=1,
base_original_total=12000,
discounted_total=10000,
final_total=10000,
promo_discount_value=0,
promo_discount_percent=0,
overall_discount_percent=0,
per_month=10000,
server_ids=[11, 22],
details={'servers_individual_prices': [500, 500]},
)
descriptor = build_payment_descriptor(
user_id=5,
subscription_id=77,
period_days=30,
total_amount_kopeks=10000,
missing_amount_kopeks=3000,
pricing_snapshot=pricing_model.to_payload(),
)
payment = types.SimpleNamespace(invoice_id='INV-1', user_id=5)
async def fake_get_user_by_id(db, user_id): # noqa: ARG001
return user if user_id == 5 else None
monkeypatch.setitem(sys.modules, 'app.services.payment_service', types.SimpleNamespace(get_user_by_id=fake_get_user_by_id))
async def fail_calculate(*args, **kwargs): # noqa: ARG001
raise AssertionError('calculate_pricing should not be called when snapshot is present')
monkeypatch.setattr(module.renewal_service, 'calculate_pricing', fail_calculate)
captured: dict[str, Any] = {}
async def fake_finalize(db, u, sub, pricing, *, charge_balance_amount=None, description=None, payment_method=None): # noqa: ARG001
captured['pricing'] = pricing
captured['charge'] = charge_balance_amount
captured['description'] = description
captured['payment_method'] = payment_method
return SubscriptionRenewalResult(
subscription=types.SimpleNamespace(id=sub.id, end_date=datetime.utcnow()),
transaction=types.SimpleNamespace(id=999),
total_amount_kopeks=pricing.final_total,
charged_from_balance_kopeks=charge_balance_amount or pricing.final_total,
old_end_date=None,
)
monkeypatch.setattr(module.renewal_service, 'finalize', fake_finalize)
async def fake_link(db, invoice_id, transaction_id): # noqa: ARG001
captured['linked'] = (invoice_id, transaction_id)
cryptobot_crud = types.SimpleNamespace(link_cryptobot_payment_to_transaction=fake_link)
result = await mixin._process_subscription_renewal_payment(
db=types.SimpleNamespace(),
payment=payment,
descriptor=descriptor,
cryptobot_crud=cryptobot_crud,
)
assert result is True
assert captured['pricing'].server_ids == [11, 22]
assert captured['pricing'].final_total == 10000
assert captured['charge'] == 7000
assert captured['payment_method'] == PaymentMethod.CRYPTOBOT
assert captured['linked'] == ('INV-1', 999)
@pytest.mark.anyio("asyncio")
async def test_cryptobot_renewal_accepts_changed_pricing_without_snapshot(monkeypatch):
module = sys.modules['app.services.payment.cryptobot']
mixin = CryptoBotPaymentMixin()
subscription = types.SimpleNamespace(id=55, connected_squads=[], traffic_limit_gb=50, device_limit=3)
user = types.SimpleNamespace(id=8, balance_kopeks=4000, subscription=subscription)
descriptor = build_payment_descriptor(
user_id=8,
subscription_id=55,
period_days=30,
total_amount_kopeks=5000,
missing_amount_kopeks=1000,
)
payment = types.SimpleNamespace(invoice_id='INV-2', user_id=8)
async def fake_get_user_by_id(db, user_id): # noqa: ARG001
return user if user_id == 8 else None
monkeypatch.setitem(sys.modules, 'app.services.payment_service', types.SimpleNamespace(get_user_by_id=fake_get_user_by_id))
recalculated_pricing = SubscriptionRenewalPricing(
period_days=30,
period_id='days:30',
months=1,
base_original_total=5200,
discounted_total=5200,
final_total=5200,
promo_discount_value=0,
promo_discount_percent=0,
overall_discount_percent=0,
per_month=5200,
server_ids=[],
details={},
)
async def fake_calculate(db, u, sub, period): # noqa: ARG001
return recalculated_pricing
monkeypatch.setattr(module.renewal_service, 'calculate_pricing', fake_calculate)
captured: dict[str, Any] = {}
async def fake_finalize(db, u, sub, pricing, *, charge_balance_amount=None, description=None, payment_method=None): # noqa: ARG001
captured['pricing'] = pricing
captured['charge'] = charge_balance_amount
return SubscriptionRenewalResult(
subscription=types.SimpleNamespace(id=sub.id, end_date=datetime.utcnow()),
transaction=None,
total_amount_kopeks=pricing.final_total,
charged_from_balance_kopeks=charge_balance_amount or pricing.final_total,
old_end_date=None,
)
monkeypatch.setattr(module.renewal_service, 'finalize', fake_finalize)
async def noop_link(*args, **kwargs):
return None
cryptobot_crud = types.SimpleNamespace(link_cryptobot_payment_to_transaction=noop_link)
result = await mixin._process_subscription_renewal_payment(
db=types.SimpleNamespace(),
payment=payment,
descriptor=descriptor,
cryptobot_crud=cryptobot_crud,
)
assert result is True
assert captured['pricing'].final_total == 5000
assert captured['charge'] == 4000
@pytest.mark.anyio("asyncio")
async def test_cryptobot_webhook_uses_inline_payload_when_db_missing(monkeypatch):
module = sys.modules['app.services.payment.cryptobot']
mixin = CryptoBotPaymentMixin()
subscription = types.SimpleNamespace(id=91, connected_squads=[], traffic_limit_gb=80, device_limit=4)
user = types.SimpleNamespace(id=21, balance_kopeks=6000, subscription=subscription)
pricing_model = SubscriptionRenewalPricing(
period_days=30,
period_id='days:30',
months=1,
base_original_total=9000,
discounted_total=9000,
final_total=9000,
promo_discount_value=0,
promo_discount_percent=0,
overall_discount_percent=0,
per_month=9000,
server_ids=[5, 6],
details={'servers_individual_prices': [300, 300]},
)
descriptor = build_payment_descriptor(
user_id=21,
subscription_id=91,
period_days=30,
total_amount_kopeks=9000,
missing_amount_kopeks=3000,
pricing_snapshot=pricing_model.to_payload(),
)
encoded_payload = encode_payment_payload(descriptor)
payment = types.SimpleNamespace(
invoice_id='INV-webhook',
user_id=21,
status='active',
payload=None,
amount='90.00',
asset='USDT',
bot_invoice_url=None,
mini_app_invoice_url=None,
web_app_invoice_url=None,
description='Продление подписки',
)
async def fake_get_user_by_id(db, user_id): # noqa: ARG001
return user if user_id == 21 else None
monkeypatch.setitem(
sys.modules,
'app.services.payment_service',
types.SimpleNamespace(get_user_by_id=fake_get_user_by_id),
)
async def fail_calculate(*args, **kwargs): # noqa: ARG001
raise AssertionError('calculate_pricing should not be called')
monkeypatch.setattr(module.renewal_service, 'calculate_pricing', fail_calculate)
captured: dict[str, Any] = {}
async def fake_finalize(db, u, sub, pricing, *, charge_balance_amount=None, description=None, payment_method=None): # noqa: ARG001
captured['pricing'] = pricing
captured['charge'] = charge_balance_amount
captured['description'] = description
captured['payment_method'] = payment_method
return SubscriptionRenewalResult(
subscription=types.SimpleNamespace(id=sub.id, end_date=datetime.utcnow()),
transaction=types.SimpleNamespace(id=1234),
total_amount_kopeks=pricing.final_total,
charged_from_balance_kopeks=charge_balance_amount or pricing.final_total,
old_end_date=None,
)
monkeypatch.setattr(module.renewal_service, 'finalize', fake_finalize)
linked: dict[str, Any] = {}
async def fake_get(db, invoice_id): # noqa: ARG001
return payment if invoice_id == payment.invoice_id else None
async def fake_update(db, invoice_id, status, paid_at): # noqa: ARG001
if invoice_id == payment.invoice_id:
payment.status = status
payment.paid_at = paid_at
return payment
async def fake_link(db, invoice_id, transaction_id): # noqa: ARG001
linked['value'] = (invoice_id, transaction_id)
return payment
monkeypatch.setitem(
sys.modules,
'app.database.crud.cryptobot',
types.SimpleNamespace(
get_cryptobot_payment_by_invoice_id=fake_get,
update_cryptobot_payment_status=fake_update,
link_cryptobot_payment_to_transaction=fake_link,
),
)
webhook_payload = {
'update_type': 'invoice_paid',
'payload': {
'invoice_id': payment.invoice_id,
'paid_at': '2024-05-01T12:00:00Z',
'payload': encoded_payload,
},
}
result = await mixin.process_cryptobot_webhook(types.SimpleNamespace(), webhook_payload)
assert result is True
assert captured['pricing'].final_total == 9000
assert captured['charge'] == 6000
assert captured['payment_method'] == PaymentMethod.CRYPTOBOT
assert linked['value'] == (payment.invoice_id, 1234)
@pytest.mark.anyio("asyncio") @pytest.mark.anyio("asyncio")
async def test_create_payment_link_pal24_uses_selected_option(monkeypatch): async def test_create_payment_link_pal24_uses_selected_option(monkeypatch):
monkeypatch.setattr(settings, 'PAL24_ENABLED', True, raising=False) monkeypatch.setattr(settings, 'PAL24_ENABLED', True, raising=False)
@@ -491,6 +1067,8 @@ async def test_get_payment_methods_exposes_stars_min_amount(monkeypatch):
assert stars_method is not None assert stars_method is not None
assert stars_method.min_amount_kopeks == 99999 assert stars_method.min_amount_kopeks == 99999
assert stars_method.amount_step_kopeks == 99999 assert stars_method.amount_step_kopeks == 99999
assert stars_method.integration_type == MiniAppPaymentIntegrationType.REDIRECT
assert stars_method.iframe_config is None
@pytest.mark.anyio("asyncio") @pytest.mark.anyio("asyncio")
@@ -515,6 +1093,32 @@ async def test_get_payment_methods_includes_wata(monkeypatch):
assert wata_method.min_amount_kopeks == 5000 assert wata_method.min_amount_kopeks == 5000
assert wata_method.max_amount_kopeks == 7500000 assert wata_method.max_amount_kopeks == 7500000
assert wata_method.icon == '🌊' assert wata_method.icon == '🌊'
assert wata_method.integration_type == MiniAppPaymentIntegrationType.REDIRECT
assert wata_method.iframe_config is None
@pytest.mark.anyio("asyncio")
async def test_get_payment_methods_marks_mulenpay_iframe(monkeypatch):
monkeypatch.setattr(settings, 'MULENPAY_ENABLED', True, raising=False)
monkeypatch.setattr(settings, 'MULENPAY_API_KEY', 'api-key', raising=False)
monkeypatch.setattr(settings, 'MULENPAY_SECRET_KEY', 'secret', raising=False)
monkeypatch.setattr(settings, 'MULENPAY_SHOP_ID', 99, raising=False)
monkeypatch.setattr(settings, 'MULENPAY_BASE_URL', 'https://checkout.example/api', raising=False)
monkeypatch.setattr(settings, 'MULENPAY_IFRAME_EXPECTED_ORIGIN', None, raising=False)
async def fake_resolve_user(db, init_data):
return types.SimpleNamespace(id=1, language='ru'), {}
monkeypatch.setattr(miniapp, '_resolve_user_from_init_data', fake_resolve_user)
payload = MiniAppPaymentMethodsRequest(initData='abc')
response = await miniapp.get_payment_methods(payload, db=types.SimpleNamespace())
mulenpay_method = next((method for method in response.methods if method.id == 'mulenpay'), None)
assert mulenpay_method is not None
assert mulenpay_method.integration_type == MiniAppPaymentIntegrationType.IFRAME
assert mulenpay_method.iframe_config is not None
assert str(mulenpay_method.iframe_config.expected_origin) == 'https://checkout.example'
@pytest.mark.anyio("asyncio") @pytest.mark.anyio("asyncio")
async def test_find_recent_deposit_ignores_transactions_before_attempt(): async def test_find_recent_deposit_ignores_transactions_before_attempt():
started_at = datetime(2024, 5, 1, 12, 0, 0) started_at = datetime(2024, 5, 1, 12, 0, 0)