Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 463c5385d6 | |||
| 41dfe39518 | |||
| f44c9b6903 | |||
| 1f35d45dc6 | |||
| ef8f6625bf | |||
| 7101555da0 | |||
| e15b18fb41 | |||
| b80eeea089 | |||
| cb61014d9c | |||
| 5b3353433b | |||
| f1d45343e9 | |||
| b40a812f3a | |||
| ac00434645 | |||
| 964c33c772 | |||
| ec875837a4 | |||
| db0e169a41 | |||
| a6dcf26c20 | |||
| 5081debee7 | |||
| 0ceff44c30 | |||
| 136f29c1eb | |||
| d0eab3f7aa | |||
| d7ad9d7033 | |||
| 1a87d438fe | |||
| aec01ce0d4 | |||
| a33a893d1a | |||
| 37c9b931ca | |||
| 22e7f150b3 | |||
| 688882237f | |||
| c14d7ab0af | |||
| e12cc9f248 | |||
| 6ff0460607 | |||
| 8d5a002996 | |||
| 31bdf8a0ae | |||
| 1364158e6c | |||
| d7931a2afa | |||
| b032c8f354 | |||
| 1306c24fa3 | |||
| 38deb70f81 | |||
| c1e015fb6e | |||
| 0730173e5b | |||
| 968f18b6e4 | |||
| 7eea35f111 | |||
| 6920e3a0fb | |||
| fddf8ef5eb | |||
| ad268329be | |||
| 1804c28f05 | |||
| f967c29bd7 | |||
| 06a00e367c | |||
| abaf279533 | |||
| 6d4430c639 | |||
| 911df7a05c | |||
| f106ce8216 | |||
| dcff6947dd | |||
| 4966e39eb9 | |||
| 4abb8cb1a3 | |||
| 04f4e6bf6e | |||
| 3d1fbc70f8 | |||
| 3089c1704b | |||
| 20eff6170f | |||
| 038c34e52a | |||
| 77f1a764d5 | |||
| 641da949a9 | |||
| 3f0b24c1ec | |||
| c34fdd10a0 | |||
| 72b5305b87 | |||
| 099391eb5f | |||
| 322d457652 | |||
| 5b722c5210 | |||
| a80a85c2a4 | |||
| f84885cc8a | |||
| 12898b7eab | |||
| 20a6fa1bcf | |||
| 94199413c2 | |||
| 826accba51 | |||
| 1cc687ac15 | |||
| e4bb0430fb | |||
| 603b9a1f46 | |||
| 557af5994d | |||
| 808818ca2b | |||
| b563796091 | |||
| 6a3e9d92b5 | |||
| cda2392411 | |||
| 04419fdff7 | |||
| 713146dd6b | |||
| 7d41ab44be | |||
| 98f6f93487 | |||
| 3752b7b067 | |||
| 2f33e55144 | |||
| c0b282a189 | |||
| e1bcb1ba91 | |||
| 3d68db0a51 | |||
| 8d7f0eea0f | |||
| 4aaf0ddd25 | |||
| db2f0c93f2 | |||
| 3f8e8993b2 | |||
| e453521098 | |||
| 8d3cd50098 | |||
| f80912e444 | |||
| 484d2f7e34 | |||
| 842fb697e6 | |||
| 3ac3a92e26 | |||
| 7648707ca2 | |||
| 7e466ef464 | |||
| 28321df4d2 | |||
| 6adf70b2da | |||
| 2d204275da | |||
| ebee8348ca | |||
| 06954c1711 | |||
| 5e04e2a020 | |||
| 08d69fb47f | |||
| 3306e02902 | |||
| 14dceaa39f | |||
| 5442f288d4 |
@@ -491,16 +491,11 @@ YOOKASSA_WEBHOOK_PORT=8082
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
|
||||
YOOKASSA_MAX_AMOUNT_KOPEKS=1000000
|
||||
|
||||
# Быстрый выбор суммы пополнения через YooKassa
|
||||
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
|
||||
|
||||
# Рекуррентные платежи YooKassa (автосохранение карты для автоплатежей)
|
||||
YOOKASSA_RECURRENT_ENABLED=false
|
||||
# true = карта сохраняется обязательно, false = пользователь решает (чекбокс на стороне YooKassa)
|
||||
YOOKASSA_RECURRENT_REQUIRED=true
|
||||
|
||||
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
|
||||
DISABLE_TOPUP_BUTTONS=false
|
||||
# Отключить пополнение баланса через поддержку
|
||||
SUPPORT_TOPUP_ENABLED=true
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 850 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.32.1"
|
||||
".": "3.35.0"
|
||||
}
|
||||
|
||||
+145
@@ -1,5 +1,150 @@
|
||||
# Changelog
|
||||
|
||||
## [3.35.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.34.1...v3.35.0) (2026-03-18)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add user_email to admin payments API response ([7101555](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7101555da0722d1eacd97f40b6b8c8c3a2327a0c))
|
||||
* include manual admin top-ups in sales statistics revenue ([b80eeea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b80eeea089568c60c20b1ae165b8dbe887bbe378))
|
||||
* раздельные топики для админских уведомлений ([e15b18f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e15b18fb41b180e7dd3d65f2f058667be321fe85))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fix Platega and CryptoBot webhook verification ([b40a812](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b40a812f3aa0596bf6c5105008451dd8a17b103f))
|
||||
* handle None autopay_days_before in autopayment processing ([f1d4534](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f1d45343e941594d69f71e822ecc9b3a7062f4bf))
|
||||
* remove forced white background from custom email template overrides ([cb61014](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cb61014d9c5a89e3aeafb191f1b9826ca1cbf338))
|
||||
* undefined currency variable in RioPay payment creation ([5b33534](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5b3353433bc524e3e51f2ae87d26bd162bd9f97b))
|
||||
|
||||
## [3.34.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.34.0...v3.34.1) (2026-03-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add null check for subscription in execute_change_devices ([5081deb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5081debee7625954bd7b82f66e09dd58e08e8822))
|
||||
* correct CryptoBot webhook signature verification and auto-fill topup amount from cart ([d7ad9d7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d7ad9d70330b6ef5599f7a9409cdd16657d61b85))
|
||||
* correct RioPay API header case and remove undocumented fields ([1a87d43](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1a87d438fe127a0b62bd6ee59887212869a3cb17))
|
||||
* disable quick amount buttons in balance topup ([d0eab3f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d0eab3f7aacf0249ca244f168c96044347c918e3))
|
||||
* reset device limit to new tariff base on tariff switch ([aec01ce](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aec01ce0d4a36da5ddd07b56ca4bd5de04735a0b))
|
||||
* sync crypto link from happ.cryptoLink in webhook handlers ([0ceff44](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ceff44c30cf11466c4cbe51b520558c51c4af4a))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* remove quick amount buttons feature entirely ([136f29c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/136f29c1eb63778b2b329ed5bf72ab06a4531d0b))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add bot preview screenshot to README ([d7931a2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d7931a2afaf272aae74453f2bb6d493593895f67))
|
||||
* add cabinet preview screenshot to README ([b032c8f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b032c8f35435ddd592df04d6352097580f5e9837))
|
||||
* add icons and list all 14+1 payment providers ([1306c24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1306c24fa36fd3e812c5bec551a0aca450ca7d2c))
|
||||
* add Redis to tech stack ([c14d7ab](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c14d7ab0af2e5dd4f35213b323a97eedcc99af0e))
|
||||
* add Redis to tech stack ([e12cc9f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e12cc9f248764104d538db1daaede9ddd8b77b2d))
|
||||
* add WATA partnership block to payments section ([31bdf8a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/31bdf8a0aeba53fcec40358d0a38b8480130a346))
|
||||
* increase logo size to 800px ([22e7f15](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/22e7f150b30c35b0419e7923e94c5ebc92c4b61a))
|
||||
* redesign README — concise feature showcase, link to docs ([38deb70](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/38deb70f8118d371e36ac92a4417bdb543635fc9))
|
||||
* replace header logo with new artwork ([6888822](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/688882237fe3019bb78d5e1d7ad54faf4cd69c09))
|
||||
* WATA partnership block with logo and table card ([8d5a002](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8d5a0029964ba52b40e03ebfe8ab6f4146d3aca9))
|
||||
|
||||
## [3.34.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.33.0...v3.34.0) (2026-03-18)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* добавлен SeverPay в админ-панель и настройки кабинета ([06a00e3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/06a00e367c303b5426008f38a20393ec4f2e07cd))
|
||||
* добавлена интеграция SeverPay для пополнения баланса ([abaf279](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/abaf279533d31994a8a70346627c962b43000c64))
|
||||
* поиск платежей в админ-панели с фильтрами и статистикой ([1804c28](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1804c28f0551fbe52883fc36fc4cfcd60d2d6bd6))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove contains_eager conflicting with selectinload on user relationship ([fddf8ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fddf8ef5ebc8e5176a75a10e92d59165bdabf2e1))
|
||||
* добавлен импорт MAX_ALL_TIME_DAYS в admin_payments routes ([ad26832](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad268329be45bdd665ce4a6f142e9762a78e55b5))
|
||||
* добавлены RioPay и SeverPay в REAL_PAYMENT_METHODS ([f967c29](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f967c29bd7cfe8a66e7e0f492573bb7521fcda22))
|
||||
|
||||
## [3.33.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.4...v3.33.0) (2026-03-17)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add SBP and Card sub-options for KassaAI payment method ([5b722c5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5b722c521036befcbbaf6192215f651c2ec9c4fb))
|
||||
* add SBP and Card sub-options to kassa_ai payment method ([04419fd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/04419fdff7dc244eb2c9553ea1501e2de454010b))
|
||||
* deep link авторизация в кабинете при блокировке oauth.telegram.org ([322d457](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/322d45765220c854e36ae0b4b862a96d36ae3be8))
|
||||
* добавлена поддержка RioPay в кабинете ([3d1fbc7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3d1fbc70f8add81d4a3561d63dcd46f385bcc6f1))
|
||||
* добавлена поддержка RioPay для лендингов и подарков ([04f4e6b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/04f4e6bf6e9031ef8512a07ab36355111c524319))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add back button to payment amount validation errors ([20eff61](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/20eff6170fb752051022a14fa9d927d59ff1d602))
|
||||
* add sync_squads=True to admin tariff change handler ([3f0b24c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3f0b24c1ec82ebfb6fdbc801ed6b493ea09c9a2f))
|
||||
* deep link auth security and reliability fixes ([099391e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/099391eb5f24319703f12ae2cb72d93b26164d99))
|
||||
* enforce promo group authorization on country/server selection ([641da94](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/641da949a907ade7b870e1a5385fdb71f9e524af))
|
||||
* merge phantom users into active accounts on /start ([77f1a76](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/77f1a764d59d68236899ec59c884907d3666d3dd))
|
||||
* MissingGreenlet crash after subscription purchase in cabinet ([a80a85c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a80a85c2a489f397efb4ffb5bd77655420a49adb))
|
||||
* MissingGreenlet crash after subscription purchase in cabinet ([1cc687a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1cc687ac15ecdf12928e5ce448514c09b6628f65))
|
||||
* MissingGreenlet при изменении количества устройств на CLASSIC подписках ([826accb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/826accba519f23a687fcc3d387f727fd31d2a88c))
|
||||
* protect external squads from deletion during server sync ([b563796](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b563796091e83edcc8dfbd6222648b5346f39a9c))
|
||||
* review findings — db.commit, isinstance guard, constants, ACTIVE check ([72b5305](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/72b5305b870ae9ecdb2672549b9c153dd8b3f7bc))
|
||||
* sub-method enabled check, guest payment provider, silent FSM return ([603b9a1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/603b9a1f4610a5b288d6fba78c98474c439529aa))
|
||||
* **subscription:** remove stale extend promo state fields causing NameError ([20a6fa1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/20a6fa1bcf362455623f43838f4ffaaf98b33e76))
|
||||
* swap Caddy auth headers — api_key to Authorization, caddy_token to X-Api-Key ([038c34e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/038c34e52a863d0c5c6993ea785587ba7e0bc61d))
|
||||
* sync squads to Remnawave panel on tariff purchase/switch ([c34fdd1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c34fdd10a0a22a85a4e29cbf44da2ac5d4a643b3))
|
||||
* защита внешних сквадов от удаления при синхронизации серверов ([f84885c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f84885cc8aa0c9a70f916076e987284f1f9c3479))
|
||||
* исправлен расчёт конверсии в статистике продаж ([3089c17](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3089c1704b54323b4c2a151d40a5b395a427c658))
|
||||
* исправлены проблемы RioPay интеграции после ревью ([4abb8cb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4abb8cb1a3f21089697199261bcc37e6f6a5c623))
|
||||
* миграция Tribute webhook с deprecated user_id на trb_user_id ([9419941](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/94199413c283167edd829b337cf9bec0a5414a54))
|
||||
* скрыть плашку верификации email при выключенной верификации ([4966e39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4966e39eb9b92967ef92c92ae434ca6cdea80c84))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* deduplicate KassaAI handlers with config dict and shared helpers ([e4bb043](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e4bb0430fb9cc4f55013767ceda6d1c214bd80a6))
|
||||
* move KASSA_AI_SUB_METHODS to service layer, add early enabled checks ([cda2392](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cda239241122ae1dd02a252b3eadc47453c0c48b))
|
||||
|
||||
## [3.32.4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.3...v3.32.4) (2026-03-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* лог полного payload при ошибке PATCH /api/users для диагностики A039 ([8d7f0ee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8d7f0eea0fecd9e66bf199bd2c288e073f1354c0))
|
||||
* не пересылать activeInternalSquads в рутинных обновлениях RemnaWave (A039) ([4aaf0dd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4aaf0ddd25527ec23fa6a479ac3826d6b6266761))
|
||||
* не пересылать externalSquadUuid в рутинных обновлениях RemnaWave ([3d68db0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3d68db0a51fac55640d44be784c832875ca2da17))
|
||||
* расширен лог PATCH /api/users payload для диагностики A039 ([db2f0c9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/db2f0c93f2974410e744411fb9111c6de1f0f0be))
|
||||
* режим «Контакт и тикеты» возвращает support_type='both' вместо 'tickets' ([2f33e55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f33e5514469f2686c4b35e2105f4188a41d4145))
|
||||
* реферальный бонус инвайтера — сумма вместо максимума, защита флага первого пополнения ([e1bcb1b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e1bcb1ba910ef3a79dec5fa974ae8e6c09494aa7))
|
||||
* сохранение user_id до rollback чтобы избежать MissingGreenlet при lazy load ([3f8e899](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3f8e8993b2949b5a5e04b1d8a468ef7dc1170e08))
|
||||
* убрана отправка externalSquadUuid=null в RemnaWave API и исправлен ложный лог синхронизации рулетки ([f80912e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f80912e444ab5706c809e689ca5ed2a38da118d0))
|
||||
* уведомление об истечении подписки теперь учитывает autopay_enabled пользователя ([c0b282a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c0b282a189a2b761c68fc70886edd91d9c807ff6))
|
||||
* устранена отправка externalSquadUuid=null в RemnaWave API (A039) и исправлен reduce_devices ([e453521](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e4535210982351413cb82483000fe441e7b7300a))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* централизация всех расчётов цен в PricingEngine ([8d3cd50](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8d3cd500980f4f640cb1ba493150f1f20e8bd58c))
|
||||
|
||||
## [3.32.3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.2...v3.32.3) (2026-03-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* campaign registration, revenue calculation, backup restore, autopay errors, referral links ([7648707](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7648707ca26d6cd2703b50b0fe8c4697e6155784))
|
||||
* implement case-insensitive email checks in authentication and user retrieval ([7e466ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7e466ef464ce918d885bd6297d1e605a633fd43e))
|
||||
* implement case-insensitive email checks in authentication and user retrieval ([ebee834](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebee8348ca338b9be5f044537e5e2b4740dc6441))
|
||||
* **payment:** prioritize saved cart after topup over expired auto-extend ([28321df](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/28321df4d274269536efebcf3da870f2e7d07d90))
|
||||
* refresh CLASSIC_PERIOD_PRICES when admin changes PRICE_*_DAYS or SALES_MODE ([6adf70b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6adf70b2da6e2250cc8e909dbb497b355302e72f))
|
||||
|
||||
## [3.32.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.1...v3.32.2) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add nested selectinload and referrer eager loading to prevent MissingGreenlet ([3306e02](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3306e029021c396e13774a205225beece4fbbcfb))
|
||||
* add selectinload to user lock queries to prevent MissingGreenlet ([5442f28](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5442f288d4c6c3973dd92ac141172a9f0e53a28f))
|
||||
* silence PARTICIPANT_ID_INVALID error in channel subscription check ([14dceaa](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/14dceaa39ff9faa1c9205483653014a1c5ac73fb))
|
||||
|
||||
## [3.32.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.0...v3.32.1) (2026-03-13)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.32.1" # x-release-please-version
|
||||
ARG VERSION="v3.35.0" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -670,8 +670,8 @@ async def preview_template(
|
||||
language = data.language if data.language in AVAILABLE_LANGUAGES else 'ru'
|
||||
|
||||
if data.body_html:
|
||||
# Preview custom content wrapped in base template
|
||||
rendered_html = templates_instance._get_base_template(data.body_html, language)
|
||||
# Preview custom content — auto-detects styled vs simple HTML
|
||||
rendered_html = templates_instance._wrap_override_template(data.body_html, language)
|
||||
subject = data.subject or notification_type
|
||||
else:
|
||||
# Preview default template
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Admin routes for payment verification in cabinet."""
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
@@ -13,6 +13,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, User
|
||||
from app.services.payment_search_service import (
|
||||
MAX_ALL_TIME_DAYS,
|
||||
PeriodPreset,
|
||||
SearchParams,
|
||||
StatusFilter,
|
||||
search_payments,
|
||||
search_payments_stats,
|
||||
)
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.payment_verification_service import (
|
||||
SUPPORTED_MANUAL_CHECK_METHODS,
|
||||
@@ -54,6 +62,7 @@ class PendingPaymentResponse(BaseModel):
|
||||
user_id: int | None = None
|
||||
user_telegram_id: int | None = None
|
||||
user_username: str | None = None
|
||||
user_email: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -87,6 +96,16 @@ class PaymentsStatsResponse(BaseModel):
|
||||
by_method: dict
|
||||
|
||||
|
||||
class SearchStatsResponse(BaseModel):
|
||||
"""Statistics for payment search results."""
|
||||
|
||||
total: int
|
||||
pending: int
|
||||
paid: int
|
||||
cancelled: int
|
||||
by_method: dict
|
||||
|
||||
|
||||
# ============ Helper functions ============
|
||||
|
||||
|
||||
@@ -241,6 +260,8 @@ def _get_payment_url(record: PendingPayment) -> str | None:
|
||||
elif record.method == PaymentMethod.CLOUDPAYMENTS or record.method == PaymentMethod.FREEKASSA:
|
||||
payment_url = getattr(payment, 'payment_url', None) or payment_url
|
||||
|
||||
if payment_url and not payment_url.startswith(('https://', 'http://')):
|
||||
return None
|
||||
return payment_url
|
||||
|
||||
|
||||
@@ -265,6 +286,7 @@ def _record_to_response(record: PendingPayment) -> PendingPaymentResponse:
|
||||
user_id=record.user.id if record.user else None,
|
||||
user_telegram_id=record.user.telegram_id if record.user else None,
|
||||
user_username=record.user.username if record.user else None,
|
||||
user_email=record.user.email if record.user else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -329,6 +351,140 @@ async def get_payments_stats(
|
||||
)
|
||||
|
||||
|
||||
@router.get('/search', response_model=PendingPaymentListResponse)
|
||||
async def search_payments_endpoint(
|
||||
search: str | None = Query(
|
||||
None, max_length=256, description='Search query (invoice, @username, telegram_id, email)'
|
||||
),
|
||||
status_filter: str = Query('all', description='Status filter: all, pending, paid, cancelled'),
|
||||
method_filter: str | None = Query(None, description='Filter by payment method'),
|
||||
period: str = Query('24h', description='Period preset: 24h, 7d, 30d, all'),
|
||||
date_from: datetime | None = Query(None, description='Custom range start (ISO 8601)'),
|
||||
date_to: datetime | None = Query(None, description='Custom range end (ISO 8601)'),
|
||||
page: int = Query(1, ge=1, description='Page number'),
|
||||
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
|
||||
admin: User = Depends(require_permission('payments:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Search payments across all providers with filters."""
|
||||
try:
|
||||
parsed_status = StatusFilter(status_filter)
|
||||
except ValueError:
|
||||
parsed_status = StatusFilter.ALL
|
||||
|
||||
try:
|
||||
parsed_period = PeriodPreset(period)
|
||||
except ValueError:
|
||||
parsed_period = PeriodPreset.H24
|
||||
|
||||
parsed_method: PaymentMethod | None = None
|
||||
if method_filter:
|
||||
try:
|
||||
parsed_method = PaymentMethod(method_filter)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Ensure custom dates are timezone-aware
|
||||
if date_from is not None and date_from.tzinfo is None:
|
||||
date_from = date_from.replace(tzinfo=UTC)
|
||||
if date_to is not None and date_to.tzinfo is None:
|
||||
date_to = date_to.replace(tzinfo=UTC)
|
||||
|
||||
# Clamp custom dates to safety limit
|
||||
min_allowed = datetime.now(UTC) - timedelta(days=MAX_ALL_TIME_DAYS)
|
||||
if date_from is not None and date_from < min_allowed:
|
||||
date_from = min_allowed
|
||||
if date_from is not None and date_to is not None and date_from > date_to:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='date_from must be before date_to')
|
||||
|
||||
params = SearchParams(
|
||||
search=search.strip() if search else None,
|
||||
status_filter=parsed_status,
|
||||
method_filter=parsed_method,
|
||||
period=parsed_period,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
)
|
||||
|
||||
page_items, total = await search_payments(db, params)
|
||||
pages = math.ceil(total / per_page) if total > 0 else 1
|
||||
items = [_record_to_response(p) for p in page_items]
|
||||
|
||||
return PendingPaymentListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/search/stats', response_model=SearchStatsResponse)
|
||||
async def search_payments_stats_endpoint(
|
||||
search: str | None = Query(
|
||||
None, max_length=256, description='Search query (invoice, @username, telegram_id, email)'
|
||||
),
|
||||
status_filter: str = Query('all', description='Status filter: all, pending, paid, cancelled'),
|
||||
method_filter: str | None = Query(None, description='Filter by payment method'),
|
||||
period: str = Query('24h', description='Period preset: 24h, 7d, 30d, all'),
|
||||
date_from: datetime | None = Query(None, description='Custom range start (ISO 8601)'),
|
||||
date_to: datetime | None = Query(None, description='Custom range end (ISO 8601)'),
|
||||
admin: User = Depends(require_permission('payments:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get aggregated statistics for payment search results."""
|
||||
try:
|
||||
parsed_status = StatusFilter(status_filter)
|
||||
except ValueError:
|
||||
parsed_status = StatusFilter.ALL
|
||||
|
||||
try:
|
||||
parsed_period = PeriodPreset(period)
|
||||
except ValueError:
|
||||
parsed_period = PeriodPreset.H24
|
||||
|
||||
parsed_method: PaymentMethod | None = None
|
||||
if method_filter:
|
||||
try:
|
||||
parsed_method = PaymentMethod(method_filter)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Ensure custom dates are timezone-aware
|
||||
if date_from is not None and date_from.tzinfo is None:
|
||||
date_from = date_from.replace(tzinfo=UTC)
|
||||
if date_to is not None and date_to.tzinfo is None:
|
||||
date_to = date_to.replace(tzinfo=UTC)
|
||||
|
||||
# Clamp custom dates to safety limit
|
||||
min_allowed = datetime.now(UTC) - timedelta(days=MAX_ALL_TIME_DAYS)
|
||||
if date_from is not None and date_from < min_allowed:
|
||||
date_from = min_allowed
|
||||
if date_from is not None and date_to is not None and date_from > date_to:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='date_from must be before date_to')
|
||||
|
||||
params = SearchParams(
|
||||
search=search.strip() if search else None,
|
||||
status_filter=parsed_status,
|
||||
method_filter=parsed_method,
|
||||
period=parsed_period,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
|
||||
stats = await search_payments_stats(db, params)
|
||||
|
||||
return SearchStatsResponse(
|
||||
total=stats.total,
|
||||
pending=stats.pending,
|
||||
paid=stats.paid,
|
||||
cancelled=stats.cancelled,
|
||||
by_method=stats.by_method or {},
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{method}/{payment_id}', response_model=PendingPaymentResponse)
|
||||
async def get_pending_payment_details(
|
||||
method: str,
|
||||
@@ -342,7 +498,7 @@ async def get_pending_payment_details(
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid payment method: {method}',
|
||||
detail='Invalid payment method',
|
||||
)
|
||||
|
||||
record = await get_payment_record(db, payment_method, payment_id)
|
||||
@@ -369,7 +525,7 @@ async def check_payment_status(
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid payment method: {method}',
|
||||
detail='Invalid payment method',
|
||||
)
|
||||
|
||||
# Get current record
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.transaction import REAL_PAYMENT_METHODS
|
||||
from app.database.models import (
|
||||
PaymentMethod,
|
||||
Subscription,
|
||||
SubscriptionConversion,
|
||||
SubscriptionStatus,
|
||||
@@ -87,6 +88,7 @@ class SalesSummary(BaseModel):
|
||||
"""Summary stats for the top cards."""
|
||||
|
||||
total_revenue_kopeks: int
|
||||
manual_topup_kopeks: int
|
||||
active_subscriptions: int
|
||||
active_trials: int
|
||||
new_trials: int
|
||||
@@ -124,6 +126,20 @@ async def get_sales_summary(
|
||||
)
|
||||
total_revenue = revenue_result.scalar() or 0
|
||||
|
||||
# Manual top-ups by admins
|
||||
manual_topup_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.payment_method == PaymentMethod.MANUAL.value,
|
||||
Transaction.created_at >= period_start,
|
||||
Transaction.created_at <= period_end,
|
||||
)
|
||||
)
|
||||
)
|
||||
manual_topup = manual_topup_result.scalar() or 0
|
||||
|
||||
# Consolidated subscription counts: active paid, active trial, new trials in period
|
||||
sub_counts_result = await db.execute(
|
||||
select(
|
||||
@@ -170,6 +186,7 @@ async def get_sales_summary(
|
||||
new_trials = row.new_trials or 0
|
||||
|
||||
# Trial-to-paid conversion in period
|
||||
# Method 1: SubscriptionConversion records (only created by some purchase flows)
|
||||
conversions_result = await db.execute(
|
||||
select(func.count(SubscriptionConversion.id)).where(
|
||||
and_(
|
||||
@@ -178,9 +195,29 @@ async def get_sales_summary(
|
||||
)
|
||||
)
|
||||
)
|
||||
conversions = conversions_result.scalar() or 0
|
||||
# Cap at 100%: conversions from previous periods can exceed current new_trials
|
||||
conversion_rate = min(round((conversions / new_trials * 100), 1), 100.0) if new_trials > 0 else 0.0
|
||||
conversion_records = conversions_result.scalar() or 0
|
||||
|
||||
# Method 2: Users registered in period who have paid (catches all purchase flows)
|
||||
converted_users_result = await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
and_(
|
||||
User.created_at >= period_start,
|
||||
User.created_at <= period_end,
|
||||
User.has_had_paid_subscription.is_(True),
|
||||
)
|
||||
)
|
||||
)
|
||||
converted_users = converted_users_result.scalar() or 0
|
||||
|
||||
# Use the higher count to catch conversions from all purchase flows
|
||||
conversions = max(conversion_records, converted_users)
|
||||
|
||||
# new_trials only counts REMAINING trials (is_trial=True), but converted users
|
||||
# had is_trial flipped to False. Add conversions back to get total trial starters.
|
||||
total_trial_starters = new_trials + conversions
|
||||
conversion_rate = (
|
||||
min(round((conversions / total_trial_starters * 100), 1), 100.0) if total_trial_starters > 0 else 0.0
|
||||
)
|
||||
|
||||
# Renewals count
|
||||
renewals_subquery = (
|
||||
@@ -222,7 +259,8 @@ async def get_sales_summary(
|
||||
addon_revenue = abs(addon_revenue_result.scalar() or 0)
|
||||
|
||||
return SalesSummary(
|
||||
total_revenue_kopeks=total_revenue,
|
||||
total_revenue_kopeks=total_revenue + manual_topup,
|
||||
manual_topup_kopeks=manual_topup,
|
||||
active_subscriptions=active_subs,
|
||||
active_trials=active_trials,
|
||||
new_trials=new_trials,
|
||||
@@ -290,6 +328,7 @@ async def get_trials_stats(
|
||||
)
|
||||
total_trials = total_result.scalar() or 0
|
||||
|
||||
# Conversion: SubscriptionConversion records + fallback to has_had_paid_subscription
|
||||
conversions_result = await db.execute(
|
||||
select(func.count(SubscriptionConversion.id)).where(
|
||||
and_(
|
||||
@@ -298,9 +337,25 @@ async def get_trials_stats(
|
||||
)
|
||||
)
|
||||
)
|
||||
conversions = conversions_result.scalar() or 0
|
||||
# Cap at 100%: conversions from previous periods can exceed current period trials
|
||||
conversion_rate = min(round((conversions / total_trials * 100), 1), 100.0) if total_trials > 0 else 0.0
|
||||
conversion_records = conversions_result.scalar() or 0
|
||||
|
||||
converted_users_result = await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
and_(
|
||||
User.created_at >= period_start,
|
||||
User.created_at <= period_end,
|
||||
User.has_had_paid_subscription.is_(True),
|
||||
)
|
||||
)
|
||||
)
|
||||
converted_users = converted_users_result.scalar() or 0
|
||||
conversions = max(conversion_records, converted_users)
|
||||
|
||||
# total_trials only counts remaining is_trial=True; add conversions for total starters
|
||||
total_trial_starters = total_trials + conversions
|
||||
conversion_rate = (
|
||||
min(round((conversions / total_trial_starters * 100), 1), 100.0) if total_trial_starters > 0 else 0.0
|
||||
)
|
||||
|
||||
avg_duration_result = await db.execute(
|
||||
select(func.avg(SubscriptionConversion.trial_duration_days)).where(
|
||||
@@ -1022,10 +1077,11 @@ async def get_deposits_stats(
|
||||
try:
|
||||
period_start, period_end = _parse_period(days, start_date, end_date)
|
||||
|
||||
methods_with_manual = [*REAL_PAYMENT_METHODS, PaymentMethod.MANUAL.value]
|
||||
base_filter = and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
Transaction.payment_method.in_(methods_with_manual),
|
||||
Transaction.created_at >= period_start,
|
||||
Transaction.created_at <= period_end,
|
||||
)
|
||||
@@ -1076,7 +1132,7 @@ async def get_deposits_stats(
|
||||
]
|
||||
|
||||
# Daily deposits grouped by payment method
|
||||
# base_filter already excludes NULLs via .in_(REAL_PAYMENT_METHODS), no coalesce needed
|
||||
# base_filter already excludes NULLs via .in_(methods_with_manual), no coalesce needed
|
||||
daily_by_method_query = await db.execute(
|
||||
select(
|
||||
func.date(Transaction.created_at).label('date'),
|
||||
|
||||
@@ -318,11 +318,10 @@ async def _sync_subscription_to_panel(
|
||||
if hwid_limit is not None:
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
# Внешний сквад: синхронизируем из тарифа или сбрасываем
|
||||
# Внешний сквад: синхронизируем из тарифа (если задан)
|
||||
# Не отправляем null — RemnaWave API не принимает null для externalSquadUuid (A039)
|
||||
if ext_squad_uuid is not None:
|
||||
update_kwargs['external_squad_uuid'] = ext_squad_uuid
|
||||
else:
|
||||
update_kwargs['external_squad_uuid'] = None
|
||||
|
||||
try:
|
||||
updated_panel_user = await api.update_user(**update_kwargs)
|
||||
@@ -2777,11 +2776,10 @@ async def sync_user_to_panel(
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
changes['device_limit'] = hwid_limit
|
||||
|
||||
# Внешний сквад: синхронизируем из тарифа или сбрасываем
|
||||
# Внешний сквад: синхронизируем из тарифа (если задан)
|
||||
# Не отправляем null — RemnaWave API не принимает null для externalSquadUuid (A039)
|
||||
if ext_squad_uuid is not None:
|
||||
update_kwargs['external_squad_uuid'] = ext_squad_uuid
|
||||
else:
|
||||
update_kwargs['external_squad_uuid'] = None
|
||||
|
||||
try:
|
||||
await api.update_user(**update_kwargs)
|
||||
|
||||
+145
-14
@@ -6,7 +6,7 @@ from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -28,10 +28,16 @@ from app.database.crud.user import (
|
||||
set_email_change_pending,
|
||||
verify_and_apply_email_change,
|
||||
)
|
||||
from app.database.models import CabinetRefreshToken, User
|
||||
from app.database.models import CabinetRefreshToken, User, UserStatus
|
||||
from app.services.campaign_service import AdvertisingCampaignService
|
||||
from app.services.disposable_email_service import disposable_email_service
|
||||
from app.services.referral_service import process_referral_registration
|
||||
from app.services.web_auth_service import (
|
||||
WEB_AUTH_TOKEN_TTL,
|
||||
consume_web_auth_token,
|
||||
create_web_auth_token,
|
||||
poll_web_auth_token,
|
||||
)
|
||||
from app.utils.cache import RateLimitCache, TokenReplayCache
|
||||
from app.utils.timezone import panel_datetime_to_utc
|
||||
|
||||
@@ -61,6 +67,8 @@ from ..schemas.auth import (
|
||||
AuthResponse,
|
||||
AutoLoginRequest,
|
||||
CampaignBonusInfo,
|
||||
DeepLinkPollRequest,
|
||||
DeepLinkTokenResponse,
|
||||
EmailChangeRequest,
|
||||
EmailChangeResponse,
|
||||
EmailChangeVerifyRequest,
|
||||
@@ -461,7 +469,7 @@ async def auth_telegram(
|
||||
if updated:
|
||||
logger.info('User profile updated from initData', user_id=user.id)
|
||||
|
||||
if user.status != 'active':
|
||||
if user.status != UserStatus.ACTIVE.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='User account is not active',
|
||||
@@ -544,7 +552,7 @@ async def auth_telegram_widget(
|
||||
)
|
||||
logger.info('User created successfully: id=, telegram_id', user_id=user.id, telegram_id=user.telegram_id)
|
||||
|
||||
if user.status != 'active':
|
||||
if user.status != UserStatus.ACTIVE.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='User account is not active',
|
||||
@@ -673,7 +681,7 @@ async def auth_telegram_oidc(
|
||||
)
|
||||
logger.info('User created successfully', user_id=user.id, telegram_id=user.telegram_id)
|
||||
|
||||
if user.status != 'active':
|
||||
if user.status != UserStatus.ACTIVE.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='User account is not active',
|
||||
@@ -721,8 +729,9 @@ async def register_email(
|
||||
detail='Disposable email addresses are not allowed',
|
||||
)
|
||||
|
||||
# Check if email already exists
|
||||
existing_user = await db.execute(select(User).where(User.email == request.email))
|
||||
# Check if email already exists (case-insensitive)
|
||||
email_lower = (request.email or '').strip().lower()
|
||||
existing_user = await db.execute(select(User).where(func.lower(User.email) == email_lower))
|
||||
if existing_user.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -837,8 +846,9 @@ async def register_email_standalone(
|
||||
detail='Disposable email addresses are not allowed',
|
||||
)
|
||||
|
||||
# Проверить что email не занят
|
||||
existing = await db.execute(select(User).where(User.email == request.email))
|
||||
# Проверить что email не занят (без учёта регистра)
|
||||
email_lower = (request.email or '').strip().lower()
|
||||
existing = await db.execute(select(User).where(func.lower(User.email) == email_lower))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -1096,8 +1106,9 @@ async def login_email(
|
||||
# Check if this is a test email login
|
||||
is_test_email = settings.is_test_email(request.email)
|
||||
|
||||
# Find user by email
|
||||
result = await db.execute(select(User).where(User.email == request.email))
|
||||
# Find user by email (case-insensitive)
|
||||
email_lower = (request.email or '').strip().lower()
|
||||
result = await db.execute(select(User).where(func.lower(User.email) == email_lower))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
@@ -1140,7 +1151,7 @@ async def login_email(
|
||||
detail='Please verify your email first',
|
||||
)
|
||||
|
||||
if user.status != 'active':
|
||||
if user.status != UserStatus.ACTIVE.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='User account is not active',
|
||||
@@ -1289,7 +1300,7 @@ async def auto_login(
|
||||
detail='User not found',
|
||||
)
|
||||
|
||||
if user.status != 'active':
|
||||
if user.status != UserStatus.ACTIVE.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Account is deactivated',
|
||||
@@ -1317,7 +1328,8 @@ async def forgot_password(
|
||||
detail='Too many requests',
|
||||
headers={'Retry-After': '60'},
|
||||
)
|
||||
result = await db.execute(select(User).where(User.email == request.email))
|
||||
email_lower = (request.email or '').strip().lower()
|
||||
result = await db.execute(select(User).where(func.lower(User.email) == email_lower))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
# Always return success to prevent email enumeration
|
||||
@@ -1670,3 +1682,122 @@ async def get_email_change_status(
|
||||
'new_email': user.email_change_new,
|
||||
'expires_at': user.email_change_expires.isoformat() if user.email_change_expires else None,
|
||||
}
|
||||
|
||||
|
||||
# --- Deep link auth (fallback when oauth.telegram.org is blocked) ---
|
||||
|
||||
|
||||
@router.post('/deeplink/request', response_model=DeepLinkTokenResponse)
|
||||
async def request_deep_link_token(
|
||||
raw_request: Request,
|
||||
):
|
||||
"""Generate a one-time deep link auth token.
|
||||
|
||||
Frontend shows t.me/{bot}?start=webauth_{token} to the user.
|
||||
No auth required (user is not logged in yet).
|
||||
"""
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'deeplink_request', limit=10, window=60, fail_closed=True):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail='Too many requests',
|
||||
headers={'Retry-After': '60'},
|
||||
)
|
||||
|
||||
try:
|
||||
token = await create_web_auth_token()
|
||||
except RuntimeError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail='Service temporarily unavailable',
|
||||
)
|
||||
|
||||
bot_username = settings.get_bot_username()
|
||||
if not bot_username:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail='Bot not configured',
|
||||
)
|
||||
|
||||
return DeepLinkTokenResponse(
|
||||
token=token,
|
||||
bot_username=bot_username,
|
||||
expires_in=WEB_AUTH_TOKEN_TTL,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/deeplink/poll', response_model=AuthResponse)
|
||||
async def poll_deep_link_token(
|
||||
request: DeepLinkPollRequest,
|
||||
raw_request: Request,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Poll for deep link auth completion.
|
||||
|
||||
Returns 202 if still pending, AuthResponse if completed, 410 if expired.
|
||||
"""
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'deeplink_poll', limit=60, window=60, fail_closed=True):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail='Too many requests',
|
||||
headers={'Retry-After': '60'},
|
||||
)
|
||||
|
||||
data = await poll_web_auth_token(request.token)
|
||||
|
||||
if data is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_410_GONE,
|
||||
detail='Token expired or not found',
|
||||
)
|
||||
|
||||
if data.get('status') == 'pending':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
detail='Waiting for confirmation',
|
||||
)
|
||||
|
||||
if data.get('status') != 'linked':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_410_GONE,
|
||||
detail='Invalid token state',
|
||||
)
|
||||
|
||||
# Token is linked - consume it atomically
|
||||
consumed = await consume_web_auth_token(request.token)
|
||||
if not consumed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_410_GONE,
|
||||
detail='Token already consumed',
|
||||
)
|
||||
|
||||
user_id = consumed.get('user_id')
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Invalid token data',
|
||||
)
|
||||
|
||||
user = await get_user_by_id(db, int(user_id))
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='User not found',
|
||||
)
|
||||
|
||||
if user.status != UserStatus.ACTIVE.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Account is deactivated',
|
||||
)
|
||||
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, response.refresh_token, device_info='deep_link')
|
||||
|
||||
logger.info('Deep link auth successful', user_id=user.id, telegram_id=user.telegram_id)
|
||||
|
||||
return response
|
||||
|
||||
@@ -701,6 +701,11 @@ async def create_topup(
|
||||
detail='KassaAI payment method is unavailable',
|
||||
)
|
||||
|
||||
# Use payment_option to select sbp or card
|
||||
KASSA_AI_OPTION_MAP = {'sbp': 44, 'card': 36}
|
||||
option = (request.payment_option or '').strip().lower()
|
||||
ps_id = KASSA_AI_OPTION_MAP.get(option) # None = use env default
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_kassa_ai_payment(
|
||||
db=db,
|
||||
@@ -709,6 +714,7 @@ async def create_topup(
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_system_id=ps_id,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
@@ -720,6 +726,33 @@ async def create_topup(
|
||||
detail='Failed to create KassaAI payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'riopay':
|
||||
if not settings.is_riopay_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='RioPay payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_riopay_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
success_url=cabinet_success_url,
|
||||
fail_url=cabinet_failed_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('riopay_order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create RioPay payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'tribute':
|
||||
if not settings.TRIBUTE_ENABLED or not settings.TRIBUTE_DONATE_LINK:
|
||||
raise HTTPException(
|
||||
@@ -871,6 +904,17 @@ def _get_status_info(record: PendingPayment) -> tuple[str, str]:
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.RIOPAY:
|
||||
mapping = {
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'failed': ('❌', 'Ошибка'),
|
||||
'canceled': ('❌', 'Отменено'),
|
||||
'expired': ('⌛', 'Истёк'),
|
||||
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
return '❓', 'Неизвестно'
|
||||
|
||||
|
||||
@@ -901,6 +945,8 @@ def _is_checkable(record: PendingPayment) -> bool:
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
if record.method == PaymentMethod.KASSA_AI:
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
if record.method == PaymentMethod.RIOPAY:
|
||||
return status in {'pending'}
|
||||
return False
|
||||
|
||||
|
||||
@@ -924,7 +970,12 @@ def _get_payment_url(record: PendingPayment) -> str | None:
|
||||
)
|
||||
elif record.method == PaymentMethod.PLATEGA:
|
||||
payment_url = getattr(payment, 'redirect_url', None) or payment_url
|
||||
elif record.method in (PaymentMethod.CLOUDPAYMENTS, PaymentMethod.FREEKASSA, PaymentMethod.KASSA_AI):
|
||||
elif record.method in (
|
||||
PaymentMethod.CLOUDPAYMENTS,
|
||||
PaymentMethod.FREEKASSA,
|
||||
PaymentMethod.KASSA_AI,
|
||||
PaymentMethod.RIOPAY,
|
||||
):
|
||||
payment_url = getattr(payment, 'payment_url', None) or payment_url
|
||||
|
||||
return payment_url
|
||||
@@ -1013,6 +1064,7 @@ async def get_latest_payment_by_method(
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PlategaPayment,
|
||||
RioPayPayment,
|
||||
WataPayment,
|
||||
YooKassaPayment,
|
||||
)
|
||||
@@ -1028,6 +1080,7 @@ async def get_latest_payment_by_method(
|
||||
PaymentMethod.CLOUDPAYMENTS: CloudPaymentsPayment,
|
||||
PaymentMethod.FREEKASSA: FreekassaPayment,
|
||||
PaymentMethod.KASSA_AI: KassaAiPayment,
|
||||
PaymentMethod.RIOPAY: RioPayPayment,
|
||||
}
|
||||
|
||||
model = model_map.get(payment_method)
|
||||
|
||||
@@ -244,6 +244,7 @@ class EmailAuthEnabledResponse(BaseModel):
|
||||
"""Email auth enabled setting."""
|
||||
|
||||
enabled: bool = True
|
||||
verification_enabled: bool = True
|
||||
|
||||
|
||||
class EmailAuthEnabledUpdate(BaseModel):
|
||||
@@ -838,10 +839,16 @@ async def get_email_auth_enabled(
|
||||
|
||||
if email_auth_value is not None:
|
||||
enabled = email_auth_value.lower() == 'true'
|
||||
return EmailAuthEnabledResponse(enabled=enabled)
|
||||
return EmailAuthEnabledResponse(
|
||||
enabled=enabled,
|
||||
verification_enabled=settings.is_cabinet_email_verification_enabled(),
|
||||
)
|
||||
|
||||
# Default: check config setting
|
||||
return EmailAuthEnabledResponse(enabled=settings.is_cabinet_email_auth_enabled())
|
||||
return EmailAuthEnabledResponse(
|
||||
enabled=settings.is_cabinet_email_auth_enabled(),
|
||||
verification_enabled=settings.is_cabinet_email_verification_enabled(),
|
||||
)
|
||||
|
||||
|
||||
@router.patch('/email-auth', response_model=EmailAuthEnabledResponse)
|
||||
@@ -855,7 +862,10 @@ async def update_email_auth_enabled(
|
||||
|
||||
logger.info('Admin set email auth enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
|
||||
|
||||
return EmailAuthEnabledResponse(enabled=payload.enabled)
|
||||
return EmailAuthEnabledResponse(
|
||||
enabled=payload.enabled,
|
||||
verification_enabled=settings.is_cabinet_email_verification_enabled(),
|
||||
)
|
||||
|
||||
|
||||
# ============ Telegram Widget Config Routes ============
|
||||
|
||||
+22
-36
@@ -22,7 +22,6 @@ from app.database.models import (
|
||||
Tariff,
|
||||
TransactionType,
|
||||
User,
|
||||
UserPromoGroup,
|
||||
)
|
||||
from app.services.guest_purchase_service import (
|
||||
GuestPurchaseError,
|
||||
@@ -112,15 +111,17 @@ async def get_gift_config(
|
||||
price = base_price
|
||||
|
||||
# Apply promo group discount
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
promo_group_discount = 0
|
||||
if promo_group:
|
||||
promo_group_discount = promo_group.get_discount_percent('period', days)
|
||||
if promo_group_discount > 0:
|
||||
price = int(price * (100 - promo_group_discount) / 100)
|
||||
price = PricingEngine.apply_discount(price, promo_group_discount)
|
||||
|
||||
# Apply active promo offer discount (stacks on top)
|
||||
if promo_offer_discount_percent > 0:
|
||||
price = price - price * promo_offer_discount_percent // 100
|
||||
price = PricingEngine.apply_discount(price, promo_offer_discount_percent)
|
||||
|
||||
# Ensure minimum price of 1 kopek after all discounts
|
||||
price = max(1, price)
|
||||
@@ -249,43 +250,28 @@ async def create_gift_purchase(
|
||||
detail='Tariff not found or inactive',
|
||||
)
|
||||
|
||||
price_kopeks = tariff.get_price_for_period(body.period_days)
|
||||
if price_kopeks is None:
|
||||
# Validate that period has a configured price before locking
|
||||
if tariff.get_price_for_period(body.period_days) is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Price is not configured for this period',
|
||||
)
|
||||
|
||||
# Lock user row to prevent concurrent promo offer double-spend
|
||||
locked_result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.id == user.id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
# Lock user BEFORE price computation to prevent TOCTOU on promo offer
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
pricing_result = await pricing_engine.calculate_tariff_purchase_price(
|
||||
tariff,
|
||||
body.period_days,
|
||||
device_limit=tariff.device_limit,
|
||||
user=user,
|
||||
)
|
||||
user = locked_result.scalar_one()
|
||||
|
||||
# Apply promo group discount
|
||||
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
|
||||
if promo_group is None:
|
||||
promo_group = getattr(user, 'promo_group', None)
|
||||
|
||||
if promo_group:
|
||||
discount_percent = promo_group.get_discount_percent('period', body.period_days)
|
||||
if discount_percent > 0:
|
||||
price_kopeks = int(price_kopeks * (100 - discount_percent) / 100)
|
||||
|
||||
# Apply active promo offer discount (stacks)
|
||||
promo_offer_discount_percent = get_user_active_promo_discount_percent(user)
|
||||
if promo_offer_discount_percent > 0:
|
||||
price_kopeks = price_kopeks - price_kopeks * promo_offer_discount_percent // 100
|
||||
|
||||
# Ensure minimum price of 1 kopek after all discounts
|
||||
price_kopeks = max(1, price_kopeks)
|
||||
price_kopeks = max(1, pricing_result.final_total)
|
||||
consume_promo = pricing_result.promo_offer_discount > 0
|
||||
|
||||
# Determine buyer contact info
|
||||
if user.email:
|
||||
@@ -420,7 +406,7 @@ async def create_gift_purchase(
|
||||
)
|
||||
|
||||
# Consume promo offer discount before committing gateway purchase
|
||||
if promo_offer_discount_percent > 0 and getattr(user, 'promo_offer_discount_percent', 0):
|
||||
if consume_promo and getattr(user, 'promo_offer_discount_percent', 0):
|
||||
user.promo_offer_discount_percent = 0
|
||||
user.promo_offer_discount_source = None
|
||||
user.promo_offer_discount_expires_at = None
|
||||
@@ -485,7 +471,7 @@ async def create_gift_purchase(
|
||||
price_kopeks,
|
||||
description=f'Gift: {tariff.name} ({body.period_days}d)',
|
||||
create_transaction=False,
|
||||
consume_promo_offer=promo_offer_discount_percent > 0,
|
||||
consume_promo_offer=consume_promo,
|
||||
)
|
||||
if not balance_ok:
|
||||
await db.rollback()
|
||||
|
||||
@@ -91,7 +91,7 @@ class SupportConfigResponse(BaseModel):
|
||||
"""Support/tickets configuration for miniapp."""
|
||||
|
||||
tickets_enabled: bool
|
||||
support_type: str # "tickets", "profile", "url"
|
||||
support_type: str # "tickets", "profile", "url", "both"
|
||||
support_url: str | None = None
|
||||
support_username: str | None = None
|
||||
|
||||
@@ -299,7 +299,7 @@ async def get_support_config():
|
||||
support_type = 'profile'
|
||||
else: # both
|
||||
tickets_enabled = True
|
||||
support_type = 'tickets'
|
||||
support_type = 'both'
|
||||
|
||||
return SupportConfigResponse(
|
||||
tickets_enabled=tickets_enabled,
|
||||
|
||||
@@ -342,7 +342,9 @@ async def _load_landing_tariffs(
|
||||
effective_discount = tariff_override if tariff_override is not None else discount.percent
|
||||
original_price_kopeks = price
|
||||
original_price_label = settings.format_price(price)
|
||||
price = max(1, price - (price * effective_discount // 100))
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
price = max(1, PricingEngine.apply_discount(price, effective_discount))
|
||||
|
||||
periods.append(
|
||||
LandingTariffPeriod(
|
||||
|
||||
@@ -91,12 +91,20 @@ async def get_referral_info(
|
||||
referral_entitlement = max(0, total_earnings - withdrawn - pending)
|
||||
available_balance = min(user.balance_kopeks, referral_entitlement)
|
||||
|
||||
# Build referral link
|
||||
# Build referral links
|
||||
referral_link = settings.get_referral_link(user.referral_code) if user.referral_code else ''
|
||||
bot_username = settings.get_bot_username()
|
||||
bot_referral_link = ''
|
||||
if user.referral_code and bot_username:
|
||||
from urllib.parse import quote
|
||||
|
||||
safe_code = quote(user.referral_code, safe='')
|
||||
bot_referral_link = f'https://t.me/{bot_username}?start={safe_code}'
|
||||
|
||||
return ReferralInfoResponse(
|
||||
referral_code=user.referral_code or '',
|
||||
referral_link=referral_link,
|
||||
bot_referral_link=bot_referral_link,
|
||||
total_referrals=total_referrals,
|
||||
active_referrals=active_referrals,
|
||||
total_earnings_kopeks=total_earnings,
|
||||
|
||||
+222
-338
@@ -42,7 +42,6 @@ from app.services.system_settings_service import bot_configuration_service
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from app.utils.cache import RateLimitCache, cache, cache_key
|
||||
from app.utils.pricing_utils import format_period_description
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.subscription import (
|
||||
@@ -68,29 +67,14 @@ router = APIRouter(prefix='/subscription', tags=['Cabinet Subscription'])
|
||||
|
||||
|
||||
def _get_addon_discount_percent(
|
||||
user: User,
|
||||
user: User | None,
|
||||
category: str,
|
||||
period_days: int | None = None,
|
||||
period_days_hint: int | None = None,
|
||||
) -> int:
|
||||
"""Get addon discount percent for user from promo group.
|
||||
"""Get addon discount percent for user — delegates to PricingEngine."""
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
Mirrors logic from app/handlers/subscription/common.py:_get_addon_discount_percent_for_user
|
||||
"""
|
||||
promo_group = (
|
||||
user.get_primary_promo_group()
|
||||
if hasattr(user, 'get_primary_promo_group')
|
||||
else getattr(user, 'promo_group', None)
|
||||
)
|
||||
if promo_group is None:
|
||||
return 0
|
||||
|
||||
if not getattr(promo_group, 'apply_discounts_to_addons', True):
|
||||
return 0
|
||||
|
||||
try:
|
||||
return user.get_promo_discount(category, period_days)
|
||||
except AttributeError:
|
||||
return 0
|
||||
return PricingEngine.get_addon_discount_percent(user, category, period_days_hint)
|
||||
|
||||
|
||||
def _apply_addon_discount(
|
||||
@@ -117,27 +101,12 @@ def _apply_addon_discount(
|
||||
}
|
||||
|
||||
|
||||
def _get_period_discount_percent(user: User, period_days: int | None = None) -> int:
|
||||
"""Get period discount percent for tariff switch calculations."""
|
||||
promo_group = (
|
||||
user.get_primary_promo_group()
|
||||
if hasattr(user, 'get_primary_promo_group')
|
||||
else getattr(user, 'promo_group', None)
|
||||
)
|
||||
if promo_group is None:
|
||||
return 0
|
||||
|
||||
try:
|
||||
return user.get_promo_discount('period', period_days)
|
||||
except AttributeError:
|
||||
return 0
|
||||
|
||||
|
||||
def _subscription_to_response(
|
||||
subscription: Subscription,
|
||||
servers: list[ServerInfo] | None = None,
|
||||
tariff_name: str | None = None,
|
||||
traffic_purchases: list[dict[str, Any]] | None = None,
|
||||
user: User | None = None,
|
||||
) -> SubscriptionData:
|
||||
"""Convert Subscription model to response."""
|
||||
now = datetime.now(UTC)
|
||||
@@ -200,6 +169,18 @@ def _subscription_to_response(
|
||||
traffic_reset_mode = None
|
||||
if tariff_id and hasattr(subscription, 'tariff') and subscription.tariff:
|
||||
daily_price_kopeks = getattr(subscription.tariff, 'daily_price_kopeks', None)
|
||||
# Применяем скидку промогруппы + promo-offer для отображения
|
||||
if daily_price_kopeks and daily_price_kopeks > 0 and user:
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
_promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
|
||||
_group_pct = _promo_group.get_discount_percent('period', 1) if _promo_group else 0
|
||||
_offer_pct = get_user_active_promo_discount_percent(user)
|
||||
if _group_pct > 0 or _offer_pct > 0:
|
||||
daily_price_kopeks, _, _ = PricingEngine.apply_stacked_discounts(
|
||||
daily_price_kopeks, _group_pct, _offer_pct
|
||||
)
|
||||
if not tariff_name: # Only set if not passed as parameter
|
||||
tariff_name = getattr(subscription.tariff, 'name', None)
|
||||
traffic_reset_mode = (
|
||||
@@ -321,7 +302,9 @@ async def get_subscription(
|
||||
}
|
||||
)
|
||||
|
||||
subscription_data = _subscription_to_response(fresh_user.subscription, servers, tariff_name, traffic_purchases_data)
|
||||
subscription_data = _subscription_to_response(
|
||||
fresh_user.subscription, servers, tariff_name, traffic_purchases_data, user=fresh_user
|
||||
)
|
||||
return SubscriptionStatusResponse(has_subscription=True, subscription=subscription_data)
|
||||
|
||||
|
||||
@@ -401,6 +384,11 @@ async def renew_subscription(
|
||||
detail='Selected renewal period is not available',
|
||||
)
|
||||
|
||||
# Lock user row to prevent TOCTOU on promo-offer state
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Unified pricing via PricingEngine
|
||||
pricing = await pricing_engine.calculate_renewal_price(
|
||||
db,
|
||||
@@ -724,6 +712,11 @@ async def purchase_traffic(
|
||||
subscription.end_date,
|
||||
)
|
||||
|
||||
# Lock user row to prevent TOCTOU on promo-offer state
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Apply discount from promo group using proper method
|
||||
period_hint_days = days_charged if days_charged > 0 else 30
|
||||
discount_result = _apply_addon_discount(user, 'traffic', prorated_price, period_hint_days)
|
||||
@@ -923,6 +916,11 @@ async def purchase_devices_legacy(
|
||||
|
||||
base_total_price = device_price * request.devices
|
||||
|
||||
# Lock user row to prevent TOCTOU on promo-offer state
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Apply discount from promo group
|
||||
discount_result = _apply_addon_discount(user, 'devices', base_total_price, 30)
|
||||
total_price = discount_result['discounted']
|
||||
@@ -1363,7 +1361,7 @@ async def activate_trial(
|
||||
except Exception as e:
|
||||
logger.error('Failed to send trial activation notification', error=e)
|
||||
|
||||
return _subscription_to_response(subscription)
|
||||
return _subscription_to_response(subscription, user=user)
|
||||
|
||||
|
||||
# ============ Full Purchase Flow (like MiniApp) ============
|
||||
@@ -1427,17 +1425,29 @@ async def _build_tariff_response(
|
||||
# Стоимость доп. устройств за этот период
|
||||
extra_devices_cost = extra_devices_count * extra_device_price_per_month * months
|
||||
|
||||
# Apply promo group discount for this period (на базовую цену тарифа)
|
||||
# Apply per-category promo group discounts
|
||||
original_price = base_tariff_price + extra_devices_cost
|
||||
discount_percent = 0
|
||||
discount_amount = 0
|
||||
final_price = original_price
|
||||
|
||||
if promo_group:
|
||||
discount_percent = promo_group.get_discount_percent('period', period_days)
|
||||
if discount_percent > 0:
|
||||
discount_amount = original_price * discount_percent // 100
|
||||
final_price = original_price - discount_amount
|
||||
period_pct = promo_group.get_discount_percent('period', period_days)
|
||||
devices_pct = promo_group.get_discount_percent('devices', period_days)
|
||||
discounted_base = (
|
||||
pricing_engine.apply_discount(base_tariff_price, period_pct)
|
||||
if period_pct > 0
|
||||
else base_tariff_price
|
||||
)
|
||||
discounted_devices = (
|
||||
pricing_engine.apply_discount(extra_devices_cost, devices_pct)
|
||||
if devices_pct > 0
|
||||
else extra_devices_cost
|
||||
)
|
||||
final_price = discounted_base + discounted_devices
|
||||
discount_amount = original_price - final_price
|
||||
discount_percent = max(period_pct, devices_pct)
|
||||
else:
|
||||
discount_percent = 0
|
||||
final_price = original_price
|
||||
|
||||
per_month = final_price // months if months > 0 else final_price
|
||||
original_per_month = original_price // months if months > 0 else original_price
|
||||
@@ -1474,16 +1484,21 @@ async def _build_tariff_response(
|
||||
|
||||
traffic_label = '♾️ Безлимит' if tariff.traffic_limit_gb == 0 else f'{tariff.traffic_limit_gb} ГБ'
|
||||
|
||||
# Apply discount to daily price if applicable
|
||||
# Apply discount to daily price if applicable (group + promo-offer)
|
||||
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
original_daily_price = daily_price
|
||||
daily_discount_percent = 0
|
||||
if promo_group and daily_price > 0:
|
||||
# For daily tariffs, use period discount with period_days=1
|
||||
daily_discount_percent = promo_group.get_discount_percent('period', 1)
|
||||
if daily_discount_percent > 0:
|
||||
discount_amount = daily_price * daily_discount_percent // 100
|
||||
daily_price = daily_price - discount_amount
|
||||
if daily_price > 0:
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
daily_group_pct = promo_group.get_discount_percent('period', 1) if promo_group else 0
|
||||
daily_offer_pct = get_user_active_promo_discount_percent(user) if user else 0
|
||||
if daily_group_pct > 0 or daily_offer_pct > 0:
|
||||
daily_price, _, _ = PricingEngine.apply_stacked_discounts(daily_price, daily_group_pct, daily_offer_pct)
|
||||
# Комбинированный процент для отображения
|
||||
remaining = (100 - daily_group_pct) * (100 - daily_offer_pct)
|
||||
daily_discount_percent = 100 - remaining // 100
|
||||
|
||||
# Apply discount to custom price_per_day if applicable
|
||||
price_per_day = tariff.price_per_day_kopeks
|
||||
@@ -1492,18 +1507,16 @@ async def _build_tariff_response(
|
||||
if promo_group and price_per_day > 0:
|
||||
custom_days_discount_percent = promo_group.get_discount_percent('period', 30) # Use 30-day rate as base
|
||||
if custom_days_discount_percent > 0:
|
||||
discount_amount = price_per_day * custom_days_discount_percent // 100
|
||||
price_per_day = price_per_day - discount_amount
|
||||
price_per_day = pricing_engine.apply_discount(price_per_day, custom_days_discount_percent)
|
||||
|
||||
# Apply discount to device price if applicable
|
||||
device_price = tariff.device_price_kopeks if tariff.device_price_kopeks is not None else 0
|
||||
original_device_price = device_price
|
||||
device_discount_percent = 0
|
||||
if promo_group and device_price > 0:
|
||||
device_discount_percent = promo_group.get_discount_percent('devices')
|
||||
device_discount_percent = promo_group.get_discount_percent('devices', 30)
|
||||
if device_discount_percent > 0:
|
||||
discount_amount = device_price * device_discount_percent // 100
|
||||
device_price = device_price - discount_amount
|
||||
device_price = pricing_engine.apply_discount(device_price, device_discount_percent)
|
||||
|
||||
# Показываем реальное количество устройств (с докупленными) для текущего тарифа
|
||||
actual_device_limit = tariff.device_limit
|
||||
@@ -1703,6 +1716,9 @@ async def submit_purchase(
|
||||
)
|
||||
|
||||
try:
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
context = await purchase_service.build_options(db, user)
|
||||
|
||||
# Convert request to dict for parsing
|
||||
@@ -1771,10 +1787,13 @@ async def submit_purchase(
|
||||
except Exception as e:
|
||||
logger.error('Failed to send admin notification for subscription purchase', error=e)
|
||||
|
||||
# Refresh expired objects after db.commit() in _record_subscription_event
|
||||
await db.refresh(subscription)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': result['message'],
|
||||
'subscription': _subscription_to_response(subscription),
|
||||
'subscription': _subscription_to_response(subscription, user=user),
|
||||
'was_trial_conversion': result.get('was_trial_conversion', False),
|
||||
}
|
||||
|
||||
@@ -1854,6 +1873,11 @@ async def purchase_tariff(
|
||||
detail='Tariff not found or inactive',
|
||||
)
|
||||
|
||||
# Lock user BEFORE price computation to prevent TOCTOU on promo offer
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Check tariff availability for user's promo group and get promo group for discounts
|
||||
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
|
||||
promo_group_id = promo_group.id if promo_group else None
|
||||
@@ -1865,105 +1889,43 @@ async def purchase_tariff(
|
||||
|
||||
# Handle daily tariffs specially
|
||||
is_daily_tariff = getattr(tariff, 'is_daily', False)
|
||||
discount_percent = 0
|
||||
original_price = 0
|
||||
|
||||
if is_daily_tariff:
|
||||
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
if daily_price <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Daily tariff has invalid price',
|
||||
)
|
||||
original_price = daily_price
|
||||
# Apply promo group discount for daily tariff
|
||||
if promo_group:
|
||||
discount_percent = promo_group.get_discount_percent('period', 1)
|
||||
if discount_percent > 0:
|
||||
discount_amount = daily_price * discount_percent // 100
|
||||
daily_price = daily_price - discount_amount
|
||||
# For daily tariffs, charge first day and set period to 1 day
|
||||
price_kopeks = daily_price
|
||||
period_days = 1
|
||||
else:
|
||||
period_days = request.period_days
|
||||
# Get price for period (support custom days)
|
||||
price_kopeks = tariff.get_price_for_period(period_days)
|
||||
if price_kopeks is None:
|
||||
# Check for custom days
|
||||
if tariff.can_purchase_custom_days():
|
||||
price_kopeks = tariff.get_price_for_custom_days(period_days)
|
||||
if price_kopeks is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Period must be between {tariff.min_days} and {tariff.max_days} days',
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid period for this tariff',
|
||||
)
|
||||
|
||||
original_price = price_kopeks
|
||||
# Apply promo group discount for period
|
||||
if promo_group and price_kopeks > 0:
|
||||
discount_percent = promo_group.get_discount_percent('period', period_days)
|
||||
if discount_percent > 0:
|
||||
discount_amount = price_kopeks * discount_percent // 100
|
||||
price_kopeks = price_kopeks - discount_amount
|
||||
|
||||
# Calculate traffic limit and price
|
||||
# Determine traffic limit (custom traffic support)
|
||||
traffic_limit_gb = tariff.traffic_limit_gb
|
||||
traffic_price_kopeks = 0
|
||||
custom_traffic_gb = None
|
||||
if request.traffic_gb is not None and tariff.can_purchase_custom_traffic():
|
||||
# Custom traffic requested
|
||||
traffic_price_kopeks = tariff.get_price_for_custom_traffic(request.traffic_gb)
|
||||
if traffic_price_kopeks is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Traffic must be between {tariff.min_traffic_gb} and {tariff.max_traffic_gb} GB',
|
||||
)
|
||||
# Apply traffic discount if promo group has it
|
||||
if promo_group and traffic_price_kopeks > 0:
|
||||
traffic_discount_percent = promo_group.get_discount_percent('traffic', period_days)
|
||||
if traffic_discount_percent > 0:
|
||||
traffic_discount = traffic_price_kopeks * traffic_discount_percent // 100
|
||||
traffic_price_kopeks = traffic_price_kopeks - traffic_discount
|
||||
custom_traffic_gb = request.traffic_gb
|
||||
traffic_limit_gb = request.traffic_gb
|
||||
price_kopeks += traffic_price_kopeks
|
||||
|
||||
# Проверяем, есть ли докупленные устройства при продлении того же тарифа
|
||||
# Determine device_limit for renewal pricing
|
||||
existing_subscription = await get_subscription_by_user_id(db, user.id)
|
||||
extra_devices = 0
|
||||
device_limit = None
|
||||
effective_device_limit = tariff.device_limit
|
||||
if existing_subscription and existing_subscription.tariff_id == tariff.id:
|
||||
extra_devices = max(0, (existing_subscription.device_limit or 0) - (tariff.device_limit or 0))
|
||||
if extra_devices > 0:
|
||||
device_limit = existing_subscription.device_limit
|
||||
if (existing_subscription.device_limit or 0) > (tariff.device_limit or 0):
|
||||
effective_device_limit = existing_subscription.device_limit
|
||||
if not is_daily_tariff:
|
||||
from app.utils.pricing_utils import calculate_months_from_days
|
||||
|
||||
device_price_per_month = (
|
||||
tariff.device_price_kopeks
|
||||
if tariff.device_price_kopeks is not None
|
||||
else settings.PRICE_PER_DEVICE
|
||||
)
|
||||
months = calculate_months_from_days(period_days)
|
||||
extra_devices_cost = extra_devices * device_price_per_month * months
|
||||
# Применяем скидку промогруппы на устройства
|
||||
if promo_group and extra_devices_cost > 0:
|
||||
devices_discount_pct = promo_group.get_discount_percent('devices', period_days)
|
||||
if devices_discount_pct > 0:
|
||||
extra_devices_cost = extra_devices_cost - (extra_devices_cost * devices_discount_pct // 100)
|
||||
price_kopeks += extra_devices_cost
|
||||
|
||||
# Apply promo offer discount (temporary discount from promo offers)
|
||||
price_before_promo_offer = price_kopeks
|
||||
promo_offer_discount_percent = get_user_active_promo_discount_percent(user)
|
||||
promo_offer_discount_value = 0
|
||||
if promo_offer_discount_percent > 0:
|
||||
promo_offer_discount_value = price_kopeks * promo_offer_discount_percent // 100
|
||||
price_kopeks = price_kopeks - promo_offer_discount_value
|
||||
# Calculate price via PricingEngine (single source of truth)
|
||||
result = await pricing_engine.calculate_tariff_purchase_price(
|
||||
tariff,
|
||||
period_days,
|
||||
device_limit=device_limit,
|
||||
custom_traffic_gb=custom_traffic_gb,
|
||||
user=user,
|
||||
)
|
||||
price_kopeks = result.final_total
|
||||
original_price = result.original_total
|
||||
bd = result.breakdown
|
||||
group_pcts = bd.get('group_discount_pct', {})
|
||||
discount_percent = group_pcts.get('period', 0)
|
||||
promo_offer_discount_percent = bd.get('offer_discount_pct', 0)
|
||||
promo_offer_discount_value = result.promo_offer_discount
|
||||
price_before_promo_offer = price_kopeks + promo_offer_discount_value
|
||||
|
||||
# Check balance
|
||||
if user.balance_kopeks < price_kopeks:
|
||||
@@ -2109,6 +2071,7 @@ async def purchase_tariff(
|
||||
subscription,
|
||||
reset_traffic=True,
|
||||
reset_reason='покупка тарифа (cabinet)',
|
||||
sync_squads=True,
|
||||
)
|
||||
else:
|
||||
await service.create_remnawave_user(
|
||||
@@ -2137,11 +2100,12 @@ async def purchase_tariff(
|
||||
logger.error('Error saving tariff cart (cabinet)', error=e)
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
response = {
|
||||
'success': True,
|
||||
'message': f"Тариф '{tariff.name}' успешно активирован",
|
||||
'subscription': _subscription_to_response(subscription),
|
||||
'subscription': _subscription_to_response(subscription, user=user),
|
||||
'tariff_id': tariff.id,
|
||||
'tariff_name': tariff.name,
|
||||
'charged_amount': price_kopeks,
|
||||
@@ -2319,6 +2283,11 @@ async def purchase_devices(
|
||||
base_price_prorated = int(base_price_per_month * days_left / total_days)
|
||||
base_price_prorated = max(100, base_price_prorated) # Minimum 1 ruble
|
||||
|
||||
# Lock user BEFORE discount computation to prevent TOCTOU on promo group
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Apply discount from promo group
|
||||
period_hint_days = days_left
|
||||
discount_result = _apply_addon_discount(user, 'devices', base_price_prorated, period_hint_days)
|
||||
@@ -2977,8 +2946,7 @@ async def get_available_countries(
|
||||
await db.refresh(user, ['subscription'])
|
||||
|
||||
promo_group_id = user.promo_group_id
|
||||
# Exclude trial-only servers from available servers for purchase
|
||||
available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id, exclude_trial_only=True)
|
||||
available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id)
|
||||
|
||||
connected_squads = []
|
||||
days_left = 0
|
||||
@@ -2989,11 +2957,10 @@ async def get_available_countries(
|
||||
delta = user.subscription.end_date - datetime.now(UTC)
|
||||
days_left = max(0, delta.days)
|
||||
|
||||
# Get discount from promo group
|
||||
servers_discount_percent = 0
|
||||
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
|
||||
if promo_group:
|
||||
servers_discount_percent = promo_group.get_discount_percent('servers', None)
|
||||
# Get discount from promo group via PricingEngine (respects apply_discounts_to_addons flag)
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
servers_discount_percent = PricingEngine.get_addon_discount_percent(user, 'servers', None)
|
||||
|
||||
countries = []
|
||||
for server in available_servers:
|
||||
@@ -3076,13 +3043,12 @@ async def update_countries(
|
||||
current_countries = user.subscription.connected_squads or []
|
||||
promo_group_id = user.promo_group_id
|
||||
|
||||
# Exclude trial-only servers from available servers for purchase
|
||||
available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id, exclude_trial_only=True)
|
||||
available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id)
|
||||
allowed_country_ids = {server.squad_uuid for server in available_servers}
|
||||
|
||||
# Validate selected countries
|
||||
for country_uuid in selected_countries:
|
||||
if country_uuid not in allowed_country_ids and country_uuid not in current_countries:
|
||||
if country_uuid not in allowed_country_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Country {country_uuid} is not available',
|
||||
@@ -3097,15 +3063,19 @@ async def update_countries(
|
||||
'connected_squads': current_countries,
|
||||
}
|
||||
|
||||
# Lock user row to prevent TOCTOU on promo-offer state
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Calculate cost for added servers
|
||||
total_cost = 0
|
||||
added_names = []
|
||||
removed_names = []
|
||||
|
||||
servers_discount_percent = 0
|
||||
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
|
||||
if promo_group:
|
||||
servers_discount_percent = promo_group.get_discount_percent('servers', None)
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
servers_discount_percent = PricingEngine.get_addon_discount_percent(user, 'servers', None)
|
||||
|
||||
added_server_prices = []
|
||||
|
||||
@@ -3175,7 +3145,7 @@ async def update_countries(
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
if getattr(user, 'remnawave_uuid', None):
|
||||
await subscription_service.update_remnawave_user(db, user.subscription)
|
||||
await subscription_service.update_remnawave_user(db, user.subscription, sync_squads=True)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(db, user.subscription)
|
||||
except Exception as e:
|
||||
@@ -3796,21 +3766,32 @@ async def reduce_devices(
|
||||
logger.error('Error checking/removing devices', error=e)
|
||||
|
||||
old_device_limit = current_device_limit
|
||||
user_id = user.id # save before potential rollback (expires ORM objects)
|
||||
|
||||
# Update subscription
|
||||
# Update subscription in memory (will be committed by update_remnawave_user on success)
|
||||
subscription.device_limit = new_device_limit
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
# Update RemnaWave
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
except Exception as e:
|
||||
logger.error('Error updating RemnaWave user', error=e)
|
||||
# Update RemnaWave — commits on success, returns None on failure
|
||||
subscription_service = SubscriptionService()
|
||||
result = await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
if result is None:
|
||||
# RemnaWave update failed — rollback local changes
|
||||
await db.rollback()
|
||||
logger.error(
|
||||
'Failed to update RemnaWave after device limit reduction',
|
||||
user_id=user_id,
|
||||
old_device_limit=old_device_limit,
|
||||
new_device_limit=new_device_limit,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail='Не удалось обновить VPN-панель. Попробуйте позже.',
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'User {user.id} reduced device limit from {old_device_limit} to {new_device_limit}'
|
||||
f'User {user_id} reduced device limit from {old_device_limit} to {new_device_limit}'
|
||||
+ (f' (removed {devices_removed_count} devices)' if devices_removed_count > 0 else '')
|
||||
)
|
||||
|
||||
@@ -3903,82 +3884,18 @@ async def preview_tariff_switch(
|
||||
delta = user.subscription.end_date - datetime.now(UTC)
|
||||
remaining_days = max(0, delta.days)
|
||||
|
||||
# Calculate switch cost
|
||||
current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False
|
||||
new_is_daily = getattr(new_tariff, 'is_daily', False)
|
||||
switching_to_daily = not current_is_daily and new_is_daily
|
||||
switching_from_daily = current_is_daily and not new_is_daily
|
||||
|
||||
def get_monthly_price(tariff) -> int:
|
||||
"""Get 30-day price from tariff, or calculate from closest period."""
|
||||
if not tariff or not tariff.period_prices:
|
||||
return 0
|
||||
# Try to get 30-day price directly
|
||||
if '30' in tariff.period_prices:
|
||||
return tariff.period_prices['30']
|
||||
# Find closest period and calculate monthly equivalent
|
||||
min_period = None
|
||||
min_price = 0
|
||||
for period_str, price in tariff.period_prices.items():
|
||||
period_days = int(period_str)
|
||||
if min_period is None or period_days < min_period:
|
||||
min_period = period_days
|
||||
min_price = price
|
||||
if min_period and min_period > 0:
|
||||
return int(min_price * 30 / min_period)
|
||||
return 0
|
||||
|
||||
# Get period discount percent for cost calculation
|
||||
period_discount_percent = _get_period_discount_percent(user, remaining_days if remaining_days > 0 else 30)
|
||||
base_upgrade_cost = 0
|
||||
discount_value = 0
|
||||
|
||||
if switching_to_daily:
|
||||
# Switching TO daily - pay first day price
|
||||
daily_price = getattr(new_tariff, 'daily_price_kopeks', 0)
|
||||
base_upgrade_cost = daily_price
|
||||
# Apply discount to daily price
|
||||
if period_discount_percent > 0 and base_upgrade_cost > 0:
|
||||
discount_value = int(base_upgrade_cost * period_discount_percent / 100)
|
||||
upgrade_cost = base_upgrade_cost - discount_value
|
||||
else:
|
||||
upgrade_cost = base_upgrade_cost
|
||||
is_upgrade = upgrade_cost > 0
|
||||
elif switching_from_daily:
|
||||
# Switching FROM daily TO periodic - full payment for new tariff
|
||||
min_period_price = 0
|
||||
if new_tariff.period_prices:
|
||||
min_period_price = min(new_tariff.period_prices.values())
|
||||
base_upgrade_cost = min_period_price
|
||||
# Apply discount
|
||||
if period_discount_percent > 0 and base_upgrade_cost > 0:
|
||||
discount_value = int(base_upgrade_cost * period_discount_percent / 100)
|
||||
upgrade_cost = base_upgrade_cost - discount_value
|
||||
else:
|
||||
upgrade_cost = base_upgrade_cost
|
||||
is_upgrade = upgrade_cost > 0
|
||||
else:
|
||||
# Calculate proportional cost difference using monthly prices
|
||||
current_monthly = get_monthly_price(current_tariff)
|
||||
new_monthly = get_monthly_price(new_tariff)
|
||||
|
||||
price_diff = new_monthly - current_monthly
|
||||
|
||||
if price_diff > 0:
|
||||
# Upgrade - pay proportional difference
|
||||
base_upgrade_cost = int(price_diff * remaining_days / 30)
|
||||
# Apply discount to upgrade cost
|
||||
if period_discount_percent > 0 and base_upgrade_cost > 0:
|
||||
discount_value = int(base_upgrade_cost * period_discount_percent / 100)
|
||||
upgrade_cost = base_upgrade_cost - discount_value
|
||||
else:
|
||||
upgrade_cost = base_upgrade_cost
|
||||
is_upgrade = True
|
||||
else:
|
||||
# Downgrade or same - free
|
||||
upgrade_cost = 0
|
||||
base_upgrade_cost = 0
|
||||
is_upgrade = False
|
||||
# Calculate switch cost (PricingEngine handles all cases: periodic↔periodic, daily→periodic, periodic→daily)
|
||||
switch_result = pricing_engine.calculate_tariff_switch_cost(
|
||||
current_tariff,
|
||||
new_tariff,
|
||||
remaining_days,
|
||||
user=user,
|
||||
)
|
||||
upgrade_cost = switch_result.upgrade_cost
|
||||
is_upgrade = switch_result.is_upgrade
|
||||
base_upgrade_cost = switch_result.raw_cost
|
||||
discount_value = switch_result.discount_value
|
||||
period_discount_percent = switch_result.effective_discount_pct
|
||||
|
||||
balance = user.balance_kopeks or 0
|
||||
has_enough = balance >= upgrade_cost
|
||||
@@ -4090,88 +4007,41 @@ async def switch_tariff(
|
||||
detail='Tariff not available',
|
||||
)
|
||||
|
||||
# Lock user BEFORE price computation to prevent TOCTOU on promo offer
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Calculate remaining days
|
||||
remaining_days = 0
|
||||
if user.subscription.end_date and user.subscription.end_date > datetime.now(UTC):
|
||||
delta = user.subscription.end_date - datetime.now(UTC)
|
||||
if subscription.end_date and subscription.end_date > datetime.now(UTC):
|
||||
delta = subscription.end_date - datetime.now(UTC)
|
||||
remaining_days = max(0, delta.days)
|
||||
|
||||
# Calculate cost
|
||||
current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False
|
||||
# Calculate cost (PricingEngine handles all cases: periodic↔periodic, daily→periodic, periodic→daily)
|
||||
switch_result = pricing_engine.calculate_tariff_switch_cost(
|
||||
current_tariff,
|
||||
new_tariff,
|
||||
remaining_days,
|
||||
user=user,
|
||||
)
|
||||
upgrade_cost = switch_result.upgrade_cost
|
||||
base_upgrade_cost = switch_result.raw_cost
|
||||
discount_value = switch_result.discount_value
|
||||
period_discount_percent = switch_result.effective_discount_pct
|
||||
new_period_days = switch_result.new_period_days
|
||||
|
||||
# Validate daily price for switching TO daily
|
||||
new_is_daily = getattr(new_tariff, 'is_daily', False)
|
||||
switching_from_daily = current_is_daily and not new_is_daily
|
||||
current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False
|
||||
switching_to_daily = not current_is_daily and new_is_daily
|
||||
switching_from_daily = current_is_daily and not new_is_daily
|
||||
|
||||
# Get period discount percent for cost calculation
|
||||
period_discount_percent = _get_period_discount_percent(user, remaining_days if remaining_days > 0 else 30)
|
||||
base_upgrade_cost = 0
|
||||
discount_value = 0
|
||||
|
||||
if switching_to_daily:
|
||||
# Switching TO daily tariff - charge first day price
|
||||
daily_price = getattr(new_tariff, 'daily_price_kopeks', 0)
|
||||
if daily_price <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Daily tariff has invalid price',
|
||||
)
|
||||
base_upgrade_cost = daily_price
|
||||
# Apply discount
|
||||
if period_discount_percent > 0 and base_upgrade_cost > 0:
|
||||
discount_value = int(base_upgrade_cost * period_discount_percent / 100)
|
||||
upgrade_cost = base_upgrade_cost - discount_value
|
||||
else:
|
||||
upgrade_cost = base_upgrade_cost
|
||||
new_period_days = 1 # Daily tariff starts with 1 day
|
||||
elif switching_from_daily:
|
||||
# Switch FROM daily to regular tariff - pay for minimum period
|
||||
min_period_days = 30
|
||||
min_period_price = 0
|
||||
if new_tariff.period_prices:
|
||||
min_period_days = min(int(k) for k in new_tariff.period_prices.keys())
|
||||
min_period_price = new_tariff.period_prices.get(str(min_period_days), 0)
|
||||
base_upgrade_cost = min_period_price
|
||||
# Apply discount
|
||||
if period_discount_percent > 0 and base_upgrade_cost > 0:
|
||||
discount_value = int(base_upgrade_cost * period_discount_percent / 100)
|
||||
upgrade_cost = base_upgrade_cost - discount_value
|
||||
else:
|
||||
upgrade_cost = base_upgrade_cost
|
||||
new_period_days = min_period_days
|
||||
else:
|
||||
# Regular tariff switch - calculate proportional cost difference using monthly prices
|
||||
def get_monthly_price(tariff) -> int:
|
||||
if not tariff or not tariff.period_prices:
|
||||
return 0
|
||||
if '30' in tariff.period_prices:
|
||||
return tariff.period_prices['30']
|
||||
min_period = None
|
||||
min_price = 0
|
||||
for period_str, price in tariff.period_prices.items():
|
||||
period_days = int(period_str)
|
||||
if min_period is None or period_days < min_period:
|
||||
min_period = period_days
|
||||
min_price = price
|
||||
if min_period and min_period > 0:
|
||||
return int(min_price * 30 / min_period)
|
||||
return 0
|
||||
|
||||
current_monthly = get_monthly_price(current_tariff)
|
||||
new_monthly = get_monthly_price(new_tariff)
|
||||
price_diff = new_monthly - current_monthly
|
||||
|
||||
if price_diff > 0:
|
||||
base_upgrade_cost = int(price_diff * remaining_days / 30)
|
||||
# Apply discount
|
||||
if period_discount_percent > 0 and base_upgrade_cost > 0:
|
||||
discount_value = int(base_upgrade_cost * period_discount_percent / 100)
|
||||
upgrade_cost = base_upgrade_cost - discount_value
|
||||
else:
|
||||
upgrade_cost = base_upgrade_cost
|
||||
else:
|
||||
upgrade_cost = 0
|
||||
base_upgrade_cost = 0
|
||||
new_period_days = 0
|
||||
if switching_to_daily and (getattr(new_tariff, 'daily_price_kopeks', 0) or 0) <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Daily tariff has invalid price',
|
||||
)
|
||||
|
||||
# Charge if upgrade
|
||||
if upgrade_cost > 0:
|
||||
@@ -4202,6 +4072,7 @@ async def switch_tariff(
|
||||
user,
|
||||
upgrade_cost,
|
||||
description,
|
||||
consume_promo_offer=switch_result.offer_discount_pct > 0,
|
||||
mark_as_paid_subscription=True,
|
||||
commit=False,
|
||||
)
|
||||
@@ -4236,7 +4107,7 @@ async def switch_tariff(
|
||||
# Update subscription
|
||||
old_tariff_name = current_tariff.name if current_tariff else 'Unknown'
|
||||
|
||||
# Preserve extra purchased devices above the old tariff's base limit
|
||||
# Reset device limit to new tariff base (extra purchased devices are not carried over)
|
||||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||||
|
||||
# Re-load subscription to avoid MissingGreenlet from expired lazy relationship
|
||||
@@ -4304,6 +4175,7 @@ async def switch_tariff(
|
||||
subscription,
|
||||
reset_traffic=should_reset_traffic,
|
||||
reset_reason='смена тарифа',
|
||||
sync_squads=True,
|
||||
)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(
|
||||
@@ -4355,11 +4227,15 @@ async def switch_tariff(
|
||||
except Exception as e:
|
||||
logger.error('Failed to send admin notification for tariff switch', error=e)
|
||||
|
||||
# Refresh expired objects after db.commit() in _record_subscription_event
|
||||
await db.refresh(subscription)
|
||||
await db.refresh(user)
|
||||
|
||||
response = {
|
||||
'success': True,
|
||||
'message': f"Switched from '{old_tariff_name}' to '{new_tariff.name}'"
|
||||
+ (' (devices reset)' if devices_reset else ''),
|
||||
'subscription': _subscription_to_response(subscription),
|
||||
'subscription': _subscription_to_response(subscription, user=user),
|
||||
'old_tariff_name': old_tariff_name,
|
||||
'new_tariff_id': new_tariff.id,
|
||||
'new_tariff_name': new_tariff.name,
|
||||
@@ -4426,7 +4302,21 @@ async def toggle_subscription_pause(
|
||||
new_paused_state = not is_currently_paused
|
||||
user.subscription.is_daily_paused = new_paused_state
|
||||
|
||||
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
|
||||
# Lock user BEFORE discount computation to prevent TOCTOU on promo group
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Apply group discount to daily price (consistent with DailySubscriptionService and miniapp resume)
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
promo_group = PricingEngine.resolve_promo_group(user)
|
||||
daily_group_pct = promo_group.get_discount_percent('period', 1) if promo_group else 0
|
||||
daily_price = (
|
||||
PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price
|
||||
)
|
||||
|
||||
# If resuming, check balance and charge
|
||||
if not new_paused_state:
|
||||
@@ -4568,22 +4458,16 @@ async def switch_traffic_package(
|
||||
# Upgrade - charge difference
|
||||
price_diff = new_price - current_price
|
||||
|
||||
# Apply promo discount
|
||||
traffic_discount_percent = 0
|
||||
promo_group = (
|
||||
user.get_primary_promo_group()
|
||||
if hasattr(user, 'get_primary_promo_group')
|
||||
else getattr(user, 'promo_group', None)
|
||||
)
|
||||
if promo_group:
|
||||
apply_to_addons = getattr(promo_group, 'apply_discounts_to_addons', True)
|
||||
if apply_to_addons:
|
||||
traffic_discount_percent = max(
|
||||
0, min(100, int(getattr(promo_group, 'traffic_discount_percent', 0) or 0))
|
||||
)
|
||||
# Lock user row to prevent TOCTOU on promo-offer state
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
if traffic_discount_percent > 0:
|
||||
price_diff = int(price_diff * (100 - traffic_discount_percent) / 100)
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Apply promo discount via PricingEngine
|
||||
price_diff, _discount_val, traffic_discount_percent = pricing_engine.calculate_traffic_discount(
|
||||
price_diff,
|
||||
user,
|
||||
)
|
||||
|
||||
# Prorated calculation
|
||||
final_price, days_charged = calculate_prorated_price(price_diff, user.subscription.end_date)
|
||||
|
||||
@@ -187,3 +187,17 @@ class EmailChangeResponse(BaseModel):
|
||||
message: str = Field(..., description='Success message')
|
||||
new_email: str = Field(..., description='New email address pending verification')
|
||||
expires_in_minutes: int = Field(..., description='Code expiration time in minutes')
|
||||
|
||||
|
||||
class DeepLinkTokenResponse(BaseModel):
|
||||
"""Response with deep link auth token."""
|
||||
|
||||
token: str = Field(..., description='One-time auth token')
|
||||
bot_username: str = Field(..., description='Bot username for deep link')
|
||||
expires_in: int = Field(..., description='Token TTL in seconds')
|
||||
|
||||
|
||||
class DeepLinkPollRequest(BaseModel):
|
||||
"""Request to poll deep link auth status."""
|
||||
|
||||
token: str = Field(..., min_length=16, max_length=128, description='Deep link auth token')
|
||||
|
||||
@@ -10,6 +10,7 @@ class ReferralInfoResponse(BaseModel):
|
||||
|
||||
referral_code: str
|
||||
referral_link: str
|
||||
bot_referral_link: str = ''
|
||||
total_referrals: int
|
||||
active_referrals: int
|
||||
total_earnings_kopeks: int
|
||||
|
||||
@@ -198,7 +198,7 @@ async def get_rendered_override(
|
||||
for key, value in context.items():
|
||||
body_html = body_html.replace(f'{{{key}}}', html.escape(str(value)))
|
||||
|
||||
rendered = templates._get_base_template(body_html, language)
|
||||
rendered = templates._wrap_override_template(body_html, language)
|
||||
subject = override['subject']
|
||||
|
||||
# Also substitute in subject
|
||||
|
||||
@@ -74,6 +74,39 @@ class EmailNotificationTemplates:
|
||||
|
||||
return template_func(language, context)
|
||||
|
||||
def _wrap_override_template(self, content: str, language: str = 'ru') -> str:
|
||||
"""Wrap override template content appropriately based on its structure.
|
||||
|
||||
Three-tier detection:
|
||||
1. Full HTML document (<!DOCTYPE or <html>) — return as-is, no wrapping
|
||||
2. Styled content (has <style> tag or background CSS) — minimal HTML wrapper
|
||||
without forced colors, headers, or footers
|
||||
3. Simple HTML fragment — wrap with base template (header, footer, white bg)
|
||||
for backward compatibility
|
||||
"""
|
||||
content_stripped = content.strip()
|
||||
content_lower = content_stripped.lower()
|
||||
|
||||
# Tier 1: Full HTML document — return as-is
|
||||
if content_lower.startswith('<!doctype') or content_lower.startswith('<html'):
|
||||
return content_stripped
|
||||
|
||||
# Tier 2: Styled content — minimal wrapper without forced styling
|
||||
if '<style' in content_lower or 'background' in content_lower:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0;">
|
||||
{content}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
# Tier 3: Simple HTML fragment — use base template for structure
|
||||
return self._get_base_template(content, language)
|
||||
|
||||
def _get_base_template(self, content: str, language: str = 'ru') -> str:
|
||||
"""Wrap content in base HTML template."""
|
||||
footer_texts = {
|
||||
|
||||
+58
-6
@@ -56,6 +56,17 @@ class Settings(BaseSettings):
|
||||
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID: int | None = None
|
||||
ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID: int | None = None
|
||||
|
||||
# Раздельные топики для уведомлений (если не задано — fallback на ADMIN_NOTIFICATIONS_TOPIC_ID)
|
||||
ADMIN_NOTIFICATIONS_PURCHASES_TOPIC_ID: int | None = None # Покупки подписок
|
||||
ADMIN_NOTIFICATIONS_RENEWALS_TOPIC_ID: int | None = None # Продления
|
||||
ADMIN_NOTIFICATIONS_TRIALS_TOPIC_ID: int | None = None # Триалы
|
||||
ADMIN_NOTIFICATIONS_BALANCE_TOPIC_ID: int | None = None # Пополнение баланса
|
||||
ADMIN_NOTIFICATIONS_ADDONS_TOPIC_ID: int | None = None # Докупка трафика/устройств/серверов
|
||||
ADMIN_NOTIFICATIONS_INFRASTRUCTURE_TOPIC_ID: int | None = None # Ноды, техработы, статус панели
|
||||
ADMIN_NOTIFICATIONS_ERRORS_TOPIC_ID: int | None = None # Ошибки бота
|
||||
ADMIN_NOTIFICATIONS_PROMO_TOPIC_ID: int | None = None # Промокоды, кампании, промогруппы
|
||||
ADMIN_NOTIFICATIONS_PARTNERS_TOPIC_ID: int | None = None # Партнёрки, выводы, админ-действия
|
||||
|
||||
# Настройки очереди чеков NaloGO
|
||||
NALOGO_QUEUE_CHECK_INTERVAL: int = 300 # Интервал проверки очереди (секунды)
|
||||
NALOGO_QUEUE_RECEIPT_DELAY: int = 3 # Задержка между отправкой чеков (секунды)
|
||||
@@ -353,10 +364,8 @@ class Settings(BaseSettings):
|
||||
YOOKASSA_TRUSTED_PROXY_NETWORKS: str = ''
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
|
||||
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
|
||||
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
|
||||
YOOKASSA_RECURRENT_ENABLED: bool = False
|
||||
YOOKASSA_RECURRENT_REQUIRED: bool = False
|
||||
DISABLE_TOPUP_BUTTONS: bool = False
|
||||
SUPPORT_TOPUP_ENABLED: bool = True
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10
|
||||
@@ -535,6 +544,11 @@ class Settings(BaseSettings):
|
||||
KASSA_AI_WEBHOOK_PORT: int = 8089
|
||||
# Способ оплаты: 44 = СБП (QR код), 36 = Карты РФ, 43 = SberPay
|
||||
KASSA_AI_PAYMENT_SYSTEM_ID: int = 44
|
||||
# Раздельные методы оплаты KassaAI (отображаются как отдельные кнопки)
|
||||
KASSA_AI_SBP_ENABLED: bool = False # СБП — payment_system_id=44
|
||||
KASSA_AI_SBP_DISPLAY_NAME: str = 'СБП (KassaAI)'
|
||||
KASSA_AI_CARD_ENABLED: bool = False # Карты РФ — payment_system_id=36
|
||||
KASSA_AI_CARD_DISPLAY_NAME: str = 'Карта (KassaAI)'
|
||||
|
||||
# RioPay (api.riopay.online) v2.0.1
|
||||
RIOPAY_ENABLED: bool = False
|
||||
@@ -548,6 +562,18 @@ class Settings(BaseSettings):
|
||||
RIOPAY_SUCCESS_URL: str | None = None
|
||||
RIOPAY_FAIL_URL: str | None = None
|
||||
|
||||
# SeverPay (severpay.io)
|
||||
SEVERPAY_ENABLED: bool = False
|
||||
SEVERPAY_MID: int | None = None # Merchant ID
|
||||
SEVERPAY_TOKEN: str | None = None # Secret token for HMAC-SHA256
|
||||
SEVERPAY_DISPLAY_NAME: str = 'SeverPay'
|
||||
SEVERPAY_CURRENCY: str = 'RUB'
|
||||
SEVERPAY_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
SEVERPAY_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
|
||||
SEVERPAY_WEBHOOK_PATH: str = '/severpay-webhook'
|
||||
SEVERPAY_RETURN_URL: str | None = None
|
||||
SEVERPAY_LIFETIME: int = 1440 # minutes, 30-4320
|
||||
|
||||
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
|
||||
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
|
||||
CABINET_BUTTON_STYLE: str = ''
|
||||
@@ -1237,10 +1263,6 @@ class Settings(BaseSettings):
|
||||
|
||||
return bool(value)
|
||||
|
||||
def is_quick_amount_buttons_enabled(self) -> bool:
|
||||
"""Показывать ли кнопки быстрого выбора суммы пополнения."""
|
||||
return self.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not self.DISABLE_TOPUP_BUTTONS
|
||||
|
||||
def get_available_languages(self) -> list[str]:
|
||||
defaults = ['ru', 'en', 'ua', 'zh', 'fa']
|
||||
|
||||
@@ -1850,6 +1872,36 @@ class Settings(BaseSettings):
|
||||
def get_riopay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_riopay_display_name())
|
||||
|
||||
def is_severpay_enabled(self) -> bool:
|
||||
return self.SEVERPAY_ENABLED and self.SEVERPAY_MID is not None and self.SEVERPAY_TOKEN is not None
|
||||
|
||||
def get_severpay_display_name(self) -> str:
|
||||
name = (self.SEVERPAY_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'SeverPay'
|
||||
|
||||
def get_severpay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_severpay_display_name())
|
||||
|
||||
def is_kassa_ai_sbp_enabled(self) -> bool:
|
||||
return self.KASSA_AI_SBP_ENABLED and self.is_kassa_ai_enabled()
|
||||
|
||||
def get_kassa_ai_sbp_display_name(self) -> str:
|
||||
name = (self.KASSA_AI_SBP_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'СБП (KassaAI)'
|
||||
|
||||
def get_kassa_ai_sbp_display_name_html(self) -> str:
|
||||
return html.escape(self.get_kassa_ai_sbp_display_name())
|
||||
|
||||
def is_kassa_ai_card_enabled(self) -> bool:
|
||||
return self.KASSA_AI_CARD_ENABLED and self.is_kassa_ai_enabled()
|
||||
|
||||
def get_kassa_ai_card_display_name(self) -> str:
|
||||
name = (self.KASSA_AI_CARD_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Карта (KassaAI)'
|
||||
|
||||
def get_kassa_ai_card_display_name_html(self) -> str:
|
||||
return html.escape(self.get_kassa_ai_card_display_name())
|
||||
|
||||
def is_payment_verification_auto_check_enabled(self) -> bool:
|
||||
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
|
||||
|
||||
|
||||
@@ -366,7 +366,8 @@ async def get_campaign_statistics(
|
||||
first_payment_amount_by_user[user_id] = amount_value
|
||||
first_payment_time_by_user[user_id] = created_at
|
||||
|
||||
total_revenue = deposits_total + subscription_payments_total
|
||||
# Revenue = only real deposits (exclude bonus-funded subscription spending)
|
||||
total_revenue = deposits_total
|
||||
|
||||
paid_user_ids = set(paid_users_from_transactions)
|
||||
paid_user_ids.update(conversion_user_ids)
|
||||
|
||||
@@ -15,7 +15,7 @@ logger = structlog.get_logger(__name__)
|
||||
async def create_riopay_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
user_id: int | None,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = 'RUB',
|
||||
|
||||
@@ -141,8 +141,12 @@ async def get_available_server_squads(
|
||||
.order_by(ServerSquad.sort_order, ServerSquad.display_name)
|
||||
)
|
||||
|
||||
if exclude_trial_only:
|
||||
query = query.where(ServerSquad.is_trial_eligible.is_(False))
|
||||
# НЕ фильтруем по is_trial_eligible — это поле означает "доступен для триала",
|
||||
# а НЕ "только для триала". Сквад может быть одновременно триальным и платным.
|
||||
# Фильтр exclude_trial_only убирал единственный доступный сквад, из-за чего
|
||||
# пользователи без триала получали пустой connected_squads при покупке.
|
||||
# Параметр exclude_trial_only сохранён для обратной совместимости, но не используется.
|
||||
# TODO: если нужна логика "только для триала", добавить отдельное поле is_trial_only
|
||||
|
||||
if promo_group_id is not None:
|
||||
query = query.join(ServerSquad.allowed_promo_groups).where(PromoGroup.id == promo_group_id)
|
||||
@@ -313,7 +317,17 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
|
||||
)
|
||||
created += 1
|
||||
|
||||
removed_servers = [server for uuid, server in existing_servers.items() if uuid not in remnawave_uuids]
|
||||
# Protect external squads referenced by tariffs from being removed during sync
|
||||
tariff_ext_uuids_result = await db.execute(
|
||||
select(Tariff.external_squad_uuid).where(Tariff.external_squad_uuid.isnot(None))
|
||||
)
|
||||
protected_uuids = {row[0] for row in tariff_ext_uuids_result.fetchall()}
|
||||
|
||||
removed_servers = [
|
||||
server
|
||||
for uuid, server in existing_servers.items()
|
||||
if uuid not in remnawave_uuids and uuid not in protected_uuids
|
||||
]
|
||||
|
||||
if removed_servers:
|
||||
removed_ids = [server.id for server in removed_servers]
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""CRUD операции для платежей SeverPay."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import SeverPayPayment
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_severpay_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = 'RUB',
|
||||
description: str | None = None,
|
||||
payment_url: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
severpay_id: str | None = None,
|
||||
severpay_uid: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: dict | None = None,
|
||||
) -> SeverPayPayment:
|
||||
"""Создает запись о платеже SeverPay."""
|
||||
payment = SeverPayPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method,
|
||||
severpay_id=severpay_id,
|
||||
severpay_uid=severpay_uid,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata_json,
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
)
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info('Создан платеж SeverPay', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_severpay_payment_by_order_id(db: AsyncSession, order_id: str) -> SeverPayPayment | None:
|
||||
"""Получает платеж по order_id (internal)."""
|
||||
result = await db.execute(select(SeverPayPayment).where(SeverPayPayment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_severpay_payment_by_severpay_id(db: AsyncSession, severpay_id: str) -> SeverPayPayment | None:
|
||||
"""Получает платеж по ID от SeverPay."""
|
||||
result = await db.execute(select(SeverPayPayment).where(SeverPayPayment.severpay_id == severpay_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_severpay_payment_by_id(db: AsyncSession, payment_id: int) -> SeverPayPayment | None:
|
||||
"""Получает платеж по ID."""
|
||||
result = await db.execute(select(SeverPayPayment).where(SeverPayPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_severpay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> SeverPayPayment | None:
|
||||
"""Получает платеж по ID с блокировкой FOR UPDATE."""
|
||||
result = await db.execute(select(SeverPayPayment).where(SeverPayPayment.id == payment_id).with_for_update())
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_severpay_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: SeverPayPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool | None = None,
|
||||
severpay_id: str | None = None,
|
||||
severpay_uid: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
transaction_id: int | None = None,
|
||||
) -> SeverPayPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
payment.status = status
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if severpay_id is not None:
|
||||
payment.severpay_id = severpay_id
|
||||
if severpay_uid is not None:
|
||||
payment.severpay_uid = severpay_uid
|
||||
if payment_method is not None:
|
||||
payment.payment_method = payment_method
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if transaction_id is not None:
|
||||
payment.transaction_id = transaction_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
'Обновлен статус платежа SeverPay',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=payment.is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_severpay_payments(db: AsyncSession, user_id: int) -> list[SeverPayPayment]:
|
||||
"""Получает незавершенные платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(SeverPayPayment).where(
|
||||
SeverPayPayment.user_id == user_id,
|
||||
SeverPayPayment.status == 'pending',
|
||||
SeverPayPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_severpay_payments(
|
||||
db: AsyncSession,
|
||||
) -> list[SeverPayPayment]:
|
||||
"""Получает просроченные платежи в статусе pending."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(SeverPayPayment).where(
|
||||
SeverPayPayment.status == 'pending',
|
||||
SeverPayPayment.is_paid == False,
|
||||
SeverPayPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def link_severpay_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: SeverPayPayment,
|
||||
transaction_id: int,
|
||||
) -> SeverPayPayment:
|
||||
"""Связывает платеж с транзакцией."""
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -1,6 +1,5 @@
|
||||
from collections.abc import Iterable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, delete, func, select
|
||||
@@ -11,7 +10,6 @@ from sqlalchemy.orm.exc import StaleDataError
|
||||
from app.config import settings
|
||||
from app.database.crud.notification import clear_notifications
|
||||
from app.database.models import (
|
||||
PromoGroup,
|
||||
Subscription,
|
||||
SubscriptionServer,
|
||||
SubscriptionStatus,
|
||||
@@ -20,7 +18,6 @@ from app.database.models import (
|
||||
User,
|
||||
UserStatus,
|
||||
)
|
||||
from app.utils.pricing_utils import calculate_months_from_days
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
|
||||
@@ -43,23 +40,18 @@ def calc_device_limit_on_tariff_switch(
|
||||
new_tariff_device_limit: int | None,
|
||||
max_device_limit: int | None = None,
|
||||
) -> int:
|
||||
"""Calculate device_limit preserving extra purchased devices when switching tariffs.
|
||||
"""Calculate device_limit when switching tariffs.
|
||||
|
||||
Extra devices = current_device_limit - old_tariff_device_limit (clamped to 0).
|
||||
Result = new_tariff_device_limit + extra_devices, capped at max_device_limit.
|
||||
Resets to new tariff base device limit — previously purchased
|
||||
extra devices are NOT carried over. Capped at max_device_limit.
|
||||
"""
|
||||
old_base = old_tariff_device_limit if old_tariff_device_limit is not None else 0
|
||||
current = current_device_limit if current_device_limit is not None else old_base
|
||||
extra = max(0, current - old_base)
|
||||
|
||||
new_base = new_tariff_device_limit if new_tariff_device_limit is not None else 1
|
||||
total = new_base + extra
|
||||
|
||||
effective_max = max_device_limit or (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
|
||||
if effective_max and total > effective_max:
|
||||
total = effective_max
|
||||
if effective_max and new_base > effective_max:
|
||||
new_base = effective_max
|
||||
|
||||
return total
|
||||
return new_base
|
||||
|
||||
|
||||
def is_active_paid_subscription(subscription: Subscription | None) -> bool:
|
||||
@@ -221,6 +213,23 @@ async def create_paid_subscription(
|
||||
if device_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
# Fallback: если connected_squads пустой — берём первый доступный сквад
|
||||
final_squads = list(connected_squads or [])
|
||||
if not final_squads:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_available_server_squads
|
||||
|
||||
available = await get_available_server_squads(db)
|
||||
if available:
|
||||
final_squads = [available[0].squad_uuid]
|
||||
logger.warning(
|
||||
'⚠️ connected_squads пустой при создании подписки, используем fallback сквад',
|
||||
user_id=user_id,
|
||||
fallback_squad=final_squads[0],
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('❌ Не удалось получить fallback сквад', user_id=user_id, error=error)
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
@@ -229,7 +238,7 @@ async def create_paid_subscription(
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads or [],
|
||||
connected_squads=final_squads,
|
||||
autopay_enabled=settings.is_autopay_enabled_by_default(),
|
||||
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
|
||||
tariff_id=tariff_id,
|
||||
@@ -249,7 +258,7 @@ async def create_paid_subscription(
|
||||
status=subscription.status,
|
||||
)
|
||||
|
||||
squad_uuids = list(connected_squads or [])
|
||||
squad_uuids = list(final_squads)
|
||||
if update_server_counters and squad_uuids:
|
||||
try:
|
||||
from app.database.crud.server_squad import (
|
||||
@@ -299,7 +308,25 @@ async def replace_subscription(
|
||||
|
||||
current_time = datetime.now(UTC)
|
||||
old_squads = set(subscription.connected_squads or [])
|
||||
new_squads = set(connected_squads or [])
|
||||
|
||||
# Fallback: если connected_squads пустой — берём первый доступный сквад
|
||||
final_connected = list(connected_squads or [])
|
||||
if not final_connected:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_available_server_squads
|
||||
|
||||
available = await get_available_server_squads(db)
|
||||
if available:
|
||||
final_connected = [available[0].squad_uuid]
|
||||
logger.warning(
|
||||
'⚠️ connected_squads пустой при замене подписки, используем fallback сквад',
|
||||
subscription_id=subscription.id,
|
||||
fallback_squad=final_connected[0],
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('❌ Не удалось получить fallback сквад', subscription_id=subscription.id, error=error)
|
||||
|
||||
new_squads = set(final_connected)
|
||||
|
||||
new_autopay_enabled = subscription.autopay_enabled if autopay_enabled is None else autopay_enabled
|
||||
new_autopay_days_before = subscription.autopay_days_before if autopay_days_before is None else autopay_days_before
|
||||
@@ -549,9 +576,17 @@ async def extend_subscription(
|
||||
logger.info('📱 Обновлен лимит устройств: →', old_devices=old_devices, device_limit=device_limit)
|
||||
|
||||
if connected_squads is not None:
|
||||
old_squads = subscription.connected_squads
|
||||
subscription.connected_squads = connected_squads
|
||||
logger.info('🌍 Обновлены сквады: →', old_squads=old_squads, connected_squads=connected_squads)
|
||||
# Не перезаписываем существующие сквады пустым списком
|
||||
if connected_squads or not subscription.connected_squads:
|
||||
old_squads = subscription.connected_squads
|
||||
subscription.connected_squads = connected_squads
|
||||
logger.info('🌍 Обновлены сквады: →', old_squads=old_squads, connected_squads=connected_squads)
|
||||
else:
|
||||
logger.warning(
|
||||
'⚠️ Попытка перезаписать сквады пустым списком, сохраняем текущие',
|
||||
subscription_id=subscription.id,
|
||||
current_squads=subscription.connected_squads,
|
||||
)
|
||||
|
||||
# Обработка daily полей при смене тарифа
|
||||
if is_tariff_change and tariff_id is not None:
|
||||
@@ -1198,212 +1233,6 @@ async def add_subscription_servers(
|
||||
return subscription
|
||||
|
||||
|
||||
async def get_server_monthly_price(db: AsyncSession, server_squad_id: int) -> int:
|
||||
from app.database.models import ServerSquad
|
||||
|
||||
result = await db.execute(select(ServerSquad.price_kopeks).where(ServerSquad.id == server_squad_id))
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def get_servers_monthly_prices(
|
||||
db: AsyncSession,
|
||||
server_squad_ids: list[int],
|
||||
*,
|
||||
user: Optional['User'] = None,
|
||||
) -> list[int]:
|
||||
"""Получает месячные цены серверов с проверкой доступности для промогруппы пользователя."""
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import ServerSquad
|
||||
|
||||
prices = []
|
||||
|
||||
# Загружаем промогруппы пользователя если нужно
|
||||
user_promo_group = None
|
||||
user_promo_group_id = None
|
||||
if user:
|
||||
try:
|
||||
# Пробуем загрузить промогруппы если ещё не загружены
|
||||
await db.refresh(user, ['user_promo_groups', 'promo_group'])
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
user_promo_group = user.get_primary_promo_group()
|
||||
user_promo_group_id = user_promo_group.id if user_promo_group else None
|
||||
except Exception as e:
|
||||
logger.warning('Не удалось получить промогруппу пользователя', error=e)
|
||||
|
||||
for server_id in server_squad_ids:
|
||||
# Загружаем сервер с промогруппами
|
||||
result = await db.execute(
|
||||
select(ServerSquad)
|
||||
.options(selectinload(ServerSquad.allowed_promo_groups))
|
||||
.where(ServerSquad.id == server_id)
|
||||
)
|
||||
server = result.scalar_one_or_none()
|
||||
|
||||
if not server:
|
||||
prices.append(0)
|
||||
continue
|
||||
|
||||
# Проверяем доступность сервера для промогруппы пользователя
|
||||
is_allowed = True
|
||||
if user_promo_group_id is not None and server.allowed_promo_groups:
|
||||
allowed_ids = {pg.id for pg in server.allowed_promo_groups}
|
||||
is_allowed = user_promo_group_id in allowed_ids
|
||||
|
||||
if server.is_available and is_allowed:
|
||||
prices.append(server.price_kopeks)
|
||||
else:
|
||||
# Сервер недоступен для промогруппы пользователя
|
||||
logger.warning(
|
||||
'⚠️ Сервер (id=) недоступен для промогруппы пользователя (promo_group_id=), allowed_promo_groups',
|
||||
display_name=server.display_name,
|
||||
server_id=server_id,
|
||||
user_promo_group_id=user_promo_group_id,
|
||||
value=[pg.id for pg in server.allowed_promo_groups] if server.allowed_promo_groups else [],
|
||||
)
|
||||
prices.append(server.price_kopeks) # Всё равно берём реальную цену
|
||||
|
||||
return prices
|
||||
|
||||
|
||||
def _get_discount_percent(
|
||||
user: User | None,
|
||||
promo_group: PromoGroup | None,
|
||||
category: str,
|
||||
*,
|
||||
period_days: int | None = None,
|
||||
) -> int:
|
||||
if user is not None:
|
||||
try:
|
||||
return user.get_promo_discount(category, period_days)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
if promo_group is not None:
|
||||
return promo_group.get_discount_percent(category, period_days)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
async def calculate_subscription_total_cost(
|
||||
db: AsyncSession,
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
server_squad_ids: list[int],
|
||||
devices: int,
|
||||
*,
|
||||
user: User | None = None,
|
||||
promo_group: PromoGroup | None = None,
|
||||
) -> tuple[int, dict]:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
base_price_original = PERIOD_PRICES.get(period_days, 0)
|
||||
period_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'period',
|
||||
period_days=period_days,
|
||||
)
|
||||
base_discount_total = base_price_original * period_discount_percent // 100
|
||||
base_price = base_price_original - base_discount_total
|
||||
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
|
||||
traffic_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'traffic',
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_price = discounted_traffic_per_month * months_in_period
|
||||
total_traffic_discount = traffic_discount_per_month * months_in_period
|
||||
|
||||
servers_prices = await get_servers_monthly_prices(db, server_squad_ids, user=user)
|
||||
servers_price_per_month = sum(servers_prices)
|
||||
servers_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'servers',
|
||||
period_days=period_days,
|
||||
)
|
||||
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
|
||||
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
|
||||
total_servers_price = discounted_servers_per_month * months_in_period
|
||||
total_servers_discount = servers_discount_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'devices',
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
total_devices_price = discounted_devices_per_month * months_in_period
|
||||
total_devices_discount = devices_discount_per_month * months_in_period
|
||||
|
||||
total_cost = base_price + total_traffic_price + total_servers_price + total_devices_price
|
||||
|
||||
details = {
|
||||
'base_price': base_price,
|
||||
'base_price_original': base_price_original,
|
||||
'base_discount_percent': period_discount_percent,
|
||||
'base_discount_total': base_discount_total,
|
||||
'traffic_price_per_month': traffic_price_per_month,
|
||||
'traffic_discount_percent': traffic_discount_percent,
|
||||
'traffic_discount_total': total_traffic_discount,
|
||||
'total_traffic_price': total_traffic_price,
|
||||
'servers_price_per_month': servers_price_per_month,
|
||||
'servers_discount_percent': servers_discount_percent,
|
||||
'servers_discount_total': total_servers_discount,
|
||||
'total_servers_price': total_servers_price,
|
||||
'devices_price_per_month': devices_price_per_month,
|
||||
'devices_discount_percent': devices_discount_percent,
|
||||
'devices_discount_total': total_devices_discount,
|
||||
'total_devices_price': total_devices_price,
|
||||
'months_in_period': months_in_period,
|
||||
'servers_individual_prices': [
|
||||
(price - (price * servers_discount_percent // 100)) * months_in_period for price in servers_prices
|
||||
],
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
'📊 Расчет стоимости подписки на дней ( мес)', period_days=period_days, months_in_period=months_in_period
|
||||
)
|
||||
logger.debug('Базовый период: ₽', base_price=base_price / 100)
|
||||
if total_traffic_price > 0:
|
||||
message = f' Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period} = {total_traffic_price / 100}₽'
|
||||
if total_traffic_discount > 0:
|
||||
message += f' (скидка {traffic_discount_percent}%: -{total_traffic_discount / 100}₽)'
|
||||
logger.debug(message)
|
||||
if total_servers_price > 0:
|
||||
message = (
|
||||
f' Серверы: {servers_price_per_month / 100}₽/мес × {months_in_period} = {total_servers_price / 100}₽'
|
||||
)
|
||||
if total_servers_discount > 0:
|
||||
message += f' (скидка {servers_discount_percent}%: -{total_servers_discount / 100}₽)'
|
||||
logger.debug(message)
|
||||
if total_devices_price > 0:
|
||||
message = (
|
||||
f' Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period} = {total_devices_price / 100}₽'
|
||||
)
|
||||
if total_devices_discount > 0:
|
||||
message += f' (скидка {devices_discount_percent}%: -{total_devices_discount / 100}₽)'
|
||||
logger.debug(message)
|
||||
logger.debug('ИТОГО: ₽', total_cost=total_cost / 100)
|
||||
|
||||
return total_cost, details
|
||||
|
||||
|
||||
async def get_subscription_server_ids(db: AsyncSession, subscription_id: int) -> list[int]:
|
||||
result = await db.execute(
|
||||
select(SubscriptionServer.server_squad_id).where(SubscriptionServer.subscription_id == subscription_id)
|
||||
@@ -1901,8 +1730,9 @@ async def get_disabled_daily_subscriptions_for_resume(
|
||||
# Не возобновляем подписки, приостановленные пользователем вручную
|
||||
# is_(False) не ловит NULL, поэтому добавляем OR is_(None)
|
||||
(Subscription.is_daily_paused.is_(False) | Subscription.is_daily_paused.is_(None)),
|
||||
# Баланс пользователя >= суточной цены тарифа
|
||||
User.balance_kopeks >= Tariff.daily_price_kopeks,
|
||||
# Баланс пользователя > 0 (permissive pre-filter;
|
||||
# actual discounted price check happens in _process_single_charge)
|
||||
User.balance_kopeks > 0,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -1947,8 +1777,9 @@ async def get_expired_daily_subscriptions_for_recovery(db: AsyncSession) -> list
|
||||
Subscription.is_trial.is_(False),
|
||||
# Только недавно экспайренные
|
||||
Subscription.updated_at >= recovery_threshold,
|
||||
# Баланс достаточен для списания
|
||||
User.balance_kopeks >= Tariff.daily_price_kopeks,
|
||||
# Баланс > 0 (permissive pre-filter;
|
||||
# actual discounted price check happens in _process_single_charge)
|
||||
User.balance_kopeks > 0,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -61,16 +61,17 @@ async def get_conversion_statistics(db: AsyncSession) -> dict:
|
||||
total_conversions = total_conversions_result.scalar() or 0
|
||||
|
||||
# Подсчитываем пользователей с платными подписками
|
||||
users_with_paid_result = await db.execute(select(func.count(User.id)).where(User.has_had_paid_subscription == True))
|
||||
users_with_paid_result = await db.execute(
|
||||
select(func.count(User.id)).where(User.has_had_paid_subscription.is_(True))
|
||||
)
|
||||
users_with_paid = users_with_paid_result.scalar() or 0
|
||||
|
||||
# Подсчитываем всех пользователей с подписками (использовавших триал)
|
||||
# Считаем что все новые пользователи начинают с триала
|
||||
total_users_with_subscriptions_result = await db.execute(select(func.count(func.distinct(Subscription.user_id))))
|
||||
total_users_with_subscriptions = total_users_with_subscriptions_result.scalar() or 0
|
||||
|
||||
# Расчёт конверсии: (оплатившие) / (всего с подписками) * 100
|
||||
# Это показывает какой % пользователей, получивших подписку, в итоге оплатили
|
||||
# Знаменатель = все юзеры с подписками (включая уже конвертированных)
|
||||
if total_users_with_subscriptions > 0:
|
||||
conversion_rate = round((users_with_paid / total_users_with_subscriptions) * 100, 1)
|
||||
else:
|
||||
|
||||
@@ -25,6 +25,8 @@ REAL_PAYMENT_METHODS = [
|
||||
PaymentMethod.CLOUDPAYMENTS.value,
|
||||
PaymentMethod.FREEKASSA.value,
|
||||
PaymentMethod.KASSA_AI.value,
|
||||
PaymentMethod.RIOPAY.value,
|
||||
PaymentMethod.SEVERPAY.value,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -415,9 +415,19 @@ async def lock_user_for_update(db: AsyncSession, user: User) -> User:
|
||||
|
||||
Returns the refreshed user object with current DB values.
|
||||
Must be called within an active transaction before modifying balance_kopeks.
|
||||
Eagerly loads key relationships to avoid MissingGreenlet in async context.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True)
|
||||
select(User)
|
||||
.where(User.id == user.id)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
@@ -434,8 +444,18 @@ async def add_user_balance(
|
||||
) -> bool:
|
||||
try:
|
||||
# Lock the user row to prevent concurrent balance race conditions
|
||||
# Eagerly load key relationships to avoid MissingGreenlet in async context
|
||||
locked_result = await db.execute(
|
||||
select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True)
|
||||
select(User)
|
||||
.where(User.id == user.id)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
user = locked_result.scalar_one()
|
||||
|
||||
@@ -508,6 +528,27 @@ async def add_user_balance_by_id(
|
||||
return False
|
||||
|
||||
|
||||
async def lock_user_for_pricing(db: AsyncSession, user_id: int) -> User:
|
||||
"""Lock user row with FOR UPDATE and return refreshed instance.
|
||||
|
||||
Call BEFORE computing prices that depend on promo offer state
|
||||
to prevent TOCTOU race conditions where two concurrent requests
|
||||
both read the same promo offer discount and charge a discounted price.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.id == user_id)
|
||||
.options(
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.subscription).selectinload(Subscription.tariff),
|
||||
)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def subtract_user_balance(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
@@ -534,8 +575,18 @@ async def subtract_user_balance(
|
||||
)
|
||||
|
||||
# Lock the user row to prevent concurrent balance race conditions
|
||||
# Eagerly load key relationships to avoid MissingGreenlet in async context
|
||||
locked_result = await db.execute(
|
||||
select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True)
|
||||
select(User)
|
||||
.where(User.id == user.id)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
user = locked_result.scalar_one()
|
||||
|
||||
@@ -1161,8 +1212,11 @@ async def create_user_by_email(
|
||||
|
||||
|
||||
async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
|
||||
"""Get user by email address."""
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
"""Get user by email address (case-insensitive)."""
|
||||
if not email or not email.strip():
|
||||
return None
|
||||
email_lower = email.strip().lower()
|
||||
result = await db.execute(select(User).where(func.lower(User.email) == email_lower))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
@@ -1178,7 +1232,10 @@ async def is_email_taken(db: AsyncSession, email: str, exclude_user_id: int | No
|
||||
Returns:
|
||||
True if email is taken, False otherwise
|
||||
"""
|
||||
query = select(User.id).where(User.email == email)
|
||||
if not email or not email.strip():
|
||||
return False
|
||||
email_lower = email.strip().lower()
|
||||
query = select(User.id).where(func.lower(User.email) == email_lower)
|
||||
if exclude_user_id:
|
||||
query = query.where(User.id != exclude_user_id)
|
||||
result = await db.execute(query)
|
||||
|
||||
+65
-1
@@ -159,6 +159,7 @@ class PaymentMethod(Enum):
|
||||
FREEKASSA = 'freekassa'
|
||||
KASSA_AI = 'kassa_ai'
|
||||
RIOPAY = 'riopay'
|
||||
SEVERPAY = 'severpay'
|
||||
MANUAL = 'manual'
|
||||
BALANCE = 'balance'
|
||||
|
||||
@@ -756,7 +757,7 @@ class RioPayPayment(Base):
|
||||
__tablename__ = 'riopay_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
|
||||
# Идентификаторы
|
||||
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
|
||||
@@ -812,6 +813,69 @@ class RioPayPayment(Base):
|
||||
return f'<RioPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class SeverPayPayment(Base):
|
||||
"""Платежи через SeverPay (severpay.io)."""
|
||||
|
||||
__tablename__ = 'severpay_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
|
||||
# Идентификаторы
|
||||
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
|
||||
severpay_id = Column(String(64), unique=True, nullable=True, index=True) # ID от SeverPay
|
||||
severpay_uid = Column(String(64), unique=True, nullable=True, index=True) # UID от SeverPay
|
||||
|
||||
# Суммы
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default='RUB')
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Статусы
|
||||
status = Column(String(32), nullable=False, default='pending')
|
||||
is_paid = Column(Boolean, default=False)
|
||||
|
||||
# Данные платежа
|
||||
payment_url = Column(Text, nullable=True)
|
||||
payment_method = Column(String(32), nullable=True)
|
||||
|
||||
# Метаданные
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
# Временные метки
|
||||
paid_at = Column(AwareDateTime(), nullable=True)
|
||||
expires_at = Column(AwareDateTime(), nullable=True)
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
# Связь с транзакцией
|
||||
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', backref='severpay_payments')
|
||||
transaction = relationship('Transaction', backref='severpay_payment')
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == 'pending'
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return self.status == 'success' and self.is_paid
|
||||
|
||||
@property
|
||||
def is_failed(self) -> bool:
|
||||
return self.status in ['failed', 'expired', 'declined', 'amount_mismatch']
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f'<SeverPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class PromoGroup(Base):
|
||||
__tablename__ = 'promo_groups'
|
||||
|
||||
|
||||
Vendored
+35
-11
@@ -1,5 +1,6 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
@@ -15,7 +16,6 @@ class CryptoBotService:
|
||||
def __init__(self):
|
||||
self.api_token = settings.CRYPTOBOT_API_TOKEN
|
||||
self.base_url = settings.get_cryptobot_base_url()
|
||||
self.webhook_secret = settings.CRYPTOBOT_WEBHOOK_SECRET
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
@@ -122,22 +122,46 @@ class CryptoBotService:
|
||||
return await self._make_request('GET', 'getExchangeRates')
|
||||
|
||||
def verify_webhook_signature(self, body: str, signature: str) -> bool:
|
||||
if not self.webhook_secret:
|
||||
logger.warning('CryptoBot webhook secret не настроен')
|
||||
# По документации CryptoBot, ключ ВСЕГДА SHA256 от API токена
|
||||
token = self.api_token
|
||||
if not token:
|
||||
logger.warning('CryptoBot API token не настроен, пропуск проверки подписи')
|
||||
return True
|
||||
|
||||
try:
|
||||
secret_hash = hashlib.sha256(self.webhook_secret.encode()).digest()
|
||||
expected_signature = hmac.new(secret_hash, body.encode(), hashlib.sha256).hexdigest()
|
||||
secret_hash = hashlib.sha256(token.encode()).digest()
|
||||
|
||||
is_valid = hmac.compare_digest(signature, expected_signature)
|
||||
# 1. Raw body — CryptoBot шлёт compact JSON
|
||||
expected = hmac.new(secret_hash, body.encode('utf-8'), hashlib.sha256).hexdigest()
|
||||
if hmac.compare_digest(signature, expected):
|
||||
logger.info('CryptoBot webhook подпись валидна (raw body)')
|
||||
return True
|
||||
|
||||
if is_valid:
|
||||
logger.info('✅ CryptoBot webhook подпись валидна')
|
||||
else:
|
||||
logger.error('❌ Неверная подпись CryptoBot webhook')
|
||||
# 2. Fallback: re-serialize compact JSON
|
||||
parsed = json.loads(body)
|
||||
check_string = json.dumps(parsed, separators=(',', ':'), ensure_ascii=False)
|
||||
expected_reserialized = hmac.new(secret_hash, check_string.encode('utf-8'), hashlib.sha256).hexdigest()
|
||||
if hmac.compare_digest(signature, expected_reserialized):
|
||||
logger.info('CryptoBot webhook подпись валидна (re-serialized)')
|
||||
return True
|
||||
|
||||
return is_valid
|
||||
# 3. Fallback: ensure_ascii=True
|
||||
check_string_ascii = json.dumps(parsed, separators=(',', ':'), ensure_ascii=True)
|
||||
expected_ascii = hmac.new(secret_hash, check_string_ascii.encode('utf-8'), hashlib.sha256).hexdigest()
|
||||
if hmac.compare_digest(signature, expected_ascii):
|
||||
logger.info('CryptoBot webhook подпись валидна (ascii-escaped)')
|
||||
return True
|
||||
|
||||
logger.error(
|
||||
'Неверная подпись CryptoBot webhook',
|
||||
received_signature=signature,
|
||||
expected_raw=expected,
|
||||
expected_reserialized=expected_reserialized,
|
||||
body_length=len(body),
|
||||
token_length=len(token),
|
||||
token_prefix=token[:4] + '...',
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка проверки подписи CryptoBot webhook', error=e)
|
||||
|
||||
Vendored
+12
-19
@@ -280,12 +280,6 @@ class RemnaWaveAPI:
|
||||
'X-Real-IP': '127.0.0.1',
|
||||
}
|
||||
|
||||
# Caddy авторизация — добавляется поверх основной
|
||||
if self.caddy_token:
|
||||
# Caddy Security: готовый base64 токен используется как есть
|
||||
headers['Authorization'] = f'Basic {self.caddy_token}'
|
||||
logger.debug('Используем Caddy Basic Auth')
|
||||
|
||||
# Основная авторизация RemnaWave API
|
||||
if self.auth_type == 'basic' and self.username and self.password:
|
||||
credentials = f'{self.username}:{self.password}'
|
||||
@@ -293,16 +287,16 @@ class RemnaWaveAPI:
|
||||
headers['X-Api-Key'] = f'Basic {encoded_credentials}'
|
||||
logger.debug('Используем Basic Auth в X-Api-Key заголовке')
|
||||
elif self.auth_type == 'caddy':
|
||||
# Для caddy auth_type основная авторизация уже в Authorization header
|
||||
# Но API ключ всё равно нужен для RemnaWave
|
||||
# Caddy Security: caddy_token → X-Api-Key, api_key → Authorization: Bearer
|
||||
if self.api_key:
|
||||
headers['X-Api-Key'] = self.api_key
|
||||
logger.debug('Используем API ключ для RemnaWave + Caddy авторизацию')
|
||||
headers['Authorization'] = f'Bearer {self.api_key}'
|
||||
if self.caddy_token:
|
||||
headers['X-Api-Key'] = self.caddy_token
|
||||
logger.debug('Используем Caddy авторизацию')
|
||||
else:
|
||||
# api_key или bearer — стандартный режим
|
||||
headers['X-Api-Key'] = self.api_key
|
||||
if not self.caddy_token:
|
||||
headers['Authorization'] = f'Bearer {self.api_key}'
|
||||
headers['Authorization'] = f'Bearer {self.api_key}'
|
||||
logger.debug('Используем API ключ в X-Api-Key заголовке')
|
||||
|
||||
return headers
|
||||
@@ -574,13 +568,12 @@ class RemnaWaveAPI:
|
||||
if external_squad_uuid is not ...:
|
||||
data['externalSquadUuid'] = external_squad_uuid
|
||||
|
||||
logger.info(
|
||||
'PATCH /api/users payload',
|
||||
uuid=uuid,
|
||||
hwidDeviceLimit=data.get('hwidDeviceLimit'),
|
||||
status=data.get('status'),
|
||||
)
|
||||
response = await self._make_request('PATCH', '/api/users', data)
|
||||
try:
|
||||
response = await self._make_request('PATCH', '/api/users', data)
|
||||
except Exception:
|
||||
# Логируем полный payload при ошибке для диагностики A039
|
||||
logger.error('PATCH /api/users FAILED — full payload', payload=data)
|
||||
raise
|
||||
user = self._parse_user(response['response'])
|
||||
logger.info(
|
||||
'PATCH /api/users response',
|
||||
|
||||
Vendored
+13
-4
@@ -72,18 +72,21 @@ class TributeService:
|
||||
status = None
|
||||
amount_kopeks = 0
|
||||
telegram_user_id = None
|
||||
trb_user_id = None
|
||||
|
||||
payment_id = webhook_data.get('id') or webhook_data.get('payment_id')
|
||||
status = webhook_data.get('status')
|
||||
amount_kopeks = webhook_data.get('amount', 0)
|
||||
telegram_user_id = webhook_data.get('telegram_user_id') or webhook_data.get('user_id')
|
||||
telegram_user_id = webhook_data.get('telegram_user_id')
|
||||
trb_user_id = webhook_data.get('trb_user_id')
|
||||
|
||||
if not payment_id and 'payload' in webhook_data:
|
||||
data = webhook_data['payload']
|
||||
payment_id = data.get('id') or data.get('payment_id')
|
||||
status = data.get('status')
|
||||
amount_kopeks = data.get('amount', 0)
|
||||
telegram_user_id = data.get('telegram_user_id') or data.get('user_id')
|
||||
telegram_user_id = data.get('telegram_user_id')
|
||||
trb_user_id = data.get('trb_user_id')
|
||||
|
||||
if not payment_id and 'name' in webhook_data:
|
||||
event_name = webhook_data.get('name')
|
||||
@@ -91,6 +94,7 @@ class TributeService:
|
||||
payment_id = str(data.get('donation_request_id'))
|
||||
amount_kopeks = data.get('amount', 0)
|
||||
telegram_user_id = data.get('telegram_user_id')
|
||||
trb_user_id = data.get('trb_user_id')
|
||||
|
||||
if event_name in ('new_donation', 'recurrent_donation'):
|
||||
status = 'paid'
|
||||
@@ -100,15 +104,19 @@ class TributeService:
|
||||
status = 'unknown'
|
||||
|
||||
logger.info(
|
||||
'📝 Извлеченные данные: payment_id=, status=, amount_kopeks=, user_id',
|
||||
'📝 Извлеченные данные: payment_id=, status=, amount_kopeks=, telegram_user_id=, trb_user_id=',
|
||||
payment_id=payment_id,
|
||||
status=status,
|
||||
amount_kopeks=amount_kopeks,
|
||||
telegram_user_id=telegram_user_id,
|
||||
trb_user_id=trb_user_id,
|
||||
)
|
||||
|
||||
if not telegram_user_id:
|
||||
logger.error('❌ Не найден telegram_user_id в webhook данных')
|
||||
logger.error(
|
||||
'❌ Не найден telegram_user_id в webhook данных',
|
||||
trb_user_id=trb_user_id,
|
||||
)
|
||||
logger.error(
|
||||
'🔍 Полные данные для отладки', dumps=json.dumps(webhook_data, ensure_ascii=False, indent=2)
|
||||
)
|
||||
@@ -124,6 +132,7 @@ class TributeService:
|
||||
'event_type': 'payment',
|
||||
'payment_id': payment_id or f'tribute_{telegram_user_id}_{amount_kopeks}',
|
||||
'user_id': telegram_user_id,
|
||||
'trb_user_id': trb_user_id,
|
||||
'amount_kopeks': int(amount_kopeks) if amount_kopeks else 0,
|
||||
'status': status or 'paid',
|
||||
'external_id': f'donation_{payment_id or "unknown"}',
|
||||
|
||||
Vendored
+4
-1
@@ -377,7 +377,10 @@ class WebhookServer:
|
||||
signature = request.headers.get('Crypto-Pay-API-Signature')
|
||||
logger.info('CryptoBot Signature', signature=signature)
|
||||
|
||||
if signature and settings.CRYPTOBOT_WEBHOOK_SECRET:
|
||||
if settings.CRYPTOBOT_API_TOKEN:
|
||||
if not signature:
|
||||
logger.error('CryptoBot webhook без подписи')
|
||||
return web.json_response({'status': 'error', 'reason': 'missing_signature'}, status=401)
|
||||
from app.external.cryptobot import CryptoBotService
|
||||
|
||||
cryptobot_service = CryptoBotService()
|
||||
|
||||
@@ -63,7 +63,7 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
|
||||
},
|
||||
'payments': {
|
||||
'title': '💳 Платежные системы',
|
||||
'description': 'YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute, Kassa AI, RioPay и Telegram Stars.',
|
||||
'description': 'YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute, Kassa AI, RioPay, SeverPay и Telegram Stars.',
|
||||
'icon': '💳',
|
||||
'categories': (
|
||||
'PAYMENT',
|
||||
@@ -75,6 +75,7 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
|
||||
'FREEKASSA',
|
||||
'KASSA_AI',
|
||||
'RIOPAY',
|
||||
'SEVERPAY',
|
||||
'MULENPAY',
|
||||
'PAL24',
|
||||
'WATA',
|
||||
@@ -1256,6 +1257,9 @@ def _build_settings_keyboard(
|
||||
elif category_key == 'RIOPAY':
|
||||
label = texts.t('PAYMENT_RIOPAY', f'💳 {settings.get_riopay_display_name()}')
|
||||
test_payment_buttons.append([_test_button(f'{label} · тест', 'riopay')])
|
||||
elif category_key == 'SEVERPAY':
|
||||
label = texts.t('PAYMENT_SEVERPAY', f'💳 {settings.get_severpay_display_name()}')
|
||||
test_payment_buttons.append([_test_button(f'{label} · тест', 'severpay')])
|
||||
|
||||
if test_payment_buttons:
|
||||
rows.extend(test_payment_buttons)
|
||||
|
||||
@@ -1103,13 +1103,27 @@ async def confirm_button_selection(callback: types.CallbackQuery, db_user: User,
|
||||
await callback.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
await callback.bot.send_photo(
|
||||
chat_id=callback.message.chat.id,
|
||||
photo=media_file_id,
|
||||
caption=preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
# Telegram ограничивает caption до 1024 символов
|
||||
if len(preview_text) <= 1024:
|
||||
await callback.bot.send_photo(
|
||||
chat_id=callback.message.chat.id,
|
||||
photo=media_file_id,
|
||||
caption=preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
# Фото без caption + текст отдельным сообщением
|
||||
await callback.bot.send_photo(
|
||||
chat_id=callback.message.chat.id,
|
||||
photo=media_file_id,
|
||||
)
|
||||
await callback.bot.send_message(
|
||||
chat_id=callback.message.chat.id,
|
||||
text=preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
# Если нет file_id, используем safe редактирование
|
||||
await safe_edit_or_send_text(
|
||||
@@ -1244,13 +1258,27 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
|
||||
'video': 'video',
|
||||
'document': 'document',
|
||||
}[media_type]
|
||||
await send_method(
|
||||
chat_id=telegram_id,
|
||||
**{media_kwarg: media_file_id},
|
||||
caption=message_text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=broadcast_keyboard,
|
||||
)
|
||||
# Telegram ограничивает caption до 1024 символов
|
||||
if len(message_text) <= 1024:
|
||||
await send_method(
|
||||
chat_id=telegram_id,
|
||||
**{media_kwarg: media_file_id},
|
||||
caption=message_text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=broadcast_keyboard,
|
||||
)
|
||||
else:
|
||||
# Медиа без caption + текст отдельным сообщением
|
||||
await send_method(
|
||||
chat_id=telegram_id,
|
||||
**{media_kwarg: media_file_id},
|
||||
)
|
||||
await callback.bot.send_message(
|
||||
chat_id=telegram_id,
|
||||
text=message_text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=broadcast_keyboard,
|
||||
)
|
||||
else:
|
||||
# Неизвестный media_type — отправляем как текст
|
||||
await callback.bot.send_message(
|
||||
|
||||
@@ -4457,6 +4457,11 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
# TOCTOU protection: lock user row before pricing to prevent concurrent balance modifications
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
target_user = await lock_user_for_pricing(db, target_user.id)
|
||||
|
||||
try:
|
||||
price_kopeks = await _calculate_subscription_period_price(
|
||||
db,
|
||||
@@ -4585,11 +4590,10 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
|
||||
if hwid_limit is not None:
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
# Внешний сквад: синхронизируем из тарифа или сбрасываем
|
||||
# Внешний сквад: синхронизируем из тарифа (если задан)
|
||||
# Не отправляем null — RemnaWave API не принимает null для externalSquadUuid (A039)
|
||||
if ext_squad_uuid is not None:
|
||||
update_kwargs['external_squad_uuid'] = ext_squad_uuid
|
||||
else:
|
||||
update_kwargs['external_squad_uuid'] = None
|
||||
|
||||
remnawave_user = await api.update_user(**update_kwargs)
|
||||
else:
|
||||
@@ -4914,7 +4918,7 @@ async def admin_buy_tariff_execute(callback: types.CallbackQuery, db_user: User,
|
||||
user_id = int(parts[4])
|
||||
tariff_id = int(parts[5])
|
||||
period = int(parts[6])
|
||||
price_kopeks = int(parts[7])
|
||||
price_kopeks_from_callback = int(parts[7])
|
||||
|
||||
user_service = UserService()
|
||||
profile = await user_service.get_user_profile(db, user_id)
|
||||
@@ -4933,7 +4937,48 @@ async def admin_buy_tariff_execute(callback: types.CallbackQuery, db_user: User,
|
||||
await callback.answer('❌ Тариф недоступен', show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем баланс ещё раз
|
||||
# TOCTOU protection: lock user row before pricing to prevent concurrent balance modifications
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
target_user = await lock_user_for_pricing(db, target_user.id)
|
||||
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
|
||||
existing_subscription = await get_subscription_by_user_id(db, target_user.id)
|
||||
|
||||
# Recalculate price from locked state (callback data may be stale)
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
pricing_engine = PricingEngine()
|
||||
device_limit = None
|
||||
if existing_subscription and existing_subscription.tariff_id == tariff_id:
|
||||
device_limit = existing_subscription.device_limit
|
||||
|
||||
try:
|
||||
result = await pricing_engine.calculate_tariff_purchase_price(
|
||||
tariff,
|
||||
period,
|
||||
device_limit=device_limit,
|
||||
user=target_user,
|
||||
)
|
||||
price_kopeks = result.final_total
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка расчёта стоимости тарифа при списании средств админом для пользователя',
|
||||
telegram_id=target_user.telegram_id,
|
||||
e=e,
|
||||
)
|
||||
await callback.answer('❌ Не удалось рассчитать стоимость тарифа', show_alert=True)
|
||||
return
|
||||
|
||||
if price_kopeks_from_callback != price_kopeks:
|
||||
logger.info(
|
||||
'Стоимость тарифа для пользователя изменилась перед списанием',
|
||||
telegram_id=target_user.telegram_id,
|
||||
price_kopeks_from_callback=price_kopeks_from_callback,
|
||||
price_kopeks=price_kopeks,
|
||||
)
|
||||
|
||||
if target_user.balance_kopeks < price_kopeks:
|
||||
await callback.answer('❌ Недостаточно средств на балансе', show_alert=True)
|
||||
return
|
||||
@@ -4942,7 +4987,6 @@ async def admin_buy_tariff_execute(callback: types.CallbackQuery, db_user: User,
|
||||
from app.database.crud.subscription import (
|
||||
create_paid_subscription,
|
||||
extend_subscription,
|
||||
get_subscription_by_user_id,
|
||||
)
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
@@ -5373,6 +5417,7 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
|
||||
reset_reason='смена тарифа (админ)',
|
||||
sync_squads=True,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -129,9 +129,9 @@ async def process_cloudpayments_payment_amount(
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly (called from quick_amount handlers).
|
||||
Process payment amount directly.
|
||||
|
||||
Similar to process_heleket_payment_amount and other payment handlers.
|
||||
Similar to other payment amount handlers.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -167,6 +167,7 @@ async def process_cloudpayments_payment_amount(
|
||||
'AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount:.0f}₽',
|
||||
).format(min_amount=min_rub),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -177,6 +178,7 @@ async def process_cloudpayments_payment_amount(
|
||||
'AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount:,.0f}₽',
|
||||
).format(max_amount=max_rub),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -195,7 +197,7 @@ async def start_cloudpayments_payment(
|
||||
"""
|
||||
Start CloudPayments payment flow.
|
||||
|
||||
Shows amount input prompt or quick amount buttons.
|
||||
Shows amount input prompt.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -290,6 +292,7 @@ async def process_cloudpayments_amount(
|
||||
'AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount:.0f}₽',
|
||||
).format(min_amount=min_rub),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
@@ -301,6 +304,7 @@ async def process_cloudpayments_amount(
|
||||
'AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount:,.0f}₽',
|
||||
).format(max_amount=max_rub),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
@@ -371,123 +375,3 @@ async def process_cloudpayments_amount(
|
||||
)
|
||||
|
||||
logger.info('CloudPayments payment created: user amount=₽', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_cloudpayments_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Handle quick amount selection for CloudPayments.
|
||||
|
||||
Called when user clicks a predefined amount button.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_cloudpayments_enabled():
|
||||
await callback.answer(
|
||||
texts.t('CLOUDPAYMENTS_NOT_AVAILABLE', 'CloudPayments временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Extract amount from callback data: topup_amount|cloudpayments|{amount_kopeks}
|
||||
try:
|
||||
parts = callback.data.split('|')
|
||||
if len(parts) >= 3:
|
||||
amount_kopeks = int(parts[2])
|
||||
else:
|
||||
await callback.answer('Invalid callback data', show_alert=True)
|
||||
return
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer('Invalid amount', show_alert=True)
|
||||
return
|
||||
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
# Validate amount
|
||||
if amount_kopeks < settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS:
|
||||
await callback.answer(
|
||||
texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS:
|
||||
await callback.answer(
|
||||
texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
# Create payment
|
||||
payment_service = PaymentService()
|
||||
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description=settings.CLOUDPAYMENTS_DESCRIPTION,
|
||||
)
|
||||
|
||||
result = await payment_service.create_cloudpayments_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
telegram_id=db_user.telegram_id,
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not result:
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'PAYMENT_CREATE_ERROR',
|
||||
'Не удалось создать платёж. Попробуйте позже.',
|
||||
),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
|
||||
# Create keyboard with payment button
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
'PAY_BUTTON',
|
||||
'💳 Оплатить {amount}₽',
|
||||
).format(amount=f'{amount_rub:.0f}'),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '◀️ Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'CLOUDPAYMENTS_PAYMENT_CREATED',
|
||||
'💳 <b>Оплата банковской картой</b>\n\n'
|
||||
'Сумма: <b>{amount}₽</b>\n\n'
|
||||
'Нажмите кнопку ниже для оплаты.\n'
|
||||
'После успешной оплаты баланс будет пополнен автоматически.',
|
||||
).format(amount=f'{amount_rub:.2f}'),
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'CloudPayments payment created (quick): user amount=₽', telegram_id=db_user.telegram_id, amount_rub=amount_rub
|
||||
)
|
||||
|
||||
@@ -53,41 +53,18 @@ async def start_cryptobot_payment(callback: types.CallbackQuery, db_user: User,
|
||||
available_assets = settings.get_cryptobot_assets()
|
||||
assets_text = ', '.join(available_assets)
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
message_text = (
|
||||
f'🪙 <b>Пополнение криптовалютой</b>\n\n'
|
||||
f'Выберите сумму пополнения или введите вручную сумму '
|
||||
f'от 100 до 100,000 ₽:\n\n'
|
||||
f'💰 Доступные активы: {assets_text}\n'
|
||||
f'⚡ Мгновенное зачисление на баланс\n'
|
||||
f'🔒 Безопасная оплата через CryptoBot\n\n'
|
||||
f'{rate_text}\n'
|
||||
f'Сумма будет автоматически конвертирована в USD для оплаты.'
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f'🪙 <b>Пополнение криптовалютой</b>\n\n'
|
||||
f'Введите сумму для пополнения от 100 до 100,000 ₽:\n\n'
|
||||
f'💰 Доступные активы: {assets_text}\n'
|
||||
f'⚡ Мгновенное зачисление на баланс\n'
|
||||
f'🔒 Безопасная оплата через CryptoBot\n\n'
|
||||
f'{rate_text}\n'
|
||||
f'Сумма будет автоматически конвертирована в USD для оплаты.'
|
||||
)
|
||||
message_text = (
|
||||
f'🪙 <b>Пополнение криптовалютой</b>\n\n'
|
||||
f'Введите сумму для пополнения от 100 до 100,000 ₽:\n\n'
|
||||
f'💰 Доступные активы: {assets_text}\n'
|
||||
f'⚡ Мгновенное зачисление на баланс\n'
|
||||
f'🔒 Безопасная оплата через CryptoBot\n\n'
|
||||
f'{rate_text}\n'
|
||||
f'Сумма будет автоматически конвертирована в USD для оплаты.'
|
||||
)
|
||||
|
||||
# Создаем клавиатуру
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(message_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
@@ -133,11 +110,13 @@ async def process_cryptobot_payment_amount(
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
if amount_rubles < 100:
|
||||
await message.answer('Минимальная сумма пополнения: 100 ₽')
|
||||
await message.answer('Минимальная сумма пополнения: 100 ₽', reply_markup=get_back_keyboard(db_user.language))
|
||||
return
|
||||
|
||||
if amount_rubles > 100000:
|
||||
await message.answer('Максимальная сумма пополнения: 100,000 ₽')
|
||||
await message.answer(
|
||||
'Максимальная сумма пополнения: 100,000 ₽', reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -154,11 +133,15 @@ async def process_cryptobot_payment_amount(
|
||||
amount_usd = round(amount_usd, 2)
|
||||
|
||||
if amount_usd < 1:
|
||||
await message.answer('❌ Минимальная сумма для оплаты в USD: 1.00 USD')
|
||||
await message.answer(
|
||||
'❌ Минимальная сумма для оплаты в USD: 1.00 USD', reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
if amount_usd > 1000:
|
||||
await message.answer('❌ Максимальная сумма для оплаты в USD: 1,000 USD')
|
||||
await message.answer(
|
||||
'❌ Максимальная сумма для оплаты в USD: 1,000 USD', reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
|
||||
@@ -154,7 +154,7 @@ async def process_freekassa_payment_amount(
|
||||
payment_method: str | None = None,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly (called from quick_amount handlers).
|
||||
Process payment amount directly.
|
||||
payment_method: 'freekassa', 'freekassa_sbp', 'freekassa_card'
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
@@ -186,6 +186,7 @@ async def process_freekassa_payment_amount(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}₽',
|
||||
).format(min_amount=min_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
@@ -196,6 +197,7 @@ async def process_freekassa_payment_amount(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}₽',
|
||||
).format(max_amount=max_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
@@ -368,127 +370,3 @@ async def process_freekassa_custom_amount(
|
||||
state=state,
|
||||
payment_method=data.get('payment_method'),
|
||||
)
|
||||
|
||||
|
||||
async def _process_freekassa_quick_amount_impl(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
payment_method: str,
|
||||
):
|
||||
"""
|
||||
Process quick amount selection for Freekassa payment.
|
||||
Called when user clicks a predefined amount button.
|
||||
payment_method: 'freekassa', 'freekassa_sbp', 'freekassa_card'
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_freekassa_enabled():
|
||||
await callback.answer(
|
||||
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if payment_method == 'freekassa_sbp' and not settings.is_freekassa_sbp_enabled():
|
||||
await callback.answer(
|
||||
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if payment_method == 'freekassa_card' and not settings.is_freekassa_card_enabled():
|
||||
await callback.answer(
|
||||
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Extract amount from callback data: topup_amount|{method}|{amount_kopeks}
|
||||
try:
|
||||
parts = callback.data.split('|')
|
||||
if len(parts) >= 3:
|
||||
amount_kopeks = int(parts[2])
|
||||
else:
|
||||
await callback.answer('Invalid callback data', show_alert=True)
|
||||
return
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer('Invalid amount', show_alert=True)
|
||||
return
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.FREEKASSA_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.FREEKASSA_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await callback.answer(
|
||||
texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await callback.answer(
|
||||
texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
await state.clear()
|
||||
|
||||
await _create_freekassa_payment_and_respond(
|
||||
message_or_callback=callback.message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=True,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_freekassa_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_freekassa_sbp_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa_sbp')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_freekassa_card_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa_card')
|
||||
|
||||
@@ -72,13 +72,6 @@ async def start_heleket_payment(
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_buttons:
|
||||
keyboard.inline_keyboard = quick_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
'\n'.join(filter(None, message_lines)),
|
||||
reply_markup=keyboard,
|
||||
@@ -129,11 +122,13 @@ async def process_heleket_payment_amount(
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
if amount_rubles < 100:
|
||||
await message.answer('Минимальная сумма пополнения: 100 ₽')
|
||||
await message.answer('Минимальная сумма пополнения: 100 ₽', reply_markup=get_back_keyboard(db_user.language))
|
||||
return
|
||||
|
||||
if amount_rubles > 100000:
|
||||
await message.answer('Максимальная сумма пополнения: 100,000 ₽')
|
||||
await message.answer(
|
||||
'Максимальная сумма пополнения: 100,000 ₽', reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
|
||||
@@ -10,6 +10,7 @@ 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.kassa_ai_service import KASSA_AI_SUB_METHODS
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
@@ -18,23 +19,55 @@ from app.utils.decorators import error_handler
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# --- Enabled check + display name lookup by payment method ---
|
||||
|
||||
_KASSA_AI_METHOD_CONFIG = {
|
||||
'kassa_ai': {
|
||||
'is_enabled': settings.is_kassa_ai_enabled,
|
||||
'display_name': settings.get_kassa_ai_display_name,
|
||||
'unavailable_text': 'KassaAI временно недоступен',
|
||||
},
|
||||
'kassa_ai_sbp': {
|
||||
'is_enabled': settings.is_kassa_ai_sbp_enabled,
|
||||
'display_name': settings.get_kassa_ai_sbp_display_name,
|
||||
'unavailable_text': 'KassaAI СБП временно недоступен',
|
||||
},
|
||||
'kassa_ai_card': {
|
||||
'is_enabled': settings.is_kassa_ai_card_enabled,
|
||||
'display_name': settings.get_kassa_ai_card_display_name,
|
||||
'unavailable_text': 'KassaAI Карта временно недоступна',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _check_topup_restriction(callback: types.CallbackQuery, db_user: User) -> bool:
|
||||
"""Check if user has topup restriction. Returns True if restricted (handler should abort)."""
|
||||
if not getattr(db_user, 'restriction_topup', False):
|
||||
return False
|
||||
texts = get_texts(db_user.language)
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
await callback.message.edit_text(
|
||||
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def _create_kassa_ai_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
payment_method: str = 'kassa_ai',
|
||||
):
|
||||
"""
|
||||
Common logic for creating KassaAI payment and sending response.
|
||||
|
||||
Args:
|
||||
message_or_callback: Either a Message or CallbackQuery object
|
||||
db_user: User object
|
||||
db: Database session
|
||||
amount_kopeks: Amount in kopeks
|
||||
edit_message: Whether to edit existing message or send new one
|
||||
"""
|
||||
"""Common logic for creating KassaAI payment and sending response."""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
@@ -46,6 +79,9 @@ async def _create_kassa_ai_payment_and_respond(
|
||||
description='Пополнение баланса',
|
||||
)
|
||||
|
||||
sub = KASSA_AI_SUB_METHODS.get(payment_method)
|
||||
payment_system_id = sub['payment_system_id'] if sub else settings.KASSA_AI_PAYMENT_SYSTEM_ID
|
||||
|
||||
result = await payment_service.create_kassa_ai_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
@@ -53,6 +89,7 @@ async def _create_kassa_ai_payment_and_respond(
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
payment_system_id=payment_system_id,
|
||||
)
|
||||
|
||||
if not result:
|
||||
@@ -74,7 +111,8 @@ async def _create_kassa_ai_payment_and_respond(
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
display_name = settings.get_kassa_ai_display_name()
|
||||
cfg = _KASSA_AI_METHOD_CONFIG.get(payment_method, _KASSA_AI_METHOD_CONFIG['kassa_ai'])
|
||||
display_name = cfg['display_name']()
|
||||
|
||||
# Create keyboard with payment button
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
@@ -128,10 +166,9 @@ async def process_kassa_ai_payment_amount(
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
payment_method: str = 'kassa_ai',
|
||||
):
|
||||
"""
|
||||
Process payment amount directly (called from quick_amount handlers).
|
||||
"""
|
||||
"""Process payment amount directly (called from custom_amount handlers)."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
@@ -161,6 +198,7 @@ async def process_kassa_ai_payment_amount(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}₽',
|
||||
).format(min_amount=min_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
@@ -171,6 +209,7 @@ async def process_kassa_ai_payment_amount(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}₽',
|
||||
).format(max_amount=max_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
@@ -183,53 +222,40 @@ async def process_kassa_ai_payment_amount(
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_kassa_ai_topup(
|
||||
# --- Generic start/quick-amount implementations ---
|
||||
|
||||
|
||||
async def _start_kassa_ai_sub_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
payment_method: str,
|
||||
):
|
||||
"""
|
||||
Start KassaAI top-up process - ask for amount.
|
||||
"""
|
||||
"""Generic start topup handler for any KassaAI sub-method."""
|
||||
cfg = _KASSA_AI_METHOD_CONFIG[payment_method]
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
if not cfg['is_enabled']():
|
||||
await callback.answer(texts.t('KASSA_AI_NOT_AVAILABLE', cfg['unavailable_text']), show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
if await _check_topup_restriction(callback, db_user):
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method='kassa_ai')
|
||||
await state.update_data(payment_method=payment_method)
|
||||
|
||||
min_amount = settings.KASSA_AI_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.KASSA_AI_MAX_AMOUNT_KOPEKS // 100
|
||||
display_name = settings.get_kassa_ai_display_name()
|
||||
display_name = cfg['display_name']()
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '◀️ Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
]
|
||||
inline_keyboard=[[InlineKeyboardButton(text=texts.t('BACK_BUTTON', '◀️ Назад'), callback_data='menu_balance')]]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -249,6 +275,20 @@ async def start_kassa_ai_topup(
|
||||
)
|
||||
|
||||
|
||||
# --- Public handler functions (registered in main.py) ---
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_kassa_ai_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Start KassaAI top-up process - ask for amount."""
|
||||
await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_kassa_ai_custom_amount(
|
||||
message: types.Message,
|
||||
@@ -256,11 +296,10 @@ async def process_kassa_ai_custom_amount(
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process custom amount input for KassaAI payment.
|
||||
"""
|
||||
"""Process custom amount input for KassaAI payment."""
|
||||
data = await state.get_data()
|
||||
if data.get('payment_method') != 'kassa_ai':
|
||||
pm = data.get('payment_method', 'kassa_ai')
|
||||
if pm not in _KASSA_AI_METHOD_CONFIG:
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
@@ -285,82 +324,27 @@ async def process_kassa_ai_custom_amount(
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
state=state,
|
||||
payment_method=pm,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_kassa_ai_quick_amount(
|
||||
async def start_kassa_ai_sbp_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process quick amount selection for KassaAI payment.
|
||||
Called when user clicks a predefined amount button.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
"""Start KassaAI SBP top-up process."""
|
||||
await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai_sbp')
|
||||
|
||||
if not settings.is_kassa_ai_enabled():
|
||||
await callback.answer(
|
||||
texts.t('KASSA_AI_NOT_AVAILABLE', 'KassaAI временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Extract amount from callback data: topup_amount|kassa_ai|{amount_kopeks}
|
||||
try:
|
||||
parts = callback.data.split('|')
|
||||
if len(parts) >= 3:
|
||||
amount_kopeks = int(parts[2])
|
||||
else:
|
||||
await callback.answer('Invalid callback data', show_alert=True)
|
||||
return
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer('Invalid amount', show_alert=True)
|
||||
return
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.KASSA_AI_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.KASSA_AI_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await callback.answer(
|
||||
texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await callback.answer(
|
||||
texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
await state.clear()
|
||||
|
||||
await _create_kassa_ai_payment_and_respond(
|
||||
message_or_callback=callback.message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=True,
|
||||
)
|
||||
@error_handler
|
||||
async def start_kassa_ai_card_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Start KassaAI Card top-up process."""
|
||||
await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai_card')
|
||||
|
||||
+48
-207
@@ -18,7 +18,6 @@ from app.keyboards.inline import (
|
||||
from app.localization.texts import get_texts
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
from app.utils.price_display import calculate_user_price
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -132,11 +131,20 @@ async def route_payment_by_method(
|
||||
)
|
||||
return True
|
||||
|
||||
if payment_method == 'kassa_ai':
|
||||
if payment_method in ('kassa_ai', 'kassa_ai_sbp', 'kassa_ai_card'):
|
||||
from .kassa_ai import process_kassa_ai_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_kassa_ai_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
await process_kassa_ai_payment_amount(
|
||||
message, db_user, db, amount_kopeks, state, payment_method=payment_method
|
||||
)
|
||||
return True
|
||||
|
||||
if payment_method == 'severpay':
|
||||
from .severpay import process_severpay_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_severpay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == 'riopay':
|
||||
@@ -149,159 +157,6 @@ async def route_payment_by_method(
|
||||
return False
|
||||
|
||||
|
||||
async def get_quick_amount_buttons(language: str, user: User) -> list:
|
||||
"""
|
||||
Generate quick amount buttons with user-specific pricing and discounts.
|
||||
|
||||
Includes full subscription cost: base period price + devices + servers + traffic.
|
||||
|
||||
Args:
|
||||
language: User's language for formatting
|
||||
user: User object to calculate personalized discounts
|
||||
|
||||
Returns:
|
||||
List of button rows for inline keyboard
|
||||
"""
|
||||
if not settings.is_quick_amount_buttons_enabled():
|
||||
return []
|
||||
|
||||
from app.config import PERIOD_PRICES
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.utils.pricing_utils import apply_percentage_discount, calculate_months_from_days
|
||||
|
||||
texts = get_texts(language)
|
||||
|
||||
tariff = None
|
||||
tariff_prices = None
|
||||
tariff_periods = None
|
||||
devices_price_per_month = 0
|
||||
servers_per_month_prices: list[int] = []
|
||||
traffic_price_per_month = 0
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
# В режиме тарифов получаем цены из тарифа пользователя
|
||||
if settings.is_tariffs_mode() and subscription and subscription.tariff_id:
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff and tariff.period_prices:
|
||||
tariff_prices = {int(k): v for k, v in tariff.period_prices.items()}
|
||||
tariff_periods = sorted(tariff_prices.keys())
|
||||
|
||||
# Получаем стоимость устройств, серверов и трафика из подписки
|
||||
if subscription and not subscription.is_trial:
|
||||
# Устройства: в режиме тарифов используем цену и базовый лимит из тарифа
|
||||
if settings.is_tariffs_mode() and tariff and tariff_prices:
|
||||
tariff_device_price = getattr(tariff, 'device_price_kopeks', None)
|
||||
if tariff_device_price and tariff_device_price > 0:
|
||||
device_unit_price = tariff_device_price
|
||||
base_device_limit = tariff.device_limit or 0
|
||||
else:
|
||||
device_unit_price = settings.PRICE_PER_DEVICE
|
||||
base_device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
device_unit_price = settings.PRICE_PER_DEVICE
|
||||
base_device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
device_limit = subscription.device_limit or base_device_limit
|
||||
additional_devices = max(0, device_limit - base_device_limit)
|
||||
if additional_devices > 0:
|
||||
devices_price_per_month = additional_devices * device_unit_price
|
||||
|
||||
# Серверы
|
||||
connected_squads = subscription.connected_squads or []
|
||||
if connected_squads:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
_, servers_per_month_prices = await subscription_service.get_countries_price_by_uuids(
|
||||
connected_squads, db, promo_group_id=user.promo_group_id
|
||||
)
|
||||
|
||||
# Трафик
|
||||
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
|
||||
buttons = []
|
||||
|
||||
# Используем периоды тарифа в режиме тарифов, иначе стандартные
|
||||
if tariff_periods:
|
||||
periods = tariff_periods[:6]
|
||||
else:
|
||||
periods = settings.get_available_subscription_periods()[:6]
|
||||
|
||||
for period in periods:
|
||||
# Получаем цену из тарифа или из PERIOD_PRICES
|
||||
if tariff_prices and period in tariff_prices:
|
||||
base_price_kopeks = tariff_prices[period]
|
||||
else:
|
||||
base_price_kopeks = PERIOD_PRICES.get(period, 0)
|
||||
|
||||
if base_price_kopeks > 0:
|
||||
# Базовая цена периода с промо-скидками
|
||||
price_info = calculate_user_price(user, base_price_kopeks, period, 'period')
|
||||
|
||||
months = calculate_months_from_days(period)
|
||||
|
||||
# Стоимость устройств со скидкой
|
||||
devices_addon = 0
|
||||
if devices_price_per_month > 0:
|
||||
devices_discount = user.get_promo_discount('devices', period)
|
||||
devices_discounted, _ = apply_percentage_discount(devices_price_per_month, devices_discount)
|
||||
devices_addon = devices_discounted * months
|
||||
|
||||
# Стоимость серверов со скидкой
|
||||
servers_addon = 0
|
||||
if servers_per_month_prices:
|
||||
servers_discount = user.get_promo_discount('servers', period)
|
||||
for server_price in servers_per_month_prices:
|
||||
discounted, _ = apply_percentage_discount(server_price, servers_discount)
|
||||
servers_addon += discounted
|
||||
servers_addon *= months
|
||||
|
||||
# Стоимость трафика со скидкой
|
||||
traffic_addon = 0
|
||||
if traffic_price_per_month > 0:
|
||||
traffic_discount = user.get_promo_discount('traffic', period)
|
||||
traffic_discounted, _ = apply_percentage_discount(traffic_price_per_month, traffic_discount)
|
||||
traffic_addon = traffic_discounted * months
|
||||
|
||||
total_price = price_info.final_price + devices_addon + servers_addon + traffic_addon
|
||||
callback_data = f'quick_amount_{total_price}'
|
||||
|
||||
period_label = f'{period} дней'
|
||||
|
||||
# Скидка считается от полной базовой стоимости (период + аддоны без скидок)
|
||||
total_base = (
|
||||
base_price_kopeks
|
||||
+ (devices_price_per_month + sum(servers_per_month_prices) + traffic_price_per_month) * months
|
||||
)
|
||||
has_discount = total_base > total_price and total_base > 0
|
||||
|
||||
if has_discount:
|
||||
discount_pct = round((total_base - total_price) * 100 / total_base)
|
||||
if discount_pct > 0:
|
||||
button_text = (
|
||||
f'{texts.format_price(total_base)} ➜ '
|
||||
f'{texts.format_price(total_price)} '
|
||||
f'(-{discount_pct}%) • {period_label}'
|
||||
)
|
||||
else:
|
||||
button_text = f'{texts.format_price(total_price)} • {period_label}'
|
||||
else:
|
||||
button_text = f'{texts.format_price(total_price)} • {period_label}'
|
||||
|
||||
buttons.append(types.InlineKeyboardButton(text=button_text, callback_data=callback_data))
|
||||
|
||||
keyboard_rows = []
|
||||
for i in range(0, len(buttons), 2):
|
||||
keyboard_rows.append(buttons[i : i + 2])
|
||||
|
||||
return keyboard_rows
|
||||
|
||||
|
||||
@error_handler
|
||||
async def show_balance_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
# Проверяем, доступно ли сообщение
|
||||
@@ -428,9 +283,22 @@ async def show_payment_methods(callback: types.CallbackQuery, db_user: User, db:
|
||||
|
||||
payment_text = get_payment_methods_text(db_user.language)
|
||||
|
||||
# Проверяем сохранённую корзину для автоподстановки суммы пополнения
|
||||
amount_kopeks = 0
|
||||
try:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
|
||||
cart_data = await user_cart_service.get_user_cart(db_user.id)
|
||||
if cart_data and cart_data.get('saved_cart'):
|
||||
missing = cart_data.get('missing_amount', 0)
|
||||
if missing > 0:
|
||||
amount_kopeks = missing
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
full_text = payment_text
|
||||
|
||||
keyboard = get_payment_methods_keyboard(0, db_user.language)
|
||||
keyboard = get_payment_methods_keyboard(amount_kopeks, db_user.language)
|
||||
|
||||
# Если сообщение недоступно, отправляем новое
|
||||
if isinstance(callback.message, InaccessibleMessage):
|
||||
@@ -600,11 +468,13 @@ async def process_topup_amount(message: types.Message, db_user: User, state: FSM
|
||||
amount_rubles = float(amount_text.replace(',', '.'))
|
||||
|
||||
if amount_rubles < 1:
|
||||
await message.answer('Минимальная сумма пополнения: 1 ₽')
|
||||
await message.answer('Минимальная сумма пополнения: 1 ₽', reply_markup=get_back_keyboard(db_user.language))
|
||||
return
|
||||
|
||||
if amount_rubles > 50000:
|
||||
await message.answer('Максимальная сумма пополнения: 50,000 ₽')
|
||||
await message.answer(
|
||||
'Максимальная сумма пополнения: 50,000 ₽', reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
amount_kopeks = int(amount_rubles * 100)
|
||||
@@ -614,13 +484,17 @@ async def process_topup_amount(message: types.Message, db_user: User, state: FSM
|
||||
if payment_method in ['yookassa', 'yookassa_sbp']:
|
||||
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
|
||||
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f'❌ Минимальная сумма для оплаты через YooKassa: {min_rubles:.0f} ₽')
|
||||
await message.answer(
|
||||
f'❌ Минимальная сумма для оплаты через YooKassa: {min_rubles:.0f} ₽',
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
|
||||
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(
|
||||
f'❌ Максимальная сумма для оплаты через YooKassa: {max_rubles:,.0f} ₽'.replace(',', ' ')
|
||||
f'❌ Максимальная сумма для оплаты через YooKassa: {max_rubles:,.0f} ₽'.replace(',', ' '),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -670,37 +544,6 @@ async def handle_sbp_payment(callback: types.CallbackQuery, db: AsyncSession):
|
||||
await callback.answer('❌ Ошибка обработки платежа', show_alert=True)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_quick_amount_selection(callback: types.CallbackQuery, db_user: User, state: FSMContext):
|
||||
"""
|
||||
Обработчик выбора суммы через кнопки быстрого выбора
|
||||
"""
|
||||
# Проверяем, что пользователь в правильном состоянии FSM
|
||||
current_state = await state.get_state()
|
||||
if current_state != BalanceStates.waiting_for_amount:
|
||||
await callback.answer('❌ Сначала выберите способ оплаты', show_alert=True)
|
||||
return
|
||||
|
||||
# Извлекаем сумму из callback_data
|
||||
try:
|
||||
amount_kopeks = int(callback.data.split('_')[-1])
|
||||
|
||||
# Получаем метод оплаты из состояния
|
||||
data = await state.get_data()
|
||||
payment_method = data.get('payment_method', 'yookassa')
|
||||
|
||||
# Роутим платеж на соответствующий обработчик
|
||||
if not await route_payment_by_method(callback.message, db_user, amount_kopeks, state, payment_method):
|
||||
await callback.answer('❌ Неизвестный способ оплаты', show_alert=True)
|
||||
return
|
||||
|
||||
except ValueError:
|
||||
await callback.answer('❌ Ошибка обработки суммы', show_alert=True)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка обработки быстрого выбора суммы', error=e)
|
||||
await callback.answer('❌ Ошибка обработки запроса', show_alert=True)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_topup_amount_callback(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -827,36 +670,37 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(start_heleket_payment, F.data == 'topup_heleket')
|
||||
dp.callback_query.register(check_heleket_payment_status, F.data.startswith('check_heleket_'))
|
||||
|
||||
from .cloudpayments import handle_cloudpayments_quick_amount, start_cloudpayments_payment
|
||||
from .cloudpayments import start_cloudpayments_payment
|
||||
|
||||
dp.callback_query.register(start_cloudpayments_payment, F.data == 'topup_cloudpayments')
|
||||
dp.callback_query.register(handle_cloudpayments_quick_amount, F.data.startswith('topup_amount|cloudpayments|'))
|
||||
|
||||
from .freekassa import (
|
||||
process_freekassa_card_quick_amount,
|
||||
process_freekassa_quick_amount,
|
||||
process_freekassa_sbp_quick_amount,
|
||||
start_freekassa_card_topup,
|
||||
start_freekassa_sbp_topup,
|
||||
start_freekassa_topup,
|
||||
)
|
||||
|
||||
dp.callback_query.register(start_freekassa_topup, F.data == 'topup_freekassa')
|
||||
dp.callback_query.register(process_freekassa_quick_amount, F.data.startswith('topup_amount|freekassa|'))
|
||||
dp.callback_query.register(start_freekassa_sbp_topup, F.data == 'topup_freekassa_sbp')
|
||||
dp.callback_query.register(process_freekassa_sbp_quick_amount, F.data.startswith('topup_amount|freekassa_sbp|'))
|
||||
dp.callback_query.register(start_freekassa_card_topup, F.data == 'topup_freekassa_card')
|
||||
dp.callback_query.register(process_freekassa_card_quick_amount, F.data.startswith('topup_amount|freekassa_card|'))
|
||||
|
||||
from .kassa_ai import process_kassa_ai_quick_amount, start_kassa_ai_topup
|
||||
from .kassa_ai import (
|
||||
start_kassa_ai_card_topup,
|
||||
start_kassa_ai_sbp_topup,
|
||||
start_kassa_ai_topup,
|
||||
)
|
||||
|
||||
dp.callback_query.register(start_kassa_ai_topup, F.data == 'topup_kassa_ai')
|
||||
dp.callback_query.register(process_kassa_ai_quick_amount, F.data.startswith('topup_amount|kassa_ai|'))
|
||||
dp.callback_query.register(start_kassa_ai_sbp_topup, F.data == 'topup_kassa_ai_sbp')
|
||||
dp.callback_query.register(start_kassa_ai_card_topup, F.data == 'topup_kassa_ai_card')
|
||||
|
||||
from .riopay import process_riopay_quick_amount, start_riopay_topup
|
||||
from .riopay import start_riopay_topup
|
||||
|
||||
dp.callback_query.register(start_riopay_topup, F.data == 'topup_riopay')
|
||||
dp.callback_query.register(process_riopay_quick_amount, F.data.startswith('topup_amount|riopay|'))
|
||||
|
||||
from .severpay import start_severpay_topup
|
||||
|
||||
dp.callback_query.register(start_severpay_topup, F.data == 'topup_severpay')
|
||||
|
||||
from .mulenpay import check_mulenpay_payment_status
|
||||
|
||||
@@ -876,9 +720,6 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(handle_payment_methods_unavailable, F.data == 'payment_methods_unavailable')
|
||||
|
||||
# Регистрируем обработчик для кнопок быстрого выбора суммы
|
||||
dp.callback_query.register(handle_quick_amount_selection, F.data.startswith('quick_amount_'))
|
||||
|
||||
dp.callback_query.register(handle_topup_amount_callback, F.data.startswith('topup_amount|'))
|
||||
|
||||
dp.callback_query.register(handle_saved_cards_list, F.data == 'saved_cards_list')
|
||||
|
||||
@@ -65,13 +65,6 @@ async def start_mulenpay_payment(
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
@@ -124,13 +117,15 @@ async def process_mulenpay_payment_amount(
|
||||
|
||||
if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
f'Минимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MIN_AMOUNT_KOPEKS)}'
|
||||
f'Минимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MIN_AMOUNT_KOPEKS)}',
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
f'Максимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MAX_AMOUNT_KOPEKS)}'
|
||||
f'Максимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MAX_AMOUNT_KOPEKS)}',
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@@ -303,13 +303,6 @@ async def start_pal24_payment(
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
@@ -359,12 +352,18 @@ async def process_pal24_payment_amount(
|
||||
|
||||
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
|
||||
min_rubles = settings.PAL24_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f'❌ Минимальная сумма для оплаты через PayPalych: {min_rubles:.0f} ₽')
|
||||
await message.answer(
|
||||
f'❌ Минимальная сумма для оплаты через PayPalych: {min_rubles:.0f} ₽',
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
|
||||
max_rubles = settings.PAL24_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f'❌ Максимальная сумма для оплаты через PayPalych: {max_rubles:,.0f} ₽'.replace(',', ' '))
|
||||
await message.answer(
|
||||
f'❌ Максимальная сумма для оплаты через PayPalych: {max_rubles:,.0f} ₽'.replace(',', ' '),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
available_methods = _get_available_pal24_methods()
|
||||
|
||||
@@ -71,13 +71,6 @@ async def _prompt_amount(
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await 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,
|
||||
@@ -250,7 +243,8 @@ async def process_platega_payment_amount(
|
||||
texts.t(
|
||||
'PLATEGA_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма для оплаты через Platega: {amount}',
|
||||
).format(amount=settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS))
|
||||
).format(amount=settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS)),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -259,7 +253,8 @@ async def process_platega_payment_amount(
|
||||
texts.t(
|
||||
'PLATEGA_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма для оплаты через Platega: {amount}',
|
||||
).format(amount=settings.format_price(settings.PLATEGA_MAX_AMOUNT_KOPEKS))
|
||||
).format(amount=settings.format_price(settings.PLATEGA_MAX_AMOUNT_KOPEKS)),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ async def process_riopay_payment_amount(
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly (called from quick_amount handlers).
|
||||
Process payment amount directly.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -161,6 +161,7 @@ async def process_riopay_payment_amount(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}₽',
|
||||
).format(min_amount=min_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
@@ -171,6 +172,7 @@ async def process_riopay_payment_amount(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}₽',
|
||||
).format(max_amount=max_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
@@ -280,75 +282,3 @@ async def process_riopay_custom_amount(
|
||||
amount_kopeks=amount_kopeks,
|
||||
state=state,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_riopay_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process quick amount selection for RioPay payment.
|
||||
Called when user clicks a predefined amount button.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_riopay_enabled():
|
||||
await callback.answer(
|
||||
texts.t('RIOPAY_NOT_AVAILABLE', 'RioPay временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Extract amount from callback data: topup_amount|riopay|{amount_kopeks}
|
||||
try:
|
||||
parts = callback.data.split('|')
|
||||
if len(parts) >= 3:
|
||||
amount_kopeks = int(parts[2])
|
||||
else:
|
||||
await callback.answer('Invalid callback data', show_alert=True)
|
||||
return
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer('Invalid amount', show_alert=True)
|
||||
return
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
await callback.message.edit_text(
|
||||
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.RIOPAY_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.RIOPAY_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await callback.answer(
|
||||
texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await callback.answer(
|
||||
texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
await state.clear()
|
||||
|
||||
await _create_riopay_payment_and_respond(
|
||||
message_or_callback=callback.message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=True,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Handler for SeverPay balance top-up."""
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
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 = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
|
||||
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
|
||||
if not getattr(db_user, 'restriction_topup', False):
|
||||
return None
|
||||
|
||||
keyboard = []
|
||||
support_url = settings.get_support_contact_url()
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
async def _create_severpay_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
):
|
||||
"""
|
||||
Common logic for creating SeverPay payment and sending response.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
# Create payment
|
||||
payment_service = PaymentService()
|
||||
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description='Пополнение баланса',
|
||||
)
|
||||
|
||||
result = await payment_service.create_severpay_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not result:
|
||||
error_text = texts.t(
|
||||
'PAYMENT_CREATE_ERROR',
|
||||
'Не удалось создать платёж. Попробуйте позже.',
|
||||
)
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
error_text,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
display_name = settings.get_severpay_display_name()
|
||||
|
||||
# Create keyboard with payment button
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
'PAY_BUTTON',
|
||||
'💳 Оплатить {amount}₽',
|
||||
).format(amount=f'{amount_rub:.0f}'),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '◀️ Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
response_text = texts.t(
|
||||
'SEVERPAY_PAYMENT_CREATED',
|
||||
'💳 <b>Оплата через {name}</b>\n\n'
|
||||
'Сумма: <b>{amount}₽</b>\n\n'
|
||||
'Нажмите кнопку ниже для оплаты.\n'
|
||||
'После успешной оплаты баланс будет пополнен автоматически.',
|
||||
).format(name=display_name, amount=f'{amount_rub:.2f}')
|
||||
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
logger.info('SeverPay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_severpay_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
await message.answer(
|
||||
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.SEVERPAY_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.SEVERPAY_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}₽',
|
||||
).format(min_amount=min_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}₽',
|
||||
).format(max_amount=max_amount // 100),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
await _create_severpay_payment_and_respond(
|
||||
message_or_callback=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_severpay_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Start SeverPay top-up process - ask for amount.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
await callback.message.edit_text(
|
||||
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method='severpay')
|
||||
|
||||
min_amount = settings.SEVERPAY_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.SEVERPAY_MAX_AMOUNT_KOPEKS // 100
|
||||
display_name = settings.get_severpay_display_name()
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '◀️ Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'SEVERPAY_ENTER_AMOUNT',
|
||||
'💳 <b>Пополнение через {name}</b>\n\n'
|
||||
'Введите сумму пополнения в рублях.\n\n'
|
||||
'Минимум: {min_amount}₽\n'
|
||||
'Максимум: {max_amount}₽',
|
||||
).format(
|
||||
name=display_name,
|
||||
min_amount=min_amount,
|
||||
max_amount=f'{max_amount:,}'.replace(',', ' '),
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
@@ -40,24 +40,10 @@ async def start_stars_payment(callback: types.CallbackQuery, db_user: User, stat
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
message_text = '⭐ <b>Пополнение через Telegram Stars</b>\n\nВыберите сумму пополнения или введите вручную:'
|
||||
else:
|
||||
message_text = texts.TOP_UP_AMOUNT
|
||||
message_text = texts.TOP_UP_AMOUNT
|
||||
|
||||
# Создаем клавиатуру
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(message_text, reply_markup=keyboard)
|
||||
|
||||
await state.update_data(
|
||||
|
||||
@@ -61,13 +61,6 @@ async def start_wata_payment(
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
@@ -120,7 +113,8 @@ async def process_wata_payment_amount(
|
||||
texts.t(
|
||||
'WATA_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {amount}',
|
||||
).format(amount=settings.format_price(settings.WATA_MIN_AMOUNT_KOPEKS))
|
||||
).format(amount=settings.format_price(settings.WATA_MIN_AMOUNT_KOPEKS)),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -129,7 +123,8 @@ async def process_wata_payment_amount(
|
||||
texts.t(
|
||||
'WATA_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {amount}',
|
||||
).format(amount=settings.format_price(settings.WATA_MAX_AMOUNT_KOPEKS))
|
||||
).format(amount=settings.format_price(settings.WATA_MAX_AMOUNT_KOPEKS)),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@@ -46,31 +46,13 @@ async def start_yookassa_payment(callback: types.CallbackQuery, db_user: User, s
|
||||
min_amount_rub = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
||||
max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
message_text = (
|
||||
f'💳 <b>Оплата банковской картой</b>\n\n'
|
||||
f'Выберите сумму пополнения или введите вручную сумму '
|
||||
f'от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f'💳 <b>Оплата банковской картой</b>\n\n'
|
||||
f'Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
|
||||
)
|
||||
message_text = (
|
||||
f'💳 <b>Оплата банковской картой</b>\n\n'
|
||||
f'Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
|
||||
)
|
||||
|
||||
# Создаем клавиатуру
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(message_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
@@ -110,31 +92,13 @@ async def start_yookassa_sbp_payment(callback: types.CallbackQuery, db_user: Use
|
||||
min_amount_rub = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
||||
max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
message_text = (
|
||||
f'🏦 <b>Оплата через СБП</b>\n\n'
|
||||
f'Выберите сумму пополнения или введите вручную сумму '
|
||||
f'от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f'🏦 <b>Оплата через СБП</b>\n\n'
|
||||
f'Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
|
||||
)
|
||||
message_text = (
|
||||
f'🏦 <b>Оплата через СБП</b>\n\n'
|
||||
f'Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
|
||||
)
|
||||
|
||||
# Создаем клавиатуру
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(message_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
@@ -178,12 +142,18 @@ async def process_yookassa_payment_amount(
|
||||
|
||||
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
|
||||
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f'❌ Минимальная сумма для оплаты картой: {min_rubles:.0f} ₽')
|
||||
await message.answer(
|
||||
f'❌ Минимальная сумма для оплаты картой: {min_rubles:.0f} ₽',
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
|
||||
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f'❌ Максимальная сумма для оплаты картой: {max_rubles:,.0f} ₽'.replace(',', ' '))
|
||||
await message.answer(
|
||||
f'❌ Максимальная сумма для оплаты картой: {max_rubles:,.0f} ₽'.replace(',', ' '),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -327,12 +297,18 @@ async def process_yookassa_sbp_payment_amount(
|
||||
|
||||
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
|
||||
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f'❌ Минимальная сумма для оплаты через СБП: {min_rubles:.0f} ₽')
|
||||
await message.answer(
|
||||
f'❌ Минимальная сумма для оплаты через СБП: {min_rubles:.0f} ₽',
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
|
||||
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f'❌ Максимальная сумма для оплаты через СБП: {max_rubles:,.0f} ₽'.replace(',', ' '))
|
||||
await message.answer(
|
||||
f'❌ Максимальная сумма для оплаты через СБП: {max_rubles:,.0f} ₽'.replace(',', ' '),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
|
||||
+23
-7
@@ -1249,7 +1249,7 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
from app.database.crud.server_squad import get_available_server_squads, get_server_ids_by_uuids
|
||||
from app.database.crud.server_squad import get_available_server_squads
|
||||
from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
@@ -1287,7 +1287,9 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
|
||||
if not connected_squads and available_servers:
|
||||
connected_squads = [available_servers[0].squad_uuid]
|
||||
|
||||
server_ids = await get_server_ids_by_uuids(db, connected_squads) if connected_squads else []
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||||
|
||||
balance = db_user.balance_kopeks
|
||||
available_periods = sorted(settings.get_available_subscription_periods(), reverse=True)
|
||||
@@ -1299,7 +1301,7 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
|
||||
best_price = 0
|
||||
best_pricing = None # Cache pricing result for reuse in finalize()
|
||||
|
||||
# Для продления используем PricingEngine (единый расчёт для всех поверхностей).
|
||||
# PricingEngine — единый расчёт для всех поверхностей (и продление, и новая подписка).
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
renewal_service = SubscriptionRenewalService() if subscription else None
|
||||
@@ -1310,9 +1312,15 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
|
||||
pricing_result = await pricing_engine.calculate_renewal_price(db, subscription, period, user=db_user)
|
||||
price = pricing_result.final_total
|
||||
else:
|
||||
price, _ = await subscription_service.calculate_subscription_price_with_months(
|
||||
period, traffic_limit_gb, server_ids, device_limit, db, user=db_user
|
||||
new_pricing = await pricing_engine.calculate_classic_new_subscription_price(
|
||||
db,
|
||||
period,
|
||||
connected_squads,
|
||||
traffic_limit_gb,
|
||||
device_limit,
|
||||
user=db_user,
|
||||
)
|
||||
price = new_pricing.final_total
|
||||
if price <= balance:
|
||||
best_period = period
|
||||
best_price = price
|
||||
@@ -1326,9 +1334,15 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
|
||||
min_pricing = await pricing_engine.calculate_renewal_price(db, subscription, min_period, user=db_user)
|
||||
min_price = min_pricing.final_total
|
||||
else:
|
||||
min_price, _ = await subscription_service.calculate_subscription_price_with_months(
|
||||
min_period, traffic_limit_gb, server_ids, device_limit, db, user=db_user
|
||||
min_new_pricing = await pricing_engine.calculate_classic_new_subscription_price(
|
||||
db,
|
||||
min_period,
|
||||
connected_squads,
|
||||
traffic_limit_gb,
|
||||
device_limit,
|
||||
user=db_user,
|
||||
)
|
||||
min_price = min_new_pricing.final_total
|
||||
missing = min_price - balance
|
||||
await callback.answer(
|
||||
texts.t('INSUFFICIENT_FUNDS_DETAILED', f'❌ Недостаточно средств. Не хватает {missing // 100} ₽'),
|
||||
@@ -1365,12 +1379,14 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
|
||||
)
|
||||
else:
|
||||
# Списать баланс ДО создания подписки (чтобы не было orphaned subscription при неудаче)
|
||||
consume_promo = get_user_active_promo_discount_percent(db_user) > 0
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
db_user,
|
||||
best_price,
|
||||
f'Активация подписки на {best_period} дней',
|
||||
mark_as_paid_subscription=True,
|
||||
consume_promo_offer=consume_promo,
|
||||
)
|
||||
if not success:
|
||||
await callback.answer('❌ Недостаточно средств', show_alert=True)
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_referral_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.admin_notification_service import AdminNotificationService, NotificationCategory
|
||||
from app.services.referral_withdrawal_service import referral_withdrawal_service
|
||||
from app.states import ReferralWithdrawalStates
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
@@ -825,7 +825,9 @@ async def confirm_withdrawal_request(callback: types.CallbackQuery, db_user: Use
|
||||
|
||||
try:
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_admin_notification(admin_text, reply_markup=admin_keyboard)
|
||||
await notification_service.send_admin_notification(
|
||||
admin_text, reply_markup=admin_keyboard, category=NotificationCategory.PARTNERS
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления админам о заявке на вывод', error=e)
|
||||
|
||||
|
||||
@@ -401,13 +401,25 @@ async def handle_simple_subscription_pay_with_balance(
|
||||
state_data=data,
|
||||
)
|
||||
|
||||
# Рассчитываем цену подписки
|
||||
# Lock user BEFORE pricing to prevent TOCTOU
|
||||
from app.database.crud.user import lock_user_for_pricing, subtract_user_balance
|
||||
|
||||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||||
|
||||
# Рассчитываем цену подписки (group discounts per-category)
|
||||
price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
|
||||
db,
|
||||
subscription_params,
|
||||
user=db_user,
|
||||
resolved_squad_uuid=resolved_squad_uuid,
|
||||
)
|
||||
|
||||
# PricingEngine already applies promo-offer discount inside calculate_classic_new_subscription_price.
|
||||
# Only determine whether to consume the offer (zero it out after use).
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
consume_promo = get_user_active_promo_discount_percent(db_user) > 0
|
||||
|
||||
total_required = price_kopeks
|
||||
logger.warning(
|
||||
'SIMPLE_SUBSCRIPTION_DEBUG_PAY_BALANCE | user= | period= | base= | traffic= | devices= | servers= | discount= | total_required= | balance',
|
||||
@@ -431,15 +443,13 @@ async def handle_simple_subscription_pay_with_balance(
|
||||
|
||||
try:
|
||||
# Списываем средства с баланса пользователя
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
|
||||
purchase_description = f'Оплата подписки на {subscription_params["period_days"]} дней'
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
db_user,
|
||||
price_kopeks,
|
||||
purchase_description,
|
||||
consume_promo_offer=False,
|
||||
consume_promo_offer=consume_promo,
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
|
||||
@@ -840,7 +850,7 @@ async def handle_simple_subscription_payment_method(
|
||||
state_data=data,
|
||||
)
|
||||
|
||||
# Рассчитываем цену подписки
|
||||
# Рассчитываем цену подписки (group discounts per-category)
|
||||
price_kopeks, _ = await _calculate_simple_subscription_price(
|
||||
db,
|
||||
subscription_params,
|
||||
@@ -848,6 +858,14 @@ async def handle_simple_subscription_payment_method(
|
||||
resolved_squad_uuid=resolved_squad_uuid,
|
||||
)
|
||||
|
||||
# Apply promo-offer discount on top of group discounts (consistent with balance-pay path)
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
offer_pct = get_user_active_promo_discount_percent(db_user)
|
||||
if offer_pct > 0:
|
||||
price_kopeks = PricingEngine.apply_discount(price_kopeks, offer_pct)
|
||||
|
||||
if payment_method == 'stars':
|
||||
# Оплата через Telegram Stars
|
||||
order = await purchase_service.create_subscription_order(
|
||||
@@ -2121,13 +2139,25 @@ async def confirm_simple_subscription_purchase(
|
||||
state_data=data,
|
||||
)
|
||||
|
||||
# Рассчитываем цену подписки
|
||||
# Lock user BEFORE pricing to prevent TOCTOU
|
||||
from app.database.crud.user import lock_user_for_pricing, subtract_user_balance
|
||||
|
||||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||||
|
||||
# Рассчитываем цену подписки (group discounts per-category)
|
||||
price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
|
||||
db,
|
||||
subscription_params,
|
||||
user=db_user,
|
||||
resolved_squad_uuid=resolved_squad_uuid,
|
||||
)
|
||||
|
||||
# PricingEngine already applies promo-offer discount inside calculate_classic_new_subscription_price.
|
||||
# Only determine whether to consume the offer (zero it out after use).
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
consume_promo = get_user_active_promo_discount_percent(db_user) > 0
|
||||
|
||||
total_required = price_kopeks
|
||||
logger.warning(
|
||||
'SIMPLE_SUBSCRIPTION_DEBUG_CONFIRM | user= | period= | base= | traffic= | devices= | servers= | discount= | total_required= | balance',
|
||||
@@ -2151,15 +2181,13 @@ async def confirm_simple_subscription_purchase(
|
||||
|
||||
try:
|
||||
# Списываем средства с баланса пользователя
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
|
||||
purchase_description = f'Оплата подписки на {subscription_params["period_days"]} дней'
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
db_user,
|
||||
price_kopeks,
|
||||
purchase_description,
|
||||
consume_promo_offer=False,
|
||||
consume_promo_offer=consume_promo,
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
from app.services.referral_service import process_referral_registration
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.services.web_auth_service import WEB_AUTH_TOKEN_MIN_LENGTH, link_web_auth_token
|
||||
from app.states import RegistrationStates
|
||||
from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
@@ -197,6 +198,80 @@ async def _claim_phantom_user(
|
||||
return True, phantom
|
||||
|
||||
|
||||
async def _merge_phantom_into_active_user(
|
||||
db: AsyncSession,
|
||||
phantom: 'User',
|
||||
active_user: 'User',
|
||||
) -> None:
|
||||
"""Merge a phantom user (created by guest landing purchase) into an existing active user.
|
||||
|
||||
Transfers GuestPurchase records and handles subscription conflict.
|
||||
The phantom is soft-deleted (status=DELETED, username cleared) to preserve
|
||||
audit trail and avoid CASCADE deletion of payment/transaction records.
|
||||
"""
|
||||
from sqlalchemy import update
|
||||
|
||||
logger.info(
|
||||
'Merging phantom user into active user',
|
||||
phantom_id=phantom.id,
|
||||
active_user_id=active_user.id,
|
||||
phantom_username=phantom.username,
|
||||
)
|
||||
|
||||
# Transfer GuestPurchase.user_id references
|
||||
await db.execute(update(GuestPurchase).where(GuestPurchase.user_id == phantom.id).values(user_id=active_user.id))
|
||||
|
||||
# Transfer GuestPurchase.buyer_user_id references
|
||||
await db.execute(
|
||||
update(GuestPurchase).where(GuestPurchase.buyer_user_id == phantom.id).values(buyer_user_id=active_user.id)
|
||||
)
|
||||
|
||||
# Transfer balance
|
||||
if phantom.balance_kopeks and phantom.balance_kopeks > 0:
|
||||
active_user.balance_kopeks = (active_user.balance_kopeks or 0) + phantom.balance_kopeks
|
||||
logger.info('Transferred balance from phantom', amount_kopeks=phantom.balance_kopeks)
|
||||
|
||||
# Handle subscription
|
||||
await db.refresh(phantom, ['subscription'])
|
||||
await db.refresh(active_user, ['subscription'])
|
||||
|
||||
if phantom.subscription and not active_user.subscription:
|
||||
# Transfer subscription from phantom to active user
|
||||
phantom.subscription.user_id = active_user.id
|
||||
# Transfer remnawave_uuid
|
||||
if phantom.remnawave_uuid and not active_user.remnawave_uuid:
|
||||
active_user.remnawave_uuid = phantom.remnawave_uuid
|
||||
phantom.remnawave_uuid = None
|
||||
await db.flush()
|
||||
logger.info(
|
||||
'Transferred subscription from phantom to active user',
|
||||
subscription_id=phantom.subscription.id,
|
||||
)
|
||||
elif phantom.subscription:
|
||||
# Both have subscriptions — disable phantom's Remnawave user and free server slots
|
||||
logger.warning(
|
||||
'Both phantom and active user have subscriptions, disabling phantom',
|
||||
phantom_subscription_id=phantom.subscription.id,
|
||||
active_subscription_id=active_user.subscription.id,
|
||||
)
|
||||
if phantom.remnawave_uuid:
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.disable_remnawave_user(phantom.remnawave_uuid)
|
||||
except Exception as exc:
|
||||
logger.warning('Failed to disable phantom Remnawave user', error=str(exc))
|
||||
await decrement_subscription_server_counts(db, phantom.subscription)
|
||||
|
||||
# Soft-delete phantom: clear identifiers to prevent future matches,
|
||||
# preserve record for audit trail and avoid CASCADE deletion of payments/transactions
|
||||
phantom.status = UserStatus.DELETED.value
|
||||
phantom.username = None
|
||||
phantom.remnawave_uuid = None
|
||||
await db.flush()
|
||||
|
||||
logger.info('Phantom user merged and soft-deleted', phantom_id=phantom.id, active_user_id=active_user.id)
|
||||
|
||||
|
||||
def _calculate_subscription_flags(subscription):
|
||||
if not subscription:
|
||||
return False, False
|
||||
@@ -302,6 +377,9 @@ async def handle_potential_referral_code(message: types.Message, state: FSMConte
|
||||
language = data.get('language') or (getattr(user, 'language', None) if user else None) or DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
if not message.text:
|
||||
return False
|
||||
|
||||
from app.utils.promo_rate_limiter import promo_limiter, validate_promo_format
|
||||
|
||||
potential_code = message.text.strip()
|
||||
@@ -530,6 +608,40 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
await state.update_data(pending_gift_token=gift_token)
|
||||
start_parameter = None # Don't treat as campaign or referral
|
||||
|
||||
# Handle web auth deep links: /start webauth_{token}
|
||||
if start_parameter and start_parameter.startswith('webauth_'):
|
||||
web_auth_token = start_parameter.removeprefix('webauth_')
|
||||
if len(web_auth_token) >= WEB_AUTH_TOKEN_MIN_LENGTH:
|
||||
user = db_user or await get_user_by_telegram_id(db, message.from_user.id)
|
||||
if user and user.status != UserStatus.DELETED.value:
|
||||
texts = get_texts(user.language)
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t('WEB_AUTH_CONFIRM_YES', '✅ Да, войти'),
|
||||
callback_data=f'webauth_confirm:{web_auth_token}',
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t('WEB_AUTH_CONFIRM_NO', '❌ Нет'),
|
||||
callback_data='webauth_deny',
|
||||
),
|
||||
],
|
||||
]
|
||||
)
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'WEB_AUTH_CONFIRM_PROMPT',
|
||||
'🔐 Подтвердите вход в личный кабинет. Если вы не запрашивали вход — нажмите «Нет».',
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
else:
|
||||
logger.warning('Web auth attempt from unregistered user', telegram_id=message.from_user.id)
|
||||
await message.answer('❌ Сначала зарегистрируйтесь в боте, затем попробуйте войти в кабинет.')
|
||||
return
|
||||
start_parameter = None # Invalid token, ignore
|
||||
|
||||
if start_parameter:
|
||||
campaign = await get_campaign_by_start_parameter(
|
||||
db,
|
||||
@@ -586,6 +698,21 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
if user and user.status != UserStatus.DELETED.value:
|
||||
logger.info('✅ Активный пользователь найден', telegram_id=user.telegram_id)
|
||||
|
||||
# Check for phantom user created by guest landing purchase and merge
|
||||
if message.from_user.username:
|
||||
phantom = await find_phantom_user_by_username(db, message.from_user.username)
|
||||
if phantom and phantom.id != user.id:
|
||||
try:
|
||||
await _merge_phantom_into_active_user(db, phantom, user)
|
||||
await db.refresh(user, ['subscription'])
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
logger.exception(
|
||||
'Failed to merge phantom user',
|
||||
phantom_id=phantom.id,
|
||||
active_user_id=user.id,
|
||||
)
|
||||
|
||||
profile_updated = False
|
||||
|
||||
if user.username != message.from_user.username:
|
||||
@@ -1186,6 +1313,10 @@ async def process_referral_code_input(message: types.Message, state: FSMContext,
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
if not message.text:
|
||||
await message.answer(texts.t('REFERRAL_OR_PROMO_CODE_INVALID', '❌ Неверный реферальный код или промокод'))
|
||||
return
|
||||
|
||||
from app.utils.promo_rate_limiter import promo_limiter, validate_promo_format
|
||||
|
||||
code = message.text.strip()
|
||||
@@ -2341,6 +2472,33 @@ async def required_sub_channel_check(
|
||||
except Exception as e:
|
||||
logger.error('Ошибка при обработке реферальной регистрации', error=e)
|
||||
|
||||
# Применяем бонус рекламной кампании (record_campaign_registration)
|
||||
campaign_message = await _apply_campaign_bonus_if_needed(db, user, state_data, texts)
|
||||
try:
|
||||
await db.refresh(user)
|
||||
except Exception as refresh_error:
|
||||
logger.error(
|
||||
'Ошибка обновления данных пользователя после бонуса кампании',
|
||||
telegram_id=user.telegram_id,
|
||||
refresh_error=refresh_error,
|
||||
)
|
||||
try:
|
||||
await db.refresh(user, ['subscription'])
|
||||
except Exception as refresh_sub_error:
|
||||
logger.error(
|
||||
'Ошибка обновления подписки после бонуса кампании',
|
||||
telegram_id=user.telegram_id,
|
||||
refresh_sub_error=refresh_sub_error,
|
||||
)
|
||||
if campaign_message:
|
||||
try:
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
text=campaign_message,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки сообщения о бонусе кампании', error=e)
|
||||
|
||||
# Показываем главное меню после создания пользователя
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(user.subscription)
|
||||
|
||||
@@ -2439,6 +2597,43 @@ async def required_sub_channel_check(
|
||||
pass
|
||||
|
||||
|
||||
async def process_webauth_confirm(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""Handle web auth confirmation or denial."""
|
||||
await callback.answer()
|
||||
|
||||
if not isinstance(callback.message, types.Message):
|
||||
return
|
||||
|
||||
if callback.data == 'webauth_deny':
|
||||
await callback.message.edit_text('❌ Вход отменён.')
|
||||
return
|
||||
|
||||
# Extract token from callback_data: "webauth_confirm:{token}"
|
||||
token = callback.data.split(':', 1)[1] if ':' in callback.data else ''
|
||||
if len(token) < WEB_AUTH_TOKEN_MIN_LENGTH:
|
||||
await callback.message.edit_text('❌ Ошибка: неверный токен.')
|
||||
return
|
||||
|
||||
user = await get_user_by_telegram_id(db, callback.from_user.id)
|
||||
if not user or user.status != UserStatus.ACTIVE.value:
|
||||
await callback.message.edit_text('❌ Учётная запись неактивна.')
|
||||
return
|
||||
|
||||
linked = await link_web_auth_token(token, callback.from_user.id, user.id)
|
||||
texts = get_texts(user.language)
|
||||
if linked:
|
||||
await callback.message.edit_text(
|
||||
texts.t('WEB_AUTH_SUCCESS', '✅ Авторизация в кабинете подтверждена! Вернитесь в браузер.'),
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
texts.t('WEB_AUTH_EXPIRED', '❌ Ссылка для входа истекла. Попробуйте снова.'),
|
||||
)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
logger.debug('=== НАЧАЛО регистрации обработчиков start.py ===')
|
||||
|
||||
@@ -2483,4 +2678,10 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(required_sub_channel_check, F.data.in_(['sub_channel_check']))
|
||||
logger.debug('Зарегистрирован required_sub_channel_check')
|
||||
|
||||
dp.callback_query.register(
|
||||
process_webauth_confirm,
|
||||
F.data.startswith('webauth_confirm:') | F.data.in_(['webauth_deny']),
|
||||
)
|
||||
logger.debug('Зарегистрирован process_webauth_confirm')
|
||||
|
||||
logger.debug('=== КОНЕЦ регистрации обработчиков start.py ===')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from aiogram import types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -114,7 +115,13 @@ async def toggle_autopay(callback: types.CallbackQuery, db_user: User, db: Async
|
||||
status = texts.t('AUTOPAY_STATUS_ENABLED', 'включен') if enable else texts.t('AUTOPAY_STATUS_DISABLED', 'выключен')
|
||||
await callback.answer(texts.t('AUTOPAY_TOGGLE_SUCCESS', '✅ Автоплатеж {status}!').format(status=status))
|
||||
|
||||
await handle_autopay_menu(callback, db_user, db)
|
||||
try:
|
||||
await handle_autopay_menu(callback, db_user, db)
|
||||
except TelegramBadRequest as e:
|
||||
if 'message is not modified' in str(e):
|
||||
pass
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
async def show_autopay_days(callback: types.CallbackQuery, db_user: User):
|
||||
|
||||
@@ -56,43 +56,6 @@ def _format_text_with_placeholders(template: str, values: dict[str, Any]) -> str
|
||||
return template
|
||||
|
||||
|
||||
def _get_addon_discount_percent_for_user(
|
||||
user: User | None,
|
||||
category: str,
|
||||
period_days_hint: int | None = None,
|
||||
) -> int:
|
||||
if user is None:
|
||||
return 0
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
if promo_group is None:
|
||||
return 0
|
||||
|
||||
if not getattr(promo_group, 'apply_discounts_to_addons', True):
|
||||
return 0
|
||||
|
||||
try:
|
||||
return user.get_promo_discount(category, period_days_hint)
|
||||
except AttributeError:
|
||||
return 0
|
||||
|
||||
|
||||
def _apply_addon_discount(
|
||||
user: User | None,
|
||||
category: str,
|
||||
amount: int,
|
||||
period_days_hint: int | None = None,
|
||||
) -> dict[str, int]:
|
||||
percent = _get_addon_discount_percent_for_user(user, category, period_days_hint)
|
||||
discounted_amount, discount_value = apply_percentage_discount(amount, percent)
|
||||
|
||||
return {
|
||||
'discounted': discounted_amount,
|
||||
'discount': discount_value,
|
||||
'percent': percent,
|
||||
}
|
||||
|
||||
|
||||
def _get_promo_offer_discount_percent(user: User | None) -> int:
|
||||
return get_user_active_promo_discount_percent(user)
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import PERIOD_PRICES, settings
|
||||
from app.config import settings
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.crud.user import lock_user_for_pricing, subtract_user_balance
|
||||
from app.database.models import TransactionType, User
|
||||
from app.keyboards.inline import (
|
||||
get_back_keyboard,
|
||||
@@ -17,6 +17,7 @@ from app.keyboards.inline import (
|
||||
get_manage_countries_keyboard,
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.pricing_engine import PricingEngine, pricing_engine
|
||||
from app.services.subscription_checkout_service import (
|
||||
save_subscription_checkout_draft,
|
||||
should_offer_checkout_resume,
|
||||
@@ -28,7 +29,7 @@ from app.utils.pricing_utils import (
|
||||
calculate_prorated_price,
|
||||
)
|
||||
|
||||
from .common import _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, logger
|
||||
from .common import _get_period_hint_from_subscription, logger
|
||||
from .summary import present_subscription_summary
|
||||
|
||||
|
||||
@@ -58,7 +59,7 @@ async def handle_add_countries(callback: types.CallbackQuery, db_user: User, db:
|
||||
current_countries = subscription.connected_squads
|
||||
|
||||
period_hint_days = _get_period_hint_from_subscription(subscription)
|
||||
servers_discount_percent = _get_addon_discount_percent_for_user(
|
||||
servers_discount_percent = PricingEngine.get_addon_discount_percent(
|
||||
db_user,
|
||||
'servers',
|
||||
period_hint_days,
|
||||
@@ -171,7 +172,7 @@ async def handle_manage_country(callback: types.CallbackQuery, db_user: User, db
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
allowed_country_ids = {country['uuid'] for country in countries}
|
||||
|
||||
if country_uuid not in allowed_country_ids and country_uuid not in current_selected:
|
||||
if country_uuid not in allowed_country_ids:
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
@@ -194,7 +195,7 @@ async def handle_manage_country(callback: types.CallbackQuery, db_user: User, db
|
||||
await state.update_data(countries=current_selected)
|
||||
|
||||
period_hint_days = _get_period_hint_from_subscription(subscription)
|
||||
servers_discount_percent = _get_addon_discount_percent_for_user(
|
||||
servers_discount_percent = PricingEngine.get_addon_discount_percent(
|
||||
db_user,
|
||||
'servers',
|
||||
period_hint_days,
|
||||
@@ -235,11 +236,7 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User,
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
allowed_country_ids = {country['uuid'] for country in countries}
|
||||
|
||||
selected_countries = [
|
||||
country_uuid
|
||||
for country_uuid in selected_countries
|
||||
if country_uuid in allowed_country_ids or country_uuid in current_countries
|
||||
]
|
||||
selected_countries = [country_uuid for country_uuid in selected_countries if country_uuid in allowed_country_ids]
|
||||
|
||||
added = [c for c in selected_countries if c not in current_countries]
|
||||
removed = [c for c in current_countries if c not in selected_countries]
|
||||
@@ -257,7 +254,12 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User,
|
||||
days_to_pay = max(1, (subscription.end_date - now).days)
|
||||
|
||||
period_hint_days = days_to_pay if days_to_pay > 0 else None
|
||||
servers_discount_percent = _get_addon_discount_percent_for_user(
|
||||
|
||||
# TOCTOU protection: lock user row before reading discount and charging balance
|
||||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||||
subscription = db_user.subscription
|
||||
|
||||
servers_discount_percent = PricingEngine.get_addon_discount_percent(
|
||||
db_user,
|
||||
'servers',
|
||||
period_hint_days,
|
||||
@@ -392,7 +394,7 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User,
|
||||
await db.commit()
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
await subscription_service.update_remnawave_user(db, subscription, sync_squads=True)
|
||||
|
||||
await db.refresh(subscription)
|
||||
|
||||
@@ -496,31 +498,18 @@ async def select_country(callback: types.CallbackQuery, state: FSMContext, db_us
|
||||
await callback.answer('❌ Сервер недоступен для вашей промогруппы', show_alert=True)
|
||||
return
|
||||
|
||||
period_base_price = PERIOD_PRICES.get(data['period_days'], 0)
|
||||
discounted_base_price, _ = apply_percentage_discount(
|
||||
period_base_price,
|
||||
db_user.get_promo_discount('period', data['period_days']),
|
||||
)
|
||||
|
||||
base_price = discounted_base_price + settings.get_traffic_price(data['traffic_gb'])
|
||||
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
countries_price, _ = await subscription_service.get_countries_price_by_uuids(
|
||||
selected_countries,
|
||||
db,
|
||||
promo_group_id=db_user.promo_group_id,
|
||||
)
|
||||
except AttributeError:
|
||||
logger.warning('Используем fallback функцию для расчета цен стран')
|
||||
countries_price, _ = await get_countries_price_by_uuids_fallback(
|
||||
selected_countries,
|
||||
db,
|
||||
promo_group_id=db_user.promo_group_id,
|
||||
)
|
||||
|
||||
data['countries'] = selected_countries
|
||||
data['total_price'] = base_price + countries_price
|
||||
|
||||
# Вычисляем цену через PricingEngine с актуальными FSM-данными
|
||||
pricing_result = await pricing_engine.calculate_classic_new_subscription_price(
|
||||
db,
|
||||
data['period_days'],
|
||||
list(selected_countries),
|
||||
data.get('traffic_gb', 0) or 0,
|
||||
data.get('devices', settings.DEFAULT_DEVICE_LIMIT),
|
||||
user=db_user,
|
||||
)
|
||||
data['total_price'] = pricing_result.final_total
|
||||
await state.set_data(data)
|
||||
|
||||
await callback.message.edit_reply_markup(
|
||||
@@ -700,7 +689,7 @@ async def handle_add_country_to_subscription(
|
||||
total_price = 0
|
||||
subscription = db_user.subscription
|
||||
period_hint_days = _get_period_hint_from_subscription(subscription)
|
||||
servers_discount_percent = _get_addon_discount_percent_for_user(
|
||||
servers_discount_percent = PricingEngine.get_addon_discount_percent(
|
||||
db_user,
|
||||
'servers',
|
||||
period_hint_days,
|
||||
@@ -795,11 +784,7 @@ async def confirm_add_countries_to_subscription(
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
allowed_country_ids = {country['uuid'] for country in countries}
|
||||
|
||||
selected_countries = [
|
||||
country_uuid
|
||||
for country_uuid in selected_countries
|
||||
if country_uuid in allowed_country_ids or country_uuid in current_countries
|
||||
]
|
||||
selected_countries = [country_uuid for country_uuid in selected_countries if country_uuid in allowed_country_ids]
|
||||
|
||||
new_countries = [c for c in selected_countries if c not in current_countries]
|
||||
removed_countries = [c for c in current_countries if c not in selected_countries]
|
||||
@@ -808,12 +793,16 @@ async def confirm_add_countries_to_subscription(
|
||||
await callback.answer('⚠️ Изменения не обнаружены', show_alert=True)
|
||||
return
|
||||
|
||||
# TOCTOU protection: lock user row before reading discount and charging balance
|
||||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||||
subscription = db_user.subscription
|
||||
|
||||
total_price = 0
|
||||
new_countries_names = []
|
||||
removed_countries_names = []
|
||||
|
||||
period_hint_days = _get_period_hint_from_subscription(subscription)
|
||||
servers_discount_percent = _get_addon_discount_percent_for_user(
|
||||
servers_discount_percent = PricingEngine.get_addon_discount_percent(
|
||||
db_user,
|
||||
'servers',
|
||||
period_hint_days,
|
||||
@@ -909,7 +898,7 @@ async def confirm_add_countries_to_subscription(
|
||||
await db.commit()
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
await subscription_service.update_remnawave_user(db, subscription, sync_squads=True)
|
||||
|
||||
await db.refresh(db_user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.crud.user import lock_user_for_pricing, subtract_user_balance
|
||||
from app.database.models import Subscription, TransactionType, User
|
||||
from app.keyboards.inline import (
|
||||
get_app_selection_keyboard,
|
||||
@@ -21,6 +21,7 @@ from app.keyboards.inline import (
|
||||
get_specific_app_keyboard,
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
@@ -33,7 +34,6 @@ from app.utils.subscription_utils import (
|
||||
)
|
||||
|
||||
from .common import (
|
||||
_get_addon_discount_percent_for_user,
|
||||
_get_period_hint_from_subscription,
|
||||
get_apps_for_platform_async,
|
||||
get_device_name,
|
||||
@@ -174,7 +174,7 @@ async def handle_change_devices(callback: types.CallbackQuery, db_user: User, db
|
||||
current_devices = subscription.device_limit
|
||||
|
||||
period_hint_days = _get_period_hint_from_subscription(subscription)
|
||||
devices_discount_percent = _get_addon_discount_percent_for_user(
|
||||
devices_discount_percent = PricingEngine.get_addon_discount_percent(
|
||||
db_user,
|
||||
'devices',
|
||||
period_hint_days,
|
||||
@@ -325,7 +325,7 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
days_left = max(1, (subscription.end_date - now).days)
|
||||
period_hint_days = days_left
|
||||
|
||||
devices_discount_percent = _get_addon_discount_percent_for_user(
|
||||
devices_discount_percent = PricingEngine.get_addon_discount_percent(
|
||||
db_user,
|
||||
'devices',
|
||||
period_hint_days,
|
||||
@@ -345,7 +345,7 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
days_left = max(1, (subscription.end_date - now).days)
|
||||
period_hint_days = days_left
|
||||
|
||||
devices_discount_percent = _get_addon_discount_percent_for_user(
|
||||
devices_discount_percent = PricingEngine.get_addon_discount_percent(
|
||||
db_user,
|
||||
'devices',
|
||||
period_hint_days,
|
||||
@@ -492,10 +492,17 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
async def execute_change_devices(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
callback_parts = callback.data.split('_')
|
||||
new_devices_count = int(callback_parts[3])
|
||||
price = int(callback_parts[4])
|
||||
|
||||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
if not subscription:
|
||||
await callback.answer(
|
||||
texts.t('NO_ACTIVE_SUBSCRIPTION', '⚠️ У вас нет активной подписки'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
current_devices = subscription.device_limit
|
||||
|
||||
# Проверяем тариф подписки
|
||||
@@ -514,12 +521,15 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
price_per_device = tariff_device_price
|
||||
elif not settings.is_devices_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t('DEVICES_SELECTION_DISABLED', '⚠️ Изменение количества устройств недоступно'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
else:
|
||||
price_per_device = settings.PRICE_PER_DEVICE
|
||||
|
||||
# Проверяем минимальное количество устройств на тарифе
|
||||
tariff_min_devices = (getattr(tariff, 'device_limit', 1) or 1) if tariff else 1
|
||||
@@ -533,6 +543,33 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
)
|
||||
return
|
||||
|
||||
# Recompute price under lock (callback-baked value may be stale)
|
||||
devices_difference = new_devices_count - current_devices
|
||||
if devices_difference > 0:
|
||||
if tariff:
|
||||
chargeable_devices = devices_difference
|
||||
elif current_devices < settings.DEFAULT_DEVICE_LIMIT:
|
||||
free_devices = settings.DEFAULT_DEVICE_LIMIT - current_devices
|
||||
chargeable_devices = max(0, devices_difference - free_devices)
|
||||
else:
|
||||
chargeable_devices = devices_difference
|
||||
|
||||
devices_price_per_month = chargeable_devices * price_per_device
|
||||
days_left = max(1, (subscription.end_date - datetime.now(UTC)).days)
|
||||
devices_discount_percent = PricingEngine.get_addon_discount_percent(
|
||||
db_user,
|
||||
'devices',
|
||||
days_left,
|
||||
)
|
||||
discounted_per_month, _ = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
price = int(discounted_per_month * days_left / 30)
|
||||
price = max(100, price)
|
||||
else:
|
||||
price = 0
|
||||
|
||||
try:
|
||||
if price > 0:
|
||||
success = await subtract_user_balance(
|
||||
@@ -1148,6 +1185,9 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
|
||||
devices_price_per_month = devices_count * price_per_device
|
||||
|
||||
# TOCTOU: lock user row before reading promo/discount state
|
||||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||||
|
||||
# Проверяем является ли тариф суточным
|
||||
is_daily_tariff = tariff and getattr(tariff, 'is_daily', False)
|
||||
|
||||
@@ -1157,7 +1197,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
days_left = max(1, (subscription.end_date - now).days)
|
||||
period_hint_days = days_left
|
||||
|
||||
devices_discount_percent = _get_addon_discount_percent_for_user(
|
||||
devices_discount_percent = PricingEngine.get_addon_discount_percent(
|
||||
db_user,
|
||||
'devices',
|
||||
period_hint_days,
|
||||
@@ -1177,7 +1217,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
days_left = max(1, (subscription.end_date - now).days)
|
||||
period_hint_days = days_left
|
||||
|
||||
devices_discount_percent = _get_addon_discount_percent_for_user(
|
||||
devices_discount_percent = PricingEngine.get_addon_discount_percent(
|
||||
db_user,
|
||||
'devices',
|
||||
period_hint_days,
|
||||
|
||||
@@ -4,18 +4,15 @@ from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import PERIOD_PRICES, settings
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.utils.pricing_utils import (
|
||||
apply_percentage_discount,
|
||||
calculate_months_from_days,
|
||||
format_period_description,
|
||||
validate_pricing_calculation,
|
||||
)
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
from .common import _apply_discount_to_monthly_component, _apply_promo_offer_discount, logger
|
||||
from .countries import _get_available_countries, _get_countries_info, get_countries_price_by_uuids_fallback
|
||||
from .common import logger
|
||||
from .countries import _get_available_countries, _get_countries_info
|
||||
from .devices import get_current_devices_count
|
||||
from .promo import _build_promo_group_discount_text, _get_promo_offer_hint
|
||||
|
||||
@@ -25,82 +22,18 @@ async def _prepare_subscription_summary(
|
||||
data: dict[str, Any],
|
||||
texts,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.services.pricing_engine import PricingEngine, pricing_engine
|
||||
|
||||
summary_data = dict(data)
|
||||
|
||||
if 'period_days' not in summary_data:
|
||||
raise KeyError('period_days missing from subscription data — FSM state likely expired')
|
||||
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
|
||||
months_in_period = calculate_months_from_days(summary_data['period_days'])
|
||||
period_display = format_period_description(summary_data['period_days'], db_user.language)
|
||||
|
||||
base_price_original = PERIOD_PRICES.get(summary_data['period_days'], 0)
|
||||
period_discount_percent = db_user.get_promo_discount(
|
||||
'period',
|
||||
summary_data['period_days'],
|
||||
)
|
||||
base_price, base_discount_total = apply_percentage_discount(
|
||||
base_price_original,
|
||||
period_discount_percent,
|
||||
)
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_limit = settings.get_fixed_traffic_limit()
|
||||
traffic_price_per_month = settings.get_traffic_price(traffic_limit)
|
||||
final_traffic_gb = traffic_limit
|
||||
else:
|
||||
traffic_gb = summary_data.get('traffic_gb', 0)
|
||||
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
|
||||
final_traffic_gb = traffic_gb
|
||||
|
||||
traffic_discount_percent = db_user.get_promo_discount(
|
||||
'traffic',
|
||||
summary_data['period_days'],
|
||||
)
|
||||
traffic_component = _apply_discount_to_monthly_component(
|
||||
traffic_price_per_month,
|
||||
traffic_discount_percent,
|
||||
months_in_period,
|
||||
)
|
||||
total_traffic_price = traffic_component['total']
|
||||
|
||||
countries_price_per_month = 0
|
||||
selected_countries_names: list[str] = []
|
||||
selected_server_prices: list[int] = []
|
||||
server_monthly_prices: list[int] = []
|
||||
|
||||
selected_country_ids = set(summary_data.get('countries', []))
|
||||
for country in countries:
|
||||
if country['uuid'] in selected_country_ids:
|
||||
server_price_per_month = country['price_kopeks']
|
||||
countries_price_per_month += server_price_per_month
|
||||
selected_countries_names.append(html.escape(country['name']))
|
||||
server_monthly_prices.append(server_price_per_month)
|
||||
|
||||
servers_discount_percent = db_user.get_promo_discount(
|
||||
'servers',
|
||||
summary_data['period_days'],
|
||||
)
|
||||
total_countries_price = 0
|
||||
total_servers_discount = 0
|
||||
discounted_servers_price_per_month = 0
|
||||
|
||||
for server_price_per_month in server_monthly_prices:
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
server_price_per_month,
|
||||
servers_discount_percent,
|
||||
)
|
||||
total_price_for_server = discounted_per_month * months_in_period
|
||||
total_discount_for_server = discount_per_month * months_in_period
|
||||
|
||||
discounted_servers_price_per_month += discounted_per_month
|
||||
total_countries_price += total_price_for_server
|
||||
total_servers_discount += total_discount_for_server
|
||||
selected_server_prices.append(total_price_for_server)
|
||||
period_days = summary_data['period_days']
|
||||
|
||||
# --- Resolve device limit (same logic as before) ---
|
||||
devices_selection_enabled = settings.is_devices_selection_enabled()
|
||||
forced_disabled_limit: int | None = None
|
||||
if devices_selection_enabled:
|
||||
devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
else:
|
||||
@@ -109,54 +42,75 @@ async def _prepare_subscription_summary(
|
||||
devices_selected = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
devices_selected = forced_disabled_limit
|
||||
|
||||
summary_data['devices'] = devices_selected
|
||||
additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = db_user.get_promo_discount(
|
||||
'devices',
|
||||
summary_data['period_days'],
|
||||
)
|
||||
devices_component = _apply_discount_to_monthly_component(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
months_in_period,
|
||||
)
|
||||
total_devices_price = devices_component['total']
|
||||
|
||||
total_price = base_price + total_traffic_price + total_countries_price + total_devices_price
|
||||
# --- Resolve traffic ---
|
||||
if settings.is_traffic_fixed():
|
||||
final_traffic_gb = settings.get_fixed_traffic_limit()
|
||||
else:
|
||||
final_traffic_gb = summary_data.get('traffic_gb', 0)
|
||||
|
||||
# --- Resolve connected squads ---
|
||||
connected_squads = list(summary_data.get('countries', []))
|
||||
|
||||
# --- Delegate pricing to PricingEngine ---
|
||||
async with AsyncSessionLocal() as db:
|
||||
pricing = await pricing_engine.calculate_classic_new_subscription_price(
|
||||
db,
|
||||
period_days,
|
||||
connected_squads,
|
||||
final_traffic_gb,
|
||||
devices_selected,
|
||||
user=db_user,
|
||||
)
|
||||
|
||||
# --- Build legacy dict from PricingEngine result ---
|
||||
details = PricingEngine.classic_pricing_to_purchase_details(pricing)
|
||||
bd = pricing.breakdown
|
||||
|
||||
months_in_period = details['months_in_period']
|
||||
base_price = details['base_price']
|
||||
base_price_original = details['base_price_original']
|
||||
base_discount_total = details['base_discount_total']
|
||||
period_discount_percent = details['base_discount_percent']
|
||||
traffic_price_per_month = details['traffic_price_per_month']
|
||||
traffic_discount_percent = details['traffic_discount_percent']
|
||||
traffic_discount_total = details['traffic_discount_total']
|
||||
total_traffic_price = details['total_traffic_price']
|
||||
servers_price_per_month = details['servers_price_per_month']
|
||||
servers_discount_percent = details['servers_discount_percent']
|
||||
servers_discount_total = details['servers_discount_total']
|
||||
total_servers_price = details['total_servers_price']
|
||||
devices_price_per_month = details['devices_price_per_month']
|
||||
devices_discount_percent = details['devices_discount_percent']
|
||||
devices_discount_total = details['devices_discount_total']
|
||||
total_devices_price = details['total_devices_price']
|
||||
|
||||
# Compute discounted per-month values (not in classic_pricing_to_purchase_details)
|
||||
traffic_discounted_per_month = PricingEngine.apply_discount(traffic_price_per_month, traffic_discount_percent)
|
||||
servers_discounted_per_month = PricingEngine.apply_discount(servers_price_per_month, servers_discount_percent)
|
||||
devices_discounted_per_month = PricingEngine.apply_discount(devices_price_per_month, devices_discount_percent)
|
||||
discounted_monthly_additions = (
|
||||
traffic_component['discounted_per_month']
|
||||
+ discounted_servers_price_per_month
|
||||
+ devices_component['discounted_per_month']
|
||||
traffic_discounted_per_month + servers_discounted_per_month + devices_discounted_per_month
|
||||
)
|
||||
|
||||
is_valid = validate_pricing_calculation(
|
||||
base_price,
|
||||
discounted_monthly_additions,
|
||||
months_in_period,
|
||||
total_price,
|
||||
)
|
||||
|
||||
if not is_valid:
|
||||
raise ValueError('Subscription price calculation validation failed')
|
||||
|
||||
original_total_price = total_price
|
||||
promo_offer_component = _apply_promo_offer_discount(db_user, total_price)
|
||||
if promo_offer_component['discount'] > 0:
|
||||
total_price = promo_offer_component['discounted']
|
||||
# --- Promo offer discount (already computed by PricingEngine) ---
|
||||
promo_offer_discount = pricing.promo_offer_discount
|
||||
offer_pct = bd.get('offer_discount_pct', 0)
|
||||
# subtotal before promo offer = final_total + promo_offer_discount
|
||||
subtotal_before_offer = pricing.final_total + promo_offer_discount
|
||||
total_price = pricing.final_total
|
||||
|
||||
summary_data['total_price'] = total_price
|
||||
if promo_offer_component['discount'] > 0:
|
||||
summary_data['promo_offer_discount_percent'] = promo_offer_component['percent']
|
||||
summary_data['promo_offer_discount_value'] = promo_offer_component['discount']
|
||||
summary_data['total_price_before_promo_offer'] = original_total_price
|
||||
if promo_offer_discount > 0:
|
||||
summary_data['promo_offer_discount_percent'] = offer_pct
|
||||
summary_data['promo_offer_discount_value'] = promo_offer_discount
|
||||
summary_data['total_price_before_promo_offer'] = subtotal_before_offer
|
||||
else:
|
||||
summary_data.pop('promo_offer_discount_percent', None)
|
||||
summary_data.pop('promo_offer_discount_value', None)
|
||||
summary_data.pop('total_price_before_promo_offer', None)
|
||||
summary_data['server_prices_for_period'] = selected_server_prices
|
||||
summary_data['server_prices_for_period'] = details['servers_individual_prices']
|
||||
summary_data['months_in_period'] = months_in_period
|
||||
summary_data['base_price'] = base_price
|
||||
summary_data['base_price_original'] = base_price_original
|
||||
@@ -164,24 +118,27 @@ async def _prepare_subscription_summary(
|
||||
summary_data['base_discount_total'] = base_discount_total
|
||||
summary_data['final_traffic_gb'] = final_traffic_gb
|
||||
summary_data['traffic_price_per_month'] = traffic_price_per_month
|
||||
summary_data['traffic_discount_percent'] = traffic_component['discount_percent']
|
||||
summary_data['traffic_discount_total'] = traffic_component['discount_total']
|
||||
summary_data['traffic_discounted_price_per_month'] = traffic_component['discounted_per_month']
|
||||
summary_data['traffic_discount_percent'] = traffic_discount_percent
|
||||
summary_data['traffic_discount_total'] = traffic_discount_total
|
||||
summary_data['traffic_discounted_price_per_month'] = traffic_discounted_per_month
|
||||
summary_data['total_traffic_price'] = total_traffic_price
|
||||
summary_data['servers_price_per_month'] = countries_price_per_month
|
||||
summary_data['countries_price_per_month'] = countries_price_per_month
|
||||
summary_data['servers_price_per_month'] = servers_price_per_month
|
||||
summary_data['countries_price_per_month'] = servers_price_per_month
|
||||
summary_data['servers_discount_percent'] = servers_discount_percent
|
||||
summary_data['servers_discount_total'] = total_servers_discount
|
||||
summary_data['servers_discounted_price_per_month'] = discounted_servers_price_per_month
|
||||
summary_data['total_servers_price'] = total_countries_price
|
||||
summary_data['total_countries_price'] = total_countries_price
|
||||
summary_data['servers_discount_total'] = servers_discount_total
|
||||
summary_data['servers_discounted_price_per_month'] = servers_discounted_per_month
|
||||
summary_data['total_servers_price'] = total_servers_price
|
||||
summary_data['total_countries_price'] = total_servers_price
|
||||
summary_data['devices_price_per_month'] = devices_price_per_month
|
||||
summary_data['devices_discount_percent'] = devices_component['discount_percent']
|
||||
summary_data['devices_discount_total'] = devices_component['discount_total']
|
||||
summary_data['devices_discounted_price_per_month'] = devices_component['discounted_per_month']
|
||||
summary_data['devices_discount_percent'] = devices_discount_percent
|
||||
summary_data['devices_discount_total'] = devices_discount_total
|
||||
summary_data['devices_discounted_price_per_month'] = devices_discounted_per_month
|
||||
summary_data['total_devices_price'] = total_devices_price
|
||||
summary_data['discounted_monthly_additions'] = discounted_monthly_additions
|
||||
|
||||
# --- Build display text ---
|
||||
period_display = format_period_description(period_days, db_user.language)
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
if final_traffic_gb == 0:
|
||||
traffic_display = 'Безлимитный'
|
||||
@@ -192,6 +149,13 @@ async def _prepare_subscription_summary(
|
||||
else:
|
||||
traffic_display = f'{summary_data.get("traffic_gb", 0)} ГБ'
|
||||
|
||||
# Resolve country display names (still needed for the summary text)
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
selected_country_ids = set(connected_squads)
|
||||
selected_countries_names: list[str] = [
|
||||
html.escape(country['name']) for country in countries if country['uuid'] in selected_country_ids
|
||||
]
|
||||
|
||||
details_lines = []
|
||||
|
||||
# Добавляем строку базового периода только если цена не равна 0
|
||||
@@ -212,40 +176,34 @@ async def _prepare_subscription_summary(
|
||||
f'- Трафик: {texts.format_price(traffic_price_per_month)}/мес × {months_in_period}'
|
||||
f' = {texts.format_price(total_traffic_price)}'
|
||||
)
|
||||
if traffic_component['discount_total'] > 0:
|
||||
traffic_line += (
|
||||
f' (скидка {traffic_component["discount_percent"]}%:'
|
||||
f' -{texts.format_price(traffic_component["discount_total"])})'
|
||||
)
|
||||
if traffic_discount_total > 0:
|
||||
traffic_line += f' (скидка {traffic_discount_percent}%: -{texts.format_price(traffic_discount_total)})'
|
||||
details_lines.append(traffic_line)
|
||||
if total_countries_price > 0:
|
||||
if total_servers_price > 0:
|
||||
servers_line = (
|
||||
f'- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period}'
|
||||
f' = {texts.format_price(total_countries_price)}'
|
||||
f'- Серверы: {texts.format_price(servers_price_per_month)}/мес × {months_in_period}'
|
||||
f' = {texts.format_price(total_servers_price)}'
|
||||
)
|
||||
if total_servers_discount > 0:
|
||||
servers_line += f' (скидка {servers_discount_percent}%: -{texts.format_price(total_servers_discount)})'
|
||||
if servers_discount_total > 0:
|
||||
servers_line += f' (скидка {servers_discount_percent}%: -{texts.format_price(servers_discount_total)})'
|
||||
details_lines.append(servers_line)
|
||||
if devices_selection_enabled and total_devices_price > 0:
|
||||
devices_line = (
|
||||
f'- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period}'
|
||||
f' = {texts.format_price(total_devices_price)}'
|
||||
)
|
||||
if devices_component['discount_total'] > 0:
|
||||
devices_line += (
|
||||
f' (скидка {devices_component["discount_percent"]}%:'
|
||||
f' -{texts.format_price(devices_component["discount_total"])})'
|
||||
)
|
||||
if devices_discount_total > 0:
|
||||
devices_line += f' (скидка {devices_discount_percent}%: -{texts.format_price(devices_discount_total)})'
|
||||
details_lines.append(devices_line)
|
||||
|
||||
if promo_offer_component['discount'] > 0:
|
||||
if promo_offer_discount > 0:
|
||||
details_lines.append(
|
||||
texts.t(
|
||||
'SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT',
|
||||
'- Промо-предложение: -{amount} ({percent}% дополнительно)',
|
||||
).format(
|
||||
amount=texts.format_price(promo_offer_component['discount']),
|
||||
percent=promo_offer_component['percent'],
|
||||
amount=texts.format_price(promo_offer_discount),
|
||||
percent=offer_pct,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -309,114 +267,21 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
|
||||
if subscription.is_trial:
|
||||
return 0
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
try:
|
||||
owner = subscription.user
|
||||
except AttributeError:
|
||||
owner = None
|
||||
|
||||
promo_group_id = getattr(owner, 'promo_group_id', None) if owner else None
|
||||
|
||||
# В тарифном режиме цена тарифа уже включает серверы и трафик
|
||||
tariff = None
|
||||
tariff_price_found = False
|
||||
if settings.is_tariffs_mode() and subscription.tariff_id:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff and tariff.period_prices:
|
||||
base_cost_original = tariff.period_prices.get('30', 0) or tariff.period_prices.get(30, 0)
|
||||
if base_cost_original > 0:
|
||||
tariff_price_found = True
|
||||
|
||||
if not tariff_price_found:
|
||||
base_cost_original = PERIOD_PRICES.get(30, 0)
|
||||
|
||||
if tariff_price_found:
|
||||
# Тарифный режим: серверы и трафик включены в цену.
|
||||
# Порядок: база + устройства → скидка на полную сумму (как в calculate_renewal_price).
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
original_price = base_cost_original
|
||||
|
||||
tariff_device_limit = tariff.device_limit if tariff.device_limit is not None else 0
|
||||
device_limit = subscription.device_limit if subscription.device_limit is not None else tariff_device_limit
|
||||
extra_devices = max(0, device_limit - tariff_device_limit)
|
||||
device_price_per_unit = (
|
||||
tariff.device_price_kopeks
|
||||
if tariff and tariff.device_price_kopeks is not None
|
||||
else settings.PRICE_PER_DEVICE
|
||||
)
|
||||
devices_price = extra_devices * device_price_per_unit
|
||||
original_price += devices_price
|
||||
|
||||
# Скидка промогруппы на полную сумму (база + устройства)
|
||||
period_discount_percent = 0
|
||||
if owner:
|
||||
try:
|
||||
period_discount_percent = owner.get_promo_discount('period', 30)
|
||||
except AttributeError:
|
||||
pass
|
||||
discount_total = original_price * period_discount_percent // 100
|
||||
total_cost = original_price - discount_total
|
||||
|
||||
# Promo-offer скидка (временная)
|
||||
promo_offer_percent = get_user_active_promo_discount_percent(owner)
|
||||
if promo_offer_percent > 0:
|
||||
promo_offer_discount = total_cost * promo_offer_percent // 100
|
||||
total_cost = total_cost - promo_offer_discount
|
||||
else:
|
||||
# Классический режим: серверы + трафик + устройства считаются отдельно
|
||||
period_discount_percent = 0
|
||||
if owner:
|
||||
try:
|
||||
period_discount_percent = owner.get_promo_discount('period', 30)
|
||||
except AttributeError:
|
||||
period_discount_percent = 0
|
||||
|
||||
base_cost, _ = apply_percentage_discount(
|
||||
base_cost_original,
|
||||
period_discount_percent,
|
||||
)
|
||||
|
||||
try:
|
||||
servers_cost, _ = await subscription_service.get_countries_price_by_uuids(
|
||||
subscription.connected_squads,
|
||||
db,
|
||||
promo_group_id=promo_group_id,
|
||||
)
|
||||
except AttributeError:
|
||||
servers_cost, _ = await get_countries_price_by_uuids_fallback(
|
||||
subscription.connected_squads,
|
||||
db,
|
||||
promo_group_id=promo_group_id,
|
||||
)
|
||||
|
||||
traffic_cost = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
device_limit = subscription.device_limit
|
||||
if device_limit is None:
|
||||
if settings.is_devices_selection_enabled():
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
device_limit = forced_limit
|
||||
|
||||
devices_cost = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
|
||||
total_cost = base_cost + servers_cost + traffic_cost + devices_cost
|
||||
|
||||
logger.info('Месячная стоимость подписки', subscription_id=subscription.id, total_cost_kopeks=total_cost)
|
||||
result = await pricing_engine.calculate_renewal_price(db, subscription, 30, user=owner)
|
||||
total_cost = result.final_total
|
||||
|
||||
logger.info('Monthly subscription cost', subscription_id=subscription.id, total_cost_kopeks=total_cost)
|
||||
return total_cost
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка расчета стоимости подписки', error=e)
|
||||
logger.error('Error calculating subscription cost', error=e)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InaccessibleMessage, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import PERIOD_PRICES, settings
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import (
|
||||
create_paid_subscription,
|
||||
create_pending_trial_subscription,
|
||||
@@ -37,6 +37,7 @@ from app.keyboards.inline import (
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
from app.services.remnawave_service import RemnaWaveConfigurationError
|
||||
from app.services.subscription_checkout_service import (
|
||||
clear_subscription_checkout_draft,
|
||||
@@ -99,7 +100,6 @@ from app.handlers.simple_subscription import (
|
||||
from app.states import SubscriptionStates
|
||||
from app.utils.price_display import PriceInfo, format_price_text
|
||||
from app.utils.pricing_utils import (
|
||||
apply_percentage_discount,
|
||||
calculate_months_from_days,
|
||||
format_period_description,
|
||||
)
|
||||
@@ -343,8 +343,23 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d
|
||||
]
|
||||
|
||||
if is_daily:
|
||||
# Для суточного тарифа показываем цену и прогресс-бар
|
||||
daily_price = getattr(tariff, 'daily_price_kopeks', 0) / 100
|
||||
# Для суточного тарифа показываем цену с учётом скидки промогруппы + promo-offer
|
||||
raw_daily_kopeks = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
promo_group = (
|
||||
db_user.get_primary_promo_group() if hasattr(db_user, 'get_primary_promo_group') else None
|
||||
)
|
||||
daily_group_pct = promo_group.get_discount_percent('period', 1) if promo_group else 0
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
daily_offer_pct = get_user_active_promo_discount_percent(db_user)
|
||||
if daily_group_pct > 0 or daily_offer_pct > 0:
|
||||
daily_kopeks, _, _ = PricingEngine.apply_stacked_discounts(
|
||||
raw_daily_kopeks, daily_group_pct, daily_offer_pct
|
||||
)
|
||||
else:
|
||||
daily_kopeks = raw_daily_kopeks
|
||||
daily_price = daily_kopeks / 100
|
||||
tariff_info_lines.append(f'Цена: {daily_price:.2f} ₽/день')
|
||||
|
||||
# Прогресс-бар до следующего списания
|
||||
@@ -1735,9 +1750,11 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us
|
||||
await callback.answer('⚠ У вас нет активной подписки', show_alert=True)
|
||||
return
|
||||
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
from app.services.subscription_renewal_service import SubscriptionRenewalChargeError, SubscriptionRenewalService
|
||||
|
||||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||||
months_in_period = calculate_months_from_days(days)
|
||||
|
||||
try:
|
||||
@@ -1884,7 +1901,7 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def select_period(callback: types.CallbackQuery, state: FSMContext, db_user: User):
|
||||
async def select_period(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession):
|
||||
period_days = int(callback.data.split('_')[1])
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -1894,18 +1911,23 @@ async def select_period(callback: types.CallbackQuery, state: FSMContext, db_use
|
||||
await callback.answer(texts.t('PERIOD_NOT_AVAILABLE', '❌ Этот период больше недоступен'), show_alert=True)
|
||||
return
|
||||
|
||||
# Получаем цену с защитой от KeyError
|
||||
period_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
data = await state.get_data()
|
||||
data['period_days'] = period_days
|
||||
data['total_price'] = period_price
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
fixed_traffic_price = settings.get_traffic_price(settings.get_fixed_traffic_limit())
|
||||
data['total_price'] += fixed_traffic_price
|
||||
data['traffic_gb'] = settings.get_fixed_traffic_limit()
|
||||
|
||||
# Вычисляем промежуточную цену через PricingEngine (countries/devices ещё не выбраны)
|
||||
pricing_result = await pricing_engine.calculate_classic_new_subscription_price(
|
||||
db,
|
||||
period_days,
|
||||
list(data.get('countries', [])),
|
||||
data.get('traffic_gb', 0) or 0,
|
||||
data.get('devices', settings.DEFAULT_DEVICE_LIMIT),
|
||||
user=db_user,
|
||||
)
|
||||
data['total_price'] = pricing_result.final_total
|
||||
|
||||
await state.set_data(data)
|
||||
|
||||
if settings.is_traffic_selectable():
|
||||
@@ -1958,7 +1980,7 @@ async def select_period(callback: types.CallbackQuery, state: FSMContext, db_use
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def select_devices(callback: types.CallbackQuery, state: FSMContext, db_user: User):
|
||||
async def select_devices(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
@@ -1980,27 +2002,27 @@ async def select_devices(callback: types.CallbackQuery, state: FSMContext, db_us
|
||||
|
||||
data = await state.get_data()
|
||||
|
||||
# Получаем цену периода с защитой от KeyError
|
||||
period_days = data.get('period_days')
|
||||
if not period_days or period_days not in PERIOD_PRICES:
|
||||
if not period_days:
|
||||
await callback.answer(
|
||||
texts.t('PERIOD_NOT_AVAILABLE', '❌ Период больше недоступен, начните заново'), show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0) + settings.get_traffic_price(data.get('traffic_gb', 0))
|
||||
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
# Проверяем, что ключ 'countries' существует в данных перед доступом к нему
|
||||
selected_countries = data.get('countries', [])
|
||||
countries_price = sum(c['price_kopeks'] for c in countries if c['uuid'] in selected_countries)
|
||||
|
||||
devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
|
||||
previous_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
|
||||
data['devices'] = devices
|
||||
data['total_price'] = base_price + countries_price + devices_price
|
||||
|
||||
# Вычисляем цену через PricingEngine с актуальными FSM-данными
|
||||
pricing_result = await pricing_engine.calculate_classic_new_subscription_price(
|
||||
db,
|
||||
period_days,
|
||||
list(data.get('countries', [])),
|
||||
data.get('traffic_gb', 0) or 0,
|
||||
devices,
|
||||
user=db_user,
|
||||
)
|
||||
data['total_price'] = pricing_result.final_total
|
||||
await state.set_data(data)
|
||||
|
||||
if devices != previous_devices:
|
||||
@@ -2049,8 +2071,6 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
|
||||
await save_subscription_checkout_draft(db_user.id, dict(data))
|
||||
resume_callback = 'subscription_resume_checkout' if should_offer_checkout_resume(db_user, True) else None
|
||||
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
|
||||
period_days = data.get('period_days')
|
||||
if period_days is None:
|
||||
await callback.message.edit_text(
|
||||
@@ -2059,62 +2079,8 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
months_in_period = data.get('months_in_period', calculate_months_from_days(period_days))
|
||||
|
||||
# Всегда пересчитываем base_price из PERIOD_PRICES для безопасности
|
||||
# (не доверяем кэшированным значениям из FSM данных)
|
||||
base_price_original = PERIOD_PRICES.get(period_days, 0)
|
||||
base_discount_percent = db_user.get_promo_discount(
|
||||
'period',
|
||||
period_days,
|
||||
)
|
||||
base_price, base_discount_total = apply_percentage_discount(
|
||||
base_price_original,
|
||||
base_discount_percent,
|
||||
)
|
||||
server_prices = data.get('server_prices_for_period', [])
|
||||
|
||||
if not server_prices:
|
||||
countries_price_per_month = 0
|
||||
per_month_prices: list[int] = []
|
||||
for country in countries:
|
||||
# Проверяем, что ключ 'countries' существует в данных перед доступом к нему
|
||||
selected_countries = data.get('countries', [])
|
||||
if country['uuid'] in selected_countries:
|
||||
server_price_per_month = country['price_kopeks']
|
||||
countries_price_per_month += server_price_per_month
|
||||
per_month_prices.append(server_price_per_month)
|
||||
|
||||
servers_discount_percent = db_user.get_promo_discount(
|
||||
'servers',
|
||||
period_days,
|
||||
)
|
||||
total_servers_price = 0
|
||||
total_servers_discount = 0
|
||||
discounted_servers_price_per_month = 0
|
||||
server_prices = []
|
||||
|
||||
for server_price_per_month in per_month_prices:
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
server_price_per_month,
|
||||
servers_discount_percent,
|
||||
)
|
||||
total_price_for_server = discounted_per_month * months_in_period
|
||||
total_discount_for_server = discount_per_month * months_in_period
|
||||
|
||||
discounted_servers_price_per_month += discounted_per_month
|
||||
total_servers_price += total_price_for_server
|
||||
total_servers_discount += total_discount_for_server
|
||||
server_prices.append(total_price_for_server)
|
||||
|
||||
total_countries_price = total_servers_price
|
||||
else:
|
||||
total_countries_price = data.get('total_servers_price', sum(server_prices))
|
||||
countries_price_per_month = data.get('servers_price_per_month', 0)
|
||||
discounted_servers_price_per_month = data.get('servers_discounted_price_per_month', countries_price_per_month)
|
||||
total_servers_discount = data.get('servers_discount_total', 0)
|
||||
servers_discount_percent = data.get('servers_discount_percent', 0)
|
||||
|
||||
# --- Resolve device limit (needed for PricingEngine and subscription creation) ---
|
||||
devices_selection_enabled = settings.is_devices_selection_enabled()
|
||||
forced_disabled_limit: int | None = None
|
||||
if devices_selection_enabled:
|
||||
@@ -2126,95 +2092,42 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
|
||||
else:
|
||||
devices_selected = forced_disabled_limit
|
||||
|
||||
additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = data.get('devices_price_per_month', additional_devices * settings.PRICE_PER_DEVICE)
|
||||
|
||||
devices_discount_percent = 0
|
||||
discounted_devices_price_per_month = 0
|
||||
devices_discount_total = 0
|
||||
total_devices_price = 0
|
||||
|
||||
if devices_selection_enabled and additional_devices > 0:
|
||||
if 'devices_discount_percent' in data:
|
||||
devices_discount_percent = data.get('devices_discount_percent', 0)
|
||||
discounted_devices_price_per_month = data.get('devices_discounted_price_per_month', devices_price_per_month)
|
||||
devices_discount_total = data.get('devices_discount_total', 0)
|
||||
total_devices_price = data.get('total_devices_price', discounted_devices_price_per_month * months_in_period)
|
||||
else:
|
||||
devices_discount_percent = db_user.get_promo_discount(
|
||||
'devices',
|
||||
period_days,
|
||||
)
|
||||
discounted_devices_price_per_month, discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
devices_discount_total = discount_per_month * months_in_period
|
||||
total_devices_price = discounted_devices_price_per_month * months_in_period
|
||||
|
||||
# --- Resolve traffic ---
|
||||
if settings.is_traffic_fixed():
|
||||
final_traffic_gb = settings.get_fixed_traffic_limit()
|
||||
traffic_price_per_month = data.get('traffic_price_per_month', settings.get_traffic_price(final_traffic_gb))
|
||||
else:
|
||||
final_traffic_gb = data.get('final_traffic_gb', data.get('traffic_gb'))
|
||||
traffic_gb = data.get('traffic_gb')
|
||||
if traffic_gb is not None:
|
||||
traffic_price_per_month = data.get('traffic_price_per_month', settings.get_traffic_price(traffic_gb))
|
||||
else:
|
||||
traffic_price_per_month = data.get('traffic_price_per_month', 0)
|
||||
final_traffic_gb = data.get('final_traffic_gb', data.get('traffic_gb', 0))
|
||||
|
||||
if 'traffic_discount_percent' in data:
|
||||
traffic_discount_percent = data.get('traffic_discount_percent', 0)
|
||||
discounted_traffic_price_per_month = data.get('traffic_discounted_price_per_month', traffic_price_per_month)
|
||||
traffic_discount_total = data.get('traffic_discount_total', 0)
|
||||
total_traffic_price = data.get('total_traffic_price', discounted_traffic_price_per_month * months_in_period)
|
||||
else:
|
||||
traffic_discount_percent = db_user.get_promo_discount(
|
||||
'traffic',
|
||||
period_days,
|
||||
)
|
||||
discounted_traffic_price_per_month, discount_per_month = apply_percentage_discount(
|
||||
traffic_price_per_month,
|
||||
traffic_discount_percent,
|
||||
)
|
||||
traffic_discount_total = discount_per_month * months_in_period
|
||||
total_traffic_price = discounted_traffic_price_per_month * months_in_period
|
||||
|
||||
total_servers_price = data.get('total_servers_price', total_countries_price)
|
||||
# --- Resolve connected squads ---
|
||||
connected_squads = list(data.get('countries', []))
|
||||
|
||||
cached_total_price = data.get('total_price', 0)
|
||||
cached_promo_discount_value = data.get('promo_offer_discount_value', 0)
|
||||
|
||||
# Всегда пересчитываем monthly_additions из компонентов для безопасности
|
||||
discounted_monthly_additions = (
|
||||
discounted_traffic_price_per_month + discounted_servers_price_per_month + discounted_devices_price_per_month
|
||||
# Lock user BEFORE promo-offer read to prevent TOCTOU
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||||
|
||||
# --- Delegate pricing to PricingEngine ---
|
||||
from app.services.pricing_engine import PricingEngine, pricing_engine
|
||||
|
||||
pricing_result = await pricing_engine.calculate_classic_new_subscription_price(
|
||||
db,
|
||||
period_days,
|
||||
connected_squads,
|
||||
final_traffic_gb,
|
||||
devices_selected,
|
||||
user=db_user,
|
||||
)
|
||||
details = PricingEngine.classic_pricing_to_purchase_details(pricing_result)
|
||||
|
||||
# Вычисляем ожидаемую цену до промо-скидки из компонентов
|
||||
calculated_total_before_promo = base_price + (discounted_monthly_additions * months_in_period)
|
||||
final_price = pricing_result.final_total
|
||||
server_prices = details['servers_individual_prices']
|
||||
months_in_period = details['months_in_period']
|
||||
promo_offer_discount_value = pricing_result.promo_offer_discount
|
||||
promo_offer_discount_percent = pricing_result.breakdown.get('offer_discount_pct', 0)
|
||||
|
||||
# Получаем сохраненную цену до промо-скидки или используем вычисленную
|
||||
validation_total_price = data.get('total_price_before_promo_offer')
|
||||
if validation_total_price is None and cached_promo_discount_value > 0:
|
||||
validation_total_price = cached_total_price + cached_promo_discount_value
|
||||
if validation_total_price is None:
|
||||
validation_total_price = cached_total_price
|
||||
|
||||
current_promo_offer_percent = _get_promo_offer_discount_percent(db_user)
|
||||
if current_promo_offer_percent > 0:
|
||||
final_price, promo_offer_discount_value = apply_percentage_discount(
|
||||
calculated_total_before_promo,
|
||||
current_promo_offer_percent,
|
||||
)
|
||||
promo_offer_discount_percent = current_promo_offer_percent
|
||||
else:
|
||||
final_price = calculated_total_before_promo
|
||||
promo_offer_discount_value = 0
|
||||
promo_offer_discount_percent = 0
|
||||
|
||||
# Валидация: проверяем что cached_total_price соответствует ожидаемой финальной цене
|
||||
# Блокируем только если цена ВЫРОСЛА (пользователь переплатит).
|
||||
# Если цена снизилась (промо-скидка активировалась) — разрешаем покупку по новой цене.
|
||||
# --- Price validation: block if price increased significantly vs cached FSM price ---
|
||||
price_difference = final_price - cached_total_price
|
||||
if price_difference > 0:
|
||||
max_allowed_increase = max(500, int(final_price * 0.05)) # 5% или минимум 5₽
|
||||
@@ -2244,36 +2157,50 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
|
||||
final_price=final_price / 100,
|
||||
)
|
||||
|
||||
# Используем пересчитанную цену
|
||||
validation_total_price = calculated_total_before_promo
|
||||
# --- Logging ---
|
||||
base_price_original = details['base_price_original']
|
||||
base_price = details['base_price']
|
||||
base_discount_total = details['base_discount_total']
|
||||
base_discount_percent = details['base_discount_percent']
|
||||
|
||||
logger.info('Расчет покупки подписки на дней ( мес)', data=data['period_days'], months_in_period=months_in_period)
|
||||
base_log = f' Период: {base_price_original / 100}₽'
|
||||
if base_discount_total and base_discount_total > 0:
|
||||
base_log += f' → {base_price / 100}₽ (скидка {base_discount_percent}%: -{base_discount_total / 100}₽)'
|
||||
logger.info(base_log)
|
||||
if total_traffic_price > 0:
|
||||
message = f' Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period} = {total_traffic_price / 100}₽'
|
||||
if traffic_discount_total > 0:
|
||||
message += f' (скидка {traffic_discount_percent}%: -{traffic_discount_total / 100}₽)'
|
||||
logger.info(message)
|
||||
if total_servers_price > 0:
|
||||
message = (
|
||||
f' Серверы: {countries_price_per_month / 100}₽/мес × {months_in_period} = {total_servers_price / 100}₽'
|
||||
if details['total_traffic_price'] > 0:
|
||||
traffic_msg = (
|
||||
f' Трафик: {details["traffic_price_per_month"] / 100}₽/мес'
|
||||
f' × {months_in_period} = {details["total_traffic_price"] / 100}₽'
|
||||
)
|
||||
if total_servers_discount > 0:
|
||||
message += f' (скидка {servers_discount_percent}%: -{total_servers_discount / 100}₽)'
|
||||
logger.info(message)
|
||||
if total_devices_price > 0:
|
||||
message = (
|
||||
f' Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period} = {total_devices_price / 100}₽'
|
||||
if details['traffic_discount_total'] > 0:
|
||||
traffic_msg += (
|
||||
f' (скидка {details["traffic_discount_percent"]}%: -{details["traffic_discount_total"] / 100}₽)'
|
||||
)
|
||||
logger.info(traffic_msg)
|
||||
if details['total_servers_price'] > 0:
|
||||
servers_msg = (
|
||||
f' Серверы: {details["servers_price_per_month"] / 100}₽/мес'
|
||||
f' × {months_in_period} = {details["total_servers_price"] / 100}₽'
|
||||
)
|
||||
if devices_discount_total > 0:
|
||||
message += f' (скидка {devices_discount_percent}%: -{devices_discount_total / 100}₽)'
|
||||
logger.info(message)
|
||||
if details['servers_discount_total'] > 0:
|
||||
servers_msg += (
|
||||
f' (скидка {details["servers_discount_percent"]}%: -{details["servers_discount_total"] / 100}₽)'
|
||||
)
|
||||
logger.info(servers_msg)
|
||||
if details['total_devices_price'] > 0:
|
||||
devices_msg = (
|
||||
f' Устройства: {details["devices_price_per_month"] / 100}₽/мес'
|
||||
f' × {months_in_period} = {details["total_devices_price"] / 100}₽'
|
||||
)
|
||||
if details['devices_discount_total'] > 0:
|
||||
devices_msg += (
|
||||
f' (скидка {details["devices_discount_percent"]}%: -{details["devices_discount_total"] / 100}₽)'
|
||||
)
|
||||
logger.info(devices_msg)
|
||||
if promo_offer_discount_value > 0:
|
||||
logger.info(
|
||||
'🎯 Промо-предложение: -₽ (%)',
|
||||
'Промо-предложение: -₽ (%)',
|
||||
promo_offer_discount_value=promo_offer_discount_value / 100,
|
||||
promo_offer_discount_percent=promo_offer_discount_percent,
|
||||
)
|
||||
@@ -2521,6 +2448,7 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
|
||||
subscription,
|
||||
reset_traffic=True,
|
||||
reset_reason='покупка подписки',
|
||||
sync_squads=True,
|
||||
)
|
||||
else:
|
||||
remnawave_user = await subscription_service.create_remnawave_user(
|
||||
@@ -2953,7 +2881,16 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
|
||||
|
||||
# При возобновлении проверяем баланс
|
||||
if needs_resume:
|
||||
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||||
promo_group = PricingEngine.resolve_promo_group(db_user)
|
||||
daily_group_pct = promo_group.get_discount_percent('period', 1) if promo_group else 0
|
||||
daily_price = (
|
||||
PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price
|
||||
)
|
||||
if daily_price > 0 and db_user.balance_kopeks < daily_price:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
@@ -2966,7 +2903,6 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
|
||||
|
||||
if needs_resume:
|
||||
# Списываем суточную оплату ДО активации (чтобы не было бесплатного дня)
|
||||
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
if daily_price > 0 and is_inactive:
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
|
||||
@@ -4147,13 +4083,14 @@ async def _extend_existing_subscription(
|
||||
):
|
||||
"""Продлевает существующую подписку."""
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.crud.user import lock_user_for_pricing, subtract_user_balance
|
||||
from app.database.models import TransactionType
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Рассчитываем цену подписки
|
||||
# Рассчитываем цену подписки (group discounts per-category)
|
||||
subscription_params = {
|
||||
'period_days': period_days,
|
||||
'device_limit': device_limit,
|
||||
@@ -4166,6 +4103,12 @@ async def _extend_existing_subscription(
|
||||
user=db_user,
|
||||
resolved_squad_uuid=squad_uuid,
|
||||
)
|
||||
|
||||
# PricingEngine already applies promo-offer discount inside calculate_classic_new_subscription_price.
|
||||
# Only determine whether to consume the offer (zero it out after use).
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
consume_promo = get_user_active_promo_discount_percent(db_user) > 0
|
||||
logger.warning(
|
||||
'SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user= | total= | base= | traffic= | devices= | servers= | discount= | device_limit',
|
||||
db_user_id=db_user.id,
|
||||
@@ -4212,7 +4155,7 @@ async def _extend_existing_subscription(
|
||||
'device_limit': device_limit,
|
||||
'traffic_limit_gb': traffic_limit_gb,
|
||||
'squad_uuid': squad_uuid,
|
||||
'consume_promo_offer': False,
|
||||
'consume_promo_offer': consume_promo,
|
||||
}
|
||||
|
||||
await user_cart_service.save_user_cart(db_user.id, cart_data)
|
||||
@@ -4233,7 +4176,7 @@ async def _extend_existing_subscription(
|
||||
db_user,
|
||||
price_kopeks,
|
||||
f'Продление подписки на {period_days} дней',
|
||||
consume_promo_offer=False, # Простая покупка не использует промо-скидки
|
||||
consume_promo_offer=consume_promo,
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -79,9 +79,14 @@ def format_tariffs_list_text(
|
||||
discount_icon = ''
|
||||
|
||||
if is_daily:
|
||||
# Для суточных тарифов показываем цену за день
|
||||
# Для суточных тарифов показываем цену за день с учётом скидки промогруппы
|
||||
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
price_text = f'🔄 {format_price_kopeks(daily_price, compact=True)}/день'
|
||||
if db_user:
|
||||
group_pct, offer_pct, daily_discount = _get_user_period_discount(db_user, 1)
|
||||
if daily_discount > 0:
|
||||
daily_price = _apply_promo_discount(daily_price, group_pct, offer_pct)
|
||||
discount_icon = '🔥'
|
||||
price_text = f'🔄 {format_price_kopeks(daily_price, compact=True)}/день{discount_icon}'
|
||||
else:
|
||||
# Для периодных тарифов показываем минимальную цену
|
||||
prices = tariff.period_prices or {}
|
||||
@@ -394,21 +399,42 @@ def _calculate_custom_tariff_price(
|
||||
return period_price, traffic_price, total_price
|
||||
|
||||
|
||||
def format_custom_tariff_preview(
|
||||
async def format_custom_tariff_preview(
|
||||
tariff: Tariff,
|
||||
days: int,
|
||||
traffic_gb: int,
|
||||
user_balance: int,
|
||||
db_user: User | None = None,
|
||||
discount_percent: int = 0,
|
||||
group_pct: int = 0,
|
||||
offer_pct: int = 0,
|
||||
) -> str:
|
||||
"""Форматирует предпросмотр покупки с кастомными параметрами."""
|
||||
period_price, traffic_price, total_price = _calculate_custom_tariff_price(tariff, days, traffic_gb)
|
||||
"""Форматирует предпросмотр покупки с кастомными параметрами.
|
||||
|
||||
# Применяем скидку
|
||||
if discount_percent > 0:
|
||||
total_price = _apply_promo_discount(total_price, group_pct, offer_pct)
|
||||
Uses PricingEngine when db_user is provided for accurate per-category discounts
|
||||
(period, traffic addon). Falls back to manual calculation otherwise.
|
||||
"""
|
||||
if db_user is not None:
|
||||
# Use PricingEngine — single source of truth for all discounts
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
result = await pricing_engine.calculate_tariff_purchase_price(
|
||||
tariff,
|
||||
days,
|
||||
device_limit=tariff.device_limit,
|
||||
custom_traffic_gb=traffic_gb if tariff.can_purchase_custom_traffic() else None,
|
||||
user=db_user,
|
||||
)
|
||||
period_price = result.base_price
|
||||
traffic_price = result.traffic_price
|
||||
total_price = result.final_total
|
||||
has_discount = result.promo_group_discount > 0 or result.promo_offer_discount > 0
|
||||
else:
|
||||
# Fallback: raw prices without discounts
|
||||
period_price, traffic_price, total_price = _calculate_custom_tariff_price(tariff, days, traffic_gb)
|
||||
has_discount = discount_percent > 0
|
||||
if has_discount:
|
||||
total_price = _apply_promo_discount(total_price, group_pct, offer_pct)
|
||||
|
||||
traffic_display = f'{traffic_gb} ГБ' if traffic_gb > 0 else format_traffic(tariff.traffic_limit_gb)
|
||||
|
||||
@@ -433,7 +459,7 @@ def format_custom_tariff_preview(
|
||||
|
||||
text += f'📱 Устройств: {tariff.device_limit}\n'
|
||||
|
||||
if discount_percent > 0:
|
||||
if has_discount:
|
||||
text += f'\n🎁 <b>Скидка: {discount_percent}%</b>\n'
|
||||
|
||||
text += f"""
|
||||
@@ -477,7 +503,9 @@ async def show_tariffs_list(
|
||||
return
|
||||
|
||||
# Проверяем есть ли у пользователя скидки по периодам
|
||||
promo_group = getattr(db_user, 'promo_group', None)
|
||||
promo_group = db_user.get_primary_promo_group() if hasattr(db_user, 'get_primary_promo_group') else None
|
||||
if promo_group is None:
|
||||
promo_group = getattr(db_user, 'promo_group', None)
|
||||
has_period_discounts = False
|
||||
if promo_group:
|
||||
period_discounts = getattr(promo_group, 'period_discounts', None)
|
||||
@@ -514,7 +542,12 @@ async def select_tariff(
|
||||
|
||||
if is_daily:
|
||||
# Для суточного тарифа показываем подтверждение без выбора периода
|
||||
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
group_pct, offer_pct, daily_discount = _get_user_period_discount(db_user, 1)
|
||||
daily_price = (
|
||||
_apply_promo_discount(raw_daily_price, group_pct, offer_pct) if daily_discount > 0 else raw_daily_price
|
||||
)
|
||||
discount_text = f'\n💎 Скидка: {daily_discount}%' if daily_discount > 0 else ''
|
||||
user_balance = db_user.balance_kopeks or 0
|
||||
traffic = format_traffic(tariff.traffic_limit_gb)
|
||||
|
||||
@@ -525,7 +558,8 @@ async def select_tariff(
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {tariff.device_limit}\n'
|
||||
f'🔄 Тип: <b>Суточный</b>\n\n'
|
||||
f'💰 <b>Цена: {format_price_kopeks(daily_price)}/день</b>\n\n'
|
||||
f'💰 <b>Цена: {format_price_kopeks(daily_price)}/день</b>'
|
||||
f'{discount_text}\n\n'
|
||||
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n\n'
|
||||
f'ℹ️ Средства будут списываться автоматически раз в сутки.\n'
|
||||
f'Вы можете приостановить подписку в любой момент.',
|
||||
@@ -557,7 +591,8 @@ async def select_tariff(
|
||||
f'❌ <b>Недостаточно средств</b>\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'🔄 Тип: Суточный\n'
|
||||
f'💰 Цена: {format_price_kopeks(daily_price)}/день\n\n'
|
||||
f'💰 Цена: {format_price_kopeks(daily_price)}/день'
|
||||
f'{discount_text}\n\n'
|
||||
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n'
|
||||
f'⚠️ Не хватает: <b>{format_price_kopeks(missing)}</b>\n\n'
|
||||
f'🛒 <i>Корзина сохранена! После пополнения баланса подписка будет оформлена автоматически.</i>',
|
||||
@@ -588,14 +623,13 @@ async def select_tariff(
|
||||
period_offer_pct=offer_pct,
|
||||
)
|
||||
|
||||
preview_text = format_custom_tariff_preview(
|
||||
preview_text = await format_custom_tariff_preview(
|
||||
tariff=tariff,
|
||||
days=initial_days,
|
||||
traffic_gb=initial_traffic,
|
||||
user_balance=user_balance,
|
||||
db_user=db_user,
|
||||
discount_percent=discount_percent,
|
||||
group_pct=group_pct,
|
||||
offer_pct=offer_pct,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -672,14 +706,13 @@ async def handle_custom_days_change(
|
||||
|
||||
user_balance = db_user.balance_kopeks or 0
|
||||
|
||||
preview_text = format_custom_tariff_preview(
|
||||
preview_text = await format_custom_tariff_preview(
|
||||
tariff=tariff,
|
||||
days=new_days,
|
||||
traffic_gb=current_traffic,
|
||||
user_balance=user_balance,
|
||||
db_user=db_user,
|
||||
discount_percent=discount_percent,
|
||||
group_pct=group_pct,
|
||||
offer_pct=offer_pct,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -722,8 +755,6 @@ async def handle_custom_traffic_change(
|
||||
current_days = state_data.get('custom_days', tariff.min_days)
|
||||
current_traffic = state_data.get('custom_traffic_gb', tariff.min_traffic_gb)
|
||||
discount_percent = state_data.get('period_discount_percent', 0)
|
||||
group_pct = state_data.get('period_group_pct', 0)
|
||||
offer_pct = state_data.get('period_offer_pct', 0)
|
||||
|
||||
# Применяем изменение
|
||||
new_traffic = current_traffic + delta
|
||||
@@ -733,14 +764,13 @@ async def handle_custom_traffic_change(
|
||||
|
||||
user_balance = db_user.balance_kopeks or 0
|
||||
|
||||
preview_text = format_custom_tariff_preview(
|
||||
preview_text = await format_custom_tariff_preview(
|
||||
tariff=tariff,
|
||||
days=current_days,
|
||||
traffic_gb=new_traffic,
|
||||
user_balance=user_balance,
|
||||
db_user=db_user,
|
||||
discount_percent=discount_percent,
|
||||
group_pct=group_pct,
|
||||
offer_pct=offer_pct,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -777,28 +807,33 @@ async def handle_custom_confirm(
|
||||
await callback.answer('Тариф недоступен', show_alert=True)
|
||||
return
|
||||
|
||||
# Lock user BEFORE price computation to prevent TOCTOU on promo offer
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||||
|
||||
state_data = await state.get_data()
|
||||
custom_days = state_data.get('custom_days', tariff.min_days)
|
||||
custom_traffic = state_data.get('custom_traffic_gb', tariff.min_traffic_gb)
|
||||
discount_percent = state_data.get('period_discount_percent', 0)
|
||||
group_pct = state_data.get('period_group_pct', 0)
|
||||
offer_pct = state_data.get('period_offer_pct', 0)
|
||||
|
||||
# Рассчитываем цену (используем общую функцию)
|
||||
period_price, traffic_price, total_price = _calculate_custom_tariff_price(tariff, custom_days, custom_traffic)
|
||||
# Calculate price via PricingEngine (single source of truth for all discounts)
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
result = await pricing_engine.calculate_tariff_purchase_price(
|
||||
tariff,
|
||||
custom_days,
|
||||
device_limit=tariff.device_limit,
|
||||
custom_traffic_gb=custom_traffic if tariff.can_purchase_custom_traffic() else None,
|
||||
user=db_user,
|
||||
)
|
||||
total_price = result.final_total
|
||||
|
||||
# Проверяем, что цена за период валидна
|
||||
if period_price == 0 and not tariff.can_purchase_custom_days():
|
||||
# Период не найден в period_prices - ошибка
|
||||
if result.base_price == 0 and not tariff.can_purchase_custom_days():
|
||||
await callback.answer('Выбранный период недоступен для этого тарифа', show_alert=True)
|
||||
return
|
||||
|
||||
# Применяем скидку к цене периода (не к трафику)
|
||||
if discount_percent > 0:
|
||||
period_price = _apply_promo_discount(period_price, group_pct, offer_pct)
|
||||
total_price = period_price + traffic_price
|
||||
|
||||
# Проверяем баланс
|
||||
# Проверяем баланс (user already locked, balance is fresh)
|
||||
user_balance = db_user.balance_kopeks or 0
|
||||
if user_balance < total_price:
|
||||
await callback.answer('Недостаточно средств на балансе', show_alert=True)
|
||||
@@ -807,7 +842,7 @@ async def handle_custom_confirm(
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Save promo offer state before deduction (for restore on failure)
|
||||
consume_promo = get_user_active_promo_discount_percent(db_user) > 0
|
||||
consume_promo = result.promo_offer_discount > 0
|
||||
saved_promo_percent = int(getattr(db_user, 'promo_offer_discount_percent', 0) or 0) if consume_promo else 0
|
||||
saved_promo_source = getattr(db_user, 'promo_offer_discount_source', None) if consume_promo else None
|
||||
saved_promo_expires = getattr(db_user, 'promo_offer_discount_expires_at', None) if consume_promo else None
|
||||
@@ -1014,14 +1049,13 @@ async def select_tariff_period_with_traffic(
|
||||
period_offer_pct=offer_pct,
|
||||
)
|
||||
|
||||
preview_text = format_custom_tariff_preview(
|
||||
preview_text = await format_custom_tariff_preview(
|
||||
tariff=tariff,
|
||||
days=period,
|
||||
traffic_gb=initial_traffic,
|
||||
user_balance=user_balance,
|
||||
db_user=db_user,
|
||||
discount_percent=discount_percent,
|
||||
group_pct=group_pct,
|
||||
offer_pct=offer_pct,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -1152,34 +1186,28 @@ async def confirm_tariff_purchase(
|
||||
await callback.answer('Тариф недоступен', show_alert=True)
|
||||
return
|
||||
|
||||
# Получаем цену
|
||||
prices = tariff.period_prices or {}
|
||||
base_price = prices.get(str(period), 0)
|
||||
# Lock user BEFORE price computation to prevent TOCTOU on promo offer
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||||
|
||||
# Calculate price via PricingEngine (single source of truth)
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
# Add extra device cost if user has more devices than tariff's included limit
|
||||
existing_sub = await get_subscription_by_user_id(db, db_user.id)
|
||||
device_price_per_unit = (
|
||||
tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE
|
||||
)
|
||||
extra_devices = 0
|
||||
device_limit = None
|
||||
if existing_sub and existing_sub.tariff_id == tariff.id:
|
||||
extra_devices = max(0, (existing_sub.device_limit or 0) - (tariff.device_limit or 0))
|
||||
devices_price = extra_devices * device_price_per_unit
|
||||
device_limit = existing_sub.device_limit
|
||||
|
||||
# Apply discounts sequentially (matching PricingEngine): group first, then offer
|
||||
subtotal = base_price + devices_price
|
||||
promo_group = db_user.get_primary_promo_group()
|
||||
group_discount_pct = promo_group.get_discount_percent('period', period) if promo_group else 0
|
||||
if group_discount_pct > 0:
|
||||
subtotal = subtotal - subtotal * group_discount_pct // 100
|
||||
result = await pricing_engine.calculate_tariff_purchase_price(
|
||||
tariff,
|
||||
period,
|
||||
device_limit=device_limit,
|
||||
user=db_user,
|
||||
)
|
||||
final_price = result.final_total
|
||||
|
||||
offer_discount_pct = get_user_active_promo_discount_percent(db_user)
|
||||
if offer_discount_pct > 0:
|
||||
subtotal = subtotal - subtotal * offer_discount_pct // 100
|
||||
|
||||
final_price = max(0, subtotal)
|
||||
|
||||
# Проверяем баланс
|
||||
# Проверяем баланс (user already locked, balance is fresh)
|
||||
user_balance = db_user.balance_kopeks or 0
|
||||
if user_balance < final_price:
|
||||
await callback.answer('Недостаточно средств на балансе', show_alert=True)
|
||||
@@ -1188,7 +1216,7 @@ async def confirm_tariff_purchase(
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Списываем баланс
|
||||
consume_promo = get_user_active_promo_discount_percent(db_user) > 0
|
||||
consume_promo = result.promo_offer_discount > 0
|
||||
# Save promo offer state before deduction (for restore on failure)
|
||||
saved_promo_percent = int(getattr(db_user, 'promo_offer_discount_percent', 0) or 0) if consume_promo else 0
|
||||
saved_promo_source = getattr(db_user, 'promo_offer_discount_source', None) if consume_promo else None
|
||||
@@ -1382,9 +1410,26 @@ async def confirm_daily_tariff_purchase(
|
||||
await callback.answer('Некорректная цена тарифа', show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем баланс
|
||||
# Lock user BEFORE price computation to prevent TOCTOU on promo offer
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||||
|
||||
# Apply group + promo-offer discounts via PricingEngine (single source of truth)
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
pricing_result = await pricing_engine.calculate_tariff_purchase_price(
|
||||
tariff,
|
||||
period_days=1,
|
||||
device_limit=tariff.device_limit,
|
||||
user=db_user,
|
||||
)
|
||||
final_daily_price = pricing_result.final_total
|
||||
consume_promo = pricing_result.breakdown.get('offer_discount_pct', 0) > 0
|
||||
|
||||
# Проверяем баланс (user already locked, balance is fresh)
|
||||
user_balance = db_user.balance_kopeks or 0
|
||||
if user_balance < daily_price:
|
||||
if user_balance < final_daily_price:
|
||||
await callback.answer('Недостаточно средств на балансе', show_alert=True)
|
||||
return
|
||||
|
||||
@@ -1395,8 +1440,9 @@ async def confirm_daily_tariff_purchase(
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
db_user,
|
||||
daily_price,
|
||||
final_daily_price,
|
||||
f'Покупка суточного тарифа {tariff.name} (первый день)',
|
||||
consume_promo_offer=consume_promo,
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
if not success:
|
||||
@@ -1423,7 +1469,7 @@ async def confirm_daily_tariff_purchase(
|
||||
try:
|
||||
if existing_subscription:
|
||||
# Обновляем существующую подписку на суточный тариф
|
||||
# Сохраняем докупленные устройства при смене тарифа
|
||||
# Сбрасываем лимит устройств на базу нового тарифа (докупленные не переносятся)
|
||||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||||
|
||||
old_tariff = (
|
||||
@@ -1485,7 +1531,7 @@ async def confirm_daily_tariff_purchase(
|
||||
await add_user_balance(
|
||||
db,
|
||||
db_user,
|
||||
daily_price,
|
||||
final_daily_price,
|
||||
'Возврат: ошибка покупки суточного тарифа',
|
||||
create_transaction=True,
|
||||
transaction_type=TransactionType.REFUND,
|
||||
@@ -1494,7 +1540,7 @@ async def confirm_daily_tariff_purchase(
|
||||
logger.critical(
|
||||
'CRITICAL: не удалось вернуть средства после ошибки покупки суточного тарифа',
|
||||
user_id=db_user.id,
|
||||
price_kopeks=daily_price,
|
||||
price_kopeks=final_daily_price,
|
||||
refund_error=refund_error,
|
||||
)
|
||||
await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True)
|
||||
@@ -1518,7 +1564,7 @@ async def confirm_daily_tariff_purchase(
|
||||
db,
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=daily_price,
|
||||
amount_kopeks=final_daily_price,
|
||||
description=f'Покупка суточного тарифа {tariff.name} (первый день)',
|
||||
)
|
||||
|
||||
@@ -1532,7 +1578,7 @@ async def confirm_daily_tariff_purchase(
|
||||
None,
|
||||
1, # 1 день
|
||||
was_trial_conversion=False,
|
||||
amount_kopeks=daily_price,
|
||||
amount_kopeks=final_daily_price,
|
||||
purchase_type='renewal' if existing_subscription else 'first_purchase',
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -1555,7 +1601,7 @@ async def confirm_daily_tariff_purchase(
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {tariff.device_limit}\n'
|
||||
f'🔄 Тип: Суточный\n'
|
||||
f'💰 Списано: {format_price_kopeks(daily_price)}\n\n'
|
||||
f'💰 Списано: {format_price_kopeks(final_daily_price)}\n\n'
|
||||
f'ℹ️ Следующее списание через 24 часа.\n'
|
||||
f'Перейдите в раздел «Подписка» для подключения.',
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
@@ -1591,26 +1637,39 @@ def get_tariff_extend_keyboard(
|
||||
subscription_device_limit: int | None = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Создает клавиатуру выбора периода для продления по тарифу с учетом скидок по периодам."""
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
texts = get_texts(language)
|
||||
buttons = []
|
||||
|
||||
promo_group = PricingEngine.resolve_promo_group(db_user) if db_user else None
|
||||
|
||||
prices = tariff.period_prices or {}
|
||||
for period_str in sorted(prices.keys(), key=int):
|
||||
period = int(period_str)
|
||||
price = prices[period_str]
|
||||
base_price = prices[period_str]
|
||||
|
||||
# Добавляем стоимость дополнительных устройств
|
||||
# Стоимость дополнительных устройств
|
||||
devices_cost = 0
|
||||
if subscription_device_limit is not None:
|
||||
price += _calc_extra_devices_cost(tariff, subscription_device_limit, period)
|
||||
devices_cost = _calc_extra_devices_cost(tariff, subscription_device_limit, period)
|
||||
|
||||
# Получаем скидку для конкретного периода
|
||||
group_pct, offer_pct, discount_percent = 0, 0, 0
|
||||
if db_user:
|
||||
group_pct, offer_pct, discount_percent = _get_user_period_discount(db_user, period)
|
||||
# Per-category group discounts (period + devices separately, like PricingEngine)
|
||||
period_pct = promo_group.get_discount_percent('period', period) if promo_group else 0
|
||||
devices_pct = promo_group.get_discount_percent('devices', period) if promo_group else 0
|
||||
offer_pct = get_user_active_promo_discount_percent(db_user) if db_user else 0
|
||||
|
||||
if discount_percent > 0:
|
||||
price = _apply_promo_discount(price, group_pct, offer_pct)
|
||||
price_text = f'{format_price_kopeks(price)} 🔥−{discount_percent}%'
|
||||
discounted_base = PricingEngine.apply_discount(base_price, period_pct)
|
||||
discounted_devices = PricingEngine.apply_discount(devices_cost, devices_pct)
|
||||
subtotal = discounted_base + discounted_devices
|
||||
price = PricingEngine.apply_discount(subtotal, offer_pct)
|
||||
|
||||
# Combined display discount
|
||||
total_original = base_price + devices_cost
|
||||
has_discount = price < total_original and total_original > 0
|
||||
if has_discount:
|
||||
combined_pct = round((1 - price / total_original) * 100)
|
||||
price_text = f'{format_price_kopeks(price)} 🔥−{combined_pct}%'
|
||||
else:
|
||||
price_text = format_price_kopeks(price)
|
||||
|
||||
@@ -1662,7 +1721,9 @@ async def show_tariff_extend(
|
||||
traffic = format_traffic(tariff.traffic_limit_gb)
|
||||
|
||||
# Проверяем есть ли у пользователя скидки по периодам
|
||||
promo_group = getattr(db_user, 'promo_group', None)
|
||||
promo_group = db_user.get_primary_promo_group() if hasattr(db_user, 'get_primary_promo_group') else None
|
||||
if promo_group is None:
|
||||
promo_group = getattr(db_user, 'promo_group', None)
|
||||
has_period_discounts = False
|
||||
if promo_group:
|
||||
period_discounts = getattr(promo_group, 'period_discounts', None)
|
||||
@@ -1716,14 +1777,21 @@ async def select_tariff_extend_period(
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
actual_device_limit = (subscription.device_limit if subscription else None) or tariff.device_limit
|
||||
|
||||
# Получаем скидку для выбранного периода
|
||||
group_pct, offer_pct, discount_percent = _get_user_period_discount(db_user, period)
|
||||
# Calculate price via PricingEngine (per-category discounts: period + devices)
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
# Получаем цену (тариф + дополнительные устройства)
|
||||
prices = tariff.period_prices or {}
|
||||
base_price = prices.get(str(period), 0)
|
||||
base_price += _calc_extra_devices_cost(tariff, actual_device_limit, period)
|
||||
final_price = _apply_promo_discount(base_price, group_pct, offer_pct)
|
||||
result = await pricing_engine.calculate_tariff_purchase_price(
|
||||
tariff,
|
||||
period,
|
||||
device_limit=actual_device_limit,
|
||||
user=db_user,
|
||||
)
|
||||
final_price = result.final_total
|
||||
original_price = result.original_total
|
||||
total_discount = result.promo_group_discount + result.promo_offer_discount
|
||||
discount_percent = (
|
||||
round((1 - final_price / original_price) * 100) if original_price > 0 and total_discount > 0 else 0
|
||||
)
|
||||
|
||||
# Проверяем баланс
|
||||
user_balance = db_user.balance_kopeks or 0
|
||||
@@ -1733,7 +1801,7 @@ async def select_tariff_extend_period(
|
||||
if user_balance >= final_price:
|
||||
discount_text = ''
|
||||
if discount_percent > 0:
|
||||
discount_text = f'\n🎁 Скидка: {discount_percent}% (-{format_price_kopeks(base_price - final_price)})'
|
||||
discount_text = f'\n🎁 Скидка: {discount_percent}% (-{format_price_kopeks(total_discount)})'
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'✅ <b>Подтверждение продления</b>\n\n'
|
||||
@@ -1791,8 +1859,6 @@ async def select_tariff_extend_period(
|
||||
extend_tariff_id=tariff_id,
|
||||
extend_period=period,
|
||||
extend_discount_percent=discount_percent,
|
||||
extend_group_pct=group_pct,
|
||||
extend_offer_pct=offer_pct,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -1821,15 +1887,21 @@ async def confirm_tariff_extend(
|
||||
|
||||
actual_device_limit = subscription.device_limit or tariff.device_limit
|
||||
|
||||
data = await state.get_data()
|
||||
group_pct = data.get('extend_group_pct', 0)
|
||||
offer_pct = data.get('extend_offer_pct', 0)
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
# Получаем цену (тариф + дополнительные устройства)
|
||||
prices = tariff.period_prices or {}
|
||||
base_price = prices.get(str(period), 0)
|
||||
base_price += _calc_extra_devices_cost(tariff, actual_device_limit, period)
|
||||
final_price = _apply_promo_discount(base_price, group_pct, offer_pct)
|
||||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||||
|
||||
# Calculate price via PricingEngine (handles per-category discounts: period + devices)
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
result = await pricing_engine.calculate_tariff_purchase_price(
|
||||
tariff,
|
||||
period,
|
||||
device_limit=actual_device_limit,
|
||||
user=db_user,
|
||||
)
|
||||
final_price = result.final_total
|
||||
consume_promo = result.promo_offer_discount > 0
|
||||
|
||||
# Проверяем баланс
|
||||
user_balance = db_user.balance_kopeks or 0
|
||||
@@ -1846,7 +1918,7 @@ async def confirm_tariff_extend(
|
||||
db_user,
|
||||
final_price,
|
||||
f'Продление тарифа {tariff.name} на {period} дней',
|
||||
consume_promo_offer=get_user_active_promo_discount_percent(db_user) > 0,
|
||||
consume_promo_offer=consume_promo,
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
if not success:
|
||||
@@ -1966,9 +2038,14 @@ def format_tariff_switch_list_text(
|
||||
discount_icon = ''
|
||||
|
||||
if is_daily:
|
||||
# Для суточных тарифов показываем цену за день
|
||||
# Для суточных тарифов показываем цену за день с учётом скидки промогруппы
|
||||
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
price_text = f'🔄 {format_price_kopeks(daily_price, compact=True)}/день'
|
||||
if db_user:
|
||||
group_pct, offer_pct, daily_discount = _get_user_period_discount(db_user, 1)
|
||||
if daily_discount > 0:
|
||||
daily_price = _apply_promo_discount(daily_price, group_pct, offer_pct)
|
||||
discount_icon = '🔥'
|
||||
price_text = f'🔄 {format_price_kopeks(daily_price, compact=True)}/день{discount_icon}'
|
||||
else:
|
||||
prices = tariff.period_prices or {}
|
||||
if prices:
|
||||
@@ -2124,7 +2201,9 @@ async def show_tariff_switch_list(
|
||||
current_tariff_name = current_tariff.name
|
||||
|
||||
# Проверяем есть ли у пользователя скидки по периодам
|
||||
promo_group = getattr(db_user, 'promo_group', None)
|
||||
promo_group = db_user.get_primary_promo_group() if hasattr(db_user, 'get_primary_promo_group') else None
|
||||
if promo_group is None:
|
||||
promo_group = getattr(db_user, 'promo_group', None)
|
||||
has_period_discounts = False
|
||||
if promo_group:
|
||||
period_discounts = getattr(promo_group, 'period_discounts', None)
|
||||
@@ -2170,7 +2249,12 @@ async def select_tariff_switch(
|
||||
|
||||
if is_daily:
|
||||
# Для суточного тарифа показываем подтверждение без выбора периода
|
||||
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
group_pct, offer_pct, daily_discount = _get_user_period_discount(db_user, 1)
|
||||
daily_price = (
|
||||
_apply_promo_discount(raw_daily_price, group_pct, offer_pct) if daily_discount > 0 else raw_daily_price
|
||||
)
|
||||
discount_text = f'\n💎 Скидка: {daily_discount}%' if daily_discount > 0 else ''
|
||||
user_balance = db_user.balance_kopeks or 0
|
||||
|
||||
# Проверяем текущую подписку на оставшиеся дни
|
||||
@@ -2189,7 +2273,8 @@ async def select_tariff_switch(
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {tariff.device_limit}\n'
|
||||
f'🔄 Тип: <b>Суточный</b>\n\n'
|
||||
f'💰 <b>Цена: {format_price_kopeks(daily_price)}/день</b>\n\n'
|
||||
f'💰 <b>Цена: {format_price_kopeks(daily_price)}/день</b>'
|
||||
f'{discount_text}\n\n'
|
||||
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}'
|
||||
f'{days_warning}\n\n'
|
||||
f'ℹ️ Средства будут списываться автоматически раз в сутки.\n'
|
||||
@@ -2212,7 +2297,8 @@ async def select_tariff_switch(
|
||||
f'❌ <b>Недостаточно средств</b>\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'🔄 Тип: Суточный\n'
|
||||
f'💰 Цена: {format_price_kopeks(daily_price)}/день\n\n'
|
||||
f'💰 Цена: {format_price_kopeks(daily_price)}/день'
|
||||
f'{discount_text}\n\n'
|
||||
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n'
|
||||
f'⚠️ Не хватает: <b>{format_price_kopeks(missing)}</b>'
|
||||
f'{days_warning}',
|
||||
@@ -2269,13 +2355,21 @@ async def select_tariff_switch_period(
|
||||
data = await state.get_data()
|
||||
current_tariff_id = data.get('current_tariff_id')
|
||||
|
||||
# Получаем скидку для выбранного периода
|
||||
group_pct, offer_pct, discount_percent = _get_user_period_discount(db_user, period)
|
||||
# Calculate price via PricingEngine (per-category discounts: period + devices for new tariff)
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
# Получаем цену
|
||||
prices = tariff.period_prices or {}
|
||||
base_price = prices.get(str(period), 0)
|
||||
final_price = _apply_promo_discount(base_price, group_pct, offer_pct)
|
||||
result = await pricing_engine.calculate_tariff_purchase_price(
|
||||
tariff,
|
||||
period,
|
||||
device_limit=tariff.device_limit or 0,
|
||||
user=db_user,
|
||||
)
|
||||
final_price = result.final_total
|
||||
original_price = result.original_total
|
||||
total_discount = result.promo_group_discount + result.promo_offer_discount
|
||||
discount_percent = (
|
||||
round((1 - final_price / original_price) * 100) if original_price > 0 and total_discount > 0 else 0
|
||||
)
|
||||
|
||||
# Проверяем баланс
|
||||
user_balance = db_user.balance_kopeks or 0
|
||||
@@ -2300,7 +2394,7 @@ async def select_tariff_switch_period(
|
||||
if user_balance >= final_price:
|
||||
discount_text = ''
|
||||
if discount_percent > 0:
|
||||
discount_text = f'\n🎁 Скидка: {discount_percent}% (-{format_price_kopeks(base_price - final_price)})'
|
||||
discount_text = f'\n🎁 Скидка: {discount_percent}% (-{format_price_kopeks(total_discount)})'
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'✅ <b>Подтверждение переключения тарифа</b>\n\n'
|
||||
@@ -2354,13 +2448,30 @@ async def confirm_tariff_switch(
|
||||
await callback.answer('Тариф недоступен', show_alert=True)
|
||||
return
|
||||
|
||||
# Получаем скидку для выбранного периода
|
||||
group_pct, offer_pct, discount_percent = _get_user_period_discount(db_user, period)
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
# Получаем цену
|
||||
prices = tariff.period_prices or {}
|
||||
base_price = prices.get(str(period), 0)
|
||||
final_price = _apply_promo_discount(base_price, group_pct, offer_pct)
|
||||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||||
|
||||
# Проверяем наличие подписки (need device_limit for pricing)
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
if not subscription:
|
||||
await callback.answer('У вас нет активной подписки', show_alert=True)
|
||||
return
|
||||
|
||||
# Calculate price via PricingEngine (handles per-category discounts + extra devices)
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
effective_device_limit = (
|
||||
subscription.device_limit if subscription.tariff_id == tariff.id else (tariff.device_limit or 0)
|
||||
)
|
||||
result = await pricing_engine.calculate_tariff_purchase_price(
|
||||
tariff,
|
||||
period,
|
||||
device_limit=effective_device_limit,
|
||||
user=db_user,
|
||||
)
|
||||
final_price = result.final_total
|
||||
consume_promo = result.promo_offer_discount > 0
|
||||
|
||||
# Проверяем баланс
|
||||
user_balance = db_user.balance_kopeks or 0
|
||||
@@ -2368,12 +2479,6 @@ async def confirm_tariff_switch(
|
||||
await callback.answer('Недостаточно средств на балансе', show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем наличие подписки
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
if not subscription:
|
||||
await callback.answer('У вас нет активной подписки', show_alert=True)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
@@ -2383,7 +2488,7 @@ async def confirm_tariff_switch(
|
||||
db_user,
|
||||
final_price,
|
||||
f'Смена тарифа на {tariff.name} ({period} дней)',
|
||||
consume_promo_offer=get_user_active_promo_discount_percent(db_user) > 0,
|
||||
consume_promo_offer=consume_promo,
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
if not success:
|
||||
@@ -2536,9 +2641,26 @@ async def confirm_daily_tariff_switch(
|
||||
await callback.answer('Некорректная цена тарифа', show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем баланс
|
||||
# Lock user BEFORE price computation to prevent TOCTOU on promo offer
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||||
|
||||
# Apply group + promo-offer discounts via PricingEngine (single source of truth)
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
pricing_result = await pricing_engine.calculate_tariff_purchase_price(
|
||||
tariff,
|
||||
period_days=1,
|
||||
device_limit=tariff.device_limit,
|
||||
user=db_user,
|
||||
)
|
||||
final_daily_price = pricing_result.final_total
|
||||
consume_promo = pricing_result.breakdown.get('offer_discount_pct', 0) > 0
|
||||
|
||||
# Проверяем баланс (user already locked, balance is fresh)
|
||||
user_balance = db_user.balance_kopeks or 0
|
||||
if user_balance < daily_price:
|
||||
if user_balance < final_daily_price:
|
||||
await callback.answer('Недостаточно средств на балансе', show_alert=True)
|
||||
return
|
||||
|
||||
@@ -2555,8 +2677,9 @@ async def confirm_daily_tariff_switch(
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
db_user,
|
||||
daily_price,
|
||||
final_daily_price,
|
||||
f'Смена на суточный тариф {tariff.name} (первый день)',
|
||||
consume_promo_offer=consume_promo,
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
if not success:
|
||||
@@ -2574,7 +2697,7 @@ async def confirm_daily_tariff_switch(
|
||||
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
|
||||
|
||||
# Обновляем подписку на суточный тариф
|
||||
# Сохраняем докупленные устройства при смене тарифа
|
||||
# Сбрасываем лимит устройств на базу нового тарифа (докупленные не переносятся)
|
||||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||||
|
||||
old_tariff = await get_tariff_by_id(db, subscription.tariff_id) if subscription.tariff_id else None
|
||||
@@ -2639,7 +2762,7 @@ async def confirm_daily_tariff_switch(
|
||||
db,
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=daily_price,
|
||||
amount_kopeks=final_daily_price,
|
||||
description=f'Смена на суточный тариф {tariff.name} (первый день)',
|
||||
)
|
||||
|
||||
@@ -2653,7 +2776,7 @@ async def confirm_daily_tariff_switch(
|
||||
None,
|
||||
1, # 1 день
|
||||
was_trial_conversion=False,
|
||||
amount_kopeks=daily_price,
|
||||
amount_kopeks=final_daily_price,
|
||||
purchase_type='tariff_switch',
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -2669,7 +2792,7 @@ async def confirm_daily_tariff_switch(
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {tariff.device_limit}\n'
|
||||
f'🔄 Тип: Суточный\n'
|
||||
f'💰 Списано: {format_price_kopeks(daily_price)}\n\n'
|
||||
f'💰 Списано: {format_price_kopeks(final_daily_price)}\n\n'
|
||||
f'ℹ️ Следующее списание через 24 часа.',
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
@@ -2683,65 +2806,53 @@ async def confirm_daily_tariff_switch(
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка при смене на суточный тариф', error=e, exc_info=True)
|
||||
await db.rollback()
|
||||
# Compensating refund: balance was already committed by subtract_user_balance
|
||||
try:
|
||||
from app.database.crud.user import add_user_balance
|
||||
|
||||
await add_user_balance(
|
||||
db,
|
||||
db_user,
|
||||
final_daily_price,
|
||||
'Возврат: ошибка смены на суточный тариф',
|
||||
create_transaction=True,
|
||||
transaction_type=TransactionType.REFUND,
|
||||
)
|
||||
except Exception as refund_error:
|
||||
logger.critical(
|
||||
'CRITICAL: не удалось вернуть средства после ошибки смены на суточный тариф',
|
||||
user_id=db_user.id,
|
||||
price_kopeks=final_daily_price,
|
||||
refund_error=refund_error,
|
||||
)
|
||||
await callback.answer('Произошла ошибка при смене тарифа', show_alert=True)
|
||||
|
||||
|
||||
# ==================== Мгновенное переключение тарифов (без выбора периода) ====================
|
||||
|
||||
|
||||
def _get_tariff_monthly_price(tariff: Tariff) -> int:
|
||||
"""Получает месячную цену тарифа (30 дней) с fallback на пропорциональный расчёт."""
|
||||
price = tariff.get_price_for_period(30)
|
||||
if price is not None:
|
||||
return price
|
||||
|
||||
# Fallback: пропорционально пересчитываем из первого доступного периода
|
||||
periods = tariff.get_available_periods()
|
||||
if periods:
|
||||
first_period = periods[0]
|
||||
first_price = tariff.get_price_for_period(first_period)
|
||||
if first_price:
|
||||
return int(first_price * 30 / first_period)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _calculate_instant_switch_cost(
|
||||
current_tariff: Tariff,
|
||||
new_tariff: Tariff,
|
||||
remaining_days: int,
|
||||
db_user: User | None = None,
|
||||
) -> tuple[int, bool]:
|
||||
"""
|
||||
Рассчитывает стоимость мгновенного переключения тарифа.
|
||||
|
||||
Если новый тариф дороже - доплата пропорционально оставшимся дням.
|
||||
Если дешевле или равен - бесплатно.
|
||||
|
||||
Формула: (new_monthly - current_monthly) * remaining_days / 30
|
||||
Скидка применяется к обоим тарифам одинаково.
|
||||
"""Рассчитывает стоимость мгновенного переключения тарифа.
|
||||
|
||||
Делегирует расчёт в PricingEngine.calculate_tariff_switch_cost().
|
||||
Returns:
|
||||
(upgrade_cost_kopeks, is_upgrade)
|
||||
"""
|
||||
current_monthly = _get_tariff_monthly_price(current_tariff)
|
||||
new_monthly = _get_tariff_monthly_price(new_tariff)
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
group_pct, offer_pct, discount_percent = 0, 0, 0
|
||||
if db_user:
|
||||
group_pct, offer_pct, discount_percent = _get_user_period_discount(db_user, 30)
|
||||
|
||||
if discount_percent > 0:
|
||||
current_monthly = _apply_promo_discount(current_monthly, group_pct, offer_pct)
|
||||
new_monthly = _apply_promo_discount(new_monthly, group_pct, offer_pct)
|
||||
|
||||
price_diff = new_monthly - current_monthly
|
||||
|
||||
if price_diff <= 0:
|
||||
return 0, False
|
||||
|
||||
upgrade_cost = int(price_diff * remaining_days / 30)
|
||||
return upgrade_cost, True
|
||||
result = pricing_engine.calculate_tariff_switch_cost(
|
||||
current_tariff,
|
||||
new_tariff,
|
||||
remaining_days,
|
||||
user=db_user,
|
||||
)
|
||||
return result.upgrade_cost, result.is_upgrade
|
||||
|
||||
|
||||
def format_instant_switch_list_text(
|
||||
@@ -2984,7 +3095,15 @@ async def preview_instant_switch(
|
||||
|
||||
# Для суточного тарифа особая логика показа
|
||||
if is_new_daily:
|
||||
daily_price = getattr(new_tariff, 'daily_price_kopeks', 0)
|
||||
raw_daily_price = getattr(new_tariff, 'daily_price_kopeks', 0)
|
||||
# Применяем групповую скидку + promo-offer для отображения
|
||||
daily_group_pct, daily_offer_pct, daily_discount = _get_user_period_discount(db_user, 1)
|
||||
daily_price = (
|
||||
_apply_promo_discount(raw_daily_price, daily_group_pct, daily_offer_pct)
|
||||
if daily_discount > 0
|
||||
else raw_daily_price
|
||||
)
|
||||
discount_text = f'\n💎 Скидка: {daily_discount}%' if daily_discount > 0 else ''
|
||||
user_balance = db_user.balance_kopeks or 0
|
||||
|
||||
if user_balance >= daily_price:
|
||||
@@ -2997,7 +3116,8 @@ async def preview_instant_switch(
|
||||
f' • Трафик: {traffic}\n'
|
||||
f' • Устройств: {new_tariff.device_limit}\n'
|
||||
f' • Тип: 🔄 Суточный\n\n'
|
||||
f'💰 <b>Цена: {format_price_kopeks(daily_price)}/день</b>\n\n'
|
||||
f'💰 <b>Цена: {format_price_kopeks(daily_price)}/день</b>'
|
||||
f'{discount_text}\n\n'
|
||||
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}'
|
||||
f'{daily_warning}\n\n'
|
||||
f'ℹ️ Средства будут списываться автоматически раз в сутки.',
|
||||
@@ -3010,7 +3130,8 @@ async def preview_instant_switch(
|
||||
f'❌ <b>Недостаточно средств</b>\n\n'
|
||||
f'📦 Тариф: <b>{new_tariff.name}</b>\n'
|
||||
f'🔄 Тип: Суточный\n'
|
||||
f'💰 Цена: {format_price_kopeks(daily_price)}/день\n\n'
|
||||
f'💰 Цена: {format_price_kopeks(daily_price)}/день'
|
||||
f'{discount_text}\n\n'
|
||||
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n'
|
||||
f'⚠️ Не хватает: <b>{format_price_kopeks(missing)}</b>'
|
||||
f'{daily_warning}',
|
||||
@@ -3099,19 +3220,37 @@ async def confirm_instant_switch(
|
||||
await callback.answer('Тариф недоступен', show_alert=True)
|
||||
return
|
||||
|
||||
# Получаем данные из состояния
|
||||
data = await state.get_data()
|
||||
upgrade_cost = data.get('upgrade_cost', 0)
|
||||
is_upgrade = data.get('is_upgrade', False)
|
||||
remaining_days = data.get('remaining_days', 0)
|
||||
|
||||
# Проверяем подписку
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
if not subscription:
|
||||
await callback.answer('Подписка не найдена', show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем баланс если это upgrade
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||||
|
||||
# Recompute upgrade_cost under lock (FSM-stored value may be stale)
|
||||
current_tariff = await get_tariff_by_id(db, subscription.tariff_id) if subscription.tariff_id else None
|
||||
if not current_tariff:
|
||||
await callback.answer('Текущий тариф не найден', show_alert=True)
|
||||
return
|
||||
remaining_days = max(0, (subscription.end_date - datetime.now(UTC)).days) if subscription.end_date else 0
|
||||
|
||||
# Use full TariffSwitchResult to access offer_discount_pct for consume_promo_offer flag
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
switch_result = pricing_engine.calculate_tariff_switch_cost(
|
||||
current_tariff,
|
||||
new_tariff,
|
||||
remaining_days,
|
||||
user=db_user,
|
||||
)
|
||||
upgrade_cost = switch_result.upgrade_cost
|
||||
is_upgrade = switch_result.is_upgrade
|
||||
consume_promo = switch_result.offer_discount_pct > 0
|
||||
|
||||
# Проверяем баланс если это upgrade (use locked user's fresh balance)
|
||||
user_balance = db_user.balance_kopeks or 0
|
||||
if is_upgrade and user_balance < upgrade_cost:
|
||||
await callback.answer('Недостаточно средств на балансе', show_alert=True)
|
||||
@@ -3121,13 +3260,14 @@ async def confirm_instant_switch(
|
||||
|
||||
try:
|
||||
# Списываем баланс если это upgrade
|
||||
# upgrade_cost includes both group + offer discounts from PricingEngine
|
||||
if is_upgrade and upgrade_cost > 0:
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
db_user,
|
||||
upgrade_cost,
|
||||
f'Переключение на тариф {new_tariff.name}',
|
||||
consume_promo_offer=get_user_active_promo_discount_percent(db_user) > 0,
|
||||
consume_promo_offer=consume_promo,
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
if not success:
|
||||
@@ -3148,7 +3288,7 @@ async def confirm_instant_switch(
|
||||
is_new_daily = getattr(new_tariff, 'is_daily', False)
|
||||
|
||||
# Обновляем подписку с новыми параметрами тарифа
|
||||
# Сохраняем докупленные устройства при смене тарифа
|
||||
# Сбрасываем лимит устройств на базу нового тарифа (докупленные не переносятся)
|
||||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||||
|
||||
old_tariff = await get_tariff_by_id(db, subscription.tariff_id) if subscription.tariff_id else None
|
||||
@@ -3176,7 +3316,15 @@ async def confirm_instant_switch(
|
||||
|
||||
if is_new_daily:
|
||||
# Для суточного тарифа - сбрасываем на 1 день и настраиваем суточные параметры
|
||||
daily_price = getattr(new_tariff, 'daily_price_kopeks', 0)
|
||||
# Apply group + promo-offer discounts via PricingEngine (single source of truth)
|
||||
daily_pricing = await pricing_engine.calculate_tariff_purchase_price(
|
||||
new_tariff,
|
||||
period_days=1,
|
||||
device_limit=new_tariff.device_limit,
|
||||
user=db_user,
|
||||
)
|
||||
daily_price = daily_pricing.final_total
|
||||
consume_promo_for_daily = daily_pricing.breakdown.get('offer_discount_pct', 0) > 0
|
||||
|
||||
# Списываем первый день если ещё не списано (upgrade_cost был 0)
|
||||
if upgrade_cost == 0 and daily_price > 0:
|
||||
@@ -3186,6 +3334,7 @@ async def confirm_instant_switch(
|
||||
db_user,
|
||||
daily_price,
|
||||
f'Переключение на суточный тариф {new_tariff.name} (первый день)',
|
||||
consume_promo_offer=consume_promo_for_daily,
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
if not success:
|
||||
@@ -3199,6 +3348,22 @@ async def confirm_instant_switch(
|
||||
description=f'Переключение на суточный тариф {new_tariff.name} (первый день)',
|
||||
)
|
||||
|
||||
# Уведомление админу о списании за первый день суточного тарифа
|
||||
try:
|
||||
admin_notification_service = AdminNotificationService(callback.bot)
|
||||
await admin_notification_service.send_subscription_purchase_notification(
|
||||
db,
|
||||
db_user,
|
||||
subscription,
|
||||
None,
|
||||
1,
|
||||
was_trial_conversion=False,
|
||||
amount_kopeks=daily_price,
|
||||
purchase_type='tariff_switch',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления админу', error=e)
|
||||
|
||||
subscription.end_date = datetime.now(UTC) + timedelta(days=1)
|
||||
subscription.is_trial = False
|
||||
subscription.is_daily_paused = False
|
||||
@@ -3266,7 +3431,6 @@ async def confirm_instant_switch(
|
||||
|
||||
# Для суточного тарифа другое сообщение об успехе
|
||||
if is_new_daily:
|
||||
daily_price = getattr(new_tariff, 'daily_price_kopeks', 0)
|
||||
await callback.message.edit_text(
|
||||
f'🎉 <b>Тариф успешно изменён!</b>\n\n'
|
||||
f'📦 Новый тариф: <b>{new_tariff.name}</b>\n'
|
||||
|
||||
@@ -19,18 +19,16 @@ from app.keyboards.inline import (
|
||||
get_reset_traffic_confirm_keyboard,
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from app.states import SubscriptionStates
|
||||
from app.utils.pricing_utils import (
|
||||
apply_percentage_discount,
|
||||
calculate_prorated_price,
|
||||
)
|
||||
|
||||
from .common import (
|
||||
_apply_addon_discount,
|
||||
_get_addon_discount_percent_for_user,
|
||||
_get_period_hint_from_subscription,
|
||||
get_confirm_switch_traffic_keyboard,
|
||||
get_traffic_switch_keyboard,
|
||||
@@ -84,7 +82,7 @@ async def handle_add_traffic(callback: types.CallbackQuery, db_user: User, db: A
|
||||
packages = tariff.get_traffic_topup_packages()
|
||||
|
||||
period_hint_days = _get_period_hint_from_subscription(subscription)
|
||||
traffic_discount_percent = _get_addon_discount_percent_for_user(
|
||||
traffic_discount_percent = PricingEngine.get_addon_discount_percent(
|
||||
db_user,
|
||||
'traffic',
|
||||
period_hint_days,
|
||||
@@ -136,7 +134,7 @@ async def handle_add_traffic(callback: types.CallbackQuery, db_user: User, db: A
|
||||
|
||||
current_traffic = subscription.traffic_limit_gb
|
||||
period_hint_days = _get_period_hint_from_subscription(subscription)
|
||||
traffic_discount_percent = _get_addon_discount_percent_for_user(
|
||||
traffic_discount_percent = PricingEngine.get_addon_discount_percent(
|
||||
db_user,
|
||||
'traffic',
|
||||
period_hint_days,
|
||||
@@ -261,6 +259,10 @@ async def confirm_reset_traffic(callback: types.CallbackQuery, db_user: User, db
|
||||
await callback.answer('⚠️ В текущем режиме трафик фиксированный', show_alert=True)
|
||||
return
|
||||
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
@@ -471,16 +473,18 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
|
||||
await callback.answer('⚠️ Цена для этого пакета не настроена', show_alert=True)
|
||||
return
|
||||
|
||||
# Lock user BEFORE price computation to prevent TOCTOU on group discount
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||||
subscription = db_user.subscription
|
||||
|
||||
period_hint_days = _get_period_hint_from_subscription(subscription)
|
||||
discount_result = _apply_addon_discount(
|
||||
db_user,
|
||||
'traffic',
|
||||
discounted_per_month, discount_per_month, traffic_discount_pct = PricingEngine.calculate_traffic_discount(
|
||||
base_price,
|
||||
db_user,
|
||||
period_hint_days,
|
||||
)
|
||||
|
||||
discounted_per_month = discount_result['discounted']
|
||||
discount_per_month = discount_result['discount']
|
||||
charged_days = 30
|
||||
|
||||
# На тарифах пакеты трафика покупаются на 1 месяц (30 дней),
|
||||
@@ -510,7 +514,7 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
|
||||
'traffic_gb': traffic_gb,
|
||||
'price_kopeks': price,
|
||||
'base_price_kopeks': discounted_per_month,
|
||||
'discount_percent': discount_result['percent'],
|
||||
'discount_percent': traffic_discount_pct,
|
||||
'source': 'bot',
|
||||
'description': f'Докупка {traffic_gb} ГБ трафика',
|
||||
}
|
||||
@@ -619,7 +623,7 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
|
||||
if price > 0:
|
||||
success_text += f'\n💰 Списано: {texts.format_price(price)}'
|
||||
if total_discount_value > 0:
|
||||
success_text += f' (скидка {discount_result["percent"]}%: -{texts.format_price(total_discount_value)})'
|
||||
success_text += f' (скидка {traffic_discount_pct}%: -{texts.format_price(total_discount_value)})'
|
||||
|
||||
await callback.message.edit_text(success_text, reply_markup=get_back_keyboard(db_user.language))
|
||||
|
||||
@@ -668,7 +672,7 @@ async def handle_switch_traffic(callback: types.CallbackQuery, db_user: User, db
|
||||
base_traffic = current_traffic - purchased_traffic
|
||||
|
||||
period_hint_days = _get_period_hint_from_subscription(subscription)
|
||||
traffic_discount_percent = _get_addon_discount_percent_for_user(
|
||||
traffic_discount_percent = PricingEngine.get_addon_discount_percent(
|
||||
db_user,
|
||||
'traffic',
|
||||
period_hint_days,
|
||||
@@ -722,17 +726,17 @@ async def confirm_switch_traffic(callback: types.CallbackQuery, db_user: User, d
|
||||
now = datetime.now(UTC)
|
||||
days_remaining = max(1, (subscription.end_date - now).days)
|
||||
period_hint_days = days_remaining if days_remaining > 0 else None
|
||||
traffic_discount_percent = _get_addon_discount_percent_for_user(
|
||||
traffic_discount_percent = PricingEngine.get_addon_discount_percent(
|
||||
db_user,
|
||||
'traffic',
|
||||
period_hint_days,
|
||||
)
|
||||
|
||||
discounted_old_per_month, _ = apply_percentage_discount(
|
||||
discounted_old_per_month = PricingEngine.apply_discount(
|
||||
old_price_per_month,
|
||||
traffic_discount_percent,
|
||||
)
|
||||
discounted_new_per_month, _ = apply_percentage_discount(
|
||||
discounted_new_per_month = PricingEngine.apply_discount(
|
||||
new_price_per_month,
|
||||
traffic_discount_percent,
|
||||
)
|
||||
@@ -800,12 +804,35 @@ async def confirm_switch_traffic(callback: types.CallbackQuery, db_user: User, d
|
||||
async def execute_switch_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
callback_parts = callback.data.split('_')
|
||||
new_traffic_gb = int(callback_parts[3])
|
||||
price_difference = int(callback_parts[4])
|
||||
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
current_traffic = subscription.traffic_limit_gb
|
||||
|
||||
# Recompute price under lock (callback-baked value may be stale)
|
||||
purchased_traffic = getattr(subscription, 'purchased_traffic_gb', 0) or 0
|
||||
base_traffic = current_traffic - purchased_traffic
|
||||
old_price_per_month = settings.get_traffic_price(base_traffic)
|
||||
new_price_per_month = settings.get_traffic_price(new_traffic_gb)
|
||||
days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days)
|
||||
traffic_discount_percent = PricingEngine.get_addon_discount_percent(
|
||||
db_user,
|
||||
'traffic',
|
||||
days_remaining,
|
||||
)
|
||||
discounted_old = PricingEngine.apply_discount(old_price_per_month, traffic_discount_percent)
|
||||
discounted_new = PricingEngine.apply_discount(new_price_per_month, traffic_discount_percent)
|
||||
price_diff_per_month = discounted_new - discounted_old
|
||||
if price_diff_per_month > 0:
|
||||
price_difference = int(price_diff_per_month * days_remaining / 30)
|
||||
price_difference = max(100, price_difference)
|
||||
else:
|
||||
price_difference = 0
|
||||
|
||||
try:
|
||||
if price_difference > 0:
|
||||
success = await subtract_user_balance(
|
||||
|
||||
+41
-1
@@ -1695,7 +1695,35 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_kassa_ai_enabled():
|
||||
if settings.is_kassa_ai_sbp_enabled():
|
||||
sbp_name = settings.get_kassa_ai_sbp_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_KASSA_AI_SBP', f'📱 {sbp_name}'),
|
||||
callback_data=_build_callback('kassa_ai_sbp'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_kassa_ai_card_enabled():
|
||||
card_name = settings.get_kassa_ai_card_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_KASSA_AI_CARD', f'💳 {card_name}'),
|
||||
callback_data=_build_callback('kassa_ai_card'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if (
|
||||
settings.is_kassa_ai_enabled()
|
||||
and not settings.is_kassa_ai_sbp_enabled()
|
||||
and not settings.is_kassa_ai_card_enabled()
|
||||
):
|
||||
kassa_ai_name = settings.get_kassa_ai_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
@@ -1718,6 +1746,18 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_severpay_enabled():
|
||||
severpay_name = settings.get_severpay_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_SEVERPAY', f'💳 Банковская карта ({severpay_name})'),
|
||||
callback_data=_build_callback('severpay'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_support_topup_enabled():
|
||||
keyboard.append(
|
||||
[
|
||||
|
||||
@@ -219,7 +219,10 @@ async def send_error_to_admin_chat(
|
||||
global _last_error_notification
|
||||
|
||||
chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None)
|
||||
topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None)
|
||||
# Используем топик для ошибок, если настроен, иначе общий
|
||||
topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_ERRORS_TOPIC_ID', None) or getattr(
|
||||
settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None
|
||||
)
|
||||
enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
|
||||
|
||||
if not enabled or not chat_id:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import html
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
@@ -26,6 +27,21 @@ from app.utils.message_patch import caption_exceeds_telegram_limit
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
|
||||
class NotificationCategory(StrEnum):
|
||||
"""Категории уведомлений для маршрутизации по топикам."""
|
||||
|
||||
PURCHASES = 'purchases' # Покупки подписок, покупки с лендинга
|
||||
RENEWALS = 'renewals' # Продления
|
||||
TRIALS = 'trials' # Триалы
|
||||
BALANCE = 'balance' # Пополнение баланса
|
||||
ADDONS = 'addons' # Докупка трафика/устройств/серверов
|
||||
INFRASTRUCTURE = 'infrastructure' # Ноды, техработы, статус панели, вебхуки
|
||||
ERRORS = 'errors' # Ошибки бота, краши
|
||||
PROMO = 'promo' # Промокоды, кампании, промогруппы
|
||||
PARTNERS = 'partners' # Партнёрки, выводы, админ-действия
|
||||
TICKETS = 'tickets' # Тикеты (уже существует)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@@ -37,6 +53,20 @@ class AdminNotificationService:
|
||||
self.ticket_topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID', None)
|
||||
self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
|
||||
|
||||
# Маппинг категорий на topic_id (None = fallback на self.topic_id)
|
||||
self.category_topics: dict[NotificationCategory, int | None] = {
|
||||
NotificationCategory.PURCHASES: getattr(settings, 'ADMIN_NOTIFICATIONS_PURCHASES_TOPIC_ID', None),
|
||||
NotificationCategory.RENEWALS: getattr(settings, 'ADMIN_NOTIFICATIONS_RENEWALS_TOPIC_ID', None),
|
||||
NotificationCategory.TRIALS: getattr(settings, 'ADMIN_NOTIFICATIONS_TRIALS_TOPIC_ID', None),
|
||||
NotificationCategory.BALANCE: getattr(settings, 'ADMIN_NOTIFICATIONS_BALANCE_TOPIC_ID', None),
|
||||
NotificationCategory.ADDONS: getattr(settings, 'ADMIN_NOTIFICATIONS_ADDONS_TOPIC_ID', None),
|
||||
NotificationCategory.INFRASTRUCTURE: getattr(settings, 'ADMIN_NOTIFICATIONS_INFRASTRUCTURE_TOPIC_ID', None),
|
||||
NotificationCategory.ERRORS: getattr(settings, 'ADMIN_NOTIFICATIONS_ERRORS_TOPIC_ID', None),
|
||||
NotificationCategory.PROMO: getattr(settings, 'ADMIN_NOTIFICATIONS_PROMO_TOPIC_ID', None),
|
||||
NotificationCategory.PARTNERS: getattr(settings, 'ADMIN_NOTIFICATIONS_PARTNERS_TOPIC_ID', None),
|
||||
NotificationCategory.TICKETS: self.ticket_topic_id,
|
||||
}
|
||||
|
||||
async def _get_referrer_info(self, db: AsyncSession, referred_by_id: int | None) -> str:
|
||||
if not referred_by_id:
|
||||
return 'Нет'
|
||||
@@ -371,7 +401,7 @@ class AdminNotificationService:
|
||||
message_lines.append('')
|
||||
message_lines.append(f'⏰ <i>{format_local_datetime(datetime.now(UTC), "%d.%m.%Y %H:%M:%S")}</i>')
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.TRIALS)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о триале', error=e)
|
||||
@@ -502,7 +532,15 @@ class AdminNotificationService:
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
# Маршрутизация по категориям (зеркалит логику заголовков выше)
|
||||
if purchase_type == 'renewal' or (
|
||||
not was_trial_conversion and purchase_type is None and user.has_had_paid_subscription
|
||||
):
|
||||
cat = NotificationCategory.RENEWALS
|
||||
else:
|
||||
cat = NotificationCategory.PURCHASES
|
||||
|
||||
return await self._send_message('\n'.join(message_lines), category=cat)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о покупке', error=e)
|
||||
@@ -565,7 +603,7 @@ class AdminNotificationService:
|
||||
else:
|
||||
message = f'{message_prefix}{message_suffix}'
|
||||
|
||||
return await self._send_message(message)
|
||||
return await self._send_message(message, category=NotificationCategory.INFRASTRUCTURE)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления об обновлении', error=e)
|
||||
@@ -586,7 +624,7 @@ class AdminNotificationService:
|
||||
|
||||
⚙️ <i>Система автоматических обновлений • {format_local_datetime(datetime.now(UTC), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
return await self._send_message(message, category=NotificationCategory.ERRORS)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления об ошибке проверки версий', error=e)
|
||||
@@ -824,7 +862,7 @@ class AdminNotificationService:
|
||||
return False
|
||||
|
||||
try:
|
||||
return await self._send_message(message)
|
||||
return await self._send_message(message, category=NotificationCategory.BALANCE)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о пополнении', error=e, exc_info=True)
|
||||
return False
|
||||
@@ -901,7 +939,7 @@ class AdminNotificationService:
|
||||
|
||||
⏰ <i>{format_local_datetime(datetime.now(UTC), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
return await self._send_message(message, category=NotificationCategory.RENEWALS)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о продлении', error=e)
|
||||
@@ -1008,7 +1046,7 @@ class AdminNotificationService:
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PROMO)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления об активации промокода', error=e)
|
||||
@@ -1097,7 +1135,7 @@ class AdminNotificationService:
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PROMO)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о переходе по кампании', error=e)
|
||||
@@ -1187,14 +1225,30 @@ class AdminNotificationService:
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PROMO)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о смене промогруппы', error=e)
|
||||
return False
|
||||
|
||||
def _resolve_topic_id(self, category: NotificationCategory | None = None) -> int | None:
|
||||
"""Определяет topic_id для сообщения.
|
||||
|
||||
Если указана category и для неё настроен топик — возвращает его.
|
||||
Иначе — fallback на self.topic_id (общий топик).
|
||||
"""
|
||||
if category:
|
||||
topic = self.category_topics.get(category)
|
||||
if topic is not None:
|
||||
return topic
|
||||
return self.topic_id
|
||||
|
||||
async def _send_message(
|
||||
self, text: str, reply_markup: types.InlineKeyboardMarkup | None = None, *, ticket_event: bool = False
|
||||
self,
|
||||
text: str,
|
||||
reply_markup: types.InlineKeyboardMarkup | None = None,
|
||||
*,
|
||||
category: NotificationCategory | None = None,
|
||||
) -> bool:
|
||||
if not self.chat_id:
|
||||
logger.warning('ADMIN_NOTIFICATIONS_CHAT_ID не настроен')
|
||||
@@ -1208,19 +1262,14 @@ class AdminNotificationService:
|
||||
'disable_web_page_preview': True,
|
||||
}
|
||||
|
||||
# route to ticket-specific topic if provided
|
||||
thread_id = None
|
||||
if ticket_event and self.ticket_topic_id:
|
||||
thread_id = self.ticket_topic_id
|
||||
elif self.topic_id:
|
||||
thread_id = self.topic_id
|
||||
thread_id = self._resolve_topic_id(category)
|
||||
if thread_id:
|
||||
message_kwargs['message_thread_id'] = thread_id
|
||||
if reply_markup is not None:
|
||||
message_kwargs['reply_markup'] = reply_markup
|
||||
|
||||
await self.bot.send_message(**message_kwargs)
|
||||
logger.info('Уведомление отправлено в чат', chat_id=self.chat_id)
|
||||
logger.info('Уведомление отправлено в чат', chat_id=self.chat_id, category=category)
|
||||
return True
|
||||
|
||||
except TelegramForbiddenError:
|
||||
@@ -1241,11 +1290,17 @@ class AdminNotificationService:
|
||||
"""Public check for whether admin notifications are configured and active."""
|
||||
return self._is_enabled()
|
||||
|
||||
async def send_admin_notification(self, text: str, reply_markup: types.InlineKeyboardMarkup | None = None) -> bool:
|
||||
async def send_admin_notification(
|
||||
self,
|
||||
text: str,
|
||||
reply_markup: types.InlineKeyboardMarkup | None = None,
|
||||
*,
|
||||
category: NotificationCategory | None = None,
|
||||
) -> bool:
|
||||
"""Send a generic notification to admin chat with optional inline keyboard."""
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
return await self._send_message(text, reply_markup=reply_markup)
|
||||
return await self._send_message(text, reply_markup=reply_markup, category=category)
|
||||
|
||||
async def send_guest_purchase_notification(
|
||||
self,
|
||||
@@ -1316,7 +1371,7 @@ class AdminNotificationService:
|
||||
|
||||
message_lines.append(f'<i>{format_local_datetime(datetime.now(UTC), "%d.%m.%Y %H:%M")}</i>')
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PURCHASES)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о гостевой покупке', error=e)
|
||||
@@ -1330,7 +1385,7 @@ class AdminNotificationService:
|
||||
"""
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
return await self._send_message(text)
|
||||
return await self._send_message(text, category=NotificationCategory.INFRASTRUCTURE)
|
||||
|
||||
def _get_payment_method_display(self, payment_method: str | None) -> str:
|
||||
if not payment_method:
|
||||
@@ -1516,7 +1571,7 @@ class AdminNotificationService:
|
||||
|
||||
message = '\n'.join(message_parts)
|
||||
|
||||
return await self._send_message(message)
|
||||
return await self._send_message(message, category=NotificationCategory.INFRASTRUCTURE)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о техработах', error=e)
|
||||
@@ -1601,7 +1656,7 @@ class AdminNotificationService:
|
||||
|
||||
message = '\n'.join(message_parts)
|
||||
|
||||
return await self._send_message(message)
|
||||
return await self._send_message(message, category=NotificationCategory.INFRASTRUCTURE)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о статусе панели Remnawave', error=e)
|
||||
@@ -1694,7 +1749,7 @@ class AdminNotificationService:
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.ADDONS)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления об изменении подписки', error=e)
|
||||
@@ -1778,7 +1833,7 @@ class AdminNotificationService:
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PARTNERS)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о заявке на партнёрку', error=e)
|
||||
@@ -1829,7 +1884,7 @@ class AdminNotificationService:
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PARTNERS)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о запросе на вывод', error=e)
|
||||
@@ -1873,7 +1928,7 @@ class AdminNotificationService:
|
||||
)
|
||||
|
||||
message = '\n'.join(message_lines)
|
||||
return await self._send_message(message)
|
||||
return await self._send_message(message, category=NotificationCategory.PARTNERS)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о массовой блокировке', error=e)
|
||||
@@ -1910,7 +1965,7 @@ class AdminNotificationService:
|
||||
if media_file_id and media_type == 'photo':
|
||||
return await self._send_ticket_photo_notification(text, media_file_id, keyboard)
|
||||
|
||||
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
|
||||
return await self._send_message(text, reply_markup=keyboard, category=NotificationCategory.TICKETS)
|
||||
|
||||
async def _send_ticket_photo_notification(
|
||||
self,
|
||||
@@ -1925,7 +1980,7 @@ class AdminNotificationService:
|
||||
if not self.chat_id:
|
||||
return False
|
||||
|
||||
thread_id = self.ticket_topic_id or self.topic_id
|
||||
thread_id = self._resolve_topic_id(category=NotificationCategory.TICKETS)
|
||||
|
||||
try:
|
||||
if not caption_exceeds_telegram_limit(text):
|
||||
@@ -1943,7 +1998,7 @@ class AdminNotificationService:
|
||||
await self.bot.send_photo(**photo_kwargs)
|
||||
else:
|
||||
# Текст отдельно, фото следом в тот же топик
|
||||
await self._send_message(text, reply_markup=keyboard, ticket_event=True)
|
||||
await self._send_message(text, reply_markup=keyboard, category=NotificationCategory.TICKETS)
|
||||
photo_kwargs = {
|
||||
'chat_id': self.chat_id,
|
||||
'photo': photo_file_id,
|
||||
@@ -1956,7 +2011,7 @@ class AdminNotificationService:
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки фото-уведомления тикета', error=e)
|
||||
# Fallback: отправляем хотя бы текст
|
||||
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
|
||||
return await self._send_message(text, reply_markup=keyboard, category=NotificationCategory.TICKETS)
|
||||
|
||||
async def send_suspicious_traffic_notification(self, message: str, bot: Bot, topic_id: int | None = None) -> bool:
|
||||
"""
|
||||
|
||||
@@ -1105,9 +1105,20 @@ class BackupService:
|
||||
existing = existing_user.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
for key, value in processed_data.items():
|
||||
if key != 'id':
|
||||
setattr(existing, key, value)
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
for key, value in processed_data.items():
|
||||
if key != 'id':
|
||||
setattr(existing, key, value)
|
||||
await db.flush()
|
||||
except IntegrityError:
|
||||
db.expire(existing)
|
||||
logger.warning(
|
||||
'Конфликт уникального ключа при обновлении пользователя, пропускаем',
|
||||
user_id=processed_data.get('id'),
|
||||
telegram_id=processed_data.get('telegram_id'),
|
||||
)
|
||||
continue
|
||||
else:
|
||||
instance = User(**processed_data)
|
||||
try:
|
||||
@@ -1376,9 +1387,20 @@ class BackupService:
|
||||
existing = existing_record.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
for key, value in processed_data.items():
|
||||
if key not in pk_cols:
|
||||
setattr(existing, key, value)
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
for key, value in processed_data.items():
|
||||
if key not in pk_cols:
|
||||
setattr(existing, key, value)
|
||||
await db.flush()
|
||||
except IntegrityError:
|
||||
db.expire(existing)
|
||||
logger.warning(
|
||||
'Конфликт уникального ключа при обновлении записи, пропускаем',
|
||||
table_name=table_name,
|
||||
pk={col: processed_data.get(col) for col in pk_cols},
|
||||
)
|
||||
continue
|
||||
else:
|
||||
instance = model(**processed_data)
|
||||
try:
|
||||
@@ -1788,10 +1810,12 @@ class BackupService:
|
||||
notification_text += f'\n\n⏰ <i>{datetime.now(UTC).strftime("%d.%m.%Y %H:%M:%S")}</i>'
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.admin_notification_service import AdminNotificationService, NotificationCategory
|
||||
|
||||
admin_service = AdminNotificationService(self.bot)
|
||||
await admin_service._send_message(notification_text)
|
||||
await admin_service.send_admin_notification(
|
||||
notification_text, category=NotificationCategory.INFRASTRUCTURE
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления через AdminNotificationService', error=e)
|
||||
|
||||
|
||||
@@ -274,8 +274,9 @@ class ChannelSubscriptionService:
|
||||
)
|
||||
return False # Fail-closed -- bot cannot verify membership
|
||||
except TelegramBadRequest as e:
|
||||
if 'user not found' in str(e).lower():
|
||||
return False # User never interacted with bot in that context
|
||||
err_msg = str(e).lower()
|
||||
if 'user not found' in err_msg or 'participant_id_invalid' in err_msg:
|
||||
return False # User never interacted with bot/channel
|
||||
logger.error('Bad request checking channel', channel_id=channel_id, error=str(e))
|
||||
return False # Fail-closed
|
||||
except TelegramNetworkError:
|
||||
|
||||
@@ -123,11 +123,25 @@ class DailySubscriptionService:
|
||||
logger.warning('Тариф не найден для подписки', subscription_id=subscription.id)
|
||||
return 'error'
|
||||
|
||||
daily_price = tariff.daily_price_kopeks
|
||||
if daily_price <= 0:
|
||||
raw_daily_price = tariff.daily_price_kopeks
|
||||
if raw_daily_price <= 0:
|
||||
logger.warning('Некорректная суточная цена для тарифа', tariff_id=tariff.id)
|
||||
return 'error'
|
||||
|
||||
# Lock user row to prevent TOCTOU between discount read and balance charge
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Apply group discount to daily price (consistent with PricingEngine._calculate_switch_to_daily)
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
promo_group = PricingEngine.resolve_promo_group(user)
|
||||
daily_group_pct = promo_group.get_discount_percent('period', 1) if promo_group else 0
|
||||
daily_price = (
|
||||
PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price
|
||||
)
|
||||
|
||||
# Проверяем баланс
|
||||
if user.balance_kopeks < daily_price:
|
||||
# Недостаточно средств - приостанавливаем подписку
|
||||
|
||||
@@ -118,7 +118,9 @@ async def validate_and_calculate(
|
||||
overrides = landing.discount_overrides or {}
|
||||
tariff_override = overrides.get(str(tariff_id))
|
||||
effective_discount = tariff_override if tariff_override is not None else landing.discount_percent
|
||||
price_kopeks = max(1, price_kopeks - (price_kopeks * effective_discount // 100))
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
price_kopeks = max(1, PricingEngine.apply_discount(price_kopeks, effective_discount))
|
||||
|
||||
return tariff, price_kopeks
|
||||
|
||||
@@ -283,6 +285,13 @@ async def fulfill_purchase(
|
||||
)
|
||||
return purchase
|
||||
|
||||
squads = list(tariff.allowed_squads or [])
|
||||
if not squads:
|
||||
from app.database.crud.server_squad import get_all_server_squads
|
||||
|
||||
all_servers, _ = await get_all_server_squads(db, available_only=True)
|
||||
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
|
||||
|
||||
if existing_subscription is not None:
|
||||
# Expired/inactive subscription — replace it
|
||||
existing_subscription.tariff_id = tariff.id
|
||||
@@ -292,7 +301,7 @@ async def fulfill_purchase(
|
||||
duration_days=purchase.period_days,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
connected_squads=tariff.allowed_squads or [],
|
||||
connected_squads=squads,
|
||||
is_trial=False,
|
||||
update_server_counters=True,
|
||||
)
|
||||
@@ -304,7 +313,7 @@ async def fulfill_purchase(
|
||||
duration_days=purchase.period_days,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
connected_squads=tariff.allowed_squads or [],
|
||||
connected_squads=squads,
|
||||
tariff_id=tariff.id,
|
||||
update_server_counters=True,
|
||||
)
|
||||
@@ -888,6 +897,13 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
|
||||
existing_subscription = await get_subscription_by_user_id(db, user.id)
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
squads = list(tariff.allowed_squads or [])
|
||||
if not squads:
|
||||
from app.database.crud.server_squad import get_all_server_squads
|
||||
|
||||
all_servers, _ = await get_all_server_squads(db, available_only=True)
|
||||
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
|
||||
|
||||
if existing_subscription is not None:
|
||||
subscription = await replace_subscription(
|
||||
db,
|
||||
@@ -895,7 +911,7 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
|
||||
duration_days=purchase.period_days,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
connected_squads=tariff.allowed_squads or [],
|
||||
connected_squads=squads,
|
||||
is_trial=False,
|
||||
update_server_counters=True,
|
||||
commit=False,
|
||||
@@ -908,7 +924,7 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
|
||||
duration_days=purchase.period_days,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
connected_squads=tariff.allowed_squads or [],
|
||||
connected_squads=squads,
|
||||
tariff_id=tariff.id,
|
||||
update_server_counters=True,
|
||||
commit=False,
|
||||
|
||||
@@ -14,6 +14,12 @@ from app.config import settings
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Sub-method to payment_system_id mapping
|
||||
KASSA_AI_SUB_METHODS = {
|
||||
'kassa_ai_sbp': {'payment_system_id': 44},
|
||||
'kassa_ai_card': {'payment_system_id': 36},
|
||||
}
|
||||
|
||||
# Кэш для публичного IP
|
||||
_cached_public_ip: str | None = None
|
||||
_ip_fetch_lock = asyncio.Lock()
|
||||
|
||||
@@ -65,11 +65,11 @@ class MaintenanceService:
|
||||
return False
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.admin_notification_service import AdminNotificationService, NotificationCategory
|
||||
|
||||
notification_service = AdminNotificationService(self._bot)
|
||||
|
||||
if not notification_service._is_enabled():
|
||||
if not notification_service.is_enabled:
|
||||
logger.debug('Уведомления администраторов отключены')
|
||||
return False
|
||||
|
||||
@@ -79,7 +79,9 @@ class MaintenanceService:
|
||||
timestamp = format_local_datetime(datetime.now(UTC), '%d.%m.%Y %H:%M:%S %Z')
|
||||
formatted_message = f'{emoji} <b>ТЕХНИЧЕСКИЕ РАБОТЫ</b>\n\n{message}\n\n⏰ <i>{timestamp}</i>'
|
||||
|
||||
return await notification_service._send_message(formatted_message)
|
||||
return await notification_service.send_admin_notification(
|
||||
formatted_message, category=NotificationCategory.INFRASTRUCTURE
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления через AdminNotificationService', error=e)
|
||||
|
||||
@@ -223,20 +223,20 @@ class MonitoringService:
|
||||
|
||||
# ВАЖНО: autopay ПЕРЕД check_expired — иначе подписки с автоплатой
|
||||
# экспайрятся до того, как autopay успеет их продлить
|
||||
if settings.ENABLE_AUTOPAY:
|
||||
await self._process_autopayments(db)
|
||||
# Рекуррентные автоплатежи: пополнение баланса с сохранённой карты
|
||||
if settings.YOOKASSA_RECURRENT_ENABLED:
|
||||
try:
|
||||
from app.services.recurrent_payment_service import process_recurrent_payments
|
||||
# Продление с баланса работает всегда, если у подписки autopay_enabled=True
|
||||
await self._process_autopayments(db)
|
||||
# Рекуррентные автоплатежи с карты: требуют ENABLE_AUTOPAY + YOOKASSA_RECURRENT_ENABLED
|
||||
if settings.ENABLE_AUTOPAY and settings.YOOKASSA_RECURRENT_ENABLED:
|
||||
try:
|
||||
from app.services.recurrent_payment_service import process_recurrent_payments
|
||||
|
||||
await process_recurrent_payments(db=db, bot=self.bot)
|
||||
except Exception as recurrent_error:
|
||||
logger.error(
|
||||
'Ошибка рекуррентных автоплатежей',
|
||||
error=recurrent_error,
|
||||
exc_info=True,
|
||||
)
|
||||
await process_recurrent_payments(db=db, bot=self.bot)
|
||||
except Exception as recurrent_error:
|
||||
logger.error(
|
||||
'Ошибка рекуррентных автоплатежей',
|
||||
error=recurrent_error,
|
||||
exc_info=True,
|
||||
)
|
||||
await self._check_expired_subscriptions(db)
|
||||
await self._check_expiring_subscriptions(db)
|
||||
await self._check_trial_expiring_soon(db)
|
||||
@@ -384,17 +384,16 @@ class MonitoringService:
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name, username=user.username, telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
)
|
||||
|
||||
# Не пересылаем activeInternalSquads в рутинном sync — сквады уже назначены
|
||||
# при создании подписки, пересылка стейловых UUID вызывает FK violation → A039
|
||||
|
||||
if hwid_limit is not None:
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
# Внешний сквад: синхронизируем из тарифа или сбрасываем
|
||||
if subscription.tariff and subscription.tariff.external_squad_uuid:
|
||||
update_kwargs['external_squad_uuid'] = subscription.tariff.external_squad_uuid
|
||||
else:
|
||||
update_kwargs['external_squad_uuid'] = None
|
||||
# Внешний сквад НЕ пересылаем в рутинном sync — стейловый UUID
|
||||
# вызывает FK violation → A039. Назначается при создании подписки.
|
||||
|
||||
updated_user = await api.update_user(**update_kwargs)
|
||||
|
||||
@@ -1024,7 +1023,7 @@ class MonitoringService:
|
||||
continue
|
||||
|
||||
days_before_expiry = (sub.end_date - current_time).days
|
||||
if days_before_expiry <= min(sub.autopay_days_before, 3):
|
||||
if days_before_expiry <= min(sub.autopay_days_before or 3, 3):
|
||||
autopay_subscriptions.append(sub)
|
||||
|
||||
processed_count = 0
|
||||
@@ -1053,8 +1052,11 @@ class MonitoringService:
|
||||
autopay_period = 30
|
||||
|
||||
try:
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
pricing = await pricing_engine.calculate_renewal_price(
|
||||
db,
|
||||
subscription,
|
||||
@@ -1301,43 +1303,39 @@ class MonitoringService:
|
||||
texts = get_texts(user.language)
|
||||
days_text = format_days_declension(days, user.language)
|
||||
|
||||
if settings.ENABLE_AUTOPAY:
|
||||
if subscription.autopay_enabled and has_saved_card:
|
||||
autopay_status = texts.t(
|
||||
'AUTOPAY_STATUS_CARD_ACTIVE',
|
||||
'✅ Включен — будет автоматическое списание с карты',
|
||||
)
|
||||
action_text = texts.t(
|
||||
'AUTOPAY_ACTION_CHECK_BALANCE',
|
||||
'💰 Убедитесь, что на балансе достаточно средств: {balance}',
|
||||
).format(balance=texts.format_price(user.balance_kopeks))
|
||||
elif subscription.autopay_enabled:
|
||||
autopay_status = texts.t(
|
||||
'AUTOPAY_STATUS_NO_CARD',
|
||||
'✅ Включен — подписка продлится автоматически',
|
||||
)
|
||||
action_text = texts.t(
|
||||
'AUTOPAY_ACTION_CHECK_BALANCE',
|
||||
'💰 Убедитесь, что на балансе достаточно средств: {balance}',
|
||||
).format(balance=texts.format_price(user.balance_kopeks))
|
||||
else:
|
||||
autopay_status = texts.t(
|
||||
'AUTOPAY_STATUS_OFF',
|
||||
'❌ Отключен — не забудьте продлить вручную!',
|
||||
)
|
||||
action_text = texts.t(
|
||||
'AUTOPAY_ACTION_ENABLE',
|
||||
'💡 Включите автоплатеж или продлите подписку вручную',
|
||||
)
|
||||
if subscription.autopay_enabled and has_saved_card:
|
||||
autopay_status = texts.t(
|
||||
'AUTOPAY_STATUS_CARD_ACTIVE',
|
||||
'✅ Включен — будет автоматическое списание с карты',
|
||||
)
|
||||
action_text = texts.t(
|
||||
'AUTOPAY_ACTION_CHECK_BALANCE',
|
||||
'💰 Убедитесь, что на балансе достаточно средств: {balance}',
|
||||
).format(balance=texts.format_price(user.balance_kopeks))
|
||||
elif subscription.autopay_enabled:
|
||||
autopay_status = texts.t(
|
||||
'AUTOPAY_STATUS_NO_CARD',
|
||||
'✅ Включен — подписка продлится автоматически',
|
||||
)
|
||||
action_text = texts.t(
|
||||
'AUTOPAY_ACTION_CHECK_BALANCE',
|
||||
'💰 Убедитесь, что на балансе достаточно средств: {balance}',
|
||||
).format(balance=texts.format_price(user.balance_kopeks))
|
||||
else:
|
||||
autopay_status = texts.t(
|
||||
'AUTOPAY_STATUS_OFF',
|
||||
'❌ Отключен — не забудьте продлить вручную!',
|
||||
)
|
||||
action_text = texts.t(
|
||||
'AUTOPAY_ACTION_RENEW',
|
||||
'💡 Продлите подписку вручную',
|
||||
)
|
||||
if settings.ENABLE_AUTOPAY:
|
||||
action_text = texts.t(
|
||||
'AUTOPAY_ACTION_ENABLE',
|
||||
'💡 Включите автоплатеж или продлите подписку вручную',
|
||||
)
|
||||
else:
|
||||
action_text = texts.t(
|
||||
'AUTOPAY_ACTION_RENEW',
|
||||
'💡 Продлите подписку вручную',
|
||||
)
|
||||
|
||||
end_date = format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')
|
||||
message = texts.t(
|
||||
|
||||
@@ -939,8 +939,7 @@ class PartnerStatsService:
|
||||
registrations_dict = {str(row.date): int(row.count) for row in registrations_by_day.all()}
|
||||
|
||||
# --- Daily revenue (DAILY_STATS_DAYS days) ---
|
||||
# Revenue = real deposits (positive) + abs(subscription_payments) (stored negative)
|
||||
# Exclude promo/bonus deposits (payment_method IS NULL) from revenue
|
||||
# Revenue = real deposits only (exclude bonus/promo balance spending on subscriptions)
|
||||
revenue_amount_expr = func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
@@ -951,10 +950,6 @@ class PartnerStatsService:
|
||||
),
|
||||
Transaction.amount_kopeks,
|
||||
),
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
func.abs(Transaction.amount_kopeks),
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
@@ -971,12 +966,8 @@ class PartnerStatsService:
|
||||
Transaction.user_id.in_(campaign_user_ids_sq),
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.created_at >= start_date,
|
||||
Transaction.type.in_(
|
||||
[
|
||||
TransactionType.DEPOSIT.value,
|
||||
TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
]
|
||||
),
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
)
|
||||
)
|
||||
.group_by(func.date(Transaction.created_at))
|
||||
@@ -1027,12 +1018,8 @@ class PartnerStatsService:
|
||||
Transaction.user_id.in_(campaign_user_ids_sq),
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.created_at >= week_ago,
|
||||
Transaction.type.in_(
|
||||
[
|
||||
TransactionType.DEPOSIT.value,
|
||||
TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
]
|
||||
),
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -1046,12 +1033,8 @@ class PartnerStatsService:
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.created_at >= previous_start,
|
||||
Transaction.created_at < week_ago,
|
||||
Transaction.type.in_(
|
||||
[
|
||||
TransactionType.DEPOSIT.value,
|
||||
TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
]
|
||||
),
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ from .mulenpay import MulenPayPaymentMixin
|
||||
from .pal24 import Pal24PaymentMixin
|
||||
from .platega import PlategaPaymentMixin
|
||||
from .riopay import RioPayPaymentMixin
|
||||
from .severpay import SeverPayPaymentMixin
|
||||
from .stars import TelegramStarsMixin
|
||||
from .tribute import TributePaymentMixin
|
||||
from .wata import WataPaymentMixin
|
||||
@@ -31,6 +32,7 @@ __all__ = [
|
||||
'PaymentCommonMixin',
|
||||
'PlategaPaymentMixin',
|
||||
'RioPayPaymentMixin',
|
||||
'SeverPayPaymentMixin',
|
||||
'TelegramStarsMixin',
|
||||
'TributePaymentMixin',
|
||||
'WataPaymentMixin',
|
||||
|
||||
@@ -347,7 +347,7 @@ class CloudPaymentsPaymentMixin:
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения CloudPayments', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
+106
-99
@@ -325,7 +325,111 @@ async def send_cart_notification_after_topup(
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Try to auto-extend expired subscription (works without cart)
|
||||
cart_data = await user_cart_service.get_user_cart(user.id)
|
||||
# В приоритете всегда сохраненная корзина: она отражает явный выбор пользователя
|
||||
# (период/тариф/сумма). Автопродление expired — только когда корзины нет.
|
||||
if cart_data:
|
||||
cart_total = cart_data.get('total_price', 0)
|
||||
if not cart_total:
|
||||
logger.warning(
|
||||
'Сохраненная корзина найдена, но total_price отсутствует или некорректен',
|
||||
user_id=user.id,
|
||||
cart_total=cart_total,
|
||||
)
|
||||
return False
|
||||
|
||||
# Try auto-purchase first
|
||||
auto_purchase_success = False
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(db, user, bot=bot)
|
||||
except Exception as auto_error:
|
||||
logger.error(
|
||||
'Ошибка автоматической покупки подписки для пользователя',
|
||||
user_id=user.id,
|
||||
auto_error=auto_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if auto_purchase_success:
|
||||
return False
|
||||
|
||||
if not bot or not getattr(user, 'telegram_id', None):
|
||||
return False
|
||||
|
||||
# Refresh balance from DB to account for any changes during auto-purchase attempt
|
||||
refreshed_user = await get_user_by_id(db, user.id)
|
||||
balance = getattr(refreshed_user or user, 'balance_kopeks', 0)
|
||||
|
||||
texts = get_texts(getattr(user, 'language', 'ru'))
|
||||
|
||||
# Build message based on whether balance is sufficient
|
||||
fmt = settings.format_price
|
||||
cart_total_formatted = fmt(cart_total)
|
||||
if balance >= cart_total:
|
||||
template = texts.get('BALANCE_TOPPED_UP_CART_SUFFICIENT', '')
|
||||
message_text = template.format(
|
||||
amount=fmt(amount_kopeks),
|
||||
balance=fmt(balance),
|
||||
cart_total=cart_total_formatted,
|
||||
total_amount=cart_total_formatted,
|
||||
)
|
||||
else:
|
||||
missing = cart_total - balance
|
||||
template = texts.get('BALANCE_TOPPED_UP_CART_INSUFFICIENT', '')
|
||||
message_text = template.format(
|
||||
amount=fmt(amount_kopeks),
|
||||
balance=fmt(balance),
|
||||
cart_total=cart_total_formatted,
|
||||
total_amount=cart_total_formatted,
|
||||
missing=fmt(missing),
|
||||
)
|
||||
|
||||
if not message_text:
|
||||
logger.warning('Missing cart notification template', language=getattr(user, 'language', 'ru'))
|
||||
return False
|
||||
|
||||
sent = False
|
||||
try:
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.get('RETURN_TO_SUBSCRIPTION_CHECKOUT', '⬅️ Checkout'),
|
||||
callback_data='return_to_saved_cart',
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.get('MY_BALANCE_BUTTON', '💰 Balance'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.get('MAIN_MENU_BUTTON', '🏠 Menu'),
|
||||
callback_data='back_to_menu',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
sent = True
|
||||
logger.info('Sent cart notification to user', user_id=user.id)
|
||||
except Exception as send_error:
|
||||
logger.error(
|
||||
'Failed to send cart notification to user',
|
||||
user_id=user.id,
|
||||
error=send_error,
|
||||
)
|
||||
|
||||
return sent
|
||||
|
||||
# Try to auto-extend expired subscription only when there is no saved cart.
|
||||
try:
|
||||
auto_extended = await try_auto_extend_expired_after_topup(db, user, bot=bot)
|
||||
if auto_extended:
|
||||
@@ -338,104 +442,7 @@ async def send_cart_notification_after_topup(
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
cart_data = await user_cart_service.get_user_cart(user.id)
|
||||
if not cart_data:
|
||||
return False
|
||||
|
||||
cart_total = cart_data.get('total_price', 0)
|
||||
if not cart_total:
|
||||
return False
|
||||
|
||||
# Try auto-purchase first
|
||||
auto_purchase_success = False
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(db, user, bot=bot)
|
||||
except Exception as auto_error:
|
||||
logger.error(
|
||||
'Ошибка автоматической покупки подписки для пользователя',
|
||||
user_id=user.id,
|
||||
auto_error=auto_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if auto_purchase_success:
|
||||
return False
|
||||
|
||||
if not bot or not getattr(user, 'telegram_id', None):
|
||||
return False
|
||||
|
||||
# Refresh balance from DB to account for any changes during auto-purchase attempt
|
||||
refreshed_user = await get_user_by_id(db, user.id)
|
||||
balance = getattr(refreshed_user or user, 'balance_kopeks', 0)
|
||||
|
||||
texts = get_texts(getattr(user, 'language', 'ru'))
|
||||
|
||||
# Build message based on whether balance is sufficient
|
||||
fmt = settings.format_price
|
||||
cart_total_formatted = fmt(cart_total)
|
||||
if balance >= cart_total:
|
||||
template = texts.get('BALANCE_TOPPED_UP_CART_SUFFICIENT', '')
|
||||
message_text = template.format(
|
||||
amount=fmt(amount_kopeks),
|
||||
balance=fmt(balance),
|
||||
cart_total=cart_total_formatted,
|
||||
total_amount=cart_total_formatted,
|
||||
)
|
||||
else:
|
||||
missing = cart_total - balance
|
||||
template = texts.get('BALANCE_TOPPED_UP_CART_INSUFFICIENT', '')
|
||||
message_text = template.format(
|
||||
amount=fmt(amount_kopeks),
|
||||
balance=fmt(balance),
|
||||
cart_total=cart_total_formatted,
|
||||
total_amount=cart_total_formatted,
|
||||
missing=fmt(missing),
|
||||
)
|
||||
|
||||
if not message_text:
|
||||
logger.warning('Missing cart notification template', language=getattr(user, 'language', 'ru'))
|
||||
return False
|
||||
|
||||
sent = False
|
||||
try:
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.get('RETURN_TO_SUBSCRIPTION_CHECKOUT', '⬅️ Checkout'),
|
||||
callback_data='return_to_saved_cart',
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.get('MY_BALANCE_BUTTON', '💰 Balance'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.get('MAIN_MENU_BUTTON', '🏠 Menu'),
|
||||
callback_data='back_to_menu',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
sent = True
|
||||
logger.info('Sent cart notification to user', user_id=user.id)
|
||||
except Exception as send_error:
|
||||
logger.error(
|
||||
'Failed to send cart notification to user',
|
||||
user_id=user.id,
|
||||
error=send_error,
|
||||
)
|
||||
|
||||
return sent
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -338,7 +338,7 @@ class CryptoBotPaymentMixin:
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения CryptoBot', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -347,7 +347,7 @@ class FreekassaPaymentMixin:
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения Freekassa', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -391,7 +391,7 @@ class HeleketPaymentMixin:
|
||||
except Exception as error: # pragma: no cover - defensive
|
||||
logger.error('Ошибка реферального начисления Heleket', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
@@ -28,6 +28,7 @@ class KassaAiPaymentMixin:
|
||||
description: str = 'Пополнение баланса',
|
||||
email: str | None = None,
|
||||
language: str = 'ru',
|
||||
payment_system_id: int | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Создает платеж KassaAI.
|
||||
@@ -96,7 +97,9 @@ class KassaAiPaymentMixin:
|
||||
amount=amount_rubles,
|
||||
currency=currency,
|
||||
email=email,
|
||||
payment_system_id=settings.KASSA_AI_PAYMENT_SYSTEM_ID,
|
||||
payment_system_id=payment_system_id
|
||||
if payment_system_id is not None
|
||||
else settings.KASSA_AI_PAYMENT_SYSTEM_ID,
|
||||
)
|
||||
|
||||
payment_url = result.get('location')
|
||||
@@ -118,7 +121,9 @@ class KassaAiPaymentMixin:
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_system_id=settings.KASSA_AI_PAYMENT_SYSTEM_ID,
|
||||
payment_system_id=payment_system_id
|
||||
if payment_system_id is not None
|
||||
else settings.KASSA_AI_PAYMENT_SYSTEM_ID,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
@@ -335,7 +340,7 @@ class KassaAiPaymentMixin:
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения KassaAI', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -322,7 +322,7 @@ class MulenPayPaymentMixin:
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения', display_name=display_name, error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -434,7 +434,7 @@ class Pal24PaymentMixin:
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения Pal24', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -442,7 +442,7 @@ class PlategaPaymentMixin:
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения Platega', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
@@ -42,11 +42,13 @@ class RioPayPaymentMixin:
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
user_id: int | None,
|
||||
amount_kopeks: int,
|
||||
description: str = 'Пополнение баланса',
|
||||
email: str | None = None,
|
||||
language: str = 'ru',
|
||||
success_url: str | None = None,
|
||||
fail_url: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Создает платеж RioPay.
|
||||
@@ -76,13 +78,15 @@ class RioPayPaymentMixin:
|
||||
return None
|
||||
|
||||
# Получаем telegram_id пользователя для order_id
|
||||
user = await get_user_by_id(db, user_id)
|
||||
tg_id = user.telegram_id if user else user_id
|
||||
if user_id is not None:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
tg_id = user.telegram_id if user else user_id
|
||||
else:
|
||||
tg_id = 'guest'
|
||||
|
||||
# Генерируем уникальный order_id с telegram_id для удобного поиска
|
||||
order_id = f'rp{tg_id}_{uuid.uuid4().hex[:6]}'
|
||||
amount_rubles = amount_kopeks / 100
|
||||
currency = settings.RIOPAY_CURRENCY
|
||||
|
||||
# Срок действия платежа (1 час по умолчанию)
|
||||
expires_at = datetime.now(UTC) + timedelta(hours=1)
|
||||
@@ -100,11 +104,9 @@ class RioPayPaymentMixin:
|
||||
# Используем API для создания заказа
|
||||
result = await riopay_service.create_order(
|
||||
amount=amount_rubles,
|
||||
currency=currency,
|
||||
external_id=order_id,
|
||||
purpose=description,
|
||||
success_url=settings.RIOPAY_SUCCESS_URL,
|
||||
fail_url=settings.RIOPAY_FAIL_URL,
|
||||
success_url=success_url or settings.RIOPAY_SUCCESS_URL,
|
||||
)
|
||||
|
||||
payment_url = result.get('paymentLink')
|
||||
@@ -124,7 +126,7 @@ class RioPayPaymentMixin:
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
currency=settings.RIOPAY_CURRENCY,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
riopay_order_id=riopay_order_id,
|
||||
@@ -138,7 +140,7 @@ class RioPayPaymentMixin:
|
||||
order_id=order_id,
|
||||
user_id=user_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
currency=settings.RIOPAY_CURRENCY,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -146,7 +148,7 @@ class RioPayPaymentMixin:
|
||||
'riopay_order_id': riopay_order_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_rubles,
|
||||
'currency': currency,
|
||||
'currency': settings.RIOPAY_CURRENCY,
|
||||
'payment_url': payment_url,
|
||||
'expires_at': expires_at.isoformat(),
|
||||
'local_payment_id': local_payment.id,
|
||||
@@ -275,6 +277,20 @@ class RioPayPaymentMixin:
|
||||
logger.info('RioPay платеж уже привязан к транзакции', order_id=payment.order_id, trigger=trigger)
|
||||
return True
|
||||
|
||||
# --- Guest purchase flow (landing page / gift) ---
|
||||
riopay_metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
from app.services.payment.common import try_fulfill_guest_purchase
|
||||
|
||||
guest_result = await try_fulfill_guest_purchase(
|
||||
db,
|
||||
metadata=riopay_metadata,
|
||||
payment_amount_kopeks=payment.amount_kopeks,
|
||||
provider_payment_id=str(riopay_order_id) if riopay_order_id else payment.order_id,
|
||||
provider_name='riopay',
|
||||
)
|
||||
if guest_result is not None:
|
||||
return True
|
||||
|
||||
# Получаем пользователя
|
||||
user = await get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
@@ -316,7 +332,7 @@ class RioPayPaymentMixin:
|
||||
UserModel.balance_kopeks: UserModel.balance_kopeks + payment.amount_kopeks,
|
||||
UserModel.updated_at: datetime.now(UTC),
|
||||
}
|
||||
if was_first_topup:
|
||||
if was_first_topup and not user.referred_by_id:
|
||||
update_values[UserModel.has_made_first_topup] = True
|
||||
|
||||
await db.execute(update(UserModel).where(UserModel.id == user.id).values(update_values))
|
||||
|
||||
@@ -0,0 +1,603 @@
|
||||
"""Mixin для интеграции с SeverPay (severpay.io)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.severpay_service import severpay_service
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
|
||||
# Маппинг статусов SeverPay -> internal
|
||||
SEVERPAY_STATUS_MAP: dict[str, tuple[str, bool]] = {
|
||||
'new': ('pending', False),
|
||||
'process': ('processing', False),
|
||||
'success': ('success', True),
|
||||
'decline': ('declined', False),
|
||||
'fail': ('failed', False),
|
||||
}
|
||||
|
||||
|
||||
class SeverPayPaymentMixin:
|
||||
"""Mixin для работы с платежами SeverPay."""
|
||||
|
||||
async def create_severpay_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None,
|
||||
amount_kopeks: int,
|
||||
description: str = 'Пополнение баланса',
|
||||
email: str | None = None,
|
||||
language: str = 'ru',
|
||||
return_url: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Создает платеж SeverPay.
|
||||
|
||||
Returns:
|
||||
Словарь с данными платежа или None при ошибке
|
||||
"""
|
||||
if not settings.is_severpay_enabled():
|
||||
logger.error('SeverPay не настроен')
|
||||
return None
|
||||
|
||||
# Валидация лимитов
|
||||
if amount_kopeks < settings.SEVERPAY_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'SeverPay: сумма меньше минимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
SEVERPAY_MIN_AMOUNT_KOPEKS=settings.SEVERPAY_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.SEVERPAY_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'SeverPay: сумма больше максимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
SEVERPAY_MAX_AMOUNT_KOPEKS=settings.SEVERPAY_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
# Получаем telegram_id пользователя для order_id
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
if user_id is not None:
|
||||
user = await payment_module.get_user_by_id(db, user_id)
|
||||
tg_id = user.telegram_id if user else user_id
|
||||
else:
|
||||
tg_id = 'guest'
|
||||
|
||||
# Генерируем уникальный order_id с telegram_id для удобного поиска
|
||||
order_id = f'sp{tg_id}_{uuid.uuid4().hex[:6]}'
|
||||
amount_rubles = amount_kopeks / 100
|
||||
currency = settings.SEVERPAY_CURRENCY
|
||||
|
||||
# Срок действия платежа
|
||||
lifetime = settings.SEVERPAY_LIFETIME
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=lifetime)
|
||||
|
||||
# Метаданные
|
||||
metadata = {
|
||||
'user_id': user_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'description': description,
|
||||
'language': language,
|
||||
'type': 'balance_topup',
|
||||
}
|
||||
|
||||
try:
|
||||
# Используем API для создания платежа
|
||||
result = await severpay_service.create_payment(
|
||||
order_id=order_id,
|
||||
amount=amount_rubles,
|
||||
currency=currency,
|
||||
client_email=email or '',
|
||||
client_id=str(tg_id),
|
||||
url_return=return_url or settings.SEVERPAY_RETURN_URL,
|
||||
lifetime=lifetime,
|
||||
)
|
||||
|
||||
payment_url = result.get('url')
|
||||
severpay_id = str(result.get('id', '')) if result.get('id') else None
|
||||
severpay_uid = result.get('uid')
|
||||
|
||||
if not payment_url:
|
||||
logger.error('SeverPay API не вернул URL платежа', result=result)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
'SeverPay API: создан платеж',
|
||||
order_id=order_id,
|
||||
severpay_id=severpay_id,
|
||||
payment_url=payment_url,
|
||||
)
|
||||
|
||||
# Вычисляем expires_at из ответа API если доступен
|
||||
expire_at_raw = result.get('expire_at')
|
||||
if expire_at_raw:
|
||||
try:
|
||||
expires_at = datetime.fromtimestamp(int(expire_at_raw), tz=UTC)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Сохраняем в БД
|
||||
severpay_crud = import_module('app.database.crud.severpay')
|
||||
local_payment = await severpay_crud.create_severpay_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
severpay_id=severpay_id,
|
||||
severpay_uid=severpay_uid,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'SeverPay: создан платеж',
|
||||
order_id=order_id,
|
||||
user_id=user_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'severpay_id': severpay_id,
|
||||
'severpay_uid': severpay_uid,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_rubles,
|
||||
'currency': currency,
|
||||
'payment_url': payment_url,
|
||||
'expires_at': expires_at.isoformat(),
|
||||
'local_payment_id': local_payment.id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('SeverPay: ошибка создания платежа', error=e)
|
||||
return None
|
||||
|
||||
async def process_severpay_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: dict[str, Any],
|
||||
) -> bool:
|
||||
"""
|
||||
Обрабатывает webhook от SeverPay.
|
||||
|
||||
Подпись проверяется в webserver/payments.py до вызова этого метода.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
payload: JSON тело webhook (sign проверен в webserver, тело передаётся как есть)
|
||||
|
||||
Returns:
|
||||
True если платеж успешно обработан
|
||||
"""
|
||||
try:
|
||||
webhook_type = payload.get('type')
|
||||
if webhook_type != 'payin':
|
||||
logger.info('SeverPay webhook: пропускаем тип', webhook_type=webhook_type)
|
||||
return True
|
||||
|
||||
data = payload.get('data', {})
|
||||
severpay_id = str(data.get('id', '')) if data.get('id') else None
|
||||
order_id = data.get('order_id')
|
||||
severpay_status = data.get('status')
|
||||
amount = data.get('amount')
|
||||
|
||||
if not severpay_id or not severpay_status:
|
||||
logger.warning('SeverPay webhook: отсутствуют обязательные поля', payload=payload)
|
||||
return False
|
||||
|
||||
# Ищем платеж по order_id (наш) или severpay_id
|
||||
severpay_crud = import_module('app.database.crud.severpay')
|
||||
payment = None
|
||||
if order_id:
|
||||
payment = await severpay_crud.get_severpay_payment_by_order_id(db, order_id)
|
||||
if not payment and severpay_id:
|
||||
payment = await severpay_crud.get_severpay_payment_by_severpay_id(db, severpay_id)
|
||||
|
||||
if not payment:
|
||||
logger.warning(
|
||||
'SeverPay webhook: платеж не найден',
|
||||
order_id=order_id,
|
||||
severpay_id=severpay_id,
|
||||
)
|
||||
return False
|
||||
|
||||
# Проверка дублирования
|
||||
if payment.is_paid:
|
||||
logger.info('SeverPay webhook: платеж уже обработан', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Маппинг статуса
|
||||
status_info = SEVERPAY_STATUS_MAP.get(severpay_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
callback_payload = {
|
||||
'severpay_id': severpay_id,
|
||||
'order_id': order_id,
|
||||
'status': severpay_status,
|
||||
'amount': amount,
|
||||
'currency': data.get('currency'),
|
||||
}
|
||||
|
||||
# Проверка суммы ДО обновления статуса
|
||||
if is_paid and amount is not None:
|
||||
received_kopeks = round(float(amount) * 100)
|
||||
if abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
logger.error(
|
||||
'SeverPay amount mismatch',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await severpay_crud.update_severpay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
severpay_id=severpay_id,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
return False
|
||||
|
||||
# Финализируем платеж если оплачен — без промежуточного commit
|
||||
if is_paid:
|
||||
# Inline field assignments to keep FOR UPDATE lock intact
|
||||
payment.status = internal_status
|
||||
payment.is_paid = True
|
||||
payment.severpay_id = severpay_id or payment.severpay_id
|
||||
payment.callback_payload = callback_payload
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
return await self._finalize_severpay_payment(db, payment, severpay_id=severpay_id, trigger='webhook')
|
||||
|
||||
# Для не-success статусов можно безопасно коммитить
|
||||
payment = await severpay_crud.update_severpay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
is_paid=False,
|
||||
severpay_id=severpay_id,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('SeverPay webhook: ошибка обработки', error=e)
|
||||
return False
|
||||
|
||||
async def _finalize_severpay_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
severpay_id: str | None,
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
|
||||
|
||||
Использует FOR UPDATE lock для защиты от race condition.
|
||||
"""
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
severpay_crud = import_module('app.database.crud.severpay')
|
||||
|
||||
# Lock FIRST, then read fresh state
|
||||
locked = await severpay_crud.get_severpay_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('SeverPay: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'SeverPay платеж уже связан с транзакцией',
|
||||
order_id=payment.order_id,
|
||||
transaction_id=payment.transaction_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
return True
|
||||
|
||||
# Read fresh metadata AFTER lock to avoid stale data
|
||||
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
|
||||
# --- Guest purchase flow ---
|
||||
from app.services.payment.common import try_fulfill_guest_purchase
|
||||
|
||||
guest_result = await try_fulfill_guest_purchase(
|
||||
db,
|
||||
metadata=metadata,
|
||||
payment_amount_kopeks=payment.amount_kopeks,
|
||||
provider_payment_id=str(severpay_id) if severpay_id else payment.order_id,
|
||||
provider_name='severpay',
|
||||
)
|
||||
if guest_result is not None:
|
||||
return True
|
||||
|
||||
# Inline field assignments to keep FOR UPDATE lock
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
balance_already_credited = bool(metadata.get('balance_credited'))
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error('Пользователь не найден для SeverPay', user_id=payment.user_id)
|
||||
return False
|
||||
|
||||
# Загружаем промогруппы в асинхронном контексте
|
||||
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
|
||||
for user_promo_group in getattr(user, 'user_promo_groups', []):
|
||||
await db.refresh(user_promo_group, attribute_names=['promo_group'])
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
|
||||
transaction_external_id = str(severpay_id) if severpay_id else payment.order_id
|
||||
|
||||
# Проверяем дупликат транзакции
|
||||
existing_transaction = None
|
||||
if transaction_external_id:
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id(
|
||||
db,
|
||||
transaction_external_id,
|
||||
PaymentMethod.SEVERPAY,
|
||||
)
|
||||
|
||||
display_name = settings.get_severpay_display_name()
|
||||
description = f'Пополнение через {display_name}'
|
||||
|
||||
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.SEVERPAY,
|
||||
external_id=transaction_external_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
commit=False,
|
||||
)
|
||||
created_transaction = True
|
||||
|
||||
await severpay_crud.link_severpay_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('SeverPay платеж уже зачислил баланс ранее', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Lock user row to prevent concurrent balance race conditions
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
# Emit deferred side-effects after atomic commit
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
transaction,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
payment_method=PaymentMethod.SEVERPAY,
|
||||
external_id=transaction_external_id,
|
||||
)
|
||||
|
||||
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('Ошибка обработки реферального пополнения SeverPay', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
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('Ошибка отправки админ уведомления SeverPay', error=error)
|
||||
|
||||
if getattr(self, 'bot', None) and user.telegram_id:
|
||||
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'💳 Способ: {display_name}\n'
|
||||
f'🆔 Транзакция: {transaction.id}\n\n'
|
||||
'Баланс пополнен автоматически!'
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки уведомления пользователю SeverPay', error=error)
|
||||
|
||||
try:
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка при работе с сохраненной корзиной для пользователя',
|
||||
user_id=payment.user_id,
|
||||
error=error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
metadata['balance_change'] = {
|
||||
'old_balance': old_balance,
|
||||
'new_balance': user.balance_kopeks,
|
||||
'credited_at': datetime.now(UTC).isoformat(),
|
||||
}
|
||||
metadata['balance_credited'] = True
|
||||
payment.metadata_json = metadata
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Обработан SeverPay платеж',
|
||||
order_id=payment.order_id,
|
||||
user_id=payment.user_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def check_severpay_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
order_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Проверяет статус платежа через API."""
|
||||
try:
|
||||
severpay_crud = import_module('app.database.crud.severpay')
|
||||
payment = await severpay_crud.get_severpay_payment_by_order_id(db, order_id)
|
||||
if not payment:
|
||||
logger.warning('SeverPay payment not found', order_id=order_id)
|
||||
return None
|
||||
|
||||
if payment.is_paid:
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'success',
|
||||
'is_paid': True,
|
||||
}
|
||||
|
||||
# Проверяем через API по severpay_id
|
||||
if payment.severpay_id:
|
||||
try:
|
||||
order_data = await severpay_service.get_payment(payment.severpay_id)
|
||||
severpay_status = order_data.get('status')
|
||||
|
||||
if severpay_status:
|
||||
status_info = SEVERPAY_STATUS_MAP.get(severpay_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
if is_paid:
|
||||
# Проверка суммы
|
||||
api_amount = order_data.get('amount')
|
||||
if api_amount is not None:
|
||||
received_kopeks = round(float(api_amount) * 100)
|
||||
if abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
logger.error(
|
||||
'SeverPay amount mismatch (API check)',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await severpay_crud.update_severpay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
severpay_id=payment.severpay_id,
|
||||
callback_payload={
|
||||
'check_source': 'api',
|
||||
'severpay_order_data': order_data,
|
||||
},
|
||||
)
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'amount_mismatch',
|
||||
'is_paid': False,
|
||||
}
|
||||
|
||||
logger.info('SeverPay payment confirmed via API', order_id=payment.order_id)
|
||||
|
||||
callback_payload = {
|
||||
'check_source': 'api',
|
||||
'severpay_order_data': order_data,
|
||||
}
|
||||
|
||||
payment = await severpay_crud.update_severpay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='success',
|
||||
is_paid=True,
|
||||
severpay_id=payment.severpay_id,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
|
||||
await self._finalize_severpay_payment(
|
||||
db,
|
||||
payment,
|
||||
severpay_id=payment.severpay_id,
|
||||
trigger='api_check',
|
||||
)
|
||||
elif internal_status != payment.status:
|
||||
# Обновляем статус если изменился
|
||||
payment = await severpay_crud.update_severpay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error checking SeverPay payment status via API', error=e)
|
||||
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': payment.status or 'pending',
|
||||
'is_paid': payment.is_paid,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('SeverPay: ошибка проверки статуса', error=e)
|
||||
return None
|
||||
@@ -260,6 +260,14 @@ class TelegramStarsMixin:
|
||||
logger.error('Не удалось активировать pending подписку пользователя', user_id=user.id)
|
||||
return False
|
||||
|
||||
# Consume promo-offer discount (invoice was created with discounted price)
|
||||
try:
|
||||
from app.utils.promo_offer import consume_user_promo_offer
|
||||
|
||||
await consume_user_promo_offer(db, user.id)
|
||||
except Exception as promo_error:
|
||||
logger.warning('Ошибка потребления промо-оффера при Stars оплате', user_id=user.id, error=promo_error)
|
||||
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
@@ -434,7 +442,7 @@ class TelegramStarsMixin:
|
||||
"❌ Описание '' не подходит для реферальной логики", description_for_referral=description_for_referral
|
||||
)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -529,7 +529,7 @@ class WataPaymentMixin:
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения WATA', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
@@ -819,7 +819,7 @@ class YooKassaPaymentMixin:
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения YooKassa', error=error)
|
||||
|
||||
if was_first_topup and not getattr(user, 'has_made_first_topup', False):
|
||||
if was_first_topup and not getattr(user, 'has_made_first_topup', False) and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
@@ -912,6 +912,18 @@ class YooKassaPaymentMixin:
|
||||
if subscription:
|
||||
logger.info('Подписка успешно активирована для пользователя', user_id=user.id)
|
||||
|
||||
# Consume promo-offer discount (invoice was created with discounted price)
|
||||
try:
|
||||
from app.utils.promo_offer import consume_user_promo_offer
|
||||
|
||||
await consume_user_promo_offer(db, user.id)
|
||||
except Exception as promo_error:
|
||||
logger.warning(
|
||||
'Ошибка потребления промо-оффера при YooKassa оплате',
|
||||
user_id=user.id,
|
||||
error=promo_error,
|
||||
)
|
||||
|
||||
# Обновляем данные подписки в RemnaWave, чтобы получить актуальные ссылки
|
||||
try:
|
||||
remnawave_user = await subscription_service.create_remnawave_user(db, subscription)
|
||||
|
||||
@@ -126,7 +126,10 @@ def _get_method_defaults() -> dict:
|
||||
'is_configured': settings.is_kassa_ai_enabled(),
|
||||
'default_min': settings.KASSA_AI_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.KASSA_AI_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': None,
|
||||
'available_sub_options': [
|
||||
{'id': 'sbp', 'name': 'СБП'},
|
||||
{'id': 'card', 'name': 'Карта'},
|
||||
],
|
||||
},
|
||||
'riopay': {
|
||||
'default_display_name': settings.get_riopay_display_name(),
|
||||
@@ -135,6 +138,13 @@ def _get_method_defaults() -> dict:
|
||||
'default_max': settings.RIOPAY_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': None,
|
||||
},
|
||||
'severpay': {
|
||||
'default_display_name': settings.get_severpay_display_name(),
|
||||
'is_configured': settings.is_severpay_enabled(),
|
||||
'default_min': settings.SEVERPAY_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.SEVERPAY_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -176,6 +186,7 @@ DEFAULT_METHOD_ORDER = [
|
||||
'cloudpayments',
|
||||
'kassa_ai',
|
||||
'riopay',
|
||||
'severpay',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,811 @@
|
||||
"""Search service for querying payments across all provider tables."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import cast, desc, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.types import String as SAString
|
||||
|
||||
from app.database.models import (
|
||||
CloudPaymentsPayment,
|
||||
CryptoBotPayment,
|
||||
FreekassaPayment,
|
||||
HeleketPayment,
|
||||
KassaAiPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PaymentMethod,
|
||||
PlategaPayment,
|
||||
RioPayPayment,
|
||||
SeverPayPayment,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
WataPayment,
|
||||
YooKassaPayment,
|
||||
)
|
||||
from app.services.payment_verification_service import (
|
||||
PendingPayment,
|
||||
_build_record,
|
||||
_metadata_is_balance,
|
||||
_parse_cryptobot_amount_kopeks,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MAX_ALL_TIME_DAYS: int = 365
|
||||
"""Safety limit for 'all time' queries to prevent unbounded scans."""
|
||||
|
||||
MAX_RECORDS_PER_PROVIDER: int = 5000
|
||||
"""Hard limit on rows fetched from each provider table to prevent memory exhaustion."""
|
||||
|
||||
DEFAULT_PER_PAGE: int = 20
|
||||
MAX_PER_PAGE: int = 100
|
||||
|
||||
|
||||
def _escape_like(value: str) -> str:
|
||||
"""Escape LIKE/ILIKE wildcard characters to prevent pattern injection."""
|
||||
return value.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
|
||||
|
||||
|
||||
class StatusFilter(str, enum.Enum):
|
||||
"""Supported status filter values."""
|
||||
|
||||
ALL = 'all'
|
||||
PENDING = 'pending'
|
||||
PAID = 'paid'
|
||||
CANCELLED = 'cancelled'
|
||||
|
||||
|
||||
class PeriodPreset(str, enum.Enum):
|
||||
"""Predefined period presets."""
|
||||
|
||||
H24 = '24h'
|
||||
D7 = '7d'
|
||||
D30 = '30d'
|
||||
ALL = 'all'
|
||||
|
||||
|
||||
_PERIOD_DELTAS: dict[PeriodPreset, timedelta] = {
|
||||
PeriodPreset.H24: timedelta(hours=24),
|
||||
PeriodPreset.D7: timedelta(days=7),
|
||||
PeriodPreset.D30: timedelta(days=30),
|
||||
PeriodPreset.ALL: timedelta(days=MAX_ALL_TIME_DAYS),
|
||||
}
|
||||
|
||||
|
||||
# Sets of provider-specific statuses used for classification.
|
||||
_CANCELLED_STATUSES: frozenset[str] = frozenset(
|
||||
{
|
||||
'cancel',
|
||||
'canceled',
|
||||
'cancelled',
|
||||
'declined',
|
||||
'error',
|
||||
'expired',
|
||||
'fail',
|
||||
'failed',
|
||||
'amount_mismatch',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search params
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SearchParams:
|
||||
"""Encapsulates validated search parameters."""
|
||||
|
||||
search: str | None = None
|
||||
status_filter: StatusFilter = StatusFilter.ALL
|
||||
method_filter: PaymentMethod | None = None
|
||||
period: PeriodPreset = PeriodPreset.H24
|
||||
date_from: datetime | None = None
|
||||
date_to: datetime | None = None
|
||||
page: int = 1
|
||||
per_page: int = DEFAULT_PER_PAGE
|
||||
|
||||
@property
|
||||
def cutoff(self) -> datetime:
|
||||
"""Calculate the earliest datetime to consider."""
|
||||
if self.date_from is not None:
|
||||
return self.date_from
|
||||
return datetime.now(UTC) - _PERIOD_DELTAS.get(self.period, _PERIOD_DELTAS[PeriodPreset.H24])
|
||||
|
||||
@property
|
||||
def upper_bound(self) -> datetime | None:
|
||||
"""Upper datetime bound (only set for custom ranges)."""
|
||||
return self.date_to
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SearchStats:
|
||||
"""Aggregated search statistics."""
|
||||
|
||||
total: int = 0
|
||||
pending: int = 0
|
||||
paid: int = 0
|
||||
cancelled: int = 0
|
||||
by_method: dict[str, int] | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status classification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_PAID_STATUSES: frozenset[str] = frozenset(
|
||||
{
|
||||
'completed',
|
||||
'confirmed',
|
||||
'paid',
|
||||
'paid_over',
|
||||
'succeeded',
|
||||
'success',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _classify_status(record: PendingPayment) -> StatusFilter:
|
||||
"""Classify a payment record into one of the three buckets."""
|
||||
if record.is_paid:
|
||||
return StatusFilter.PAID
|
||||
status_lower = (record.status or '').lower()
|
||||
if status_lower in _PAID_STATUSES:
|
||||
return StatusFilter.PAID
|
||||
if status_lower in _CANCELLED_STATUSES:
|
||||
return StatusFilter.CANCELLED
|
||||
return StatusFilter.PENDING
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User search type detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _UserSearchKind(enum.Enum):
|
||||
USERNAME = 'username'
|
||||
TELEGRAM_ID = 'telegram_id'
|
||||
EMAIL = 'email'
|
||||
INVOICE = 'invoice'
|
||||
|
||||
|
||||
def _detect_user_search_kind(query: str) -> _UserSearchKind:
|
||||
"""Auto-detect the type of user search query."""
|
||||
stripped = query.strip()
|
||||
if stripped.startswith('@'):
|
||||
return _UserSearchKind.USERNAME
|
||||
if stripped.isdigit():
|
||||
return _UserSearchKind.TELEGRAM_ID
|
||||
if '@' in stripped:
|
||||
return _UserSearchKind.EMAIL
|
||||
return _UserSearchKind.INVOICE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-provider search functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _apply_date_filter(
|
||||
stmt: Any,
|
||||
created_at_col: Any,
|
||||
cutoff: datetime,
|
||||
upper_bound: datetime | None,
|
||||
) -> Any:
|
||||
"""Apply date range filters to a select statement."""
|
||||
stmt = stmt.where(created_at_col >= cutoff)
|
||||
if upper_bound is not None:
|
||||
stmt = stmt.where(created_at_col <= upper_bound)
|
||||
return stmt
|
||||
|
||||
|
||||
def _apply_user_join_filter(
|
||||
stmt: Any,
|
||||
model: type,
|
||||
search_kind: _UserSearchKind,
|
||||
search_value: str,
|
||||
) -> Any:
|
||||
"""Apply user-based search filters by joining the User table."""
|
||||
stmt = stmt.join(User, model.user_id == User.id)
|
||||
if search_kind == _UserSearchKind.USERNAME:
|
||||
username = search_value.lstrip('@')
|
||||
stmt = stmt.where(User.username.ilike(f'%{_escape_like(username)}%'))
|
||||
elif search_kind == _UserSearchKind.TELEGRAM_ID:
|
||||
stmt = stmt.where(User.telegram_id == int(search_value))
|
||||
elif search_kind == _UserSearchKind.EMAIL:
|
||||
stmt = stmt.where(User.email.ilike(f'%{_escape_like(search_value)}%'))
|
||||
return stmt
|
||||
|
||||
|
||||
async def _search_yookassa(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(YooKassaPayment).options(selectinload(YooKassaPayment.user)).order_by(desc(YooKassaPayment.created_at))
|
||||
)
|
||||
stmt = _apply_date_filter(stmt, YooKassaPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
stmt = stmt.where(YooKassaPayment.yookassa_payment_id.ilike(f'%{_escape_like(params.search)}%'))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, YooKassaPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _metadata_is_balance(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.YOOKASSA,
|
||||
payment,
|
||||
identifier=payment.yookassa_payment_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(getattr(payment, 'is_paid', False)),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_cryptobot(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(CryptoBotPayment)
|
||||
.options(selectinload(CryptoBotPayment.user))
|
||||
.order_by(desc(CryptoBotPayment.created_at))
|
||||
)
|
||||
stmt = _apply_date_filter(stmt, CryptoBotPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
stmt = stmt.where(CryptoBotPayment.invoice_id.ilike(f'%{_escape_like(params.search)}%'))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, CryptoBotPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
amount_kopeks = _parse_cryptobot_amount_kopeks(payment)
|
||||
record = _build_record(
|
||||
PaymentMethod.CRYPTOBOT,
|
||||
payment,
|
||||
identifier=payment.invoice_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_heleket(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = select(HeleketPayment).options(selectinload(HeleketPayment.user)).order_by(desc(HeleketPayment.created_at))
|
||||
stmt = _apply_date_filter(stmt, HeleketPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
HeleketPayment.uuid.ilike(f'%{_escape_like(params.search)}%'),
|
||||
HeleketPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
)
|
||||
)
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, HeleketPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.HELEKET,
|
||||
payment,
|
||||
identifier=payment.uuid,
|
||||
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 _search_mulenpay(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(MulenPayPayment).options(selectinload(MulenPayPayment.user)).order_by(desc(MulenPayPayment.created_at))
|
||||
)
|
||||
stmt = _apply_date_filter(stmt, MulenPayPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [MulenPayPayment.uuid.ilike(f'%{_escape_like(params.search)}%')]
|
||||
# mulen_payment_id is Integer -- cast for ILIKE
|
||||
if params.search.isdigit():
|
||||
conditions.append(MulenPayPayment.mulen_payment_id == int(params.search))
|
||||
else:
|
||||
conditions.append(
|
||||
cast(MulenPayPayment.mulen_payment_id, SAString).ilike(f'%{_escape_like(params.search)}%')
|
||||
)
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, MulenPayPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.MULENPAY,
|
||||
payment,
|
||||
identifier=payment.uuid,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_pal24(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = select(Pal24Payment).options(selectinload(Pal24Payment.user)).order_by(desc(Pal24Payment.created_at))
|
||||
stmt = _apply_date_filter(stmt, Pal24Payment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
Pal24Payment.bill_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
Pal24Payment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, Pal24Payment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.PAL24,
|
||||
payment,
|
||||
identifier=payment.bill_id,
|
||||
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 _search_wata(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = select(WataPayment).options(selectinload(WataPayment.user)).order_by(desc(WataPayment.created_at))
|
||||
stmt = _apply_date_filter(stmt, WataPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
WataPayment.payment_link_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
WataPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, WataPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.WATA,
|
||||
payment,
|
||||
identifier=payment.payment_link_id,
|
||||
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 _search_platega(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = select(PlategaPayment).options(selectinload(PlategaPayment.user)).order_by(desc(PlategaPayment.created_at))
|
||||
stmt = _apply_date_filter(stmt, PlategaPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
PlategaPayment.correlation_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
PlategaPayment.platega_transaction_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, PlategaPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
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 _search_cloudpayments(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(CloudPaymentsPayment)
|
||||
.options(selectinload(CloudPaymentsPayment.user))
|
||||
.order_by(desc(CloudPaymentsPayment.created_at))
|
||||
)
|
||||
stmt = _apply_date_filter(stmt, CloudPaymentsPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [CloudPaymentsPayment.invoice_id.ilike(f'%{_escape_like(params.search)}%')]
|
||||
# transaction_id_cp is BigInteger -- cast for ILIKE
|
||||
if params.search.isdigit():
|
||||
conditions.append(CloudPaymentsPayment.transaction_id_cp == int(params.search))
|
||||
else:
|
||||
conditions.append(
|
||||
cast(CloudPaymentsPayment.transaction_id_cp, SAString).ilike(f'%{_escape_like(params.search)}%')
|
||||
)
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, CloudPaymentsPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.CLOUDPAYMENTS,
|
||||
payment,
|
||||
identifier=payment.invoice_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_freekassa(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(FreekassaPayment)
|
||||
.options(selectinload(FreekassaPayment.user))
|
||||
.order_by(desc(FreekassaPayment.created_at))
|
||||
)
|
||||
stmt = _apply_date_filter(stmt, FreekassaPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
FreekassaPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
FreekassaPayment.freekassa_order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, FreekassaPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.FREEKASSA,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_kassa_ai(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = select(KassaAiPayment).options(selectinload(KassaAiPayment.user)).order_by(desc(KassaAiPayment.created_at))
|
||||
stmt = _apply_date_filter(stmt, KassaAiPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
KassaAiPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
KassaAiPayment.kassa_ai_order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, KassaAiPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.KASSA_AI,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_riopay(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = select(RioPayPayment).options(selectinload(RioPayPayment.user)).order_by(desc(RioPayPayment.created_at))
|
||||
stmt = _apply_date_filter(stmt, RioPayPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
RioPayPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
RioPayPayment.riopay_order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, RioPayPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.RIOPAY,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
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 _search_severpay(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(SeverPayPayment).options(selectinload(SeverPayPayment.user)).order_by(desc(SeverPayPayment.created_at))
|
||||
)
|
||||
stmt = _apply_date_filter(stmt, SeverPayPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
SeverPayPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
SeverPayPayment.severpay_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
SeverPayPayment.severpay_uid.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, SeverPayPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.SEVERPAY,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
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 _search_stars(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(Transaction)
|
||||
.options(selectinload(Transaction.user))
|
||||
.where(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.payment_method == PaymentMethod.TELEGRAM_STARS.value,
|
||||
)
|
||||
.order_by(desc(Transaction.created_at))
|
||||
)
|
||||
stmt = _apply_date_filter(stmt, Transaction.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
stmt = stmt.where(Transaction.external_id.ilike(f'%{_escape_like(params.search)}%'))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, Transaction, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for transaction in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.TELEGRAM_STARS,
|
||||
transaction,
|
||||
identifier=transaction.external_id or str(transaction.id),
|
||||
amount_kopeks=transaction.amount_kopeks,
|
||||
status='paid' if transaction.is_completed else 'pending',
|
||||
is_paid=bool(transaction.is_completed),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider -> search function mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PROVIDER_SEARCH_MAP: dict[PaymentMethod, Any] = {
|
||||
PaymentMethod.YOOKASSA: _search_yookassa,
|
||||
PaymentMethod.CRYPTOBOT: _search_cryptobot,
|
||||
PaymentMethod.HELEKET: _search_heleket,
|
||||
PaymentMethod.MULENPAY: _search_mulenpay,
|
||||
PaymentMethod.PAL24: _search_pal24,
|
||||
PaymentMethod.WATA: _search_wata,
|
||||
PaymentMethod.PLATEGA: _search_platega,
|
||||
PaymentMethod.CLOUDPAYMENTS: _search_cloudpayments,
|
||||
PaymentMethod.FREEKASSA: _search_freekassa,
|
||||
PaymentMethod.KASSA_AI: _search_kassa_ai,
|
||||
PaymentMethod.RIOPAY: _search_riopay,
|
||||
PaymentMethod.SEVERPAY: _search_severpay,
|
||||
PaymentMethod.TELEGRAM_STARS: _search_stars,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def search_payments(
|
||||
db: AsyncSession,
|
||||
params: SearchParams,
|
||||
) -> tuple[list[PendingPayment], int]:
|
||||
"""Search payments across all (or filtered) providers.
|
||||
|
||||
Returns:
|
||||
Tuple of ``(page_items, total_count)`` where *page_items* is
|
||||
a slice according to ``params.page`` / ``params.per_page``.
|
||||
"""
|
||||
|
||||
# Determine which providers to query
|
||||
if params.method_filter is not None:
|
||||
search_fn = _PROVIDER_SEARCH_MAP.get(params.method_filter)
|
||||
if search_fn is None:
|
||||
return [], 0
|
||||
provider_results: list[list[PendingPayment]] = [await search_fn(db, params)]
|
||||
else:
|
||||
provider_results = []
|
||||
for search_fn in _PROVIDER_SEARCH_MAP.values():
|
||||
provider_results.append(await search_fn(db, params))
|
||||
|
||||
# Flatten
|
||||
all_records: list[PendingPayment] = []
|
||||
for batch in provider_results:
|
||||
all_records.extend(batch)
|
||||
|
||||
# Apply status filter in Python (status classification depends on provider logic)
|
||||
if params.status_filter != StatusFilter.ALL:
|
||||
all_records = [r for r in all_records if _classify_status(r) == params.status_filter]
|
||||
|
||||
# Sort globally by created_at desc
|
||||
all_records.sort(key=lambda r: r.created_at, reverse=True)
|
||||
|
||||
total = len(all_records)
|
||||
|
||||
# Paginate
|
||||
start_idx = (params.page - 1) * params.per_page
|
||||
page_items = all_records[start_idx : start_idx + params.per_page]
|
||||
|
||||
return page_items, total
|
||||
|
||||
|
||||
async def search_payments_stats(
|
||||
db: AsyncSession,
|
||||
params: SearchParams,
|
||||
) -> SearchStats:
|
||||
"""Compute aggregated statistics for the given search filters.
|
||||
|
||||
Pagination params are ignored -- stats cover the full result set.
|
||||
"""
|
||||
|
||||
# Reuse the same search logic but force ALL statuses for counting
|
||||
stats_params = SearchParams(
|
||||
search=params.search,
|
||||
status_filter=StatusFilter.ALL,
|
||||
method_filter=params.method_filter,
|
||||
period=params.period,
|
||||
date_from=params.date_from,
|
||||
date_to=params.date_to,
|
||||
page=1,
|
||||
per_page=MAX_PER_PAGE,
|
||||
)
|
||||
|
||||
# Query all providers
|
||||
if stats_params.method_filter is not None:
|
||||
search_fn = _PROVIDER_SEARCH_MAP.get(stats_params.method_filter)
|
||||
if search_fn is None:
|
||||
return SearchStats()
|
||||
all_records: list[PendingPayment] = await search_fn(db, stats_params)
|
||||
else:
|
||||
all_records = []
|
||||
for search_fn in _PROVIDER_SEARCH_MAP.values():
|
||||
all_records.extend(await search_fn(db, stats_params))
|
||||
|
||||
# Classify
|
||||
pending_count = 0
|
||||
paid_count = 0
|
||||
cancelled_count = 0
|
||||
method_counter: Counter[str] = Counter()
|
||||
|
||||
for record in all_records:
|
||||
status = _classify_status(record)
|
||||
if status == StatusFilter.PAID:
|
||||
paid_count += 1
|
||||
elif status == StatusFilter.CANCELLED:
|
||||
cancelled_count += 1
|
||||
else:
|
||||
pending_count += 1
|
||||
method_counter[record.method.value] += 1
|
||||
|
||||
return SearchStats(
|
||||
total=len(all_records),
|
||||
pending=pending_count,
|
||||
paid=paid_count,
|
||||
cancelled=cancelled_count,
|
||||
by_method=dict(method_counter),
|
||||
)
|
||||
@@ -34,6 +34,7 @@ from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin
|
||||
from app.services.payment.freekassa import FreekassaPaymentMixin
|
||||
from app.services.payment.kassa_ai import KassaAiPaymentMixin
|
||||
from app.services.payment.riopay import RioPayPaymentMixin
|
||||
from app.services.payment.severpay import SeverPayPaymentMixin
|
||||
from app.services.platega_service import PlategaService
|
||||
from app.services.wata_service import WataService
|
||||
from app.services.yookassa_service import YooKassaService
|
||||
@@ -296,6 +297,41 @@ async def update_cloudpayments_payment(*args, **kwargs):
|
||||
return await cloudpayments_crud.update_cloudpayments_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_severpay_payment(*args, **kwargs):
|
||||
severpay_crud = import_module('app.database.crud.severpay')
|
||||
return await severpay_crud.create_severpay_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_severpay_payment_by_order_id(*args, **kwargs):
|
||||
severpay_crud = import_module('app.database.crud.severpay')
|
||||
return await severpay_crud.get_severpay_payment_by_order_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_severpay_payment_by_severpay_id(*args, **kwargs):
|
||||
severpay_crud = import_module('app.database.crud.severpay')
|
||||
return await severpay_crud.get_severpay_payment_by_severpay_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_severpay_payment_by_id(*args, **kwargs):
|
||||
severpay_crud = import_module('app.database.crud.severpay')
|
||||
return await severpay_crud.get_severpay_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_severpay_payment_by_id_for_update(*args, **kwargs):
|
||||
severpay_crud = import_module('app.database.crud.severpay')
|
||||
return await severpay_crud.get_severpay_payment_by_id_for_update(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_severpay_payment_status(*args, **kwargs):
|
||||
severpay_crud = import_module('app.database.crud.severpay')
|
||||
return await severpay_crud.update_severpay_payment_status(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_severpay_payment_to_transaction(*args, **kwargs):
|
||||
severpay_crud = import_module('app.database.crud.severpay')
|
||||
return await severpay_crud.link_severpay_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
# Mapping from model_name to getter function name for providers
|
||||
# where it differs from the standard get_{model_name}_payment_by_id pattern.
|
||||
_GETTER_OVERRIDES: dict[str, str] = {
|
||||
@@ -318,6 +354,7 @@ class PaymentService(
|
||||
FreekassaPaymentMixin,
|
||||
KassaAiPaymentMixin,
|
||||
RioPayPaymentMixin,
|
||||
SeverPayPaymentMixin,
|
||||
):
|
||||
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
|
||||
|
||||
@@ -666,23 +703,74 @@ class PaymentService(
|
||||
return None
|
||||
|
||||
# --- KassaAI ----------------------------------------------------------
|
||||
if payment_method == 'kassa_ai':
|
||||
if payment_method in ('kassa_ai', 'kassa_ai_sbp', 'kassa_ai_card'):
|
||||
if not settings.is_kassa_ai_enabled():
|
||||
logger.warning('KassaAI is not enabled, cannot create guest payment')
|
||||
return None
|
||||
|
||||
from app.services.kassa_ai_service import KASSA_AI_SUB_METHODS
|
||||
|
||||
sub = KASSA_AI_SUB_METHODS.get(payment_method)
|
||||
ps_id = sub['payment_system_id'] if sub else None
|
||||
|
||||
result = await self.create_kassa_ai_payment(
|
||||
db=db,
|
||||
user_id=None,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
payment_system_id=ps_id,
|
||||
)
|
||||
if result:
|
||||
await _patch_guest_metadata(result['local_payment_id'], 'kassa_ai')
|
||||
await _patch_guest_metadata(result['local_payment_id'], payment_method)
|
||||
return {
|
||||
'payment_url': result.get('payment_url'),
|
||||
'payment_id': result.get('order_id'),
|
||||
'provider': 'kassa_ai',
|
||||
'provider': payment_method,
|
||||
}
|
||||
return None
|
||||
|
||||
# --- RioPay -----------------------------------------------------------
|
||||
if payment_method == 'riopay':
|
||||
if not settings.is_riopay_enabled():
|
||||
logger.warning('RioPay is not enabled, cannot create guest payment')
|
||||
return None
|
||||
|
||||
result = await self.create_riopay_payment(
|
||||
db=db,
|
||||
user_id=None,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
success_url=return_url,
|
||||
fail_url=return_url,
|
||||
)
|
||||
if result:
|
||||
await _patch_guest_metadata(result['local_payment_id'], 'riopay')
|
||||
return {
|
||||
'payment_url': result.get('payment_url'),
|
||||
'payment_id': result.get('riopay_order_id') or result.get('order_id'),
|
||||
'provider': 'riopay',
|
||||
}
|
||||
return None
|
||||
|
||||
# --- SeverPay ---------------------------------------------------------
|
||||
if payment_method == 'severpay':
|
||||
if not settings.is_severpay_enabled():
|
||||
logger.warning('SeverPay is not enabled, cannot create guest payment')
|
||||
return None
|
||||
|
||||
result = await self.create_severpay_payment(
|
||||
db=db,
|
||||
user_id=None,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
return_url=return_url,
|
||||
)
|
||||
if result:
|
||||
await _patch_guest_metadata(result['local_payment_id'], 'severpay')
|
||||
return {
|
||||
'payment_url': result.get('payment_url'),
|
||||
'payment_id': result.get('severpay_id') or result.get('order_id'),
|
||||
'provider': 'severpay',
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ from app.database.models import (
|
||||
Pal24Payment,
|
||||
PaymentMethod,
|
||||
PlategaPayment,
|
||||
RioPayPayment,
|
||||
SeverPayPayment,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
@@ -72,6 +74,8 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
PaymentMethod.CLOUDPAYMENTS,
|
||||
PaymentMethod.FREEKASSA,
|
||||
PaymentMethod.KASSA_AI,
|
||||
PaymentMethod.RIOPAY,
|
||||
PaymentMethod.SEVERPAY,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -90,6 +94,8 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
# Payments are processed via webhook (wata_webhook.py).
|
||||
PaymentMethod.FREEKASSA,
|
||||
PaymentMethod.KASSA_AI,
|
||||
PaymentMethod.RIOPAY,
|
||||
PaymentMethod.SEVERPAY,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -115,6 +121,10 @@ def method_display_name(method: PaymentMethod) -> str:
|
||||
return 'Freekassa'
|
||||
if method == PaymentMethod.KASSA_AI:
|
||||
return settings.get_kassa_ai_display_name()
|
||||
if method == PaymentMethod.RIOPAY:
|
||||
return settings.get_riopay_display_name()
|
||||
if method == PaymentMethod.SEVERPAY:
|
||||
return settings.get_severpay_display_name()
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
return 'Telegram Stars'
|
||||
return method.value
|
||||
@@ -141,6 +151,10 @@ def _method_is_enabled(method: PaymentMethod) -> bool:
|
||||
return settings.is_freekassa_enabled()
|
||||
if method == PaymentMethod.KASSA_AI:
|
||||
return settings.is_kassa_ai_enabled()
|
||||
if method == PaymentMethod.RIOPAY:
|
||||
return settings.is_riopay_enabled()
|
||||
if method == PaymentMethod.SEVERPAY:
|
||||
return settings.is_severpay_enabled()
|
||||
return False
|
||||
|
||||
|
||||
@@ -242,12 +256,23 @@ class AutoPaymentVerificationService:
|
||||
)
|
||||
|
||||
for record in candidates:
|
||||
refreshed = await run_manual_check(
|
||||
session,
|
||||
record.method,
|
||||
record.local_id,
|
||||
self._payment_service,
|
||||
)
|
||||
try:
|
||||
refreshed = await run_manual_check(
|
||||
session,
|
||||
record.method,
|
||||
record.local_id,
|
||||
self._payment_service,
|
||||
)
|
||||
except Exception as check_error:
|
||||
logger.error(
|
||||
'Ошибка проверки платежа, откатываем сессию',
|
||||
method_display_name=method_display_name(record.method),
|
||||
identifier=record.identifier,
|
||||
error=check_error,
|
||||
)
|
||||
if session.in_transaction():
|
||||
await session.rollback()
|
||||
continue
|
||||
|
||||
if not refreshed:
|
||||
logger.debug(
|
||||
@@ -365,6 +390,20 @@ def _is_kassa_ai_pending(payment: KassaAiPayment) -> bool:
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _is_severpay_pending(payment: SeverPayPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or '').lower()
|
||||
return status in {'pending', 'processing'}
|
||||
|
||||
|
||||
def _is_riopay_pending(payment: RioPayPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or '').lower()
|
||||
return status in {'pending'}
|
||||
|
||||
|
||||
def _parse_cryptobot_amount_kopeks(payment: CryptoBotPayment) -> int:
|
||||
payload = payment.payload or ''
|
||||
match = re.search(r'_(\d+)$', payload)
|
||||
@@ -682,6 +721,58 @@ async def _fetch_kassa_ai_payments(db: AsyncSession, cutoff: datetime) -> list[P
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_riopay_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(RioPayPayment)
|
||||
.options(selectinload(RioPayPayment.user))
|
||||
.where(RioPayPayment.created_at >= cutoff)
|
||||
.order_by(desc(RioPayPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_riopay_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.RIOPAY,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
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_severpay_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(SeverPayPayment)
|
||||
.options(selectinload(SeverPayPayment.user))
|
||||
.where(SeverPayPayment.created_at >= cutoff)
|
||||
.order_by(desc(SeverPayPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_severpay_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.SEVERPAY,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
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_stars_transactions(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(Transaction)
|
||||
@@ -729,6 +820,8 @@ async def list_recent_pending_payments(
|
||||
await _fetch_cloudpayments_payments(db, cutoff),
|
||||
await _fetch_freekassa_payments(db, cutoff),
|
||||
await _fetch_kassa_ai_payments(db, cutoff),
|
||||
await _fetch_riopay_payments(db, cutoff),
|
||||
await _fetch_severpay_payments(db, cutoff),
|
||||
await _fetch_stars_transactions(db, cutoff),
|
||||
)
|
||||
|
||||
@@ -897,6 +990,36 @@ async def get_payment_record(
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.RIOPAY:
|
||||
payment = await db.get(RioPayPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=['user'])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
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.SEVERPAY:
|
||||
payment = await db.get(SeverPayPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=['user'])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
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.TELEGRAM_STARS:
|
||||
transaction = await db.get(Transaction, local_payment_id)
|
||||
if not transaction:
|
||||
@@ -955,6 +1078,20 @@ async def run_manual_check(
|
||||
elif method == PaymentMethod.KASSA_AI:
|
||||
result = await payment_service.get_kassa_ai_payment_status(db, local_payment_id)
|
||||
payment = result.get('payment') if result else None
|
||||
elif method == PaymentMethod.SEVERPAY:
|
||||
severpay_payment = await db.get(SeverPayPayment, local_payment_id)
|
||||
if severpay_payment:
|
||||
result = await payment_service.check_severpay_payment_status(db, severpay_payment.order_id)
|
||||
payment = result.get('payment') if result else None
|
||||
else:
|
||||
payment = None
|
||||
elif method == PaymentMethod.RIOPAY:
|
||||
riopay_payment = await db.get(RioPayPayment, local_payment_id)
|
||||
if riopay_payment:
|
||||
result = await payment_service.check_riopay_payment_status(db, riopay_payment.order_id)
|
||||
payment = result.get('payment') if result else None
|
||||
else:
|
||||
payment = None
|
||||
else:
|
||||
logger.warning('Manual check requested for unsupported method', method=method)
|
||||
return None
|
||||
@@ -972,6 +1109,9 @@ async def run_manual_check(
|
||||
error=error,
|
||||
exc_info=True,
|
||||
)
|
||||
# Откатываем сессию чтобы не оставлять её в грязном состоянии
|
||||
if db.in_transaction():
|
||||
await db.rollback()
|
||||
return None
|
||||
|
||||
|
||||
|
||||
+548
-41
@@ -15,7 +15,7 @@ from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import Subscription, User
|
||||
from app.database.models import Subscription, Tariff, User
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -27,8 +27,9 @@ class TariffBreakdown:
|
||||
|
||||
tariff_id: int
|
||||
extra_devices: int
|
||||
group_discount_pct: int
|
||||
group_discount_pct: dict[str, int]
|
||||
offer_discount_pct: int
|
||||
months_in_period: int = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -42,9 +43,14 @@ class ClassicBreakdown:
|
||||
base_traffic_gb: int
|
||||
purchased_traffic_gb: int
|
||||
extra_devices: int
|
||||
# NB: dict[str, int] per-category (period/servers/traffic/devices), unlike TariffBreakdown's single int
|
||||
# Per-category discount percents (period/servers/traffic/devices)
|
||||
group_discount_pct: dict[str, int]
|
||||
offer_discount_pct: int
|
||||
# Original (pre-discount) prices — used by classic_pricing_to_purchase_details()
|
||||
base_price_original: int = 0
|
||||
traffic_price_per_month: int = 0
|
||||
servers_price_per_month: int = 0
|
||||
devices_price_per_month: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -68,6 +74,30 @@ class RenewalPricing:
|
||||
return self.final_total + self.promo_group_discount + self.promo_offer_discount
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TariffSwitchResult:
|
||||
"""Immutable result of a tariff switch cost calculation."""
|
||||
|
||||
upgrade_cost: int # kopeks — amount to charge (0 if downgrade/same)
|
||||
is_upgrade: bool # True if new tariff is more expensive
|
||||
raw_cost: int # kopeks — cost before discounts (for UI display)
|
||||
group_discount_pct: int
|
||||
offer_discount_pct: int
|
||||
new_period_days: int = 0 # 0 = keep current end date, >0 = set new subscription period
|
||||
|
||||
@property
|
||||
def discount_value(self) -> int:
|
||||
"""Сумма скидки в копейках."""
|
||||
return self.raw_cost - self.upgrade_cost
|
||||
|
||||
@property
|
||||
def effective_discount_pct(self) -> int:
|
||||
"""Эффективный процент скидки (стекинг group + offer)."""
|
||||
if self.raw_cost <= 0:
|
||||
return 0
|
||||
return round(self.discount_value * 100 / self.raw_cost)
|
||||
|
||||
|
||||
class PricingEngine:
|
||||
"""Unified pricing engine for all subscription renewal calculations."""
|
||||
|
||||
@@ -93,6 +123,286 @@ class PricingEngine:
|
||||
offer_discount_value = after_group - after_offer
|
||||
return after_offer, group_discount_value, offer_discount_value
|
||||
|
||||
@staticmethod
|
||||
def resolve_promo_group(user: User | None):
|
||||
"""Resolve primary promo group: get_primary_promo_group() first, fallback to user.promo_group."""
|
||||
if not user:
|
||||
return None
|
||||
if hasattr(user, 'get_primary_promo_group'):
|
||||
pg = user.get_primary_promo_group()
|
||||
if pg is not None:
|
||||
return pg
|
||||
return getattr(user, 'promo_group', None)
|
||||
|
||||
@staticmethod
|
||||
def get_addon_discount_percent(
|
||||
user: User | None,
|
||||
category: str,
|
||||
period_days_hint: int | None = None,
|
||||
*,
|
||||
promo_group: PromoGroup | None = None,
|
||||
) -> int:
|
||||
"""Return addon discount percent for a given category.
|
||||
|
||||
Uses promo_group.get_discount_percent() which handles is_default fallback.
|
||||
Checks apply_discounts_to_addons flag. Returns 0 if no discount.
|
||||
|
||||
If promo_group is provided explicitly, it takes precedence over
|
||||
resolving from user (useful when caller already resolved the group).
|
||||
"""
|
||||
if promo_group is None:
|
||||
if not user:
|
||||
return 0
|
||||
promo_group = PricingEngine.resolve_promo_group(user)
|
||||
|
||||
if not promo_group:
|
||||
return 0
|
||||
|
||||
if not getattr(promo_group, 'apply_discounts_to_addons', True):
|
||||
return 0
|
||||
|
||||
if hasattr(promo_group, 'get_discount_percent'):
|
||||
return promo_group.get_discount_percent(category, period_days_hint)
|
||||
|
||||
# Fallback for promo groups without get_discount_percent
|
||||
mapping = {
|
||||
'traffic': 'traffic_discount_percent',
|
||||
'servers': 'server_discount_percent',
|
||||
'devices': 'device_discount_percent',
|
||||
}
|
||||
attr = mapping.get(category)
|
||||
if attr:
|
||||
return max(0, min(100, int(getattr(promo_group, attr, 0) or 0)))
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def calculate_traffic_discount(
|
||||
base_price: int,
|
||||
user: User | None,
|
||||
period_days_hint: int | None = None,
|
||||
) -> tuple[int, int, int]:
|
||||
"""Apply traffic addon discount from user's promo group.
|
||||
|
||||
Checks apply_discounts_to_addons flag. Uses integer arithmetic.
|
||||
Uses get_discount_percent() for correct is_default fallback.
|
||||
Returns: (final_price, discount_value, discount_percent).
|
||||
"""
|
||||
if not user or base_price <= 0:
|
||||
return base_price, 0, 0
|
||||
|
||||
pct = PricingEngine.get_addon_discount_percent(user, 'traffic', period_days_hint)
|
||||
if pct <= 0:
|
||||
return base_price, 0, 0
|
||||
|
||||
final = PricingEngine.apply_discount(base_price, pct)
|
||||
return final, base_price - final, pct
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tariff switch
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def get_tariff_daily_rate_fraction(tariff: Tariff, target_days: int) -> tuple[int, int]:
|
||||
"""Дневная ставка тарифа как (price, period_days) для целочисленных вычислений.
|
||||
|
||||
Возвращает числитель и знаменатель дроби price/period_days,
|
||||
чтобы избежать float-ошибок в финансовых расчётах.
|
||||
"""
|
||||
periods = tariff.get_available_periods()
|
||||
if not periods:
|
||||
return 0, 1
|
||||
best_period = min(periods, key=lambda p: abs(p - target_days))
|
||||
price = tariff.get_price_for_period(best_period)
|
||||
if not price or best_period <= 0:
|
||||
return 0, 1
|
||||
return price, best_period
|
||||
|
||||
def calculate_tariff_switch_cost(
|
||||
self,
|
||||
current_tariff: Tariff,
|
||||
new_tariff: Tariff,
|
||||
remaining_days: int,
|
||||
*,
|
||||
user: User | None = None,
|
||||
) -> TariffSwitchResult:
|
||||
"""Рассчитывает стоимость переключения тарифа.
|
||||
|
||||
Автоматически определяет тип переключения:
|
||||
- periodic→daily: оплата первого дня (daily_price_kopeks)
|
||||
- daily→periodic: оплата кратчайшего периода нового тарифа
|
||||
- periodic→periodic: пропорциональная разница дневных ставок × remaining_days
|
||||
|
||||
Для всех типов переключений скидки (group + offer) применяются stacked.
|
||||
"""
|
||||
current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False
|
||||
new_is_daily = getattr(new_tariff, 'is_daily', False)
|
||||
|
||||
# Daily tariff edge cases
|
||||
if not current_is_daily and new_is_daily:
|
||||
return self._calculate_switch_to_daily(new_tariff, remaining_days, user=user)
|
||||
if current_is_daily and not new_is_daily:
|
||||
return self._calculate_switch_from_daily(new_tariff, remaining_days, user=user)
|
||||
if current_is_daily and new_is_daily:
|
||||
# Daily → Daily: бесплатное переключение, cron начислит новую цену завтра
|
||||
return TariffSwitchResult(
|
||||
upgrade_cost=0,
|
||||
is_upgrade=False,
|
||||
raw_cost=0,
|
||||
group_discount_pct=0,
|
||||
offer_discount_pct=0,
|
||||
new_period_days=1,
|
||||
)
|
||||
|
||||
# --- Periodic → Periodic ---
|
||||
|
||||
# Early return: нечего считать при нулевом остатке
|
||||
if remaining_days <= 0:
|
||||
return TariffSwitchResult(
|
||||
upgrade_cost=0,
|
||||
is_upgrade=False,
|
||||
raw_cost=0,
|
||||
group_discount_pct=0,
|
||||
offer_discount_pct=0,
|
||||
new_period_days=0,
|
||||
)
|
||||
|
||||
# Целочисленная арифметика (без float round-trip):
|
||||
# raw_cost = (new_p/new_d - cur_p/cur_d) * remaining
|
||||
# = (new_p * cur_d - cur_p * new_d) * remaining / (new_d * cur_d)
|
||||
# Floor division (//) округляет дробные копейки вниз — в пользу пользователя.
|
||||
cur_price, cur_period = self.get_tariff_daily_rate_fraction(current_tariff, remaining_days)
|
||||
new_price, new_period = self.get_tariff_daily_rate_fraction(new_tariff, remaining_days)
|
||||
|
||||
numerator = (new_price * cur_period - cur_price * new_period) * remaining_days
|
||||
denominator = new_period * cur_period
|
||||
raw_cost = max(0, numerator // denominator)
|
||||
|
||||
if numerator <= 0:
|
||||
return TariffSwitchResult(
|
||||
upgrade_cost=0,
|
||||
is_upgrade=False,
|
||||
raw_cost=0,
|
||||
group_discount_pct=0,
|
||||
offer_discount_pct=0,
|
||||
new_period_days=0,
|
||||
)
|
||||
|
||||
# Resolve discounts via resolve_promo_group (get_primary_promo_group first)
|
||||
group_pct = 0
|
||||
offer_pct = 0
|
||||
if user:
|
||||
promo_group = self.resolve_promo_group(user)
|
||||
if promo_group is not None:
|
||||
best_period = min(
|
||||
current_tariff.get_available_periods() or [30],
|
||||
key=lambda p: abs(p - remaining_days),
|
||||
)
|
||||
group_pct = promo_group.get_discount_percent('period', best_period)
|
||||
offer_pct = get_user_active_promo_discount_percent(user)
|
||||
|
||||
# Применяем stacked скидки к итоговой сумме напрямую (без float round-trip)
|
||||
if group_pct > 0 or offer_pct > 0:
|
||||
upgrade_cost, _, _ = self.apply_stacked_discounts(raw_cost, group_pct, offer_pct)
|
||||
else:
|
||||
upgrade_cost = raw_cost
|
||||
|
||||
return TariffSwitchResult(
|
||||
upgrade_cost=upgrade_cost,
|
||||
is_upgrade=True,
|
||||
raw_cost=raw_cost,
|
||||
group_discount_pct=group_pct,
|
||||
offer_discount_pct=offer_pct,
|
||||
new_period_days=0,
|
||||
)
|
||||
|
||||
def _calculate_switch_to_daily(
|
||||
self,
|
||||
new_tariff: Tariff,
|
||||
remaining_days: int,
|
||||
*,
|
||||
user: User | None = None,
|
||||
) -> TariffSwitchResult:
|
||||
"""Periodic → Daily: оплата первого дня с group + offer discount."""
|
||||
daily_price = getattr(new_tariff, 'daily_price_kopeks', 0) or 0
|
||||
if daily_price <= 0:
|
||||
return TariffSwitchResult(
|
||||
upgrade_cost=0,
|
||||
is_upgrade=False,
|
||||
raw_cost=0,
|
||||
group_discount_pct=0,
|
||||
offer_discount_pct=0,
|
||||
new_period_days=1,
|
||||
)
|
||||
|
||||
group_pct = 0
|
||||
offer_pct = 0
|
||||
if user:
|
||||
promo_group = self.resolve_promo_group(user)
|
||||
if promo_group:
|
||||
period_hint = remaining_days if remaining_days > 0 else 30
|
||||
group_pct = promo_group.get_discount_percent('period', period_hint)
|
||||
offer_pct = get_user_active_promo_discount_percent(user)
|
||||
|
||||
if group_pct > 0 or offer_pct > 0:
|
||||
upgrade_cost, _, _ = self.apply_stacked_discounts(daily_price, group_pct, offer_pct)
|
||||
else:
|
||||
upgrade_cost = daily_price
|
||||
|
||||
return TariffSwitchResult(
|
||||
upgrade_cost=upgrade_cost,
|
||||
is_upgrade=upgrade_cost > 0,
|
||||
raw_cost=daily_price,
|
||||
group_discount_pct=group_pct,
|
||||
offer_discount_pct=offer_pct,
|
||||
new_period_days=1,
|
||||
)
|
||||
|
||||
def _calculate_switch_from_daily(
|
||||
self,
|
||||
new_tariff: Tariff,
|
||||
remaining_days: int,
|
||||
*,
|
||||
user: User | None = None,
|
||||
) -> TariffSwitchResult:
|
||||
"""Daily → Periodic: оплата кратчайшего периода нового тарифа с group + offer discount."""
|
||||
min_period_days = 30
|
||||
min_period_price = 0
|
||||
if new_tariff.period_prices:
|
||||
min_period_days = min(int(k) for k in new_tariff.period_prices.keys())
|
||||
min_period_price = new_tariff.period_prices.get(str(min_period_days), 0) or 0
|
||||
|
||||
if min_period_price <= 0:
|
||||
return TariffSwitchResult(
|
||||
upgrade_cost=0,
|
||||
is_upgrade=False,
|
||||
raw_cost=0,
|
||||
group_discount_pct=0,
|
||||
offer_discount_pct=0,
|
||||
new_period_days=min_period_days,
|
||||
)
|
||||
|
||||
group_pct = 0
|
||||
offer_pct = 0
|
||||
if user:
|
||||
promo_group = self.resolve_promo_group(user)
|
||||
if promo_group:
|
||||
group_pct = promo_group.get_discount_percent('period', min_period_days)
|
||||
offer_pct = get_user_active_promo_discount_percent(user)
|
||||
|
||||
if group_pct > 0 or offer_pct > 0:
|
||||
upgrade_cost, _, _ = self.apply_stacked_discounts(min_period_price, group_pct, offer_pct)
|
||||
else:
|
||||
upgrade_cost = min_period_price
|
||||
|
||||
return TariffSwitchResult(
|
||||
upgrade_cost=upgrade_cost,
|
||||
is_upgrade=upgrade_cost > 0,
|
||||
raw_cost=min_period_price,
|
||||
group_discount_pct=group_pct,
|
||||
offer_discount_pct=offer_pct,
|
||||
new_period_days=min_period_days,
|
||||
)
|
||||
|
||||
async def _calculate_servers_price(
|
||||
self,
|
||||
country_uuids: list[str],
|
||||
@@ -222,37 +532,99 @@ class PricingEngine:
|
||||
) -> RenewalPricing:
|
||||
"""Price calculation when subscription is linked to a Tariff."""
|
||||
tariff = subscription.tariff
|
||||
period_prices: dict = tariff.period_prices or {}
|
||||
base_price = int(period_prices.get(str(period_days), 0) or 0)
|
||||
device_limit = subscription.device_limit or 0
|
||||
return await self._calculate_tariff_core(
|
||||
tariff,
|
||||
period_days,
|
||||
device_limit,
|
||||
user=user,
|
||||
)
|
||||
|
||||
# Extra devices above the tariff's included limit
|
||||
async def _calculate_tariff_core(
|
||||
self,
|
||||
tariff: Tariff,
|
||||
period_days: int,
|
||||
device_limit: int,
|
||||
*,
|
||||
custom_traffic_gb: int | None = None,
|
||||
user: User | None = None,
|
||||
) -> RenewalPricing:
|
||||
"""Core tariff pricing logic (raw params, no Subscription needed).
|
||||
|
||||
Per-category discounts:
|
||||
- 'period' → base tariff price
|
||||
- 'devices' → extra device cost
|
||||
Promo-offer discount applied on the discounted subtotal.
|
||||
Device cost is monthly × months_in_period.
|
||||
"""
|
||||
months = calculate_months_from_days(period_days)
|
||||
|
||||
# --- Base price ---
|
||||
is_daily = getattr(tariff, 'is_daily', False)
|
||||
if is_daily and period_days <= 1:
|
||||
base_price = int(getattr(tariff, 'daily_price_kopeks', 0) or 0)
|
||||
else:
|
||||
period_prices: dict = tariff.period_prices or {}
|
||||
base_price = int(period_prices.get(str(period_days), 0) or 0)
|
||||
if base_price == 0 and hasattr(tariff, 'get_price_for_custom_days'):
|
||||
if hasattr(tariff, 'can_purchase_custom_days') and tariff.can_purchase_custom_days():
|
||||
custom_price = tariff.get_price_for_custom_days(period_days)
|
||||
if custom_price is not None:
|
||||
base_price = int(custom_price)
|
||||
|
||||
# --- Extra devices (monthly × months) ---
|
||||
device_price_per_unit = (
|
||||
tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE
|
||||
)
|
||||
extra_devices = max(0, (subscription.device_limit or 0) - (tariff.device_limit or 0))
|
||||
devices_price = extra_devices * device_price_per_unit
|
||||
tariff_device_limit = tariff.device_limit or 0
|
||||
extra_devices = max(0, (device_limit or 0) - tariff_device_limit)
|
||||
if is_daily and period_days <= 1:
|
||||
devices_price = extra_devices * device_price_per_unit
|
||||
else:
|
||||
devices_price = extra_devices * device_price_per_unit * months
|
||||
|
||||
subtotal = base_price + devices_price
|
||||
# --- Custom traffic (tariff add-on, uses addon discount path) ---
|
||||
traffic_price = 0
|
||||
if custom_traffic_gb is not None and hasattr(tariff, 'get_price_for_custom_traffic'):
|
||||
raw_traffic = tariff.get_price_for_custom_traffic(custom_traffic_gb)
|
||||
if raw_traffic and raw_traffic > 0:
|
||||
traffic_price = int(raw_traffic)
|
||||
|
||||
# Resolve discounts
|
||||
group_pct = 0
|
||||
if user and getattr(user, 'promo_group', None) is not None:
|
||||
group_pct = user.promo_group.get_discount_percent('period', period_days)
|
||||
# --- Per-category group discounts ---
|
||||
period_pct = 0
|
||||
devices_pct = 0
|
||||
promo_group = self.resolve_promo_group(user)
|
||||
if promo_group is not None:
|
||||
period_pct = promo_group.get_discount_percent('period', period_days)
|
||||
devices_pct = promo_group.get_discount_percent('devices', period_days)
|
||||
|
||||
offer_pct = get_user_active_promo_discount_percent(user) if user else 0
|
||||
|
||||
final_total, group_discount, offer_discount = self.apply_stacked_discounts(
|
||||
subtotal,
|
||||
group_pct,
|
||||
offer_pct,
|
||||
)
|
||||
discounted_base = self.apply_discount(base_price, period_pct)
|
||||
discounted_devices = self.apply_discount(devices_price, devices_pct)
|
||||
|
||||
# Traffic uses addon discount (checks apply_discounts_to_addons flag)
|
||||
discounted_traffic = traffic_price
|
||||
if traffic_price > 0 and user:
|
||||
discounted_traffic, _, _ = self.calculate_traffic_discount(traffic_price, user)
|
||||
|
||||
base_group_disc = base_price - discounted_base
|
||||
devices_group_disc = devices_price - discounted_devices
|
||||
traffic_group_disc = traffic_price - discounted_traffic
|
||||
total_group_discount = base_group_disc + devices_group_disc + traffic_group_disc
|
||||
|
||||
subtotal = discounted_base + discounted_devices + discounted_traffic
|
||||
after_offer = self.apply_discount(subtotal, offer_pct)
|
||||
offer_discount = subtotal - after_offer
|
||||
final_total = after_offer
|
||||
|
||||
breakdown = dataclasses.asdict(
|
||||
TariffBreakdown(
|
||||
tariff_id=tariff.id,
|
||||
extra_devices=extra_devices,
|
||||
group_discount_pct=group_pct,
|
||||
group_discount_pct={'period': period_pct, 'devices': devices_pct},
|
||||
offer_discount_pct=offer_pct,
|
||||
months_in_period=months,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -261,16 +633,17 @@ class PricingEngine:
|
||||
'Negative final_total in tariff mode, clamping to 0',
|
||||
final_total=final_total,
|
||||
subtotal=subtotal,
|
||||
group_pct=group_pct,
|
||||
period_pct=period_pct,
|
||||
devices_pct=devices_pct,
|
||||
offer_pct=offer_pct,
|
||||
)
|
||||
|
||||
return RenewalPricing(
|
||||
base_price=base_price,
|
||||
base_price=discounted_base,
|
||||
servers_price=0,
|
||||
traffic_price=0,
|
||||
devices_price=devices_price,
|
||||
promo_group_discount=group_discount,
|
||||
traffic_price=discounted_traffic,
|
||||
devices_price=discounted_devices,
|
||||
promo_group_discount=total_group_discount,
|
||||
promo_offer_discount=offer_discount,
|
||||
final_total=max(0, final_total),
|
||||
period_days=period_days,
|
||||
@@ -278,19 +651,45 @@ class PricingEngine:
|
||||
breakdown=breakdown,
|
||||
)
|
||||
|
||||
async def calculate_tariff_purchase_price(
|
||||
self,
|
||||
tariff: Tariff,
|
||||
period_days: int,
|
||||
*,
|
||||
device_limit: int | None = None,
|
||||
custom_traffic_gb: int | None = None,
|
||||
user: User | None = None,
|
||||
) -> RenewalPricing:
|
||||
"""Calculate price for a tariff purchase (new or renewal).
|
||||
|
||||
Public method that delegates to _calculate_tariff_core.
|
||||
If device_limit is None, uses the tariff's included limit (no extra devices).
|
||||
"""
|
||||
effective_device_limit = device_limit if device_limit is not None else (tariff.device_limit or 0)
|
||||
return await self._calculate_tariff_core(
|
||||
tariff,
|
||||
period_days,
|
||||
effective_device_limit,
|
||||
custom_traffic_gb=custom_traffic_gb,
|
||||
user=user,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Classic mode
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _calculate_classic_mode(
|
||||
async def _calculate_classic_core(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
period_days: int,
|
||||
connected_squads: list[str],
|
||||
traffic_limit_gb: int,
|
||||
device_limit: int,
|
||||
*,
|
||||
purchased_traffic_gb: int = 0,
|
||||
user: User | None = None,
|
||||
) -> RenewalPricing:
|
||||
"""Price calculation for legacy (non-tariff) subscriptions.
|
||||
"""Core classic-mode pricing logic (raw params, no Subscription needed).
|
||||
|
||||
Uses CLASSIC_PERIOD_PRICES from settings, falling back to the
|
||||
global PERIOD_PRICES dict during migration.
|
||||
@@ -298,6 +697,7 @@ class PricingEngine:
|
||||
Per-category discounts (period, servers, traffic, devices) are
|
||||
applied separately to each component. Servers, traffic, and
|
||||
devices are monthly prices multiplied by months_in_period.
|
||||
Promo-offer discount is applied on the subtotal.
|
||||
"""
|
||||
months = calculate_months_from_days(period_days)
|
||||
|
||||
@@ -312,14 +712,13 @@ class PricingEngine:
|
||||
fallback_price_kopeks=base_price_original,
|
||||
)
|
||||
|
||||
# --- Per-category discount percents ---
|
||||
# --- Per-category discount percents (resolve_promo_group: get_primary_promo_group first) ---
|
||||
period_pct = 0
|
||||
servers_pct = 0
|
||||
traffic_pct = 0
|
||||
devices_pct = 0
|
||||
promo_group = None
|
||||
if user and getattr(user, 'promo_group', None) is not None:
|
||||
promo_group = user.promo_group
|
||||
promo_group = self.resolve_promo_group(user)
|
||||
if promo_group is not None:
|
||||
period_pct = promo_group.get_discount_percent('period', period_days)
|
||||
servers_pct = promo_group.get_discount_percent('servers', period_days)
|
||||
traffic_pct = promo_group.get_discount_percent('traffic', period_days)
|
||||
@@ -331,7 +730,6 @@ class PricingEngine:
|
||||
base_price = self.apply_discount(base_price_original, period_pct)
|
||||
|
||||
# --- Servers (monthly × months, with servers discount) ---
|
||||
connected_squads: list[str] = subscription.connected_squads or []
|
||||
promo_group_id = getattr(user, 'promo_group_id', None) if user else None
|
||||
servers_price_per_month, server_details = await self._calculate_servers_price(
|
||||
connected_squads,
|
||||
@@ -345,13 +743,6 @@ class PricingEngine:
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_limit_gb = settings.get_fixed_traffic_limit()
|
||||
purchased_traffic_gb = 0
|
||||
else:
|
||||
traffic_limit_gb = (
|
||||
subscription.traffic_limit_gb
|
||||
if subscription.traffic_limit_gb is not None
|
||||
else settings.DEFAULT_TRAFFIC_LIMIT_GB
|
||||
)
|
||||
purchased_traffic_gb = subscription.purchased_traffic_gb or 0
|
||||
traffic_price_per_month = self._calculate_traffic_price(traffic_limit_gb, purchased_traffic_gb)
|
||||
discounted_traffic_per_month = self.apply_discount(traffic_price_per_month, traffic_pct)
|
||||
traffic_price = discounted_traffic_per_month * months
|
||||
@@ -359,7 +750,7 @@ class PricingEngine:
|
||||
# --- Devices (monthly × months, with devices discount) ---
|
||||
default_device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
device_price_per_unit = settings.PRICE_PER_DEVICE
|
||||
extra_devices = max(0, (subscription.device_limit or 0) - default_device_limit)
|
||||
extra_devices = max(0, (device_limit or 0) - default_device_limit)
|
||||
devices_price_per_month = extra_devices * device_price_per_unit
|
||||
discounted_devices_per_month = self.apply_discount(devices_price_per_month, devices_pct)
|
||||
devices_price = discounted_devices_per_month * months
|
||||
@@ -386,7 +777,9 @@ class PricingEngine:
|
||||
ClassicBreakdown(
|
||||
months_in_period=months,
|
||||
servers=server_details,
|
||||
servers_individual_prices=[d['price'] * months for d in valid_servers],
|
||||
servers_individual_prices=[
|
||||
self.apply_discount(d['price'], servers_pct) * months for d in valid_servers
|
||||
],
|
||||
server_ids=[d['id'] for d in valid_servers],
|
||||
base_traffic_gb=max(0, traffic_limit_gb - purchased_traffic_gb),
|
||||
purchased_traffic_gb=purchased_traffic_gb,
|
||||
@@ -398,6 +791,10 @@ class PricingEngine:
|
||||
'devices': devices_pct,
|
||||
},
|
||||
offer_discount_pct=offer_pct,
|
||||
base_price_original=base_price_original,
|
||||
traffic_price_per_month=traffic_price_per_month,
|
||||
servers_price_per_month=servers_price_per_month,
|
||||
devices_price_per_month=devices_price_per_month,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -422,6 +819,116 @@ class PricingEngine:
|
||||
breakdown=breakdown,
|
||||
)
|
||||
|
||||
async def _calculate_classic_mode(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
period_days: int,
|
||||
*,
|
||||
user: User | None = None,
|
||||
) -> RenewalPricing:
|
||||
"""Price calculation for legacy (non-tariff) subscriptions.
|
||||
|
||||
Thin wrapper that extracts raw params from a Subscription
|
||||
and delegates to _calculate_classic_core.
|
||||
"""
|
||||
connected_squads: list[str] = subscription.connected_squads or []
|
||||
traffic_limit_gb = (
|
||||
subscription.traffic_limit_gb
|
||||
if subscription.traffic_limit_gb is not None
|
||||
else settings.DEFAULT_TRAFFIC_LIMIT_GB
|
||||
)
|
||||
purchased_traffic_gb = subscription.purchased_traffic_gb or 0
|
||||
device_limit = subscription.device_limit or 0
|
||||
|
||||
return await self._calculate_classic_core(
|
||||
db,
|
||||
period_days,
|
||||
connected_squads,
|
||||
traffic_limit_gb,
|
||||
device_limit,
|
||||
purchased_traffic_gb=purchased_traffic_gb,
|
||||
user=user,
|
||||
)
|
||||
|
||||
async def calculate_classic_new_subscription_price(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
period_days: int,
|
||||
connected_squads: list[str],
|
||||
traffic_limit_gb: int,
|
||||
device_limit: int,
|
||||
*,
|
||||
user: User | None = None,
|
||||
) -> RenewalPricing:
|
||||
"""Calculate price for a NEW classic (non-tariff) subscription.
|
||||
|
||||
Like calculate_renewal_price but without requiring an existing
|
||||
Subscription object. purchased_traffic_gb is always 0.
|
||||
"""
|
||||
return await self._calculate_classic_core(
|
||||
db,
|
||||
period_days,
|
||||
connected_squads,
|
||||
traffic_limit_gb,
|
||||
device_limit,
|
||||
purchased_traffic_gb=0,
|
||||
user=user,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def classic_pricing_to_purchase_details(pricing: RenewalPricing) -> dict[str, Any]:
|
||||
"""Convert RenewalPricing to the legacy details dict format.
|
||||
|
||||
The returned dict is compatible with build_preview_payload
|
||||
in SubscriptionPurchaseService.
|
||||
"""
|
||||
bd = pricing.breakdown
|
||||
months = bd.get('months_in_period', 1) or 1
|
||||
group_pct = bd.get('group_discount_pct', {})
|
||||
|
||||
base_price_original = bd.get('base_price_original', 0)
|
||||
traffic_price_per_month = bd.get('traffic_price_per_month', 0)
|
||||
servers_price_per_month = bd.get('servers_price_per_month', 0)
|
||||
devices_price_per_month = bd.get('devices_price_per_month', 0)
|
||||
|
||||
period_pct = group_pct.get('period', 0)
|
||||
traffic_pct = group_pct.get('traffic', 0)
|
||||
servers_pct = group_pct.get('servers', 0)
|
||||
devices_pct = group_pct.get('devices', 0)
|
||||
|
||||
base_discount_total = base_price_original - pricing.base_price
|
||||
traffic_discount_total = (
|
||||
traffic_price_per_month - PricingEngine.apply_discount(traffic_price_per_month, traffic_pct)
|
||||
) * months
|
||||
servers_discount_total = (
|
||||
servers_price_per_month - PricingEngine.apply_discount(servers_price_per_month, servers_pct)
|
||||
) * months
|
||||
devices_discount_total = (
|
||||
devices_price_per_month - PricingEngine.apply_discount(devices_price_per_month, devices_pct)
|
||||
) * months
|
||||
|
||||
return {
|
||||
'base_price': pricing.base_price,
|
||||
'base_price_original': base_price_original,
|
||||
'base_discount_percent': period_pct,
|
||||
'base_discount_total': base_discount_total,
|
||||
'traffic_price_per_month': traffic_price_per_month,
|
||||
'traffic_discount_percent': traffic_pct,
|
||||
'traffic_discount_total': traffic_discount_total,
|
||||
'total_traffic_price': pricing.traffic_price,
|
||||
'servers_price_per_month': servers_price_per_month,
|
||||
'servers_discount_percent': servers_pct,
|
||||
'servers_discount_total': servers_discount_total,
|
||||
'total_servers_price': pricing.servers_price,
|
||||
'devices_price_per_month': devices_price_per_month,
|
||||
'devices_discount_percent': devices_pct,
|
||||
'devices_discount_total': devices_discount_total,
|
||||
'total_devices_price': pricing.devices_price,
|
||||
'months_in_period': months,
|
||||
'servers_individual_prices': bd.get('servers_individual_prices', []),
|
||||
}
|
||||
|
||||
|
||||
# Module-level singleton — use this instead of PricingEngine()
|
||||
pricing_engine = PricingEngine()
|
||||
|
||||
@@ -115,6 +115,7 @@ class PromoOfferService:
|
||||
remnawave_user = await self.subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
sync_squads=True,
|
||||
)
|
||||
if remnawave_user is None:
|
||||
await db.rollback()
|
||||
@@ -188,7 +189,7 @@ class PromoOfferService:
|
||||
subscription.connected_squads = list(updated)
|
||||
subscription.updated_at = now
|
||||
try:
|
||||
await self.subscription_service.update_remnawave_user(db, subscription)
|
||||
await self.subscription_service.update_remnawave_user(db, subscription, sync_squads=True)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
'Ошибка обновления Remnawave при отзыве тестового доступа подписки',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user