Compare commits

..

162 Commits

Author SHA1 Message Date
dependabot[bot] e7da2a64f8 docker(deps): bump python from 3.13-slim to 3.14-slim
Bumps python from 3.13-slim to 3.14-slim.

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-10 03:33:10 +00:00
Egor 813193102c Update Dockerfile 2025-11-10 06:28:50 +03:00
Egor 42112d6c8a Update docker-registry.yml 2025-11-10 06:28:40 +03:00
Egor dfe5e36fe0 Update docker-hub.yml 2025-11-10 06:28:31 +03:00
Egor b5d4a54e97 Update README.md 2025-11-10 06:22:26 +03:00
Egor 1eaf7204ce Merge pull request #1859 from Fr1ngg/dev4
fix #1849 Round CryptoBot top-up amounts up to whole rubles
2025-11-10 06:13:51 +03:00
Egor 8cc37dc439 Merge pull request #1858 from Fr1ngg/bedolaga/fix-webhook-handler-error-in-cryptobot-i1csym
Round CryptoBot top-up amounts up to whole rubles
2025-11-10 06:11:42 +03:00
Egor ccafb18122 Round CryptoBot top-up amounts up to whole rubles 2025-11-10 06:11:18 +03:00
Egor 654b132ae6 Merge pull request #1857 from Fr1ngg/revert-1855-bedolaga/fix-webhook-handler-error-in-cryptobot
Revert "Refactor CryptoBot notifications to avoid transaction rollback"
2025-11-10 06:11:05 +03:00
Egor 815fa0560c Revert "Refactor CryptoBot notifications to avoid transaction rollback" 2025-11-10 06:10:54 +03:00
Egor c4d19810a4 Merge pull request #1856 from Fr1ngg/dev4
fix
2025-11-10 06:05:55 +03:00
Egor 9fbb32eb5b Merge pull request #1855 from Fr1ngg/bedolaga/fix-webhook-handler-error-in-cryptobot
Refactor CryptoBot notifications to avoid transaction rollback
2025-11-10 05:59:09 +03:00
Egor 3ce3cde859 Refactor CryptoBot notifications to avoid transaction rollback 2025-11-10 05:53:26 +03:00
Egor 4e71dcd2b1 Merge pull request #1853 from Fr1ngg/dev4
Update Pal24 callback handling and SBP link fallback
2025-11-10 05:33:11 +03:00
Egor acf21fa6d2 Merge pull request #1852 from Fr1ngg/bedolaga/update-requirements.txt-and-fix-pal24-webhook
Update Pal24 callback handling and SBP link fallback
2025-11-10 05:29:04 +03:00
Egor f2b724a78d Adjust Pal24 callback handling and SBP link fallback 2025-11-10 05:26:02 +03:00
Egor 03473360c1 Merge pull request #1851 from Fr1ngg/dev4
Dev4
2025-11-10 04:30:16 +03:00
Egor 27dc68be41 Merge pull request #1850 from Fr1ngg/bedolaga/fix-webhook-bot-error-handling
Prevent maintenance monitoring from crashing without RemnaWave URL
2025-11-10 04:26:35 +03:00
Egor b15d78f8af Handle missing RemnaWave config in maintenance monitoring 2025-11-10 04:25:52 +03:00
Egor d232d14937 Merge pull request #1848 from Fr1ngg/dev4
fix migration on new bases
2025-11-10 04:13:19 +03:00
Egor 927d469db2 Merge pull request #1847 from Fr1ngg/bedolaga/fix-database-migration-crash-on-first-bot-launch
Handle missing tables when creating indexes during init_db
2025-11-10 04:06:46 +03:00
Egor e20e9e7cec Fix init_db index creation on fresh databases 2025-11-10 04:06:28 +03:00
Egor 0f097c3108 Merge pull request #1830 from Gy9vin/main
Фикс простой покупки!
2025-11-10 03:37:31 +03:00
gy9vin eaa3c80d59 Фикс простой покупки! 2025-11-09 21:55:58 +03:00
Egor 0c49eddfde Merge pull request #1828 from Gy9vin/main
Фикс проверки зачисления платежа юкассы(защита от дублирования)
2025-11-09 20:09:15 +03:00
gy9vin 33882f1e93 Фикс проверки зачисления платежа юкассы(защита от дублирования) 2025-11-09 18:31:39 +03:00
Egor f94666b68d Merge pull request #1826 from Fr1ngg/dev4
Dev4
2025-11-09 10:29:27 +03:00
Egor d32a49f801 Merge pull request #1825 from Fr1ngg/bedolaga/fix-bulk_update-to-include-where-clause
Fix bulk_update to target records by primary key
2025-11-09 10:29:11 +03:00
Egor 9596f82ea6 Fix bulk_update to target records by primary key 2025-11-09 10:28:47 +03:00
Egor 0e3fdf7d61 Merge pull request #1824 from Fr1ngg/dev4
Razgonchik
2025-11-09 10:24:57 +03:00
Egor 54cfec6029 Merge pull request #1823 from Fr1ngg/bedolaga/fix-repeated-yookassa-deposits-y7sxo6
Ensure YooKassa retries finish pending credits
2025-11-09 10:13:20 +03:00
Egor 9e88eae416 Ensure YooKassa retries finish pending credits 2025-11-09 10:13:01 +03:00
Egor 8d32efac73 Merge pull request #1822 from Fr1ngg/revert-1821-bedolaga/fix-repeated-yookassa-deposits
Revert "Prevent double processing of YooKassa payments"
2025-11-09 10:04:02 +03:00
Egor 9f07b6007e Revert "Prevent double processing of YooKassa payments" 2025-11-09 10:03:54 +03:00
Egor 2fb3b6c5dc Merge pull request #1821 from Fr1ngg/bedolaga/fix-repeated-yookassa-deposits
Prevent double processing of YooKassa payments
2025-11-09 10:03:38 +03:00
Egor 0637576425 Prevent double processing of YooKassa payments 2025-11-09 10:00:17 +03:00
Egor ee7a445610 Update database.py 2025-11-09 09:46:40 +03:00
Egor ba4c7ee7ac Update database.py 2025-11-09 09:43:26 +03:00
Egor 4da169eb0f Merge pull request #1819 from Fr1ngg/bedolaga/fix-health-endpoints-for-sqlite-nullpool
Handle missing pool metrics for NullPool
2025-11-09 09:40:53 +03:00
Egor 28937abab4 Handle missing pool metrics for NullPool 2025-11-09 09:40:08 +03:00
Egor b1dcf2779e Update database.py 2025-11-09 09:25:59 +03:00
Egor 3219062ce2 Update database.py 2025-11-09 09:22:36 +03:00
Egor 2ac72951f8 Merge pull request #1818 from Fr1ngg/bedolaga/add-health-and-metrics-endpoints-to-fastapi
Add database health and pool metrics endpoints
2025-11-09 09:17:35 +03:00
Egor cefb8cba78 Add database health and pool metrics endpoints 2025-11-09 09:17:18 +03:00
Egor e869655028 Update database.py 2025-11-09 09:11:47 +03:00
Egor ae03ede209 Merge pull request #1817 from Fr1ngg/dev4
Dev
2025-11-09 08:34:49 +03:00
Egor a72bb25ea2 Update database.py 2025-11-09 08:30:38 +03:00
Egor 0b22bc7056 Merge pull request #1815 from Fr1ngg/bedolaga/-nullpool-queuepool-wh494i
Handle optional Pal24 metadata fields
2025-11-09 08:24:26 +03:00
Egor cde84ff20b Handle optional Pal24 metadata fields 2025-11-09 08:23:01 +03:00
Egor f76114d7dc Update database.py 2025-11-09 08:17:43 +03:00
Egor 9708a56fd7 Update database.py 2025-11-09 08:14:41 +03:00
Egor a360337660 Merge pull request #1813 from Fr1ngg/dev4
фикс бекапов/восстановления
2025-11-09 07:39:18 +03:00
Egor 9f78335651 Merge pull request #1812 from Fr1ngg/bedolaga/improve-backup-and-restore-mechanism-1wazcg
Avoid duplicate association errors during restore
2025-11-09 07:31:17 +03:00
Egor ef8142a99b Skip duplicate squad promo associations during restore 2025-11-09 07:30:54 +03:00
Egor 06d51f9fcf Merge pull request #1811 from Fr1ngg/revert-1810-bedolaga/improve-backup-and-restore-mechanism-gn1h90
Revert "Handle PostgreSQL backups without pg_dump"
2025-11-09 07:30:41 +03:00
Egor 4d94347179 Revert "Handle PostgreSQL backups without pg_dump" 2025-11-09 07:30:31 +03:00
Egor 2f6aa0b1e6 Merge pull request #1810 from Fr1ngg/bedolaga/improve-backup-and-restore-mechanism-gn1h90
Handle PostgreSQL backups without pg_dump
2025-11-09 07:23:11 +03:00
Egor 536a58d4d3 Handle PostgreSQL backups without pg_dump 2025-11-09 07:22:50 +03:00
Egor 62f7c07059 Merge pull request #1809 from Fr1ngg/revert-1808-bedolaga/improve-backup-and-restore-mechanism
Revert "Refactor backup pipeline to use archive dumps"
2025-11-09 07:22:10 +03:00
Egor 0648edb3c4 Revert "Refactor backup pipeline to use archive dumps" 2025-11-09 07:21:56 +03:00
Egor 9b6c235424 Merge pull request #1808 from Fr1ngg/bedolaga/improve-backup-and-restore-mechanism
Refactor backup pipeline to use archive dumps
2025-11-09 07:12:18 +03:00
Egor 2fc35950cf Refactor backup system with archive dumps 2025-11-09 07:12:01 +03:00
Egor d2a5d74239 Merge pull request #1807 from Fr1ngg/dev4
Dev4
2025-11-09 07:06:24 +03:00
Egor 6b04c6748e Merge pull request #1806 from Fr1ngg/bedolaga/add-trial-activation-funds-check
Prefill top-up amount for paid trial activation
2025-11-09 06:58:03 +03:00
Egor 10b44cbdb7 Prefill top-up amount for paid trial activation 2025-11-09 06:51:50 +03:00
Egor dde04c716c Merge pull request #1805 from Fr1ngg/bedolaga/fix-variable-loading-for-minimum-sum
Adjust Platega top-up prompt to show configured limits
2025-11-09 06:44:55 +03:00
Egor 819f19a7ea Update Platega prompt to use configured limits 2025-11-09 06:44:23 +03:00
Egor e7201597ab Merge pull request #1804 from Fr1ngg/bedolaga/fix-pall24-payment-method-issue
Fix Pal24 SBP link handling after status checks
2025-11-09 06:35:54 +03:00
Egor ea61aa7053 Fix Pal24 SBP link handling after status checks 2025-11-09 06:31:02 +03:00
Egor 14546a8485 Merge pull request #1803 from Fr1ngg/bedolaga/add-ip-validation-for-yookassa-webhooks
Handle Cloudflare proxied YooKassa webhooks
2025-11-09 06:22:17 +03:00
Egor f55455761f Trust Cloudflare headers for YooKassa webhooks 2025-11-09 06:21:40 +03:00
Egor b1a7767827 Merge pull request #1802 from Fr1ngg/revert-1801-bedolaga/add-server-category-feature-to-admin-panel-ltnwjl
Revert "Fix server category button routing in admin panel"
2025-11-09 05:55:16 +03:00
Egor 43c04b7e64 Revert "Fix server category button routing in admin panel" 2025-11-09 05:55:07 +03:00
Egor add679cf6f Merge pull request #1801 from Fr1ngg/bedolaga/add-server-category-feature-to-admin-panel-ltnwjl
Fix server category button routing in admin panel
2025-11-09 05:49:00 +03:00
Egor ee173190a0 Fix category edit menu callback 2025-11-09 05:48:45 +03:00
Egor a6e21b47f6 Merge pull request #1800 from Fr1ngg/revert-1799-bedolaga/add-server-category-feature-to-admin-panel-fwofd2
Revert "Fix category pricing to ignore full squads"
2025-11-09 05:40:56 +03:00
Egor cd627e5840 Revert "Fix category pricing to ignore full squads" 2025-11-09 05:40:47 +03:00
Egor fc096e6867 Merge pull request #1799 from Fr1ngg/bedolaga/add-server-category-feature-to-admin-panel-fwofd2
Fix category pricing to ignore full squads
2025-11-09 05:33:29 +03:00
Egor b591844c4e Fix category pricing to ignore full squads 2025-11-09 05:33:13 +03:00
Egor 9557acb546 Merge pull request #1798 from Fr1ngg/revert-1797-bedolaga/add-server-category-feature-to-admin-panel
Revert "Ensure server category selection skips full squads"
2025-11-09 05:31:20 +03:00
Egor 1fa6e75bd3 Revert "Ensure server category selection skips full squads" 2025-11-09 05:31:09 +03:00
Egor 8965e43a55 Merge pull request #1797 from Fr1ngg/bedolaga/add-server-category-feature-to-admin-panel
Ensure server category selection skips full squads
2025-11-09 05:24:50 +03:00
Egor 8e7a6beece Ensure category auto-selection respects capacity 2025-11-09 05:23:58 +03:00
Egor e9f4fd8007 Merge pull request #1796 from Fr1ngg/revert-1793-bedolaga/implement-server-grouping-system-for-vpn-bot
Revert "Add server group backend selection logic"
2025-11-09 04:54:55 +03:00
Egor a991c59d58 Revert "Add server group backend selection logic" 2025-11-09 04:54:47 +03:00
Egor dd0624adf8 Merge pull request #1795 from Fr1ngg/revert-1794-bedolaga/check-functionality-for-server-grouping
Revert "Fix autopurchase server selection and RemnaWave stats handling"
2025-11-09 04:54:31 +03:00
Egor ab51cbe96a Revert "Fix autopurchase server selection and RemnaWave stats handling" 2025-11-09 04:54:24 +03:00
Egor 2f7a08deb0 Merge pull request #1794 from Fr1ngg/bedolaga/check-functionality-for-server-grouping
Fix autopurchase server selection and RemnaWave stats handling
2025-11-09 04:50:50 +03:00
Egor a0fa7f986b Fix autopurchase server selection and RemnaWave stats handling 2025-11-09 04:50:32 +03:00
Egor b2d3eebe39 Merge pull request #1793 from Fr1ngg/bedolaga/implement-server-grouping-system-for-vpn-bot
Add server group backend selection logic
2025-11-09 04:39:54 +03:00
Egor a043fc0e46 feat: add backend support for server groups 2025-11-09 04:36:33 +03:00
Egor d6eec8787e Merge pull request #1792 from Fr1ngg/bedolaga/fix-payment-methods-display-for-platega.io
Add Platega payment option to balance top-up list
2025-11-09 03:31:21 +03:00
Egor 95eee42047 Добавить Platega в список пополнения 2025-11-09 03:30:49 +03:00
Egor c673d6db4f Merge pull request #1790 from Fr1ngg/dev4
Dev4
2025-11-08 18:58:33 +03:00
Egor bb32a63d48 Merge pull request #1789 from Fr1ngg/bedolaga/fix-null-value-constraint-in-promo_groups
Fix default promo group creation with priority column
2025-11-08 18:58:13 +03:00
Egor af5acb272c Fix default promo group creation with priority column 2025-11-08 18:57:53 +03:00
Egor 5cd5ac8710 Merge pull request #1788 from Fr1ngg/revert-1787-dev4
Revert "Dev4"
2025-11-08 12:05:27 +03:00
Egor 8bb58b44b3 Revert "Dev4" 2025-11-08 12:05:12 +03:00
Egor 511ecd506f Merge pull request #1787 from Fr1ngg/dev4
Dev4
2025-11-08 11:38:41 +03:00
Egor 62fc014bc3 Restore json serialization helper for YooKassa webhook tests 2025-11-08 11:38:18 +03:00
Egor 961f564274 Merge pull request #1786 from Fr1ngg/bedolaga/fix-webhook-ip-verification-for-lcydgf
Remove YooKassa IP filtering from webhooks
2025-11-08 11:27:53 +03:00
Egor 984870c78c Remove YooKassa IP filtering from webhooks 2025-11-08 11:27:37 +03:00
Egor 6196c135d2 Merge pull request #1785 from Fr1ngg/revert-1782-bedolaga/fix-webhook-ip-verification-for-w66vvd
Revert "Verify YooKassa webhooks against API"
2025-11-08 11:27:23 +03:00
Egor bf1b8315a8 Revert "Verify YooKassa webhooks against API" 2025-11-08 11:27:13 +03:00
Egor b7eabcf7e7 Merge pull request #1784 from Fr1ngg/revert-1783-dev4
Revert "Dev4"
2025-11-08 11:08:55 +03:00
Egor e83ebc4f02 Revert "Dev4" 2025-11-08 11:08:47 +03:00
Egor 1a4758f4cc Merge pull request #1783 from Fr1ngg/dev4
Dev4
2025-11-08 11:04:08 +03:00
Egor b80e8b3804 Merge pull request #1782 from Fr1ngg/bedolaga/fix-webhook-ip-verification-for-w66vvd
Verify YooKassa webhooks against API
2025-11-08 11:00:49 +03:00
Egor e036173670 Verify YooKassa webhooks against API 2025-11-08 11:00:28 +03:00
Egor bd728c6466 Merge pull request #1781 from Fr1ngg/revert-1777-bedolaga/fix-webhook-ip-verification-for
Revert "Handle Cloudflare forwarded IPs for YooKassa webhooks"
2025-11-08 10:58:48 +03:00
Egor e3aec310a3 Revert "Handle Cloudflare forwarded IPs for YooKassa webhooks" 2025-11-08 10:58:34 +03:00
Egor 1c59ce601e Merge pull request #1780 from Fr1ngg/revert-1779-dev4
Revert "da eb tvoyu mat' kakoy pidoras delal etu ukassu "
2025-11-08 10:58:21 +03:00
Egor 0563f7524f Revert "da eb tvoyu mat' kakoy pidoras delal etu ukassu " 2025-11-08 10:58:12 +03:00
Egor cedbe07759 Merge pull request #1779 from Fr1ngg/dev4
da eb tvoyu mat' kakoy pidoras delal etu ukassu
2025-11-08 10:53:02 +03:00
Egor 0746b2a5a7 Merge pull request #1778 from Fr1ngg/main
w
2025-11-08 10:52:15 +03:00
Egor e8a72860dc Merge pull request #1777 from Fr1ngg/bedolaga/fix-webhook-ip-verification-for
Handle Cloudflare forwarded IPs for YooKassa webhooks
2025-11-08 10:51:50 +03:00
Egor abbfe4a7d3 Handle Cloudflare forwarded IPs for YooKassa webhooks 2025-11-08 10:50:56 +03:00
Egor 1ad5d9123b Update Dockerfile 2025-11-08 09:05:30 +03:00
Egor 57031e4afe Update docker-registry.yml 2025-11-08 09:05:14 +03:00
Egor b54d2434c0 Update docker-hub.yml 2025-11-08 09:05:02 +03:00
Egor 7c2d7b6b4f Merge pull request #1776 from Fr1ngg/dev4
платный триал + фиксы вебхука ебучей юкассы(без негатива))(
2025-11-08 08:35:25 +03:00
Egor 9e731a5f18 Merge pull request #1775 from Fr1ngg/bedolaga/add-paid-trial-activation-feature-fo5kxf
Bedolaga/add paid trial activation feature fo5kxf
2025-11-08 08:25:32 +03:00
Egor cd3832fef0 Handle trial provisioning failures by refunding charges 2025-11-08 08:14:49 +03:00
Egor cb6d51ed3d Remove trial squad override setting 2025-11-08 07:55:24 +03:00
Egor 8b75768296 Merge pull request #1773 from Fr1ngg/revert-1772-bedolaga/add-paid-trial-activation-feature-pws6p8
Revert "Rollback trial activation when charge errors occur"
2025-11-08 07:55:14 +03:00
Egor 3f0a26b1c7 Revert "Rollback trial activation when charge errors occur" 2025-11-08 07:55:04 +03:00
Egor de4f608d09 Merge pull request #1772 from Fr1ngg/bedolaga/add-paid-trial-activation-feature-pws6p8
Rollback trial activation when charge errors occur
2025-11-08 07:42:33 +03:00
Egor 618327b9f1 Rollback trial trials when post-charge errors occur 2025-11-08 07:37:21 +03:00
Egor 54fdad8f0c Merge pull request #1771 from Fr1ngg/revert-1770-bedolaga/add-paid-trial-activation-feature-obw312
Revert "Delay trial activation charges until after subscription creation"
2025-11-08 07:27:03 +03:00
Egor e9bbd68aa1 Revert "Delay trial activation charges until after subscription creation" 2025-11-08 07:26:54 +03:00
Egor 5df4837087 Merge pull request #1770 from Fr1ngg/bedolaga/add-paid-trial-activation-feature-obw312
Delay trial activation charges until after subscription creation
2025-11-08 07:18:14 +03:00
Egor 025e74c462 Defer trial activation charges until subscription creation succeeds 2025-11-08 07:17:57 +03:00
Egor 3208303c23 Merge pull request #1767 from Fr1ngg/bedolaga/remove-webhook-secret-references-for-yukassa-n5vl0v
Handle YooKassa cancellations in FastAPI webhook
2025-11-08 05:59:35 +03:00
Egor 4cf0c5a971 Handle YooKassa cancellations in FastAPI webhook 2025-11-08 05:44:53 +03:00
Egor 7519352e79 Merge pull request #1765 from Fr1ngg/main
w
2025-11-08 04:47:01 +03:00
Egor e7f94f9661 Merge pull request #1763 from aleksandryants/patch-1
Add wata payment processing method
2025-11-08 04:46:16 +03:00
Egor cb68a612e9 Merge pull request #1764 from Gy9vin/main
Обновка!
2025-11-08 04:44:25 +03:00
Egor 44d4deb5a6 Update .gitignore 2025-11-08 04:43:58 +03:00
gy9vin 23c73d1f21 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-11-07 23:56:51 +03:00
gy9vin 607beca837 fix 2025-11-07 23:56:49 +03:00
Egor 31a5f8766f Delete QWEN.md 2025-11-07 23:21:06 +03:00
gy9vin cfdc829f48 update 2025-11-07 22:24:43 +03:00
aleksandryants c44cefb983 Add wata payment processing method
Добавить обработку платежа wata
2025-11-07 19:49:27 +03:00
Egor 79c2307c92 Merge pull request #1762 from Fr1ngg/dev4
ебучая юкасса
2025-11-07 10:55:04 +03:00
Egor 25f126a5cb Merge pull request #1761 from Fr1ngg/bedolaga/disable-webhook-secret-validation-and-check-ip-tr0szb
fix: allow trusted public proxies for YooKassa webhooks
2025-11-07 10:49:17 +03:00
Egor 45bd54e7b0 fix: allow trusted public proxies for YooKassa webhooks 2025-11-07 10:43:19 +03:00
Egor 4ce43f80b8 Merge pull request #1755 from Fr1ngg/dev4
Platega + small fixs
2025-11-07 08:40:52 +03:00
Egor f7a4cedbbf Fix missing logging import in Platega handler 2025-11-07 08:29:29 +03:00
Egor 6f83122e42 Merge pull request #1753 from Fr1ngg/bedolaga/update-readme-with-platega.io-info
docs: document Platega payment integration
2025-11-07 08:18:18 +03:00
Egor dbcf3541a8 docs: document Platega payment integration 2025-11-07 08:16:45 +03:00
Egor b7adf3e971 Merge pull request #1752 from Fr1ngg/main
w
2025-11-07 08:08:01 +03:00
Egor 037b1f876b Merge pull request #1751 from Fr1ngg/bedolaga/fix-display-issue-for-unlimited-traffic
Fix unlimited traffic display in payment methods
2025-11-07 08:01:35 +03:00
Egor 4495658fad Fix unlimited traffic display in payment methods 2025-11-07 07:58:54 +03:00
Egor 017b758e6c Merge pull request #1750 from Fr1ngg/bedolaga/move-platega-settings-to-payments-section
Add Platega payment tests
2025-11-07 07:52:37 +03:00
Egor edff6d5102 Add tests for Platega payment flows and method parsing 2025-11-07 07:49:38 +03:00
Egor 979fbe305a Merge pull request #1749 from Fr1ngg/bedolaga/fix-trial-access-issue-for-users
Handle missing devices placeholder in trial offer message
2025-11-07 07:24:34 +03:00
Egor d1edb8f892 Fix trial offer formatting for device limit 2025-11-07 07:17:53 +03:00
Egor 0eeda6b9ac Merge pull request #1748 from Fr1ngg/bedolaga/integrate-new-payment-method-for-bot
Integrate Platega balance top-ups
2025-11-07 07:05:45 +03:00
Egor 7c31c3616e Guard Platega finalization against duplicate callbacks 2025-11-07 07:05:22 +03:00
Egor fba80b1a0d Integrate Platega balance top-ups 2025-11-07 06:52:57 +03:00
Egor 86dbb317f3 Delete redirect directory 2025-11-07 05:10:03 +03:00
Egor 7432d43b55 Update README.md 2025-11-07 03:32:54 +03:00
70 changed files with 6179 additions and 1477 deletions
+18 -2
View File
@@ -85,7 +85,8 @@ REMNAWAVE_USER_DELETE_MODE=delete
TRIAL_DURATION_DAYS=3
TRIAL_TRAFFIC_LIMIT_GB=10
TRIAL_DEVICE_LIMIT=1
TRIAL_SQUAD_UUID=
TRIAL_PAYMENT_ENABLED=false
TRIAL_ACTIVATION_PRICE=0
# ===== ПЛАТНАЯ ПОДПИСКА =====
# Сколько устройств доступно по дефолту при покупке платной подписки
@@ -242,7 +243,6 @@ YOOKASSA_PAYMENT_SUBJECT=service
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
YOOKASSA_WEBHOOK_HOST=0.0.0.0
YOOKASSA_WEBHOOK_PORT=8082
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
# Лимиты сумм пополнения через YooKassa (в копейках)
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
@@ -331,6 +331,22 @@ PAL24_SBP_BUTTON_VISIBLE=true
# Отображать кнопку оплаты картой в PayPalych (true - отображать, false - скрывать)
PAL24_CARD_BUTTON_VISIBLE=true
# PLATEGA
PLATEGA_ENABLED=false
PLATEGA_MERCHANT_ID=
PLATEGA_SECRET=
PLATEGA_BASE_URL=https://app.platega.io
PLATEGA_RETURN_URL=
PLATEGA_FAILED_URL=
PLATEGA_CURRENCY=RUB
# Список ID активных методов из кабинета Platega (через запятую)
PLATEGA_ACTIVE_METHODS=2,10,11,12,13
PLATEGA_MIN_AMOUNT_KOPEKS=10000
PLATEGA_MAX_AMOUNT_KOPEKS=100000000
PLATEGA_WEBHOOK_PATH=/platega-webhook
PLATEGA_WEBHOOK_HOST=0.0.0.0
PLATEGA_WEBHOOK_PORT=8086
# ===== ИНТЕРФЕЙС И UX =====
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
+3 -3
View File
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.6.0-$(git rev-parse --short HEAD)"
VERSION="v2.6.2-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.6.0-dev-$(git rev-parse --short HEAD)"
VERSION="v2.6.2-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.6.0-pr-$(git rev-parse --short HEAD)"
VERSION="v2.6.2-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+3 -3
View File
@@ -49,13 +49,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.6.0-$(git rev-parse --short HEAD)"
VERSION="v2.6.2-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.6.0-dev-$(git rev-parse --short HEAD)"
VERSION="v2.6.2-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.6.0-pr-$(git rev-parse --short HEAD)"
VERSION="v2.6.2-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+3 -3
View File
@@ -1,4 +1,4 @@
FROM python:3.13-slim AS builder
FROM python:3.14-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
@@ -12,9 +12,9 @@ COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
FROM python:3.13-slim
FROM python:3.14-slim
ARG VERSION="v2.6.0"
ARG VERSION="v2.6.2"
ARG BUILD_DATE
ARG VCS_REF
+39 -5
View File
@@ -36,7 +36,7 @@
### ⚡ **Полная автоматизация VPN бизнеса**
- 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + Heleket + YooKassa (СБП + карты) + MulenPay + PayPalych (СБП + карты) + WATA
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + Heleket + YooKassa (СБП + карты) + MulenPay + PayPalych (СБП + карты) + Platega (карты + СБП) + WATA
- 🔄 **Автоматизация 99%** - от регистрации до продления подписок
- - 📱 **MiniApp лк** - личный кабинет с возможностью покупки/продления подписки
- 📊 **Детальная аналитика** - полная картина вашего бизнеса
@@ -150,8 +150,8 @@ docker compose logs
```env
BOT_RUN_MODE=webhook
WEBHOOK_URL=https://bot.example.com
WEBHOOK_PATH=/telegram/webhook
WEBHOOK_URL=https://api.domain.com
WEBHOOK_PATH=/webhook
WEBHOOK_SECRET_TOKEN=super-secret-token
WEBHOOK_DROP_PENDING_UPDATES=true
WEBHOOK_MAX_QUEUE_SIZE=1024
@@ -437,7 +437,7 @@ http {
| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` |
| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима.
[Полный список доступных параметров:](remnawave-bedolaga-telegram-bot/blob/main/.env.example)
[Полный список доступных параметров:](.env.example)
### 🌐 Интеграция веб-админки
@@ -479,6 +479,32 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
REMNAWAVE_SECRET_KEY=secret_key_name
```
### 💳 Platega.io
Платёжный провайдер [Platega.io](https://platega.io) добавляет ещё один способ приёма оплат картой и по СБП. Включите его, если у вас есть кабинет мерчанта и доступ к API.
1. В кабинете Platega получите `Merchant ID` и `Secret` (раздел **Интеграция → API**).
2. В настройках провайдера укажите URL возврата и ошибки. Их можно задать в `.env` (`PLATEGA_RETURN_URL`, `PLATEGA_FAILED_URL`).
3. Активируйте только нужные платёжные методы и пропишите их ID через запятую в `PLATEGA_ACTIVE_METHODS`.
4. Добавьте вебхук `https://your-domain.com/platega-webhook` в личном кабинете Platega.
Пример набора переменных окружения:
```env
PLATEGA_ENABLED=true
PLATEGA_MERCHANT_ID=your_merchant_id
PLATEGA_SECRET=your_secret_key
PLATEGA_RETURN_URL=https://your-domain.com/payments/success
PLATEGA_FAILED_URL=https://your-domain.com/payments/failed
PLATEGA_ACTIVE_METHODS=2,10,11
PLATEGA_MIN_AMOUNT_KOPEKS=10000
PLATEGA_MAX_AMOUNT_KOPEKS=5000000
PLATEGA_CURRENCY=RUB
PLATEGA_WEBHOOK_PATH=/platega-webhook
```
Остальные параметры (`PLATEGA_BASE_URL`, `PLATEGA_WEBHOOK_HOST`, `PLATEGA_WEBHOOK_PORT`) оставьте по умолчанию, если работаете через встроенный FastAPI сервер.
### 📊 Режимы продажи трафика
#### **Выбираемые пакеты** (по умолчанию)
@@ -512,6 +538,12 @@ TRAFFIC_PACKAGES_CONFIG="100:15000:true"
# Бесплатные устройства в триал подписке
TRIAL_DEVICE_LIMIT=1
# Требовать оплату за активацию триала
TRIAL_PAYMENT_ENABLED=false
# Стоимость активации триала (в копейках)
TRIAL_ACTIVATION_PRICE=0
# Бесплатные устройства в платной подписке
DEFAULT_DEVICE_LIMIT=3
@@ -609,6 +641,7 @@ REDIS_URL=redis://redis:6379/0
- 🪙 Heleket (криптовалюта с наценкой)
- 💳 MulenPay (СБП)
- 🏦 PayPalych/Pal24 (СБП + карты)
- 💳 Platega (СБП + банковские карты)
- 💳 **WATA**
- 📥 Автогенерация счетов и webhook-уведомления
- 💼 История операций
@@ -807,6 +840,7 @@ REDIS_URL=redis://redis:6379/0
- **Heleket**: Настрой webhook на `https://your-domain.com/heleket-webhook`
- **MulenPay**: Настрой webhook на `https://your-domain.com/mulenpay-webhook`
- **PayPalych**: Укажи Result URL `https://your-domain.com/pal24-webhook` в кабинете Pal24
- **Platega**: Настрой webhook на `https://your-domain.com/platega-webhook`
- **WATA**: Настрой webhook на `https://your-domain.com/wata-webhook`
4. **🔄 Настройка автосинхронизации** (опционально)
@@ -1131,7 +1165,7 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
| Метрика | Значение |
|---------|----------|
| 💳 **Платёжных систем** | 8 (Stars, YooKassa, Tribute, CryptoBot, Heleket, MulenPay, Pal24, WATA) |
| 💳 **Платёжных систем** | 9 (Stars, YooKassa, Tribute, CryptoBot, Heleket, MulenPay, Pal24, Platega, WATA) |
| 🌍 **Языков интерфейса** | 2 (RU, EN) с возможностью расширения |
| 📊 **Периодов подписки** | 6 (от 14 дней до года) |
| 🎁 **Типов промо-акций** | 5 (коды, группы, предложения, скидки, кампании) |
-1
View File
@@ -238,7 +238,6 @@ REMNAWAVE_API_KEY=your_api_key
ADMIN_IDS=123456789,987654321
# Webhook секреты - генерируйте случайные значения
YOOKASSA_WEBHOOK_SECRET=random_secret_here
TRIBUTE_WEBHOOK_SECRET=another_random_secret
CRYPTOBOT_WEBHOOK_SECRET=yet_another_secret
```
+103 -2
View File
@@ -85,9 +85,10 @@ class Settings(BaseSettings):
TRIAL_TRAFFIC_LIMIT_GB: int = 10
TRIAL_DEVICE_LIMIT: int = 2
TRIAL_ADD_REMAINING_DAYS_TO_PAID: bool = False
TRIAL_PAYMENT_ENABLED: bool = False
TRIAL_ACTIVATION_PRICE: int = 0
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
DEFAULT_DEVICE_LIMIT: int = 1
TRIAL_SQUAD_UUID: Optional[str] = None
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
RESET_TRAFFIC_ON_PAYMENT: bool = False
MAX_DEVICES_LIMIT: int = 20
@@ -187,7 +188,7 @@ class Settings(BaseSettings):
YOOKASSA_WEBHOOK_PATH: str = "/yookassa-webhook"
YOOKASSA_WEBHOOK_HOST: str = "0.0.0.0"
YOOKASSA_WEBHOOK_PORT: int = 8082
YOOKASSA_WEBHOOK_SECRET: Optional[str] = None
YOOKASSA_TRUSTED_PROXY_NETWORKS: str = ""
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
@@ -270,6 +271,20 @@ class Settings(BaseSettings):
PAL24_SBP_BUTTON_VISIBLE: bool = True
PAL24_CARD_BUTTON_VISIBLE: bool = True
PLATEGA_ENABLED: bool = False
PLATEGA_MERCHANT_ID: Optional[str] = None
PLATEGA_SECRET: Optional[str] = None
PLATEGA_BASE_URL: str = "https://app.platega.io"
PLATEGA_RETURN_URL: Optional[str] = None
PLATEGA_FAILED_URL: Optional[str] = None
PLATEGA_CURRENCY: str = "RUB"
PLATEGA_ACTIVE_METHODS: str = "2,10,11,12,13"
PLATEGA_MIN_AMOUNT_KOPEKS: int = 10000
PLATEGA_MAX_AMOUNT_KOPEKS: int = 100000000
PLATEGA_WEBHOOK_PATH: str = "/platega-webhook"
PLATEGA_WEBHOOK_HOST: str = "0.0.0.0"
PLATEGA_WEBHOOK_PORT: int = 8086
WATA_ENABLED: bool = False
WATA_BASE_URL: str = "https://api.wata.pro/api/h2h"
WATA_ACCESS_TOKEN: Optional[str] = None
@@ -869,6 +884,24 @@ class Settings(BaseSettings):
def get_disabled_mode_device_limit(self) -> Optional[int]:
return self.get_devices_selection_disabled_amount()
def is_trial_paid_activation_enabled(self) -> bool:
return bool(self.TRIAL_PAYMENT_ENABLED)
def get_trial_activation_price(self) -> int:
try:
value = int(self.TRIAL_ACTIVATION_PRICE)
except (TypeError, ValueError):
logger.warning(
"Некорректное значение TRIAL_ACTIVATION_PRICE: %s",
self.TRIAL_ACTIVATION_PRICE,
)
return 0
if value < 0:
return 0
return value
def is_yookassa_enabled(self) -> bool:
return (self.YOOKASSA_ENABLED and
@@ -917,6 +950,74 @@ class Settings(BaseSettings):
and self.PAL24_SHOP_ID is not None
)
def is_platega_enabled(self) -> bool:
return (
self.PLATEGA_ENABLED
and self.PLATEGA_MERCHANT_ID is not None
and self.PLATEGA_SECRET is not None
)
def get_platega_return_url(self) -> Optional[str]:
if self.PLATEGA_RETURN_URL:
return self.PLATEGA_RETURN_URL
if self.WEBHOOK_URL:
return f"{self.WEBHOOK_URL}/payment-success"
return None
def get_platega_failed_url(self) -> Optional[str]:
if self.PLATEGA_FAILED_URL:
return self.PLATEGA_FAILED_URL
if self.WEBHOOK_URL:
return f"{self.WEBHOOK_URL}/payment-failed"
return None
def get_platega_active_methods(self) -> List[int]:
raw_value = str(self.PLATEGA_ACTIVE_METHODS or "")
normalized = raw_value.replace(";", ",")
methods: list[int] = []
seen: set[int] = set()
for part in normalized.split(","):
part = part.strip()
if not part:
continue
try:
method_code = int(part)
except ValueError:
logger.warning("Некорректный код метода Platega: %s", part)
continue
if method_code in {2, 10, 11, 12, 13} and method_code not in seen:
methods.append(method_code)
seen.add(method_code)
if not methods:
return [2]
return methods
@staticmethod
def get_platega_method_definitions() -> Dict[int, Dict[str, str]]:
return {
2: {"name": "СБП (QR)", "title": "🏦 СБП (QR)"},
10: {"name": "Банковские карты (RUB)", "title": "💳 Карты (RUB)"},
11: {"name": "Банковские карты", "title": "💳 Банковские карты"},
12: {"name": "Международные карты", "title": "🌍 Международные карты"},
13: {"name": "Криптовалюта", "title": "🪙 Криптовалюта"},
}
def get_platega_method_display_name(self, method_code: int) -> str:
definitions = self.get_platega_method_definitions()
info = definitions.get(method_code)
if info and info.get("name"):
return info["name"]
return f"Метод {method_code}"
def get_platega_method_display_title(self, method_code: int) -> str:
definitions = self.get_platega_method_definitions()
info = definitions.get(method_code)
if not info:
return f"Platega {method_code}"
return info.get("title") or info.get("name") or f"Platega {method_code}"
def is_wata_enabled(self) -> bool:
return (
self.WATA_ENABLED
+23
View File
@@ -0,0 +1,23 @@
"""Database package exports."""
from .database import (
DatabaseManager,
batch_ops,
close_db,
db_manager,
get_db,
get_db_read_only,
get_pool_metrics,
init_db,
)
__all__ = [
"DatabaseManager",
"batch_ops",
"close_db",
"db_manager",
"get_db",
"get_db_read_only",
"get_pool_metrics",
"init_db",
]
+156
View File
@@ -0,0 +1,156 @@
"""CRUD-операции для платежей Platega."""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PlategaPayment
logger = logging.getLogger(__name__)
async def create_platega_payment(
db: AsyncSession,
*,
user_id: int,
amount_kopeks: int,
currency: str,
description: Optional[str],
status: str,
payment_method_code: int,
correlation_id: str,
platega_transaction_id: Optional[str],
redirect_url: Optional[str],
return_url: Optional[str],
failed_url: Optional[str],
payload: Optional[str],
metadata: Optional[dict[str, Any]] = None,
expires_at: Optional[datetime] = None,
) -> PlategaPayment:
payment = PlategaPayment(
user_id=user_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
status=status,
payment_method_code=payment_method_code,
correlation_id=correlation_id,
platega_transaction_id=platega_transaction_id,
redirect_url=redirect_url,
return_url=return_url,
failed_url=failed_url,
payload=payload,
metadata_json=metadata or {},
expires_at=expires_at,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(
"Создан Platega платеж #%s (tx=%s) на сумму %s копеек для пользователя %s",
payment.id,
platega_transaction_id,
amount_kopeks,
user_id,
)
return payment
async def get_platega_payment_by_id(
db: AsyncSession, payment_id: int
) -> Optional[PlategaPayment]:
result = await db.execute(
select(PlategaPayment).where(PlategaPayment.id == payment_id)
)
return result.scalar_one_or_none()
async def get_platega_payment_by_id_for_update(
db: AsyncSession, payment_id: int
) -> Optional[PlategaPayment]:
result = await db.execute(
select(PlategaPayment)
.where(PlategaPayment.id == payment_id)
.with_for_update()
)
return result.scalar_one_or_none()
async def get_platega_payment_by_transaction_id(
db: AsyncSession, transaction_id: str
) -> Optional[PlategaPayment]:
result = await db.execute(
select(PlategaPayment).where(
PlategaPayment.platega_transaction_id == transaction_id
)
)
return result.scalar_one_or_none()
async def get_platega_payment_by_correlation_id(
db: AsyncSession, correlation_id: str
) -> Optional[PlategaPayment]:
result = await db.execute(
select(PlategaPayment).where(
PlategaPayment.correlation_id == correlation_id
)
)
return result.scalar_one_or_none()
async def update_platega_payment(
db: AsyncSession,
*,
payment: PlategaPayment,
status: Optional[str] = None,
is_paid: Optional[bool] = None,
paid_at: Optional[datetime] = None,
platega_transaction_id: Optional[str] = None,
redirect_url: Optional[str] = None,
callback_payload: Optional[dict[str, Any]] = None,
metadata: Optional[dict[str, Any]] = None,
expires_at: Optional[datetime] = None,
) -> PlategaPayment:
if status is not None:
payment.status = status
if is_paid is not None:
payment.is_paid = is_paid
if paid_at is not None:
payment.paid_at = paid_at
if platega_transaction_id and not payment.platega_transaction_id:
payment.platega_transaction_id = platega_transaction_id
if redirect_url is not None:
payment.redirect_url = redirect_url
if callback_payload is not None:
payment.callback_payload = callback_payload
if metadata is not None:
payment.metadata_json = metadata
if expires_at is not None:
payment.expires_at = expires_at
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
return payment
async def link_platega_payment_to_transaction(
db: AsyncSession,
*,
payment: PlategaPayment,
transaction_id: int,
) -> PlategaPayment:
payment.transaction_id = transaction_id
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
return payment
+1 -2
View File
@@ -515,7 +515,6 @@ async def choose_random_trial_server_squad(
async def get_random_trial_squad_uuid(
db: AsyncSession,
fallback_uuid: Optional[str] = None,
) -> Optional[str]:
squad = await choose_random_trial_server_squad(db)
@@ -523,7 +522,7 @@ async def get_random_trial_squad_uuid(
if squad:
return squad.squad_uuid
return fallback_uuid
return None
def _generate_display_name(original_name: str) -> str:
+1 -5
View File
@@ -55,10 +55,7 @@ async def create_trial_subscription(
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
squad_uuid = await get_random_trial_squad_uuid(
db,
settings.TRIAL_SQUAD_UUID,
)
squad_uuid = await get_random_trial_squad_uuid(db)
if squad_uuid:
logger.debug(
@@ -72,7 +69,6 @@ async def create_trial_subscription(
user_id,
error,
)
squad_uuid = settings.TRIAL_SQUAD_UUID
end_date = datetime.utcnow() + timedelta(days=duration_days)
+360 -14
View File
@@ -1,31 +1,167 @@
import logging
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.pool import NullPool
from typing import AsyncGenerator, Optional
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import (
AsyncSession,
create_async_engine,
async_sessionmaker,
AsyncEngine
)
from sqlalchemy.pool import NullPool, AsyncAdaptedQueuePool
from sqlalchemy import event, text, bindparam, inspect
from sqlalchemy.engine import Engine
import time
from app.config import settings
from app.database.models import Base
logger = logging.getLogger(__name__)
# ============================================================================
# PRODUCTION-GRADE CONNECTION POOLING
# ============================================================================
if settings.get_database_url().startswith("sqlite"):
poolclass = NullPool
pool_kwargs = {}
else:
poolclass = AsyncAdaptedQueuePool
pool_kwargs = {
"pool_size": 20,
"max_overflow": 30,
"pool_timeout": 30,
"pool_recycle": 3600,
"pool_pre_ping": True,
# 🔥 Агрессивная очистка мертвых соединений
"pool_reset_on_return": "rollback",
}
# ============================================================================
# ENGINE WITH ADVANCED OPTIMIZATIONS
# ============================================================================
engine = create_async_engine(
settings.get_database_url(),
poolclass=NullPool,
poolclass=poolclass,
echo=settings.DEBUG,
future=True
future=True,
**pool_kwargs,
connect_args={
"server_settings": {
"application_name": "remnawave_bot",
"jit": "on",
"statement_timeout": "60000", # 60 секунд
"idle_in_transaction_session_timeout": "300000", # 5 минут
},
"command_timeout": 60,
"timeout": 10,
} if not settings.get_database_url().startswith("sqlite") else {},
execution_options={
"isolation_level": "READ COMMITTED", # Оптимальный для большинства случаев
"compiled_cache_size": 500, # Кеш скомпилированных запросов
}
)
# ============================================================================
# SESSION FACTORY WITH OPTIMIZATIONS
# ============================================================================
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=True,
autocommit=False
autoflush=False, # 🔥 Критично для производительности
autocommit=False,
)
# ============================================================================
# QUERY PERFORMANCE MONITORING
# ============================================================================
if settings.DEBUG:
@event.listens_for(Engine, "before_cursor_execute")
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
conn.info.setdefault("query_start_time", []).append(time.time())
logger.debug(f"🔍 Executing query: {statement[:100]}...")
@event.listens_for(Engine, "after_cursor_execute")
def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
total = time.time() - conn.info["query_start_time"].pop(-1)
if total > 0.1: # Логируем медленные запросы > 100ms
logger.warning(f"🐌 Slow query ({total:.3f}s): {statement[:100]}...")
else:
logger.debug(f"⚡ Query executed in {total:.3f}s")
# ============================================================================
# ADVANCED SESSION MANAGER WITH READ REPLICAS
# ============================================================================
class DatabaseManager:
"""Продвинутый менеджер БД с поддержкой реплик и кеширования"""
def __init__(self):
self.engine = engine
self.read_replica_engine: Optional[AsyncEngine] = None
if hasattr(settings, 'DATABASE_READ_REPLICA_URL') and settings.DATABASE_READ_REPLICA_URL:
self.read_replica_engine = create_async_engine(
settings.DATABASE_READ_REPLICA_URL,
poolclass=poolclass,
pool_size=30, # Больше для read операций
max_overflow=50,
pool_pre_ping=True,
echo=False,
)
@asynccontextmanager
async def session(self, read_only: bool = False):
target_engine = self.read_replica_engine if (read_only and self.read_replica_engine) else self.engine
async_session = async_sessionmaker(
bind=target_engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
async with async_session() as session:
try:
yield session
if not read_only:
await session.commit()
except Exception:
await session.rollback()
raise
async def health_check(self) -> dict:
pool = self.engine.pool
try:
async with AsyncSessionLocal() as session:
start = time.time()
await session.execute(text("SELECT 1"))
latency = (time.time() - start) * 1000
status = "healthy"
except Exception as e:
logger.error(f"❌ Database health check failed: {e}")
status = "unhealthy"
latency = None
return {
"status": status,
"latency_ms": round(latency, 2) if latency else None,
"pool": _collect_health_pool_metrics(pool),
}
db_manager = DatabaseManager()
# ============================================================================
# SESSION DEPENDENCY FOR FASTAPI/AIOGRAM
# ============================================================================
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""Стандартная dependency для FastAPI"""
async with AsyncSessionLocal() as session:
try:
yield session
@@ -33,19 +169,229 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def get_db_read_only() -> AsyncGenerator[AsyncSession, None]:
"""Read-only dependency для тяжелых SELECT запросов"""
async with db_manager.session(read_only=True) as session:
yield session
# ============================================================================
# BATCH OPERATIONS FOR PERFORMANCE
# ============================================================================
class BatchOperations:
"""Утилиты для массовых операций"""
@staticmethod
async def bulk_insert(session: AsyncSession, model, data: list[dict], chunk_size: int = 1000):
"""Массовая вставка с чанками"""
for i in range(0, len(data), chunk_size):
chunk = data[i:i + chunk_size]
session.add_all([model(**item) for item in chunk])
await session.flush()
await session.commit()
@staticmethod
async def bulk_update(session: AsyncSession, model, data: list[dict], chunk_size: int = 1000):
"""Массовое обновление с чанками"""
if not data:
return
primary_keys = [column.name for column in model.__table__.primary_key.columns]
if not primary_keys:
raise ValueError("Model must have a primary key for bulk_update")
updatable_columns = [
column.name
for column in model.__table__.columns
if column.name not in primary_keys
]
if not updatable_columns:
raise ValueError("No columns available for update in bulk_update")
stmt = (
model.__table__.update()
.where(
*[
getattr(model.__table__.c, pk) == bindparam(pk)
for pk in primary_keys
]
)
.values(
**{
column: bindparam(column, required=False)
for column in updatable_columns
}
)
)
for i in range(0, len(data), chunk_size):
chunk = data[i:i + chunk_size]
filtered_chunk = []
for item in chunk:
missing_keys = [pk for pk in primary_keys if pk not in item]
if missing_keys:
raise ValueError(
f"Missing primary key values {missing_keys} for bulk_update"
)
filtered_item = {
key: value
for key, value in item.items()
if key in primary_keys or key in updatable_columns
}
filtered_chunk.append(filtered_item)
await session.execute(stmt, filtered_chunk)
await session.commit()
batch_ops = BatchOperations()
# ============================================================================
# INITIALIZATION AND CLEANUP
# ============================================================================
async def init_db():
logger.info("Создание таблиц базы данных...")
"""Инициализация БД с оптимизациями"""
logger.info("🚀 Создание таблиц базы данных...")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
if not settings.get_database_url().startswith("sqlite"):
logger.info("📊 Создание индексов для оптимизации...")
async with engine.begin() as conn:
indexes = [
("users", "CREATE INDEX IF NOT EXISTS idx_users_telegram_id ON users(telegram_id)"),
(
"subscriptions",
"CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id)",
),
(
"subscriptions",
"CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status) WHERE status = 'active'",
),
(
"payments",
"CREATE INDEX IF NOT EXISTS idx_payments_created_at ON payments(created_at DESC)",
),
]
for table_name, index_sql in indexes:
table_exists = await conn.run_sync(lambda sync_conn: inspect(sync_conn).has_table(table_name))
if not table_exists:
logger.debug(
"Пропускаем создание индекса %s: таблица %s отсутствует",
index_sql,
table_name,
)
continue
try:
await conn.execute(text(index_sql))
except Exception as e:
logger.debug("Index creation skipped for %s: %s", table_name, e)
logger.info("✅ База данных успешно инициализирована")
health = await db_manager.health_check()
logger.info(f"📊 Database health: {health}")
async def close_db():
"""Корректное закрытие всех соединений"""
logger.info("🔄 Закрытие соединений с БД...")
await engine.dispose()
logger.info("✅ Подключение к базе данных закрыто")
if db_manager.read_replica_engine:
await db_manager.read_replica_engine.dispose()
logger.info("✅ Все подключения к базе данных закрыты")
# ============================================================================
# CONNECTION POOL METRICS (для мониторинга)
# ============================================================================
def _pool_counters(pool):
"""Return basic pool counters or ``None`` when unsupported."""
required_methods = ("size", "checkedin", "checkedout", "overflow")
for method_name in required_methods:
method = getattr(pool, method_name, None)
if method is None or not callable(method):
return None
size = pool.size()
checked_in = pool.checkedin()
checked_out = pool.checkedout()
overflow = pool.overflow()
total_connections = size + overflow
return {
"size": size,
"checked_in": checked_in,
"checked_out": checked_out,
"overflow": overflow,
"total_connections": total_connections,
"utilization_percent": (checked_out / total_connections * 100) if total_connections else 0.0,
}
def _collect_health_pool_metrics(pool) -> dict:
counters = _pool_counters(pool)
if counters is None:
return {
"metrics_available": False,
"size": 0,
"checked_in": 0,
"checked_out": 0,
"overflow": 0,
"total_connections": 0,
"utilization": "0.0%",
}
return {
"metrics_available": True,
"size": counters["size"],
"checked_in": counters["checked_in"],
"checked_out": counters["checked_out"],
"overflow": counters["overflow"],
"total_connections": counters["total_connections"],
"utilization": f"{counters['utilization_percent']:.1f}%",
}
async def get_pool_metrics() -> dict:
"""Детальные метрики пула для Prometheus/Grafana"""
pool = engine.pool
counters = _pool_counters(pool)
if counters is None:
return {
"metrics_available": False,
"pool_size": 0,
"checked_in_connections": 0,
"checked_out_connections": 0,
"overflow_connections": 0,
"total_connections": 0,
"max_possible_connections": 0,
"pool_utilization_percent": 0.0,
}
return {
"metrics_available": True,
"pool_size": counters["size"],
"checked_in_connections": counters["checked_in"],
"checked_out_connections": counters["checked_out"],
"overflow_connections": counters["overflow"],
"total_connections": counters["total_connections"],
"max_possible_connections": counters["total_connections"] + (getattr(pool, "_max_overflow", 0) or 0),
"pool_utilization_percent": round(counters["utilization_percent"], 2),
}
+51
View File
@@ -82,6 +82,7 @@ class PaymentMethod(Enum):
MULENPAY = "mulenpay"
PAL24 = "pal24"
WATA = "wata"
PLATEGA = "platega"
MANUAL = "manual"
@@ -414,6 +415,56 @@ class WataPayment(Base):
)
class PlategaPayment(Base):
__tablename__ = "platega_payments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
platega_transaction_id = Column(String(255), unique=True, nullable=True, index=True)
correlation_id = Column(String(64), unique=True, nullable=False, index=True)
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default="RUB")
description = Column(Text, nullable=True)
payment_method_code = Column(Integer, nullable=False)
status = Column(String(50), nullable=False, default="PENDING")
is_paid = Column(Boolean, default=False)
paid_at = Column(DateTime, nullable=True)
redirect_url = Column(Text, nullable=True)
return_url = Column(Text, nullable=True)
failed_url = Column(Text, nullable=True)
payload = Column(String(255), nullable=True)
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
expires_at = Column(DateTime, nullable=True)
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
user = relationship("User", backref="platega_payments")
transaction = relationship("Transaction", backref="platega_payment")
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
def __repr__(self) -> str: # pragma: no cover - debug helper
return (
"<PlategaPayment(id={0}, transaction_id={1}, amount={2}₽, status={3}, method={4})>".format(
self.id,
self.platega_transaction_id,
self.amount_rubles,
self.status,
self.payment_method_code,
)
)
class PromoGroup(Base):
__tablename__ = "promo_groups"
+31 -1
View File
@@ -2107,6 +2107,9 @@ async def ensure_promo_groups_setup():
addon_discount_column_exists = await check_column_exists(
"promo_groups", "apply_discounts_to_addons"
)
priority_column_exists = await check_column_exists(
"promo_groups", "priority"
)
if not addon_discount_column_exists:
if db_type == "sqlite":
@@ -2289,7 +2292,23 @@ async def ensure_promo_groups_setup():
"is_default": True,
}
if addon_discount_column_exists:
if priority_column_exists:
insert_params["priority"] = 0
if addon_discount_column_exists and priority_column_exists:
insert_sql = """
INSERT INTO promo_groups (
name,
priority,
server_discount_percent,
traffic_discount_percent,
device_discount_percent,
apply_discounts_to_addons,
is_default
) VALUES (:name, :priority, 0, 0, 0, :apply_discounts_to_addons, :is_default)
"""
insert_params["apply_discounts_to_addons"] = True
elif addon_discount_column_exists:
insert_sql = """
INSERT INTO promo_groups (
name,
@@ -2301,6 +2320,17 @@ async def ensure_promo_groups_setup():
) VALUES (:name, 0, 0, 0, :apply_discounts_to_addons, :is_default)
"""
insert_params["apply_discounts_to_addons"] = True
elif priority_column_exists:
insert_sql = """
INSERT INTO promo_groups (
name,
priority,
server_discount_percent,
traffic_discount_percent,
device_discount_percent,
is_default
) VALUES (:name, :priority, 0, 0, 0, :is_default)
"""
else:
insert_sql = """
INSERT INTO promo_groups (
+4 -4
View File
@@ -1,4 +1,4 @@
"""Flask webhook server for PayPalych postbacks."""
"""Flask webhook server for PayPalych callbacks."""
from __future__ import annotations
@@ -65,7 +65,7 @@ def create_pal24_flask_app(
return jsonify({"status": "error", "reason": "empty_payload"}), 400
try:
parsed_payload = pal24_service.parse_postback(payload)
parsed_payload = pal24_service.parse_callback(payload)
except Pal24APIError as error:
logger.error("Ошибка валидации Pal24 webhook: %s", error)
return jsonify({"status": "error", "reason": str(error)}), 400
@@ -73,7 +73,7 @@ def create_pal24_flask_app(
async def process() -> bool:
async with AsyncSessionLocal() as db:
try:
return await payment_service.process_pal24_postback(db, parsed_payload)
return await payment_service.process_pal24_callback(db, parsed_payload)
except Exception:
await db.rollback()
raise
@@ -112,7 +112,7 @@ def create_pal24_flask_app(
class Pal24WebhookServer:
"""Threaded Flask server for Pal24 postbacks."""
"""Threaded Flask server for Pal24 callbacks."""
def __init__(self, payment_service: PaymentService, loop: AbstractEventLoop) -> None:
self.app = create_pal24_flask_app(payment_service, loop)
+268 -127
View File
@@ -1,12 +1,16 @@
import asyncio
import logging
import json
import hashlib
import hmac
import base64
from typing import Optional, Dict, Any
from ipaddress import (
IPv4Address,
IPv4Network,
IPv6Address,
IPv6Network,
ip_address,
ip_network,
)
from typing import Iterable, Optional, Dict, Any, List, Union, Tuple
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.services.payment_service import PaymentService
@@ -15,155 +19,292 @@ from app.database.database import get_db
logger = logging.getLogger(__name__)
IPAddress = Union[IPv4Address, IPv6Address]
IPNetwork = Union[IPv4Network, IPv6Network]
YOOKASSA_ALLOWED_IP_NETWORKS: tuple[IPNetwork, ...] = (
ip_network("185.71.76.0/27"),
ip_network("185.71.77.0/27"),
ip_network("77.75.153.0/25"),
ip_network("77.75.154.128/25"),
ip_network("77.75.156.11/32"),
ip_network("77.75.156.35/32"),
ip_network("2a02:5180::/32"),
)
CLOUDFLARE_TRUSTED_NETWORKS: tuple[IPNetwork, ...] = (
ip_network("173.245.48.0/20"),
ip_network("103.21.244.0/22"),
ip_network("103.22.200.0/22"),
ip_network("103.31.4.0/22"),
ip_network("141.101.64.0/18"),
ip_network("108.162.192.0/18"),
ip_network("190.93.240.0/20"),
ip_network("188.114.96.0/20"),
ip_network("197.234.240.0/22"),
ip_network("198.41.128.0/17"),
ip_network("162.158.0.0/15"),
ip_network("104.16.0.0/13"),
ip_network("104.24.0.0/14"),
ip_network("172.64.0.0/13"),
ip_network("131.0.72.0/22"),
ip_network("2400:cb00::/32"),
ip_network("2606:4700::/32"),
ip_network("2803:f800::/32"),
ip_network("2405:b500::/32"),
ip_network("2405:8100::/32"),
ip_network("2a06:98c0::/29"),
ip_network("2c0f:f248::/32"),
)
YOOKASSA_ALLOWED_EVENTS: tuple[str, ...] = (
"payment.succeeded",
"payment.waiting_for_capture",
"payment.canceled",
)
def collect_yookassa_ip_candidates(*values: Optional[str]) -> List[str]:
candidates: List[str] = []
for value in values:
if not value:
continue
for part in value.split(","):
normalized = part.strip()
if normalized:
candidates.append(normalized)
return candidates
def _parse_candidate_ip(candidate: str) -> Optional[IPAddress]:
value = candidate.strip()
if not value:
return None
if value.startswith("[") and "]" in value:
value = value[1:value.index("]")]
if "%" in value:
value = value.split("%", 1)[0]
if value.count(":") == 1 and "." in value:
host, _, port = value.rpartition(":")
if port.isdigit():
value = host
try:
return ip_address(value)
except ValueError:
return None
def _should_trust_forwarded_headers(remote_ip: Optional[IPAddress]) -> bool:
if remote_ip is None:
return True
if _is_trusted_proxy_ip(remote_ip):
return True
return any(
getattr(remote_ip, attribute)
for attribute in ("is_private", "is_loopback", "is_link_local", "is_reserved")
)
_TRUSTED_PROXY_NETWORKS_CACHE: Tuple[str, Tuple[IPNetwork, ...]] = ("", ())
def _get_trusted_proxy_networks() -> Tuple[IPNetwork, ...]:
global _TRUSTED_PROXY_NETWORKS_CACHE
raw_value = getattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "") or ""
cached_raw, cached_networks = _TRUSTED_PROXY_NETWORKS_CACHE
if raw_value == cached_raw:
return cached_networks
networks: List[IPNetwork] = []
for part in raw_value.split(","):
candidate = part.strip()
if not candidate:
continue
try:
networks.append(ip_network(candidate, strict=False))
except ValueError:
logger.warning("Неверная сеть доверенного прокси YooKassa: %s", candidate)
cached_networks = tuple(networks)
_TRUSTED_PROXY_NETWORKS_CACHE = (raw_value, cached_networks)
return cached_networks
def _is_trusted_proxy_ip(ip_object: IPAddress) -> bool:
if any(
getattr(ip_object, attribute)
for attribute in ("is_private", "is_loopback", "is_link_local", "is_reserved")
):
return True
if any(ip_object in network for network in CLOUDFLARE_TRUSTED_NETWORKS):
return True
return any(ip_object in network for network in _get_trusted_proxy_networks())
def resolve_yookassa_ip(
candidates: Iterable[str],
*,
remote: Optional[str] = None,
) -> Optional[IPAddress]:
remote_ip = _parse_candidate_ip(remote) if remote else None
if (
remote_ip is not None
and remote_ip.is_global
and not _is_trusted_proxy_ip(remote_ip)
):
return remote_ip
candidate_list = list(candidates)
if _should_trust_forwarded_headers(remote_ip):
last_hop = remote_ip
for candidate in reversed(candidate_list):
ip_object = _parse_candidate_ip(candidate)
if ip_object is not None:
if last_hop is None or _is_trusted_proxy_ip(last_hop):
if _is_trusted_proxy_ip(ip_object):
last_hop = ip_object
continue
return ip_object
break
if last_hop is not None and not _is_trusted_proxy_ip(last_hop):
return last_hop
return remote_ip if remote_ip is not None else next(
(ip for ip in (_parse_candidate_ip(value) for value in candidate_list) if ip is not None),
None,
)
def is_yookassa_ip_allowed(ip_object: IPAddress) -> bool:
return any(ip_object in network for network in YOOKASSA_ALLOWED_IP_NETWORKS)
class YooKassaWebhookHandler:
@staticmethod
def verify_webhook_signature(body: str, signature: str, secret: str) -> bool:
try:
signature_parts = signature.strip().split(' ')
if len(signature_parts) < 4:
logger.error(f"Неверный формат подписи YooKassa: {signature}")
return False
version = signature_parts[0]
payment_id = signature_parts[1]
timestamp = signature_parts[2]
received_signature = signature_parts[3]
if version != "v1":
logger.error(f"Неподдерживаемая версия подписи: {version}")
return False
logger.info(f"Проверка подписи v1 для платежа {payment_id}, timestamp: {timestamp}")
expected_signature_1 = hmac.new(
secret.encode('utf-8'),
body.encode('utf-8'),
hashlib.sha256
).digest()
expected_signature_1_b64 = base64.b64encode(expected_signature_1).decode('utf-8')
signed_payload_2 = f"{payment_id}.{timestamp}.{body}"
expected_signature_2 = hmac.new(
secret.encode('utf-8'),
signed_payload_2.encode('utf-8'),
hashlib.sha256
).digest()
expected_signature_2_b64 = base64.b64encode(expected_signature_2).decode('utf-8')
signed_payload_3 = f"{timestamp}.{body}"
expected_signature_3 = hmac.new(
secret.encode('utf-8'),
signed_payload_3.encode('utf-8'),
hashlib.sha256
).digest()
expected_signature_3_b64 = base64.b64encode(expected_signature_3).decode('utf-8')
logger.debug(f"Получена подпись: {received_signature}")
logger.debug(f"Ожидаемая подпись (вариант 1): {expected_signature_1_b64}")
logger.debug(f"Ожидаемая подпись (вариант 2): {expected_signature_2_b64}")
logger.debug(f"Ожидаемая подпись (вариант 3): {expected_signature_3_b64}")
is_valid = (
hmac.compare_digest(received_signature, expected_signature_1_b64) or
hmac.compare_digest(received_signature, expected_signature_2_b64) or
hmac.compare_digest(received_signature, expected_signature_3_b64)
)
if is_valid:
logger.info("✅ Подпись YooKassa webhook проверена успешно")
else:
logger.warning("⚠️ Подпись YooKassa webhook не совпадает ни с одним вариантом")
return is_valid
except Exception as e:
logger.error(f"Ошибка проверки подписи YooKassa: {e}")
return False
def __init__(self, payment_service: PaymentService):
self.payment_service = payment_service
async def handle_webhook(self, request: web.Request) -> web.Response:
try:
logger.info(f"📥 Получен YooKassa webhook: {request.method} {request.path}")
logger.info(f"📋 Headers: {dict(request.headers)}")
header_ip_candidates = collect_yookassa_ip_candidates(
request.headers.get("X-Forwarded-For"),
request.headers.get("X-Real-IP"),
request.headers.get("Cf-Connecting-Ip"),
)
client_ip = resolve_yookassa_ip(
header_ip_candidates,
remote=request.remote,
)
if client_ip is None:
logger.warning(
"🚫 Не удалось определить IP-адрес отправителя YooKassa webhook. Кандидаты: %s",
header_ip_candidates + ([request.remote] if request.remote else []),
)
return web.Response(status=403, text="Forbidden")
if not is_yookassa_ip_allowed(client_ip):
logger.warning(
"🚫 YooKassa webhook отклонён: IP %s не входит в доверенные диапазоны (%s)",
client_ip,
", ".join(str(network) for network in YOOKASSA_ALLOWED_IP_NETWORKS),
)
return web.Response(status=403, text="Forbidden")
logger.info("🌐 IP-адрес YooKassa подтверждён: %s", client_ip)
body = await request.text()
if not body:
logger.warning("⚠️ Получен пустой webhook от YooKassa")
return web.Response(status=400, text="Empty body")
logger.info(f"📄 Body: {body}")
signature = request.headers.get('Signature') or request.headers.get('X-YooKassa-Signature')
if settings.YOOKASSA_WEBHOOK_SECRET and signature:
logger.info(f"🔐 Получена подпись: {signature}")
if not YooKassaWebhookHandler.verify_webhook_signature(body, signature, settings.YOOKASSA_WEBHOOK_SECRET):
logger.warning("❌ Подпись не совпала, но продолжаем обработку (режим отладки)")
else:
logger.info("✅ Подпись webhook проверена успешно")
elif settings.YOOKASSA_WEBHOOK_SECRET and not signature:
logger.warning("⚠️ Webhook без подписи, но секрет настроен")
elif signature and not settings.YOOKASSA_WEBHOOK_SECRET:
logger.info("ℹ️ Подпись получена, но проверка отключена (YOOKASSA_WEBHOOK_SECRET не настроен)")
else:
logger.info("ℹ️ Проверка подписи отключена")
if signature:
logger.info("ℹ️ Получена подпись YooKassa: %s", signature)
try:
webhook_data = json.loads(body)
except json.JSONDecodeError as e:
logger.error(f"❌ Ошибка парсинга JSON webhook YooKassa: {e}")
return web.Response(status=400, text="Invalid JSON")
logger.info(f"📊 Обработка webhook YooKassa: {webhook_data.get('event', 'unknown_event')}")
logger.debug(f"🔍 Полные данные webhook: {webhook_data}")
# Извлекаем ID платежа из вебхука для предотвращения дублирования
yookassa_payment_id = webhook_data.get("object", {}).get("id")
if not yookassa_payment_id:
logger.warning("⚠️ Webhook YooKassa без ID платежа")
return web.Response(status=400, text="No payment ID")
event_type = webhook_data.get("event")
if not event_type:
logger.warning("⚠️ Webhook YooKassa без типа события")
return web.Response(status=400, text="No event type")
if event_type not in ["payment.succeeded", "payment.waiting_for_capture"]:
if event_type not in YOOKASSA_ALLOWED_EVENTS:
logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}")
return web.Response(status=200, text="OK")
async for db in get_db():
try:
success = await self.payment_service.process_yookassa_webhook(db, webhook_data)
# Проверяем, не обрабатывается ли этот платеж уже (защита от дублирования)
from app.database.models import PaymentMethod
from app.database.crud.transaction import get_transaction_by_external_id
existing_transaction = await get_transaction_by_external_id(db, yookassa_payment_id, PaymentMethod.YOOKASSA)
if existing_transaction and event_type == "payment.succeeded":
logger.info(f"️ Платеж YooKassa {yookassa_payment_id} уже был обработан. Пропускаем дублирующий вебхук.")
return web.Response(status=200, text="OK")
success = await self.payment_service.process_yookassa_webhook(db, webhook_data)
if success:
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type}")
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type} для платежа {yookassa_payment_id}")
return web.Response(status=200, text="OK")
else:
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type}")
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type} для платежа {yookassa_payment_id}")
return web.Response(status=500, text="Processing error")
finally:
await db.close()
except Exception as e:
logger.error(f"❌ Критическая ошибка обработки webhook YooKassa: {e}", exc_info=True)
return web.Response(status=500, text="Internal server error")
def setup_routes(self, app: web.Application) -> None:
webhook_path = settings.YOOKASSA_WEBHOOK_PATH
app.router.add_post(webhook_path, self.handle_webhook)
app.router.add_get(webhook_path, self._get_handler)
app.router.add_options(webhook_path, self._options_handler)
app.router.add_get(webhook_path, self._get_handler)
app.router.add_options(webhook_path, self._options_handler)
logger.info(f"✅ Настроен YooKassa webhook на пути: POST {webhook_path}")
async def _get_handler(self, request: web.Request) -> web.Response:
return web.json_response({
"status": "ok",
@@ -172,7 +313,7 @@ class YooKassaWebhookHandler:
"path": request.path,
"note": "Use POST method for actual webhooks"
})
async def _options_handler(self, request: web.Request) -> web.Response:
return web.Response(
status=200,
@@ -185,46 +326,46 @@ class YooKassaWebhookHandler:
def create_yookassa_webhook_app(payment_service: PaymentService) -> web.Application:
app = web.Application()
webhook_handler = YooKassaWebhookHandler(payment_service)
webhook_handler.setup_routes(app)
async def health_check(request):
return web.json_response({
"status": "ok",
"status": "ok",
"service": "yookassa_webhook",
"port": settings.YOOKASSA_WEBHOOK_PORT,
"path": settings.YOOKASSA_WEBHOOK_PATH,
"enabled": settings.is_yookassa_enabled()
})
app.router.add_get("/health", health_check)
return app
async def start_yookassa_webhook_server(payment_service: PaymentService) -> None:
if not settings.is_yookassa_enabled():
logger.info("ℹ️ YooKassa отключена, webhook сервер не запускается")
return
try:
app = create_yookassa_webhook_app(payment_service)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(
runner,
host=settings.YOOKASSA_WEBHOOK_HOST,
port=settings.YOOKASSA_WEBHOOK_PORT
)
await site.start()
logger.info(
"✅ YooKassa webhook сервер запущен на %s:%s",
settings.YOOKASSA_WEBHOOK_HOST,
@@ -236,7 +377,7 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None
settings.YOOKASSA_WEBHOOK_PORT,
settings.YOOKASSA_WEBHOOK_PATH,
)
try:
while True:
await asyncio.sleep(1)
@@ -246,7 +387,7 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None
await site.stop()
await runner.cleanup()
logger.info("✅ YooKassa webhook сервер остановлен")
except Exception as e:
logger.error(f"❌ Ошибка запуска YooKassa webhook сервера: {e}", exc_info=True)
raise
raise
+3 -1
View File
@@ -61,7 +61,7 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
},
"payments": {
"title": "💳 Платежные системы",
"description": "YooKassa, CryptoBot, Heleket, MulenPay, PAL24, Wata, Tribute и Telegram Stars.",
"description": "YooKassa, CryptoBot, Heleket, MulenPay, PAL24, Wata, Platega, Tribute и Telegram Stars.",
"icon": "💳",
"categories": (
"PAYMENT",
@@ -72,6 +72,7 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
"MULENPAY",
"PAL24",
"WATA",
"PLATEGA",
"TRIBUTE",
"TELEGRAM",
),
@@ -253,6 +254,7 @@ def _get_group_status(group_key: str) -> Tuple[str, str]:
payment_statuses = {
"YooKassa": settings.is_yookassa_enabled(),
"CryptoBot": settings.is_cryptobot_enabled(),
"Platega": settings.is_platega_enabled(),
"MulenPay": settings.is_mulenpay_enabled(),
"PAL24": settings.is_pal24_enabled(),
"Tribute": settings.TRIBUTE_ENABLED,
+16
View File
@@ -37,6 +37,8 @@ def _method_display(method: PaymentMethod) -> str:
return "Heleket"
if method == PaymentMethod.YOOKASSA:
return "YooKassa"
if method == PaymentMethod.PLATEGA:
return "Platega"
if method == PaymentMethod.CRYPTOBOT:
return "CryptoBot"
if method == PaymentMethod.TELEGRAM_STARS:
@@ -90,6 +92,18 @@ def _status_info(
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.PLATEGA:
mapping = {
"pending": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"inprogress": ("", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
"confirmed": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"failed": ("", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
"canceled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"cancelled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"expired": ("", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.HELEKET:
if status in {"pending", "created", "waiting", "check", "processing"}:
return "", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")
@@ -136,6 +150,8 @@ def _is_checkable(record: PendingPayment) -> bool:
return status in {"created", "processing", "hold"}
if record.method == PaymentMethod.WATA:
return status in {"opened", "pending", "processing", "inprogress", "in_progress"}
if record.method == PaymentMethod.PLATEGA:
return status in {"pending", "inprogress", "in_progress"}
if record.method == PaymentMethod.HELEKET:
return status not in {"paid", "paid_over", "cancel", "canceled", "fail", "failed", "expired"}
if record.method == PaymentMethod.YOOKASSA:
+22 -10
View File
@@ -96,6 +96,24 @@ TRIAL_ENTRIES: Tuple[SettingEntry, ...] = (
label_en="📱 Device limit",
action="input",
),
SettingEntry(
key="TRIAL_PAYMENT_ENABLED",
section="trial",
label_ru="💳 Платная активация",
label_en="💳 Paid activation",
action="toggle",
description_ru="Если включено — за активацию триала будет списываться указанная сумма.",
description_en="When enabled, the configured amount is charged during trial activation.",
),
SettingEntry(
key="TRIAL_ACTIVATION_PRICE",
section="trial",
label_ru="💰 Стоимость активации",
label_en="💰 Activation price",
action="price",
description_ru="Указывается в копейках. 0 — бесплатная активация.",
description_en="Amount in kopeks. 0 — free activation.",
),
SettingEntry(
key="TRIAL_ADD_REMAINING_DAYS_TO_PAID",
section="trial",
@@ -105,15 +123,6 @@ TRIAL_ENTRIES: Tuple[SettingEntry, ...] = (
description_ru="Если включено — при покупке платной подписки оставшиеся дни триала будут добавлены к сроку.",
description_en="When enabled, remaining trial days are added to paid subscription duration.",
),
SettingEntry(
key="TRIAL_SQUAD_UUID",
section="trial",
label_ru="🆔 Squad UUID",
label_en="🆔 Squad UUID",
action="input",
description_ru="Можно оставить пустым, если не требуется назначать пробные подписки в конкретный Squad.",
description_en="Leave empty if trial subscriptions shouldn't be bound to a specific Squad.",
),
)
@@ -290,11 +299,14 @@ def _format_trial_summary(lang_code: str) -> str:
duration = settings.TRIAL_DURATION_DAYS
traffic = settings.TRIAL_TRAFFIC_LIMIT_GB
devices = settings.TRIAL_DEVICE_LIMIT
price_note = ""
if settings.is_trial_paid_activation_enabled():
price_note = f", 💳 {settings.format_price(settings.get_trial_activation_price())}"
traffic_label = _format_traffic_label(traffic, lang_code, short=True)
devices_label = f"{devices}📱" if lang_code == "ru" else f"{devices}📱"
days_suffix = "д" if lang_code == "ru" else "d"
return f"{duration}{days_suffix}, {traffic_label}, {devices_label}"
return f"{duration}{days_suffix}, {traffic_label}, {devices_label}{price_note}"
def _format_core_summary(lang_code: str) -> str:
+1 -6
View File
@@ -3836,10 +3836,7 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
trial_uuid = await get_random_trial_squad_uuid(
db,
getattr(settings, "TRIAL_SQUAD_UUID", None),
)
trial_uuid = await get_random_trial_squad_uuid(db)
if trial_uuid:
trial_squads = [trial_uuid]
except Exception as error:
@@ -3848,8 +3845,6 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad
admin_id,
error,
)
if getattr(settings, "TRIAL_SQUAD_UUID", None):
trial_squads = [settings.TRIAL_SQUAD_UUID]
forced_devices = None
if not settings.is_devices_selection_enabled():
+60 -1
View File
@@ -284,7 +284,16 @@ async def show_payment_methods(
devices_discounted_per_month * months_in_period
)
current_tariff_desc = f"📱 Подписка: {len(current_connected_squads)} серверов, {current_traffic} ГБ, {current_device_limit} устр."
traffic_value = current_traffic or 0
if traffic_value <= 0:
traffic_display = texts.t("TRAFFIC_UNLIMITED_SHORT", "Безлимит")
else:
traffic_display = texts.format_traffic(traffic_value)
current_tariff_desc = (
f"📱 Подписка: {len(current_connected_squads)} серверов, "
f"{traffic_display}, {current_device_limit} устр."
)
estimated_price_info = f"💰 Стоимость продления (примерно): {texts.format_price(total_price)} за {duration_days} дней"
tariff_info = f"\n\n📋 <b>Ваш текущий тариф:</b>\n{current_tariff_desc}\n{estimated_price_info}"
@@ -386,6 +395,10 @@ async def handle_successful_topup_with_cart(
success_text = (
f"✅ Баланс пополнен на {texts.format_price(amount_kopeks)}!\n\n"
f"💰 Текущий баланс: {texts.format_price(user.balance_kopeks)}\n\n"
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса.\n\n"
f"🛒 У вас есть сохраненная корзина подписки\n"
f"Стоимость: {texts.format_price(total_price)}\n\n"
f"Хотите продолжить оформление?"
@@ -519,6 +532,14 @@ async def process_topup_amount(
from .mulenpay import process_mulenpay_payment_amount
async with AsyncSessionLocal() as db:
await process_mulenpay_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "platega":
from app.database.database import AsyncSessionLocal
from .platega import process_platega_payment_amount
async with AsyncSessionLocal() as db:
await process_platega_payment_amount(
message, db_user, db, amount_kopeks, state
)
elif payment_method == "wata":
from app.database.database import AsyncSessionLocal
from .wata import process_wata_payment_amount
@@ -630,6 +651,14 @@ async def handle_quick_amount_selection(
await process_mulenpay_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "platega":
from app.database.database import AsyncSessionLocal
from .platega import process_platega_payment_amount
async with AsyncSessionLocal() as db:
await process_platega_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "wata":
from app.database.database import AsyncSessionLocal
from .wata import process_wata_payment_amount
@@ -717,6 +746,13 @@ async def handle_topup_amount_callback(
await process_mulenpay_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "platega":
from app.database.database import AsyncSessionLocal
from .platega import process_platega_payment_amount
async with AsyncSessionLocal() as db:
await process_platega_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "pal24":
from app.database.database import AsyncSessionLocal
from .pal24 import process_pal24_payment_amount
@@ -738,6 +774,13 @@ async def handle_topup_amount_callback(
await process_heleket_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "wata":
from app.database.database import AsyncSessionLocal
from .wata import process_wata_payment_amount
async with AsyncSessionLocal() as db:
await process_wata_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "stars":
from .stars import process_stars_payment_amount
await process_stars_payment_amount(
@@ -821,6 +864,16 @@ def register_balance_handlers(dp: Dispatcher):
F.data.startswith("pal24_method_"),
)
from .platega import start_platega_payment, handle_platega_method_selection
dp.callback_query.register(
start_platega_payment,
F.data == "topup_platega"
)
dp.callback_query.register(
handle_platega_method_selection,
F.data.startswith("platega_method_"),
)
from .yookassa import check_yookassa_payment_status
dp.callback_query.register(
check_yookassa_payment_status,
@@ -889,6 +942,12 @@ def register_balance_handlers(dp: Dispatcher):
F.data.startswith("check_pal24_")
)
from .platega import check_platega_payment_status
dp.callback_query.register(
check_platega_payment_status,
F.data.startswith("check_platega_")
)
dp.callback_query.register(
handle_payment_methods_unavailable,
F.data == "payment_methods_unavailable"
+120 -6
View File
@@ -1,5 +1,7 @@
import html
import logging
from typing import Any, Optional
from aiogram import types
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
@@ -423,8 +425,77 @@ async def check_pal24_payment_status(
if not isinstance(links_meta, dict):
links_meta = {}
sbp_link = links_meta.get("sbp") or payment.link_url
card_link = links_meta.get("card")
links_info = status_info.get("links") or {}
def _extract_link(source: Any, keys: tuple[str, ...]) -> Optional[str]:
stack: list[Any] = [source]
while stack:
current = stack.pop()
if isinstance(current, dict):
for key in keys:
value = current.get(key)
if value:
return str(value)
stack.extend(current.values())
elif isinstance(current, list):
stack.extend(current)
return None
raw_response = metadata.get("raw_response") if isinstance(metadata, dict) else None
remote_data = status_info.get("remote_data")
transfer_keys = (
"transfer_url",
"transferUrl",
"transfer_link",
"transferLink",
"transfer",
"sbp_url",
"sbpUrl",
"sbp_link",
"sbpLink",
)
card_keys = (
"link_url",
"linkUrl",
"link",
"card_url",
"cardUrl",
"card_link",
"cardLink",
"payment_url",
"paymentUrl",
"url",
)
extra_sbp_link = (
_extract_link(raw_response, transfer_keys)
if raw_response
else None
)
if not extra_sbp_link and remote_data:
extra_sbp_link = _extract_link(remote_data, transfer_keys)
extra_card_link = (
_extract_link(raw_response, card_keys)
if raw_response
else None
)
if not extra_card_link and remote_data:
extra_card_link = _extract_link(remote_data, card_keys)
sbp_link = (
links_info.get("sbp")
or links_meta.get("sbp")
or status_info.get("sbp_url")
or extra_sbp_link
or payment.link_url
)
card_link = (
links_info.get("card")
or links_meta.get("card")
or status_info.get("card_url")
or extra_card_link
)
if not card_link and payment.link_page_url and payment.link_page_url != sbp_link:
card_link = payment.link_page_url
@@ -459,10 +530,53 @@ async def check_pal24_payment_status(
db_user = getattr(callback, 'db_user', None)
texts = get_texts(db_user.language if db_user else 'ru') if db_user else get_texts('ru')
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), callback_data=f"check_pal24_{local_payment_id}")],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
pay_rows: list[list[types.InlineKeyboardButton]] = []
if not payment.is_paid and payment.status in {"NEW", "PROCESS"}:
default_sbp_text = texts.t(
"PAL24_SBP_PAY_BUTTON",
"🏦 Оплатить через PayPalych (СБП)",
)
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
if sbp_link and settings.is_pal24_sbp_button_visible():
pay_rows.append(
[
types.InlineKeyboardButton(
text=sbp_button_text,
url=sbp_link,
)
]
)
default_card_text = texts.t(
"PAL24_CARD_PAY_BUTTON",
"💳 Оплатить банковской картой (PayPalych)",
)
card_button_text = settings.get_pal24_card_button_text(default_card_text)
if card_link and settings.is_pal24_card_button_visible():
if not pay_rows or pay_rows[-1][0].url != card_link:
pay_rows.append(
[
types.InlineKeyboardButton(
text=card_button_text,
url=card_link,
)
]
)
keyboard_rows = pay_rows + [
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
callback_data=f"check_pal24_{local_payment_id}",
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
]
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
await callback.answer()
try:
+333
View File
@@ -0,0 +1,333 @@
"""Handlers for Platega balance interactions."""
import logging
from typing import List
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
def _get_active_methods() -> List[int]:
methods = settings.get_platega_active_methods()
return [code for code in methods if code in {2, 10, 11, 12, 13}]
async def _prompt_amount(
message: types.Message,
db_user: User,
state: FSMContext,
method_code: int,
) -> None:
texts = get_texts(db_user.language)
method_name = settings.get_platega_method_display_title(method_code)
min_amount_label = settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS)
max_amount_kopeks = settings.PLATEGA_MAX_AMOUNT_KOPEKS
max_amount_label = (
settings.format_price(max_amount_kopeks)
if max_amount_kopeks and max_amount_kopeks > 0
else ""
)
default_prompt_body = (
"Введите сумму для пополнения от {min_amount} до {max_amount}.\n"
if max_amount_kopeks and max_amount_kopeks > 0
else "Введите сумму для пополнения от {min_amount}.\n"
)
prompt_template = texts.t(
"PLATEGA_TOPUP_PROMPT",
(
"💳 <b>Оплата через Platega ({method_name})</b>\n\n"
f"{default_prompt_body}"
"Оплата происходит через Platega."
),
)
keyboard = get_back_keyboard(db_user.language)
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await message.edit_text(
prompt_template.format(
method_name=method_name,
min_amount=min_amount_label,
max_amount=max_amount_label,
),
reply_markup=keyboard,
parse_mode="HTML",
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="platega", platega_method=method_code)
@error_handler
async def start_platega_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_platega_enabled():
await callback.answer(
texts.t(
"PLATEGA_TEMPORARILY_UNAVAILABLE",
"❌ Оплата через Platega временно недоступна",
),
show_alert=True,
)
return
active_methods = _get_active_methods()
if not active_methods:
await callback.answer(
texts.t(
"PLATEGA_METHODS_NOT_CONFIGURED",
"⚠️ На стороне Platega нет доступных методов оплаты",
),
show_alert=True,
)
return
await state.update_data(payment_method="platega")
if len(active_methods) == 1:
await _prompt_amount(callback.message, db_user, state, active_methods[0])
await callback.answer()
return
method_buttons: list[list[types.InlineKeyboardButton]] = []
for method_code in active_methods:
label = settings.get_platega_method_display_title(method_code)
method_buttons.append(
[
types.InlineKeyboardButton(
text=label,
callback_data=f"platega_method_{method_code}",
)
]
)
method_buttons.append(
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
)
await callback.message.edit_text(
texts.t(
"PLATEGA_SELECT_PAYMENT_METHOD",
"Выберите способ оплаты Platega:",
),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=method_buttons),
)
await state.set_state(BalanceStates.waiting_for_platega_method)
await callback.answer()
@error_handler
async def handle_platega_method_selection(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
try:
method_code = int(callback.data.rsplit("_", 1)[-1])
except ValueError:
await callback.answer("❌ Некорректный способ оплаты", show_alert=True)
return
if method_code not in _get_active_methods():
await callback.answer("⚠️ Этот способ сейчас недоступен", show_alert=True)
return
await _prompt_amount(callback.message, db_user, state, method_code)
await callback.answer()
@error_handler
async def process_platega_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_platega_enabled():
await message.answer(
texts.t(
"PLATEGA_TEMPORARILY_UNAVAILABLE",
"❌ Оплата через Platega временно недоступна",
)
)
return
data = await state.get_data()
method_code = int(data.get("platega_method", 0))
if method_code not in _get_active_methods():
await message.answer(
texts.t(
"PLATEGA_METHOD_SELECTION_REQUIRED",
"⚠️ Выберите способ оплаты Platega перед вводом суммы",
)
)
await state.set_state(BalanceStates.waiting_for_platega_method)
return
if amount_kopeks < settings.PLATEGA_MIN_AMOUNT_KOPEKS:
await message.answer(
texts.t(
"PLATEGA_AMOUNT_TOO_LOW",
"Минимальная сумма для оплаты через Platega: {amount}",
).format(amount=settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS))
)
return
if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS:
await message.answer(
texts.t(
"PLATEGA_AMOUNT_TOO_HIGH",
"Максимальная сумма для оплаты через Platega: {amount}",
).format(amount=settings.format_price(settings.PLATEGA_MAX_AMOUNT_KOPEKS))
)
return
try:
payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_platega_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
language=db_user.language,
payment_method_code=method_code,
)
except Exception as error:
logger.exception("Ошибка создания платежа Platega: %s", error)
payment_result = None
if not payment_result or not payment_result.get("redirect_url"):
await message.answer(
texts.t(
"PLATEGA_PAYMENT_ERROR",
"❌ Ошибка создания платежа Platega. Попробуйте позже или обратитесь в поддержку.",
)
)
await state.clear()
return
redirect_url = payment_result.get("redirect_url")
local_payment_id = payment_result.get("local_payment_id")
transaction_id = payment_result.get("transaction_id")
method_title = settings.get_platega_method_display_title(method_code)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"PLATEGA_PAY_BUTTON",
"💳 Оплатить через {method}",
).format(method=method_title),
url=redirect_url,
)
],
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
callback_data=f"check_platega_{local_payment_id}",
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
]
)
instructions_template = texts.t(
"PLATEGA_PAYMENT_INSTRUCTIONS",
(
"💳 <b>Оплата через Platega ({method})</b>\n\n"
"💰 Сумма: {amount}\n"
"🆔 ID транзакции: {transaction}\n\n"
"📱 <b>Инструкция:</b>\n"
"1. Нажмите кнопку «Оплатить»\n"
"2. Следуйте подсказкам платёжной системы\n"
"3. Подтвердите перевод\n"
"4. Средства зачислятся автоматически\n\n"
"❓ Если возникнут проблемы, обратитесь в {support}"
),
)
await message.answer(
instructions_template.format(
method=method_title,
amount=settings.format_price(amount_kopeks),
transaction=transaction_id or local_payment_id,
support=settings.get_support_contact_display_html(),
),
reply_markup=keyboard,
parse_mode="HTML",
)
await state.clear()
@error_handler
async def check_platega_payment_status(
callback: types.CallbackQuery,
db: AsyncSession,
):
try:
local_payment_id = int(callback.data.split("_")[-1])
except ValueError:
await callback.answer("❌ Некорректный идентификатор платежа", show_alert=True)
return
payment_service = PaymentService(callback.bot)
try:
status_info = await payment_service.get_platega_payment_status(db, local_payment_id)
except Exception as error:
logger.exception("Ошибка проверки статуса Platega: %s", error)
await callback.answer("⚠️ Ошибка проверки статуса", show_alert=True)
return
if not status_info:
await callback.answer("⚠️ Платёж не найден", show_alert=True)
return
payment = status_info.get("payment")
status = status_info.get("status")
is_paid = status_info.get("is_paid")
language = "ru"
user = getattr(payment, "user", None)
if user and getattr(user, "language", None):
language = user.language
texts = get_texts(language)
if is_paid:
await callback.answer(texts.t("PLATEGA_PAYMENT_ALREADY_CONFIRMED", "✅ Платёж уже зачислен"), show_alert=True)
else:
await callback.answer(
texts.t("PLATEGA_PAYMENT_STATUS", "Текущий статус платежа: {status}").format(status=status),
show_alert=True,
)
+50 -3
View File
@@ -1655,8 +1655,18 @@ async def check_simple_pal24_payment_status(
if not isinstance(links_meta, dict):
links_meta = {}
sbp_link = links_meta.get("sbp") or payment.link_url
card_link = links_meta.get("card")
links_info = status_info.get("links") or {}
sbp_link = (
links_info.get("sbp")
or links_meta.get("sbp")
or status_info.get("sbp_url")
or payment.link_url
)
card_link = (
links_info.get("card")
or links_meta.get("card")
or status_info.get("card_url")
)
if not card_link and payment.link_page_url and payment.link_page_url != sbp_link:
card_link = payment.link_page_url
@@ -1689,8 +1699,45 @@ async def check_simple_pal24_payment_status(
f"❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}",
]
pay_rows: list[list[types.InlineKeyboardButton]] = []
if not payment.is_paid and payment.status in {"NEW", "PROCESS"}:
default_sbp_text = texts.t(
"PAL24_SBP_PAY_BUTTON",
"🏦 Оплатить через PayPalych (СБП)",
)
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
if sbp_link and settings.is_pal24_sbp_button_visible():
pay_rows.append(
[
types.InlineKeyboardButton(
text=sbp_button_text,
url=sbp_link,
)
]
)
default_card_text = texts.t(
"PAL24_CARD_PAY_BUTTON",
"💳 Оплатить банковской картой (PayPalych)",
)
card_button_text = settings.get_pal24_card_button_text(default_card_text)
if card_link and settings.is_pal24_card_button_visible():
if not pay_rows or pay_rows[-1][0].url != card_link:
pay_rows.append(
[
types.InlineKeyboardButton(
text=card_button_text,
url=card_link,
)
]
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
inline_keyboard=pay_rows
+ [
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
+4
View File
@@ -130,6 +130,10 @@ async def handle_successful_payment(
"⭐ Потрачено звезд: {stars_spent}\n"
"💰 Зачислено на баланс: {amount}\n"
"🆔 ID транзакции: {transaction_id}...\n\n"
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
"Обязательно активируйте подписку отдельно!\n\n"
"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
"подписка будет приобретена автоматически после пополнения баланса.\n\n"
"Спасибо за пополнение! 🚀",
).format(
stars_spent=payment.total_amount,
+320 -68
View File
@@ -50,7 +50,7 @@ from app.keyboards.inline import (
from app.services.user_cart_service import user_cart_service
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveService
from app.services.remnawave_service import RemnaWaveConfigurationError, RemnaWaveService
from app.services.subscription_checkout_service import (
clear_subscription_checkout_draft,
get_subscription_checkout_draft,
@@ -58,6 +58,14 @@ from app.services.subscription_checkout_service import (
should_offer_checkout_resume,
)
from app.services.subscription_service import SubscriptionService
from app.services.trial_activation_service import (
TrialPaymentChargeFailed,
TrialPaymentInsufficientFunds,
charge_trial_activation_if_required,
preview_trial_activation_charge,
revert_trial_activation,
rollback_trial_subscription_activation,
)
def _serialize_markup(markup: Optional[InlineKeyboardMarkup]) -> Optional[Any]:
@@ -405,10 +413,7 @@ async def show_trial_offer(
trial_server_name = texts.t("TRIAL_SERVER_DEFAULT_NAME", "🎯 Тестовый сервер")
try:
from app.database.crud.server_squad import (
get_server_squad_by_uuid,
get_trial_eligible_server_squads,
)
from app.database.crud.server_squad import get_trial_eligible_server_squads
trial_squads = await get_trial_eligible_server_squads(db, include_unavailable=True)
@@ -420,21 +425,18 @@ async def show_trial_offer(
"TRIAL_SERVER_RANDOM_POOL",
"🎲 Случайный из {count} серверов",
).format(count=len(trial_squads))
elif settings.TRIAL_SQUAD_UUID:
trial_server = await get_server_squad_by_uuid(db, settings.TRIAL_SQUAD_UUID)
if trial_server:
trial_server_name = trial_server.display_name
else:
logger.warning(
"Триальный сервер с UUID %s не найден в БД",
settings.TRIAL_SQUAD_UUID,
)
else:
logger.warning("Не настроены сквады для выдачи триалов")
except Exception as e:
logger.error(f"Ошибка получения триального сервера: {e}")
trial_device_limit = settings.TRIAL_DEVICE_LIMIT
if not settings.is_devices_selection_enabled():
forced_limit = settings.get_disabled_mode_device_limit()
if forced_limit is not None:
trial_device_limit = forced_limit
devices_line = ""
if settings.is_devices_selection_enabled():
devices_line_template = texts.t(
@@ -442,14 +444,25 @@ async def show_trial_offer(
"\n📱 <b>Устройства:</b> {devices} шт.",
)
devices_line = devices_line_template.format(
devices=settings.TRIAL_DEVICE_LIMIT,
devices=trial_device_limit,
)
price_line = ""
if settings.is_trial_paid_activation_enabled():
trial_price = settings.get_trial_activation_price()
if trial_price > 0:
price_line = texts.t(
"TRIAL_PAYMENT_PRICE_LINE",
"\n💳 <b>Стоимость активации:</b> {price}",
).format(price=settings.format_price(trial_price))
trial_text = texts.TRIAL_AVAILABLE.format(
days=settings.TRIAL_DURATION_DAYS,
traffic=texts.format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB),
devices=trial_device_limit if trial_device_limit is not None else "",
devices_line=devices_line,
server_name=trial_server_name
server_name=trial_server_name,
price_line=price_line,
)
await callback.message.edit_text(
@@ -475,6 +488,33 @@ async def activate_trial(
await callback.answer()
return
try:
preview_trial_activation_charge(db_user)
except TrialPaymentInsufficientFunds as error:
required_label = settings.format_price(error.required_amount)
balance_label = settings.format_price(error.balance_amount)
missing_label = settings.format_price(error.missing_amount)
message = texts.t(
"TRIAL_PAYMENT_INSUFFICIENT_FUNDS",
"⚠️ Недостаточно средств для активации триала.\n"
"Необходимо: {required}\nНа балансе: {balance}\n"
"Не хватает: {missing}\n\nПополните баланс и попробуйте снова.",
).format(required=required_label, balance=balance_label, missing=missing_label)
await callback.message.edit_text(
message,
reply_markup=get_insufficient_balance_keyboard(
db_user.language,
amount_kopeks=error.required_amount,
),
)
await callback.answer()
return
charged_amount = 0
subscription: Optional[Subscription] = None
remnawave_user = None
try:
forced_devices = None
if not settings.is_devices_selection_enabled():
@@ -488,48 +528,197 @@ async def activate_trial(
await db.refresh(db_user)
try:
charged_amount = await charge_trial_activation_if_required(
db,
db_user,
description="Активация триала через бота",
)
except TrialPaymentInsufficientFunds as error:
rollback_success = await rollback_trial_subscription_activation(db, subscription)
await db.refresh(db_user)
if not rollback_success:
await callback.answer(
texts.t(
"TRIAL_ROLLBACK_FAILED",
"Не удалось отменить активацию триала. Попробуйте позже.",
),
show_alert=True,
)
return
logger.error(
"Insufficient funds detected after trial creation for user %s: %s",
db_user.id,
error,
)
required_label = settings.format_price(error.required_amount)
balance_label = settings.format_price(error.balance_amount)
missing_label = settings.format_price(error.missing_amount)
message = texts.t(
"TRIAL_PAYMENT_INSUFFICIENT_FUNDS",
"⚠️ Недостаточно средств для активации триала.\n"
"Необходимо: {required}\nНа балансе: {balance}\n"
"Не хватает: {missing}\n\nПополните баланс и попробуйте снова.",
).format(
required=required_label,
balance=balance_label,
missing=missing_label,
)
await callback.message.edit_text(
message,
reply_markup=get_insufficient_balance_keyboard(
db_user.language,
amount_kopeks=error.required_amount,
),
)
await callback.answer()
return
except TrialPaymentChargeFailed:
rollback_success = await rollback_trial_subscription_activation(db, subscription)
await db.refresh(db_user)
if not rollback_success:
await callback.answer(
texts.t(
"TRIAL_ROLLBACK_FAILED",
"Не удалось отменить активацию триала. Попробуйте позже.",
),
show_alert=True,
)
return
await callback.answer(
texts.t(
"TRIAL_PAYMENT_FAILED",
"Не удалось списать средства для активации триала. Попробуйте позже.",
),
show_alert=True,
)
return
subscription_service = SubscriptionService()
remnawave_user = await subscription_service.create_remnawave_user(
db, subscription
)
try:
remnawave_user = await subscription_service.create_remnawave_user(
db,
subscription,
)
except RemnaWaveConfigurationError as error:
logger.error("RemnaWave update skipped due to configuration error: %s", error)
revert_result = await revert_trial_activation(
db,
db_user,
subscription,
charged_amount,
refund_description="Возврат оплаты за активацию триала через бота",
)
if not revert_result.subscription_rolled_back:
failure_text = texts.t(
"TRIAL_ROLLBACK_FAILED",
"Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.",
)
elif charged_amount > 0 and not revert_result.refunded:
failure_text = texts.t(
"TRIAL_REFUND_FAILED",
"Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.",
)
else:
failure_text = texts.t(
"TRIAL_PROVISIONING_FAILED",
"Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.",
)
await callback.message.edit_text(
failure_text,
reply_markup=get_back_keyboard(db_user.language),
)
await callback.answer()
return
except Exception as error:
logger.error(
"Failed to create RemnaWave user for trial subscription %s: %s",
getattr(subscription, "id", "<unknown>"),
error,
)
revert_result = await revert_trial_activation(
db,
db_user,
subscription,
charged_amount,
refund_description="Возврат оплаты за активацию триала через бота",
)
if not revert_result.subscription_rolled_back:
failure_text = texts.t(
"TRIAL_ROLLBACK_FAILED",
"Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.",
)
elif charged_amount > 0 and not revert_result.refunded:
failure_text = texts.t(
"TRIAL_REFUND_FAILED",
"Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.",
)
else:
failure_text = texts.t(
"TRIAL_PROVISIONING_FAILED",
"Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.",
)
await callback.message.edit_text(
failure_text,
reply_markup=get_back_keyboard(db_user.language),
)
await callback.answer()
return
await db.refresh(db_user)
try:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_trial_activation_notification(db, db_user, subscription)
await notification_service.send_trial_activation_notification(
db,
db_user,
subscription,
charged_amount_kopeks=charged_amount,
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о триале: {e}")
subscription_link = get_display_subscription_link(subscription)
hide_subscription_link = settings.should_hide_subscription_link()
payment_note = ""
if charged_amount > 0:
payment_note = "\n\n" + texts.t(
"TRIAL_PAYMENT_CHARGED_NOTE",
"💳 С вашего баланса списано {amount}.",
).format(amount=settings.format_price(charged_amount))
if remnawave_user and subscription_link:
if settings.is_happ_cryptolink_mode():
trial_success_text = (
f"{texts.TRIAL_ACTIVATED}\n\n"
+ texts.t(
"SUBSCRIPTION_HAPP_LINK_PROMPT",
"🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",
)
+ "\n\n"
+ texts.t(
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
"📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
)
f"{texts.TRIAL_ACTIVATED}\n\n"
+ texts.t(
"SUBSCRIPTION_HAPP_LINK_PROMPT",
"🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",
)
+ "\n\n"
+ texts.t(
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
"📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
)
)
elif hide_subscription_link:
trial_success_text = (
f"{texts.TRIAL_ACTIVATED}\n\n"
+ texts.t(
"SUBSCRIPTION_LINK_HIDDEN_NOTICE",
"ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
)
+ "\n\n"
+ texts.t(
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
"📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
)
f"{texts.TRIAL_ACTIVATED}\n\n"
+ texts.t(
"SUBSCRIPTION_LINK_HIDDEN_NOTICE",
"ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
)
+ "\n\n"
+ texts.t(
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
"📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
)
)
else:
subscription_import_link = texts.t(
@@ -543,6 +732,8 @@ async def activate_trial(
f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}"
)
trial_success_text += payment_note
connect_mode = settings.CONNECT_BUTTON_MODE
if connect_mode == "miniapp_subscription":
@@ -553,8 +744,12 @@ async def activate_trial(
web_app=types.WebAppInfo(url=subscription_link),
)
],
[InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
callback_data="back_to_menu")],
[
InlineKeyboardButton(
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
callback_data="back_to_menu",
)
],
])
elif connect_mode == "miniapp_custom":
if not settings.MINIAPP_CUSTOM_URL:
@@ -574,22 +769,33 @@ async def activate_trial(
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL),
)
],
[InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
callback_data="back_to_menu")],
[
InlineKeyboardButton(
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
callback_data="back_to_menu",
)
],
])
elif connect_mode == "link":
rows = [
[InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)]
[
InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
url=subscription_link,
)
]
]
happ_row = get_happ_download_button_row(texts)
if happ_row:
rows.append(happ_row)
rows.append([
InlineKeyboardButton(
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
callback_data="back_to_menu"
)
])
rows.append(
[
InlineKeyboardButton(
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
callback_data="back_to_menu",
)
]
)
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
elif connect_mode == "happ_cryptolink":
rows = [
@@ -603,40 +809,86 @@ async def activate_trial(
happ_row = get_happ_download_button_row(texts)
if happ_row:
rows.append(happ_row)
rows.append([
InlineKeyboardButton(
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
callback_data="back_to_menu"
)
])
rows.append(
[
InlineKeyboardButton(
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
callback_data="back_to_menu",
)
]
)
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
else:
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
callback_data="subscription_connect")],
[InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
callback_data="back_to_menu")],
])
connect_keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
callback_data="subscription_connect",
)
],
[
InlineKeyboardButton(
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
callback_data="back_to_menu",
)
],
]
)
await callback.message.edit_text(
trial_success_text,
reply_markup=connect_keyboard,
parse_mode="HTML"
parse_mode="HTML",
)
else:
trial_success_text = (
f"{texts.TRIAL_ACTIVATED}\n\n⚠️ Ссылка генерируется, попробуйте перейти в раздел 'Моя подписка' через несколько секунд."
)
trial_success_text += payment_note
await callback.message.edit_text(
f"{texts.TRIAL_ACTIVATED}\n\n⚠️ Ссылка генерируется, попробуйте перейти в раздел 'Моя подписка' через несколько секунд.",
reply_markup=get_back_keyboard(db_user.language)
trial_success_text,
reply_markup=get_back_keyboard(db_user.language),
)
logger.info(f"✅ Активирована тестовая подписка для пользователя {db_user.telegram_id}")
logger.info(
f"✅ Активирована тестовая подписка для пользователя {db_user.telegram_id}"
)
except Exception as e:
logger.error(f"Ошибка активации триала: {e}")
failure_text = texts.ERROR
if subscription and remnawave_user is None:
revert_result = await revert_trial_activation(
db,
db_user,
subscription,
charged_amount,
refund_description="Возврат оплаты за активацию триала через бота",
)
if not revert_result.subscription_rolled_back:
failure_text = texts.t(
"TRIAL_ROLLBACK_FAILED",
"Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.",
)
elif charged_amount > 0 and not revert_result.refunded:
failure_text = texts.t(
"TRIAL_REFUND_FAILED",
"Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.",
)
else:
failure_text = texts.t(
"TRIAL_PROVISIONING_FAILED",
"Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.",
)
await callback.message.edit_text(
texts.ERROR,
failure_text,
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()
return
await callback.answer()
+5 -1
View File
@@ -114,7 +114,11 @@ async def handle_successful_payment(message: types.Message):
)
await message.answer(
f"✅ Баланс успешно пополнен на {settings.format_price(amount_kopeks)}!"
f"✅ Баланс успешно пополнен на {settings.format_price(amount_kopeks)}!\n\n"
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса."
)
logger.info(f"✅ Обработан Stars платеж: {payment.telegram_payment_charge_id}")
+8
View File
@@ -1159,6 +1159,14 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
])
if settings.is_platega_enabled() and settings.get_platega_active_methods():
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_PLATEGA", "💳 Platega"),
callback_data=_build_callback("platega"),
)
])
if settings.is_cryptobot_enabled():
keyboard.append([
InlineKeyboardButton(
+25 -2
View File
@@ -1027,6 +1027,7 @@
"PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)",
"PAYMENT_CARD_WATA": "💳 Bank card (WATA)",
"PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)",
"PAYMENT_PLATEGA": "💳 Platega",
"PAYMENT_CHARGE_ERROR": "⚠️ Failed to charge the payment",
"PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)",
"PAYMENT_HELEKET": "🪙 Cryptocurrency (Heleket)",
@@ -1060,6 +1061,8 @@
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card ({mulenpay_name})</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System",
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>SBP (PayPalych)</b>",
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "via Platega (cards + SBP)",
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Bank card (Platega)</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options",
@@ -1078,6 +1081,18 @@
"PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)",
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
"PAYMENT_VIA_SUPPORT": "🛠️ Via support",
"PLATEGA_TOPUP_PROMPT": "💳 <b>Payment via Platega ({method_name})</b>\n\nEnter an amount from {min_amount} to {max_amount}.\nPayment is processed by Platega.",
"PLATEGA_SELECT_PAYMENT_METHOD": "Choose a Platega payment method:",
"PLATEGA_TEMPORARILY_UNAVAILABLE": "❌ Platega payments are temporarily unavailable",
"PLATEGA_METHODS_NOT_CONFIGURED": "⚠️ No active Platega methods configured",
"PLATEGA_METHOD_SELECTION_REQUIRED": "⚠️ Select a Platega payment method before entering the amount",
"PLATEGA_AMOUNT_TOO_LOW": "Minimum amount for Platega: {amount}",
"PLATEGA_AMOUNT_TOO_HIGH": "Maximum amount for Platega: {amount}",
"PLATEGA_PAYMENT_ERROR": "❌ Failed to create Platega payment. Please try again later or contact support.",
"PLATEGA_PAYMENT_INSTRUCTIONS": "💳 <b>Payment via Platega ({method})</b>\n\n💰 Amount: {amount}\n🆔 Transaction ID: {transaction}\n\n📱 <b>Instructions:</b>\n1. Tap the Pay button\n2. Follow the payment provider instructions\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ If you have issues, contact {support}",
"PLATEGA_PAY_BUTTON": "💳 Pay via {method}",
"PLATEGA_PAYMENT_ALREADY_CONFIRMED": "✅ Payment already credited",
"PLATEGA_PAYMENT_STATUS": "Current payment status: {status}",
"PAY_NOW_BUTTON": "💳 Pay",
"PAY_WITH_COINS_BUTTON": "🪙 Pay",
"PENDING_CANCEL_BUTTON": "⌛ Cancel",
@@ -1229,7 +1244,7 @@
"SKIP_BUTTON": "Skip ➡️",
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Failed to credit funds. Please contact support; the payment will be verified manually.",
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Technical error processing the payment. Please contact support for assistance.",
"STARS_PAYMENT_SUCCESS": "🎉 <b>Payment processed successfully!</b>\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\nThank you for topping up! 🚀",
"STARS_PAYMENT_SUCCESS": "🎉 <b>Payment processed successfully!</b>\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\n⚠️ <b>Important:</b> Balance top-up does not automatically activate your subscription. Please activate your subscription separately!\n\n🔄 If you have a saved subscription cart and auto-purchase is enabled, your subscription will be automatically purchased after balance top-up.\n\nThank you for topping up! 🚀",
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Error: user not found. Please contact support.",
"STARS_PRECHECK_INVALID_PAYLOAD": "Payment validation error. Please try again.",
"STARS_PRECHECK_TECHNICAL_ERROR": "Technical error. Please try again later.",
@@ -1396,11 +1411,19 @@
"TRAFFIC_NO_CHANGE": "️ Traffic limit was not changed",
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured",
"TRAFFIC_UNLIMITED": "📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
"TRAFFIC_UNLIMITED_SHORT": "Unlimited",
"TRIAL_ACTIVATED": "🎉 Trial subscription activated!",
"TRIAL_ACTIVATE_BUTTON": "🎁 Activate",
"TRIAL_ALREADY_USED": "❌ The trial subscription has already been used",
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a free trial plan:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic}{devices_line}\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a trial plan{price_line}:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic}{devices_line}\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
"TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 <b>Devices:</b> {devices} pcs",
"TRIAL_PAYMENT_PRICE_LINE": "\n💳 <b>Activation price:</b> {price}",
"TRIAL_PAYMENT_INSUFFICIENT_FUNDS": "⚠️ Not enough funds to activate the trial.\nRequired: {required}\nOn balance: {balance}\nMissing: {missing}\n\nTop up your balance and try again.",
"TRIAL_PAYMENT_FAILED": "We couldn't charge your balance to activate the trial. Please try again later.",
"TRIAL_PROVISIONING_FAILED": "We couldn't finish setting up the trial. Any charge has been refunded. Please try again later.",
"TRIAL_ROLLBACK_FAILED": "We couldn't cancel the trial activation after a payment error. Please contact support and try again later.",
"TRIAL_REFUND_FAILED": "We couldn't refund the trial activation charge. Please contact support immediately.",
"TRIAL_PAYMENT_CHARGED_NOTE": "💳 {amount} has been deducted from your balance.",
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Access paused</b>\n\nWe couldn't find your subscription to our channel, so the trial plan has been disabled.\n\nJoin the channel and tap “{check_button}” to restore access.",
"TRIAL_ENDING_SOON": "\n🎁 <b>The trial subscription is ending soon!</b>\n\nYour trial expires in a few hours.\n\n💎 <b>Don't want to lose VPN access?</b>\nSwitch to the full subscription!\n\n🔥 <b>Special offer:</b>\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n",
"TRIAL_INACTIVE_1H": "⏳ <b>An hour has passed and we haven't seen any traffic yet</b>\n\nOpen the connection guide and follow the steps. We're always ready to help!",
+25 -2
View File
@@ -1047,6 +1047,7 @@
"PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)",
"PAYMENT_CARD_WATA": "💳 Банковская карта (WATA)",
"PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)",
"PAYMENT_PLATEGA": "💳 Platega",
"PAYMENT_CHARGE_ERROR": "⚠️ Ошибка списания средств",
"PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)",
"PAYMENT_HELEKET": "🪙 Криптовалюта (Heleket)",
@@ -1080,6 +1081,8 @@
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта ({mulenpay_name})</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей",
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>СБП (PayPalych)</b>",
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (карты + СБП)",
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банковская карта (Platega)</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы",
@@ -1098,6 +1101,18 @@
"PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)",
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
"PAYMENT_VIA_SUPPORT": "🛠️ Через поддержку",
"PLATEGA_TOPUP_PROMPT": "💳 <b>Оплата через Platega ({method_name})</b>\n\nВведите сумму для пополнения от {min_amount} до {max_amount}.\nОплата происходит через Platega.",
"PLATEGA_SELECT_PAYMENT_METHOD": "Выберите способ оплаты Platega:",
"PLATEGA_TEMPORARILY_UNAVAILABLE": "❌ Оплата через Platega временно недоступна",
"PLATEGA_METHODS_NOT_CONFIGURED": "⚠️ На стороне Platega нет доступных методов оплаты",
"PLATEGA_METHOD_SELECTION_REQUIRED": "⚠️ Выберите способ оплаты Platega перед вводом суммы",
"PLATEGA_AMOUNT_TOO_LOW": "Минимальная сумма для оплаты через Platega: {amount}",
"PLATEGA_AMOUNT_TOO_HIGH": "Максимальная сумма для оплаты через Platega: {amount}",
"PLATEGA_PAYMENT_ERROR": "❌ Ошибка создания платежа Platega. Попробуйте позже или обратитесь в поддержку.",
"PLATEGA_PAYMENT_INSTRUCTIONS": "💳 <b>Оплата через Platega ({method})</b>\n\n💰 Сумма: {amount}\n🆔 ID транзакции: {transaction}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку «Оплатить»\n2. Следуйте подсказкам платёжной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
"PLATEGA_PAY_BUTTON": "💳 Оплатить через {method}",
"PLATEGA_PAYMENT_ALREADY_CONFIRMED": "✅ Платёж уже зачислен",
"PLATEGA_PAYMENT_STATUS": "Текущий статус платежа: {status}",
"PAY_NOW_BUTTON": "💳 Оплатить",
"PAY_WITH_COINS_BUTTON": "🪙 Оплатить",
"PENDING_CANCEL_BUTTON": "⌛ Отмена",
@@ -1249,7 +1264,7 @@
"SKIP_BUTTON": "⏭️ Пропустить",
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Произошла ошибка при зачислении средств. Обратитесь в поддержку, платеж будет проверен вручную.",
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Техническая ошибка при обработке платежа. Обратитесь в поддержку для решения проблемы.",
"STARS_PAYMENT_SUCCESS": "🎉 <b>Платеж успешно обработан!</b>\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n🆔 ID транзакции: {transaction_id}...\n\nСпасибо за пополнение! 🚀",
"STARS_PAYMENT_SUCCESS": "🎉 <b>Платеж успешно обработан!</b>\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n\n🆔 ID транзакции: {transaction_id}...\n\n⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. Обязательно активируйте подписку отдельно!\n\n🔄 При наличии сохранённой корзины подписки и включенной автопокупке, подписка будет приобретена автоматически после пополнения баланса.\n\nСпасибо за пополнение! 🚀",
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Ошибка: пользователь не найден. Обратитесь в поддержку.",
"STARS_PRECHECK_INVALID_PAYLOAD": "Ошибка валидации платежа. Попробуйте еще раз.",
"STARS_PRECHECK_TECHNICAL_ERROR": "Техническая ошибка. Попробуйте позже.",
@@ -1416,11 +1431,19 @@
"TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился",
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакеты трафика не настроены",
"TRAFFIC_UNLIMITED": "📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
"TRAFFIC_UNLIMITED_SHORT": "Безлимит",
"TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!",
"TRIAL_ACTIVATE_BUTTON": "🎁 Активировать",
"TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована",
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic}{devices_line}\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить тестовую подписку{price_line}:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic}{devices_line}\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
"TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 <b>Устройства:</b> {devices} шт.",
"TRIAL_PAYMENT_PRICE_LINE": "\n💳 <b>Стоимость активации:</b> {price}",
"TRIAL_PAYMENT_INSUFFICIENT_FUNDS": "⚠️ Недостаточно средств для активации триала.\nНеобходимо: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nПополните баланс и попробуйте снова.",
"TRIAL_PAYMENT_FAILED": "Не удалось списать средства для активации триала. Попробуйте позже.",
"TRIAL_PROVISIONING_FAILED": "Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.",
"TRIAL_ROLLBACK_FAILED": "Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.",
"TRIAL_REFUND_FAILED": "Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.",
"TRIAL_PAYMENT_CHARGED_NOTE": "💳 С вашего баланса списано {amount}.",
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Доступ приостановлен</b>\n\nМы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\nПодпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.",
"TRIAL_ENDING_SOON": "\n🎁 <b>Тестовая подписка скоро закончится!</b>\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 <b>Не хотите остаться без VPN?</b>\nПереходите на полную подписку!\n\n🔥 <b>Специальное предложение:</b>\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n",
"TRIAL_INACTIVE_1H": "⏳ <b>Прошёл час, а подключение не выполнено</b>\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!",
+10 -1
View File
@@ -190,7 +190,9 @@ class AdminNotificationService:
self,
db: AsyncSession,
user: User,
subscription: Subscription
subscription: Subscription,
*,
charged_amount_kopeks: Optional[int] = None,
) -> bool:
if not self._is_enabled():
return False
@@ -210,6 +212,12 @@ class AdminNotificationService:
else:
trial_device_limit = settings.TRIAL_DEVICE_LIMIT
payment_block = ""
if charged_amount_kopeks and charged_amount_kopeks > 0:
payment_block = (
f"\n💳 <b>Оплата за активацию:</b> {settings.format_price(charged_amount_kopeks)}"
)
message = f"""🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>
👤 <b>Пользователь:</b> {user_display}
@@ -224,6 +232,7 @@ class AdminNotificationService:
📊 Трафик: {self._format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB)}
📱 Устройства: {trial_device_limit}
🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else 'По умолчанию'}
{payment_block}
📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}
🔗 <b>Реферер:</b> {referrer_info}
File diff suppressed because it is too large Load Diff
+1 -6
View File
@@ -129,10 +129,7 @@ class AdvertisingCampaignService:
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
trial_uuid = await get_random_trial_squad_uuid(
db,
getattr(settings, "TRIAL_SQUAD_UUID", None),
)
trial_uuid = await get_random_trial_squad_uuid(db)
if trial_uuid:
squads = [trial_uuid]
except Exception as error:
@@ -141,8 +138,6 @@ class AdvertisingCampaignService:
campaign.id,
error,
)
if getattr(settings, "TRIAL_SQUAD_UUID", None):
squads = [settings.TRIAL_SQUAD_UUID]
new_subscription = await create_paid_subscription(
db=db,
+23 -5
View File
@@ -288,12 +288,30 @@ class MaintenanceService:
self._status.last_check = datetime.utcnow()
auth_params = settings.get_remnawave_auth_params()
base_url = (auth_params.get("base_url") or "").strip()
api_key = (auth_params.get("api_key") or "").strip()
secret_key = (auth_params.get("secret_key") or "").strip() or None
username = (auth_params.get("username") or "").strip() or None
password = (auth_params.get("password") or "").strip() or None
if not base_url:
logger.error("REMNAWAVE_API_URL не настроен, пропускаем проверку API")
self._status.api_status = False
self._status.consecutive_failures = 0
return False
if not api_key:
logger.error("REMNAWAVE_API_KEY не настроен, пропускаем проверку API")
self._status.api_status = False
self._status.consecutive_failures = 0
return False
api = RemnaWaveAPI(
base_url=auth_params["base_url"],
api_key=auth_params["api_key"],
secret_key=auth_params["secret_key"],
username=auth_params["username"],
password=auth_params["password"]
base_url=base_url,
api_key=api_key,
secret_key=secret_key,
username=username,
password=password
)
attempts = settings.get_maintenance_retry_attempts()
+4 -1
View File
@@ -937,7 +937,10 @@ class MonitoringService:
if not user:
continue
renewal_cost = settings.PRICE_30_DAYS
# Правильный расчет стоимости продления с учетом всех параметров подписки
renewal_cost = await self.subscription_service.calculate_renewal_price(
subscription, 30, db, user=user
)
promo_discount_percent = self._get_user_promo_offer_discount_percent(user)
charge_amount = renewal_cost
promo_discount_value = 0
+4 -4
View File
@@ -90,21 +90,21 @@ class Pal24Service:
return await self.client.get_bill_payments(bill_id)
@staticmethod
def parse_postback(payload: Dict[str, Any]) -> Dict[str, Any]:
def parse_callback(payload: Dict[str, Any]) -> Dict[str, Any]:
required_fields = ["InvId", "OutSum", "Status", "SignatureValue"]
missing = [field for field in required_fields if field not in payload]
if missing:
raise Pal24APIError(f"Pal24 postback missing fields: {', '.join(missing)}")
raise Pal24APIError(f"Pal24 callback missing fields: {', '.join(missing)}")
inv_id = str(payload["InvId"])
out_sum = str(payload["OutSum"])
signature = str(payload["SignatureValue"])
if not Pal24Client.verify_signature(out_sum, inv_id, signature):
raise Pal24APIError("Pal24 postback signature mismatch")
raise Pal24APIError("Pal24 callback signature mismatch")
logger.info(
"Получен Pal24 postback: InvId=%s, Status=%s, TrsId=%s",
"Получен Pal24 callback: InvId=%s, Status=%s, TrsId=%s",
inv_id,
payload.get("Status"),
payload.get("TrsId"),
+2
View File
@@ -12,6 +12,7 @@ from .cryptobot import CryptoBotPaymentMixin
from .heleket import HeleketPaymentMixin
from .mulenpay import MulenPayPaymentMixin
from .pal24 import Pal24PaymentMixin
from .platega import PlategaPaymentMixin
from .wata import WataPaymentMixin
__all__ = [
@@ -23,5 +24,6 @@ __all__ = [
"HeleketPaymentMixin",
"MulenPayPaymentMixin",
"Pal24PaymentMixin",
"PlategaPaymentMixin",
"WataPaymentMixin",
]
+16 -2
View File
@@ -42,6 +42,7 @@ class PaymentCommonMixin:
user and user.subscription and not user.subscription.is_trial and user.subscription.is_active
)
# Создаем основную кнопку: если есть активная подписка - продлить, иначе купить
first_button = build_miniapp_or_callback_button(
text=(
texts.MENU_EXTEND_SUBSCRIPTION
@@ -53,7 +54,16 @@ class PaymentCommonMixin:
),
)
keyboard_rows: list[list[InlineKeyboardButton]] = [[first_button]]
# Кнопка активации подписки (всегда отображается)
activate_subscription_button = build_miniapp_or_callback_button(
text="🚀 Активировать подписку",
callback_data="menu_buy" # Используем ту же callback_data что и "Купить подписку"
)
keyboard_rows: list[list[InlineKeyboardButton]] = [
[first_button],
[activate_subscription_button]
]
# Если для пользователя есть незавершённый checkout, предлагаем вернуться к нему.
if user:
@@ -128,7 +138,11 @@ class PaymentCommonMixin:
"✅ <b>Платеж успешно завершен!</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"💳 Способ: {payment_method}\n\n"
"Средства зачислены на ваш баланс!"
"Средства зачислены на ваш баланс!\n\n"
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса."
)
await self.bot.send_message(
+205 -65
View File
@@ -1,8 +1,9 @@
"""Mixin с логикой обработки платежей CryptoBot."""
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from datetime import datetime
from importlib import import_module
from typing import Any, Dict, Optional
@@ -10,6 +11,7 @@ from typing import Any, Dict, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.database import AsyncSessionLocal
from app.database.models import PaymentMethod, TransactionType
from app.services.subscription_auto_purchase_service import (
auto_purchase_saved_cart_after_topup,
@@ -20,6 +22,33 @@ from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class _AdminNotificationContext:
user_id: int
transaction_id: int
old_balance: int
topup_status: str
referrer_info: str
@dataclass(slots=True)
class _UserNotificationPayload:
telegram_id: int
text: str
parse_mode: Optional[str]
reply_markup: Any
amount_rubles: float
asset: str
@dataclass(slots=True)
class _SavedCartNotificationPayload:
telegram_id: int
text: str
reply_markup: Any
user_id: int
class CryptoBotPaymentMixin:
"""Mixin, отвечающий за генерацию инвойсов CryptoBot и обработку webhook."""
@@ -149,14 +178,16 @@ class CryptoBotPaymentMixin:
try:
amount_rubles = await currency_converter.usd_to_rub(amount_usd)
amount_kopeks = int(amount_rubles * 100)
amount_rubles_rounded = math.ceil(amount_rubles)
amount_kopeks = int(amount_rubles_rounded * 100)
conversion_rate = (
amount_rubles / amount_usd if amount_usd > 0 else 0
)
logger.info(
"Конвертация USD->RUB: $%s -> %s₽ (курс: %.2f)",
"Конвертация USD->RUB: $%s -> %s₽ (округлено до %s₽, курс: %.2f)",
amount_usd,
amount_rubles,
amount_rubles_rounded,
conversion_rate,
)
except Exception as error:
@@ -166,7 +197,8 @@ class CryptoBotPaymentMixin:
error,
)
amount_rubles = amount_usd
amount_kopeks = int(amount_usd * 100)
amount_rubles_rounded = math.ceil(amount_rubles)
amount_kopeks = int(amount_rubles_rounded * 100)
conversion_rate = 1.0
if amount_kopeks <= 0:
@@ -185,7 +217,7 @@ class CryptoBotPaymentMixin:
amount_kopeks=amount_kopeks,
description=(
"Пополнение через CryptoBot "
f"({updated_payment.amount} {updated_payment.asset}{amount_rubles:.2f}₽)"
f"({updated_payment.amount} {updated_payment.asset}{amount_rubles_rounded:.2f}₽)"
),
payment_method=PaymentMethod.CRYPTOBOT,
external_id=invoice_id,
@@ -211,8 +243,6 @@ class CryptoBotPaymentMixin:
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = user.get_primary_promo_group()
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = (
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
@@ -241,55 +271,41 @@ class CryptoBotPaymentMixin:
await db.refresh(user)
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import (
AdminNotificationService,
)
admin_notification: Optional[_AdminNotificationContext] = None
user_notification: Optional[_UserNotificationPayload] = None
saved_cart_notification: Optional[_SavedCartNotificationPayload] = None
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о пополнении CryptoBot: %s",
error,
)
bot_instance = getattr(self, "bot", None)
if bot_instance:
admin_notification = _AdminNotificationContext(
user_id=user.id,
transaction_id=transaction.id,
old_balance=old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
)
if getattr(self, "bot", None):
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"🪙 Платеж: {updated_payment.amount} {updated_payment.asset}\n"
f"💱 Курс: 1 USD = {conversion_rate:.2f}\n"
f"🆔 Транзакция: {invoice_id[:8]}...\n\n"
"Баланс пополнен автоматически!"
),
message_text = (
"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"🪙 Платеж: {updated_payment.amount} {updated_payment.asset}\n"
f"💱 Курс: 1 USD = {conversion_rate:.2f}\n"
f"🆔 Транзакция: {invoice_id[:8]}...\n\n"
"Баланс пополнен автоматически!"
)
user_notification = _UserNotificationPayload(
telegram_id=user.telegram_id,
text=message_text,
parse_mode="HTML",
reply_markup=keyboard,
)
logger.info(
"✅ Отправлено уведомление пользователю %s о пополнении на %s₽ (%s)",
user.telegram_id,
f"{amount_rubles:.2f}",
updated_payment.asset,
amount_rubles=amount_rubles_rounded,
asset=updated_payment.asset,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о пополнении CryptoBot: %s",
"Ошибка подготовки уведомления о пополнении CryptoBot: %s",
error,
)
@@ -305,7 +321,7 @@ class CryptoBotPaymentMixin:
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
db,
user,
bot=getattr(self, "bot", None),
bot=bot_instance,
)
except Exception as auto_error:
logger.error(
@@ -318,17 +334,14 @@ class CryptoBotPaymentMixin:
if auto_purchase_success:
has_saved_cart = False
if has_saved_cart and getattr(self, "bot", None):
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
if has_saved_cart and bot_instance:
from app.localization.texts import get_texts
texts = get_texts(user.language)
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
total_amount=settings.format_price(payment.amount_kopeks)
)
# Создаем клавиатуру с кнопками
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
@@ -343,18 +356,35 @@ class CryptoBotPaymentMixin:
callback_data="back_to_menu"
)]
])
await self.bot.send_message(
chat_id=user.telegram_id,
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
reply_markup=keyboard
saved_cart_notification = _SavedCartNotificationPayload(
telegram_id=user.telegram_id,
text=(
f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}"
),
reply_markup=keyboard,
user_id=user.id,
)
logger.info(
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
user.id,
)
except Exception as e:
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
except Exception as error:
logger.error(
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
user.id,
error,
exc_info=True,
)
if admin_notification:
await self._deliver_admin_topup_notification(admin_notification)
if user_notification and bot_instance:
await self._deliver_user_topup_notification(user_notification)
if saved_cart_notification and bot_instance:
await self._deliver_saved_cart_reminder(saved_cart_notification)
return True
@@ -364,6 +394,116 @@ class CryptoBotPaymentMixin:
)
return False
async def _deliver_admin_topup_notification(
self, context: _AdminNotificationContext
) -> None:
bot_instance = getattr(self, "bot", None)
if not bot_instance:
return
try:
from app.services.admin_notification_service import AdminNotificationService
from app.database.crud.user import get_user_by_id
from app.database.crud.transaction import get_transaction_by_id
except Exception as error:
logger.error(
"Не удалось импортировать зависимости для админ-уведомления CryptoBot: %s",
error,
exc_info=True,
)
return
async with AsyncSessionLocal() as session:
try:
user = await get_user_by_id(session, context.user_id)
transaction = await get_transaction_by_id(session, context.transaction_id)
except Exception as error:
logger.error(
"Ошибка загрузки данных для админ-уведомления CryptoBot: %s",
error,
exc_info=True,
)
await session.rollback()
return
if not user or not transaction:
logger.warning(
"Пропущена отправка админ-уведомления CryptoBot: user=%s transaction=%s",
bool(user),
bool(transaction),
)
return
notification_service = AdminNotificationService(bot_instance)
try:
await notification_service.send_balance_topup_notification(
user,
transaction,
context.old_balance,
topup_status=context.topup_status,
referrer_info=context.referrer_info,
subscription=getattr(user, "subscription", None),
promo_group=getattr(user, "promo_group", None),
db=session,
)
except Exception as error:
logger.error(
"Ошибка отправки админ-уведомления о пополнении CryptoBot: %s",
error,
exc_info=True,
)
async def _deliver_user_topup_notification(
self, payload: _UserNotificationPayload
) -> None:
bot_instance = getattr(self, "bot", None)
if not bot_instance:
return
try:
await bot_instance.send_message(
payload.telegram_id,
payload.text,
parse_mode=payload.parse_mode,
reply_markup=payload.reply_markup,
)
logger.info(
"✅ Отправлено уведомление пользователю %s о пополнении на %s₽ (%s)",
payload.telegram_id,
f"{payload.amount_rubles:.2f}",
payload.asset,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о пополнении CryptoBot: %s",
error,
)
async def _deliver_saved_cart_reminder(
self, payload: _SavedCartNotificationPayload
) -> None:
bot_instance = getattr(self, "bot", None)
if not bot_instance:
return
try:
await bot_instance.send_message(
chat_id=payload.telegram_id,
text=payload.text,
reply_markup=payload.reply_markup,
)
logger.info(
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
payload.user_id,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления о сохраненной корзине для пользователя %s: %s",
payload.user_id,
error,
exc_info=True,
)
async def get_cryptobot_payment_status(
self,
db: AsyncSession,
+5 -1
View File
@@ -389,7 +389,11 @@ class MulenPayPaymentMixin:
await self.bot.send_message(
chat_id=user.telegram_id,
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}",
reply_markup=keyboard
)
logger.info(
+183 -43
View File
@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
class Pal24PaymentMixin:
"""Mixin с созданием счетов Pal24, обработкой postback и запросом статуса."""
"""Mixin с созданием счетов Pal24, обработкой callback и запросом статуса."""
async def create_pal24_payment(
self,
@@ -197,12 +197,12 @@ class Pal24PaymentMixin:
"payment_url": primary_link,
}
async def process_pal24_postback(
async def process_pal24_callback(
self,
db: AsyncSession,
postback: Dict[str, Any],
callback: Dict[str, Any],
) -> bool:
"""Обрабатывает postback от Pal24 и начисляет баланс при успехе."""
"""Обрабатывает callback от Pal24 и начисляет баланс при успехе."""
try:
payment_module = import_module("app.services.payment_service")
@@ -213,26 +213,26 @@ class Pal24PaymentMixin:
return None
payment_id = _first_non_empty(
postback.get("id"),
postback.get("TrsId"),
postback.get("TrsID"),
callback.get("id"),
callback.get("TrsId"),
callback.get("TrsID"),
)
bill_id = _first_non_empty(
postback.get("bill_id"),
postback.get("billId"),
postback.get("BillId"),
postback.get("BillID"),
callback.get("bill_id"),
callback.get("billId"),
callback.get("BillId"),
callback.get("BillID"),
)
order_id = _first_non_empty(
postback.get("order_id"),
postback.get("orderId"),
postback.get("InvId"),
postback.get("InvID"),
callback.get("order_id"),
callback.get("orderId"),
callback.get("InvId"),
callback.get("InvID"),
)
status = (postback.get("status") or postback.get("Status") or "").upper()
status = (callback.get("status") or callback.get("Status") or "").upper()
if not bill_id and not order_id:
logger.error("Pal24 postback без идентификаторов: %s", postback)
logger.error("Pal24 callback без идентификаторов: %s", callback)
return False
payment = None
@@ -250,57 +250,65 @@ class Pal24PaymentMixin:
return True
if status in {"PAID", "SUCCESS", "OVERPAID"}:
metadata = getattr(payment, "metadata_json", {}) or {}
if not isinstance(metadata, dict):
metadata = {}
payment = await payment_module.update_pal24_payment_status(
db,
payment,
status=status,
is_paid=True,
paid_at=datetime.utcnow(),
postback_payload=postback,
callback_payload=callback,
payment_id=payment_id,
payment_status=postback.get("Status") or status,
payment_status=callback.get("Status") or status,
payment_method=(
postback.get("payment_method")
or postback.get("PaymentMethod")
or (payment.metadata_json or {}).get("selected_method")
callback.get("payment_method")
or callback.get("PaymentMethod")
or metadata.get("selected_method")
or getattr(payment, "payment_method", None)
),
balance_amount=postback.get("BalanceAmount")
or postback.get("balance_amount"),
balance_currency=postback.get("BalanceCurrency")
or postback.get("balance_currency"),
payer_account=postback.get("AccountNumber")
or postback.get("account")
or postback.get("Account"),
balance_amount=callback.get("BalanceAmount")
or callback.get("balance_amount"),
balance_currency=callback.get("BalanceCurrency")
or callback.get("balance_currency"),
payer_account=callback.get("AccountNumber")
or callback.get("account")
or callback.get("Account"),
)
return await self._finalize_pal24_payment(
db,
payment,
payment_id=payment_id,
trigger="postback",
trigger="callback",
)
metadata = getattr(payment, "metadata_json", {}) or {}
if not isinstance(metadata, dict):
metadata = {}
await payment_module.update_pal24_payment_status(
db,
payment,
status=status or "UNKNOWN",
is_paid=False,
postback_payload=postback,
callback_payload=callback,
payment_id=payment_id,
payment_status=postback.get("Status") or status,
payment_status=callback.get("Status") or status,
payment_method=(
postback.get("payment_method")
or postback.get("PaymentMethod")
callback.get("payment_method")
or callback.get("PaymentMethod")
or getattr(payment, "payment_method", None)
),
balance_amount=postback.get("BalanceAmount")
or postback.get("balance_amount"),
balance_currency=postback.get("BalanceCurrency")
or postback.get("balance_currency"),
payer_account=postback.get("AccountNumber")
or postback.get("account")
or postback.get("Account"),
balance_amount=callback.get("BalanceAmount")
or callback.get("balance_amount"),
balance_currency=callback.get("BalanceCurrency")
or callback.get("balance_currency"),
payer_account=callback.get("AccountNumber")
or callback.get("account")
or callback.get("Account"),
)
logger.info(
"Обновили Pal24 платеж %s до статуса %s",
@@ -310,7 +318,7 @@ class Pal24PaymentMixin:
return True
except Exception as error:
logger.error("Ошибка обработки Pal24 postback: %s", error, exc_info=True)
logger.error("Ошибка обработки Pal24 callback: %s", error, exc_info=True)
return False
async def _finalize_pal24_payment(
@@ -489,7 +497,11 @@ class Pal24PaymentMixin:
chat_id=user.telegram_id,
text=(
"✅ Баланс пополнен на "
f"{settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}"
f"{settings.format_price(payment.amount_kopeks)}!\n\n"
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}"
),
reply_markup=keyboard,
)
@@ -681,12 +693,34 @@ class Pal24PaymentMixin:
exc_info=True,
)
links_map, selected_method = self._build_links_map(payment, remote_payloads)
primary_url = (
links_map.get(selected_method)
or links_map.get("sbp")
or links_map.get("page")
or links_map.get("card")
)
secondary_url = (
links_map.get("page")
or links_map.get("card")
or links_map.get("sbp")
)
return {
"payment": payment,
"status": payment.status,
"is_paid": payment.is_paid,
"remote_status": remote_status_for_return,
"remote_data": remote_data,
"links": links_map or None,
"primary_url": primary_url,
"secondary_url": secondary_url,
"sbp_url": links_map.get("sbp"),
"card_url": links_map.get("card"),
"link_page_url": links_map.get("page")
or getattr(payment, "link_page_url", None),
"link_url": getattr(payment, "link_url", None),
"selected_method": selected_method,
}
except Exception as error:
@@ -837,6 +871,112 @@ class Pal24PaymentMixin:
normalized = payment_method.strip().lower()
return mapping.get(normalized, "sbp")
@staticmethod
def _pick_first(mapping: Dict[str, Any], *keys: str) -> Optional[str]:
for key in keys:
value = mapping.get(key)
if value:
return str(value)
return None
@classmethod
def _build_links_map(
cls,
payment: Any,
remote_payloads: Dict[str, Any],
) -> tuple[Dict[str, str], str]:
links: Dict[str, str] = {}
metadata = getattr(payment, "metadata_json", {}) or {}
if not isinstance(metadata, dict):
metadata = {}
if metadata:
links_meta = metadata.get("links")
if isinstance(links_meta, dict):
for key, value in links_meta.items():
if value:
links[key] = str(value)
selected_method = cls._normalize_payment_method(
(metadata.get("selected_method") if isinstance(metadata, dict) else None)
or getattr(payment, "payment_method", None)
)
def _visit(value: Any) -> List[Dict[str, Any]]:
stack: List[Any] = [value]
result: List[Dict[str, Any]] = []
while stack:
current = stack.pop()
if isinstance(current, dict):
result.append(current)
stack.extend(current.values())
elif isinstance(current, list):
stack.extend(current)
return result
payload_sources: List[Any] = []
if metadata:
payload_sources.append(metadata.get("raw_response"))
payload_sources.append(getattr(payment, "callback_payload", None))
payload_sources.extend(remote_payloads.values())
sbp_keys = (
"transfer_url",
"transferUrl",
"transfer_link",
"transferLink",
"transfer",
"sbp_url",
"sbpUrl",
"sbp_link",
"sbpLink",
)
card_keys = (
"link_url",
"linkUrl",
"link",
"card_url",
"cardUrl",
"card_link",
"cardLink",
"payment_url",
"paymentUrl",
"url",
)
page_keys = (
"link_page_url",
"linkPageUrl",
"page_url",
"pageUrl",
)
for source in payload_sources:
if not source:
continue
for candidate in _visit(source):
sbp_url = cls._pick_first(candidate, *sbp_keys)
if sbp_url and "sbp" not in links:
links["sbp"] = sbp_url
card_url = cls._pick_first(candidate, *card_keys)
if card_url and "card" not in links:
links["card"] = card_url
page_url = cls._pick_first(candidate, *page_keys)
if page_url and "page" not in links:
links["page"] = page_url
if getattr(payment, "link_page_url", None):
links.setdefault("page", str(payment.link_page_url))
if getattr(payment, "link_url", None):
link_url_value = str(payment.link_url)
if selected_method == "card":
links.setdefault("card", link_url_value)
else:
links.setdefault("sbp", link_url_value)
return links, selected_method
@staticmethod
def _map_api_payment_method(normalized_payment_method: str) -> Optional[str]:
"""Преобразует нормализованный метод оплаты в значение для Pal24 API."""
+536
View File
@@ -0,0 +1,536 @@
"""Mixin для интеграции платежей Platega."""
from __future__ import annotations
import logging
import uuid
from datetime import datetime
from importlib import import_module
from typing import Any, Dict, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.platega_service import PlategaService
from app.services.subscription_auto_purchase_service import (
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
class PlategaPaymentMixin:
"""Логика создания и обработки платежей Platega."""
_SUCCESS_STATUSES = {"CONFIRMED"}
_FAILED_STATUSES = {"FAILED", "CANCELED", "EXPIRED"}
_PENDING_STATUSES = {"PENDING", "INPROGRESS"}
async def create_platega_payment(
self,
db: AsyncSession,
*,
user_id: int,
amount_kopeks: int,
description: str,
language: str,
payment_method_code: int,
) -> Optional[Dict[str, Any]]:
service: Optional[PlategaService] = getattr(self, "platega_service", None)
if not service or not service.is_configured:
logger.error("Platega сервис не инициализирован")
return None
if amount_kopeks < settings.PLATEGA_MIN_AMOUNT_KOPEKS:
logger.warning(
"Сумма Platega меньше минимальной: %s < %s",
amount_kopeks,
settings.PLATEGA_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS:
logger.warning(
"Сумма Platega больше максимальной: %s > %s",
amount_kopeks,
settings.PLATEGA_MAX_AMOUNT_KOPEKS,
)
return None
correlation_id = uuid.uuid4().hex
payload_token = f"platega:{correlation_id}"
amount_value = amount_kopeks / 100
try:
response = await service.create_payment(
payment_method=payment_method_code,
amount=amount_value,
currency=settings.PLATEGA_CURRENCY,
description=description,
return_url=settings.get_platega_return_url(),
failed_url=settings.get_platega_failed_url(),
payload=payload_token,
)
except Exception as error: # pragma: no cover - network errors
logger.exception("Ошибка Platega при создании платежа: %s", error)
return None
if not response:
logger.error("Platega вернул пустой ответ при создании платежа")
return None
transaction_id = response.get("transactionId") or response.get("id")
redirect_url = response.get("redirect")
status = str(response.get("status") or "PENDING").upper()
expires_at = PlategaService.parse_expires_at(response.get("expiresIn"))
metadata = {
"raw_response": response,
"language": language,
"selected_method": payment_method_code,
}
payment_module = import_module("app.services.payment_service")
payment = await payment_module.create_platega_payment(
db,
user_id=user_id,
amount_kopeks=amount_kopeks,
currency=settings.PLATEGA_CURRENCY,
description=description,
status=status,
payment_method_code=payment_method_code,
correlation_id=correlation_id,
platega_transaction_id=transaction_id,
redirect_url=redirect_url,
return_url=settings.get_platega_return_url(),
failed_url=settings.get_platega_failed_url(),
payload=payload_token,
metadata=metadata,
expires_at=expires_at,
)
logger.info(
"Создан Platega платеж %s для пользователя %s (метод %s, сумма %s₽)",
transaction_id or payment.id,
user_id,
payment_method_code,
amount_value,
)
return {
"local_payment_id": payment.id,
"transaction_id": transaction_id,
"redirect_url": redirect_url,
"status": status,
"expires_at": expires_at,
"correlation_id": correlation_id,
}
async def process_platega_webhook(
self,
db: AsyncSession,
payload: Dict[str, Any],
) -> bool:
payment_module = import_module("app.services.payment_service")
transaction_id = str(payload.get("id") or "").strip()
payload_token = payload.get("payload")
payment = None
if transaction_id:
payment = await payment_module.get_platega_payment_by_transaction_id(
db, transaction_id
)
if not payment and payload_token:
payment = await payment_module.get_platega_payment_by_correlation_id(
db, str(payload_token).replace("platega:", "")
)
if not payment:
logger.warning("Platega webhook: платеж не найден (id=%s)", transaction_id)
return False
status_raw = str(payload.get("status") or "").upper()
if not status_raw:
logger.warning("Platega webhook без статуса для платежа %s", payment.id)
return False
update_kwargs = {
"status": status_raw,
"callback_payload": payload,
}
if transaction_id:
update_kwargs["platega_transaction_id"] = transaction_id
if status_raw in self._SUCCESS_STATUSES:
if payment.is_paid:
logger.info(
"Platega платеж %s уже помечен как оплачен", payment.correlation_id
)
await payment_module.update_platega_payment(
db,
payment=payment,
**update_kwargs,
is_paid=True,
)
return True
payment = await payment_module.update_platega_payment(
db,
payment=payment,
**update_kwargs,
)
await self._finalize_platega_payment(db, payment, payload)
return True
if status_raw in self._FAILED_STATUSES:
await payment_module.update_platega_payment(
db,
payment=payment,
**update_kwargs,
is_paid=False,
)
logger.info(
"Platega платеж %s перешёл в статус %s", payment.correlation_id, status_raw
)
return True
await payment_module.update_platega_payment(
db,
payment=payment,
**update_kwargs,
)
return True
async def get_platega_payment_status(
self,
db: AsyncSession,
local_payment_id: int,
) -> Optional[Dict[str, Any]]:
payment_module = import_module("app.services.payment_service")
payment = await payment_module.get_platega_payment_by_id(db, local_payment_id)
if not payment:
return None
service: Optional[PlategaService] = getattr(self, "platega_service", None)
remote_status: Optional[str] = None
remote_payload: Optional[Dict[str, Any]] = None
if service and payment.platega_transaction_id:
try:
remote_payload = await service.get_transaction(
payment.platega_transaction_id
)
except Exception as error: # pragma: no cover - network errors
logger.error(
"Ошибка Platega при получении транзакции %s: %s",
payment.platega_transaction_id,
error,
)
if remote_payload:
remote_status = str(remote_payload.get("status") or "").upper()
if remote_status and remote_status != payment.status:
await payment_module.update_platega_payment(
db,
payment=payment,
status=remote_status,
metadata={
**(getattr(payment, "metadata_json", {}) or {}),
"remote_status": remote_payload,
},
)
payment = await payment_module.get_platega_payment_by_id(db, local_payment_id)
if (
remote_status in self._SUCCESS_STATUSES
and not payment.is_paid
):
payment = await payment_module.update_platega_payment(
db,
payment=payment,
status=remote_status,
callback_payload=remote_payload,
)
await self._finalize_platega_payment(db, payment, remote_payload)
return {
"payment": payment,
"status": payment.status,
"is_paid": payment.is_paid,
"remote": remote_payload,
}
async def _finalize_platega_payment(
self,
db: AsyncSession,
payment: Any,
payload: Optional[Dict[str, Any]],
) -> Any:
payment_module = import_module("app.services.payment_service")
metadata = dict(getattr(payment, "metadata_json", {}) or {})
if payload is not None:
metadata["webhook"] = payload
paid_at = None
if isinstance(payload, dict):
paid_at_raw = payload.get("paidAt") or payload.get("confirmedAt")
if paid_at_raw:
try:
paid_at = datetime.fromisoformat(str(paid_at_raw))
except ValueError:
paid_at = None
payment = await payment_module.update_platega_payment(
db,
payment=payment,
status="CONFIRMED",
is_paid=True,
paid_at=paid_at,
metadata=metadata,
callback_payload=payload,
)
locked_payment = await payment_module.get_platega_payment_by_id_for_update(
db, payment.id
)
if locked_payment:
payment = locked_payment
metadata = dict(getattr(payment, "metadata_json", {}) or {})
balance_already_credited = bool(metadata.get("balance_credited"))
if payment.transaction_id:
logger.info(
"Platega платеж %s уже связан с транзакцией %s",
payment.correlation_id,
payment.transaction_id,
)
return payment
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error("Пользователь %s не найден для Platega", payment.user_id)
return payment
transaction_external_id = (
str(payload.get("id"))
if isinstance(payload, dict) and payload.get("id")
else payment.platega_transaction_id
)
existing_transaction = None
if transaction_external_id:
existing_transaction = await payment_module.get_transaction_by_external_id(
db,
transaction_external_id,
PaymentMethod.PLATEGA,
)
method_display = settings.get_platega_method_display_name(payment.payment_method_code)
description = (
f"Пополнение через Platega ({method_display})"
if method_display
else "Пополнение через Platega"
)
transaction = existing_transaction
created_transaction = False
if not transaction:
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=description,
payment_method=PaymentMethod.PLATEGA,
external_id=transaction_external_id or payment.correlation_id,
is_completed=True,
)
created_transaction = True
await payment_module.link_platega_payment_to_transaction(
db, payment=payment, transaction_id=transaction.id
)
should_credit_balance = created_transaction or not balance_already_credited
if not should_credit_balance:
logger.info(
"Platega платеж %s уже зачислил баланс ранее",
payment.correlation_id,
)
return payment
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
promo_group = user.get_primary_promo_group()
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
payment.amount_kopeks,
getattr(self, "bot", None),
)
except Exception as error:
logger.error("Ошибка обработки реферального пополнения Platega: %s", error)
if was_first_topup and not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error("Ошибка отправки админ уведомления Platega: %s", error)
method_title = settings.get_platega_method_display_title(payment.payment_method_code)
if getattr(self, "bot", None):
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
f"🦊 Способ: {method_title}\n"
f"🆔 Транзакция: {transaction.id}\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
reply_markup=keyboard,
)
except Exception as error:
logger.error("Ошибка отправки уведомления пользователю Platega: %s", error)
try:
from app.services.user_cart_service import user_cart_service
from aiogram import types
has_saved_cart = await user_cart_service.has_user_cart(user.id)
auto_purchase_success = False
if has_saved_cart:
try:
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
db,
user,
bot=getattr(self, "bot", None),
)
except Exception as auto_error:
logger.error(
"Ошибка автоматической покупки подписки для пользователя %s: %s",
user.id,
auto_error,
exc_info=True,
)
if auto_purchase_success:
has_saved_cart = False
if has_saved_cart and getattr(self, "bot", None):
from app.localization.texts import get_texts
texts = get_texts(user.language)
cart_message = texts.t(
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
"🛒 У вас есть неоформленный заказ.\n\n"
"Вы можете продолжить оформление с теми же параметрами.",
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="return_to_saved_cart",
)
],
[
types.InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance",
)
],
[
types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu",
)
],
]
)
await self.bot.send_message(
chat_id=user.telegram_id,
text=(
f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса.\n\n"
f"{cart_message}"
),
reply_markup=keyboard,
)
except Exception as error:
logger.error(
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
payment.user_id,
error,
exc_info=True,
)
metadata["balance_change"] = {
"old_balance": old_balance,
"new_balance": user.balance_kopeks,
"credited_at": datetime.utcnow().isoformat(),
}
metadata["balance_credited"] = True
await payment_module.update_platega_payment(
db,
payment=payment,
metadata=metadata,
)
logger.info(
"✅ Обработан Platega платеж %s для пользователя %s",
payment.correlation_id,
payment.user_id,
)
return payment
+5 -1
View File
@@ -604,7 +604,11 @@ class TelegramStarsMixin:
await self.bot.send_message(
chat_id=user.telegram_id,
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n{cart_message}",
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n"
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}",
reply_markup=keyboard,
)
logger.info(
+4
View File
@@ -514,6 +514,10 @@ class WataPaymentMixin:
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
"🦊 Способ: WATA\n"
f"🆔 Транзакция: {transaction.id}\n\n"
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
"Обязательно активируйте подписку отдельно!\n\n"
"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
"подписка будет приобретена автоматически после пополнения баланса.\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
+307 -21
View File
@@ -30,6 +30,66 @@ if TYPE_CHECKING:
class YooKassaPaymentMixin:
"""Mixin с операциями по созданию и подтверждению платежей YooKassa."""
@staticmethod
def _format_amount_value(value: Any) -> str:
"""Форматирует сумму для хранения в webhook-объекте."""
try:
quantized = Decimal(str(value)).quantize(Decimal("0.00"))
return format(quantized, "f")
except (InvalidOperation, ValueError, TypeError):
return str(value)
@classmethod
def _merge_remote_yookassa_payload(
cls,
event_object: Dict[str, Any],
remote_data: Dict[str, Any],
) -> Dict[str, Any]:
"""Объединяет локальные данные вебхука с ответом API YooKassa."""
merged: Dict[str, Any] = dict(event_object)
status = remote_data.get("status")
if status:
merged["status"] = status
if "paid" in remote_data:
merged["paid"] = bool(remote_data.get("paid"))
if "refundable" in remote_data:
merged["refundable"] = bool(remote_data.get("refundable"))
payment_method_type = remote_data.get("payment_method_type")
if payment_method_type:
payment_method = dict(merged.get("payment_method") or {})
payment_method["type"] = payment_method_type
merged["payment_method"] = payment_method
amount_value = remote_data.get("amount_value")
amount_currency = remote_data.get("amount_currency")
if amount_value is not None or amount_currency:
merged_amount = dict(merged.get("amount") or {})
if amount_value is not None:
merged_amount["value"] = cls._format_amount_value(amount_value)
if amount_currency:
merged_amount["currency"] = str(amount_currency).upper()
merged["amount"] = merged_amount
for datetime_field in ("captured_at", "created_at"):
value = remote_data.get(datetime_field)
if value:
merged[datetime_field] = value
metadata = remote_data.get("metadata")
if metadata:
try:
merged["metadata"] = dict(metadata) # type: ignore[arg-type]
except TypeError:
merged["metadata"] = metadata
return merged
async def create_yookassa_payment(
self,
db: AsyncSession,
@@ -322,7 +382,47 @@ class YooKassaPaymentMixin:
from sqlalchemy import select
payment_module = import_module("app.services.payment_service")
payment_description = getattr(payment, "description", "YooKassa платеж")
# Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования)
existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined]
db,
payment.yookassa_payment_id,
PaymentMethod.YOOKASSA,
)
if existing_transaction:
# Если транзакция уже существует, просто завершаем обработку
logger.info(
"Платеж YooKassa %s уже был обработан транзакцией %s. Пропускаем повторную обработку.",
payment.yookassa_payment_id,
existing_transaction.id,
)
# Убедимся, что платеж связан с транзакцией
if not getattr(payment, "transaction_id", None):
try:
linked_payment = await payment_module.link_yookassa_payment_to_transaction( # type: ignore[attr-defined]
db,
payment.yookassa_payment_id,
existing_transaction.id,
)
if linked_payment:
payment.transaction_id = getattr(
linked_payment,
"transaction_id",
existing_transaction.id,
)
if hasattr(linked_payment, "transaction"):
payment.transaction = linked_payment.transaction
except Exception as link_error: # pragma: no cover - защитный лог
logger.warning(
"Не удалось привязать платеж YooKassa %s к существующей транзакции %s: %s",
payment.yookassa_payment_id,
existing_transaction.id,
link_error,
exc_info=True,
)
return True
payment_metadata: Dict[str, Any] = {}
try:
@@ -337,6 +437,84 @@ class YooKassaPaymentMixin:
except Exception as parse_error:
logger.error(f"Ошибка парсинга метаданных платежа: {parse_error}")
processing_completed = bool(payment_metadata.get("processing_completed"))
transaction = None
existing_transaction_id = getattr(payment, "transaction_id", None)
if existing_transaction_id:
try:
from app.database.crud.transaction import get_transaction_by_id
transaction = await get_transaction_by_id(db, existing_transaction_id)
except Exception as fetch_error: # pragma: no cover - диагностический лог
logger.warning(
"Не удалось получить транзакцию %s для платежа YooKassa %s: %s",
existing_transaction_id,
payment.yookassa_payment_id,
fetch_error,
exc_info=True,
)
if transaction and processing_completed:
logger.info(
"Пропускаем повторную обработку платежа YooKassa %s: транзакция %s уже завершила начисление.",
payment.yookassa_payment_id,
existing_transaction_id,
)
return True
if transaction:
logger.info(
"Транзакция %s для платежа YooKassa %s найдена, но обработка ранее не была завершена — повторяем критические шаги.",
existing_transaction_id,
payment.yookassa_payment_id,
)
if transaction is None:
existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined]
db,
payment.yookassa_payment_id,
PaymentMethod.YOOKASSA,
)
if existing_transaction:
# Если транзакция уже существует, пропускаем обработку
logger.info(
"Платеж YooKassa %s уже был обработан транзакцией %s. Пропускаем повторную обработку.",
payment.yookassa_payment_id,
existing_transaction.id,
)
# Убедимся, что платеж связан с транзакцией
if not getattr(payment, "transaction_id", None):
try:
linked_payment = await payment_module.link_yookassa_payment_to_transaction( # type: ignore[attr-defined]
db,
payment.yookassa_payment_id,
existing_transaction.id,
)
if linked_payment:
payment.transaction_id = getattr(
linked_payment,
"transaction_id",
existing_transaction.id,
)
if hasattr(linked_payment, "transaction"):
payment.transaction = linked_payment.transaction
except Exception as link_error: # pragma: no cover - защитный лог
logger.warning(
"Не удалось привязать платеж YooKassa %s к существующей транзакции %s: %s",
payment.yookassa_payment_id,
existing_transaction.id,
link_error,
exc_info=True,
)
return True
payment_description = getattr(payment, "description", "YooKassa платеж")
payment_purpose = payment_metadata.get("payment_purpose", "")
is_simple_subscription = payment_purpose == "simple_subscription_purchase"
@@ -351,27 +529,32 @@ class YooKassaPaymentMixin:
else f"Пополнение через YooKassa: {payment_description}"
)
transaction = await payment_module.create_transaction(
db=db,
user_id=payment.user_id,
type=transaction_type,
amount_kopeks=payment.amount_kopeks,
description=transaction_description,
payment_method=PaymentMethod.YOOKASSA,
external_id=payment.yookassa_payment_id,
is_completed=True,
)
if transaction is None:
transaction = await payment_module.create_transaction(
db=db,
user_id=payment.user_id,
type=transaction_type,
amount_kopeks=payment.amount_kopeks,
description=transaction_description,
payment_method=PaymentMethod.YOOKASSA,
external_id=payment.yookassa_payment_id,
is_completed=True,
)
linked_payment = await payment_module.link_yookassa_payment_to_transaction(
db,
payment.yookassa_payment_id,
transaction.id,
)
if not getattr(payment, "transaction_id", None):
linked_payment = await payment_module.link_yookassa_payment_to_transaction(
db,
payment.yookassa_payment_id,
transaction.id,
)
if linked_payment:
payment.transaction_id = getattr(linked_payment, "transaction_id", transaction.id)
if hasattr(linked_payment, "transaction"):
payment.transaction = linked_payment.transaction
if linked_payment:
payment.transaction_id = getattr(linked_payment, "transaction_id", transaction.id)
if hasattr(linked_payment, "transaction"):
payment.transaction = linked_payment.transaction
critical_flow_completed = False
processing_marked = False
user = await payment_module.get_user_by_id(db, payment.user_id)
if user:
@@ -413,6 +596,14 @@ class YooKassaPaymentMixin:
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
)
payment_metadata = await self._mark_yookassa_payment_processing_completed(
db,
payment,
payment_metadata,
commit=False,
)
processing_marked = True
await db.commit()
try:
@@ -554,7 +745,11 @@ class YooKassaPaymentMixin:
await self.bot.send_message(
chat_id=user.telegram_id,
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}",
reply_markup=keyboard,
)
logger.info(
@@ -684,6 +879,31 @@ class YooKassaPaymentMixin:
except Exception as e:
logger.error(f"Ошибка активации подписки для пользователя {user.id}: {e}", exc_info=True)
if not processing_marked:
payment_metadata = await self._mark_yookassa_payment_processing_completed(
db,
payment,
payment_metadata,
commit=True,
)
processing_marked = True
critical_flow_completed = True
else:
logger.warning(
"Пользователь %s для платежа YooKassa %s не найден — начисление баланса невозможно",
payment.user_id,
payment.yookassa_payment_id,
)
if critical_flow_completed and not processing_marked:
payment_metadata = await self._mark_yookassa_payment_processing_completed(
db,
payment,
payment_metadata,
commit=True,
)
if is_simple_subscription:
logger.info(
"Успешно обработан платеж YooKassa %s как покупка подписки: пользователь %s, сумма %s",
@@ -709,6 +929,46 @@ class YooKassaPaymentMixin:
)
return False
async def _mark_yookassa_payment_processing_completed(
self,
db: AsyncSession,
payment: "YooKassaPayment",
payment_metadata: Dict[str, Any],
*,
commit: bool = False,
) -> Dict[str, Any]:
"""Отмечает платёж как полностью обработанный, чтобы избежать повторного начисления."""
if payment_metadata.get("processing_completed"):
return payment_metadata
updated_metadata = dict(payment_metadata)
updated_metadata["processing_completed"] = True
try:
from sqlalchemy import update
from app.database.models import YooKassaPayment as YooKassaPaymentModel
await db.execute(
update(YooKassaPaymentModel)
.where(YooKassaPaymentModel.id == payment.id)
.values(metadata_json=updated_metadata, updated_at=datetime.utcnow())
)
if commit:
await db.commit()
else:
await db.flush()
payment.metadata_json = updated_metadata
except Exception as mark_error: # pragma: no cover - защитный лог
logger.warning(
"Не удалось отметить платеж YooKassa %s как завершенный: %s",
payment.yookassa_payment_id,
mark_error,
exc_info=True,
)
return updated_metadata
async def process_yookassa_webhook(
self,
db: AsyncSession,
@@ -722,6 +982,32 @@ class YooKassaPaymentMixin:
logger.warning("Webhook без payment id: %s", event)
return False
remote_data: Optional[Dict[str, Any]] = None
if getattr(self, "yookassa_service", None):
try:
remote_data = await self.yookassa_service.get_payment_info( # type: ignore[union-attr]
yookassa_payment_id
)
except Exception as error: # pragma: no cover - диагностический лог
logger.warning(
"Не удалось запросить актуальный статус платежа YooKassa %s: %s",
yookassa_payment_id,
error,
exc_info=True,
)
if remote_data:
previous_status = event_object.get("status")
event_object = self._merge_remote_yookassa_payload(event_object, remote_data)
if previous_status and event_object.get("status") != previous_status:
logger.info(
"Статус платежа YooKassa %s скорректирован по данным API: %s%s",
yookassa_payment_id,
previous_status,
event_object.get("status"),
)
event["object"] = event_object
payment_module = import_module("app.services.payment_service")
payment = await payment_module.get_yookassa_payment_by_id(db, yookassa_payment_id)
+48 -1
View File
@@ -15,11 +15,13 @@ from app.external.heleket import HeleketService
from app.external.telegram_stars import TelegramStarsService
from app.services.mulenpay_service import MulenPayService
from app.services.pal24_service import Pal24Service
from app.services.platega_service import PlategaService
from app.services.payment import (
CryptoBotPaymentMixin,
HeleketPaymentMixin,
MulenPayPaymentMixin,
Pal24PaymentMixin,
PlategaPaymentMixin,
PaymentCommonMixin,
TelegramStarsMixin,
TributePaymentMixin,
@@ -65,6 +67,11 @@ async def create_transaction(*args, **kwargs):
return await transaction_crud.create_transaction(*args, **kwargs)
async def get_transaction_by_external_id(*args, **kwargs):
transaction_crud = import_module("app.database.crud.transaction")
return await transaction_crud.get_transaction_by_external_id(*args, **kwargs)
async def add_user_balance(*args, **kwargs):
user_crud = import_module("app.database.crud.user")
return await user_crud.add_user_balance(*args, **kwargs)
@@ -170,6 +177,41 @@ async def link_wata_payment_to_transaction(*args, **kwargs):
return await wata_crud.link_wata_payment_to_transaction(*args, **kwargs)
async def create_platega_payment(*args, **kwargs):
platega_crud = import_module("app.database.crud.platega")
return await platega_crud.create_platega_payment(*args, **kwargs)
async def get_platega_payment_by_id(*args, **kwargs):
platega_crud = import_module("app.database.crud.platega")
return await platega_crud.get_platega_payment_by_id(*args, **kwargs)
async def get_platega_payment_by_id_for_update(*args, **kwargs):
platega_crud = import_module("app.database.crud.platega")
return await platega_crud.get_platega_payment_by_id_for_update(*args, **kwargs)
async def get_platega_payment_by_transaction_id(*args, **kwargs):
platega_crud = import_module("app.database.crud.platega")
return await platega_crud.get_platega_payment_by_transaction_id(*args, **kwargs)
async def get_platega_payment_by_correlation_id(*args, **kwargs):
platega_crud = import_module("app.database.crud.platega")
return await platega_crud.get_platega_payment_by_correlation_id(*args, **kwargs)
async def update_platega_payment(*args, **kwargs):
platega_crud = import_module("app.database.crud.platega")
return await platega_crud.update_platega_payment(*args, **kwargs)
async def link_platega_payment_to_transaction(*args, **kwargs):
platega_crud = import_module("app.database.crud.platega")
return await platega_crud.link_platega_payment_to_transaction(*args, **kwargs)
async def create_cryptobot_payment(*args, **kwargs):
crypto_crud = import_module("app.database.crud.cryptobot")
return await crypto_crud.create_cryptobot_payment(*args, **kwargs)
@@ -224,6 +266,7 @@ class PaymentService(
HeleketPaymentMixin,
MulenPayPaymentMixin,
Pal24PaymentMixin,
PlategaPaymentMixin,
WataPaymentMixin,
):
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
@@ -248,11 +291,14 @@ class PaymentService(
self.pal24_service = (
Pal24Service() if settings.is_pal24_enabled() else None
)
self.platega_service = (
PlategaService() if settings.is_platega_enabled() else None
)
self.wata_service = WataService() if settings.is_wata_enabled() else None
mulenpay_name = settings.get_mulenpay_display_name()
logger.debug(
"PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, Heleket=%s, %s=%s, Pal24=%s, Wata=%s)",
"PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, Heleket=%s, %s=%s, Pal24=%s, Platega=%s, Wata=%s)",
bool(self.yookassa_service),
bool(self.stars_service),
bool(self.cryptobot_service),
@@ -260,5 +306,6 @@ class PaymentService(
mulenpay_name,
bool(self.mulenpay_service),
bool(self.pal24_service),
bool(self.platega_service),
bool(self.wata_service),
)
@@ -21,6 +21,7 @@ from app.database.models import (
HeleketPayment,
MulenPayPayment,
Pal24Payment,
PlategaPayment,
PaymentMethod,
Transaction,
TransactionType,
@@ -62,6 +63,7 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
PaymentMethod.WATA,
PaymentMethod.HELEKET,
PaymentMethod.CRYPTOBOT,
PaymentMethod.PLATEGA,
}
)
@@ -73,6 +75,7 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
PaymentMethod.PAL24,
PaymentMethod.WATA,
PaymentMethod.CRYPTOBOT,
PaymentMethod.PLATEGA,
}
)
@@ -86,6 +89,8 @@ def method_display_name(method: PaymentMethod) -> str:
return "YooKassa"
if method == PaymentMethod.WATA:
return "WATA"
if method == PaymentMethod.PLATEGA:
return "Platega"
if method == PaymentMethod.CRYPTOBOT:
return "CryptoBot"
if method == PaymentMethod.HELEKET:
@@ -104,6 +109,8 @@ def _method_is_enabled(method: PaymentMethod) -> bool:
return settings.is_pal24_enabled()
if method == PaymentMethod.WATA:
return settings.is_wata_enabled()
if method == PaymentMethod.PLATEGA:
return settings.is_platega_enabled()
if method == PaymentMethod.CRYPTOBOT:
return settings.is_cryptobot_enabled()
if method == PaymentMethod.HELEKET:
@@ -315,6 +322,13 @@ def _is_wata_pending(payment: WataPayment) -> bool:
}
def _is_platega_pending(payment: PlategaPayment) -> bool:
if payment.is_paid:
return False
status = (payment.status or "").lower()
return status in {"pending", "inprogress", "in_progress"}
def _is_heleket_pending(payment: HeleketPayment) -> bool:
if payment.is_paid:
return False
@@ -459,6 +473,33 @@ async def _fetch_wata_payments(db: AsyncSession, cutoff: datetime) -> List[Pendi
return records
async def _fetch_platega_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
stmt = (
select(PlategaPayment)
.options(selectinload(PlategaPayment.user))
.where(PlategaPayment.created_at >= cutoff)
.order_by(desc(PlategaPayment.created_at))
)
result = await db.execute(stmt)
records: List[PendingPayment] = []
for payment in result.scalars().all():
if not _is_platega_pending(payment):
continue
identifier = payment.platega_transaction_id or payment.correlation_id or str(payment.id)
record = _build_record(
PaymentMethod.PLATEGA,
payment,
identifier=identifier,
amount_kopeks=payment.amount_kopeks,
status=payment.status or "",
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, "expires_at", None),
)
if record:
records.append(record)
return records
async def _fetch_heleket_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
stmt = (
select(HeleketPayment)
@@ -582,6 +623,7 @@ async def list_recent_pending_payments(
await _fetch_pal24_payments(db, cutoff),
await _fetch_mulenpay_payments(db, cutoff),
await _fetch_wata_payments(db, cutoff),
await _fetch_platega_payments(db, cutoff),
await _fetch_heleket_payments(db, cutoff),
await _fetch_cryptobot_payments(db, cutoff),
await _fetch_stars_transactions(db, cutoff),
@@ -648,6 +690,22 @@ async def get_payment_record(
expires_at=getattr(payment, "expires_at", None),
)
if method == PaymentMethod.PLATEGA:
payment = await db.get(PlategaPayment, local_payment_id)
if not payment:
return None
await db.refresh(payment, attribute_names=["user"])
identifier = payment.platega_transaction_id or payment.correlation_id or str(payment.id)
return _build_record(
method,
payment,
identifier=identifier,
amount_kopeks=payment.amount_kopeks,
status=payment.status or "",
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, "expires_at", None),
)
if method == PaymentMethod.HELEKET:
payment = await db.get(HeleketPayment, local_payment_id)
if not payment:
@@ -732,6 +790,9 @@ async def run_manual_check(
elif method == PaymentMethod.WATA:
result = await payment_service.get_wata_payment_status(db, local_payment_id)
payment = result.get("payment") if result else None
elif method == PaymentMethod.PLATEGA:
result = await payment_service.get_platega_payment_status(db, local_payment_id)
payment = result.get("payment") if result else None
elif method == PaymentMethod.HELEKET:
payment = await payment_service.sync_heleket_payment_status(
db, local_payment_id=local_payment_id
+190
View File
@@ -0,0 +1,190 @@
"""HTTP-интеграция с Platega API."""
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime, timedelta
from typing import Any, Dict, Optional
import aiohttp
from app.config import settings
logger = logging.getLogger(__name__)
class PlategaService:
"""Обертка над Platega API с базовой повторной отправкой запросов."""
def __init__(self) -> None:
self.base_url = (settings.PLATEGA_BASE_URL or "https://app.platega.io").rstrip("/")
self.merchant_id = settings.PLATEGA_MERCHANT_ID
self.secret = settings.PLATEGA_SECRET
self._timeout = aiohttp.ClientTimeout(total=30, connect=10, sock_read=25)
self._max_retries = 3
self._retry_delay = 0.5
self._retryable_statuses = {500, 502, 503, 504}
@property
def is_configured(self) -> bool:
return settings.is_platega_enabled()
async def create_payment(
self,
*,
payment_method: int,
amount: float,
currency: str,
description: Optional[str] = None,
return_url: Optional[str] = None,
failed_url: Optional[str] = None,
payload: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
body: Dict[str, Any] = {
"paymentMethod": payment_method,
"paymentDetails": {
"amount": round(amount, 2),
"currency": currency,
},
}
if description:
body["description"] = description
if return_url:
body["return"] = return_url
if failed_url:
body["failedUrl"] = failed_url
if payload:
body["payload"] = payload
return await self._request("POST", "/transaction/process", json_data=body)
async def get_transaction(self, transaction_id: str) -> Optional[Dict[str, Any]]:
endpoint = f"/transaction/{transaction_id}"
return await self._request("GET", endpoint)
async def _request(
self,
method: str,
endpoint: str,
*,
json_data: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
if not self.is_configured:
logger.error("Platega service is not configured")
return None
url = f"{self.base_url}{endpoint}"
headers = {
"X-MerchantId": self.merchant_id or "",
"X-Secret": self.secret or "",
"Content-Type": "application/json",
}
last_error: Optional[BaseException] = None
for attempt in range(1, self._max_retries + 1):
try:
async with aiohttp.ClientSession(timeout=self._timeout) as session:
async with session.request(
method,
url,
json=json_data,
params=params,
headers=headers,
) as response:
data, raw_text = await self._deserialize_response(response)
if response.status >= 400:
logger.error(
"Platega API error %s %s: %s",
response.status,
endpoint,
raw_text,
)
if (
response.status in self._retryable_statuses
and attempt < self._max_retries
):
await asyncio.sleep(self._retry_delay * attempt)
continue
return None
return data
except asyncio.CancelledError:
logger.debug("Platega request cancelled: %s %s", method, endpoint)
raise
except asyncio.TimeoutError as error:
last_error = error
logger.warning(
"Platega request timeout (%s %s) attempt %s/%s",
method,
endpoint,
attempt,
self._max_retries,
)
except aiohttp.ClientError as error:
last_error = error
logger.warning(
"Platega client error (%s %s) attempt %s/%s: %s",
method,
endpoint,
attempt,
self._max_retries,
error,
)
except Exception as error: # pragma: no cover - safety
logger.exception("Unexpected Platega error: %s", error)
return None
if attempt < self._max_retries:
await asyncio.sleep(self._retry_delay * attempt)
if last_error is not None:
logger.error(
"Platega request failed after %s attempts (%s %s): %s",
self._max_retries,
method,
endpoint,
last_error,
)
return None
@staticmethod
async def _deserialize_response(
response: aiohttp.ClientResponse,
) -> tuple[Optional[Dict[str, Any]], str]:
raw_text = await response.text()
if not raw_text:
return None, ""
content_type = response.headers.get("Content-Type", "")
if "json" in content_type.lower() or not content_type:
try:
return json.loads(raw_text), raw_text
except json.JSONDecodeError as error:
logger.error(
"Failed to decode Platega JSON response %s: %s",
response.url,
error,
)
return None, raw_text
return None, raw_text
@staticmethod
def parse_expires_at(expires_in: Optional[str]) -> Optional[datetime]:
if not expires_in:
return None
try:
hours, minutes, seconds = [int(part) for part in expires_in.split(":", 2)]
delta = timedelta(hours=hours, minutes=minutes, seconds=seconds)
return datetime.utcnow() + delta
except Exception:
logger.warning("Failed to parse Platega expiresIn value: %s", expires_in)
return None
+1 -6
View File
@@ -162,10 +162,7 @@ class PromoCodeService:
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
trial_uuid = await get_random_trial_squad_uuid(
db,
settings.TRIAL_SQUAD_UUID,
)
trial_uuid = await get_random_trial_squad_uuid(db)
if trial_uuid:
trial_squads = [trial_uuid]
except Exception as error:
@@ -174,8 +171,6 @@ class PromoCodeService:
promocode.code,
error,
)
if getattr(settings, 'TRIAL_SQUAD_UUID', None):
trial_squads = [settings.TRIAL_SQUAD_UUID]
forced_devices = None
if not settings.is_devices_selection_enabled():
+3
View File
@@ -84,6 +84,7 @@ class BotConfigurationService:
"CRYPTOBOT": "🪙 CryptoBot",
"HELEKET": "🪙 Heleket",
"YOOKASSA": "🟣 YooKassa",
"PLATEGA": "💳 Platega",
"TRIBUTE": "🎁 Tribute",
"MULENPAY": "💰 {mulenpay_name}",
"PAL24": "🏦 PAL24 / PayPalych",
@@ -137,6 +138,7 @@ class BotConfigurationService:
"YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.",
"CRYPTOBOT": "CryptoBot и криптоплатежи через Telegram.",
"HELEKET": "Heleket: криптоплатежи, ключи мерчанта и вебхуки.",
"PLATEGA": "Platega: merchant ID, секрет, ссылки возврата и методы оплаты.",
"MULENPAY": "Платежи {mulenpay_name} и параметры магазина.",
"PAL24": "PAL24 / PayPalych подключения и лимиты.",
"TRIBUTE": "Tribute и донат-сервисы.",
@@ -303,6 +305,7 @@ class BotConfigurationService:
"YOOKASSA_": "YOOKASSA",
"CRYPTOBOT_": "CRYPTOBOT",
"HELEKET_": "HELEKET",
"PLATEGA_": "PLATEGA",
"MULENPAY_": "MULENPAY",
"PAL24_": "PAL24",
"PAYMENT_": "PAYMENT",
+201
View File
@@ -0,0 +1,201 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.subscription import decrement_subscription_server_counts
from app.database.crud.user import add_user_balance, subtract_user_balance
from app.database.models import Subscription, TransactionType, User
logger = logging.getLogger(__name__)
class TrialPaymentError(Exception):
"""Base exception for trial activation payment issues."""
@dataclass(slots=True)
class TrialPaymentInsufficientFunds(TrialPaymentError):
required_amount: int
balance_amount: int
@property
def missing_amount(self) -> int:
return max(0, self.required_amount - self.balance_amount)
class TrialPaymentChargeFailed(TrialPaymentError):
"""Raised when balance charge could not be completed."""
@dataclass(slots=True)
class TrialActivationReversionResult:
refunded: bool = True
subscription_rolled_back: bool = True
def get_trial_activation_charge_amount() -> int:
"""Returns the configured activation charge in kopeks if payment is enabled."""
if not settings.is_trial_paid_activation_enabled():
return 0
try:
price_kopeks = int(settings.get_trial_activation_price() or 0)
except (TypeError, ValueError): # pragma: no cover - defensive
price_kopeks = 0
return max(0, price_kopeks)
def preview_trial_activation_charge(user: User) -> int:
"""Validates that the user can afford the trial activation charge."""
price_kopeks = get_trial_activation_charge_amount()
if price_kopeks <= 0:
return 0
balance = int(getattr(user, "balance_kopeks", 0) or 0)
if balance < price_kopeks:
raise TrialPaymentInsufficientFunds(price_kopeks, balance)
return price_kopeks
async def charge_trial_activation_if_required(
db: AsyncSession,
user: User,
*,
description: Optional[str] = None,
) -> int:
"""Charges the user's balance if paid trial activation is enabled.
Returns the charged amount in kopeks. If payment is not required or the
configured price is zero, the function returns ``0``.
"""
price_kopeks = preview_trial_activation_charge(user)
if price_kopeks <= 0:
return 0
charge_description = description or "Активация триальной подписки"
success = await subtract_user_balance(
db,
user,
price_kopeks,
charge_description,
)
if not success:
raise TrialPaymentChargeFailed()
# subtract_user_balance обновляет пользователя, но на всякий случай приводим к int
return int(price_kopeks)
async def refund_trial_activation_charge(
db: AsyncSession,
user: User,
amount_kopeks: int,
*,
description: Optional[str] = None,
) -> bool:
"""Refunds a previously charged trial activation amount back to the user."""
if amount_kopeks <= 0:
return True
refund_description = description or "Возврат оплаты за активацию триальной подписки"
success = await add_user_balance(
db,
user,
amount_kopeks,
refund_description,
transaction_type=TransactionType.REFUND,
)
if not success:
logger.error(
"Failed to refund %s kopeks for user %s during trial activation rollback",
amount_kopeks,
getattr(user, "id", "<unknown>"),
)
return success
async def rollback_trial_subscription_activation(
db: AsyncSession,
subscription: Optional[Subscription],
) -> bool:
"""Attempts to undo a previously created trial subscription.
Returns ``True`` when the rollback succeeds or when ``subscription`` is
falsy. In case of a database failure the function returns ``False`` after
logging the error so callers can decide how to proceed.
"""
if not subscription:
return True
try:
await decrement_subscription_server_counts(db, subscription)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"Failed to decrement server counters during trial rollback for %s: %s",
subscription.user_id,
error,
)
try:
await db.delete(subscription)
await db.commit()
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"Failed to remove trial subscription %s after charge failure: %s",
getattr(subscription, "id", "<unknown>"),
error,
)
await db.rollback()
return False
return True
async def revert_trial_activation(
db: AsyncSession,
user: User,
subscription: Optional[Subscription],
charged_amount: int,
*,
refund_description: Optional[str] = None,
) -> TrialActivationReversionResult:
"""Rolls back a trial subscription and refunds any charged amount."""
rollback_success = await rollback_trial_subscription_activation(db, subscription)
refund_success = await refund_trial_activation_charge(
db,
user,
charged_amount,
description=refund_description,
)
try:
await db.refresh(user)
except Exception as error: # pragma: no cover - defensive logging
logger.warning(
"Failed to refresh user %s after reverting trial activation: %s",
getattr(user, "id", "<unknown>"),
error,
)
return TrialActivationReversionResult(
refunded=refund_success,
subscription_rolled_back=rollback_success,
)
+7 -1
View File
@@ -257,6 +257,10 @@ class TributeService:
f"💰 Сумма: {int(amount_rubles)}\n"
f"💳 Способ оплаты: Tribute\n"
f"🎉 Средства зачислены на баланс!\n\n"
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса.\n\n"
f"Спасибо за оплату! 🙏"
)
@@ -318,7 +322,9 @@ class TributeService:
await self.bot.send_message(
chat_id=user_id,
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n{cart_message}",
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n"
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n{cart_message}",
reply_markup=keyboard
)
logger.info(
+1
View File
@@ -25,6 +25,7 @@ class SubscriptionStates(StatesGroup):
class BalanceStates(StatesGroup):
waiting_for_amount = State()
waiting_for_pal24_method = State()
waiting_for_platega_method = State()
waiting_for_stars_payment = State()
waiting_for_support_request = State()
+15 -1
View File
@@ -90,7 +90,16 @@ def get_available_payment_methods() -> List[Dict[str, str]]:
"description": "через Heleket",
"callback": "topup_heleket"
})
if settings.is_platega_enabled() and settings.get_platega_active_methods():
methods.append({
"id": "platega",
"name": "Банковская карта",
"icon": "💳",
"description": "через Platega (карты + СБП)",
"callback": "topup_platega",
})
# Поддержка всегда доступна
methods.append({
"id": "support",
@@ -174,6 +183,8 @@ def is_payment_method_available(method_id: str) -> bool:
return settings.is_cryptobot_enabled()
elif method_id == "heleket":
return settings.is_heleket_enabled()
elif method_id == "platega":
return settings.is_platega_enabled() and bool(settings.get_platega_active_methods())
elif method_id == "support":
return True # Поддержка всегда доступна
else:
@@ -192,6 +203,7 @@ def get_payment_method_status() -> Dict[str, bool]:
"pal24": settings.is_pal24_enabled(),
"cryptobot": settings.is_cryptobot_enabled(),
"heleket": settings.is_heleket_enabled(),
"platega": settings.is_platega_enabled() and bool(settings.get_platega_active_methods()),
"support": True
}
@@ -216,4 +228,6 @@ def get_enabled_payment_methods_count() -> int:
count += 1
if settings.is_heleket_enabled():
count += 1
if settings.is_platega_enabled() and settings.get_platega_active_methods():
count += 1
return count
+15
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from fastapi import APIRouter, Security
from app.config import settings
from app.database import db_manager, get_pool_metrics
from app.services.version_service import version_service
from ..dependencies import require_api_token
@@ -24,3 +25,17 @@ async def health_check(_: object = Security(require_api_token)) -> HealthCheckRe
webhooks=bool(settings.WEBHOOK_URL),
),
)
@router.get("/health/database", tags=["health"])
async def database_health(_: object = Security(require_api_token)) -> dict:
"""Детальная информация о состоянии базы данных."""
return await db_manager.health_check()
@router.get("/metrics/pool", tags=["health"])
async def pool_metrics(_: object = Security(require_api_token)) -> dict:
"""Метрики пула подключений к базе данных."""
return await get_pool_metrics()
+179 -2
View File
@@ -67,6 +67,14 @@ 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.promocode_service import PromoCodeService
from app.services.subscription_service import SubscriptionService
from app.services.trial_activation_service import (
TrialPaymentChargeFailed,
TrialPaymentInsufficientFunds,
charge_trial_activation_if_required,
preview_trial_activation_charge,
revert_trial_activation,
rollback_trial_subscription_activation,
)
from app.services.subscription_purchase_service import (
purchase_service,
PurchaseBalanceError,
@@ -1529,6 +1537,8 @@ async def _resolve_pal24_payment_status(
if remote_status:
message = f"Status: {remote_status}"
links_info = status_info.get("links") if status_info else {}
return MiniAppPaymentStatusResult(
method="pal24",
status=status,
@@ -1548,6 +1558,14 @@ async def _resolve_pal24_payment_status(
"payment_method": getattr(payment, "payment_method", None),
"payload": query.payload,
"started_at": query.started_at,
"links": links_info or None,
"sbp_url": status_info.get("sbp_url") if status_info else None,
"card_url": status_info.get("card_url") if status_info else None,
"link_url": status_info.get("link_url") if status_info else None,
"link_page_url": status_info.get("link_page_url") if status_info else None,
"primary_url": status_info.get("primary_url") if status_info else None,
"secondary_url": status_info.get("secondary_url") if status_info else None,
"selected_method": status_info.get("selected_method") if status_info else None,
},
)
@@ -2888,6 +2906,13 @@ async def get_subscription_details(
trial_duration_days = (
settings.TRIAL_DURATION_DAYS if settings.TRIAL_DURATION_DAYS > 0 else None
)
trial_price_kopeks = settings.get_trial_activation_price()
trial_payment_required = (
settings.is_trial_paid_activation_enabled() and trial_price_kopeks > 0
)
trial_price_label = (
settings.format_price(trial_price_kopeks) if trial_payment_required else None
)
subscription_missing_reason = None
if subscription is None:
@@ -2951,6 +2976,9 @@ async def get_subscription_details(
trial_available=trial_available,
trial_duration_days=trial_duration_days,
trial_status="available" if trial_available else "unavailable",
trial_payment_required=trial_payment_required,
trial_price_kopeks=trial_price_kopeks if trial_payment_required else None,
trial_price_label=trial_price_label,
**autopay_extras,
)
@@ -3103,6 +3131,20 @@ async def activate_subscription_trial_endpoint(
},
)
try:
preview_trial_activation_charge(user)
except TrialPaymentInsufficientFunds as error:
missing = error.missing_amount
raise HTTPException(
status.HTTP_402_PAYMENT_REQUIRED,
detail={
"code": "insufficient_funds",
"message": "Not enough funds to activate the trial",
"missing_amount_kopeks": missing,
"required_amount_kopeks": error.required_amount,
"balance_kopeks": error.balance_amount,
},
) from error
forced_devices = None
if not settings.is_devices_selection_enabled():
forced_devices = settings.get_disabled_mode_device_limit()
@@ -3127,6 +3169,61 @@ async def activate_subscription_trial_endpoint(
},
) from error
charged_amount = 0
try:
charged_amount = await charge_trial_activation_if_required(db, user)
except TrialPaymentInsufficientFunds as error:
rollback_success = await rollback_trial_subscription_activation(db, subscription)
await db.refresh(user)
if not rollback_success:
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": "trial_rollback_failed",
"message": "Failed to revert trial activation after charge error",
},
) from error
logger.error(
"Balance check failed after trial creation for user %s: %s",
user.id,
error,
)
raise HTTPException(
status.HTTP_402_PAYMENT_REQUIRED,
detail={
"code": "insufficient_funds",
"message": "Not enough funds to activate the trial",
"missing_amount_kopeks": error.missing_amount,
"required_amount_kopeks": error.required_amount,
"balance_kopeks": error.balance_amount,
},
) from error
except TrialPaymentChargeFailed as error:
rollback_success = await rollback_trial_subscription_activation(db, subscription)
await db.refresh(user)
if not rollback_success:
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": "trial_rollback_failed",
"message": "Failed to revert trial activation after charge error",
},
) from error
logger.error(
"Failed to charge balance for trial activation after subscription %s creation: %s",
subscription.id,
error,
)
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": "charge_failed",
"message": "Failed to charge balance for trial activation",
},
) from error
await db.refresh(user)
await db.refresh(subscription)
@@ -3134,13 +3231,75 @@ async def activate_subscription_trial_endpoint(
try:
await subscription_service.create_remnawave_user(db, subscription)
except RemnaWaveConfigurationError as error: # pragma: no cover - configuration issues
logger.warning("RemnaWave update skipped: %s", error)
logger.error("RemnaWave update skipped due to configuration error: %s", error)
revert_result = await revert_trial_activation(
db,
user,
subscription,
charged_amount,
refund_description="Возврат оплаты за активацию триала в мини-приложении",
)
if not revert_result.subscription_rolled_back:
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": "trial_rollback_failed",
"message": "Failed to revert trial activation after RemnaWave error",
},
) from error
if charged_amount > 0 and not revert_result.refunded:
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": "trial_refund_failed",
"message": "Failed to refund trial activation charge after RemnaWave error",
},
) from error
raise HTTPException(
status.HTTP_502_BAD_GATEWAY,
detail={
"code": "remnawave_configuration_error",
"message": "Trial activation failed due to RemnaWave configuration. Charge refunded.",
},
) from error
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"Failed to create RemnaWave user for trial subscription %s: %s",
subscription.id,
error,
)
revert_result = await revert_trial_activation(
db,
user,
subscription,
charged_amount,
refund_description="Возврат оплаты за активацию триала в мини-приложении",
)
if not revert_result.subscription_rolled_back:
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": "trial_rollback_failed",
"message": "Failed to revert trial activation after RemnaWave error",
},
) from error
if charged_amount > 0 and not revert_result.refunded:
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": "trial_refund_failed",
"message": "Failed to refund trial activation charge after RemnaWave error",
},
) from error
raise HTTPException(
status.HTTP_502_BAD_GATEWAY,
detail={
"code": "remnawave_provisioning_failed",
"message": "Trial activation failed due to RemnaWave provisioning. Charge refunded.",
},
) from error
await db.refresh(subscription)
@@ -3158,6 +3317,9 @@ async def activate_subscription_trial_endpoint(
duration_days = settings.TRIAL_DURATION_DAYS
language_code = _normalize_language_code(user)
charged_amount_label = (
settings.format_price(charged_amount) if charged_amount > 0 else None
)
if language_code == "ru":
if duration_days:
message = f"Триал активирован на {duration_days} дн. Приятного пользования!"
@@ -3169,8 +3331,19 @@ async def activate_subscription_trial_endpoint(
else:
message = "Trial activated successfully. Enjoy!"
if charged_amount_label:
if language_code == "ru":
message = f"{message}\n\n💳 С вашего баланса списано {charged_amount_label}."
else:
message = f"{message}\n\n💳 {charged_amount_label} has been deducted from your balance."
await _with_admin_notification_service(
lambda service: service.send_trial_activation_notification(db, user, subscription)
lambda service: service.send_trial_activation_notification(
db,
user,
subscription,
charged_amount_kopeks=charged_amount,
)
)
return MiniAppSubscriptionTrialResponse(
@@ -3178,6 +3351,10 @@ async def activate_subscription_trial_endpoint(
subscription_id=getattr(subscription, "id", None),
trial_status="activated",
trial_duration_days=duration_days,
charged_amount_kopeks=charged_amount if charged_amount > 0 else None,
charged_amount_label=charged_amount_label,
balance_kopeks=user.balance_kopeks,
balance_label=settings.format_price(user.balance_kopeks),
)
+43 -13
View File
@@ -158,6 +158,12 @@ async def get_user(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> UserResponse:
# First check if the provided ID is a telegram_id
user = await get_user_by_telegram_id(db, user_id)
if user:
return _serialize_user(user)
# If not found as telegram_id, check as internal user ID
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
@@ -202,8 +208,15 @@ async def update_user_endpoint(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> UserResponse:
user = await get_user_by_id(db, user_id)
if not user:
# First check if the provided ID is a telegram_id
user = await get_user_by_telegram_id(db, user_id)
if user:
found_user = user
else:
# If not found as telegram_id, check as internal user ID
found_user = await get_user_by_id(db, user_id)
if not found_user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
updates: dict[str, Any] = {}
@@ -234,18 +247,23 @@ async def update_user_endpoint(
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Promo group not found")
updates["promo_group_id"] = promo_group.id
if payload.referral_code is not None and payload.referral_code != user.referral_code:
if payload.referral_code is not None and payload.referral_code != found_user.referral_code:
existing_code_owner = await get_user_by_referral_code(db, payload.referral_code)
if existing_code_owner and existing_code_owner.id != user.id:
if existing_code_owner and existing_code_owner.id != found_user.id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Referral code already in use")
updates["referral_code"] = payload.referral_code
if not updates:
return _serialize_user(user)
return _serialize_user(found_user)
user = await update_user(db, user, **updates)
user = await get_user_by_id(db, user.id)
return _serialize_user(user)
found_user = await update_user(db, found_user, **updates)
# Reload the user to ensure we have the latest data
if found_user.telegram_id == user_id:
found_user = await get_user_by_telegram_id(db, user_id)
else:
found_user = await get_user_by_id(db, found_user.id)
return _serialize_user(found_user)
@router.post("/{user_id}/balance", response_model=UserResponse)
@@ -258,13 +276,20 @@ async def update_balance(
if payload.amount_kopeks == 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Amount must be non-zero")
user = await get_user_by_id(db, user_id)
if not user:
# First check if the provided ID is a telegram_id
user = await get_user_by_telegram_id(db, user_id)
if user:
found_user = user
else:
# If not found as telegram_id, check as internal user ID
found_user = await get_user_by_id(db, user_id)
if not found_user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
success = await add_user_balance(
db,
user,
found_user,
amount_kopeks=payload.amount_kopeks,
description=payload.description or "Корректировка через веб-API",
create_transaction=payload.create_transaction,
@@ -273,5 +298,10 @@ async def update_balance(
if not success:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to update balance")
user = await get_user_by_id(db, user_id)
return _serialize_user(user)
# Reload the user to ensure we have the latest data
if found_user.telegram_id == user_id:
found_user = await get_user_by_telegram_id(db, user_id)
else:
found_user = await get_user_by_id(db, found_user.id)
return _serialize_user(found_user)
+8 -1
View File
@@ -465,8 +465,11 @@ class MiniAppSubscriptionResponse(BaseModel):
trial_available: bool = False
trial_duration_days: Optional[int] = None
trial_status: Optional[str] = None
trial_payment_required: bool = Field(default=False, alias="trialPaymentRequired")
trial_price_kopeks: Optional[int] = Field(default=None, alias="trialPriceKopeks")
trial_price_label: Optional[str] = Field(default=None, alias="trialPriceLabel")
model_config = ConfigDict(extra="allow")
model_config = ConfigDict(extra="allow", populate_by_name=True)
class MiniAppSubscriptionServerOption(BaseModel):
@@ -736,6 +739,10 @@ class MiniAppSubscriptionTrialResponse(BaseModel):
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
trial_status: Optional[str] = Field(default=None, alias="trialStatus")
trial_duration_days: Optional[int] = Field(default=None, alias="trialDurationDays")
charged_amount_kopeks: Optional[int] = Field(default=None, alias="chargedAmountKopeks")
charged_amount_label: Optional[str] = Field(default=None, alias="chargedAmountLabel")
balance_kopeks: Optional[int] = Field(default=None, alias="balanceKopeks")
balance_label: Optional[str] = Field(default=None, alias="balanceLabel")
model_config = ConfigDict(populate_by_name=True)
+90 -17
View File
@@ -15,7 +15,7 @@ from aiogram import Bot
from app.config import settings
from app.database.database import get_db
from app.external.tribute import TributeService as TributeAPI
from app.external.yookassa_webhook import YooKassaWebhookHandler
from app.external import yookassa_webhook as yookassa_webhook_module
from app.external.wata_webhook import WataWebhookHandler
from app.external.heleket_webhook import HeleketWebhookHandler
from app.external.pal24_client import Pal24APIError
@@ -322,7 +322,6 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
routes_registered = True
if settings.is_yookassa_enabled():
yookassa_secret = settings.YOOKASSA_WEBHOOK_SECRET or ""
@router.options(settings.YOOKASSA_WEBHOOK_PATH)
async def yookassa_options() -> Response:
@@ -347,6 +346,37 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
@router.post(settings.YOOKASSA_WEBHOOK_PATH)
async def yookassa_webhook(request: Request) -> JSONResponse:
header_ip_candidates = yookassa_webhook_module.collect_yookassa_ip_candidates(
request.headers.get("X-Forwarded-For"),
request.headers.get("X-Real-IP"),
request.headers.get("Cf-Connecting-Ip"),
)
remote_ip = request.client.host if request.client else None
client_ip = yookassa_webhook_module.resolve_yookassa_ip(
header_ip_candidates,
remote=remote_ip,
)
if client_ip is None:
return JSONResponse(
{
"status": "error",
"reason": "unknown_ip",
"candidates": header_ip_candidates + ([remote_ip] if remote_ip else []),
},
status_code=status.HTTP_403_FORBIDDEN,
)
if not yookassa_webhook_module.is_yookassa_ip_allowed(client_ip):
return JSONResponse(
{
"status": "error",
"reason": "forbidden_ip",
"ip": str(client_ip),
},
status_code=status.HTTP_403_FORBIDDEN,
)
body_bytes = await request.body()
if not body_bytes:
return JSONResponse({"status": "error", "reason": "empty_body"}, status_code=status.HTTP_400_BAD_REQUEST)
@@ -354,18 +384,8 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
body = body_bytes.decode("utf-8")
signature = request.headers.get("Signature") or request.headers.get("X-YooKassa-Signature")
if yookassa_secret:
if not signature:
return JSONResponse(
{"status": "error", "reason": "missing_signature"},
status_code=status.HTTP_401_UNAUTHORIZED,
)
if not YooKassaWebhookHandler.verify_webhook_signature(body, signature, yookassa_secret):
return JSONResponse(
{"status": "error", "reason": "invalid_signature"},
status_code=status.HTTP_401_UNAUTHORIZED,
)
if signature:
logger.info("ℹ️ Получена подпись YooKassa: %s", signature)
try:
webhook_data = json.loads(body)
@@ -382,7 +402,11 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
status_code=status.HTTP_400_BAD_REQUEST,
)
if event_type not in {"payment.succeeded", "payment.waiting_for_capture"}:
if event_type not in {
"payment.succeeded",
"payment.waiting_for_capture",
"payment.canceled",
}:
return JSONResponse({"status": "ok", "ignored": event_type})
success = await _process_payment_service_callback(
@@ -555,7 +579,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
)
try:
parsed_payload = pal24_service.parse_postback(payload)
parsed_payload = pal24_service.parse_callback(payload)
except Pal24APIError as error:
return JSONResponse(
{"status": "error", "reason": str(error)},
@@ -565,7 +589,55 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
success = await _process_payment_service_callback(
payment_service,
parsed_payload,
"process_pal24_postback",
"process_pal24_callback",
)
if success:
return JSONResponse({"status": "ok"})
return JSONResponse(
{"status": "error", "reason": "not_processed"},
status_code=status.HTTP_400_BAD_REQUEST,
)
routes_registered = True
if settings.is_platega_enabled():
@router.get(settings.PLATEGA_WEBHOOK_PATH)
async def platega_health() -> JSONResponse:
return JSONResponse(
{
"status": "ok",
"service": "platega_webhook",
"enabled": settings.is_platega_enabled(),
}
)
@router.post(settings.PLATEGA_WEBHOOK_PATH)
async def platega_webhook(request: Request) -> JSONResponse:
merchant_id = request.headers.get("X-MerchantId", "")
secret = request.headers.get("X-Secret", "")
if (
merchant_id != (settings.PLATEGA_MERCHANT_ID or "")
or secret != (settings.PLATEGA_SECRET or "")
):
return JSONResponse(
{"status": "error", "reason": "unauthorized"},
status_code=status.HTTP_401_UNAUTHORIZED,
)
try:
payload = await request.json()
except json.JSONDecodeError:
return JSONResponse(
{"status": "error", "reason": "invalid_json"},
status_code=status.HTTP_400_BAD_REQUEST,
)
success = await _process_payment_service_callback(
payment_service,
payload,
"process_platega_webhook",
)
if success:
return JSONResponse({"status": "ok"})
@@ -590,6 +662,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
"wata_enabled": settings.is_wata_enabled(),
"heleket_enabled": settings.is_heleket_enabled(),
"pal24_enabled": settings.is_pal24_enabled(),
"platega_enabled": settings.is_platega_enabled(),
}
)
+3 -3
View File
@@ -170,8 +170,8 @@
- `app/external/pal24_client.py` — Async client for PayPalych (Pal24) API.
Классы: `Pal24APIError` — Base error for Pal24 API operations., `Pal24Response` (2 методов) — Wrapper for Pal24 API responses., `Pal24Client` (5 методов) — Async client implementing PayPalych API methods.
Функции: нет
- `app/external/pal24_webhook.py` — Flask webhook server for PayPalych postbacks.
Классы: `Pal24WebhookServer` (3 методов) — Threaded Flask server for Pal24 postbacks.
- `app/external/pal24_webhook.py` — Flask webhook server for PayPalych callbacks.
Классы: `Pal24WebhookServer` (3 методов) — Threaded Flask server for Pal24 callbacks.
Функции: `_normalize_payload`, `create_pal24_flask_app`
- `app/external/remnawave_api.py` — Python-модуль
Классы: `UserStatus`, `TrafficLimitStrategy`, `RemnaWaveUser`, `RemnaWaveInternalSquad`, `RemnaWaveNode`, `SubscriptionInfo`, `RemnaWaveAPIError` (1 методов), `RemnaWaveAPI` (8 методов)
@@ -731,7 +731,7 @@
Функции: `anyio_backend`, `_enable_service`, `test_is_configured`, `test_format_and_signature`
- `tests/services/test_pal24_service_adapter.py` — Тесты Pal24Service и вспомогательных функций.
Классы: `StubPal24Client` (1 методов)
Функции: `_enable_pal24`, `anyio_backend`, `test_parse_postback_success`, `test_parse_postback_missing_fields`, `test_convert_to_kopeks_and_expiration`
Функции: `_enable_pal24`, `anyio_backend`, `test_parse_callback_success`, `test_parse_callback_missing_fields`, `test_convert_to_kopeks_and_expiration`
- `tests/services/test_payment_service_cryptobot.py` — Тесты сценариев CryptoBot в PaymentService.
Классы: `DummySession` (2 методов), `DummyLocalPayment` (1 методов), `StubCryptoBotService` (1 методов)
Функции: `anyio_backend`, `_make_service`
+4 -4
View File
@@ -105,10 +105,10 @@ curl -X POST "http://127.0.0.1:8080/tokens" \
| `PUT` | `/settings/{key}` | Обновить значение настройки.
| `DELETE` | `/settings/{key}` | Сбросить настройку к значению по умолчанию.
| `GET` | `/users` | Список пользователей с фильтрами и пагинацией.
| `GET` | `/users/{id}` | Детали пользователя.
| `GET` | `/users/{id}` | Детали пользователя. ID может быть как внутренним (user.id), так и Telegram ID (user.telegram_id).
| `POST` | `/users` | Создать пользователя (например, для ручной выдачи доступа).
| `PATCH` | `/users/{id}` | Обновить профиль пользователя или статус.
| `POST` | `/users/{id}/balance` | Корректировка баланса с созданием транзакции.
| `PATCH` | `/users/{id}` | Обновить профиль пользователя или статус. ID может быть как внутренним (user.id), так и Telegram ID (user.telegram_id).
| `POST` | `/users/{id}/balance` | Корректировка баланса с созданием транзакции. ID может быть как внутренним (user.id), так и Telegram ID (user.telegram_id).
| `GET` | `/subscriptions` | Список подписок с фильтрами.
| `POST` | `/subscriptions` | Создать триальную или платную подписку.
| `POST` | `/subscriptions/{id}/extend` | Продлить подписку на N дней.
@@ -260,7 +260,7 @@ curl -X POST "http://127.0.0.1:8080/tokens" \
1. **Health-check** — перед авторизацией UI вызывает `GET /health`, чтобы отобразить статус и версию бота.
2. **Настройки UI** — подгружает категории через `GET /settings/categories`, далее выводит форму со значениями из `GET /settings`.
3. **Статистика дашборда**`GET /stats/overview` для карточек с показателями.
4. **Раздел пользователи**`GET /users` с поиском (`search`), фильтрами по статусу или промо-группе. Для детальной карточки использовать `GET /users/{id}`.
4. **Раздел пользователи**`GET /users` с поиском (`search`), фильтрами по статусу или промо-группе. Для детальной карточки использовать `GET /users/{id}` (в качестве ID можно использовать как внутренний user.id, так и telegram_id пользователя).
5. **Операции с подпиской** — использовать `POST /subscriptions/{id}/...` эндпоинты для продления, выдачи трафика и устройств.
6. **Поддержка** — список тикетов (`GET /tickets`), изменение статуса (`POST /tickets/{id}/status`), блокировка ответов (`POST /tickets/{id}/reply-block`).
7. **История операций**`GET /transactions` с фильтрами по пользователю, типу и периоду.
@@ -0,0 +1,95 @@
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "2b3c1d4e5f6a"
down_revision: Union[str, None] = "9f0f2d5a1c7b"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"platega_payments",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("platega_transaction_id", sa.String(length=255), nullable=True, unique=True),
sa.Column("correlation_id", sa.String(length=64), nullable=False, unique=True),
sa.Column("amount_kopeks", sa.Integer(), nullable=False),
sa.Column(
"currency",
sa.String(length=10),
nullable=False,
server_default="RUB",
),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("payment_method_code", sa.Integer(), nullable=False),
sa.Column(
"status",
sa.String(length=50),
nullable=False,
server_default="PENDING",
),
sa.Column(
"is_paid",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
sa.Column("paid_at", sa.DateTime(), nullable=True),
sa.Column("redirect_url", sa.Text(), nullable=True),
sa.Column("return_url", sa.Text(), nullable=True),
sa.Column("failed_url", sa.Text(), nullable=True),
sa.Column("payload", sa.String(length=255), nullable=True),
sa.Column("metadata_json", sa.JSON(), nullable=True),
sa.Column("callback_payload", sa.JSON(), nullable=True),
sa.Column("expires_at", sa.DateTime(), nullable=True),
sa.Column("transaction_id", sa.Integer(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["transaction_id"], ["transactions.id"], ondelete="SET NULL"),
)
op.create_index("ix_platega_payments_id", "platega_payments", ["id"])
op.create_index("ix_platega_payments_user_id", "platega_payments", ["user_id"])
op.create_index(
"ix_platega_payments_platega_transaction_id",
"platega_payments",
["platega_transaction_id"],
)
op.create_index(
"ix_platega_payments_correlation_id",
"platega_payments",
["correlation_id"],
unique=True,
)
op.create_index(
"ix_platega_payments_transaction_id",
"platega_payments",
["transaction_id"],
)
def downgrade() -> None:
op.drop_index("ix_platega_payments_transaction_id", table_name="platega_payments")
op.drop_index("ix_platega_payments_correlation_id", table_name="platega_payments")
op.drop_index(
"ix_platega_payments_platega_transaction_id",
table_name="platega_payments",
)
op.drop_index("ix_platega_payments_user_id", table_name="platega_payments")
op.drop_index("ix_platega_payments_id", table_name="platega_payments")
op.drop_table("platega_payments")
-703
View File
@@ -1,703 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#2481cc">
<title>Connecting to VPN...</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
:root {
--tg-theme-bg-color: #ffffff;
--tg-theme-text-color: #000000;
--tg-theme-hint-color: #999999;
--tg-theme-link-color: #2481cc;
--tg-theme-button-color: #2481cc;
--tg-theme-button-text-color: #ffffff;
--tg-theme-secondary-bg-color: #f0f0f0;
--primary: var(--tg-theme-button-color);
--primary-rgb: 36, 129, 204;
--text-primary: var(--tg-theme-text-color);
--text-secondary: var(--tg-theme-hint-color);
--bg-primary: var(--tg-theme-bg-color);
--bg-secondary: var(--tg-theme-secondary-bg-color);
--border-color: rgba(0, 0, 0, 0.08);
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.12);
--radius-sm: 8px;
--radius: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--info: #3b82f6;
}
:root[data-theme="dark"] {
color-scheme: dark;
--bg-primary: #0f172a;
--bg-secondary: rgba(30, 41, 59, 0.85);
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--border-color: rgba(148, 163, 184, 0.25);
--shadow-sm: 0 2px 8px rgba(2, 6, 23, 0.3);
--shadow-md: 0 4px 16px rgba(2, 6, 23, 0.45);
--shadow-lg: 0 8px 32px rgba(2, 6, 23, 0.55);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
line-height: 1.6;
display: flex;
align-items: center;
justify-content: center;
}
.container {
max-width: 480px;
width: 100%;
margin: 0 auto;
padding: 16px;
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes shimmer {
to { transform: translateX(100%); }
}
.animate-in {
animation: fadeIn 0.5s ease-out forwards;
}
/* Language Switcher */
.top-bar {
position: fixed;
top: 16px;
right: 16px;
z-index: 100;
display: flex;
gap: 12px;
}
.language-select {
padding: 8px 32px 8px 12px;
border-radius: var(--radius);
border: 2px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
cursor: pointer;
appearance: none;
transition: all 0.3s ease;
position: relative;
min-width: 120px;
}
.language-select:hover {
border-color: var(--primary);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
/* Theme Toggle */
.theme-toggle {
width: 44px;
height: 44px;
border-radius: var(--radius);
border: 2px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
.theme-toggle:hover {
border-color: var(--primary);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
/* Header */
.header {
text-align: center;
margin-bottom: 32px;
position: relative;
}
.logo-container {
width: 80px;
height: 80px;
margin: 0 auto 16px;
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.7));
border-radius: var(--radius-xl);
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-md);
position: relative;
overflow: hidden;
animation: pulse 2s infinite;
}
.logo-container::after {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(45deg, transparent, rgba(255, 255, 255, 0.3), transparent);
transform: rotate(45deg);
animation: shimmer 3s infinite;
}
.logo-icon {
font-size: 40px;
color: white;
z-index: 1;
font-weight: 700;
}
.logo {
font-size: 28px;
font-weight: 800;
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.7));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 8px;
}
.subtitle {
font-size: 15px;
color: var(--text-secondary);
font-weight: 500;
}
/* Main Card */
.card {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
padding: 32px;
box-shadow: var(--shadow-sm);
transition: all 0.3s ease;
overflow: hidden;
text-align: center;
}
.card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
/* Error State */
.card.error {
border: 2px solid var(--danger);
background: linear-gradient(135deg, var(--bg-secondary), rgba(239, 68, 68, 0.05));
}
.card.error .logo-container {
background: linear-gradient(135deg, var(--danger), rgba(239, 68, 68, 0.7));
}
/* Loading State */
.loading {
text-align: center;
padding: 24px;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid var(--bg-secondary);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 20px;
}
.loading-text {
font-size: 16px;
color: var(--text-secondary);
font-weight: 500;
margin-bottom: 8px;
}
/* Timer */
.timer-container {
background: var(--bg-primary);
border-radius: var(--radius);
padding: 24px;
margin: 24px 0;
border: 2px solid var(--border-color);
}
.timer-label {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
.timer-display {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 16px;
}
.timer-number {
font-size: 48px;
font-weight: 800;
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.7));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1;
}
.timer-unit {
font-size: 16px;
color: var(--text-secondary);
font-weight: 500;
}
.progress-track {
width: 100%;
height: 6px;
background: var(--border-color);
border-radius: 3px;
overflow: hidden;
position: relative;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary), rgba(var(--primary-rgb), 0.6));
border-radius: 3px;
transition: width 1s linear;
width: 100%;
position: relative;
overflow: hidden;
}
.progress-fill::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent);
animation: shimmer 2s infinite;
}
/* Status Text */
.status-text {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.status-description {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 24px;
}
.error-text {
color: var(--danger);
}
/* Button */
.btn {
width: 100%;
padding: 16px 24px;
border: none;
border-radius: var(--radius);
font-size: 15px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
text-transform: uppercase;
letter-spacing: 0.5px;
text-decoration: none;
}
.btn::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
}
.btn:active::before {
width: 300px;
height: 300px;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.8));
color: white;
box-shadow: var(--shadow-md);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.btn-primary:active {
transform: scale(0.98);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
}
.btn-icon {
width: 20px;
height: 20px;
stroke-width: 2.5;
}
/* Hidden */
.hidden {
display: none !important;
}
/* Mobile Optimizations */
@media (max-width: 480px) {
.container {
padding: 12px;
}
.timer-number {
font-size: 40px;
}
.logo {
font-size: 24px;
}
}
/* Dark Mode Adjustments */
:root[data-theme="dark"] .timer-container {
background: var(--bg-secondary);
}
:root[data-theme="dark"] .btn-primary {
box-shadow: 0 10px 30px rgba(37, 99, 235, 0.45);
}
</style>
</head>
<body>
<!-- Top Bar -->
<div class="top-bar">
<select id="languageSelect" class="language-select">
<option value="en">🇬🇧 English</option>
<option value="ru">🇷🇺 Русский</option>
</select>
<button class="theme-toggle" id="themeToggle">
<svg class="icon-sun" width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 18a6 6 0 100-12 6 6 0 000 12zM12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
<svg class="icon-moon" width="20" height="20" fill="currentColor" viewBox="0 0 24 24" style="display: none;">
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/>
</svg>
</button>
</div>
<div class="container">
<!-- Header -->
<div class="header animate-in">
<div class="logo-container">
<div class="logo-icon" id="appIcon"></div>
</div>
<div class="logo" data-i18n="app.name">VPN</div>
<div class="subtitle" id="appSubtitle" data-i18n="app.connecting">Connecting to VPN...</div>
</div>
<!-- Main Card -->
<div class="card animate-in" id="mainCard">
<div class="loading" id="loadingState">
<div class="spinner"></div>
<div class="loading-text" id="statusText" data-i18n="status.connecting">Establishing secure connection...</div>
<div class="timer-container" id="timerContainer">
<div class="timer-label" data-i18n="timer.label">Redirecting in</div>
<div class="timer-display">
<span class="timer-number" id="timer">10</span>
<span class="timer-unit" data-i18n="timer.seconds">seconds</span>
</div>
<div class="progress-track">
<div class="progress-fill" id="progressFill"></div>
</div>
</div>
<p class="status-description" data-i18n="status.manual">If nothing happens, click the button below.</p>
</div>
<!-- Error State (hidden by default) -->
<div class="error-state hidden" id="errorState">
<div class="status-text error-text" data-i18n="error.title">Connection Error</div>
<p class="status-description" data-i18n="error.description">The connection link is missing or invalid.</p>
</div>
<!-- Action Button -->
<a class="btn btn-primary" id="actionButton" href="#" target="_blank" rel="noopener">
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<span data-i18n="button.connect">Connect to VPN</span>
</a>
</div>
</div>
<script>
// App schemes configuration
const appSchemes = [
{ scheme: 'happ://', id: 'happ', icon: 'H', name: { en: 'Happ', ru: 'Happ' } },
{ scheme: 'flclash://', id: 'flclash', icon: 'F', name: { en: 'FlClash', ru: 'FlClash' } },
{ scheme: 'clash://', id: 'clash-meta', icon: 'C', name: { en: 'Clash Meta', ru: 'Clash Meta' } },
{ scheme: 'sing-box://', id: 'sing-box', icon: 'S', name: { en: 'Sing-box', ru: 'Sing-box' } },
{ scheme: 'v2rayng://', id: 'v2rayng', icon: 'V', name: { en: 'v2rayNG', ru: 'v2rayNG' } },
{ scheme: 'sub://', id: 'shadowrocket', icon: 'R', name: { en: 'Shadowrocket', ru: 'Shadowrocket' } },
{ scheme: 'hiddify://', id: 'hiddify', icon: 'H', name: { en: 'Hiddify', ru: 'Hiddify' } }
];
// Translations
const translations = {
en: {
'app.name': 'VPN',
'app.connecting': 'Connecting to VPN...',
'status.connecting': 'Establishing secure connection...',
'timer.label': 'Redirecting in',
'timer.seconds': 'seconds',
'status.manual': 'If nothing happens, click the button below.',
'button.connect': 'Connect to VPN',
'error.title': 'Connection Error',
'error.description': 'The connection link is missing or invalid. Please contact support.'
},
ru: {
'app.name': 'VPN',
'app.connecting': 'Подключение к VPN...',
'status.connecting': 'Установка безопасного соединения...',
'timer.label': 'Перенаправление через',
'timer.seconds': 'секунд',
'status.manual': 'Если ничего не происходит, нажмите кнопку ниже.',
'button.connect': 'Подключиться к VPN',
'error.title': 'Ошибка подключения',
'error.description': 'Ссылка для подключения отсутствует или недействительна. Обратитесь в поддержку.'
}
};
// Global variables
let currentLanguage = 'en';
let currentTheme = 'light';
let timerValue = 10;
let timerId = null;
let redirectTo = '';
let appInfo = null;
// Initialize
document.addEventListener('DOMContentLoaded', function() {
loadSettings();
// Get redirect URL from URL parameters
const urlParams = new URLSearchParams(window.location.search);
redirectTo = urlParams.get('redirect_to') || '';
// Detect app from URL scheme
if (redirectTo) {
appInfo = appSchemes.find(a => redirectTo.startsWith(a.scheme));
}
updateUI();
if (redirectTo) {
startTimer();
} else {
showError();
}
});
// Load saved settings
function loadSettings() {
const savedTheme = localStorage.getItem('remnawave-miniapp-theme') || 'light';
const savedLang = localStorage.getItem('remnawave-miniapp-language') || getBrowserLanguage();
currentTheme = savedTheme;
currentLanguage = savedLang;
applyTheme(savedTheme);
document.getElementById('languageSelect').value = currentLanguage;
}
// Get browser language
function getBrowserLanguage() {
const lang = navigator.language || navigator.userLanguage;
if (lang.startsWith('ru')) return 'ru';
return 'en';
}
// Apply theme
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
updateThemeToggle();
}
// Update theme toggle icon
function updateThemeToggle() {
const sunIcon = document.querySelector('.icon-sun');
const moonIcon = document.querySelector('.icon-moon');
if (currentTheme === 'dark') {
sunIcon.style.display = 'none';
moonIcon.style.display = 'block';
} else {
sunIcon.style.display = 'block';
moonIcon.style.display = 'none';
}
}
// Toggle theme
document.getElementById('themeToggle').addEventListener('click', function() {
currentTheme = currentTheme === 'dark' ? 'light' : 'dark';
localStorage.setItem('remnawave-miniapp-theme', currentTheme);
applyTheme(currentTheme);
});
// Change language
document.getElementById('languageSelect').addEventListener('change', function(e) {
currentLanguage = e.target.value;
localStorage.setItem('remnawave-miniapp-language', currentLanguage);
updateUI();
});
// Get translation
function t(key) {
return translations[currentLanguage]?.[key] || translations.en[key] || key;
}
// Update UI with translations
function updateUI() {
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = t(key);
});
// Update app-specific info
if (appInfo && redirectTo) {
const appName = appInfo.name[currentLanguage] || appInfo.name.en;
document.getElementById('appIcon').textContent = appInfo.icon;
document.getElementById('appSubtitle').textContent = `${t('app.connecting').replace('VPN', appName)}`;
const statusText = document.getElementById('statusText');
if (statusText) {
statusText.textContent = `${t('status.connecting').replace('...', '')} ${appName}...`;
}
}
// Update action button
const actionButton = document.getElementById('actionButton');
if (actionButton && redirectTo) {
actionButton.href = redirectTo;
}
}
// Start countdown timer
function startTimer() {
if (!redirectTo || timerId) return;
const timerEl = document.getElementById('timer');
const progressEl = document.getElementById('progressFill');
const totalTime = 10;
timerEl.textContent = timerValue;
timerId = setInterval(() => {
timerValue--;
timerEl.textContent = timerValue;
// Update progress bar
const progress = ((totalTime - timerValue) / totalTime) * 100;
progressEl.style.width = `${100 - progress}%`;
if (timerValue <= 0) {
clearInterval(timerId);
window.location.href = redirectTo;
}
}, 1000);
}
// Show error state
function showError() {
document.getElementById('loadingState').classList.add('hidden');
document.getElementById('errorState').classList.remove('hidden');
document.getElementById('actionButton').classList.add('hidden');
document.getElementById('mainCard').classList.add('error');
document.getElementById('appIcon').textContent = '!';
// Update subtitle
document.getElementById('appSubtitle').textContent = t('error.title');
}
</script>
</body>
</html>
+1
View File
@@ -14,6 +14,7 @@ redis==5.0.1
PyYAML==6.0.2
fastapi==0.115.6
uvicorn==0.32.1
python-multipart==0.0.9
# YooKassa SDK
yookassa==3.7.0
+211
View File
@@ -0,0 +1,211 @@
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from app.config import settings
from app.external.yookassa_webhook import (
create_yookassa_webhook_app,
resolve_yookassa_ip,
)
ALLOWED_IP = "185.71.76.10"
class DummyDB:
async def close(self) -> None: # pragma: no cover - simple stub
pass
@pytest.fixture(autouse=True)
def configure_settings(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
monkeypatch.setattr(settings, "YOOKASSA_SHOP_ID", "shop", raising=False)
monkeypatch.setattr(settings, "YOOKASSA_SECRET_KEY", "key", raising=False)
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_PATH", "/yookassa-webhook", raising=False)
monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "", raising=False)
def _build_headers(**overrides: str) -> dict[str, str]:
headers = {
"Content-Type": "application/json",
"X-Forwarded-For": ALLOWED_IP,
"Cf-Connecting-Ip": ALLOWED_IP,
}
headers.update(overrides)
return headers
@pytest.mark.parametrize(
("remote", "expected"),
(
("185.71.76.10", "185.71.76.10"),
("8.8.8.8", "8.8.8.8"),
("10.0.0.5", "185.71.76.10"),
(None, "185.71.76.10"),
),
)
def test_resolve_yookassa_ip_trust_rules(remote: str | None, expected: str) -> None:
candidates = [ALLOWED_IP]
ip_object = resolve_yookassa_ip(candidates, remote=remote)
assert ip_object is not None
assert str(ip_object) == expected
def test_resolve_yookassa_ip_prefers_last_forwarded_candidate() -> None:
candidates = ["185.71.76.10", "8.8.8.8"]
ip_object = resolve_yookassa_ip(candidates, remote="10.0.0.5")
assert ip_object is not None
assert str(ip_object) == "8.8.8.8"
def test_resolve_yookassa_ip_accepts_allowed_last_forwarded_candidate() -> None:
candidates = ["8.8.8.8", ALLOWED_IP]
ip_object = resolve_yookassa_ip(candidates, remote="10.0.0.5")
assert ip_object is not None
assert str(ip_object) == ALLOWED_IP
def test_resolve_yookassa_ip_skips_trusted_proxy_hops(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "203.0.113.0/24", raising=False)
candidates = [ALLOWED_IP, "203.0.113.10"]
ip_object = resolve_yookassa_ip(candidates, remote="10.0.0.5")
assert ip_object is not None
assert str(ip_object) == ALLOWED_IP
def test_resolve_yookassa_ip_trusted_public_proxy(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "198.51.100.0/24", raising=False)
candidates = [ALLOWED_IP, "198.51.100.10"]
ip_object = resolve_yookassa_ip(candidates, remote="198.51.100.20")
assert ip_object is not None
assert str(ip_object) == ALLOWED_IP
def test_resolve_yookassa_ip_returns_none_when_no_candidates() -> None:
assert resolve_yookassa_ip([], remote=None) is None
async def _post_webhook(client: TestClient, payload: dict, **headers: str) -> web.Response:
body = json.dumps(payload, ensure_ascii=False)
return await client.post(
settings.YOOKASSA_WEBHOOK_PATH,
data=body.encode("utf-8"),
headers=_build_headers(**headers),
)
def _patch_get_db(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_get_db():
yield DummyDB()
monkeypatch.setattr("app.external.yookassa_webhook.get_db", fake_get_db)
@pytest.mark.asyncio
async def test_handle_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None:
_patch_get_db(monkeypatch)
process_mock = AsyncMock(return_value=True)
service = SimpleNamespace(process_yookassa_webhook=process_mock)
app = create_yookassa_webhook_app(service)
async with TestClient(TestServer(app)) as client:
payload = {"event": "payment.succeeded"}
body = json.dumps(payload, ensure_ascii=False)
response = await client.post(
settings.YOOKASSA_WEBHOOK_PATH,
data=body.encode("utf-8"),
headers=_build_headers(),
)
status = response.status
text = await response.text()
assert status == 200
assert text == "OK"
process_mock.assert_awaited_once()
@pytest.mark.asyncio
async def test_handle_webhook_trusts_cf_connecting_ip(monkeypatch: pytest.MonkeyPatch) -> None:
_patch_get_db(monkeypatch)
process_mock = AsyncMock(return_value=True)
service = SimpleNamespace(process_yookassa_webhook=process_mock)
app = create_yookassa_webhook_app(service)
async with TestClient(TestServer(app)) as client:
payload = {"event": "payment.succeeded"}
body = json.dumps(payload, ensure_ascii=False)
headers = _build_headers()
headers.pop("X-Forwarded-For")
response = await client.post(
settings.YOOKASSA_WEBHOOK_PATH,
data=body.encode("utf-8"),
headers=headers,
)
status = response.status
text = await response.text()
assert status == 200
assert text == "OK"
process_mock.assert_awaited_once()
@pytest.mark.asyncio
async def test_handle_webhook_with_optional_signature(monkeypatch: pytest.MonkeyPatch) -> None:
_patch_get_db(monkeypatch)
process_mock = AsyncMock(return_value=True)
service = SimpleNamespace(process_yookassa_webhook=process_mock)
app = create_yookassa_webhook_app(service)
async with TestClient(TestServer(app)) as client:
payload = {"event": "payment.succeeded"}
body = json.dumps(payload, ensure_ascii=False)
response = await client.post(
settings.YOOKASSA_WEBHOOK_PATH,
data=body.encode("utf-8"),
headers=_build_headers(Signature="test-signature"),
)
status = response.status
text = await response.text()
assert status == 200
assert text == "OK"
process_mock.assert_awaited_once()
@pytest.mark.asyncio
async def test_handle_webhook_accepts_canceled_event(monkeypatch: pytest.MonkeyPatch) -> None:
_patch_get_db(monkeypatch)
process_mock = AsyncMock(return_value=True)
service = SimpleNamespace(process_yookassa_webhook=process_mock)
app = create_yookassa_webhook_app(service)
async with TestClient(TestServer(app)) as client:
payload = {"event": "payment.canceled", "object": {"id": "yk_1"}}
response = await client.post(
settings.YOOKASSA_WEBHOOK_PATH,
data=json.dumps(payload).encode("utf-8"),
headers=_build_headers(),
)
status = response.status
assert status == 200
process_mock.assert_awaited_once()
+4 -4
View File
@@ -110,7 +110,7 @@ async def test_get_bill_payments(monkeypatch: pytest.MonkeyPatch) -> None:
assert result == {"id": "BILL42", "payments": [{"id": "PAY-1"}]}
def test_parse_postback_success(monkeypatch: pytest.MonkeyPatch) -> None:
def test_parse_callback_success(monkeypatch: pytest.MonkeyPatch) -> None:
_enable_pal24(monkeypatch)
sig = Pal24Client.calculate_signature("100.00", "INV1", api_token="sigsecret")
payload = {
@@ -119,14 +119,14 @@ def test_parse_postback_success(monkeypatch: pytest.MonkeyPatch) -> None:
"Status": "SUCCESS",
"SignatureValue": sig,
}
result = Pal24Service.parse_postback(payload)
result = Pal24Service.parse_callback(payload)
assert result["InvId"] == "INV1"
def test_parse_postback_missing_fields(monkeypatch: pytest.MonkeyPatch) -> None:
def test_parse_callback_missing_fields(monkeypatch: pytest.MonkeyPatch) -> None:
_enable_pal24(monkeypatch)
with pytest.raises(Pal24APIError):
Pal24Service.parse_postback({"InvId": "1"})
Pal24Service.parse_callback({"InvId": "1"})
def test_convert_to_kopeks_and_expiration() -> None:
@@ -0,0 +1,232 @@
"""Тесты для сценариев Platega в PaymentService."""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
import sys
import pytest
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
import app.services.payment_service as payment_service_module # noqa: E402
from app.config import settings # noqa: E402
from app.services.payment_service import PaymentService # noqa: E402
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
class DummySession:
async def commit(self) -> None: # pragma: no cover - no custom logic required
return None
async def refresh(self, *_: Any) -> None: # pragma: no cover - no custom logic required
return None
class DummyLocalPayment:
def __init__(self, payment_id: int = 101) -> None:
self.id = payment_id
self.created_at = datetime.utcnow()
class StubPlategaService:
def __init__(
self,
*,
configured: bool = True,
response: Optional[Dict[str, Any]] = None,
transaction_payload: Optional[Dict[str, Any]] = None,
) -> None:
self.is_configured = configured
self.response = response or {
"transactionId": "trx-001",
"redirect": "https://platega.example/pay",
"status": "PENDING",
"expiresIn": 900,
}
self.transaction_payload = transaction_payload
self.calls: list[Dict[str, Any]] = []
self.raise_error: Optional[Exception] = None
async def create_payment(self, **kwargs: Any) -> Optional[Dict[str, Any]]:
self.calls.append(kwargs)
if self.raise_error:
raise self.raise_error
return self.response
async def get_transaction(self, transaction_id: str) -> Optional[Dict[str, Any]]:
self.calls.append({"transaction_lookup": transaction_id})
return self.transaction_payload
def _make_service(stub: Optional[StubPlategaService]) -> PaymentService:
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
service.bot = None
service.platega_service = stub
service.yookassa_service = None
service.cryptobot_service = None
service.heleket_service = None
service.mulenpay_service = None
service.pal24_service = None
service.stars_service = None
service.wata_service = None
return service
@pytest.mark.anyio("asyncio")
async def test_create_platega_payment_success(monkeypatch: pytest.MonkeyPatch) -> None:
stub = StubPlategaService()
service = _make_service(stub)
db = DummySession()
captured_args: Dict[str, Any] = {}
async def fake_create_platega_payment(*args: Any, **kwargs: Any) -> DummyLocalPayment:
if args:
captured_args["db_arg"] = args[0]
captured_args.update(kwargs)
return DummyLocalPayment(payment_id=777)
monkeypatch.setattr(
payment_service_module,
"create_platega_payment",
fake_create_platega_payment,
raising=False,
)
monkeypatch.setattr(settings, "PLATEGA_MIN_AMOUNT_KOPEKS", 10_000, raising=False)
monkeypatch.setattr(settings, "PLATEGA_MAX_AMOUNT_KOPEKS", 500_000, raising=False)
monkeypatch.setattr(settings, "PLATEGA_CURRENCY", "RUB", raising=False)
monkeypatch.setattr(settings, "PLATEGA_RETURN_URL", "https://return", raising=False)
monkeypatch.setattr(settings, "PLATEGA_FAILED_URL", "https://failed", raising=False)
result = await service.create_platega_payment(
db=db,
user_id=42,
amount_kopeks=50_000,
description="Пополнение счёта",
language="ru",
payment_method_code=10,
)
assert result is not None
assert result["local_payment_id"] == 777
assert result["transaction_id"] == "trx-001"
assert result["redirect_url"] == "https://platega.example/pay"
assert result["status"] == "PENDING"
assert "correlation_id" in result and len(result["correlation_id"]) == 32
assert captured_args["user_id"] == 42
assert captured_args["amount_kopeks"] == 50_000
assert captured_args["payment_method_code"] == 10
assert captured_args["metadata"]["selected_method"] == 10
assert stub.calls and stub.calls[0]["payment_method"] == 10
assert stub.calls[0]["amount"] == pytest.approx(500.0)
assert stub.calls[0]["currency"] == "RUB"
assert captured_args["metadata"]["language"] == "ru"
@pytest.mark.anyio("asyncio")
async def test_create_platega_payment_respects_limits_and_configuration(monkeypatch: pytest.MonkeyPatch) -> None:
stub = StubPlategaService()
service = _make_service(stub)
db = DummySession()
monkeypatch.setattr(settings, "PLATEGA_MIN_AMOUNT_KOPEKS", 20_000, raising=False)
monkeypatch.setattr(settings, "PLATEGA_MAX_AMOUNT_KOPEKS", 40_000, raising=False)
too_low = await service.create_platega_payment(
db=db,
user_id=1,
amount_kopeks=10_000,
description="Пополнение",
language="ru",
payment_method_code=2,
)
assert too_low is None
too_high = await service.create_platega_payment(
db=db,
user_id=1,
amount_kopeks=100_000,
description="Пополнение",
language="ru",
payment_method_code=2,
)
assert too_high is None
not_configured_service = _make_service(StubPlategaService(configured=False))
result = await not_configured_service.create_platega_payment(
db=db,
user_id=1,
amount_kopeks=30_000,
description="Пополнение",
language="ru",
payment_method_code=2,
)
assert result is None
@pytest.mark.anyio("asyncio")
async def test_create_platega_payment_handles_service_errors(monkeypatch: pytest.MonkeyPatch) -> None:
stub = StubPlategaService()
stub.raise_error = RuntimeError("network down")
service = _make_service(stub)
db = DummySession()
async def fake_create_platega_payment(*_: Any, **__: Any) -> DummyLocalPayment:
pytest.fail("local payment must not be created when Platega call fails")
monkeypatch.setattr(
payment_service_module,
"create_platega_payment",
fake_create_platega_payment,
raising=False,
)
monkeypatch.setattr(settings, "PLATEGA_MIN_AMOUNT_KOPEKS", 1_000, raising=False)
monkeypatch.setattr(settings, "PLATEGA_MAX_AMOUNT_KOPEKS", 1_000_000, raising=False)
result = await service.create_platega_payment(
db=db,
user_id=5,
amount_kopeks=25_000,
description="Пополнение",
language="ru",
payment_method_code=13,
)
assert result is None
assert stub.calls and "payment_method" in stub.calls[0]
def test_get_platega_active_methods_parses_and_filters(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
settings,
"PLATEGA_ACTIVE_METHODS",
" 2,10, 11 ;12,13,13,invalid ",
raising=False,
)
methods = settings.get_platega_active_methods()
assert methods == [2, 10, 11, 12, 13]
def test_get_platega_active_methods_returns_default(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "PLATEGA_ACTIVE_METHODS", "", raising=False)
methods = settings.get_platega_active_methods()
assert methods == [2]
def test_platega_method_display_helpers() -> None:
assert settings.get_platega_method_display_name(10) == "Банковские карты (RUB)"
assert settings.get_platega_method_display_title(10) == "💳 Карты (RUB)"
assert settings.get_platega_method_display_name(999) == "Метод 999"
assert settings.get_platega_method_display_title(999) == "Platega 999"
+195 -5
View File
@@ -17,6 +17,7 @@ if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
import app.services.payment_service as payment_service_module # noqa: E402
import app.services.payment.cryptobot as cryptobot_module # noqa: E402
from app.services.payment_service import PaymentService # noqa: E402
from app.database.models import PaymentMethod # noqa: E402
from app.config import settings # noqa: E402
@@ -283,13 +284,21 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch
monkeypatch.setitem(sys.modules, "app.database.crud.cryptobot", fake_cryptobot_module)
transactions: list[Dict[str, Any]] = []
created_transaction: SimpleNamespace | None = None
async def fake_create_transaction(db, **kwargs):
nonlocal created_transaction
transactions.append(kwargs)
return SimpleNamespace(id=888, **kwargs)
created_transaction = SimpleNamespace(id=888, **kwargs)
return created_transaction
fake_transaction_module = ModuleType("app.database.crud.transaction")
fake_transaction_module.create_transaction = fake_create_transaction
async def fake_get_transaction_by_id(db, transaction_id):
return created_transaction
fake_transaction_module.get_transaction_by_id = fake_get_transaction_by_id
monkeypatch.setitem(sys.modules, "app.database.crud.transaction", fake_transaction_module)
monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction)
@@ -310,6 +319,10 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch
monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user_crypto)
fake_user_module = ModuleType("app.database.crud.user")
fake_user_module.get_user_by_id = fake_get_user_crypto
monkeypatch.setitem(sys.modules, "app.database.crud.user", fake_user_module)
referral_crypto = SimpleNamespace(process_referral_topup=AsyncMock())
monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_crypto)
@@ -323,6 +336,18 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch
admin_calls.append((args, kwargs))
monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService2(bot)))
class DummyAsyncSession:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def rollback(self): # pragma: no cover - defensive stub
return None
monkeypatch.setattr(cryptobot_module, "AsyncSessionLocal", lambda: DummyAsyncSession())
monkeypatch.setattr(payment_service_module.currency_converter, "usd_to_rub", AsyncMock(return_value=140.0))
monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}", raising=False)
service.build_topup_success_keyboard = AsyncMock(return_value=None)
@@ -584,6 +609,171 @@ async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch)
assert admin_calls
@pytest.mark.anyio("asyncio")
async def test_process_yookassa_webhook_uses_remote_status(monkeypatch: pytest.MonkeyPatch) -> None:
bot = DummyBot()
service = _make_service(bot)
fake_session = FakeSession()
payment = SimpleNamespace(
yookassa_payment_id="yk_789",
user_id=42,
amount_kopeks=20000,
transaction_id=None,
status="pending",
is_paid=False,
)
async def fake_get_payment(db, payment_id):
return payment
async def fake_update(db, payment_id, status, is_paid, is_captured, captured_at, payment_method_type):
payment.status = status
payment.is_paid = is_paid
payment.captured_at = captured_at
payment.payment_method_type = payment_method_type
return payment
async def fake_link(db, payment_id, transaction_id):
payment.transaction_id = transaction_id
yk_module = ModuleType("app.database.crud.yookassa")
yk_module.get_yookassa_payment_by_id = fake_get_payment
yk_module.update_yookassa_payment_status = fake_update
yk_module.link_yookassa_payment_to_transaction = fake_link
monkeypatch.setitem(sys.modules, "app.database.crud.yookassa", yk_module)
transactions: list[Dict[str, Any]] = []
async def fake_create_transaction(db, **kwargs):
transactions.append(kwargs)
return SimpleNamespace(id=555, **kwargs)
monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction)
user = SimpleNamespace(
id=42,
telegram_id=4200,
balance_kopeks=0,
has_made_first_topup=False,
promo_group=None,
subscription=None,
referred_by_id=None,
referrer=None,
)
async def fake_get_user(db, user_id):
return user
monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user)
monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}", raising=False)
referral_mock = SimpleNamespace(process_referral_topup=AsyncMock())
monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_mock)
admin_calls: list[Any] = []
class DummyAdminService:
def __init__(self, bot):
self.bot = bot
async def send_balance_topup_notification(self, *args, **kwargs):
admin_calls.append((args, kwargs))
monkeypatch.setitem(
sys.modules,
"app.services.admin_notification_service",
SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)),
)
service.build_topup_success_keyboard = AsyncMock(return_value=None)
remote_payload = {
"id": "yk_789",
"status": "succeeded",
"paid": True,
"amount_value": 200.0,
"amount_currency": "rub",
"payment_method_type": "bank_card",
"refundable": True,
}
get_info_mock = AsyncMock(return_value=remote_payload)
service.yookassa_service = SimpleNamespace(get_payment_info=get_info_mock)
payload = {
"object": {
"id": "yk_789",
"status": "pending",
"paid": False,
}
}
result = await service.process_yookassa_webhook(fake_session, payload)
assert result is True
assert payment.status == "succeeded"
assert payment.is_paid is True
assert transactions and transactions[0]["amount_kopeks"] == 20000
assert payment.transaction_id == 555
get_info_mock.assert_awaited_once_with("yk_789")
assert admin_calls
@pytest.mark.anyio("asyncio")
async def test_process_yookassa_webhook_handles_cancellation(monkeypatch: pytest.MonkeyPatch) -> None:
bot = DummyBot()
service = _make_service(bot)
fake_session = FakeSession()
payment = SimpleNamespace(
yookassa_payment_id="yk_cancel",
user_id=77,
amount_kopeks=5000,
transaction_id=None,
status="pending",
is_paid=False,
captured_at=None,
payment_method_type=None,
)
async def fake_get_payment(db, payment_id):
return payment
monkeypatch.setattr(
payment_service_module,
"get_yookassa_payment_by_id",
fake_get_payment,
)
get_info_mock = AsyncMock(
return_value={
"id": "yk_cancel",
"status": "canceled",
"paid": False,
"amount_value": 50.0,
"amount_currency": "RUB",
}
)
service.yookassa_service = SimpleNamespace(get_payment_info=get_info_mock)
payload = {
"object": {
"id": "yk_cancel",
"status": "pending",
"paid": False,
}
}
result = await service.process_yookassa_webhook(fake_session, payload)
assert result is True
assert payment.status == "canceled"
assert payment.is_paid is False
assert fake_session.commits == 1
assert fake_session.refreshed and fake_session.refreshed[0] is payment
assert bot.sent_messages == []
get_info_mock.assert_awaited_once_with("yk_cancel")
@pytest.mark.anyio("asyncio")
async def test_process_yookassa_webhook_restores_missing_payment(
monkeypatch: pytest.MonkeyPatch,
@@ -756,7 +946,7 @@ async def test_process_yookassa_webhook_missing_id(monkeypatch: pytest.MonkeyPat
@pytest.mark.anyio("asyncio")
async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -> None:
async def test_process_pal24_callback_success(monkeypatch: pytest.MonkeyPatch) -> None:
bot = DummyBot()
service = _make_service(bot)
service.pal24_service = SimpleNamespace(is_configured=True)
@@ -879,7 +1069,7 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -
"TrsId": "trs-1",
}
result = await service.process_pal24_postback(fake_session, payload)
result = await service.process_pal24_callback(fake_session, payload)
assert result is True
assert payment.transaction_id == 654
@@ -1044,7 +1234,7 @@ async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.Monkey
assert transactions and transactions[0]["user_id"] == 91
@pytest.mark.anyio("asyncio")
async def test_process_pal24_postback_payment_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
async def test_process_pal24_callback_payment_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
bot = DummyBot()
service = _make_service(bot)
service.pal24_service = SimpleNamespace(is_configured=True)
@@ -1071,5 +1261,5 @@ async def test_process_pal24_postback_payment_not_found(monkeypatch: pytest.Monk
"Status": "SUCCESS",
}
result = await service.process_pal24_postback(db, payload)
result = await service.process_pal24_callback(db, payload)
assert result is False
+68
View File
@@ -0,0 +1,68 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.handlers.subscription.purchase import activate_trial
from app.services.trial_activation_service import TrialPaymentInsufficientFunds
@pytest.fixture
def trial_callback_query():
callback = AsyncMock(spec=CallbackQuery)
callback.message = AsyncMock(spec=Message)
callback.message.edit_text = AsyncMock()
callback.answer = AsyncMock()
return callback
@pytest.fixture
def trial_user():
user = MagicMock(spec=User)
user.subscription = None
user.has_had_paid_subscription = False
user.language = "ru"
return user
@pytest.fixture
def trial_db():
return AsyncMock(spec=AsyncSession)
@pytest.mark.asyncio
async def test_activate_trial_uses_trial_price_for_topup_redirect(
trial_callback_query,
trial_user,
trial_db,
):
error = TrialPaymentInsufficientFunds(required_amount=15900, balance_amount=100)
mock_keyboard = InlineKeyboardMarkup(inline_keyboard=[])
with (
patch(
"app.handlers.subscription.purchase.preview_trial_activation_charge",
side_effect=error,
),
patch(
"app.handlers.subscription.purchase.get_texts",
return_value=MagicMock(
t=lambda key, default, **kwargs: default,
),
),
patch(
"app.handlers.subscription.purchase.get_insufficient_balance_keyboard",
return_value=mock_keyboard,
) as insufficient_keyboard,
):
await activate_trial(trial_callback_query, trial_user, trial_db)
insufficient_keyboard.assert_called_once_with(
trial_user.language,
amount_kopeks=error.required_amount,
)
trial_callback_query.message.edit_text.assert_called_once()
trial_callback_query.answer.assert_called_once()
+349 -35
View File
@@ -1,3 +1,4 @@
import base64
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
@@ -11,8 +12,6 @@ from app.webserver.payments import create_payment_router
class DummyBot:
pass
@pytest.fixture(autouse=True)
def reset_settings(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "TRIBUTE_ENABLED", False, raising=False)
@@ -25,9 +24,9 @@ def reset_settings(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_SECRET", None, raising=False)
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", False, raising=False)
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_PATH", "/yookassa", raising=False)
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_SECRET", None, raising=False)
monkeypatch.setattr(settings, "YOOKASSA_SHOP_ID", "shop", raising=False)
monkeypatch.setattr(settings, "YOOKASSA_SECRET_KEY", "key", raising=False)
monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "", raising=False)
monkeypatch.setattr(settings, "WEBHOOK_URL", "http://test", raising=False)
@@ -38,7 +37,12 @@ def _get_route(router, path: str, method: str = "POST"):
raise AssertionError(f"Route {path} with method {method} not found")
def _build_request(path: str, body: bytes, headers: dict[str, str]) -> Request:
def _build_request(
path: str,
body: bytes,
headers: dict[str, str],
client_ip: str | None = "185.71.76.1",
) -> Request:
scope = {
"type": "http",
"asgi": {"version": "3.0"},
@@ -47,6 +51,9 @@ def _build_request(path: str, body: bytes, headers: dict[str, str]) -> Request:
"headers": [(k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in headers.items()],
}
if client_ip is not None:
scope["client"] = (client_ip, 12345)
async def receive() -> dict:
return {"type": "http.request", "body": body, "more_body": False}
@@ -92,38 +99,12 @@ async def test_tribute_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None:
@pytest.mark.anyio
async def test_yookassa_invalid_signature(monkeypatch: pytest.MonkeyPatch) -> None:
async def test_yookassa_unknown_ip(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_SECRET", "secret", raising=False)
class StubHandler:
@staticmethod
def verify_webhook_signature(body: str, signature: str, secret: str) -> bool: # noqa: D401
return False
service = SimpleNamespace(process_yookassa_webhook=AsyncMock())
monkeypatch.setattr("app.webserver.payments.YooKassaWebhookHandler", StubHandler)
router = create_payment_router(DummyBot(), SimpleNamespace())
assert router is not None
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
request = _build_request(
settings.YOOKASSA_WEBHOOK_PATH,
body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"),
headers={"Signature": "bad"},
)
response = await route.endpoint(request)
assert response.status_code == 401
@pytest.mark.anyio
async def test_yookassa_missing_signature(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_SECRET", "secret", raising=False)
router = create_payment_router(DummyBot(), SimpleNamespace())
router = create_payment_router(DummyBot(), service)
assert router is not None
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
@@ -131,13 +112,346 @@ async def test_yookassa_missing_signature(monkeypatch: pytest.MonkeyPatch) -> No
settings.YOOKASSA_WEBHOOK_PATH,
body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"),
headers={},
client_ip=None,
)
response = await route.endpoint(request)
assert response.status_code == 401
assert response.status_code == 403
payload = json.loads(response.body.decode("utf-8"))
assert payload["reason"] == "missing_signature"
assert payload["reason"] == "unknown_ip"
service.process_yookassa_webhook.assert_not_awaited()
@pytest.mark.anyio
async def test_yookassa_forbidden_ip(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
service = SimpleNamespace(process_yookassa_webhook=AsyncMock())
router = create_payment_router(DummyBot(), service)
assert router is not None
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
request = _build_request(
settings.YOOKASSA_WEBHOOK_PATH,
body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"),
headers={},
client_ip="8.8.8.8",
)
response = await route.endpoint(request)
assert response.status_code == 403
payload = json.loads(response.body.decode("utf-8"))
assert payload["reason"] == "forbidden_ip"
assert payload["ip"] == "8.8.8.8"
service.process_yookassa_webhook.assert_not_awaited()
@pytest.mark.anyio
async def test_yookassa_forbidden_ip_ignores_spoofed_header(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
service = SimpleNamespace(process_yookassa_webhook=AsyncMock())
router = create_payment_router(DummyBot(), service)
assert router is not None
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
request = _build_request(
settings.YOOKASSA_WEBHOOK_PATH,
body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"),
headers={"X-Forwarded-For": "185.71.76.10"},
client_ip="8.8.8.8",
)
response = await route.endpoint(request)
assert response.status_code == 403
payload = json.loads(response.body.decode("utf-8"))
assert payload["reason"] == "forbidden_ip"
assert payload["ip"] == "8.8.8.8"
service.process_yookassa_webhook.assert_not_awaited()
@pytest.mark.anyio
async def test_yookassa_forbidden_ip_ignores_spoofed_forwarded_chain(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
service = SimpleNamespace(process_yookassa_webhook=AsyncMock())
router = create_payment_router(DummyBot(), service)
assert router is not None
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
request = _build_request(
settings.YOOKASSA_WEBHOOK_PATH,
body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"),
headers={"X-Forwarded-For": "185.71.76.10, 8.8.8.8"},
client_ip="10.0.0.5",
)
response = await route.endpoint(request)
assert response.status_code == 403
payload = json.loads(response.body.decode("utf-8"))
assert payload["reason"] == "forbidden_ip"
assert payload["ip"] == "8.8.8.8"
service.process_yookassa_webhook.assert_not_awaited()
@pytest.mark.anyio
async def test_yookassa_allowed_ip(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
async def fake_get_db():
yield SimpleNamespace()
monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db)
process_mock = AsyncMock(return_value=True)
service = SimpleNamespace(process_yookassa_webhook=process_mock)
router = create_payment_router(DummyBot(), service)
assert router is not None
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
request = _build_request(
settings.YOOKASSA_WEBHOOK_PATH,
body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"),
headers={},
client_ip="185.71.76.10",
)
response = await route.endpoint(request)
assert response.status_code == 200
payload = json.loads(response.body.decode("utf-8"))
assert payload["status"] == "ok"
process_mock.assert_awaited_once()
@pytest.mark.anyio
async def test_yookassa_allowed_via_forwarded_header_when_proxy(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
async def fake_get_db():
yield SimpleNamespace()
monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db)
process_mock = AsyncMock(return_value=True)
service = SimpleNamespace(process_yookassa_webhook=process_mock)
router = create_payment_router(DummyBot(), service)
assert router is not None
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
request = _build_request(
settings.YOOKASSA_WEBHOOK_PATH,
body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"),
headers={"X-Forwarded-For": "185.71.76.10"},
client_ip="10.0.0.5",
)
response = await route.endpoint(request)
assert response.status_code == 200
payload = json.loads(response.body.decode("utf-8"))
assert payload["status"] == "ok"
process_mock.assert_awaited_once()
@pytest.mark.anyio
async def test_yookassa_allowed_via_cf_connecting_ip(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
async def fake_get_db():
yield SimpleNamespace()
monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db)
process_mock = AsyncMock(return_value=True)
service = SimpleNamespace(process_yookassa_webhook=process_mock)
router = create_payment_router(DummyBot(), service)
assert router is not None
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
request = _build_request(
settings.YOOKASSA_WEBHOOK_PATH,
body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"),
headers={"Cf-Connecting-Ip": "185.71.76.10"},
client_ip="172.64.223.133",
)
response = await route.endpoint(request)
assert response.status_code == 200
payload = json.loads(response.body.decode("utf-8"))
assert payload["status"] == "ok"
process_mock.assert_awaited_once()
@pytest.mark.anyio
async def test_yookassa_allowed_via_trusted_forwarded_chain(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "203.0.113.0/24", raising=False)
async def fake_get_db():
yield SimpleNamespace()
monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db)
process_mock = AsyncMock(return_value=True)
service = SimpleNamespace(process_yookassa_webhook=process_mock)
router = create_payment_router(DummyBot(), service)
assert router is not None
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
request = _build_request(
settings.YOOKASSA_WEBHOOK_PATH,
body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"),
headers={"X-Forwarded-For": "185.71.76.10, 203.0.113.10"},
client_ip="10.0.0.5",
)
response = await route.endpoint(request)
assert response.status_code == 200
payload = json.loads(response.body.decode("utf-8"))
assert payload["status"] == "ok"
process_mock.assert_awaited_once()
@pytest.mark.anyio
async def test_yookassa_allowed_via_trusted_public_proxy(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "198.51.100.0/24", raising=False)
async def fake_get_db():
yield SimpleNamespace()
monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db)
process_mock = AsyncMock(return_value=True)
service = SimpleNamespace(process_yookassa_webhook=process_mock)
router = create_payment_router(DummyBot(), service)
assert router is not None
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
request = _build_request(
settings.YOOKASSA_WEBHOOK_PATH,
body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"),
headers={"X-Forwarded-For": "185.71.76.10, 198.51.100.10"},
client_ip="198.51.100.20",
)
response = await route.endpoint(request)
assert response.status_code == 200
payload = json.loads(response.body.decode("utf-8"))
assert payload["status"] == "ok"
process_mock.assert_awaited_once()
@pytest.mark.anyio
async def test_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
async def fake_get_db():
yield SimpleNamespace()
monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db)
process_mock = AsyncMock(return_value=True)
service = SimpleNamespace(process_yookassa_webhook=process_mock)
router = create_payment_router(DummyBot(), service)
assert router is not None
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
payload = {"event": "payment.succeeded"}
body = json.dumps(payload).encode("utf-8")
request = _build_request(
settings.YOOKASSA_WEBHOOK_PATH,
body=body,
headers={},
)
response = await route.endpoint(request)
assert response.status_code == 200
payload = json.loads(response.body.decode("utf-8"))
assert payload["status"] == "ok"
process_mock.assert_awaited_once()
@pytest.mark.anyio
async def test_yookassa_webhook_cancellation(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
async def fake_get_db():
yield SimpleNamespace()
monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db)
process_mock = AsyncMock(return_value=True)
service = SimpleNamespace(process_yookassa_webhook=process_mock)
router = create_payment_router(DummyBot(), service)
assert router is not None
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
payload = {"event": "payment.canceled"}
body = json.dumps(payload).encode("utf-8")
request = _build_request(
settings.YOOKASSA_WEBHOOK_PATH,
body=body,
headers={},
)
response = await route.endpoint(request)
assert response.status_code == 200
payload = json.loads(response.body.decode("utf-8"))
assert payload["status"] == "ok"
process_mock.assert_awaited_once()
@pytest.mark.anyio
async def test_yookassa_webhook_with_signature(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
async def fake_get_db():
yield SimpleNamespace()
monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db)
process_mock = AsyncMock(return_value=True)
service = SimpleNamespace(process_yookassa_webhook=process_mock)
router = create_payment_router(DummyBot(), service)
assert router is not None
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
payload = {"event": "payment.succeeded"}
body = json.dumps(payload).encode("utf-8")
request = _build_request(
settings.YOOKASSA_WEBHOOK_PATH,
body=body,
headers={"Signature": "dummy"},
)
response = await route.endpoint(request)
assert response.status_code == 200
payload = json.loads(response.body.decode("utf-8"))
assert payload["status"] == "ok"
process_mock.assert_awaited_once()
@pytest.mark.anyio