Compare commits
96 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a48818bc3 | |||
| ff7388766c | |||
| 6b36dc4df1 | |||
| fe847e35f0 | |||
| 4c2cb63cf9 | |||
| d3c994083e | |||
| cce3b0c13b | |||
| 6c208581d9 | |||
| c307278231 | |||
| 9eab802000 | |||
| 13ea3768b5 | |||
| ed5a92ab96 | |||
| 48265f1cd4 | |||
| 3b9568fcc1 | |||
| 79cfcbcece | |||
| 6d5aceb4ca | |||
| 905dbcc779 | |||
| f33dfdf031 | |||
| 59cd74d307 | |||
| 90209ebef1 | |||
| ab43e74ab7 | |||
| 4244962337 | |||
| ba79d03e38 | |||
| 8e4e2ddd1a | |||
| 38853cdd5a | |||
| f837c0c244 | |||
| 8a7b9cc651 | |||
| 3bf31055e7 | |||
| 3c5bf4fa22 | |||
| 4990ddf9e4 | |||
| de00612965 | |||
| 43f5629c8c | |||
| 448799a3ed | |||
| b2b5f104b5 | |||
| 5f71eaa926 | |||
| 54155b5649 | |||
| 8dc778654d | |||
| 5175cccab6 | |||
| 39ae095d9b | |||
| db3ac254ba | |||
| bff9ebf078 | |||
| 42ddadec5b | |||
| c6c1599e14 | |||
| 77b2d645c5 | |||
| 3875335cd7 | |||
| 0a53b85b8a | |||
| 82b6a8bf70 | |||
| 2a72deadd6 | |||
| afefcc9c07 | |||
| 82c79c1306 | |||
| 0e1296e0ea | |||
| 9dd6b54c6e | |||
| 8a5710aff3 | |||
| ce36fba54f | |||
| f601bedb48 | |||
| 801921ff74 | |||
| 67da390371 | |||
| d400cd7b49 | |||
| bf0ba22790 | |||
| f82a713110 | |||
| fedcf2569a | |||
| 1882909b3e | |||
| 226d3f2766 | |||
| 6982d27378 | |||
| 27ef75214e | |||
| 13dba5a303 | |||
| d7f91c8358 | |||
| ee9f0b7382 | |||
| e16eba10d9 | |||
| 1771cc4d13 | |||
| 3bda0a2001 | |||
| 877b1cde11 | |||
| 5faf7015ac | |||
| 55f386d7e8 | |||
| e6a310dc32 | |||
| ccd8f86e96 | |||
| b6d4373933 | |||
| 266620904d | |||
| 479af5741a | |||
| 79c110ff41 | |||
| 3d78974af7 | |||
| 57c5c679ee | |||
| 2781236011 | |||
| 4a002b7db1 | |||
| 8b2668087b | |||
| 3ec9e71de7 | |||
| da7a9cc3c5 | |||
| b5471b7720 | |||
| 6a4ce3dd38 | |||
| df086b09c7 | |||
| 01132a7bc7 | |||
| 182667ecb8 | |||
| c8f4cca340 | |||
| ac9fcd8d30 | |||
| c08c903e8f | |||
| 69bb399b63 |
@@ -13,6 +13,11 @@ SUPPORT_USERNAME=@support
|
||||
# Имя пользователя бота (опционально, автоопределяется)
|
||||
# BOT_USERNAME=
|
||||
|
||||
# ===== SOCKS5 ПРОКСИ =====
|
||||
# URL SOCKS5 прокси-сервера для маршрутизации трафика бота к Telegram API
|
||||
# Формат: socks5://user:password@host:port или socks5://host:port
|
||||
# PROXY_URL=socks5://127.0.0.1:1080
|
||||
|
||||
# ===== СИСТЕМА ПОДДЕРЖКИ =====
|
||||
# Включить меню поддержки в интерфейсе
|
||||
SUPPORT_MENU_ENABLED=true
|
||||
@@ -194,6 +199,9 @@ REMNAWAVE_WEBHOOK_PATH=/remnawave-webhook
|
||||
# Сгенерируйте: openssl rand -hex 32
|
||||
# ВАЖНО: этот же секрет указывается в панели Remnawave при создании вебхука
|
||||
REMNAWAVE_WEBHOOK_SECRET=
|
||||
# Уведомления администраторам о потере/восстановлении связи с нодами
|
||||
# false = не отправлять события node.connection_lost / node.connection_restored
|
||||
REMNAWAVE_WEBHOOK_NOTIFY_NODE_CONNECTION_STATUS=true
|
||||
|
||||
# ===== УВЕДОМЛЕНИЯ ОТ ВЕБХУКОВ (что получают пользователи) =====
|
||||
# Глобальный переключатель уведомлений пользователям от вебхуков
|
||||
@@ -514,6 +522,7 @@ NALOGO_STORAGE_PATH=./nalogo_tokens.json # Путь к файлу с токен
|
||||
NALOGO_QUEUE_CHECK_INTERVAL=300 # Интервал проверки очереди чеков (секунды)
|
||||
NALOGO_QUEUE_RECEIPT_DELAY=3 # Задержка между отправкой чеков (секунды)
|
||||
NALOGO_QUEUE_MAX_ATTEMPTS=10 # Максимум попыток отправки одного чека
|
||||
# NALOGO_PROXY_URL=socks5://127.0.0.1:1080 # SOCKS прокси для nalog.ru (если не задан — используется PROXY_URL)
|
||||
|
||||
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.35.0"
|
||||
".": "3.40.0"
|
||||
}
|
||||
|
||||
+122
@@ -1,5 +1,127 @@
|
||||
# Changelog
|
||||
|
||||
## [3.40.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.39.0...v3.40.0) (2026-03-22)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* allow inactive tariffs for trial subscription activation ([cce3b0c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cce3b0c13bcbf0b567bd4dcf2670973382e7cab0))
|
||||
* custom broadcast buttons and fix home button to use bot menu ([13ea376](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/13ea3768b516337c4e0320120bc60a9acb27a16b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* accept stale Telegram initData to prevent MiniApp auth failures ([4c2cb63](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c2cb63cf9f71fb392c3723a99e88ca3d02b127d))
|
||||
* daily subscription pause not persisting in cabinet and miniapp ([d3c9940](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d3c994083e3b054d02d4911172968c914724d051))
|
||||
* handle spurious user.deleted webhooks — preserve active subscriptions and prevent orphaned panel users ([9eab802](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9eab80200006e576967204b52f90bf9866875917))
|
||||
* prevent MESSAGE_TOO_LONG in promo groups list ([c307278](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c30727823169159b4b6b61f54897b209ced8dfd2))
|
||||
* referral system — self-referral protection, race condition fix, deleted user re-registration ([ed5a92a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ed5a92ab966dac54c15217050eae87f4b05eed62))
|
||||
* sanitize email dots in RemnaWave username generation ([6c20858](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6c208581d936f5ab7d6b978baafd50881b8ce9f1))
|
||||
* send DISABLED instead of EXPIRED status to RemnaWave API ([79cfcbc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79cfcbcece3938f2daa83206f96ec1bffd0857e0))
|
||||
|
||||
## [3.39.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.38.0...v3.39.0) (2026-03-21)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add NaloGO fiscal receipts for code-only gift purchases ([90209eb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/90209ebef1a872665e622124a1898d52eff398e7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add NaloGO fiscal receipt creation for landing page purchases ([4244962](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/424496233773b4cee4e389a1172e95208b3afeaf))
|
||||
* manual admin top-ups missing from sales statistics ([ab43e74](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ab43e74ab7484f8d3517f91e366ea395e1944b99))
|
||||
* skip non-JSON payload rows in cryptobot payment index and query ([ba79d03](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba79d03e389afed972296fe2bc05104aa6b883f3))
|
||||
|
||||
## [3.38.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.37.0...v3.38.0) (2026-03-21)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add SOCKS proxy support for nalogo (tax service) module ([3c5bf4f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3c5bf4fa22d1cdf144269f4e6ab32a4523c8f1f3))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add diagnostic payload logging in create_user error path ([4990ddf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4990ddf9e46495b65fc3638ea8d6bed0cbe6b857))
|
||||
* retry Remnawave API calls without externalSquadUuid on A039 FK violation ([de00612](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/de006129657ce3dac2b1f2fc0ab1b91e23e44241))
|
||||
* sanitize proxy credentials in all nalogo error paths ([3bf3105](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3bf31055e71ff64e6a6d94486bb7f7775ac7dc91))
|
||||
|
||||
## [3.37.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.36.1...v3.37.0) (2026-03-21)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add SOCKS5 proxy support for Telegram API traffic ([82b6a8b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/82b6a8bf707736541b58637b0fc9a84b0c403a6c))
|
||||
* broadcast caption validation + landing daily created stats ([d400cd7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d400cd7b49cb18edf8545a5af54009561610218a))
|
||||
* expose cabinet_email/password vars in subscription delivered template admin UI ([f82a713](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f82a713110c90494e41f2de9910f5dc60a06962e))
|
||||
* include cabinet credentials in subscription delivered email ([fedcf25](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fedcf2569a153ac40699dd5b402208ac86db0fc3))
|
||||
* show both bot and cabinet referral links everywhere ([67da390](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/67da3903715e97c0d84d0018efdd74b085ee8720))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* default payment_method to BALANCE for bot subscription payments ([226d3f2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/226d3f2766bb0842e5a4aefb1458a9e40389108e))
|
||||
* derive income_today from revenue_chart to ensure consistency ([1882909](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1882909b3e60f4959921ae085e909bc91c0756b3))
|
||||
* include landing page revenue in dashboard statistics ([c6c1599](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c6c1599e14a8c7033dfa399ff3f2c3ee6d156598))
|
||||
* include SUBSCRIPTION_PAYMENT in dashboard revenue ([5f71eaa](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f71eaa92604faa2c6928994bd32a9c6c8ae7ae5))
|
||||
* include SUBSCRIPTION_PAYMENT in dashboard revenue calculations ([13dba5a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/13dba5a303604f8d39e85d1382a4d36225f957ac))
|
||||
* include SUBSCRIPTION_PAYMENT in dashboard revenue calculations ([d7f91c8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d7f91c83584384ef5d0fc90a28debc722b2db79f))
|
||||
* include SUBSCRIPTION_PAYMENT in recent payments today/week totals ([6982d27](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6982d27378d0dbe5c7b37d1491e6c9d0461dffc8))
|
||||
* include SUBSCRIPTION_PAYMENT in sales summary and deposits stats ([27ef752](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/27ef75214e73211a68d541f5375ca14275ea86e9))
|
||||
* increase landing purchase rate limit from 5 to 30 req/min ([801921f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/801921ff74107daccc17e49a0335c7b133268280))
|
||||
* narrow exception handling and fix session leak in gift.py ([3875335](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3875335cd7083115043d22f8726f0d33a225cd10))
|
||||
* prevent bootstrap from reactivating revoked superadmin roles ([9dd6b54](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9dd6b54c6e963d2a842d4ac48c9a463014306ed7))
|
||||
* prevent double balance credit on concurrent Platega webhooks ([0e1296e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0e1296e0ea291ee661aa82508d5eb8049ce23cac))
|
||||
* prevent double-payment TOCTOU race in all payment providers ([82c79c1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/82c79c130601736cd149350f761dcceacfb7c2db))
|
||||
* resolve EmailService stale SMTP config causing NoneType crash on from_email ([2a72dea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2a72deadd6ac3baea15a8d4ee414d763c16690b0))
|
||||
* resolve remaining TOCTOU issues in RioPay, SeverPay and restore paid_at ([afefcc9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/afefcc9c07a80ecf65871bc2d0fbcfe13ffc24fd))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* centralize Bot instantiation via create_bot() factory ([0a53b85](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0a53b85b8a3193f67e148c0f2bde246cc92f010c))
|
||||
|
||||
## [3.36.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.36.0...v3.36.1) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* handle duplicate admin roles in RBAC bootstrap ([877b1cd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/877b1cde11c462c0cf5692119ca09944476a2fb6))
|
||||
* make migration 0042 idempotent for retry_count column ([5faf701](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5faf7015ac043f44366fd2a5e00be19eab76b945))
|
||||
|
||||
## [3.36.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.35.0...v3.36.0) (2026-03-20)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add media attachment support for admin ticket replies ([69bb399](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/69bb399b63d6e1d761cc5518e6272917fc5a6ae7))
|
||||
* add multi-provider recovery, retry_count, amount verification, and indexes ([3d78974](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3d78974af70b360449d9cf634e09a79821cdc7c0))
|
||||
* add partner → campaign edges to referral network graph ([01132a7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/01132a7bc77b07eaaaf876c05d639bfed83e5324))
|
||||
* add referral network graph visualization admin API ([c08c903](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c08c903e8f94f3730872b19de904ce166ba35b98))
|
||||
* add scoped referral network graph with scope selector API ([df086b0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/df086b09c75a9157cdc558fb610b617a2d49deaf))
|
||||
* multi-select scope for referral network graph API ([6a4ce3d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6a4ce3dd38dc3cf2e9db08322093bfd0b84f1e1c))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address review findings for guest purchase recovery ([57c5c67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/57c5c679eef987e9bacee1a9d420ac3c0ff69ff7))
|
||||
* address review findings for multi-provider recovery ([79c110f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79c110ff41659ff225164c13690bafc859212d05))
|
||||
* allow repeated auto-assignment of promo groups on each purchase ([4a002b7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4a002b7db1bfa8fd1149b0e3dfa469e8912a0e3b))
|
||||
* correct revenue calculations in referral network ([c8f4cca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c8f4cca34053713eb2793bbb82e74cbb9a6f893c))
|
||||
* improve referral network query correctness and cleanup ([ac9fcd8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ac9fcd8d30dd64fdc7363e689e9ffaf03700976e))
|
||||
* prevent duplicate promo groups during auto-assignment after purchase ([da7a9cc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da7a9cc3c5fd771b932a2bfd154400f6f923a4d1))
|
||||
* prevent guest purchases from getting stuck in PENDING/FAILED status ([2781236](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2781236011942e794949544b9c5422aa8679e5eb))
|
||||
* prevent premature commits in promocode promo group operations ([8b26680](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8b2668087b4831c08474846b20591b2158d607fc))
|
||||
* propagate exceptions from get_primary_user_promo_group ([3ec9e71](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3ec9e71de7d8d40e969f9ea738455445d04d983a))
|
||||
* use 'kassa_ai' base model name for guest metadata patch ([182667e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/182667ecb86f9bbbe87d640865dcbcaadf9e72f6))
|
||||
* use base model name for KassaAI guest metadata patch ([b6d4373](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b6d43739337cd2e2cfd61ac33398eb6def5b28b6))
|
||||
|
||||
|
||||
### Performance
|
||||
|
||||
* add covering indexes for referral network queries ([b5471b7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b5471b7720213c217fc452dc1234a7d3c53447d5))
|
||||
|
||||
## [3.35.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.34.1...v3.35.0) (2026-03-18)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.35.0" # x-release-please-version
|
||||
ARG VERSION="v3.40.0" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -41,21 +41,21 @@ Bedolaga — полнофункциональная платформа для п
|
||||
|
||||
### 📦 Подписки и тарифы
|
||||
|
||||
- 🎯 Гибкие тарифные планы (от 14 дней до года)
|
||||
- 📊 Трафик: безлимит, фиксированный лимит или пакеты
|
||||
- 📱 Управление устройствами (1–20 на подписку)
|
||||
- 🌍 Автовыбор сервера или ручной выбор
|
||||
- 🆓 Пробный период с конвертацией в платный
|
||||
- 🎯 Гибкие тарифные планы (от X дней до X дней)
|
||||
- 📊 Трафик: безлимит, фиксированный лимит или пакеты с возможностью докупки
|
||||
- 📱 Управление устройствами (1–20 на подписку) или отключение лимитов
|
||||
- 🌍 Автовыбор сервера(Тарифы) или ручной выбор(Конфигуратор подписки - с возможностью докупки)
|
||||
- 🆓 Пробный период(Возможен платный) с конвертацией в платный
|
||||
- 🛒 Умная корзина — сохраняет выбор при недостатке баланса
|
||||
- 🔄 Автопродление за 3 дня до окончания
|
||||
- 🎁 Подарочные подписки
|
||||
- 🎁 Подарочные подписки и конфигурируемые лендинги для быстрой продажи в вебе без авторизации
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
### 💳 Платежи
|
||||
|
||||
- 🏦 **14 платёжных провайдеров** одновременно
|
||||
- 🏦 **15 платёжных провайдеров** одновременно
|
||||
- 💰 Единый баланс: пополнение любым способом → покупка с баланса
|
||||
- ⚡ Автопокупка подписки после пополнения
|
||||
- 💾 Рекуррентные платежи (сохранённые карты)
|
||||
@@ -72,11 +72,13 @@ Bedolaga — полнофункциональная платформа для п
|
||||
|
||||
- 🏷 Промокоды (деньги, дни подписки, триалы)
|
||||
- 👥 Реферальная программа с выводом средств
|
||||
- 👥 Партнерская система
|
||||
- 📨 Рассылки по сегментам пользователей
|
||||
- 🌐 Кастомные лендинги с аналитикой
|
||||
- 🎮 Конкурсы и ежедневные игры с призами
|
||||
- 🎯 Персональные предложения и скидки
|
||||
- 📈 Маркетинговые кампании с трекингом
|
||||
- 🌐 Обязательная мультиподписка на каналы с возможностью автоотключения подписки - при отписки от канала
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
@@ -91,6 +93,9 @@ Bedolaga — полнофункциональная платформа для п
|
||||
- 📡 Мониторинг трафика и аномалий
|
||||
- 🤝 Партнёрская программа
|
||||
- 🔐 RBAC: роли и гранулярные права доступа
|
||||
- 📈 Детальная отчетность с возможностью визуализации Реф сети
|
||||
- 🔐 Блокировка юзеров из общего черного списка
|
||||
И многое др...
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
+14
-3
@@ -96,10 +96,21 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
except Exception as e:
|
||||
logger.warning('Кеш не инициализирован', error=e)
|
||||
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
bot = create_bot()
|
||||
|
||||
proxy_url = settings.get_proxy_url()
|
||||
nalogo_proxy_url = settings.get_nalogo_proxy_url()
|
||||
|
||||
if proxy_url or nalogo_proxy_url:
|
||||
from app.utils.proxy import mask_proxy_url
|
||||
|
||||
if proxy_url:
|
||||
logger.info('Proxy configured', proxy_url=mask_proxy_url(proxy_url))
|
||||
if nalogo_proxy_url:
|
||||
source = 'NALOGO_PROXY_URL' if settings.NALOGO_PROXY_URL else 'PROXY_URL (fallback)'
|
||||
logger.info('Nalogo proxy configured', proxy_url=mask_proxy_url(nalogo_proxy_url), source=source)
|
||||
|
||||
maintenance_service.set_bot(bot)
|
||||
logger.info('Бот установлен в maintenance_service')
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Factory for creating Bot instances with proxy support."""
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def create_bot(token: str | None = None, **kwargs) -> Bot:
|
||||
"""Create a Bot instance with SOCKS5 proxy session if PROXY_URL is configured."""
|
||||
proxy_url = settings.get_proxy_url()
|
||||
session = None
|
||||
if proxy_url:
|
||||
from aiogram.client.session.aiohttp import AiohttpSession
|
||||
|
||||
session = AiohttpSession(proxy=proxy_url)
|
||||
|
||||
kwargs.setdefault('default', DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
return Bot(token=token or settings.BOT_TOKEN, session=session, **kwargs)
|
||||
@@ -49,7 +49,17 @@ def validate_telegram_login_widget(data: dict[str, Any], max_age_seconds: int =
|
||||
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
|
||||
age = (datetime.now(UTC) - auth_time).total_seconds()
|
||||
if age > max_age_seconds or age < -_MAX_CLOCK_SKEW_SECONDS:
|
||||
logger.warning(
|
||||
'Telegram widget auth rejected: too old',
|
||||
age_hours=round(age / 3600, 1),
|
||||
max_age_hours=round(max_age_seconds / 3600, 1),
|
||||
)
|
||||
return False
|
||||
if age > 86400:
|
||||
logger.info(
|
||||
'Telegram widget auth accepted with stale auth_date',
|
||||
age_hours=round(age / 3600, 1),
|
||||
)
|
||||
except (ValueError, TypeError, OSError):
|
||||
return False
|
||||
|
||||
@@ -96,7 +106,17 @@ def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) ->
|
||||
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
|
||||
age = (datetime.now(UTC) - auth_time).total_seconds()
|
||||
if age > max_age_seconds or age < -_MAX_CLOCK_SKEW_SECONDS:
|
||||
logger.warning(
|
||||
'Telegram initData rejected: too old',
|
||||
age_hours=round(age / 3600, 1),
|
||||
max_age_hours=round(max_age_seconds / 3600, 1),
|
||||
)
|
||||
return None
|
||||
if age > 86400:
|
||||
logger.info(
|
||||
'Telegram initData accepted with stale auth_date (Telegram caching bug)',
|
||||
age_hours=round(age / 3600, 1),
|
||||
)
|
||||
except (ValueError, TypeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from .admin_pinned_messages import router as admin_pinned_messages_router
|
||||
from .admin_policies import router as admin_policies_router
|
||||
from .admin_promo_offers import router as admin_promo_offers_router
|
||||
from .admin_promocodes import promo_groups_router as admin_promo_groups_router, router as admin_promocodes_router
|
||||
from .admin_referral_network import router as admin_referral_network_router
|
||||
from .admin_remnawave import router as admin_remnawave_router
|
||||
from .admin_roles import router as admin_roles_router
|
||||
from .admin_sales_stats import router as admin_sales_stats_router
|
||||
@@ -99,6 +100,7 @@ router.include_router(admin_wheel_router)
|
||||
router.include_router(admin_tariffs_router)
|
||||
router.include_router(admin_servers_router)
|
||||
router.include_router(admin_stats_router)
|
||||
router.include_router(admin_referral_network_router)
|
||||
router.include_router(admin_sales_stats_router)
|
||||
router.include_router(admin_ban_system_router)
|
||||
router.include_router(admin_broadcasts_router)
|
||||
|
||||
@@ -487,7 +487,8 @@ async def link_telegram(
|
||||
|
||||
if request.init_data:
|
||||
# Mini App flow: validate initData
|
||||
user_data = validate_telegram_init_data(request.init_data)
|
||||
# Generous max_age: Telegram Desktop/iOS cache initData with stale auth_date
|
||||
user_data = validate_telegram_init_data(request.init_data, max_age_seconds=86400 * 30)
|
||||
if not user_data or not user_data.get('id'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -560,7 +561,8 @@ async def link_telegram(
|
||||
if request.photo_url is not None:
|
||||
widget_data['photo_url'] = request.photo_url
|
||||
|
||||
if not validate_telegram_login_widget(widget_data):
|
||||
# Generous max_age: Telegram caches auth data with stale auth_date
|
||||
if not validate_telegram_login_widget(widget_data, max_age_seconds=86400 * 30):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid or expired Telegram Login Widget data',
|
||||
|
||||
@@ -411,6 +411,13 @@ async def create_broadcast(
|
||||
|
||||
media_payload = request.media
|
||||
|
||||
# Validate caption length for media messages (Telegram limit: 1024 chars)
|
||||
if media_payload and len(message_text) > 1024:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Текст слишком длинный для сообщения с медиа. Максимум 1024 символов, сейчас {len(message_text)}. Сократите текст или уберите медиафайл.',
|
||||
)
|
||||
|
||||
# Create broadcast record
|
||||
broadcast = BroadcastHistory(
|
||||
target_type=request.target,
|
||||
@@ -446,6 +453,7 @@ async def create_broadcast(
|
||||
selected_buttons=request.selected_buttons,
|
||||
media=media_config,
|
||||
initiator_name=admin.username or f'Admin #{admin.id}',
|
||||
custom_buttons=[btn.model_dump() for btn in request.custom_buttons] if request.custom_buttons else None,
|
||||
)
|
||||
|
||||
# Start broadcast
|
||||
@@ -644,6 +652,7 @@ async def create_combined_broadcast(
|
||||
selected_buttons=request.selected_buttons,
|
||||
media=media_config,
|
||||
initiator_name=admin_name,
|
||||
custom_buttons=[btn.model_dump() for btn in request.custom_buttons] if request.custom_buttons else None,
|
||||
)
|
||||
|
||||
await broadcast_service.start_broadcast(broadcast.id, telegram_config)
|
||||
|
||||
@@ -312,7 +312,7 @@ TEMPLATE_TYPES = [
|
||||
'zh': '通过落地页成功付款后发送给买家的邮件',
|
||||
'ua': 'Лист покупцю після успішної оплати через лендінг',
|
||||
},
|
||||
'context_vars': ['tariff_name', 'period_days', 'cabinet_url'],
|
||||
'context_vars': ['tariff_name', 'period_days', 'cabinet_url', 'cabinet_email', 'cabinet_password'],
|
||||
},
|
||||
{
|
||||
'type': 'guest_activation_required',
|
||||
@@ -425,6 +425,8 @@ SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = {
|
||||
'tariff_name': 'Premium',
|
||||
'period_days': 30,
|
||||
'cabinet_url': 'https://example.com/cabinet',
|
||||
'cabinet_email': 'user@example.com',
|
||||
'cabinet_password': 'SecurePass123',
|
||||
},
|
||||
'guest_activation_required': {
|
||||
'tariff_name': 'Premium',
|
||||
|
||||
@@ -483,6 +483,7 @@ class OrderRequest(BaseModel):
|
||||
|
||||
class LandingDailyStat(BaseModel):
|
||||
date: str # YYYY-MM-DD
|
||||
created: int = 0
|
||||
purchases: int
|
||||
revenue_kopeks: int
|
||||
gifts: int
|
||||
@@ -844,17 +845,35 @@ async def get_landing_stats(
|
||||
)
|
||||
daily_rows = {str(r.day): r for r in daily_result.all()}
|
||||
|
||||
# Created per day (all statuses, by created_at)
|
||||
day_created_utc = func.date(func.timezone('UTC', GuestPurchase.created_at))
|
||||
created_result = await db.execute(
|
||||
select(
|
||||
day_created_utc.label('day'),
|
||||
func.count(GuestPurchase.id).label('created'),
|
||||
)
|
||||
.where(
|
||||
GuestPurchase.landing_id == landing_id,
|
||||
GuestPurchase.created_at >= cutoff,
|
||||
)
|
||||
.group_by(day_created_utc)
|
||||
.order_by(day_created_utc)
|
||||
)
|
||||
created_rows = {str(r.day): r.created for r in created_result.all()}
|
||||
|
||||
# Fill missing days with zeros
|
||||
today = now.date()
|
||||
daily_stats: list[LandingDailyStat] = []
|
||||
for i in range(_STATS_PERIOD_DAYS, -1, -1):
|
||||
day = today - timedelta(days=i)
|
||||
day_str = day.isoformat()
|
||||
day_created = created_rows.get(day_str, 0)
|
||||
if day_str in daily_rows:
|
||||
r = daily_rows[day_str]
|
||||
daily_stats.append(
|
||||
LandingDailyStat(
|
||||
date=day_str,
|
||||
created=day_created,
|
||||
purchases=r.purchases,
|
||||
revenue_kopeks=r.revenue_kopeks,
|
||||
gifts=r.gifts,
|
||||
@@ -864,6 +883,7 @@ async def get_landing_stats(
|
||||
daily_stats.append(
|
||||
LandingDailyStat(
|
||||
date=day_str,
|
||||
created=day_created,
|
||||
purchases=0,
|
||||
revenue_kopeks=0,
|
||||
gifts=0,
|
||||
@@ -897,7 +917,7 @@ async def get_landing_stats(
|
||||
]
|
||||
|
||||
return LandingStatsResponse(
|
||||
total_purchases=total_successful,
|
||||
total_purchases=total_created,
|
||||
total_revenue_kopeks=total_revenue_kopeks,
|
||||
total_gifts=total_gifts,
|
||||
total_regular=total_regular,
|
||||
|
||||
@@ -227,8 +227,7 @@ async def approve_application(
|
||||
|
||||
# Notify user about approval
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.config import settings
|
||||
from app.services.notification_delivery_service import notification_delivery_service
|
||||
|
||||
@@ -240,7 +239,7 @@ async def approve_application(
|
||||
tg_message = (
|
||||
f'✅ Ваша заявка на партнёрство одобрена!\nКомиссия: {request.commission_percent}%{comment_text}'
|
||||
)
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
await notification_delivery_service.notify_partner_approved(
|
||||
user=user,
|
||||
@@ -280,8 +279,7 @@ async def reject_application(
|
||||
|
||||
# Notify user about rejection
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.config import settings
|
||||
from app.services.notification_delivery_service import notification_delivery_service
|
||||
|
||||
@@ -291,7 +289,7 @@ async def reject_application(
|
||||
if user:
|
||||
comment_text = f'\nПричина: {request.comment}' if request.comment else ''
|
||||
tg_message = f'❌ Ваша заявка на партнёрство отклонена.{comment_text}'
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
await notification_delivery_service.notify_partner_rejected(
|
||||
user=user,
|
||||
|
||||
@@ -4,14 +4,11 @@ import math
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.bot_factory import create_bot
|
||||
from app.database.models import PaymentMethod, User
|
||||
from app.services.payment_search_service import (
|
||||
MAX_ALL_TIME_DAYS,
|
||||
@@ -550,7 +547,7 @@ async def check_payment_status(
|
||||
old_is_paid = record.is_paid
|
||||
|
||||
# Run manual check
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
bot = create_bot()
|
||||
try:
|
||||
payment_service = PaymentService(bot=bot)
|
||||
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
|
||||
|
||||
@@ -5,13 +5,11 @@ from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.bot_factory import create_bot
|
||||
from app.database.models import PinnedMessage, User
|
||||
from app.services.pinned_message_service import (
|
||||
broadcast_pinned_message,
|
||||
@@ -77,10 +75,7 @@ _cached_bot: Bot | None = None
|
||||
def _get_bot() -> Bot:
|
||||
global _cached_bot
|
||||
if _cached_bot is None:
|
||||
_cached_bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
_cached_bot = create_bot()
|
||||
return _cached_bot
|
||||
|
||||
|
||||
|
||||
@@ -8,15 +8,13 @@ from typing import Any
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.bot_factory import create_bot
|
||||
from app.database.crud.discount_offer import (
|
||||
count_discount_offers,
|
||||
list_discount_offers,
|
||||
@@ -369,10 +367,7 @@ async def list_offers(
|
||||
|
||||
def _get_bot() -> Bot:
|
||||
"""Create bot instance for sending notifications."""
|
||||
return Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
return create_bot()
|
||||
|
||||
|
||||
def _build_default_promo_message(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,12 +4,13 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.rbac import AdminRoleCRUD, UserRoleCRUD
|
||||
from app.database.crud.rbac import SUPERADMIN_LEVEL, AdminRoleCRUD, UserRoleCRUD
|
||||
from app.database.models import User
|
||||
from app.services.permission_service import PERMISSION_REGISTRY, get_all_permissions
|
||||
|
||||
@@ -129,21 +130,26 @@ async def _role_to_response(db: AsyncSession, role) -> RoleResponse:
|
||||
|
||||
|
||||
async def _get_admin_level(db: AsyncSession, admin: User) -> int:
|
||||
"""Get the maximum role level of the current admin.
|
||||
"""Get the effective management level of the current admin.
|
||||
|
||||
Legacy config-based admins (ADMIN_IDS) get superadmin level (999+1=1000)
|
||||
so they can manage all roles including level 999.
|
||||
Superadmin-tier users (DB level 999 or legacy ADMIN_IDS) are promoted to
|
||||
level 1000 so they can manage peer Superadmins. Without this, the ``>=``
|
||||
hierarchy guard would block 999-vs-999 operations.
|
||||
"""
|
||||
from app.config import settings
|
||||
|
||||
_perms, _names, max_level = await UserRoleCRUD.get_user_permissions(db, admin.id)
|
||||
|
||||
# DB-assigned Superadmins can manage peers
|
||||
if max_level >= SUPERADMIN_LEVEL:
|
||||
max_level = SUPERADMIN_LEVEL + 1
|
||||
|
||||
# Legacy config-based admins always get the highest level
|
||||
if settings.is_admin(
|
||||
telegram_id=admin.telegram_id,
|
||||
email=admin.email if admin.email_verified else None,
|
||||
):
|
||||
max_level = max(max_level, 1000)
|
||||
max_level = max(max_level, SUPERADMIN_LEVEL + 1)
|
||||
|
||||
return max_level
|
||||
|
||||
@@ -339,6 +345,15 @@ async def update_role(
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
|
||||
# System roles: only permissions can be extended, block is_active/level changes
|
||||
if role.is_system:
|
||||
blocked = {'is_active', 'level'} & update_data.keys()
|
||||
if blocked:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f'Cannot change {", ".join(sorted(blocked))} on a system role',
|
||||
)
|
||||
|
||||
# Validate level change
|
||||
if 'level' in update_data and update_data['level'] >= admin_level:
|
||||
raise HTTPException(
|
||||
@@ -435,6 +450,13 @@ async def assign_role(
|
||||
detail='Cannot assign a role with level >= your own role level',
|
||||
)
|
||||
|
||||
# Superadmin assignments must be permanent — expiry would cause silent lockout
|
||||
if role.level == SUPERADMIN_LEVEL and payload.expires_at is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Superadmin role assignments cannot be time-limited',
|
||||
)
|
||||
|
||||
# Verify target user exists
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
@@ -484,12 +506,12 @@ async def revoke_role(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Revoke a role assignment. Cannot remove the last superadmin."""
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.models import UserRole
|
||||
|
||||
# Load the assignment to check hierarchy
|
||||
result = await db.execute(sa_select(UserRole).where(UserRole.id == assignment_id))
|
||||
# Lock the assignment row (FOR UPDATE held until commit)
|
||||
result = await db.execute(sa.select(UserRole).where(UserRole.id == assignment_id).with_for_update())
|
||||
user_role = result.scalar_one_or_none()
|
||||
if not user_role:
|
||||
raise HTTPException(
|
||||
@@ -513,9 +535,19 @@ async def revoke_role(
|
||||
detail='Cannot revoke a role at or above your own level',
|
||||
)
|
||||
|
||||
# Protect last superadmin (level 999)
|
||||
superadmin_level = 999
|
||||
if role.level == superadmin_level:
|
||||
# Block self-revocation of superadmin role
|
||||
if role.level == SUPERADMIN_LEVEL and user_role.user_id == admin.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot revoke your own superadmin role',
|
||||
)
|
||||
|
||||
# Protect last superadmin (level 999).
|
||||
# Advisory lock serializes concurrent superadmin revocations so two requests
|
||||
# cannot both read count=2 and then both proceed to revoke.
|
||||
if role.level == SUPERADMIN_LEVEL:
|
||||
if not settings.is_sqlite():
|
||||
await db.execute(sa.text('SELECT pg_advisory_xact_lock(736453)'))
|
||||
superadmin_count = await UserRoleCRUD.get_superadmin_count(db)
|
||||
if superadmin_count <= 1:
|
||||
raise HTTPException(
|
||||
@@ -523,13 +555,16 @@ async def revoke_role(
|
||||
detail='Cannot remove the last superadmin',
|
||||
)
|
||||
|
||||
revoked = await UserRoleCRUD.revoke_role(db, assignment_id)
|
||||
if not revoked:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to revoke role',
|
||||
)
|
||||
# Warn if target user is a legacy admin — RBAC revocation won't actually block access
|
||||
target_user = await get_user_by_id(db, user_role.user_id)
|
||||
is_target_legacy = target_user and settings.is_admin(
|
||||
telegram_id=target_user.telegram_id,
|
||||
email=target_user.email if target_user.email_verified else None,
|
||||
)
|
||||
|
||||
# Revoke directly on the locked object (avoid CRUD re-fetch without FOR UPDATE)
|
||||
user_role.is_active = False
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
@@ -539,4 +574,11 @@ async def revoke_role(
|
||||
target_user_id=user_role.user_id,
|
||||
role_name=role.name,
|
||||
)
|
||||
return {'message': 'Role revoked', 'assignment_id': assignment_id}
|
||||
|
||||
result_msg = {'message': 'Role revoked', 'assignment_id': assignment_id}
|
||||
if is_target_legacy:
|
||||
result_msg['warning'] = (
|
||||
'This user is still listed in ADMIN_IDS/ADMIN_EMAILS env config. '
|
||||
'They retain full access until removed from those settings and the bot is restarted.'
|
||||
)
|
||||
return result_msg
|
||||
|
||||
@@ -112,11 +112,11 @@ async def get_sales_summary(
|
||||
try:
|
||||
period_start, period_end = _parse_period(days, start_date, end_date)
|
||||
|
||||
# Total revenue (deposits with real payment methods)
|
||||
# Total revenue (deposits + direct subscription payments with real payment methods)
|
||||
revenue_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
|
||||
Transaction.is_completed == True,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
Transaction.created_at >= period_start,
|
||||
@@ -128,7 +128,7 @@ async def get_sales_summary(
|
||||
|
||||
# Manual top-ups by admins
|
||||
manual_topup_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
@@ -246,7 +246,7 @@ async def get_sales_summary(
|
||||
|
||||
# Add-on revenue
|
||||
addon_revenue_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.is_completed == True,
|
||||
@@ -256,7 +256,7 @@ async def get_sales_summary(
|
||||
)
|
||||
)
|
||||
)
|
||||
addon_revenue = abs(addon_revenue_result.scalar() or 0)
|
||||
addon_revenue = addon_revenue_result.scalar() or 0
|
||||
|
||||
return SalesSummary(
|
||||
total_revenue_kopeks=total_revenue + manual_topup,
|
||||
@@ -1079,7 +1079,7 @@ async def get_deposits_stats(
|
||||
|
||||
methods_with_manual = [*REAL_PAYMENT_METHODS, PaymentMethod.MANUAL.value]
|
||||
base_filter = and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
|
||||
Transaction.is_completed == True,
|
||||
Transaction.payment_method.in_(methods_with_manual),
|
||||
Transaction.created_at >= period_start,
|
||||
@@ -1089,7 +1089,7 @@ async def get_deposits_stats(
|
||||
totals_result = await db.execute(
|
||||
select(
|
||||
func.count(Transaction.id).label('count'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
|
||||
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
|
||||
).where(base_filter)
|
||||
)
|
||||
totals = totals_result.one()
|
||||
@@ -1101,11 +1101,11 @@ async def get_deposits_stats(
|
||||
select(
|
||||
Transaction.payment_method.label('method'),
|
||||
func.count(Transaction.id).label('count'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
|
||||
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
|
||||
)
|
||||
.where(base_filter)
|
||||
.group_by(Transaction.payment_method)
|
||||
.order_by(func.sum(Transaction.amount_kopeks).desc())
|
||||
.order_by(func.sum(func.abs(Transaction.amount_kopeks)).desc())
|
||||
)
|
||||
by_method = [
|
||||
DepositByMethodItem(method=row.method or 'unknown', count=row.count, amount_kopeks=row.amount)
|
||||
@@ -1116,7 +1116,7 @@ async def get_deposits_stats(
|
||||
select(
|
||||
func.date(Transaction.created_at).label('date'),
|
||||
func.count(Transaction.id).label('count'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
|
||||
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
|
||||
)
|
||||
.where(base_filter)
|
||||
.group_by(func.date(Transaction.created_at))
|
||||
@@ -1137,7 +1137,7 @@ async def get_deposits_stats(
|
||||
select(
|
||||
func.date(Transaction.created_at).label('date'),
|
||||
Transaction.payment_method.label('method'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
|
||||
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
|
||||
)
|
||||
.where(base_filter)
|
||||
.group_by(func.date(Transaction.created_at), Transaction.payment_method)
|
||||
|
||||
@@ -275,6 +275,14 @@ async def get_dashboard_stats(
|
||||
# Get tariff statistics
|
||||
tariff_stats = await _get_tariff_stats(db)
|
||||
|
||||
# Derive income_today from revenue_chart to ensure consistency with chart
|
||||
today_str = now.date().isoformat()
|
||||
income_today_from_chart = sum(
|
||||
item.get('amount_kopeks', 0) for item in revenue_data if str(item.get('date', '')) == today_str
|
||||
)
|
||||
# Use chart-derived value if available, otherwise fall back to trans_stats
|
||||
income_today_kopeks = income_today_from_chart or trans_stats.get('today', {}).get('income_kopeks', 0)
|
||||
|
||||
# Build response
|
||||
return DashboardStats(
|
||||
nodes=nodes_data,
|
||||
@@ -290,8 +298,8 @@ async def get_dashboard_stats(
|
||||
trial_to_paid_conversion=sub_stats.get('trial_to_paid_conversion', 0.0),
|
||||
),
|
||||
financial=FinancialStats(
|
||||
income_today_kopeks=trans_stats.get('today', {}).get('income_kopeks', 0),
|
||||
income_today_rubles=trans_stats.get('today', {}).get('income_kopeks', 0) / 100,
|
||||
income_today_kopeks=income_today_kopeks,
|
||||
income_today_rubles=income_today_kopeks / 100,
|
||||
income_month_kopeks=trans_stats.get('totals', {}).get('income_kopeks', 0),
|
||||
income_month_rubles=trans_stats.get('totals', {}).get('income_kopeks', 0) / 100,
|
||||
income_total_kopeks=all_time_stats.get('totals', {}).get('income_kopeks', 0),
|
||||
@@ -926,9 +934,9 @@ async def get_recent_payments(
|
||||
total_count = total_count_result.scalar() or 0
|
||||
|
||||
today_total_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= today_start,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
@@ -938,9 +946,9 @@ async def get_recent_payments(
|
||||
total_today = today_total_result.scalar() or 0
|
||||
|
||||
week_total_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= week_ago,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -90,6 +90,19 @@ class AdminReplyRequest(BaseModel):
|
||||
"""Admin reply to ticket."""
|
||||
|
||||
message: str = Field(..., min_length=1, max_length=4000, description='Reply message')
|
||||
media_type: str | None = Field(None, description='Media type: photo, video, or document')
|
||||
media_file_id: str | None = Field(None, max_length=255, description='Telegram file_id from media upload')
|
||||
media_caption: str | None = Field(None, max_length=1000, description='Caption for media')
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_media_fields(self) -> 'AdminReplyRequest':
|
||||
if self.media_file_id and not self.media_type:
|
||||
raise ValueError('media_type is required when media_file_id is provided')
|
||||
if self.media_type and not self.media_file_id:
|
||||
raise ValueError('media_file_id is required when media_type is provided')
|
||||
if self.media_type and self.media_type not in {'photo', 'video', 'document'}:
|
||||
raise ValueError('media_type must be one of: photo, video, document')
|
||||
return self
|
||||
|
||||
|
||||
class AdminStatusUpdateRequest(BaseModel):
|
||||
@@ -443,11 +456,16 @@ async def reply_to_ticket(
|
||||
)
|
||||
|
||||
# Create admin message
|
||||
has_media = bool(request.media_file_id)
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket.id,
|
||||
user_id=ticket.user_id,
|
||||
message_text=request.message,
|
||||
is_from_admin=True,
|
||||
has_media=has_media,
|
||||
media_type=request.media_type if has_media else None,
|
||||
media_file_id=request.media_file_id if has_media else None,
|
||||
media_caption=request.media_caption if has_media else None,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(message)
|
||||
@@ -461,14 +479,9 @@ async def reply_to_ticket(
|
||||
|
||||
# Try to notify user via Telegram
|
||||
try:
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
bot = create_bot()
|
||||
try:
|
||||
from app.handlers.admin.tickets import notify_user_about_ticket_reply
|
||||
|
||||
|
||||
@@ -7,16 +7,13 @@ import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.types import BufferedInputFile
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.bot_factory import create_bot
|
||||
from app.database.models import Subscription, Transaction, TransactionType, User
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
@@ -680,10 +677,7 @@ async def export_traffic_csv(
|
||||
filename = f'traffic_usage_{period_label}_{timestamp}.csv'
|
||||
|
||||
try:
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
bot = create_bot()
|
||||
async with bot:
|
||||
await bot.send_document(
|
||||
chat_id=admin.telegram_id,
|
||||
|
||||
@@ -29,6 +29,7 @@ from app.database.crud.user import (
|
||||
from app.database.crud.user_promo_group import sync_user_primary_promo_group
|
||||
from app.database.models import (
|
||||
GuestPurchase,
|
||||
PaymentMethod,
|
||||
PromoGroup,
|
||||
ReferralEarning,
|
||||
Subscription,
|
||||
@@ -897,6 +898,7 @@ async def update_user_balance(
|
||||
description=request.description,
|
||||
create_transaction=request.create_transaction,
|
||||
transaction_type=TransactionType.DEPOSIT,
|
||||
payment_method=PaymentMethod.MANUAL,
|
||||
)
|
||||
else:
|
||||
# Subtract balance
|
||||
@@ -912,6 +914,7 @@ async def update_user_balance(
|
||||
amount_kopeks=amount_to_subtract,
|
||||
description=request.description,
|
||||
create_transaction=request.create_transaction,
|
||||
payment_method=PaymentMethod.MANUAL,
|
||||
)
|
||||
|
||||
if not success:
|
||||
|
||||
@@ -199,8 +199,7 @@ async def approve_withdrawal(
|
||||
|
||||
# Notify user about approval
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.config import settings
|
||||
from app.services.notification_delivery_service import notification_delivery_service
|
||||
|
||||
@@ -211,7 +210,7 @@ async def approve_withdrawal(
|
||||
formatted_amount = settings.format_price(withdrawal.amount_kopeks)
|
||||
comment_text = f'\n{request.comment}' if request.comment else ''
|
||||
tg_message = f'✅ Ваш запрос на вывод {formatted_amount} одобрен.{comment_text}'
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
await notification_delivery_service.notify_withdrawal_approved(
|
||||
user=user,
|
||||
@@ -251,8 +250,7 @@ async def reject_withdrawal(
|
||||
|
||||
# Notify user about rejection
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.config import settings
|
||||
from app.services.notification_delivery_service import notification_delivery_service
|
||||
|
||||
@@ -263,7 +261,7 @@ async def reject_withdrawal(
|
||||
formatted_amount = settings.format_price(withdrawal.amount_kopeks)
|
||||
comment_text = f'\nПричина: {request.comment}' if request.comment else ''
|
||||
tg_message = f'❌ Ваш запрос на вывод {formatted_amount} отклонён.{comment_text}'
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
await notification_delivery_service.notify_withdrawal_rejected(
|
||||
user=user,
|
||||
|
||||
+91
-29
@@ -196,12 +196,10 @@ async def _process_campaign_bonus(
|
||||
user.referred_by_id = campaign.partner_user_id
|
||||
await db.flush()
|
||||
try:
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot)
|
||||
async with create_bot() as bot:
|
||||
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot)
|
||||
logger.info(
|
||||
'Referral set from campaign partner',
|
||||
user_id=user.id,
|
||||
@@ -241,11 +239,39 @@ async def _process_referral_code(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
referral_code: str | None,
|
||||
*,
|
||||
is_new_user: bool = False,
|
||||
) -> None:
|
||||
"""Set referred_by_id for user if referral_code is valid. Never raises."""
|
||||
if not referral_code or user.referred_by_id:
|
||||
"""Process referral for a newly created user. Never raises.
|
||||
|
||||
Only applies to new users (is_new_user=True). Existing users cannot be
|
||||
assigned a referrer — same logic as the bot /start handler.
|
||||
|
||||
Handles two cases:
|
||||
- referred_by_id already set by create_user() → fire registration event
|
||||
- referred_by_id not set (resolution failed earlier) → resolve, set, fire
|
||||
"""
|
||||
if not referral_code or not is_new_user:
|
||||
return
|
||||
try:
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
# Lock user row to prevent concurrent referral application (TOCTOU race)
|
||||
await db.execute(select(User).where(User.id == user.id).with_for_update())
|
||||
await db.refresh(user)
|
||||
|
||||
# Case 1: referred_by_id already set by create_user() — just fire the event
|
||||
if user.referred_by_id:
|
||||
async with create_bot() as bot:
|
||||
await process_referral_registration(db, user.id, user.referred_by_id, bot=bot)
|
||||
logger.info(
|
||||
'Referral registration processed for pre-set referrer',
|
||||
user_id=user.id,
|
||||
referrer_id=user.referred_by_id,
|
||||
)
|
||||
return
|
||||
|
||||
# Case 2: referred_by_id not set — resolve referral code and set it
|
||||
referrer = await get_user_by_referral_code(db, referral_code)
|
||||
if not referrer:
|
||||
return
|
||||
@@ -255,12 +281,9 @@ async def _process_referral_code(
|
||||
return
|
||||
user.referred_by_id = referrer.id
|
||||
await db.flush()
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
await process_referral_registration(db, user.id, referrer.id, bot=bot)
|
||||
async with create_bot() as bot:
|
||||
await process_referral_registration(db, user.id, referrer.id, bot=bot)
|
||||
logger.info('Referral applied from code', user_id=user.id, referrer_id=referrer.id, referral_code=referral_code)
|
||||
except Exception as e:
|
||||
logger.error('Failed to process referral code', error=e, referral_code=referral_code)
|
||||
@@ -408,7 +431,11 @@ async def auth_telegram(
|
||||
detail='Too many requests',
|
||||
headers={'Retry-After': '60'},
|
||||
)
|
||||
user_data = validate_telegram_init_data(request.init_data)
|
||||
# Telegram Desktop/iOS cache initData with stale auth_date (known Telegram bug:
|
||||
# https://github.com/telegramdesktop/tdesktop/issues/28303).
|
||||
# Use generous max_age: HMAC signature proves authenticity,
|
||||
# JWT tokens handle actual session expiration after login.
|
||||
user_data = validate_telegram_init_data(request.init_data, max_age_seconds=86400 * 30)
|
||||
|
||||
if not user_data:
|
||||
raise HTTPException(
|
||||
@@ -437,10 +464,19 @@ async def auth_telegram(
|
||||
try:
|
||||
referrer = await get_user_by_referral_code(db, request.referral_code)
|
||||
if referrer:
|
||||
referrer_id = referrer.id
|
||||
# Self-referral protection by telegram_id (user doesn't exist yet, can't compare user.id)
|
||||
if referrer.telegram_id and referrer.telegram_id == telegram_id:
|
||||
logger.warning(
|
||||
'Self-referral attempt blocked via telegram_id',
|
||||
telegram_id=telegram_id,
|
||||
referral_code=request.referral_code,
|
||||
)
|
||||
else:
|
||||
referrer_id = referrer.id
|
||||
except Exception as e:
|
||||
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
|
||||
|
||||
is_new_user = not user
|
||||
if not user:
|
||||
# Create new user from Telegram initData
|
||||
logger.info('Creating new user from cabinet (initData): telegram_id', telegram_id=telegram_id)
|
||||
@@ -484,8 +520,8 @@ async def auth_telegram(
|
||||
# Store refresh token
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
|
||||
# Process referral code (before campaign bonus, which may also set referrer)
|
||||
await _process_referral_code(db, user, request.referral_code)
|
||||
# Process referral code (only for new users — existing users cannot be assigned a referrer)
|
||||
await _process_referral_code(db, user, request.referral_code, is_new_user=is_new_user)
|
||||
|
||||
# Process campaign bonus
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
@@ -518,7 +554,8 @@ async def auth_telegram_widget(
|
||||
|
||||
widget_data = request.model_dump(exclude={'campaign_slug', 'referral_code'})
|
||||
|
||||
if not validate_telegram_login_widget(widget_data):
|
||||
# Generous max_age: Telegram caches auth data with stale auth_date
|
||||
if not validate_telegram_login_widget(widget_data, max_age_seconds=86400 * 30):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Invalid or expired Telegram authentication data',
|
||||
@@ -532,10 +569,19 @@ async def auth_telegram_widget(
|
||||
try:
|
||||
referrer = await get_user_by_referral_code(db, request.referral_code)
|
||||
if referrer:
|
||||
referrer_id = referrer.id
|
||||
# Self-referral protection by telegram_id (user doesn't exist yet, can't compare user.id)
|
||||
if referrer.telegram_id and referrer.telegram_id == request.id:
|
||||
logger.warning(
|
||||
'Self-referral attempt blocked via telegram_id',
|
||||
telegram_id=request.id,
|
||||
referral_code=request.referral_code,
|
||||
)
|
||||
else:
|
||||
referrer_id = referrer.id
|
||||
except Exception as e:
|
||||
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
|
||||
|
||||
is_new_user = not user
|
||||
if not user:
|
||||
# Create new user from Telegram data
|
||||
logger.info(
|
||||
@@ -572,8 +618,8 @@ async def auth_telegram_widget(
|
||||
response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
|
||||
# Process referral code (before campaign bonus, which may also set referrer)
|
||||
await _process_referral_code(db, user, request.referral_code)
|
||||
# Process referral code (only for new users — existing users cannot be assigned a referrer)
|
||||
await _process_referral_code(db, user, request.referral_code, is_new_user=is_new_user)
|
||||
|
||||
# Process campaign bonus
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
@@ -664,10 +710,19 @@ async def auth_telegram_oidc(
|
||||
try:
|
||||
referrer = await get_user_by_referral_code(db, request.referral_code)
|
||||
if referrer:
|
||||
referrer_id = referrer.id
|
||||
except (ValueError, LookupError) as e:
|
||||
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=str(e))
|
||||
# Self-referral protection by telegram_id (user doesn't exist yet, can't compare user.id)
|
||||
if referrer.telegram_id and referrer.telegram_id == telegram_id:
|
||||
logger.warning(
|
||||
'Self-referral attempt blocked via telegram_id',
|
||||
telegram_id=telegram_id,
|
||||
referral_code=request.referral_code,
|
||||
)
|
||||
else:
|
||||
referrer_id = referrer.id
|
||||
except Exception as e:
|
||||
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
|
||||
|
||||
is_new_user = not user
|
||||
if not user:
|
||||
logger.info('Creating new user from cabinet OIDC', telegram_id=telegram_id, username=username)
|
||||
user = await create_user(
|
||||
@@ -701,7 +756,8 @@ async def auth_telegram_oidc(
|
||||
response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
|
||||
await _process_referral_code(db, user, request.referral_code)
|
||||
# Process referral code (only for new users — existing users cannot be assigned a referrer)
|
||||
await _process_referral_code(db, user, request.referral_code, is_new_user=is_new_user)
|
||||
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
if response.campaign_bonus:
|
||||
@@ -937,12 +993,10 @@ async def register_email_standalone(
|
||||
# Обработать реферальную регистрацию (если есть реферер)
|
||||
if referrer:
|
||||
try:
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
await process_referral_registration(db, user.id, referrer.id, bot=bot)
|
||||
async with create_bot() as bot:
|
||||
await process_referral_registration(db, user.id, referrer.id, bot=bot)
|
||||
logger.info(
|
||||
'Processed referral registration: user_id=, referrer_id', user_id=user.id, referrer_id=referrer.id
|
||||
)
|
||||
@@ -1798,6 +1852,14 @@ async def poll_deep_link_token(
|
||||
response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, response.refresh_token, device_info='deep_link')
|
||||
|
||||
# Deep link auth is always for existing users — referral code not applicable
|
||||
# (kept for campaign bonus processing only)
|
||||
|
||||
# Process campaign bonus
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
if response.campaign_bonus:
|
||||
response.user = _user_to_response(user)
|
||||
|
||||
logger.info('Deep link auth successful', user_id=user.id, telegram_id=user.telegram_id)
|
||||
|
||||
return response
|
||||
|
||||
@@ -4,15 +4,12 @@ import math
|
||||
import time
|
||||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.config import settings
|
||||
from app.database.crud.saved_payment_method import (
|
||||
deactivate_payment_method,
|
||||
@@ -272,50 +269,37 @@ async def create_stars_invoice(
|
||||
|
||||
# Create invoice through Telegram Bot API
|
||||
try:
|
||||
bot_token = settings.BOT_TOKEN
|
||||
api_url = f'https://api.telegram.org/bot{bot_token}/createInvoiceLink'
|
||||
from aiogram.exceptions import TelegramAPIError
|
||||
from aiogram.types import LabeledPrice
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
api_url,
|
||||
json={
|
||||
'title': 'Пополнение баланса VPN',
|
||||
'description': f'Пополнение баланса на {normalized_kopeks / 100:.2f} ₽ ({stars_amount} ⭐)',
|
||||
'payload': payload,
|
||||
'provider_token': '', # Empty for Stars
|
||||
'currency': 'XTR',
|
||||
'prices': [{'label': 'Пополнение баланса', 'amount': stars_amount}],
|
||||
},
|
||||
async with create_bot() as bot:
|
||||
invoice_url = await bot.create_invoice_link(
|
||||
title='Пополнение баланса VPN',
|
||||
description=f'Пополнение баланса на {normalized_kopeks / 100:.2f} ₽ ({stars_amount} ⭐)',
|
||||
payload=payload,
|
||||
provider_token='',
|
||||
currency='XTR',
|
||||
prices=[LabeledPrice(label='Пополнение баланса', amount=stars_amount)],
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
logger.info(
|
||||
'Created Stars invoice for balance top-up: user=, amount= kopeks, stars',
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
stars_amount=stars_amount,
|
||||
)
|
||||
|
||||
if not result.get('ok'):
|
||||
logger.error('Telegram API error', result=result)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create Stars invoice',
|
||||
)
|
||||
return StarsInvoiceResponse(
|
||||
invoice_url=invoice_url,
|
||||
stars_amount=stars_amount,
|
||||
amount_kopeks=normalized_kopeks,
|
||||
)
|
||||
|
||||
invoice_url = result['result']
|
||||
logger.info(
|
||||
'Created Stars invoice for balance top-up: user=, amount= kopeks, stars',
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
stars_amount=stars_amount,
|
||||
)
|
||||
|
||||
return StarsInvoiceResponse(
|
||||
invoice_url=invoice_url,
|
||||
stars_amount=stars_amount,
|
||||
amount_kopeks=normalized_kopeks,
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error('HTTP error creating Stars invoice', error=e)
|
||||
except TelegramAPIError as e:
|
||||
logger.error('Error creating Stars invoice', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to connect to Telegram API',
|
||||
detail='Failed to create Stars invoice',
|
||||
)
|
||||
|
||||
|
||||
@@ -1202,7 +1186,7 @@ async def check_payment_status(
|
||||
old_is_paid = record.is_paid
|
||||
|
||||
# Run manual check
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
bot = create_bot()
|
||||
try:
|
||||
payment_service = PaymentService(bot=bot)
|
||||
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
|
||||
|
||||
+17
-13
@@ -306,9 +306,9 @@ async def create_gift_purchase(
|
||||
else:
|
||||
# 2) Fall back to Bot API (works for public usernames the bot has seen)
|
||||
try:
|
||||
from aiogram import Bot
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
async with Bot(token=settings.BOT_TOKEN) as bot:
|
||||
async with create_bot() as bot:
|
||||
chat = await asyncio.wait_for(bot.get_chat(chat_id=f'@{tg_username}'), timeout=5.0)
|
||||
pre_resolved_telegram_id = chat.id
|
||||
except Exception:
|
||||
@@ -371,19 +371,23 @@ async def create_gift_purchase(
|
||||
# Stars payments need a Bot instance to create invoice links
|
||||
bot = None
|
||||
if body.payment_method == 'telegram_stars':
|
||||
from aiogram import Bot
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
|
||||
payment_service = PaymentService(bot=bot)
|
||||
payment_result = await payment_service.create_guest_payment(
|
||||
db=db,
|
||||
amount_kopeks=price_kopeks,
|
||||
payment_method=body.payment_method,
|
||||
description=f'Gift: {tariff.name} ({body.period_days}d)',
|
||||
purchase_token=purchase.token,
|
||||
return_url=return_url,
|
||||
)
|
||||
try:
|
||||
payment_service = PaymentService(bot=bot)
|
||||
payment_result = await payment_service.create_guest_payment(
|
||||
db=db,
|
||||
amount_kopeks=price_kopeks,
|
||||
payment_method=body.payment_method,
|
||||
description=f'Gift: {tariff.name} ({body.period_days}d)',
|
||||
purchase_token=purchase.token,
|
||||
return_url=return_url,
|
||||
)
|
||||
finally:
|
||||
if bot:
|
||||
await bot.session.close()
|
||||
|
||||
if payment_result is None:
|
||||
await db.rollback()
|
||||
|
||||
@@ -550,7 +550,7 @@ async def create_landing_purchase(
|
||||
No authentication required.
|
||||
"""
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'landing_purchase', limit=5, window=60, fail_closed=True):
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'landing_purchase', limit=30, window=60, fail_closed=True):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail='Too many purchase attempts, please try again later',
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
import mimetypes
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.types import BufferedInputFile
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
|
||||
@@ -98,10 +96,7 @@ async def upload_media(
|
||||
target_chat_id = _resolve_target_chat_id()
|
||||
upload = BufferedInputFile(file_bytes, filename=file.filename or 'upload')
|
||||
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
bot = create_bot()
|
||||
|
||||
try:
|
||||
if media_type_normalized == 'photo':
|
||||
@@ -158,10 +153,7 @@ async def download_media(
|
||||
Download media file by file_id.
|
||||
Used to display images/documents in ticket messages.
|
||||
"""
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
bot = create_bot()
|
||||
|
||||
try:
|
||||
file = await bot.get_file(file_id)
|
||||
|
||||
@@ -40,6 +40,8 @@ async def _finalize_oauth_login(
|
||||
provider: str,
|
||||
campaign_slug: str | None = None,
|
||||
referral_code: str | None = None,
|
||||
*,
|
||||
is_new_user: bool = False,
|
||||
) -> AuthResponse:
|
||||
"""Update last login, create tokens, store refresh token."""
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
@@ -47,10 +49,10 @@ async def _finalize_oauth_login(
|
||||
auth_response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}')
|
||||
|
||||
# Process referral code (before campaign bonus, which may also set referrer)
|
||||
# Process referral code (only for new users — existing users cannot be assigned a referrer)
|
||||
from .auth import _process_referral_code, _user_to_response
|
||||
|
||||
await _process_referral_code(db, user, referral_code)
|
||||
await _process_referral_code(db, user, referral_code, is_new_user=is_new_user)
|
||||
|
||||
auth_response.campaign_bonus = await _process_campaign_bonus(db, user, campaign_slug)
|
||||
if auth_response.campaign_bonus:
|
||||
@@ -232,4 +234,6 @@ async def oauth_callback(
|
||||
referred_by_id=referrer_id,
|
||||
)
|
||||
logger.info('New OAuth user created', provider=provider, user_id=user.id)
|
||||
return await _finalize_oauth_login(db, user, provider, request.campaign_slug, request.referral_code)
|
||||
return await _finalize_oauth_login(
|
||||
db, user, provider, request.campaign_slug, request.referral_code, is_new_user=True
|
||||
)
|
||||
|
||||
@@ -178,12 +178,11 @@ async def apply_for_partner(
|
||||
|
||||
# Уведомляем админов о новой заявке
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_partner_application_notification(
|
||||
|
||||
@@ -92,14 +92,8 @@ async def get_referral_info(
|
||||
available_balance = min(user.balance_kopeks, referral_entitlement)
|
||||
|
||||
# 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}'
|
||||
referral_link = (settings.get_cabinet_referral_link(user.referral_code) or '') if user.referral_code else ''
|
||||
bot_referral_link = settings.get_bot_referral_link(user.referral_code) if user.referral_code else ''
|
||||
|
||||
return ReferralInfoResponse(
|
||||
referral_code=user.referral_code or '',
|
||||
|
||||
@@ -815,12 +815,11 @@ async def purchase_traffic(
|
||||
|
||||
# Отправляем уведомление админам
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
old_traffic = subscription.traffic_limit_gb - request.gb
|
||||
@@ -1043,12 +1042,11 @@ async def purchase_devices_legacy(
|
||||
|
||||
# Отправляем уведомление админам
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
@@ -1147,6 +1145,7 @@ async def get_trial_info(
|
||||
price_kopeks = settings.TRIAL_ACTIVATION_PRICE if requires_payment else 0
|
||||
|
||||
# Get trial parameters from tariff if configured (same logic as activate_trial)
|
||||
# Триальный тариф может быть неактивным — используется для отдельных лимитов
|
||||
try:
|
||||
from app.database.crud.tariff import get_tariff_by_id, get_trial_tariff
|
||||
|
||||
@@ -1156,8 +1155,6 @@ async def get_trial_info(
|
||||
trial_tariff_id = settings.get_trial_tariff_id()
|
||||
if trial_tariff_id > 0:
|
||||
trial_tariff = await get_tariff_by_id(db, trial_tariff_id)
|
||||
if trial_tariff and not trial_tariff.is_active:
|
||||
trial_tariff = None
|
||||
|
||||
if trial_tariff:
|
||||
traffic_limit_gb = trial_tariff.traffic_limit_gb
|
||||
@@ -1290,6 +1287,7 @@ async def activate_trial(
|
||||
|
||||
# First check for tariff with is_trial_available flag in DB (set via admin panel)
|
||||
# Then fallback to TRIAL_TARIFF_ID from settings
|
||||
# Триальный тариф может быть неактивным — используется для отдельных лимитов
|
||||
trial_tariff = None
|
||||
try:
|
||||
from app.database.crud.tariff import get_tariff_by_id, get_trial_tariff
|
||||
@@ -1300,8 +1298,6 @@ async def activate_trial(
|
||||
trial_tariff_id = settings.get_trial_tariff_id()
|
||||
if trial_tariff_id > 0:
|
||||
trial_tariff = await get_tariff_by_id(db, trial_tariff_id)
|
||||
if trial_tariff and not trial_tariff.is_active:
|
||||
trial_tariff = None
|
||||
|
||||
if trial_tariff:
|
||||
trial_traffic_limit = trial_tariff.traffic_limit_gb
|
||||
@@ -1344,12 +1340,11 @@ async def activate_trial(
|
||||
|
||||
# Send admin notification about trial activation
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
charged_amount = settings.TRIAL_ACTIVATION_PRICE if requires_payment else None
|
||||
@@ -1763,12 +1758,11 @@ async def submit_purchase(
|
||||
|
||||
# Отправляем уведомление админам о покупке подписки
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
is_new_subscription = result.get('was_trial_conversion') or not context.subscription
|
||||
@@ -2161,12 +2155,11 @@ async def purchase_tariff(
|
||||
|
||||
# Отправляем уведомление админам о покупке/продлении тарифа
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
# Определяем тип покупки: новая подписка или продление
|
||||
@@ -2418,12 +2411,11 @@ async def purchase_devices(
|
||||
|
||||
# Отправляем уведомление админам
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
@@ -4204,12 +4196,11 @@ async def switch_tariff(
|
||||
|
||||
# Отправляем уведомление админам о смене тарифа
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
@@ -4262,6 +4253,7 @@ async def toggle_subscription_pause(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, Any]:
|
||||
"""Toggle pause/resume for daily subscription."""
|
||||
logger.debug('toggle_subscription_pause called', user_id=user.id)
|
||||
await db.refresh(user, ['subscription'])
|
||||
|
||||
if not user.subscription:
|
||||
@@ -4284,7 +4276,15 @@ async def toggle_subscription_pause(
|
||||
detail='Pause is only available for daily tariffs',
|
||||
)
|
||||
|
||||
# Determine current state
|
||||
raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
|
||||
# Lock user BEFORE reading state and mutating to prevent TOCTOU on promo group
|
||||
# and to ensure is_daily_paused mutation is not overwritten by populate_existing
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Determine current state from the LOCKED instance
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
is_currently_paused = getattr(user.subscription, 'is_daily_paused', False)
|
||||
@@ -4302,13 +4302,6 @@ async def toggle_subscription_pause(
|
||||
new_paused_state = not is_currently_paused
|
||||
user.subscription.is_daily_paused = new_paused_state
|
||||
|
||||
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
|
||||
|
||||
@@ -4318,6 +4311,8 @@ async def toggle_subscription_pause(
|
||||
PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price
|
||||
)
|
||||
|
||||
resume_transaction = None
|
||||
|
||||
# If resuming, check balance and charge
|
||||
if not new_paused_state:
|
||||
if daily_price > 0 and user.balance_kopeks < daily_price:
|
||||
@@ -4342,6 +4337,7 @@ async def toggle_subscription_pause(
|
||||
daily_price,
|
||||
f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
|
||||
mark_as_paid_subscription=True,
|
||||
commit=False,
|
||||
)
|
||||
if not deducted:
|
||||
raise HTTPException(
|
||||
@@ -4357,26 +4353,45 @@ async def toggle_subscription_pause(
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.models import TransactionType
|
||||
|
||||
try:
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=daily_price,
|
||||
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning('Failed to create resume transaction', error=exc)
|
||||
resume_transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=daily_price,
|
||||
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
|
||||
commit=False,
|
||||
)
|
||||
|
||||
# Balance deducted successfully — now activate
|
||||
now = datetime.now(UTC)
|
||||
user.subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
user.subscription.last_daily_charge_at = datetime.now(UTC)
|
||||
user.subscription.end_date = datetime.now(UTC) + timedelta(days=1)
|
||||
user.subscription.last_daily_charge_at = now
|
||||
user.subscription.end_date = now + timedelta(days=1)
|
||||
|
||||
# Re-apply is_daily_paused on the current identity-mapped instance
|
||||
# (subtract_user_balance with populate_existing=True may have reloaded it from DB)
|
||||
user.subscription.is_daily_paused = new_paused_state
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user.subscription)
|
||||
await db.refresh(user)
|
||||
|
||||
# Emit deferred transaction side effects after commit
|
||||
if not new_paused_state and was_disabled and daily_price > 0 and resume_transaction is not None:
|
||||
try:
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
await emit_transaction_side_effects(
|
||||
db=db,
|
||||
transaction=resume_transaction,
|
||||
amount_kopeks=daily_price,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning('Failed to emit resume transaction side effects', error=exc)
|
||||
|
||||
# Sync with RemnaWave only when resuming from DISABLED state
|
||||
if not new_paused_state and was_disabled:
|
||||
try:
|
||||
|
||||
+20
-35
@@ -5,7 +5,6 @@ API роуты колеса удачи для пользователей.
|
||||
import math
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
@@ -21,7 +20,6 @@ from app.cabinet.schemas.wheel import (
|
||||
WheelConfigResponse,
|
||||
WheelPrizeDisplay,
|
||||
)
|
||||
from app.config import settings
|
||||
from app.database.crud.wheel import (
|
||||
get_or_create_wheel_config,
|
||||
get_user_spin_history,
|
||||
@@ -251,44 +249,31 @@ async def create_stars_invoice(
|
||||
|
||||
# Создаем invoice через Telegram Bot API
|
||||
try:
|
||||
bot_token = settings.BOT_TOKEN
|
||||
api_url = f'https://api.telegram.org/bot{bot_token}/createInvoiceLink'
|
||||
from aiogram.exceptions import TelegramAPIError
|
||||
from aiogram.types import LabeledPrice
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
api_url,
|
||||
json={
|
||||
'title': 'Колесо удачи',
|
||||
'description': f'Спин колеса удачи ({stars_amount} ⭐)',
|
||||
'payload': payload,
|
||||
'provider_token': '', # Пустой для Stars
|
||||
'currency': 'XTR',
|
||||
'prices': [{'label': 'Спин колеса', 'amount': stars_amount}],
|
||||
},
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
async with create_bot() as bot:
|
||||
invoice_url = await bot.create_invoice_link(
|
||||
title='Колесо удачи',
|
||||
description=f'Спин колеса удачи ({stars_amount} ⭐)',
|
||||
payload=payload,
|
||||
provider_token='',
|
||||
currency='XTR',
|
||||
prices=[LabeledPrice(label='Спин колеса', amount=stars_amount)],
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
logger.info('Created Stars invoice for wheel spin: user=, stars', user_id=user.id, stars_amount=stars_amount)
|
||||
|
||||
if not result.get('ok'):
|
||||
logger.error('Telegram API error', result=result)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Ошибка создания инвойса',
|
||||
)
|
||||
return StarsInvoiceResponse(
|
||||
invoice_url=invoice_url,
|
||||
stars_amount=stars_amount,
|
||||
)
|
||||
|
||||
invoice_url = result['result']
|
||||
logger.info(
|
||||
'Created Stars invoice for wheel spin: user=, stars', user_id=user.id, stars_amount=stars_amount
|
||||
)
|
||||
|
||||
return StarsInvoiceResponse(
|
||||
invoice_url=invoice_url,
|
||||
stars_amount=stars_amount,
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error('HTTP error creating invoice', error=e)
|
||||
except TelegramAPIError as e:
|
||||
logger.error('Error creating invoice', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Ошибка соединения с Telegram',
|
||||
detail='Ошибка создания инвойса',
|
||||
)
|
||||
|
||||
@@ -70,12 +70,11 @@ async def create_withdrawal(
|
||||
|
||||
# Уведомляем админов о запросе на вывод
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_withdrawal_request_notification(
|
||||
|
||||
@@ -198,6 +198,17 @@ class DeepLinkTokenResponse(BaseModel):
|
||||
|
||||
|
||||
class DeepLinkPollRequest(BaseModel):
|
||||
"""Request to poll deep link auth status."""
|
||||
"""Request to poll deep link auth status.
|
||||
|
||||
Deep link auth is always for existing bot users — referral codes are not applicable here.
|
||||
Only campaign_slug is supported (campaign bonus can apply to existing users).
|
||||
"""
|
||||
|
||||
token: str = Field(..., min_length=16, max_length=128, description='Deep link auth token')
|
||||
campaign_slug: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=64,
|
||||
pattern=r'^[a-zA-Z0-9_-]+$',
|
||||
description='Campaign slug captured from cabinet URL',
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
# ============ Channel Types ============
|
||||
@@ -75,6 +75,27 @@ class BroadcastButtonsResponse(BaseModel):
|
||||
buttons: list[BroadcastButton]
|
||||
|
||||
|
||||
class CustomBroadcastButton(BaseModel):
|
||||
"""Custom button for broadcast message."""
|
||||
|
||||
label: str = Field(..., min_length=1, max_length=64)
|
||||
action_type: Literal['callback', 'url'] = 'callback'
|
||||
action_value: str = Field(..., min_length=1, max_length=256)
|
||||
|
||||
@field_validator('action_value')
|
||||
@classmethod
|
||||
def validate_action_value(cls, v: str, info) -> str:
|
||||
action_type = info.data.get('action_type', 'callback')
|
||||
if action_type == 'url':
|
||||
if not v.startswith(('https://', 'tg://')):
|
||||
raise ValueError('URL must start with https:// or tg://')
|
||||
elif action_type == 'callback':
|
||||
# Telegram API limits callback_data to 64 bytes
|
||||
if len(v.encode('utf-8')) > 64:
|
||||
raise ValueError('Callback data must be at most 64 bytes')
|
||||
return v
|
||||
|
||||
|
||||
# ============ Media ============
|
||||
|
||||
|
||||
@@ -95,6 +116,7 @@ class BroadcastCreateRequest(BaseModel):
|
||||
target: str
|
||||
message_text: str = Field(..., min_length=1, max_length=4000)
|
||||
selected_buttons: list[str] = Field(default_factory=lambda: ['home'])
|
||||
custom_buttons: list[CustomBroadcastButton] = Field(default_factory=list, max_length=10)
|
||||
media: BroadcastMediaRequest | None = None
|
||||
|
||||
|
||||
@@ -187,6 +209,7 @@ class CombinedBroadcastCreateRequest(BaseModel):
|
||||
# Telegram-specific fields
|
||||
message_text: str | None = Field(default=None, max_length=4000)
|
||||
selected_buttons: list[str] = Field(default_factory=lambda: ['home'])
|
||||
custom_buttons: list[CustomBroadcastButton] = Field(default_factory=list, max_length=10)
|
||||
media: BroadcastMediaRequest | None = None
|
||||
|
||||
# Email-specific fields
|
||||
|
||||
@@ -17,14 +17,33 @@ logger = structlog.get_logger(__name__)
|
||||
class EmailService:
|
||||
"""Service for sending emails via SMTP."""
|
||||
|
||||
def __init__(self):
|
||||
self.host = settings.SMTP_HOST
|
||||
self.port = settings.SMTP_PORT
|
||||
self.user = settings.SMTP_USER
|
||||
self.password = settings.SMTP_PASSWORD
|
||||
self.from_email = settings.get_smtp_from_email()
|
||||
self.from_name = settings.SMTP_FROM_NAME
|
||||
self.use_tls = settings.SMTP_USE_TLS
|
||||
@property
|
||||
def host(self) -> str | None:
|
||||
return settings.SMTP_HOST
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
return settings.SMTP_PORT
|
||||
|
||||
@property
|
||||
def user(self) -> str | None:
|
||||
return settings.SMTP_USER
|
||||
|
||||
@property
|
||||
def password(self) -> str | None:
|
||||
return settings.SMTP_PASSWORD
|
||||
|
||||
@property
|
||||
def from_email(self) -> str | None:
|
||||
return settings.get_smtp_from_email()
|
||||
|
||||
@property
|
||||
def from_name(self) -> str:
|
||||
return settings.SMTP_FROM_NAME
|
||||
|
||||
@property
|
||||
def use_tls(self) -> bool:
|
||||
return settings.SMTP_USE_TLS
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""Check if SMTP is properly configured."""
|
||||
@@ -71,6 +90,11 @@ class EmailService:
|
||||
logger.warning('SMTP is not configured, cannot send email')
|
||||
return False
|
||||
|
||||
sender_email = self.from_email
|
||||
if not sender_email or '@' not in sender_email:
|
||||
logger.error('Invalid or missing SMTP from_email, cannot send email', from_email=sender_email)
|
||||
return False
|
||||
|
||||
# Defensive: strip newlines to prevent header injection
|
||||
to_email = to_email.strip().replace('\n', '').replace('\r', '')
|
||||
subject = subject.replace('\n', '').replace('\r', '')
|
||||
@@ -79,11 +103,11 @@ class EmailService:
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['Subject'] = subject
|
||||
safe_from_name = self.from_name.replace('\n', '').replace('\r', '') if self.from_name else ''
|
||||
safe_from_email = self.from_email.replace('\n', '').replace('\r', '') if self.from_email else ''
|
||||
safe_from_email = sender_email.replace('\n', '').replace('\r', '')
|
||||
msg['From'] = f'{safe_from_name} <{safe_from_email}>'
|
||||
msg['To'] = to_email
|
||||
msg['Date'] = formatdate(localtime=False)
|
||||
msg['Message-ID'] = make_msgid(domain=self.from_email.split('@')[-1])
|
||||
msg['Message-ID'] = make_msgid(domain=safe_from_email.split('@')[-1])
|
||||
|
||||
# Plain text version
|
||||
if body_text is None:
|
||||
@@ -103,7 +127,7 @@ class EmailService:
|
||||
msg.attach(part2)
|
||||
|
||||
with self._get_smtp_connection() as smtp:
|
||||
smtp.sendmail(self.from_email, to_email, msg.as_string())
|
||||
smtp.sendmail(safe_from_email, to_email, msg.as_string())
|
||||
|
||||
logger.info('Email sent successfully to', to_email=to_email)
|
||||
return True
|
||||
|
||||
@@ -1373,6 +1373,8 @@ class EmailNotificationTemplates:
|
||||
tariff_name = html.escape(context.get('tariff_name', ''))
|
||||
period_days = context.get('period_days', 0)
|
||||
cabinet_url = html.escape(context.get('cabinet_url', ''))
|
||||
cabinet_email = html.escape(context.get('cabinet_email', ''))
|
||||
cabinet_password = context.get('cabinet_password', '')
|
||||
|
||||
subjects = {
|
||||
'ru': 'Ваша VPN подписка готова',
|
||||
@@ -1382,6 +1384,66 @@ class EmailNotificationTemplates:
|
||||
'fa': 'اشتراک VPN شما آماده است',
|
||||
}
|
||||
|
||||
creds_block_ru = (
|
||||
f"""
|
||||
<div class="highlight">
|
||||
<p><strong>Данные для входа в личный кабинет:</strong></p>
|
||||
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
|
||||
<p><strong>Пароль:</strong> <code>{cabinet_password}</code></p>
|
||||
</div>
|
||||
"""
|
||||
if cabinet_password
|
||||
else ''
|
||||
)
|
||||
|
||||
creds_block_en = (
|
||||
f"""
|
||||
<div class="highlight">
|
||||
<p><strong>Your cabinet login credentials:</strong></p>
|
||||
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
|
||||
<p><strong>Password:</strong> <code>{cabinet_password}</code></p>
|
||||
</div>
|
||||
"""
|
||||
if cabinet_password
|
||||
else ''
|
||||
)
|
||||
|
||||
creds_block_zh = (
|
||||
f"""
|
||||
<div class="highlight">
|
||||
<p><strong>个人中心登录信息:</strong></p>
|
||||
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
|
||||
<p><strong>密码:</strong> <code>{cabinet_password}</code></p>
|
||||
</div>
|
||||
"""
|
||||
if cabinet_password
|
||||
else ''
|
||||
)
|
||||
|
||||
creds_block_ua = (
|
||||
f"""
|
||||
<div class="highlight">
|
||||
<p><strong>Дані для входу в особистий кабінет:</strong></p>
|
||||
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
|
||||
<p><strong>Пароль:</strong> <code>{cabinet_password}</code></p>
|
||||
</div>
|
||||
"""
|
||||
if cabinet_password
|
||||
else ''
|
||||
)
|
||||
|
||||
creds_block_fa = (
|
||||
f"""
|
||||
<div class="highlight">
|
||||
<p><strong>اطلاعات ورود به پنل کاربری:</strong></p>
|
||||
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
|
||||
<p><strong>رمز عبور:</strong> <code>{cabinet_password}</code></p>
|
||||
</div>
|
||||
"""
|
||||
if cabinet_password
|
||||
else ''
|
||||
)
|
||||
|
||||
bodies = {
|
||||
'ru': f"""
|
||||
<h2>Ваша VPN подписка готова!</h2>
|
||||
@@ -1389,6 +1451,7 @@ class EmailNotificationTemplates:
|
||||
<p>Тариф: <strong>{tariff_name}</strong></p>
|
||||
<p>Период: <strong>{period_days} дней</strong></p>
|
||||
</div>
|
||||
{creds_block_ru}
|
||||
<p>Подписка активирована в вашем личном кабинете.</p>
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти в личный кабинет</a></p>
|
||||
""",
|
||||
@@ -1398,6 +1461,7 @@ class EmailNotificationTemplates:
|
||||
<p>Plan: <strong>{tariff_name}</strong></p>
|
||||
<p>Period: <strong>{period_days} days</strong></p>
|
||||
</div>
|
||||
{creds_block_en}
|
||||
<p>Your subscription has been activated in your cabinet.</p>
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Go to Cabinet</a></p>
|
||||
""",
|
||||
@@ -1407,6 +1471,7 @@ class EmailNotificationTemplates:
|
||||
<p>套餐: <strong>{tariff_name}</strong></p>
|
||||
<p>期限: <strong>{period_days} 天</strong></p>
|
||||
</div>
|
||||
{creds_block_zh}
|
||||
<p>订阅已在您的个人中心激活。</p>
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">前往个人中心</a></p>
|
||||
""",
|
||||
@@ -1416,6 +1481,7 @@ class EmailNotificationTemplates:
|
||||
<p>Тариф: <strong>{tariff_name}</strong></p>
|
||||
<p>Період: <strong>{period_days} днів</strong></p>
|
||||
</div>
|
||||
{creds_block_ua}
|
||||
<p>Підписка активована у вашому особистому кабінеті.</p>
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти до кабінету</a></p>
|
||||
""",
|
||||
@@ -1425,6 +1491,7 @@ class EmailNotificationTemplates:
|
||||
<p>طرح: <strong>{tariff_name}</strong></p>
|
||||
<p>مدت: <strong>{period_days} روز</strong></p>
|
||||
</div>
|
||||
{creds_block_fa}
|
||||
<p>اشتراک شما در پنل کاربری فعال شده است.</p>
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">رفتن به پنل کاربری</a></p>
|
||||
""",
|
||||
|
||||
+75
-18
@@ -7,7 +7,7 @@ from collections import defaultdict
|
||||
from datetime import time
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import quote as _url_quote, urlparse
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import structlog
|
||||
@@ -118,6 +118,7 @@ class Settings(BaseSettings):
|
||||
REMNAWAVE_WEBHOOK_ENABLED: bool = False
|
||||
REMNAWAVE_WEBHOOK_PATH: str = '/remnawave-webhook'
|
||||
REMNAWAVE_WEBHOOK_SECRET: str | None = None # HMAC-SHA256 shared secret (min 32 chars)
|
||||
REMNAWAVE_WEBHOOK_NOTIFY_NODE_CONNECTION_STATUS: bool = True
|
||||
|
||||
# Webhook user notification toggles (what Telegram messages users receive from webhook events)
|
||||
WEBHOOK_NOTIFY_USER_ENABLED: bool = True
|
||||
@@ -375,6 +376,7 @@ class Settings(BaseSettings):
|
||||
NALOGO_PASSWORD: str | None = None
|
||||
NALOGO_DEVICE_ID: str | None = None
|
||||
NALOGO_STORAGE_PATH: str = './nalogo_tokens.json'
|
||||
NALOGO_PROXY_URL: str | None = None # SOCKS proxy for nalog.ru; falls back to PROXY_URL if not set
|
||||
|
||||
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
|
||||
|
||||
@@ -802,6 +804,27 @@ class Settings(BaseSettings):
|
||||
BAN_SYSTEM_API_TOKEN: str | None = None
|
||||
BAN_SYSTEM_REQUEST_TIMEOUT: int = 30
|
||||
|
||||
# SOCKS5 proxy for routing bot traffic to Telegram API
|
||||
# Format: socks5://user:password@host:port or socks5://host:port
|
||||
PROXY_URL: str | None = None
|
||||
|
||||
@field_validator('PROXY_URL', 'NALOGO_PROXY_URL', mode='before')
|
||||
@classmethod
|
||||
def validate_proxy_url(cls, value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(value)
|
||||
if parsed.scheme not in ('socks5', 'socks5h', 'socks4'):
|
||||
raise ValueError(
|
||||
f'Proxy URL must use socks5://, socks5h://, or socks4:// scheme, got: {parsed.scheme!r}. '
|
||||
'HTTP proxies are not supported for security reasons.'
|
||||
)
|
||||
if not parsed.hostname:
|
||||
raise ValueError('Proxy URL must contain a hostname')
|
||||
return value
|
||||
|
||||
@field_validator('MAIN_MENU_MODE', mode='before')
|
||||
@classmethod
|
||||
def normalize_main_menu_mode(cls, value: str | None) -> str:
|
||||
@@ -928,6 +951,17 @@ class Settings(BaseSettings):
|
||||
"""Проверяет, используется ли SQLite"""
|
||||
return 'sqlite' in self.get_database_url()
|
||||
|
||||
def get_proxy_url(self) -> str | None:
|
||||
"""Return SOCKS5 proxy URL or None."""
|
||||
return self.PROXY_URL if self.PROXY_URL else None
|
||||
|
||||
def get_nalogo_proxy_url(self) -> str | None:
|
||||
"""Return SOCKS proxy URL for nalogo or None.
|
||||
|
||||
Uses NALOGO_PROXY_URL if set, otherwise falls back to PROXY_URL.
|
||||
"""
|
||||
return self.NALOGO_PROXY_URL or self.PROXY_URL
|
||||
|
||||
def is_admin(self, telegram_id: int | None = None, email: str | None = None) -> bool:
|
||||
"""
|
||||
Check if user is admin by telegram_id or email.
|
||||
@@ -1099,12 +1133,17 @@ class Settings(BaseSettings):
|
||||
username_clean = (username or '').lstrip('@')
|
||||
full_name_value = full_name or ''
|
||||
|
||||
# Remnawave разрешает только буквы, цифры, подчёркивания и дефисы
|
||||
def _sanitize(value: str) -> str:
|
||||
result = re.sub(r'[^0-9A-Za-z_-]+', '_', value)
|
||||
return re.sub(r'_+', '_', result).strip('_-')
|
||||
|
||||
# Для email-пользователей формируем уникальный identifier
|
||||
if telegram_id:
|
||||
identifier = str(telegram_id)
|
||||
elif email:
|
||||
email_prefix = email.split('@')[0][:10]
|
||||
identifier = f'email_{email_prefix}_{user_id}' if user_id else f'email_{email_prefix}'
|
||||
email_prefix = _sanitize(email.split('@')[0][:10])
|
||||
identifier = _sanitize(f'email_{email_prefix}_{user_id}' if user_id else f'email_{email_prefix}')
|
||||
elif user_id:
|
||||
identifier = f'id_{user_id}'
|
||||
else:
|
||||
@@ -1118,20 +1157,18 @@ class Settings(BaseSettings):
|
||||
'username_clean': username_clean,
|
||||
'telegram_id': str(telegram_id) if telegram_id else identifier,
|
||||
'identifier': identifier,
|
||||
'email': email.split('@')[0] if email else '',
|
||||
'email': _sanitize(email.split('@')[0]) if email else '',
|
||||
'user_id': str(user_id) if user_id else '',
|
||||
},
|
||||
)
|
||||
|
||||
raw_username = template.format_map(values).strip()
|
||||
# Remnawave разрешает только буквы, цифры, подчёркивания и дефисы
|
||||
sanitized_username = re.sub(r'[^0-9A-Za-z_-]+', '_', raw_username)
|
||||
sanitized_username = re.sub(r'_+', '_', sanitized_username).strip('_-')
|
||||
sanitized_username = _sanitize(raw_username)
|
||||
|
||||
if not sanitized_username:
|
||||
sanitized_username = f'user_{identifier}'
|
||||
sanitized_username = _sanitize(f'user_{identifier}')
|
||||
|
||||
return sanitized_username[:36]
|
||||
return sanitized_username[:36].strip('_-') or 'user'
|
||||
|
||||
@staticmethod
|
||||
def parse_daily_time_list(raw_value: str | None) -> list[time]:
|
||||
@@ -1439,24 +1476,44 @@ class Settings(BaseSettings):
|
||||
|
||||
_CABINET_URL_DEFAULT = 'https://example.com/cabinet'
|
||||
|
||||
def _encode_referral_code(self, referral_code: str) -> str:
|
||||
"""Validate and URL-encode a referral code."""
|
||||
if not referral_code:
|
||||
raise ValueError('referral_code must not be empty or None')
|
||||
return _url_quote(referral_code, safe='')
|
||||
|
||||
def _normalized_cabinet_url(self) -> str | None:
|
||||
"""Return normalized cabinet URL, or None if not configured."""
|
||||
cabinet_url = (self.CABINET_URL or '').strip().rstrip('/')
|
||||
if not cabinet_url or cabinet_url == self._CABINET_URL_DEFAULT:
|
||||
return None
|
||||
return cabinet_url
|
||||
|
||||
def get_referral_link(self, referral_code: str, bot_username: str | None = None) -> str:
|
||||
"""Build a referral link pointing to the web cabinet.
|
||||
|
||||
Falls back to a Telegram bot deep link when CABINET_URL is not configured.
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
cabinet_link = self.get_cabinet_referral_link(referral_code)
|
||||
if cabinet_link:
|
||||
return cabinet_link
|
||||
return self.get_bot_referral_link(referral_code, bot_username)
|
||||
|
||||
if not referral_code:
|
||||
raise ValueError('referral_code must not be empty or None')
|
||||
|
||||
safe_code = quote(referral_code, safe='')
|
||||
cabinet_url = (self.CABINET_URL or '').strip().rstrip('/')
|
||||
if cabinet_url and cabinet_url != self._CABINET_URL_DEFAULT:
|
||||
sep = '&' if '?' in cabinet_url else '?'
|
||||
return f'{cabinet_url}{sep}ref={safe_code}'
|
||||
def get_bot_referral_link(self, referral_code: str, bot_username: str | None = None) -> str:
|
||||
"""Always return the Telegram bot deep link for a referral code."""
|
||||
safe_code = self._encode_referral_code(referral_code)
|
||||
username = bot_username or self.get_bot_username() or 'bot'
|
||||
return f'https://t.me/{username}?start={safe_code}'
|
||||
|
||||
def get_cabinet_referral_link(self, referral_code: str) -> str | None:
|
||||
"""Return the cabinet referral link, or None if cabinet is not configured."""
|
||||
cabinet_url = self._normalized_cabinet_url()
|
||||
if not cabinet_url:
|
||||
return None
|
||||
safe_code = self._encode_referral_code(referral_code)
|
||||
sep = '&' if '?' in cabinet_url else '?'
|
||||
return f'{cabinet_url}{sep}ref={safe_code}'
|
||||
|
||||
def is_deep_links_enabled(self) -> bool:
|
||||
return self.ENABLE_DEEP_LINKS
|
||||
|
||||
|
||||
@@ -67,8 +67,24 @@ async def get_cryptobot_payment_by_id(db: AsyncSession, payment_id: int) -> Cryp
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_cryptobot_payment_by_invoice_id_for_update(db: AsyncSession, invoice_id: str) -> CryptoBotPayment | None:
|
||||
result = await db.execute(
|
||||
select(CryptoBotPayment)
|
||||
.options(selectinload(CryptoBotPayment.user))
|
||||
.where(CryptoBotPayment.invoice_id == invoice_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_cryptobot_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> CryptoBotPayment | None:
|
||||
result = await db.execute(select(CryptoBotPayment).where(CryptoBotPayment.id == payment_id).with_for_update())
|
||||
result = await db.execute(
|
||||
select(CryptoBotPayment)
|
||||
.where(CryptoBotPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
|
||||
@@ -63,7 +63,12 @@ async def get_freekassa_payment_by_id(db: AsyncSession, payment_id: int) -> Free
|
||||
|
||||
|
||||
async def get_freekassa_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> FreekassaPayment | None:
|
||||
result = await db.execute(select(FreekassaPayment).where(FreekassaPayment.id == payment_id).with_for_update())
|
||||
result = await db.execute(
|
||||
select(FreekassaPayment)
|
||||
.where(FreekassaPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
|
||||
@@ -65,7 +65,12 @@ async def get_kassa_ai_payment_by_id(db: AsyncSession, payment_id: int) -> Kassa
|
||||
|
||||
|
||||
async def get_kassa_ai_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> KassaAiPayment | None:
|
||||
result = await db.execute(select(KassaAiPayment).where(KassaAiPayment.id == payment_id).with_for_update())
|
||||
result = await db.execute(
|
||||
select(KassaAiPayment)
|
||||
.where(KassaAiPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
|
||||
@@ -58,7 +58,12 @@ async def get_mulenpay_payment_by_local_id(db: AsyncSession, payment_id: int) ->
|
||||
|
||||
|
||||
async def get_mulenpay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> MulenPayPayment | None:
|
||||
result = await db.execute(select(MulenPayPayment).where(MulenPayPayment.id == payment_id).with_for_update())
|
||||
result = await db.execute(
|
||||
select(MulenPayPayment)
|
||||
.where(MulenPayPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
|
||||
@@ -67,7 +67,12 @@ async def get_pal24_payment_by_id(db: AsyncSession, payment_id: int) -> Pal24Pay
|
||||
|
||||
|
||||
async def get_pal24_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> Pal24Payment | None:
|
||||
result = await db.execute(select(Pal24Payment).where(Pal24Payment.id == payment_id).with_for_update())
|
||||
result = await db.execute(
|
||||
select(Pal24Payment)
|
||||
.where(Pal24Payment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
|
||||
@@ -71,7 +71,12 @@ async def get_platega_payment_by_id(db: AsyncSession, payment_id: int) -> Plateg
|
||||
|
||||
|
||||
async def get_platega_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> PlategaPayment | None:
|
||||
result = await db.execute(select(PlategaPayment).where(PlategaPayment.id == payment_id).with_for_update())
|
||||
result = await db.execute(
|
||||
select(PlategaPayment)
|
||||
.where(PlategaPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
|
||||
@@ -38,8 +38,8 @@ _POLICY_UPDATABLE_FIELDS = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
# Superadmin level constant
|
||||
_SUPERADMIN_LEVEL = 999
|
||||
# Superadmin level constant — single source of truth, imported by admin_roles and bootstrap
|
||||
SUPERADMIN_LEVEL = 999
|
||||
|
||||
|
||||
class AdminRoleCRUD:
|
||||
@@ -240,21 +240,6 @@ class UserRoleCRUD:
|
||||
logger.info('Assigned role to user', user_role_id=user_role.id, user_id=user_id, role_id=role_id)
|
||||
return user_role
|
||||
|
||||
@staticmethod
|
||||
async def revoke_role(db: AsyncSession, user_role_id: int) -> bool:
|
||||
"""Soft-revoke: set is_active=False. Returns False if not found."""
|
||||
result = await db.execute(select(UserRole).where(UserRole.id == user_role_id))
|
||||
user_role = result.scalar_one_or_none()
|
||||
if not user_role:
|
||||
return False
|
||||
|
||||
user_role.is_active = False
|
||||
await db.flush()
|
||||
logger.info(
|
||||
'Revoked user role', user_role_id=user_role_id, user_id=user_role.user_id, role_id=user_role.role_id
|
||||
)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def get_all_admins(
|
||||
db: AsyncSession,
|
||||
@@ -296,14 +281,16 @@ class UserRoleCRUD:
|
||||
|
||||
@staticmethod
|
||||
async def get_superadmin_count(db: AsyncSession) -> int:
|
||||
"""Count users with an active role at superadmin level (999)."""
|
||||
"""Count users with an active, non-expired role at superadmin level (999)."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(func.count(func.distinct(UserRole.user_id)))
|
||||
.join(AdminRole, UserRole.role_id == AdminRole.id)
|
||||
.where(
|
||||
UserRole.is_active.is_(True),
|
||||
AdminRole.is_active.is_(True),
|
||||
AdminRole.level == _SUPERADMIN_LEVEL,
|
||||
AdminRole.level == SUPERADMIN_LEVEL,
|
||||
or_(UserRole.expires_at.is_(None), UserRole.expires_at > now),
|
||||
)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
@@ -66,6 +66,17 @@ async def get_riopay_payment_by_id(db: AsyncSession, payment_id: int) -> RioPayP
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_riopay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> RioPayPayment | None:
|
||||
"""Получает платеж по ID с блокировкой FOR UPDATE (для защиты от TOCTOU race)."""
|
||||
result = await db.execute(
|
||||
select(RioPayPayment)
|
||||
.where(RioPayPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_riopay_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: RioPayPayment,
|
||||
|
||||
@@ -70,7 +70,12 @@ async def get_severpay_payment_by_id(db: AsyncSession, payment_id: int) -> Sever
|
||||
|
||||
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())
|
||||
result = await db.execute(
|
||||
select(SeverPayPayment)
|
||||
.where(SeverPayPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
|
||||
@@ -1442,6 +1442,7 @@ async def create_pending_subscription(
|
||||
payment_method: str = 'pending',
|
||||
total_price_kopeks: int = 0,
|
||||
is_trial: bool = False,
|
||||
tariff_id: int | None = None,
|
||||
) -> Subscription:
|
||||
"""Creates a pending subscription that will be activated after payment.
|
||||
|
||||
@@ -1475,6 +1476,8 @@ async def create_pending_subscription(
|
||||
existing_subscription.connected_squads = connected_squads or []
|
||||
existing_subscription.traffic_used_gb = 0.0
|
||||
existing_subscription.updated_at = current_time
|
||||
if tariff_id is not None:
|
||||
existing_subscription.tariff_id = tariff_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_subscription)
|
||||
@@ -1497,6 +1500,7 @@ async def create_pending_subscription(
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads or [],
|
||||
tariff_id=tariff_id,
|
||||
autopay_enabled=settings.is_autopay_enabled_by_default(),
|
||||
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
|
||||
)
|
||||
@@ -1526,6 +1530,7 @@ async def create_pending_trial_subscription(
|
||||
connected_squads: list[str] = None,
|
||||
payment_method: str = 'pending',
|
||||
total_price_kopeks: int = 0,
|
||||
tariff_id: int | None = None,
|
||||
) -> Subscription:
|
||||
"""Creates a pending trial subscription. Wrapper for create_pending_subscription with is_trial=True."""
|
||||
return await create_pending_subscription(
|
||||
@@ -1538,6 +1543,7 @@ async def create_pending_trial_subscription(
|
||||
payment_method=payment_method,
|
||||
total_price_kopeks=total_price_kopeks,
|
||||
is_trial=True,
|
||||
tariff_id=tariff_id,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -83,13 +83,16 @@ async def count_tariffs(db: AsyncSession, *, include_inactive: bool = False) ->
|
||||
async def get_trial_tariff(db: AsyncSession) -> Tariff | None:
|
||||
"""Получает тариф, доступный для триала (is_trial_available=True).
|
||||
|
||||
Триальный тариф может быть неактивным — это сделано специально,
|
||||
чтобы он не отображался в списке покупки, но использовался для триала
|
||||
со своими лимитами (трафик, устройства, серверы).
|
||||
|
||||
Сортируется по updated_at DESC, чтобы вернуть последний установленный
|
||||
триальный тариф (на случай если их несколько).
|
||||
"""
|
||||
query = (
|
||||
select(Tariff)
|
||||
.where(Tariff.is_trial_available.is_(True))
|
||||
.where(Tariff.is_active.is_(True))
|
||||
.options(selectinload(Tariff.allowed_promo_groups))
|
||||
.order_by(Tariff.updated_at.desc().nullslast(), Tariff.id.desc())
|
||||
.limit(1)
|
||||
|
||||
@@ -51,6 +51,11 @@ async def create_transaction(
|
||||
else amount_kopeks
|
||||
)
|
||||
|
||||
# Default payment_method to BALANCE for subscription/gift payments from bot (not landing)
|
||||
# to avoid double-counting with DEPOSIT in revenue calculations
|
||||
if payment_method is None and type in (TransactionType.SUBSCRIPTION_PAYMENT, TransactionType.GIFT_PAYMENT):
|
||||
payment_method = PaymentMethod.BALANCE
|
||||
|
||||
transaction = Transaction(
|
||||
user_id=user_id,
|
||||
type=type.value,
|
||||
@@ -108,7 +113,7 @@ async def create_transaction(
|
||||
|
||||
await maybe_assign_promo_group_by_total_spent(db, user_id)
|
||||
except Exception as exc:
|
||||
logger.debug('Не удалось проверить автовыдачу промогруппы для пользователя', user_id=user_id, exc=exc)
|
||||
logger.warning('Не удалось проверить автовыдачу промогруппы для пользователя', user_id=user_id, exc=exc)
|
||||
if type == TransactionType.SUBSCRIPTION_PAYMENT and is_completed:
|
||||
try:
|
||||
from app.services.referral_contest_service import referral_contest_service
|
||||
@@ -168,7 +173,7 @@ async def emit_transaction_side_effects(
|
||||
|
||||
await maybe_assign_promo_group_by_total_spent(db, user_id)
|
||||
except Exception as exc:
|
||||
logger.debug('Не удалось проверить автовыдачу промогруппы для пользователя', user_id=user_id, exc=exc)
|
||||
logger.warning('Не удалось проверить автовыдачу промогруппы для пользователя', user_id=user_id, exc=exc)
|
||||
|
||||
if type == TransactionType.SUBSCRIPTION_PAYMENT and is_completed:
|
||||
try:
|
||||
@@ -253,7 +258,7 @@ async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Tr
|
||||
|
||||
await maybe_assign_promo_group_by_total_spent(db, transaction.user_id)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
logger.warning(
|
||||
'Не удалось проверить автовыдачу промогруппы для пользователя', user_id=transaction.user_id, exc=exc
|
||||
)
|
||||
|
||||
@@ -278,11 +283,11 @@ async def get_transactions_statistics(
|
||||
if not end_date:
|
||||
end_date = datetime.now(UTC)
|
||||
|
||||
# Доход считаем только по реальным платежам (исключаем колесо, промокоды, админские пополнения)
|
||||
# Доход считаем по реальным платежам + прямые покупки подписок (лендинги)
|
||||
income_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= start_date,
|
||||
Transaction.created_at <= end_date,
|
||||
@@ -339,11 +344,11 @@ async def get_transactions_statistics(
|
||||
select(
|
||||
Transaction.payment_method,
|
||||
func.count(Transaction.id).label('count'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('total_amount'),
|
||||
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('total_amount'),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= start_date,
|
||||
Transaction.created_at <= end_date,
|
||||
@@ -363,11 +368,11 @@ async def get_transactions_statistics(
|
||||
)
|
||||
transactions_today = today_result.scalar()
|
||||
|
||||
# Доход за сегодня - только реальные платежи
|
||||
# Доход за сегодня — реальные платежи + прямые покупки подписок (лендинги)
|
||||
today_income_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= today,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
@@ -391,17 +396,17 @@ async def get_transactions_statistics(
|
||||
|
||||
|
||||
async def get_revenue_by_period(db: AsyncSession, days: int = 30) -> list[dict]:
|
||||
"""Доход по дням - только реальные платежи."""
|
||||
"""Доход по дням — реальные платежи + прямые покупки подписок (лендинги)."""
|
||||
start_date = datetime.now(UTC) - timedelta(days=days)
|
||||
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.date(Transaction.created_at).label('date'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
|
||||
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= start_date,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, desc, select
|
||||
from sqlalchemy import and_, desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -24,7 +24,7 @@ async def _sync_user_primary_promo_group(
|
||||
select(UserPromoGroup.promo_group_id)
|
||||
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
|
||||
.where(UserPromoGroup.user_id == user_id)
|
||||
.order_by(desc(PromoGroup.priority), PromoGroup.id)
|
||||
.order_by(desc(PromoGroup.priority), desc(PromoGroup.id))
|
||||
)
|
||||
|
||||
first = result.first()
|
||||
@@ -53,7 +53,12 @@ async def sync_user_primary_promo_group(
|
||||
|
||||
|
||||
async def add_user_to_promo_group(
|
||||
db: AsyncSession, user_id: int, promo_group_id: int, assigned_by: str = 'admin'
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promo_group_id: int,
|
||||
assigned_by: str = 'admin',
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> UserPromoGroup | None:
|
||||
"""
|
||||
Добавляет пользователю промогруппу.
|
||||
@@ -63,6 +68,7 @@ async def add_user_to_promo_group(
|
||||
user_id: ID пользователя
|
||||
promo_group_id: ID промогруппы
|
||||
assigned_by: Кто назначил ('admin', 'system', 'auto', 'promocode')
|
||||
commit: Коммитить транзакцию (False для батчевых операций)
|
||||
|
||||
Returns:
|
||||
UserPromoGroup или None если уже существует
|
||||
@@ -85,8 +91,9 @@ async def add_user_to_promo_group(
|
||||
|
||||
await _sync_user_primary_promo_group(db, user_id)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user_promo_group)
|
||||
if commit:
|
||||
await db.commit()
|
||||
await db.refresh(user_promo_group)
|
||||
|
||||
logger.info(
|
||||
'Пользователю добавлена промогруппа',
|
||||
@@ -98,11 +105,19 @@ async def add_user_to_promo_group(
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Ошибка добавления промогруппы пользователю', error=error)
|
||||
await db.rollback()
|
||||
return None
|
||||
if commit:
|
||||
await db.rollback()
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
async def remove_user_from_promo_group(db: AsyncSession, user_id: int, promo_group_id: int) -> bool:
|
||||
async def remove_user_from_promo_group(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promo_group_id: int,
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> bool:
|
||||
"""
|
||||
Удаляет промогруппу у пользователя.
|
||||
|
||||
@@ -110,6 +125,7 @@ async def remove_user_from_promo_group(db: AsyncSession, user_id: int, promo_gro
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
promo_group_id: ID промогруппы
|
||||
commit: Коммитить транзакцию (False для батчевых операций)
|
||||
|
||||
Returns:
|
||||
True если удалено, False если связи не было
|
||||
@@ -133,15 +149,18 @@ async def remove_user_from_promo_group(db: AsyncSession, user_id: int, promo_gro
|
||||
|
||||
await _sync_user_primary_promo_group(db, user_id)
|
||||
|
||||
await db.commit()
|
||||
if commit:
|
||||
await db.commit()
|
||||
|
||||
logger.info('У пользователя удалена промогруппа', user_id=user_id, promo_group_id=promo_group_id)
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Ошибка удаления промогруппы у пользователя', error=error)
|
||||
await db.rollback()
|
||||
return False
|
||||
if commit:
|
||||
await db.rollback()
|
||||
return False
|
||||
raise
|
||||
|
||||
|
||||
async def get_user_promo_groups(db: AsyncSession, user_id: int) -> list[UserPromoGroup]:
|
||||
@@ -155,19 +174,14 @@ async def get_user_promo_groups(db: AsyncSession, user_id: int) -> list[UserProm
|
||||
Returns:
|
||||
Список UserPromoGroup с загруженными PromoGroup, отсортированный по приоритету DESC
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup)
|
||||
.options(selectinload(UserPromoGroup.promo_group))
|
||||
.where(UserPromoGroup.user_id == user_id)
|
||||
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
|
||||
.order_by(desc(PromoGroup.priority), PromoGroup.id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Ошибка получения промогрупп пользователя', user_id=user_id, error=error)
|
||||
return []
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup)
|
||||
.options(selectinload(UserPromoGroup.promo_group))
|
||||
.where(UserPromoGroup.user_id == user_id)
|
||||
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
|
||||
.order_by(desc(PromoGroup.priority), desc(PromoGroup.id))
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_primary_user_promo_group(db: AsyncSession, user_id: int) -> PromoGroup | None:
|
||||
@@ -181,19 +195,14 @@ async def get_primary_user_promo_group(db: AsyncSession, user_id: int) -> PromoG
|
||||
Returns:
|
||||
PromoGroup с максимальным приоритетом или None
|
||||
"""
|
||||
try:
|
||||
user_promo_groups = await get_user_promo_groups(db, user_id)
|
||||
user_promo_groups = await get_user_promo_groups(db, user_id)
|
||||
|
||||
if not user_promo_groups:
|
||||
return None
|
||||
|
||||
# Первая в списке имеет максимальный приоритет (список уже отсортирован)
|
||||
return user_promo_groups[0].promo_group or None
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Ошибка получения primary промогруппы пользователя', user_id=user_id, error=error)
|
||||
if not user_promo_groups:
|
||||
return None
|
||||
|
||||
# Первая в списке имеет максимальный приоритет (список уже отсортирован)
|
||||
return user_promo_groups[0].promo_group or None
|
||||
|
||||
|
||||
async def has_user_promo_group(db: AsyncSession, user_id: int, promo_group_id: int) -> bool:
|
||||
"""
|
||||
@@ -207,17 +216,12 @@ async def has_user_promo_group(db: AsyncSession, user_id: int, promo_group_id: i
|
||||
Returns:
|
||||
True если пользователь уже имеет эту промогруппу
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup).where(
|
||||
and_(UserPromoGroup.user_id == user_id, UserPromoGroup.promo_group_id == promo_group_id)
|
||||
)
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup).where(
|
||||
and_(UserPromoGroup.user_id == user_id, UserPromoGroup.promo_group_id == promo_group_id)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Ошибка проверки промогруппы пользователя', error=error)
|
||||
return False
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def count_user_promo_groups(db: AsyncSession, user_id: int) -> int:
|
||||
@@ -232,8 +236,10 @@ async def count_user_promo_groups(db: AsyncSession, user_id: int) -> int:
|
||||
Количество промогрупп
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(select(UserPromoGroup).where(UserPromoGroup.user_id == user_id))
|
||||
return len(list(result.scalars().all()))
|
||||
result = await db.execute(
|
||||
select(func.count()).select_from(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Ошибка подсчета промогрупп пользователя', error=error)
|
||||
@@ -257,15 +263,18 @@ async def replace_user_promo_groups(
|
||||
"""
|
||||
try:
|
||||
# Удаляем все текущие промогруппы
|
||||
await db.execute(select(UserPromoGroup).where(UserPromoGroup.user_id == user_id))
|
||||
result = await db.execute(select(UserPromoGroup).where(UserPromoGroup.user_id == user_id))
|
||||
for upg in result.scalars().all():
|
||||
await db.delete(upg)
|
||||
await db.flush()
|
||||
|
||||
# Добавляем новые
|
||||
for promo_group_id in promo_group_ids:
|
||||
user_promo_group = UserPromoGroup(user_id=user_id, promo_group_id=promo_group_id, assigned_by=assigned_by)
|
||||
db.add(user_promo_group)
|
||||
await db.flush()
|
||||
|
||||
await _sync_user_primary_promo_group(db, user_id)
|
||||
|
||||
await db.commit()
|
||||
logger.info('Промогруппы пользователя заменены на', user_id=user_id, promo_group_ids=promo_group_ids)
|
||||
|
||||
@@ -72,7 +72,12 @@ async def get_wata_payment_by_id(
|
||||
|
||||
|
||||
async def get_wata_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> WataPayment | None:
|
||||
result = await db.execute(select(WataPayment).where(WataPayment.id == payment_id).with_for_update())
|
||||
result = await db.execute(
|
||||
select(WataPayment)
|
||||
.where(WataPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
|
||||
@@ -1574,6 +1574,7 @@ class Transaction(Base):
|
||||
Index('ix_transactions_type_created_completed', 'type', 'created_at', 'is_completed'),
|
||||
Index('ix_transactions_user_created', 'user_id', 'created_at'),
|
||||
Index('ix_transactions_type_method_created', 'type', 'payment_method', 'created_at'),
|
||||
Index('ix_transactions_user_type_completed_amount', 'user_id', 'type', 'is_completed', 'amount_kopeks'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
@@ -2490,7 +2491,10 @@ class AdvertisingCampaign(Base):
|
||||
|
||||
class AdvertisingCampaignRegistration(Base):
|
||||
__tablename__ = 'advertising_campaign_registrations'
|
||||
__table_args__ = (UniqueConstraint('campaign_id', 'user_id', name='uq_campaign_user'),)
|
||||
__table_args__ = (
|
||||
UniqueConstraint('campaign_id', 'user_id', name='uq_campaign_user'),
|
||||
Index('ix_campaign_reg_user_created', 'user_id', 'created_at'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
campaign_id = Column(Integer, ForeignKey('advertising_campaigns.id', ondelete='CASCADE'), nullable=False)
|
||||
@@ -3283,6 +3287,9 @@ class GuestPurchase(Base):
|
||||
cabinet_password = Column(Text, nullable=True)
|
||||
auto_login_token = Column(Text, nullable=True)
|
||||
recipient_warning = Column(String(50), nullable=True)
|
||||
retry_count = Column(Integer, nullable=False, default=0, server_default='0')
|
||||
receipt_uuid = Column(String(255), nullable=True, index=True)
|
||||
receipt_created_at = Column(AwareDateTime(), nullable=True)
|
||||
|
||||
landing = relationship('LandingPage', back_populates='guest_purchases', lazy='selectin')
|
||||
tariff = relationship('Tariff', lazy='selectin')
|
||||
|
||||
Vendored
+30
-5
@@ -469,7 +469,22 @@ class RemnaWaveAPI:
|
||||
hwidDeviceLimit=data.get('hwidDeviceLimit'),
|
||||
status=data.get('status'),
|
||||
)
|
||||
response = await self._make_request('POST', '/api/users', data)
|
||||
try:
|
||||
response = await self._make_request('POST', '/api/users', data)
|
||||
except RemnaWaveAPIError as e:
|
||||
# A039 = FK violation on externalSquadUuid — retry without it
|
||||
error_code = (e.response_data or {}).get('errorCode', '')
|
||||
if error_code == 'A039' and 'externalSquadUuid' in data:
|
||||
stale_uuid = data.pop('externalSquadUuid')
|
||||
logger.warning(
|
||||
'A039 FK violation on externalSquadUuid, retrying without it',
|
||||
stale_uuid=stale_uuid,
|
||||
username=data.get('username'),
|
||||
)
|
||||
response = await self._make_request('POST', '/api/users', data)
|
||||
else:
|
||||
logger.error('POST /api/users FAILED — full payload', payload=data)
|
||||
raise
|
||||
user = self._parse_user(response['response'])
|
||||
logger.info(
|
||||
'POST /api/users response',
|
||||
@@ -570,10 +585,20 @@ class RemnaWaveAPI:
|
||||
|
||||
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
|
||||
except RemnaWaveAPIError as e:
|
||||
# A039 = FK violation on externalSquadUuid — retry without it
|
||||
error_code = (e.response_data or {}).get('errorCode', '')
|
||||
if error_code == 'A039' and 'externalSquadUuid' in data:
|
||||
stale_uuid = data.pop('externalSquadUuid')
|
||||
logger.warning(
|
||||
'A039 FK violation on externalSquadUuid, retrying without it',
|
||||
stale_uuid=stale_uuid,
|
||||
uuid=uuid,
|
||||
)
|
||||
response = await self._make_request('PATCH', '/api/users', data)
|
||||
else:
|
||||
logger.error('PATCH /api/users FAILED — full payload', payload=data)
|
||||
raise
|
||||
user = self._parse_user(response['response'])
|
||||
logger.info(
|
||||
'PATCH /api/users response',
|
||||
|
||||
@@ -83,7 +83,6 @@ CABINET_MINIAPP_BUTTON_KEYS = {
|
||||
'connect',
|
||||
'subscription',
|
||||
'support',
|
||||
'home',
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +96,11 @@ def get_updated_message_buttons_selector_keyboard(
|
||||
return get_updated_message_buttons_selector_keyboard_with_media(selected_buttons, False, language)
|
||||
|
||||
|
||||
def create_broadcast_keyboard(selected_buttons: list, language: str = 'ru') -> types.InlineKeyboardMarkup | None:
|
||||
def create_broadcast_keyboard(
|
||||
selected_buttons: list,
|
||||
language: str = 'ru',
|
||||
custom_buttons: list[dict] | None = None,
|
||||
) -> types.InlineKeyboardMarkup | None:
|
||||
selected_buttons = selected_buttons or []
|
||||
keyboard: list[list[types.InlineKeyboardButton]] = []
|
||||
button_config_map = get_broadcast_button_config(language)
|
||||
@@ -123,6 +126,20 @@ def create_broadcast_keyboard(selected_buttons: list, language: str = 'ru') -> t
|
||||
if row_buttons:
|
||||
keyboard.append(row_buttons)
|
||||
|
||||
# Append custom buttons (each on its own row)
|
||||
if custom_buttons:
|
||||
for btn in custom_buttons:
|
||||
label = btn.get('label', '')
|
||||
action_type = btn.get('action_type', 'callback')
|
||||
action_value = btn.get('action_value', '')
|
||||
if not label or not action_value:
|
||||
continue
|
||||
if action_type == 'url':
|
||||
keyboard.append([types.InlineKeyboardButton(text=label, url=action_value)])
|
||||
else:
|
||||
# callback type
|
||||
keyboard.append([types.InlineKeyboardButton(text=label, callback_data=action_value)])
|
||||
|
||||
if not keyboard:
|
||||
return None
|
||||
|
||||
|
||||
@@ -462,28 +462,17 @@ async def show_promo_groups_menu(
|
||||
|
||||
keyboard_rows = []
|
||||
for group, member_count in groups:
|
||||
icon = '⭐' if group.is_default else '🎯'
|
||||
default_suffix = texts.t('ADMIN_PROMO_GROUPS_DEFAULT_LABEL', ' (базовая)') if group.is_default else ''
|
||||
group_lines = [
|
||||
f'{"⭐" if group.is_default else "🎯"} <b>{group.name}</b>{default_suffix}',
|
||||
]
|
||||
group_lines.extend(_format_discount_lines(texts, group))
|
||||
group_lines.append(_format_auto_assign_line(texts, group))
|
||||
group_lines.append(
|
||||
texts.t(
|
||||
'ADMIN_PROMO_GROUPS_MEMBERS_COUNT',
|
||||
'Участников: {count}',
|
||||
).format(count=member_count)
|
||||
)
|
||||
|
||||
period_lines = _format_period_discounts_lines(texts, group, db_user.language)
|
||||
group_lines.extend(period_lines)
|
||||
group_lines.append('')
|
||||
|
||||
lines.extend(group_lines)
|
||||
members_label = texts.t(
|
||||
'ADMIN_PROMO_GROUPS_MEMBERS_COUNT',
|
||||
'Участников: {count}',
|
||||
).format(count=member_count)
|
||||
lines.append(f'{icon} <b>{group.name}</b>{default_suffix} — {members_label}')
|
||||
keyboard_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f'{"⭐" if group.is_default else "🎯"} {group.name}',
|
||||
text=f'{icon} {group.name}',
|
||||
callback_data=f'promo_group_manage_{group.id}',
|
||||
)
|
||||
]
|
||||
|
||||
@@ -1019,7 +1019,7 @@ async def delete_user_account(callback: types.CallbackQuery, db_user: User, db:
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
user_service = UserService()
|
||||
delete_result = await user_service.delete_user_account(db, user_id, db_user.id)
|
||||
delete_result = await user_service.delete_user_account(db, user_id, db_user.id, force_panel_delete=True)
|
||||
|
||||
if delete_result.bot_deleted:
|
||||
await callback.message.edit_text(
|
||||
@@ -4571,7 +4571,7 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
update_kwargs = dict(
|
||||
uuid=target_user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
|
||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.DISABLED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3)
|
||||
if subscription.traffic_limit_gb > 0
|
||||
@@ -4608,7 +4608,7 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
|
||||
create_kwargs = dict(
|
||||
username=username,
|
||||
expire_at=subscription.end_date,
|
||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
|
||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.DISABLED,
|
||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3)
|
||||
if subscription.traffic_limit_gb > 0
|
||||
else 0,
|
||||
|
||||
+69
-32
@@ -1,5 +1,6 @@
|
||||
import hashlib
|
||||
import json
|
||||
from html import escape as html_escape
|
||||
from pathlib import Path
|
||||
|
||||
import qrcode
|
||||
@@ -45,7 +46,8 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
|
||||
summary = await get_user_referral_summary(db, db_user.id)
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
|
||||
bot_referral_link = settings.get_bot_referral_link(db_user.referral_code, bot_username)
|
||||
cabinet_referral_link = settings.get_cabinet_referral_link(db_user.referral_code)
|
||||
|
||||
referral_text = (
|
||||
texts.t('REFERRAL_PROGRAM_TITLE', '👥 <b>Реферальная программа</b>')
|
||||
@@ -114,13 +116,27 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
|
||||
'• Комиссия с каждого пополнения реферала: <b>{percent}%</b>',
|
||||
).format(percent=get_effective_referral_commission_percent(db_user))
|
||||
|
||||
referral_text += '\n' + commission_line + '\n\n'
|
||||
|
||||
# Show bot link
|
||||
referral_text += (
|
||||
texts.t('REFERRAL_BOT_LINK_TITLE', '🤖 <b>Ссылка на бота:</b>')
|
||||
+ f'\n<code>{html_escape(bot_referral_link)}</code>\n'
|
||||
)
|
||||
|
||||
# Show cabinet link if configured
|
||||
if cabinet_referral_link:
|
||||
referral_text += (
|
||||
'\n'
|
||||
+ texts.t('REFERRAL_CABINET_LINK_TITLE', '🌐 <b>Ссылка на кабинет:</b>')
|
||||
+ f'\n<code>{html_escape(cabinet_referral_link)}</code>\n'
|
||||
)
|
||||
|
||||
referral_text += (
|
||||
'\n'
|
||||
+ commission_line
|
||||
+ '\n\n'
|
||||
+ texts.t('REFERRAL_LINK_TITLE', '🔗 <b>Ваша реферальная ссылка:</b>')
|
||||
+ f'\n<code>{referral_link}</code>\n\n'
|
||||
+ texts.t('REFERRAL_CODE_TITLE', '🆔 <b>Ваш код:</b> <code>{code}</code>').format(code=db_user.referral_code)
|
||||
+ texts.t('REFERRAL_CODE_TITLE', '🆔 <b>Ваш код:</b> <code>{code}</code>').format(
|
||||
code=html_escape(str(db_user.referral_code or ''))
|
||||
)
|
||||
+ '\n\n'
|
||||
)
|
||||
|
||||
@@ -158,7 +174,7 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
|
||||
).format(
|
||||
reason=reason_text,
|
||||
amount=texts.format_price(earning['amount_kopeks']),
|
||||
referral_name=earning['referral_name'],
|
||||
referral_name=html_escape(str(earning['referral_name'] or '')),
|
||||
)
|
||||
+ '\n'
|
||||
)
|
||||
@@ -243,15 +259,15 @@ async def show_referral_qr(
|
||||
await callback.answer()
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
|
||||
bot_referral_link = settings.get_bot_referral_link(db_user.referral_code, bot_username)
|
||||
|
||||
qr_dir = Path('data') / 'referral_qr'
|
||||
qr_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
link_hash = hashlib.md5(referral_link.encode()).hexdigest()[:8]
|
||||
link_hash = hashlib.md5(bot_referral_link.encode()).hexdigest()[:8]
|
||||
file_path = qr_dir / f'{db_user.id}_{link_hash}.png'
|
||||
if not file_path.exists():
|
||||
img = qrcode.make(referral_link)
|
||||
img = qrcode.make(bot_referral_link)
|
||||
img.save(file_path)
|
||||
|
||||
photo = FSInputFile(file_path)
|
||||
@@ -259,25 +275,28 @@ async def show_referral_qr(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_referrals')]]
|
||||
)
|
||||
|
||||
caption = texts.t(
|
||||
'REFERRAL_QR_BOT_LINK',
|
||||
'🤖 Ссылка на бота:\n{link}',
|
||||
).format(link=bot_referral_link)
|
||||
|
||||
cabinet_referral_link = settings.get_cabinet_referral_link(db_user.referral_code)
|
||||
if cabinet_referral_link:
|
||||
caption += '\n\n' + texts.t(
|
||||
'REFERRAL_QR_CABINET_LINK',
|
||||
'🌐 Ссылка на кабинет:\n{link}',
|
||||
).format(link=cabinet_referral_link)
|
||||
|
||||
try:
|
||||
await callback.message.edit_media(
|
||||
types.InputMediaPhoto(
|
||||
media=photo,
|
||||
caption=texts.t(
|
||||
'REFERRAL_LINK_CAPTION',
|
||||
'🔗 Ваша реферальная ссылка:\n{link}',
|
||||
).format(link=referral_link),
|
||||
),
|
||||
types.InputMediaPhoto(media=photo, caption=caption),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
await callback.message.delete()
|
||||
await callback.message.answer_photo(
|
||||
photo,
|
||||
caption=texts.t(
|
||||
'REFERRAL_LINK_CAPTION',
|
||||
'🔗 Ваша реферальная ссылка:\n{link}',
|
||||
).format(link=referral_link),
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
@@ -322,7 +341,7 @@ async def show_detailed_referral_list(callback: types.CallbackQuery, db_user: Us
|
||||
texts.t(
|
||||
'REFERRAL_LIST_ITEM_HEADER',
|
||||
'{index}. {status} <b>{name}</b>',
|
||||
).format(index=i, status=status_emoji, name=referral['full_name'])
|
||||
).format(index=i, status=status_emoji, name=html_escape(str(referral['full_name'] or '')))
|
||||
+ '\n'
|
||||
)
|
||||
text += (
|
||||
@@ -454,7 +473,7 @@ async def show_referral_analytics(callback: types.CallbackQuery, db_user: User,
|
||||
'{index}. {name}: {amount} ({count} начислений)',
|
||||
).format(
|
||||
index=i,
|
||||
name=ref['referral_name'],
|
||||
name=html_escape(str(ref['referral_name'] or '')),
|
||||
amount=texts.format_price(ref['total_earned_kopeks']),
|
||||
count=ref['earnings_count'],
|
||||
)
|
||||
@@ -485,7 +504,8 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User):
|
||||
return
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
|
||||
bot_referral_link = settings.get_bot_referral_link(db_user.referral_code, bot_username)
|
||||
cabinet_referral_link = settings.get_cabinet_referral_link(db_user.referral_code)
|
||||
|
||||
invite_text = texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!')
|
||||
|
||||
@@ -507,14 +527,29 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User):
|
||||
+ texts.t('REFERRAL_INVITE_FEATURE_SECURE', '🔒 Надежная защита')
|
||||
+ '\n\n'
|
||||
+ texts.t('REFERRAL_INVITE_LINK_PROMPT', '👇 Переходи по ссылке:')
|
||||
+ f'\n{referral_link}'
|
||||
+ f'\n{bot_referral_link}'
|
||||
)
|
||||
|
||||
if cabinet_referral_link:
|
||||
invite_text += (
|
||||
'\n\n'
|
||||
+ texts.t('REFERRAL_INVITE_CABINET_LINK', '🌐 Или через личный кабинет:')
|
||||
+ f'\n{cabinet_referral_link}'
|
||||
)
|
||||
|
||||
# Compact share text for switch_inline_query (256-char limit)
|
||||
share_text = invite_text
|
||||
if len(share_text) > 256:
|
||||
share_text = texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!') + f'\n\n👇 {bot_referral_link}'
|
||||
if cabinet_referral_link and len(share_text) + len(cabinet_referral_link) + 5 <= 256:
|
||||
share_text += f'\n🌐 {cabinet_referral_link}'
|
||||
share_text = share_text[:256]
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t('REFERRAL_SHARE_BUTTON', '📤 Поделиться'), switch_inline_query=invite_text
|
||||
text=texts.t('REFERRAL_SHARE_BUTTON', '📤 Поделиться'), switch_inline_query=share_text
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_referrals')],
|
||||
@@ -531,7 +566,7 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User):
|
||||
'Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:',
|
||||
)
|
||||
+ '\n\n'
|
||||
f'<code>{invite_text}</code>'
|
||||
f'<code>{html_escape(invite_text)}</code>'
|
||||
),
|
||||
keyboard,
|
||||
)
|
||||
@@ -584,7 +619,7 @@ async def show_withdrawal_info(callback: types.CallbackQuery, db_user: User, db:
|
||||
]
|
||||
)
|
||||
else:
|
||||
text += f'❌ {reason}\n'
|
||||
text += f'❌ {html_escape(str(reason))}\n'
|
||||
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_referrals')])
|
||||
|
||||
@@ -746,7 +781,7 @@ async def process_payment_details(message: types.Message, db_user: User, db: Asy
|
||||
)
|
||||
text += (
|
||||
texts.t('REFERRAL_WITHDRAWAL_CONFIRM_DETAILS', '💳 Реквизиты:\n<code>{details}</code>').format(
|
||||
details=payment_details
|
||||
details=html_escape(payment_details)
|
||||
)
|
||||
+ '\n\n'
|
||||
)
|
||||
@@ -792,16 +827,18 @@ async def confirm_withdrawal_request(callback: types.CallbackQuery, db_user: Use
|
||||
# Отправляем уведомление админам
|
||||
analysis = json.loads(request.risk_analysis) if request.risk_analysis else {}
|
||||
|
||||
user_id_display = db_user.telegram_id or db_user.email or f'#{db_user.id}'
|
||||
user_id_display = html_escape(str(db_user.telegram_id or db_user.email or f'#{db_user.id}'))
|
||||
safe_name = html_escape(db_user.full_name or 'Без имени')
|
||||
safe_details = html_escape(payment_details)
|
||||
admin_text = f"""
|
||||
🔔 <b>Новая заявка на вывод #{request.id}</b>
|
||||
|
||||
👤 Пользователь: {db_user.full_name or 'Без имени'}
|
||||
👤 Пользователь: {safe_name}
|
||||
🆔 ID: <code>{user_id_display}</code>
|
||||
💰 Сумма: <b>{amount_kopeks / 100:.0f}₽</b>
|
||||
|
||||
💳 Реквизиты:
|
||||
<code>{payment_details}</code>
|
||||
<code>{safe_details}</code>
|
||||
|
||||
{referral_withdrawal_service.format_analysis_for_admin(analysis)}
|
||||
"""
|
||||
|
||||
@@ -354,10 +354,6 @@ async def _handle_guest_purchase_payment(
|
||||
stars_amount=stars_amount,
|
||||
purchase_token_prefix=purchase_token[:5],
|
||||
)
|
||||
elif result is False:
|
||||
await message.answer(
|
||||
'❌ Произошла ошибка при обработке подарочной подписки. Обратитесь в поддержку.',
|
||||
)
|
||||
else:
|
||||
logger.error('try_fulfill_guest_purchase returned None for Stars gift', payload=payload)
|
||||
await message.answer('❌ Ошибка обработки платежа. Обратитесь в поддержку.')
|
||||
|
||||
@@ -888,7 +888,8 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
)
|
||||
|
||||
user.status = UserStatus.ACTIVE.value
|
||||
# Keep status=DELETED so complete_registration properly handles
|
||||
# referral assignment and status change (not the "already active" branch)
|
||||
user.balance_kopeks = 0
|
||||
user.remnawave_uuid = None
|
||||
user.has_had_paid_subscription = False
|
||||
@@ -1191,7 +1192,7 @@ async def process_rules_accept(callback: types.CallbackQuery, state: FSMContext,
|
||||
reply_markup=get_rules_keyboard(language),
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -1302,7 +1303,7 @@ async def process_privacy_policy_accept(callback: types.CallbackQuery, state: FS
|
||||
reply_markup=get_privacy_policy_keyboard(language),
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept)
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -1392,7 +1393,7 @@ async def process_referral_code_skip(callback: types.CallbackQuery, state: FSMCo
|
||||
await callback.message.edit_text(
|
||||
texts.t('REGISTRATION_COMPLETING', '✅ Завершаем регистрацию...'), reply_markup=None
|
||||
)
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
|
||||
@@ -619,8 +619,6 @@ async def show_trial_offer(callback: types.CallbackQuery, db_user: User, db: Asy
|
||||
trial_tariff_id = settings.get_trial_tariff_id()
|
||||
if trial_tariff_id > 0:
|
||||
trial_tariff = await get_tariff(db, trial_tariff_id)
|
||||
if trial_tariff and not trial_tariff.is_active:
|
||||
trial_tariff = None
|
||||
|
||||
if trial_tariff:
|
||||
trial_traffic = trial_tariff.traffic_limit_gb
|
||||
@@ -811,14 +809,36 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
|
||||
user_balance_kopeks = getattr(db_user, 'balance_kopeks', 0) or 0
|
||||
can_pay_from_balance = user_balance_kopeks >= trial_price_kopeks
|
||||
|
||||
traffic_label = 'Безлимит' if settings.TRIAL_TRAFFIC_LIMIT_GB == 0 else f'{settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ'
|
||||
# Берём параметры из триального тарифа если доступен
|
||||
paid_trial_days = settings.TRIAL_DURATION_DAYS
|
||||
paid_trial_traffic = settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
paid_trial_devices = settings.TRIAL_DEVICE_LIMIT
|
||||
if settings.is_tariffs_mode():
|
||||
try:
|
||||
from app.database.crud.tariff import get_tariff_by_id as get_tariff, get_trial_tariff
|
||||
|
||||
paid_trial_tariff = await get_trial_tariff(db)
|
||||
if not paid_trial_tariff:
|
||||
trial_tariff_id = settings.get_trial_tariff_id()
|
||||
if trial_tariff_id > 0:
|
||||
paid_trial_tariff = await get_tariff(db, trial_tariff_id)
|
||||
if paid_trial_tariff:
|
||||
paid_trial_traffic = paid_trial_tariff.traffic_limit_gb
|
||||
paid_trial_devices = paid_trial_tariff.device_limit
|
||||
tariff_trial_days = getattr(paid_trial_tariff, 'trial_duration_days', None)
|
||||
if tariff_trial_days:
|
||||
paid_trial_days = tariff_trial_days
|
||||
except Exception as e:
|
||||
logger.error('Ошибка получения триального тарифа для платного триала', error=e)
|
||||
|
||||
traffic_label = 'Безлимит' if paid_trial_traffic == 0 else f'{paid_trial_traffic} ГБ'
|
||||
|
||||
message_lines = [
|
||||
texts.t('PAID_TRIAL_HEADER', '⚡ <b>Пробная подписка</b>'),
|
||||
'',
|
||||
f'📅 {texts.t("PERIOD", "Период")}: {settings.TRIAL_DURATION_DAYS} {texts.t("DAYS", "дней")}',
|
||||
f'📅 {texts.t("PERIOD", "Период")}: {paid_trial_days} {texts.t("DAYS", "дней")}',
|
||||
f'📊 {texts.t("TRAFFIC", "Трафик")}: {traffic_label}',
|
||||
f'📱 {texts.t("DEVICES", "Устройства")}: {settings.TRIAL_DEVICE_LIMIT}',
|
||||
f'📱 {texts.t("DEVICES", "Устройства")}: {paid_trial_devices}',
|
||||
'',
|
||||
f'💰 {texts.t("PRICE", "Стоимость")}: {settings.format_price(trial_price_kopeks)}',
|
||||
f'💳 {texts.t("YOUR_BALANCE", "Ваш баланс")}: {settings.format_price(user_balance_kopeks)}',
|
||||
@@ -865,6 +885,7 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
|
||||
from app.database.crud.tariff import get_tariff_by_id, get_trial_tariff
|
||||
|
||||
# Сначала проверяем тариф из БД с флагом is_trial_available
|
||||
# Триальный тариф может быть неактивным — используется для отдельных лимитов
|
||||
trial_tariff = await get_trial_tariff(db)
|
||||
|
||||
# Если не найден в БД, проверяем настройку TRIAL_TARIFF_ID
|
||||
@@ -872,8 +893,6 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
|
||||
trial_tariff_id = settings.get_trial_tariff_id()
|
||||
if trial_tariff_id > 0:
|
||||
trial_tariff = await get_tariff_by_id(db, trial_tariff_id)
|
||||
if trial_tariff and not trial_tariff.is_active:
|
||||
trial_tariff = None
|
||||
|
||||
if trial_tariff:
|
||||
trial_traffic_limit = trial_tariff.traffic_limit_gb
|
||||
@@ -3044,10 +3063,47 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user:
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_devices = settings.get_disabled_mode_device_limit()
|
||||
|
||||
# Получаем параметры из триального тарифа (аналогично бесплатному триалу)
|
||||
trial_tariff = None
|
||||
trial_traffic_limit = None
|
||||
trial_device_limit = forced_devices
|
||||
trial_squads = None
|
||||
tariff_id_for_trial = None
|
||||
trial_duration = None
|
||||
|
||||
if settings.is_tariffs_mode():
|
||||
try:
|
||||
from app.database.crud.tariff import get_tariff_by_id as _get_tariff, get_trial_tariff
|
||||
|
||||
trial_tariff = await get_trial_tariff(db)
|
||||
if not trial_tariff:
|
||||
trial_tariff_id = settings.get_trial_tariff_id()
|
||||
if trial_tariff_id > 0:
|
||||
trial_tariff = await _get_tariff(db, trial_tariff_id)
|
||||
if trial_tariff:
|
||||
trial_traffic_limit = trial_tariff.traffic_limit_gb
|
||||
trial_device_limit = trial_tariff.device_limit
|
||||
trial_squads = trial_tariff.allowed_squads or []
|
||||
tariff_id_for_trial = trial_tariff.id
|
||||
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
|
||||
if tariff_trial_days:
|
||||
trial_duration = tariff_trial_days
|
||||
logger.info(
|
||||
'Платный триал с баланса: используем тариф',
|
||||
trial_tariff_name=trial_tariff.name,
|
||||
trial_tariff_id=trial_tariff.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка получения триального тарифа для платного триала', error=e)
|
||||
|
||||
subscription = await create_trial_subscription(
|
||||
db,
|
||||
db_user.id,
|
||||
device_limit=forced_devices,
|
||||
duration_days=trial_duration,
|
||||
device_limit=trial_device_limit,
|
||||
traffic_limit_gb=trial_traffic_limit,
|
||||
connected_squads=trial_squads,
|
||||
tariff_id=tariff_id_for_trial,
|
||||
)
|
||||
|
||||
await db.refresh(db_user)
|
||||
@@ -3365,28 +3421,63 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
try:
|
||||
payment_service = PaymentService(callback.bot)
|
||||
|
||||
# Получаем случайный сквад для триала
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
# Получаем параметры из триального тарифа
|
||||
trial_duration = settings.TRIAL_DURATION_DAYS
|
||||
trial_traffic = settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
trial_devices = settings.TRIAL_DEVICE_LIMIT
|
||||
trial_squads_list = []
|
||||
tariff_id_for_trial = None
|
||||
|
||||
trial_squad_uuid = await get_random_trial_squad_uuid(db)
|
||||
if settings.is_tariffs_mode():
|
||||
try:
|
||||
from app.database.crud.tariff import get_tariff_by_id as _get_tariff, get_trial_tariff
|
||||
|
||||
trial_tariff = await get_trial_tariff(db)
|
||||
if not trial_tariff:
|
||||
trial_tariff_id = settings.get_trial_tariff_id()
|
||||
if trial_tariff_id > 0:
|
||||
trial_tariff = await _get_tariff(db, trial_tariff_id)
|
||||
if trial_tariff:
|
||||
trial_traffic = trial_tariff.traffic_limit_gb
|
||||
trial_devices = trial_tariff.device_limit
|
||||
trial_squads_list = trial_tariff.allowed_squads or []
|
||||
tariff_id_for_trial = trial_tariff.id
|
||||
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
|
||||
if tariff_trial_days:
|
||||
trial_duration = tariff_trial_days
|
||||
logger.info(
|
||||
'Платный триал через платёжку: используем тариф',
|
||||
trial_tariff_name=trial_tariff.name,
|
||||
trial_tariff_id=trial_tariff.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка получения триального тарифа для платного триала', error=e)
|
||||
|
||||
# Если тариф не задал серверы, получаем случайный сквад
|
||||
if not trial_squads_list:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
trial_squad_uuid = await get_random_trial_squad_uuid(db)
|
||||
trial_squads_list = [trial_squad_uuid] if trial_squad_uuid else []
|
||||
|
||||
# Создаем pending триальную подписку
|
||||
pending_subscription = await create_pending_trial_subscription(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
duration_days=settings.TRIAL_DURATION_DAYS,
|
||||
traffic_limit_gb=settings.TRIAL_TRAFFIC_LIMIT_GB,
|
||||
device_limit=settings.TRIAL_DEVICE_LIMIT,
|
||||
connected_squads=[trial_squad_uuid] if trial_squad_uuid else [],
|
||||
duration_days=trial_duration,
|
||||
traffic_limit_gb=trial_traffic,
|
||||
device_limit=trial_devices,
|
||||
connected_squads=trial_squads_list,
|
||||
payment_method=f'trial_{payment_method}',
|
||||
total_price_kopeks=trial_price_kopeks,
|
||||
tariff_id=tariff_id_for_trial,
|
||||
)
|
||||
|
||||
if not pending_subscription:
|
||||
await callback.answer('❌ Не удалось подготовить заказ. Попробуйте позже.', show_alert=True)
|
||||
return
|
||||
|
||||
traffic_label = 'Безлимит' if settings.TRIAL_TRAFFIC_LIMIT_GB == 0 else f'{settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ'
|
||||
traffic_label = 'Безлимит' if trial_traffic == 0 else f'{trial_traffic} ГБ'
|
||||
|
||||
if payment_method == 'stars':
|
||||
# Оплата через Telegram Stars
|
||||
@@ -3395,11 +3486,11 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
await callback.bot.send_invoice(
|
||||
chat_id=callback.from_user.id,
|
||||
title=texts.t('PAID_TRIAL_INVOICE_TITLE', 'Пробная подписка на {days} дней').format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
days=trial_duration
|
||||
),
|
||||
description=(
|
||||
f'{texts.t("PERIOD", "Период")}: {settings.TRIAL_DURATION_DAYS} {texts.t("DAYS", "дней")}\n'
|
||||
f'{texts.t("DEVICES", "Устройства")}: {settings.TRIAL_DEVICE_LIMIT}\n'
|
||||
f'{texts.t("PERIOD", "Период")}: {trial_duration} {texts.t("DAYS", "дней")}\n'
|
||||
f'{texts.t("DEVICES", "Устройства")}: {trial_devices}\n'
|
||||
f'{texts.t("TRAFFIC", "Трафик")}: {traffic_label}'
|
||||
),
|
||||
payload=f'trial_{pending_subscription.id}',
|
||||
@@ -3426,7 +3517,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
db=db,
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
days=trial_duration
|
||||
),
|
||||
user_id=db_user.id,
|
||||
metadata={
|
||||
@@ -3465,7 +3556,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
days=trial_duration
|
||||
),
|
||||
metadata={
|
||||
'type': 'trial',
|
||||
@@ -3514,7 +3605,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
amount_usd=amount_usd,
|
||||
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
|
||||
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
days=trial_duration
|
||||
),
|
||||
payload=f'trial_{pending_subscription.id}_{db_user.id}',
|
||||
)
|
||||
@@ -3562,7 +3653,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
days=trial_duration
|
||||
),
|
||||
language=db_user.language,
|
||||
)
|
||||
@@ -3600,7 +3691,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
days=trial_duration
|
||||
),
|
||||
language=db_user.language,
|
||||
)
|
||||
@@ -3637,7 +3728,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
days=trial_duration
|
||||
),
|
||||
language=db_user.language,
|
||||
)
|
||||
@@ -3675,7 +3766,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
days=trial_duration
|
||||
),
|
||||
language=db_user.language,
|
||||
)
|
||||
@@ -3719,7 +3810,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
days=trial_duration
|
||||
),
|
||||
language=db_user.language,
|
||||
payment_method_code=method_code,
|
||||
|
||||
@@ -41,11 +41,13 @@ class AsyncHTTPClient:
|
||||
auth_provider: AuthProvider,
|
||||
default_headers: dict[str, str] | None = None,
|
||||
timeout: float = 10.0,
|
||||
proxy_url: str | None = None,
|
||||
):
|
||||
self.base_url = base_url
|
||||
self.auth_provider = auth_provider
|
||||
self.default_headers = default_headers or {}
|
||||
self.timeout = timeout
|
||||
self.proxy_url = proxy_url
|
||||
self._refresh_lock = asyncio.Lock()
|
||||
self.max_retries = 2 # Same as PHP AuthenticationPlugin::RETRY_LIMIT
|
||||
|
||||
@@ -124,7 +126,7 @@ class AsyncHTTPClient:
|
||||
if json_data is not None:
|
||||
request_kwargs['json'] = json_data
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx.AsyncClient(proxy=self.proxy_url) as client:
|
||||
# Initial request
|
||||
response = await client.request(**request_kwargs)
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ class AuthProviderImpl(AuthProvider):
|
||||
base_url: str = 'https://lknpd.nalog.ru/api',
|
||||
storage_path: str | None = None,
|
||||
device_id: str | None = None,
|
||||
proxy_url: str | None = None,
|
||||
):
|
||||
self.base_url_v1 = f'{base_url}/v1'
|
||||
self.base_url_v2 = f'{base_url}/v2'
|
||||
@@ -47,6 +48,7 @@ class AuthProviderImpl(AuthProvider):
|
||||
self.device_id = device_id or generate_device_id()
|
||||
self.device_info = DeviceInfo(sourceDeviceId=self.device_id)
|
||||
self._token_data: dict[str, Any] | None = None
|
||||
self.proxy_url = proxy_url
|
||||
|
||||
# Default headers similar to PHP Authenticator
|
||||
self.default_headers = {
|
||||
@@ -130,7 +132,7 @@ class AuthProviderImpl(AuthProvider):
|
||||
'deviceInfo': self.device_info.model_dump(),
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx.AsyncClient(proxy=self.proxy_url) as client:
|
||||
response = await client.post(
|
||||
f'{self.base_url_v1}/auth/lkfl',
|
||||
json=request_data,
|
||||
@@ -165,7 +167,7 @@ class AuthProviderImpl(AuthProvider):
|
||||
'requireTpToBeActive': True,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx.AsyncClient(proxy=self.proxy_url) as client:
|
||||
response = await client.post(
|
||||
f'{self.base_url_v2}/auth/challenge/sms/start',
|
||||
json=request_data,
|
||||
@@ -200,7 +202,7 @@ class AuthProviderImpl(AuthProvider):
|
||||
'deviceInfo': self.device_info.model_dump(),
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx.AsyncClient(proxy=self.proxy_url) as client:
|
||||
response = await client.post(
|
||||
f'{self.base_url_v1}/auth/challenge/sms/verify',
|
||||
json=request_data,
|
||||
@@ -233,7 +235,7 @@ class AuthProviderImpl(AuthProvider):
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx.AsyncClient(proxy=self.proxy_url) as client:
|
||||
response = await client.post(
|
||||
f'{self.base_url_v1}/auth/token',
|
||||
json=request_data,
|
||||
|
||||
@@ -36,6 +36,7 @@ class Client:
|
||||
storage_path: str | None = None,
|
||||
device_id: str | None = None,
|
||||
timeout: float = 10.0,
|
||||
proxy_url: str | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize Moy Nalog API client.
|
||||
@@ -45,6 +46,7 @@ class Client:
|
||||
storage_path: Optional file path for token storage
|
||||
device_id: Optional device ID (auto-generated if not provided)
|
||||
timeout: HTTP request timeout in seconds
|
||||
proxy_url: Optional SOCKS proxy URL for routing traffic
|
||||
"""
|
||||
self.base_url = base_url
|
||||
self.timeout = timeout
|
||||
@@ -54,6 +56,7 @@ class Client:
|
||||
base_url=base_url,
|
||||
storage_path=storage_path,
|
||||
device_id=device_id,
|
||||
proxy_url=proxy_url,
|
||||
)
|
||||
|
||||
# Initialize HTTP client with auth middleware
|
||||
@@ -67,6 +70,7 @@ class Client:
|
||||
'Referrer': 'https://lknpd.nalog.ru/auth/login',
|
||||
},
|
||||
timeout=timeout,
|
||||
proxy_url=proxy_url,
|
||||
)
|
||||
|
||||
# User profile data (for receipt operations)
|
||||
|
||||
@@ -1317,6 +1317,11 @@
|
||||
"REFERRAL_INVITE_TITLE": "🎉 Join the VPN service!",
|
||||
"REFERRAL_LINK_CAPTION": "🔗 Your referral link:\n{link}",
|
||||
"REFERRAL_LINK_TITLE": "🔗 <b>Your referral link:</b>",
|
||||
"REFERRAL_BOT_LINK_TITLE": "🤖 <b>Bot link:</b>",
|
||||
"REFERRAL_CABINET_LINK_TITLE": "🌐 <b>Cabinet link:</b>",
|
||||
"REFERRAL_QR_BOT_LINK": "🤖 Bot link:\n{link}",
|
||||
"REFERRAL_QR_CABINET_LINK": "🌐 Cabinet link:\n{link}",
|
||||
"REFERRAL_INVITE_CABINET_LINK": "🌐 Or via personal cabinet:",
|
||||
"REFERRAL_LIST_BUTTON": "👥 Referral list",
|
||||
"REFERRAL_LIST_EMPTY": "📋 You have no referrals yet.\n\nShare your referral link to start earning!",
|
||||
"REFERRAL_LIST_HEADER": "👥 <b>Your referrals</b> (page {current}/{total})",
|
||||
|
||||
@@ -1338,6 +1338,11 @@
|
||||
"REFERRAL_INVITE_TITLE": "🎉 به سرویس VPN بپیوند!",
|
||||
"REFERRAL_LINK_CAPTION": "🔗 لینک دعوت شما:\n{link}",
|
||||
"REFERRAL_LINK_TITLE": "🔗 <b>لینک دعوت شما:</b>",
|
||||
"REFERRAL_BOT_LINK_TITLE": "🤖 <b>لینک ربات:</b>",
|
||||
"REFERRAL_CABINET_LINK_TITLE": "🌐 <b>لینک کابینت:</b>",
|
||||
"REFERRAL_QR_BOT_LINK": "🤖 لینک ربات:\n{link}",
|
||||
"REFERRAL_QR_CABINET_LINK": "🌐 لینک کابینت:\n{link}",
|
||||
"REFERRAL_INVITE_CABINET_LINK": "🌐 یا از طریق کابینت شخصی:",
|
||||
"REFERRAL_LIST_BUTTON": "👥 لیست دعوتشدگان",
|
||||
"REFERRAL_LIST_EMPTY": "📋 هنوز دعوتشدهای ندارید.\n\nلینک دعوت خود را به اشتراک بگذارید!",
|
||||
"REFERRAL_LIST_HEADER": "👥 <b>دعوتشدگان شما</b> (صفحه {current}/{total})",
|
||||
|
||||
@@ -1338,6 +1338,11 @@
|
||||
"REFERRAL_INVITE_TITLE": "🎉 Присоединяйся к VPN сервису!",
|
||||
"REFERRAL_LINK_CAPTION": "🔗 Ваша реферальная ссылка:\n{link}",
|
||||
"REFERRAL_LINK_TITLE": "🔗 <b>Ваша реферальная ссылка:</b>",
|
||||
"REFERRAL_BOT_LINK_TITLE": "🤖 <b>Ссылка на бота:</b>",
|
||||
"REFERRAL_CABINET_LINK_TITLE": "🌐 <b>Ссылка на кабинет:</b>",
|
||||
"REFERRAL_QR_BOT_LINK": "🤖 Ссылка на бота:\n{link}",
|
||||
"REFERRAL_QR_CABINET_LINK": "🌐 Ссылка на кабинет:\n{link}",
|
||||
"REFERRAL_INVITE_CABINET_LINK": "🌐 Или через личный кабинет:",
|
||||
"REFERRAL_LIST_BUTTON": "👥 Список рефералов",
|
||||
"REFERRAL_LIST_EMPTY": "📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!",
|
||||
"REFERRAL_LIST_HEADER": "👥 <b>Ваши рефералы</b> (стр. {current}/{total})",
|
||||
|
||||
@@ -1254,6 +1254,11 @@
|
||||
"REFERRAL_INVITE_TITLE": "🎉 Приєднуйся до VPN сервісу!",
|
||||
"REFERRAL_LINK_CAPTION": "🔗 Ваше реферальне посилання:\n{link}",
|
||||
"REFERRAL_LINK_TITLE": "🔗 <b>Ваше реферальне посилання:</b>",
|
||||
"REFERRAL_BOT_LINK_TITLE": "🤖 <b>Посилання на бота:</b>",
|
||||
"REFERRAL_CABINET_LINK_TITLE": "🌐 <b>Посилання на кабінет:</b>",
|
||||
"REFERRAL_QR_BOT_LINK": "🤖 Посилання на бота:\n{link}",
|
||||
"REFERRAL_QR_CABINET_LINK": "🌐 Посилання на кабінет:\n{link}",
|
||||
"REFERRAL_INVITE_CABINET_LINK": "🌐 Або через особистий кабінет:",
|
||||
"REFERRAL_LIST_BUTTON": "👥 Список рефералів",
|
||||
"REFERRAL_LIST_EMPTY": "📋 У вас поки немає рефералів.\n\nПоділіться своїм реферальним посиланням, щоб почати заробляти!",
|
||||
"REFERRAL_LIST_HEADER": "👥 <b>Ваші реферали</b> (стор. {current}/{total})",
|
||||
|
||||
@@ -1252,6 +1252,11 @@
|
||||
"REFERRAL_INVITE_TITLE": "🎉加入VPN服务!",
|
||||
"REFERRAL_LINK_CAPTION": "🔗您的推荐链接:\n{link}",
|
||||
"REFERRAL_LINK_TITLE": "🔗<b>您的推荐链接:</b>",
|
||||
"REFERRAL_BOT_LINK_TITLE": "🤖<b>机器人链接:</b>",
|
||||
"REFERRAL_CABINET_LINK_TITLE": "🌐<b>控制面板链接:</b>",
|
||||
"REFERRAL_QR_BOT_LINK": "🤖机器人链接:\n{link}",
|
||||
"REFERRAL_QR_CABINET_LINK": "🌐控制面板链接:\n{link}",
|
||||
"REFERRAL_INVITE_CABINET_LINK": "🌐或通过个人面板:",
|
||||
"REFERRAL_LIST_BUTTON": "👥推荐列表",
|
||||
"REFERRAL_LIST_EMPTY": "📋您目前没有推荐。\n\n分享您的推荐链接开始赚钱吧!",
|
||||
"REFERRAL_LIST_HEADER": "👥<b>您的推荐</b>(第{current}/{total}页)",
|
||||
|
||||
@@ -61,6 +61,7 @@ class BroadcastConfig:
|
||||
selected_buttons: list[str]
|
||||
media: BroadcastMediaConfig | None = None
|
||||
initiator_name: str | None = None
|
||||
custom_buttons: list[dict] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -179,7 +180,7 @@ class BroadcastService:
|
||||
await self._mark_finished(broadcast_id, sent_count, failed_count, blocked_count, cancelled=False)
|
||||
return
|
||||
|
||||
keyboard = self._build_keyboard(config.selected_buttons)
|
||||
keyboard = self._build_keyboard(config.selected_buttons, config.custom_buttons)
|
||||
|
||||
logger.info(
|
||||
'Рассылка : начинаем отправку получателям (batch delay=s)',
|
||||
@@ -358,10 +359,14 @@ class BroadcastService:
|
||||
|
||||
return sent_count, failed_count, blocked_count, False
|
||||
|
||||
def _build_keyboard(self, selected_buttons: list[str] | None) -> InlineKeyboardMarkup | None:
|
||||
def _build_keyboard(
|
||||
self,
|
||||
selected_buttons: list[str] | None,
|
||||
custom_buttons: list[dict] | None = None,
|
||||
) -> InlineKeyboardMarkup | None:
|
||||
if selected_buttons is None:
|
||||
selected_buttons = []
|
||||
return create_broadcast_keyboard(selected_buttons)
|
||||
return create_broadcast_keyboard(selected_buttons, custom_buttons=custom_buttons)
|
||||
|
||||
async def _deliver_message(
|
||||
self,
|
||||
|
||||
@@ -7,7 +7,7 @@ from datetime import UTC, datetime, timedelta
|
||||
from typing import Literal
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy import func, or_, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -25,6 +25,7 @@ from app.database.models import (
|
||||
LandingPage,
|
||||
PaymentMethod,
|
||||
Tariff,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
@@ -46,11 +47,10 @@ async def _send_admin_notification(
|
||||
if not getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) or not settings.BOT_TOKEN:
|
||||
return
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
async with Bot(token=settings.BOT_TOKEN) as bot:
|
||||
async with create_bot() as bot:
|
||||
service = AdminNotificationService(bot)
|
||||
await service.send_guest_purchase_notification(
|
||||
purchase,
|
||||
@@ -177,6 +177,97 @@ async def create_purchase(
|
||||
return purchase
|
||||
|
||||
|
||||
async def _create_nalogo_receipt_for_purchase(
|
||||
db: AsyncSession,
|
||||
purchase: GuestPurchase,
|
||||
user: User,
|
||||
transaction: Transaction | None = None,
|
||||
) -> None:
|
||||
"""Create NaloGO fiscal receipt for a guest purchase (best-effort)."""
|
||||
if not settings.is_nalogo_enabled():
|
||||
return
|
||||
|
||||
# Без payment_id нет dedup-ключа в Redis — нельзя гарантировать идемпотентность
|
||||
if not purchase.payment_id:
|
||||
logger.warning(
|
||||
'Cannot create NaloGO receipt: purchase has no payment_id',
|
||||
purchase_id=purchase.id,
|
||||
)
|
||||
return
|
||||
|
||||
# Нулевые/отрицательные суммы не фискализируем
|
||||
if purchase.amount_kopeks <= 0:
|
||||
return
|
||||
|
||||
# Защита от дублей: если у транзакции или покупки уже есть чек — не создаём новый
|
||||
if transaction and transaction.receipt_uuid:
|
||||
logger.info(
|
||||
'NaloGO receipt already exists for guest purchase (transaction)',
|
||||
purchase_id=purchase.id,
|
||||
receipt_uuid=transaction.receipt_uuid,
|
||||
)
|
||||
return
|
||||
|
||||
if purchase.receipt_uuid:
|
||||
logger.info(
|
||||
'NaloGO receipt already exists for guest purchase (purchase)',
|
||||
purchase_id=purchase.id,
|
||||
receipt_uuid=purchase.receipt_uuid,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
from app.services.nalogo_service import NaloGoService
|
||||
|
||||
nalogo_service = NaloGoService()
|
||||
if not nalogo_service.configured:
|
||||
return
|
||||
|
||||
amount_rubles = purchase.amount_kopeks / 100
|
||||
# Не передаём telegram_user_id в описание чека — privacy (VPN-сервис)
|
||||
receipt_name = settings.get_balance_payment_description(purchase.amount_kopeks)
|
||||
|
||||
receipt_uuid = await nalogo_service.create_receipt(
|
||||
name=receipt_name,
|
||||
amount=amount_rubles,
|
||||
quantity=1,
|
||||
payment_id=purchase.payment_id,
|
||||
telegram_user_id=user.telegram_id,
|
||||
amount_kopeks=purchase.amount_kopeks,
|
||||
)
|
||||
|
||||
if receipt_uuid:
|
||||
logger.info(
|
||||
'NaloGO receipt created for guest purchase',
|
||||
purchase_id=purchase.id,
|
||||
receipt_uuid=receipt_uuid,
|
||||
saved_to_transaction=transaction is not None,
|
||||
)
|
||||
# Всегда сохраняем receipt_uuid на purchase (persistent dedup)
|
||||
try:
|
||||
purchase.receipt_uuid = receipt_uuid
|
||||
purchase.receipt_created_at = datetime.now(UTC)
|
||||
if transaction:
|
||||
transaction.receipt_uuid = receipt_uuid
|
||||
transaction.receipt_created_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
logger.warning(
|
||||
'Failed to save receipt_uuid to purchase/transaction',
|
||||
purchase_id=purchase.id,
|
||||
receipt_uuid=receipt_uuid,
|
||||
)
|
||||
except Exception as exc:
|
||||
from app.utils.proxy import sanitize_proxy_error
|
||||
|
||||
logger.error(
|
||||
'Failed to create nalogo receipt for guest purchase',
|
||||
purchase_id=purchase.id,
|
||||
error=sanitize_proxy_error(exc),
|
||||
)
|
||||
|
||||
|
||||
async def fulfill_purchase(
|
||||
db: AsyncSession,
|
||||
purchase_token: str,
|
||||
@@ -272,6 +363,10 @@ async def fulfill_purchase(
|
||||
|
||||
await _send_admin_notification(purchase, notification_tariff_name, is_pending_activation=True)
|
||||
|
||||
# Создаем чек через NaloGO (деньги получены, чек нужен)
|
||||
await _create_nalogo_receipt_for_purchase(db, purchase, user)
|
||||
await db.refresh(purchase) # guard: inner rollback may expire the object
|
||||
|
||||
# Clear plaintext password after email delivery
|
||||
if purchase.cabinet_password:
|
||||
purchase.cabinet_password = None
|
||||
@@ -336,9 +431,10 @@ async def fulfill_purchase(
|
||||
await db.refresh(purchase, attribute_names=['landing', 'user'])
|
||||
|
||||
# Create transaction so promo group auto-assignment and contest tracking work
|
||||
transaction = None
|
||||
try:
|
||||
payment_method_enum = _resolve_payment_method(purchase.payment_method)
|
||||
await create_transaction(
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
@@ -364,6 +460,12 @@ async def fulfill_purchase(
|
||||
|
||||
await _send_admin_notification(purchase, notification_tariff_name, is_pending_activation=False)
|
||||
|
||||
# Создаем чек через NaloGO
|
||||
await _create_nalogo_receipt_for_purchase(db, purchase, user, transaction)
|
||||
|
||||
# Refresh purchase: если внутри nalogo helper был rollback, объект expired
|
||||
await db.refresh(purchase)
|
||||
|
||||
# Clear plaintext password after email delivery — no longer needed in DB
|
||||
if purchase.cabinet_password:
|
||||
purchase.cabinet_password = None
|
||||
@@ -392,24 +494,41 @@ async def fulfill_purchase(
|
||||
return purchase
|
||||
|
||||
|
||||
def _resolve_base_payment_method(method_str: str | None) -> str:
|
||||
"""Resolve base payment method string by stripping sub-option suffixes.
|
||||
|
||||
'yookassa_sbp' → 'yookassa', 'kassa_ai' → 'kassa_ai' (enum match keeps it),
|
||||
'platega_2' → 'platega'.
|
||||
"""
|
||||
if not method_str:
|
||||
return ''
|
||||
# If exact enum match, return as-is (handles 'telegram_stars', 'kassa_ai', etc.)
|
||||
try:
|
||||
PaymentMethod(method_str)
|
||||
return method_str
|
||||
except ValueError:
|
||||
pass
|
||||
# Strip sub-option suffix
|
||||
if '_' in method_str:
|
||||
base = method_str.rsplit('_', 1)[0]
|
||||
try:
|
||||
PaymentMethod(base)
|
||||
return base
|
||||
except ValueError:
|
||||
pass
|
||||
return method_str
|
||||
|
||||
|
||||
def _resolve_payment_method(method_str: str | None) -> PaymentMethod | None:
|
||||
"""Convert payment method string from GuestPurchase to PaymentMethod enum."""
|
||||
if not method_str:
|
||||
return None
|
||||
# Try exact match first (handles 'telegram_stars', 'kassa_ai', 'yookassa', etc.)
|
||||
base = _resolve_base_payment_method(method_str)
|
||||
try:
|
||||
return PaymentMethod(method_str)
|
||||
return PaymentMethod(base)
|
||||
except ValueError:
|
||||
pass
|
||||
# Strip sub-option suffix ('yookassa_sbp' → 'yookassa', 'platega_2' → 'platega')
|
||||
if '_' in method_str:
|
||||
base_method = method_str.split('_')[0]
|
||||
try:
|
||||
return PaymentMethod(base_method)
|
||||
except ValueError:
|
||||
pass
|
||||
logger.debug('Unknown payment method for transaction', method=method_str)
|
||||
return None
|
||||
logger.debug('Unknown payment method for transaction', method=method_str)
|
||||
return None
|
||||
|
||||
|
||||
def _mask_email(email: str) -> str:
|
||||
@@ -529,9 +648,9 @@ async def _find_or_create_user(
|
||||
resolved_telegram_id: int | None = pre_resolved_telegram_id
|
||||
if resolved_telegram_id is None:
|
||||
try:
|
||||
from aiogram import Bot
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
async with Bot(token=settings.BOT_TOKEN) as bot:
|
||||
async with create_bot() as bot:
|
||||
chat = await asyncio.wait_for(
|
||||
bot.get_chat(chat_id=f'@{username}'),
|
||||
timeout=5.0,
|
||||
@@ -639,11 +758,10 @@ async def _send_telegram_gift_notification(
|
||||
try:
|
||||
import html as html_mod
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
gift_from = ''
|
||||
if purchase.contact_value:
|
||||
safe_name = html_mod.escape(purchase.contact_value)
|
||||
@@ -674,10 +792,7 @@ async def _send_telegram_gift_notification(
|
||||
]
|
||||
)
|
||||
|
||||
async with Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
) as bot:
|
||||
async with create_bot() as bot:
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=text,
|
||||
@@ -1002,14 +1117,16 @@ async def retry_stuck_paid_purchases(
|
||||
stale_minutes: int = 5,
|
||||
limit: int = 10,
|
||||
max_age_hours: int = 24,
|
||||
max_retries: int = 20,
|
||||
) -> int:
|
||||
"""Retry fulfillment for purchases stuck in PAID status.
|
||||
|
||||
Finds purchases that have been in PAID status for longer than stale_minutes
|
||||
(but not older than max_age_hours) and attempts to fulfill them in isolated
|
||||
sessions. Returns the number of successfully retried purchases.
|
||||
(but not older than max_age_hours, and with retry_count < max_retries) and
|
||||
attempts to fulfill them in isolated sessions.
|
||||
|
||||
Purchases older than max_age_hours are left for manual investigation.
|
||||
Purchases exceeding max_retries are marked FAILED and an admin alert is sent.
|
||||
Returns the number of successfully retried purchases.
|
||||
"""
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
@@ -1018,10 +1135,12 @@ async def retry_stuck_paid_purchases(
|
||||
|
||||
# Collect tokens only — each retry gets its own session.
|
||||
# NULL paid_at is included via or_() as a safety net for data anomalies.
|
||||
# Filter retry_count < max_retries in SQL to avoid wasting LIMIT slots.
|
||||
result = await db.execute(
|
||||
select(GuestPurchase.token)
|
||||
.where(
|
||||
GuestPurchase.status == GuestPurchaseStatus.PAID.value,
|
||||
GuestPurchase.retry_count < max_retries,
|
||||
or_(GuestPurchase.paid_at < cutoff, GuestPurchase.paid_at.is_(None)),
|
||||
or_(GuestPurchase.paid_at > max_age, GuestPurchase.paid_at.is_(None)),
|
||||
# Exclude code-only gifts — they stay PAID intentionally until activated
|
||||
@@ -1032,6 +1151,9 @@ async def retry_stuck_paid_purchases(
|
||||
)
|
||||
tokens = result.scalars().all()
|
||||
|
||||
# Separately fail exhausted purchases (retry_count >= max_retries)
|
||||
await _fail_exhausted_purchases_batch(db, GuestPurchaseStatus.PAID, max_retries, max_age)
|
||||
|
||||
if not tokens:
|
||||
return 0
|
||||
|
||||
@@ -1039,6 +1161,7 @@ async def retry_stuck_paid_purchases(
|
||||
for token in tokens:
|
||||
try:
|
||||
async with AsyncSessionLocal() as retry_db:
|
||||
await _increment_retry_count(retry_db, token)
|
||||
await fulfill_purchase(retry_db, token)
|
||||
retried += 1
|
||||
logger.info('Retried stuck purchase successfully', token_prefix=token[:5])
|
||||
@@ -1053,12 +1176,15 @@ async def retry_stuck_pending_activation(
|
||||
stale_minutes: int = 10,
|
||||
limit: int = 10,
|
||||
max_age_hours: int = 24,
|
||||
max_retries: int = 20,
|
||||
) -> int:
|
||||
"""Retry activation for purchases stuck in PENDING_ACTIVATION status.
|
||||
|
||||
This handles the case where activate_purchase() failed after the status
|
||||
was already transitioned to PENDING_ACTIVATION (e.g., Remnawave panel was
|
||||
temporarily down). Each retry runs in an isolated session.
|
||||
|
||||
Purchases exceeding max_retries are marked FAILED and an admin alert is sent.
|
||||
"""
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
@@ -1069,6 +1195,7 @@ async def retry_stuck_pending_activation(
|
||||
select(GuestPurchase.token)
|
||||
.where(
|
||||
GuestPurchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value,
|
||||
GuestPurchase.retry_count < max_retries,
|
||||
or_(GuestPurchase.paid_at < cutoff, GuestPurchase.paid_at.is_(None)),
|
||||
or_(GuestPurchase.paid_at > max_age, GuestPurchase.paid_at.is_(None)),
|
||||
GuestPurchase.user_id.isnot(None),
|
||||
@@ -1078,6 +1205,9 @@ async def retry_stuck_pending_activation(
|
||||
)
|
||||
tokens = result.scalars().all()
|
||||
|
||||
# Separately fail exhausted purchases (retry_count >= max_retries)
|
||||
await _fail_exhausted_purchases_batch(db, GuestPurchaseStatus.PENDING_ACTIVATION, max_retries, max_age)
|
||||
|
||||
if not tokens:
|
||||
return 0
|
||||
|
||||
@@ -1085,6 +1215,7 @@ async def retry_stuck_pending_activation(
|
||||
for token in tokens:
|
||||
try:
|
||||
async with AsyncSessionLocal() as retry_db:
|
||||
await _increment_retry_count(retry_db, token)
|
||||
await activate_purchase(retry_db, token)
|
||||
retried += 1
|
||||
logger.info('Retried stuck pending_activation successfully', token_prefix=token[:5])
|
||||
@@ -1092,3 +1223,368 @@ async def retry_stuck_pending_activation(
|
||||
logger.exception('Failed to retry stuck pending_activation', token_prefix=token[:5])
|
||||
|
||||
return retried
|
||||
|
||||
|
||||
async def _increment_retry_count(db: AsyncSession, purchase_token: str) -> None:
|
||||
"""Atomically increment retry_count via UPDATE statement (no SELECT, no identity map pollution)."""
|
||||
await db.execute(
|
||||
update(GuestPurchase)
|
||||
.where(GuestPurchase.token == purchase_token)
|
||||
.values(retry_count=GuestPurchase.retry_count + 1)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _fail_exhausted_purchases_batch(
|
||||
db: AsyncSession,
|
||||
status: GuestPurchaseStatus,
|
||||
max_retries: int,
|
||||
max_age: datetime,
|
||||
) -> None:
|
||||
"""Find and mark exhausted purchases as FAILED, then send admin alerts."""
|
||||
from app.database.crud.landing import update_purchase_status
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
result = await db.execute(
|
||||
select(GuestPurchase.token, GuestPurchase.retry_count)
|
||||
.where(
|
||||
GuestPurchase.status == status.value,
|
||||
GuestPurchase.retry_count >= max_retries,
|
||||
or_(GuestPurchase.paid_at > max_age, GuestPurchase.paid_at.is_(None)),
|
||||
)
|
||||
.limit(10)
|
||||
)
|
||||
exhausted = result.all()
|
||||
|
||||
for token, retry_count in exhausted:
|
||||
# Collect alert data before closing the session
|
||||
alert_data: dict | None = None
|
||||
try:
|
||||
async with AsyncSessionLocal() as fail_db:
|
||||
row = await fail_db.execute(select(GuestPurchase).where(GuestPurchase.token == token).with_for_update())
|
||||
purchase = row.scalars().first()
|
||||
if purchase and purchase.status not in (
|
||||
GuestPurchaseStatus.DELIVERED.value,
|
||||
GuestPurchaseStatus.FAILED.value,
|
||||
):
|
||||
# Capture alert data before commit expires attributes
|
||||
alert_data = {
|
||||
'id': purchase.id,
|
||||
'token': purchase.token,
|
||||
'amount_kopeks': purchase.amount_kopeks,
|
||||
'payment_method': purchase.payment_method,
|
||||
'payment_id': purchase.payment_id,
|
||||
'contact_type': purchase.contact_type,
|
||||
'contact_value': purchase.contact_value,
|
||||
'created_at': purchase.created_at,
|
||||
}
|
||||
await update_purchase_status(fail_db, token, GuestPurchaseStatus.FAILED)
|
||||
logger.error(
|
||||
'Purchase exceeded max retries — marked FAILED',
|
||||
token_prefix=token[:5],
|
||||
retry_count=retry_count,
|
||||
phase=status.value,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to mark exhausted purchase as FAILED', token_prefix=token[:5])
|
||||
|
||||
# Send alert OUTSIDE the session (no row lock held)
|
||||
if alert_data:
|
||||
await _send_stuck_purchase_alert(alert_data, retry_count, status.value)
|
||||
|
||||
|
||||
async def _send_stuck_purchase_alert(data: dict, retry_count: int, phase: str) -> None:
|
||||
"""Send admin notification about a purchase that exhausted all retries.
|
||||
|
||||
Accepts a plain dict (not ORM object) so it can be called after the session is closed.
|
||||
"""
|
||||
if not getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) or not settings.BOT_TOKEN:
|
||||
return
|
||||
try:
|
||||
import html as html_mod
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService, NotificationCategory
|
||||
|
||||
amount_rub = data['amount_kopeks'] / 100
|
||||
contact_value = html_mod.escape(str(data.get('contact_value', '?')))
|
||||
contact_type = html_mod.escape(str(data.get('contact_type', '?')))
|
||||
text = (
|
||||
f'<b>STUCK PURCHASE — retries exhausted</b>\n\n'
|
||||
f'Token: <code>{data["token"][:8]}...</code>\n'
|
||||
f'Status: <code>{phase}</code> → <code>FAILED</code>\n'
|
||||
f'Retries: <b>{retry_count}</b>\n'
|
||||
f'Amount: <b>{amount_rub:.0f} ₽</b>\n'
|
||||
f'Payment: <code>{html_mod.escape(str(data.get("payment_method") or "?"))}</code>\n'
|
||||
f'Payment ID: <code>{html_mod.escape(str(data.get("payment_id") or "?"))}</code>\n'
|
||||
f'Contact: {contact_type}: <code>{contact_value}</code>\n'
|
||||
f'Created: {data["created_at"]:%Y-%m-%d %H:%M UTC}\n\n'
|
||||
f'Requires manual investigation.'
|
||||
)
|
||||
|
||||
async with create_bot() as bot:
|
||||
service = AdminNotificationService(bot)
|
||||
await service.send_admin_notification(text, category=NotificationCategory.ERRORS)
|
||||
except Exception:
|
||||
logger.warning('Failed to send stuck purchase admin alert', purchase_id=data.get('id'), exc_info=True)
|
||||
|
||||
|
||||
async def _send_amount_mismatch_alert(
|
||||
purchase: GuestPurchase,
|
||||
provider_amount_kopeks: int,
|
||||
provider_payment_id: str,
|
||||
payment_method: str | None,
|
||||
) -> None:
|
||||
"""Send admin alert when recovery detects an amount mismatch (possible fraud or bug)."""
|
||||
if not getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) or not settings.BOT_TOKEN:
|
||||
return
|
||||
try:
|
||||
import html as html_mod
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService, NotificationCategory
|
||||
|
||||
text = (
|
||||
f'<b>AMOUNT MISMATCH — purchase marked FAILED</b>\n\n'
|
||||
f'Token: <code>{purchase.token[:8]}...</code>\n'
|
||||
f'Expected: <b>{purchase.amount_kopeks / 100:.0f} ₽</b>\n'
|
||||
f'Provider: <b>{provider_amount_kopeks / 100:.0f} ₽</b>\n'
|
||||
f'Payment: <code>{html_mod.escape(str(payment_method or "?"))}</code>\n'
|
||||
f'Payment ID: <code>{html_mod.escape(str(provider_payment_id))}</code>\n'
|
||||
f'Contact: {html_mod.escape(str(purchase.contact_type))}: '
|
||||
f'<code>{html_mod.escape(str(purchase.contact_value))}</code>\n\n'
|
||||
f'Requires manual investigation.'
|
||||
)
|
||||
|
||||
async with create_bot() as bot:
|
||||
service = AdminNotificationService(bot)
|
||||
await service.send_admin_notification(text, category=NotificationCategory.ERRORS)
|
||||
except Exception:
|
||||
logger.warning('Failed to send amount mismatch alert', purchase_id=purchase.id, exc_info=True)
|
||||
|
||||
|
||||
async def recover_stuck_pending_purchases(
|
||||
db: AsyncSession,
|
||||
stale_minutes: int = 10,
|
||||
limit: int = 10,
|
||||
max_age_hours: int = 24,
|
||||
) -> int:
|
||||
"""Recover purchases stuck in PENDING by checking provider payment status.
|
||||
|
||||
Queries all payment provider tables (YooKassa, Heleket, CryptoBot, etc.)
|
||||
for succeeded payments matching the purchase_token. If a provider payment
|
||||
is confirmed but the GuestPurchase is still PENDING (webhook was lost or
|
||||
processing failed), marks the purchase as PAID so retry_stuck_paid_purchases
|
||||
can fulfill it. Includes amount verification.
|
||||
|
||||
Returns the number of recovered purchases.
|
||||
"""
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
cutoff = datetime.now(UTC) - timedelta(minutes=stale_minutes)
|
||||
max_age = datetime.now(UTC) - timedelta(hours=max_age_hours)
|
||||
|
||||
# Find PENDING purchases older than stale_minutes but younger than max_age_hours
|
||||
result = await db.execute(
|
||||
select(GuestPurchase.token, GuestPurchase.payment_method)
|
||||
.where(
|
||||
GuestPurchase.status == GuestPurchaseStatus.PENDING.value,
|
||||
GuestPurchase.created_at < cutoff,
|
||||
GuestPurchase.created_at > max_age,
|
||||
)
|
||||
.order_by(GuestPurchase.created_at.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
pending_purchases = result.all()
|
||||
|
||||
if not pending_purchases:
|
||||
return 0
|
||||
|
||||
recovered = 0
|
||||
for token, payment_method in pending_purchases:
|
||||
try:
|
||||
async with AsyncSessionLocal() as recover_db:
|
||||
paid = await _check_and_recover_pending_purchase(recover_db, token, payment_method)
|
||||
if paid:
|
||||
recovered += 1
|
||||
except Exception:
|
||||
logger.exception('Failed to recover pending purchase', token_prefix=token[:5])
|
||||
|
||||
return recovered
|
||||
|
||||
|
||||
async def _find_succeeded_provider_payment(
|
||||
db: AsyncSession,
|
||||
base_method: str,
|
||||
purchase_token: str,
|
||||
) -> tuple[str, int | None] | None:
|
||||
"""Query provider payment tables for a succeeded payment matching purchase_token.
|
||||
|
||||
Returns ``(provider_payment_id, amount_kopeks)`` or ``None``.
|
||||
``amount_kopeks`` is ``None`` when the amount check should be skipped
|
||||
(e.g., CryptoBot where USD→RUB conversion introduces imprecision).
|
||||
"""
|
||||
from sqlalchemy import cast
|
||||
from sqlalchemy.types import JSON as SA_JSON
|
||||
|
||||
from app.database.models import (
|
||||
CloudPaymentsPayment,
|
||||
CryptoBotPayment,
|
||||
FreekassaPayment,
|
||||
HeleketPayment,
|
||||
KassaAiPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PlategaPayment,
|
||||
RioPayPayment,
|
||||
SeverPayPayment,
|
||||
WataPayment,
|
||||
YooKassaPayment,
|
||||
)
|
||||
|
||||
# --- CryptoBot: special case — payload field (text JSON), skip amount check ---
|
||||
if base_method == 'cryptobot':
|
||||
result = await db.execute(
|
||||
select(CryptoBotPayment).where(
|
||||
CryptoBotPayment.status == 'paid',
|
||||
CryptoBotPayment.payload.like('{%'),
|
||||
cast(CryptoBotPayment.payload, SA_JSON)['purchase_token'].as_string() == purchase_token,
|
||||
)
|
||||
)
|
||||
p = result.scalars().first()
|
||||
return (p.invoice_id, None) if p else None
|
||||
|
||||
# --- All other providers: metadata_json['purchase_token'] + is_paid/status filters ---
|
||||
model = None
|
||||
payment_id_attr: str = ''
|
||||
extra_conditions: list = []
|
||||
|
||||
if base_method.startswith('yookassa'):
|
||||
model = YooKassaPayment
|
||||
payment_id_attr = 'yookassa_payment_id'
|
||||
extra_conditions = [YooKassaPayment.status == 'succeeded', YooKassaPayment.is_paid.is_(True)]
|
||||
elif base_method == 'heleket':
|
||||
model = HeleketPayment
|
||||
payment_id_attr = 'uuid'
|
||||
extra_conditions = [HeleketPayment.status.in_(['paid', 'paid_over'])]
|
||||
elif base_method == 'mulenpay':
|
||||
model = MulenPayPayment
|
||||
payment_id_attr = 'uuid'
|
||||
extra_conditions = [MulenPayPayment.is_paid.is_(True)]
|
||||
elif base_method == 'pal24':
|
||||
model = Pal24Payment
|
||||
payment_id_attr = 'bill_id'
|
||||
extra_conditions = [Pal24Payment.is_paid.is_(True)]
|
||||
elif base_method == 'wata':
|
||||
model = WataPayment
|
||||
payment_id_attr = 'payment_link_id'
|
||||
extra_conditions = [WataPayment.is_paid.is_(True)]
|
||||
elif base_method == 'platega':
|
||||
model = PlategaPayment
|
||||
payment_id_attr = 'platega_transaction_id'
|
||||
extra_conditions = [PlategaPayment.is_paid.is_(True)]
|
||||
elif base_method == 'cloudpayments':
|
||||
model = CloudPaymentsPayment
|
||||
payment_id_attr = 'invoice_id'
|
||||
extra_conditions = [CloudPaymentsPayment.status == 'completed', CloudPaymentsPayment.is_paid.is_(True)]
|
||||
elif base_method == 'freekassa':
|
||||
model = FreekassaPayment
|
||||
payment_id_attr = 'order_id'
|
||||
extra_conditions = [FreekassaPayment.status == 'success', FreekassaPayment.is_paid.is_(True)]
|
||||
elif base_method == 'kassa_ai':
|
||||
model = KassaAiPayment
|
||||
payment_id_attr = 'order_id'
|
||||
extra_conditions = [KassaAiPayment.status == 'success', KassaAiPayment.is_paid.is_(True)]
|
||||
elif base_method == 'riopay':
|
||||
model = RioPayPayment
|
||||
payment_id_attr = 'order_id'
|
||||
extra_conditions = [RioPayPayment.status == 'success', RioPayPayment.is_paid.is_(True)]
|
||||
elif base_method == 'severpay':
|
||||
model = SeverPayPayment
|
||||
payment_id_attr = 'order_id'
|
||||
extra_conditions = [SeverPayPayment.status == 'success', SeverPayPayment.is_paid.is_(True)]
|
||||
|
||||
if model is None:
|
||||
return None
|
||||
|
||||
result = await db.execute(
|
||||
select(model).where(
|
||||
model.metadata_json['purchase_token'].as_string() == purchase_token,
|
||||
*extra_conditions,
|
||||
)
|
||||
)
|
||||
p = result.scalars().first()
|
||||
if p is None:
|
||||
return None
|
||||
|
||||
payment_id = str(getattr(p, payment_id_attr))
|
||||
# amount_kopeks: Integer column for most providers, @property for Heleket
|
||||
amount = getattr(p, 'amount_kopeks', None)
|
||||
return (payment_id, amount)
|
||||
|
||||
|
||||
async def _check_and_recover_pending_purchase(
|
||||
db: AsyncSession,
|
||||
purchase_token: str,
|
||||
payment_method: str | None,
|
||||
) -> bool:
|
||||
"""Check if a PENDING purchase has a succeeded payment and transition to PAID.
|
||||
|
||||
Uses SELECT ... FOR UPDATE on the GuestPurchase row to prevent concurrent
|
||||
webhook processing from racing with the recovery.
|
||||
Verifies amount match between provider payment and guest purchase.
|
||||
"""
|
||||
from app.database.crud.landing import update_purchase_status
|
||||
|
||||
# Lock the row to prevent TOCTOU race with concurrent webhook processing
|
||||
result = await db.execute(select(GuestPurchase).where(GuestPurchase.token == purchase_token).with_for_update())
|
||||
purchase = result.scalars().first()
|
||||
if purchase is None or purchase.status != GuestPurchaseStatus.PENDING.value:
|
||||
return False
|
||||
|
||||
# Resolve base method: 'yookassa_sbp' → 'yookassa', 'kassa_ai' stays 'kassa_ai'
|
||||
base_method = _resolve_base_payment_method(payment_method)
|
||||
|
||||
match = await _find_succeeded_provider_payment(db, base_method, purchase_token)
|
||||
if match is None:
|
||||
if base_method:
|
||||
logger.debug(
|
||||
'No succeeded provider payment found for PENDING purchase',
|
||||
token_prefix=purchase_token[:5],
|
||||
payment_method=payment_method,
|
||||
)
|
||||
return False
|
||||
|
||||
provider_payment_id, provider_amount_kopeks = match
|
||||
|
||||
# Amount verification (skip when provider_amount_kopeks is None, e.g., crypto)
|
||||
if provider_amount_kopeks is not None and provider_amount_kopeks != purchase.amount_kopeks:
|
||||
logger.error(
|
||||
'Amount mismatch during PENDING recovery — skipping',
|
||||
token_prefix=purchase_token[:5],
|
||||
provider_amount=provider_amount_kopeks,
|
||||
purchase_amount=purchase.amount_kopeks,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
# Mark FAILED to prevent repeated mismatch logs every cycle
|
||||
from app.database.crud.landing import update_purchase_status as _update_status
|
||||
|
||||
await _update_status(db, purchase_token, GuestPurchaseStatus.FAILED)
|
||||
await _send_amount_mismatch_alert(purchase, provider_amount_kopeks, provider_payment_id, payment_method)
|
||||
return False
|
||||
|
||||
# Transition PENDING → PAID for retry_stuck_paid_purchases to handle
|
||||
await update_purchase_status(
|
||||
db,
|
||||
purchase_token,
|
||||
GuestPurchaseStatus.PAID,
|
||||
payment_id=provider_payment_id,
|
||||
paid_at=datetime.now(UTC),
|
||||
)
|
||||
logger.info(
|
||||
'Recovered stuck PENDING purchase → PAID',
|
||||
token_prefix=purchase_token[:5],
|
||||
payment_method=payment_method,
|
||||
provider_payment_id=provider_payment_id,
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -377,8 +377,10 @@ class MonitoringService:
|
||||
|
||||
update_kwargs = dict(
|
||||
uuid=user.remnawave_uuid,
|
||||
status=RemnaWaveUserStatus.ACTIVE if is_active else RemnaWaveUserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
status=RemnaWaveUserStatus.ACTIVE if is_active else RemnaWaveUserStatus.DISABLED,
|
||||
expire_at=subscription.end_date
|
||||
if is_active
|
||||
else max(subscription.end_date, current_time + timedelta(minutes=1)),
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
description=settings.format_remnawave_user_description(
|
||||
@@ -1742,18 +1744,35 @@ class MonitoringService:
|
||||
)
|
||||
|
||||
async def _retry_stuck_guest_purchases(self, db: AsyncSession):
|
||||
try:
|
||||
from app.services.guest_purchase_service import retry_stuck_paid_purchases, retry_stuck_pending_activation
|
||||
from app.services.guest_purchase_service import (
|
||||
recover_stuck_pending_purchases,
|
||||
retry_stuck_paid_purchases,
|
||||
retry_stuck_pending_activation,
|
||||
)
|
||||
|
||||
# Phase 1: Recover PENDING purchases where provider payment already succeeded
|
||||
try:
|
||||
recovered = await recover_stuck_pending_purchases(db, stale_minutes=10, limit=10)
|
||||
if recovered:
|
||||
logger.info('Recovered stuck PENDING purchases', recovered=recovered)
|
||||
except Exception:
|
||||
logger.error('Error recovering stuck PENDING guest purchases', exc_info=True)
|
||||
|
||||
# Phase 2: Retry fulfillment for purchases in PAID status
|
||||
try:
|
||||
retried = await retry_stuck_paid_purchases(db, stale_minutes=5, limit=10)
|
||||
if retried:
|
||||
logger.info('Retried stuck guest purchases', retried=retried)
|
||||
except Exception:
|
||||
logger.error('Error retrying stuck PAID guest purchases', exc_info=True)
|
||||
|
||||
# Phase 3: Retry activation for purchases in PENDING_ACTIVATION status
|
||||
try:
|
||||
retried_pa = await retry_stuck_pending_activation(db, stale_minutes=10, limit=10)
|
||||
if retried_pa:
|
||||
logger.info('Retried stuck pending_activation purchases', retried=retried_pa)
|
||||
except Exception:
|
||||
logger.error('Error retrying stuck guest purchases', exc_info=True)
|
||||
logger.error('Error retrying stuck PENDING_ACTIVATION guest purchases', exc_info=True)
|
||||
|
||||
async def _cleanup_inactive_users(self, db: AsyncSession):
|
||||
try:
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.config import settings
|
||||
from app.lib.nalogo import Client
|
||||
from app.lib.nalogo.dto.income import IncomeClient, IncomeType
|
||||
from app.utils.cache import cache
|
||||
from app.utils.proxy import mask_proxy_url, sanitize_proxy_error
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -41,18 +42,25 @@ class NaloGoService:
|
||||
try:
|
||||
# Таймаут 30 секунд — nalog.ru иногда отвечает медленно
|
||||
timeout = getattr(settings, 'NALOGO_TIMEOUT', 30.0)
|
||||
proxy_url = settings.get_nalogo_proxy_url()
|
||||
self.client = Client(
|
||||
base_url='https://lknpd.nalog.ru/api',
|
||||
storage_path=storage_path,
|
||||
device_id=device_id or 'bot-device-123',
|
||||
timeout=timeout,
|
||||
proxy_url=proxy_url,
|
||||
)
|
||||
self.inn = inn
|
||||
self.password = password
|
||||
self.configured = True
|
||||
logger.info('NaloGO клиент инициализирован для ИНН: ...', inn=inn[:5])
|
||||
if proxy_url:
|
||||
logger.info(
|
||||
'NaloGO клиент инициализирован с прокси', inn=inn[:5], proxy_url=mask_proxy_url(proxy_url)
|
||||
)
|
||||
else:
|
||||
logger.info('NaloGO клиент инициализирован для ИНН: ...', inn=inn[:5])
|
||||
except Exception as error:
|
||||
logger.error('Ошибка инициализации NaloGO клиента', error=error, exc_info=True)
|
||||
logger.error('Ошибка инициализации NaloGO клиента', error=sanitize_proxy_error(error))
|
||||
self.configured = False
|
||||
|
||||
@staticmethod
|
||||
@@ -281,9 +289,9 @@ class NaloGoService:
|
||||
return True
|
||||
except Exception as error:
|
||||
if self._is_service_unavailable(error):
|
||||
logger.warning('NaloGO временно недоступен (техработы)', error=str(error)[:200])
|
||||
logger.warning('NaloGO временно недоступен (техработы)', error=sanitize_proxy_error(error))
|
||||
else:
|
||||
logger.error('Ошибка аутентификации в NaloGO', error=error, exc_info=True)
|
||||
logger.error('Ошибка аутентификации в NaloGO', error=sanitize_proxy_error(error))
|
||||
return False
|
||||
|
||||
async def create_receipt(
|
||||
@@ -355,7 +363,7 @@ class NaloGoService:
|
||||
name, amount, quantity, client_info, payment_id, telegram_user_id, amount_kopeks
|
||||
)
|
||||
else:
|
||||
logger.error('Ошибка аутентификации NaloGO', auth_error=auth_error, exc_info=True)
|
||||
logger.error('Ошибка аутентификации NaloGO', auth_error=sanitize_proxy_error(auth_error))
|
||||
return None
|
||||
|
||||
# ЭТАП 2: Создание чека
|
||||
@@ -400,9 +408,9 @@ class NaloGoService:
|
||||
# ВАЖНО: Аутентификация была успешной, запрос на создание чека УШЁЛ
|
||||
# При таймауте чек МОГ быть создан на сервере — НЕ добавляем в очередь!
|
||||
if self._is_service_unavailable(error):
|
||||
error_msg = str(error)[:200]
|
||||
error_msg = sanitize_proxy_error(error)[:200]
|
||||
logger.error(
|
||||
'⚠️ ТАЙМАУТ после успешной аутентификации! Чек МОГ быть создан! (payment_id=, сумма=₽). Сохраняем в очередь проверки. Проверьте lknpd.nalog.ru',
|
||||
'ТАЙМАУТ после успешной аутентификации! Чек МОГ быть создан!',
|
||||
payment_id=payment_id,
|
||||
amount=amount,
|
||||
)
|
||||
@@ -418,7 +426,7 @@ class NaloGoService:
|
||||
error_message=error_msg,
|
||||
)
|
||||
else:
|
||||
logger.error('Ошибка создания чека в NaloGO', error=error, exc_info=True)
|
||||
logger.error('Ошибка создания чека в NaloGO', error=sanitize_proxy_error(error))
|
||||
return None
|
||||
|
||||
async def get_queue_length(self) -> int:
|
||||
@@ -511,7 +519,7 @@ class NaloGoService:
|
||||
return None
|
||||
|
||||
except Exception as error:
|
||||
logger.warning('Ошибка проверки дубликата чека', error=error)
|
||||
logger.warning('Ошибка проверки дубликата чека', error=sanitize_proxy_error(error))
|
||||
return None
|
||||
|
||||
async def get_incomes(
|
||||
@@ -555,7 +563,7 @@ class NaloGoService:
|
||||
|
||||
except Exception as error:
|
||||
if self._is_service_unavailable(error):
|
||||
logger.warning('NaloGO временно недоступен', error=error)
|
||||
logger.warning('NaloGO временно недоступен', error=sanitize_proxy_error(error))
|
||||
else:
|
||||
logger.error('Ошибка получения списка доходов', error=error, exc_info=True)
|
||||
logger.error('Ошибка получения списка доходов', error=sanitize_proxy_error(error))
|
||||
return None # None = ошибка, [] = нет чеков
|
||||
|
||||
@@ -452,18 +452,10 @@ class CloudPaymentsPaymentMixin:
|
||||
transaction: Any,
|
||||
) -> None:
|
||||
"""Send success notification to user via Telegram."""
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
from app.config import settings
|
||||
from app.bot_factory import create_bot
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
|
||||
# Skip email-only users (no telegram_id)
|
||||
if not user.telegram_id:
|
||||
logger.debug('Skipping CloudPayments notification for email-only user', user_id=user.id)
|
||||
@@ -492,15 +484,18 @@ class CloudPaymentsPaymentMixin:
|
||||
if referrer_info:
|
||||
message += f'\n\n{referrer_info}'
|
||||
|
||||
try:
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message,
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning('Не удалось отправить уведомление пользователю', telegram_id=user.telegram_id, error=error)
|
||||
async with create_bot() as bot:
|
||||
try:
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message,
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
'Не удалось отправить уведомление пользователю', telegram_id=user.telegram_id, error=error
|
||||
)
|
||||
|
||||
async def _send_cloudpayments_fail_notification(
|
||||
self,
|
||||
@@ -508,27 +503,20 @@ class CloudPaymentsPaymentMixin:
|
||||
message: str,
|
||||
) -> None:
|
||||
"""Send failure notification to user via Telegram."""
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
from app.config import settings
|
||||
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
text = f'❌ <b>Оплата не прошла</b>\n\n{message}'
|
||||
|
||||
try:
|
||||
await bot.send_message(
|
||||
chat_id=telegram_id,
|
||||
text=text,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning('Не удалось отправить уведомление пользователю', telegram_id=telegram_id, error=error)
|
||||
async with create_bot() as bot:
|
||||
try:
|
||||
await bot.send_message(
|
||||
chat_id=telegram_id,
|
||||
text=text,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning('Не удалось отправить уведомление пользователю', telegram_id=telegram_id, error=error)
|
||||
|
||||
async def get_cloudpayments_payment_status(
|
||||
self,
|
||||
|
||||
@@ -476,20 +476,21 @@ async def try_fulfill_guest_purchase(
|
||||
introduces imprecision.
|
||||
|
||||
Returns:
|
||||
``True`` -- guest purchase was detected and successfully fulfilled.
|
||||
``False`` -- guest purchase was detected but fulfillment failed.
|
||||
``True`` -- guest purchase was detected and consumed (fulfilled or queued for retry).
|
||||
``None`` -- this is NOT a guest purchase (caller should proceed normally).
|
||||
"""
|
||||
purchase_token = _extract_guest_purchase_token(metadata)
|
||||
if purchase_token is None:
|
||||
return None
|
||||
|
||||
from app.database.crud.landing import get_purchase_by_token, update_purchase_status
|
||||
from app.database.models import GuestPurchaseStatus
|
||||
from app.database.crud.landing import update_purchase_status
|
||||
from app.database.models import GuestPurchase, GuestPurchaseStatus
|
||||
from app.services.guest_purchase_service import fulfill_purchase
|
||||
|
||||
try:
|
||||
existing = await get_purchase_by_token(db, purchase_token)
|
||||
# FOR UPDATE prevents concurrent webhooks from double-processing the same purchase
|
||||
result = await db.execute(select(GuestPurchase).where(GuestPurchase.token == purchase_token).with_for_update())
|
||||
existing = result.scalars().first()
|
||||
|
||||
# Verify amount (skip for providers with currency conversion imprecision)
|
||||
if existing and not skip_amount_check and payment_amount_kopeks != existing.amount_kopeks:
|
||||
@@ -503,11 +504,20 @@ async def try_fulfill_guest_purchase(
|
||||
await update_purchase_status(db, purchase_token, GuestPurchaseStatus.FAILED)
|
||||
return True # consumed, even though failed
|
||||
|
||||
# Idempotency: skip terminal states
|
||||
if existing and existing.status in (
|
||||
GuestPurchaseStatus.DELIVERED.value,
|
||||
GuestPurchaseStatus.PENDING_ACTIVATION.value,
|
||||
GuestPurchaseStatus.FAILED.value,
|
||||
# Idempotency: skip terminal states (and code-only gifts already in PAID)
|
||||
if (
|
||||
existing
|
||||
and existing.status
|
||||
in (
|
||||
GuestPurchaseStatus.DELIVERED.value,
|
||||
GuestPurchaseStatus.PENDING_ACTIVATION.value,
|
||||
GuestPurchaseStatus.FAILED.value,
|
||||
)
|
||||
) or (
|
||||
existing
|
||||
and existing.status == GuestPurchaseStatus.PAID.value
|
||||
and existing.is_gift
|
||||
and not existing.gift_recipient_type
|
||||
):
|
||||
logger.info(
|
||||
'Guest purchase already in terminal state, skipping',
|
||||
@@ -537,6 +547,24 @@ async def try_fulfill_guest_purchase(
|
||||
purchase_token_prefix=purchase_token[:5],
|
||||
provider=provider_name,
|
||||
)
|
||||
# NaloGO receipt: payment received, fulfillment deferred until code activation
|
||||
try:
|
||||
await db.refresh(existing)
|
||||
if existing.buyer:
|
||||
from app.services.guest_purchase_service import _create_nalogo_receipt_for_purchase
|
||||
|
||||
await _create_nalogo_receipt_for_purchase(db, existing, existing.buyer)
|
||||
else:
|
||||
logger.warning(
|
||||
'Code-only gift has no buyer, skipping NaloGO receipt',
|
||||
purchase_token_prefix=purchase_token[:5],
|
||||
buyer_user_id=existing.buyer_user_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
'Failed to create NaloGO receipt for code-only gift',
|
||||
purchase_token_prefix=purchase_token[:5],
|
||||
)
|
||||
return True
|
||||
|
||||
# Fulfill: create user, subscription, deliver (commits on success)
|
||||
@@ -558,13 +586,26 @@ async def try_fulfill_guest_purchase(
|
||||
provider=provider_name,
|
||||
error=guest_error,
|
||||
)
|
||||
# Mark as FAILED so it doesn't get retried forever
|
||||
# Mark as PAID (not FAILED) so retry_stuck_paid_purchases can pick it up.
|
||||
# Use a fresh session to avoid tainted-session issues after rollback.
|
||||
# The monitoring service retries PAID purchases every 5 minutes for up to 24 hours.
|
||||
try:
|
||||
await update_purchase_status(
|
||||
db,
|
||||
purchase_token,
|
||||
GuestPurchaseStatus.FAILED,
|
||||
)
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as recovery_db:
|
||||
# Use FOR UPDATE to prevent TOCTOU race with concurrent webhook.
|
||||
row = await recovery_db.execute(
|
||||
select(GuestPurchase).where(GuestPurchase.token == purchase_token).with_for_update()
|
||||
)
|
||||
current = row.scalars().first()
|
||||
if current and current.status in (
|
||||
GuestPurchaseStatus.PENDING.value,
|
||||
GuestPurchaseStatus.PAID.value,
|
||||
):
|
||||
current.status = GuestPurchaseStatus.PAID.value
|
||||
current.payment_id = provider_payment_id
|
||||
current.paid_at = datetime.now(UTC)
|
||||
await recovery_db.commit()
|
||||
except Exception:
|
||||
logger.exception('Failed to mark guest purchase as FAILED')
|
||||
return False
|
||||
logger.exception('Failed to mark guest purchase as PAID for retry')
|
||||
return True
|
||||
|
||||
@@ -151,6 +151,13 @@ class CryptoBotPaymentMixin:
|
||||
)
|
||||
return True
|
||||
|
||||
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
|
||||
locked = await cryptobot_crud.get_cryptobot_payment_by_invoice_id_for_update(db, invoice_id)
|
||||
if not locked:
|
||||
logger.error('CryptoBot: не удалось заблокировать платёж', invoice_id=invoice_id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
if payment.status == 'paid':
|
||||
logger.info('CryptoBot платеж уже обработан', invoice_id=invoice_id)
|
||||
return True
|
||||
@@ -164,13 +171,14 @@ class CryptoBotPaymentMixin:
|
||||
else:
|
||||
paid_at = datetime.now(UTC)
|
||||
|
||||
updated_payment = await cryptobot_crud.update_cryptobot_payment_status(
|
||||
db,
|
||||
invoice_id,
|
||||
status,
|
||||
paid_at,
|
||||
commit=False,
|
||||
)
|
||||
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
|
||||
payment.status = status
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
if status == 'paid' and paid_at:
|
||||
payment.paid_at = paid_at
|
||||
await db.flush()
|
||||
|
||||
updated_payment = payment
|
||||
|
||||
descriptor = decode_payment_payload(
|
||||
getattr(updated_payment, 'payload', '') or '',
|
||||
@@ -202,11 +210,7 @@ class CryptoBotPaymentMixin:
|
||||
if renewal_handled:
|
||||
return True
|
||||
|
||||
locked = await cryptobot_crud.get_cryptobot_payment_by_id_for_update(db, updated_payment.id)
|
||||
if not locked:
|
||||
logger.error('CryptoBot: не удалось заблокировать платёж', payment_id=updated_payment.id)
|
||||
return False
|
||||
updated_payment = locked
|
||||
# FOR UPDATE lock already acquired above — no need to re-lock
|
||||
|
||||
# --- Guest purchase flow (landing page) ---
|
||||
# CryptoBot stores guest metadata in the payload field (JSON string),
|
||||
|
||||
@@ -198,7 +198,14 @@ class FreekassaPaymentMixin:
|
||||
logger.warning('Freekassa webhook: платеж не найден order_id', order_id=order_id)
|
||||
return False
|
||||
|
||||
# Проверка дублирования
|
||||
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
|
||||
locked = await freekassa_crud.get_freekassa_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('Freekassa webhook: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
# Re-check is_paid from the locked row
|
||||
if payment.is_paid:
|
||||
logger.info('Freekassa webhook: платеж уже обработан order_id', order_id=order_id)
|
||||
return True
|
||||
@@ -213,7 +220,7 @@ class FreekassaPaymentMixin:
|
||||
)
|
||||
return False
|
||||
|
||||
# Обновляем статус платежа
|
||||
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
|
||||
callback_payload = {
|
||||
'merchant_id': merchant_id,
|
||||
'amount': amount,
|
||||
@@ -221,16 +228,15 @@ class FreekassaPaymentMixin:
|
||||
'intid': intid,
|
||||
'cur_id': cur_id,
|
||||
}
|
||||
|
||||
payment = await freekassa_crud.update_freekassa_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='success',
|
||||
is_paid=True,
|
||||
freekassa_order_id=intid,
|
||||
payment_system_id=cur_id,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.callback_payload = callback_payload
|
||||
payment.freekassa_order_id = intid
|
||||
if cur_id is not None:
|
||||
payment.payment_system_id = cur_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
# Финализируем платеж (начисляем баланс, создаем транзакцию)
|
||||
return await self._finalize_freekassa_payment(db, payment, intid=intid, trigger='webhook')
|
||||
@@ -250,13 +256,7 @@ class FreekassaPaymentMixin:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления."""
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
|
||||
freekassa_lock_crud = import_module('app.database.crud.freekassa')
|
||||
locked = await freekassa_lock_crud.get_freekassa_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('Freekassa: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
# FOR UPDATE lock already acquired by caller — just check idempotency
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'Freekassa платеж уже привязан к транзакции (trigger=)', order_id=payment.order_id, trigger=trigger
|
||||
@@ -506,32 +506,43 @@ class FreekassaPaymentMixin:
|
||||
if fk_status == 1:
|
||||
logger.info('Freekassa payment confirmed via API', order_id=payment.order_id)
|
||||
|
||||
callback_payload = {
|
||||
'check_source': 'api',
|
||||
'fk_order_data': target_order,
|
||||
}
|
||||
# Lock payment row before finalization to prevent concurrent double-processing
|
||||
locked = await freekassa_crud.get_freekassa_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('Freekassa status check: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
elif locked.is_paid:
|
||||
# Another concurrent handler already processed — skip
|
||||
logger.info('Freekassa платеж уже оплачен после блокировки', order_id=locked.order_id)
|
||||
payment = locked
|
||||
else:
|
||||
payment = locked
|
||||
|
||||
# ID заказа на стороне FK (fk_order_id или id)
|
||||
fk_intid = str(target_order.get('fk_order_id') or target_order.get('id'))
|
||||
callback_payload = {
|
||||
'check_source': 'api',
|
||||
'fk_order_data': target_order,
|
||||
}
|
||||
|
||||
# Обновляем статус
|
||||
payment = await freekassa_crud.update_freekassa_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='success',
|
||||
is_paid=True,
|
||||
freekassa_order_id=fk_intid,
|
||||
payment_system_id=int(target_order.get('curID')) if target_order.get('curID') else None,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
# ID заказа на стороне FK (fk_order_id или id)
|
||||
fk_intid = str(target_order.get('fk_order_id') or target_order.get('id'))
|
||||
|
||||
# Финализируем
|
||||
await self._finalize_freekassa_payment(
|
||||
db,
|
||||
payment,
|
||||
intid=fk_intid,
|
||||
trigger='api_check',
|
||||
)
|
||||
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.callback_payload = callback_payload
|
||||
payment.freekassa_order_id = fk_intid
|
||||
if target_order.get('curID'):
|
||||
payment.payment_system_id = int(target_order['curID'])
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
# Финализируем
|
||||
await self._finalize_freekassa_payment(
|
||||
db,
|
||||
payment,
|
||||
intid=fk_intid,
|
||||
trigger='api_check',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Error checking Freekassa payment status', e=e)
|
||||
|
||||
|
||||
@@ -191,7 +191,14 @@ class KassaAiPaymentMixin:
|
||||
logger.warning('KassaAI webhook: платеж не найден order_id', order_id=order_id)
|
||||
return False
|
||||
|
||||
# Проверка дублирования
|
||||
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
|
||||
locked = await kassa_ai_crud.get_kassa_ai_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('KassaAI webhook: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
# Re-check is_paid from the locked row
|
||||
if payment.is_paid:
|
||||
logger.info('KassaAI webhook: платеж уже обработан order_id', order_id=order_id)
|
||||
return True
|
||||
@@ -206,7 +213,7 @@ class KassaAiPaymentMixin:
|
||||
)
|
||||
return False
|
||||
|
||||
# Обновляем статус платежа
|
||||
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
|
||||
callback_payload = {
|
||||
'merchant_id': merchant_id,
|
||||
'amount': amount,
|
||||
@@ -214,16 +221,15 @@ class KassaAiPaymentMixin:
|
||||
'intid': intid,
|
||||
'cur_id': cur_id,
|
||||
}
|
||||
|
||||
payment = await kassa_ai_crud.update_kassa_ai_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='success',
|
||||
is_paid=True,
|
||||
kassa_ai_order_id=intid,
|
||||
payment_system_id=cur_id,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.callback_payload = callback_payload
|
||||
payment.kassa_ai_order_id = intid
|
||||
if cur_id is not None:
|
||||
payment.payment_system_id = cur_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
# Финализируем платеж (начисляем баланс, создаем транзакцию)
|
||||
return await self._finalize_kassa_ai_payment(db, payment, intid=intid, trigger='webhook')
|
||||
@@ -243,13 +249,7 @@ class KassaAiPaymentMixin:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления."""
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
|
||||
kassa_ai_lock_crud = import_module('app.database.crud.kassa_ai')
|
||||
locked = await kassa_ai_lock_crud.get_kassa_ai_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('KassaAI: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
# FOR UPDATE lock already acquired by caller — just check idempotency
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'KassaAI платеж уже привязан к транзакции (trigger=)', order_id=payment.order_id, trigger=trigger
|
||||
@@ -485,32 +485,43 @@ class KassaAiPaymentMixin:
|
||||
if kai_status == 1:
|
||||
logger.info('KassaAI payment confirmed via API', order_id=payment.order_id)
|
||||
|
||||
callback_payload = {
|
||||
'check_source': 'api',
|
||||
'kai_order_data': target_order,
|
||||
}
|
||||
# Lock payment row before finalization to prevent concurrent double-processing
|
||||
locked = await kassa_ai_crud.get_kassa_ai_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('KassaAI status check: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
elif locked.is_paid:
|
||||
# Another concurrent handler already processed — skip
|
||||
logger.info('KassaAI платеж уже оплачен после блокировки', order_id=locked.order_id)
|
||||
payment = locked
|
||||
else:
|
||||
payment = locked
|
||||
|
||||
# ID заказа на стороне KassaAI
|
||||
kai_intid = str(target_order.get('fk_order_id') or target_order.get('id'))
|
||||
callback_payload = {
|
||||
'check_source': 'api',
|
||||
'kai_order_data': target_order,
|
||||
}
|
||||
|
||||
# Обновляем статус
|
||||
payment = await kassa_ai_crud.update_kassa_ai_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='success',
|
||||
is_paid=True,
|
||||
kassa_ai_order_id=kai_intid,
|
||||
payment_system_id=int(target_order.get('curID')) if target_order.get('curID') else None,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
# ID заказа на стороне KassaAI
|
||||
kai_intid = str(target_order.get('fk_order_id') or target_order.get('id'))
|
||||
|
||||
# Финализируем (начисляем баланс)
|
||||
await self._finalize_kassa_ai_payment(
|
||||
db,
|
||||
payment,
|
||||
intid=kai_intid,
|
||||
trigger='api_check',
|
||||
)
|
||||
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.callback_payload = callback_payload
|
||||
payment.kassa_ai_order_id = kai_intid
|
||||
if target_order.get('curID'):
|
||||
payment.payment_system_id = int(target_order['curID'])
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
# Финализируем (начисляем баланс)
|
||||
await self._finalize_kassa_ai_payment(
|
||||
db,
|
||||
payment,
|
||||
intid=kai_intid,
|
||||
trigger='api_check',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Error checking KassaAI payment status', e=e)
|
||||
|
||||
|
||||
@@ -174,6 +174,14 @@ class MulenPayPaymentMixin:
|
||||
)
|
||||
return False
|
||||
|
||||
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
|
||||
mulenpay_lock_crud = import_module('app.database.crud.mulenpay')
|
||||
locked = await mulenpay_lock_crud.get_mulenpay_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('MulenPay: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
invoice_message = metadata.get('invoice_message') or {}
|
||||
|
||||
@@ -199,11 +207,9 @@ class MulenPayPaymentMixin:
|
||||
if payment.is_paid:
|
||||
if invoice_message_removed:
|
||||
try:
|
||||
await payment_module.update_mulenpay_payment_metadata(
|
||||
db,
|
||||
payment=payment,
|
||||
metadata=metadata,
|
||||
)
|
||||
payment.metadata_json = metadata
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
except Exception as error: # pragma: no cover - diagnostics
|
||||
logger.warning(
|
||||
'Не удалось обновить метаданные после удаления счёта',
|
||||
@@ -217,21 +223,16 @@ class MulenPayPaymentMixin:
|
||||
return True
|
||||
|
||||
if payment_status == 'success':
|
||||
await payment_module.update_mulenpay_payment_status(
|
||||
db,
|
||||
payment=payment,
|
||||
status='success',
|
||||
callback_payload=callback_data,
|
||||
mulen_payment_id=mulen_payment_id_int,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
mulenpay_lock_crud = import_module('app.database.crud.mulenpay')
|
||||
locked = await mulenpay_lock_crud.get_mulenpay_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('MulenPay: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.callback_payload = callback_data
|
||||
if mulen_payment_id_int is not None and not payment.mulen_payment_id:
|
||||
payment.mulen_payment_id = mulen_payment_id_int
|
||||
payment.metadata_json = metadata
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
if payment.transaction_id:
|
||||
logger.info('Для платежа уже создана транзакция', display_name=display_name, uuid=payment.uuid)
|
||||
|
||||
@@ -240,6 +240,14 @@ class Pal24PaymentMixin:
|
||||
logger.error('Pal24 платеж не найден: /', bill_id=bill_id, order_id=order_id)
|
||||
return False
|
||||
|
||||
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
|
||||
pal24_lock_crud = import_module('app.database.crud.pal24')
|
||||
locked = await pal24_lock_crud.get_pal24_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('Pal24: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
if payment.is_paid:
|
||||
logger.info('Pal24 платеж уже обработан', bill_id=payment.bill_id)
|
||||
return True
|
||||
@@ -249,25 +257,32 @@ class Pal24PaymentMixin:
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
|
||||
payment = await payment_module.update_pal24_payment_status(
|
||||
db,
|
||||
payment,
|
||||
status=status,
|
||||
is_paid=True,
|
||||
paid_at=datetime.now(UTC),
|
||||
callback_payload=callback,
|
||||
payment_id=payment_id,
|
||||
payment_status=callback.get('Status') or status,
|
||||
payment_method=(
|
||||
callback.get('payment_method')
|
||||
or callback.get('PaymentMethod')
|
||||
or metadata.get('selected_method')
|
||||
or getattr(payment, 'payment_method', None)
|
||||
),
|
||||
balance_amount=callback.get('BalanceAmount') or callback.get('balance_amount'),
|
||||
balance_currency=callback.get('BalanceCurrency') or callback.get('balance_currency'),
|
||||
payer_account=callback.get('AccountNumber') or callback.get('account') or callback.get('Account'),
|
||||
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
|
||||
payment.status = status
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.callback_payload = callback
|
||||
if payment_id is not None:
|
||||
payment.payment_id = payment_id
|
||||
payment.payment_status = callback.get('Status') or status
|
||||
payment.payment_method = (
|
||||
callback.get('payment_method')
|
||||
or callback.get('PaymentMethod')
|
||||
or metadata.get('selected_method')
|
||||
or getattr(payment, 'payment_method', None)
|
||||
)
|
||||
balance_amount = callback.get('BalanceAmount') or callback.get('balance_amount')
|
||||
if balance_amount is not None:
|
||||
payment.balance_amount = balance_amount
|
||||
balance_currency = callback.get('BalanceCurrency') or callback.get('balance_currency')
|
||||
if balance_currency is not None:
|
||||
payment.balance_currency = balance_currency
|
||||
payer_account = callback.get('AccountNumber') or callback.get('account') or callback.get('Account')
|
||||
if payer_account is not None:
|
||||
payment.payer_account = payer_account
|
||||
payment.last_status = status
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
return await self._finalize_pal24_payment(
|
||||
db,
|
||||
@@ -336,23 +351,13 @@ class Pal24PaymentMixin:
|
||||
|
||||
if invoice_message_removed:
|
||||
try:
|
||||
await payment_module.update_pal24_payment_status(
|
||||
db,
|
||||
payment,
|
||||
status=payment.status,
|
||||
metadata=metadata,
|
||||
)
|
||||
payment.metadata_json = metadata
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
except Exception as error: # pragma: no cover - diagnostics
|
||||
logger.warning('Не удалось обновить метаданные PayPalych после удаления счёта', error=error)
|
||||
|
||||
pal24_lock_crud = import_module('app.database.crud.pal24')
|
||||
locked = await pal24_lock_crud.get_pal24_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('Pal24: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
# FOR UPDATE lock already acquired by caller — just check idempotency
|
||||
if payment.transaction_id:
|
||||
logger.info('Pal24 платеж уже привязан к транзакции (trigger=)', bill_id=payment.bill_id, trigger=trigger)
|
||||
return True
|
||||
@@ -643,14 +648,20 @@ class Pal24PaymentMixin:
|
||||
|
||||
if payment.is_paid and not payment.transaction_id:
|
||||
try:
|
||||
finalized = await self._finalize_pal24_payment(
|
||||
db,
|
||||
payment,
|
||||
payment_id=getattr(payment, 'payment_id', None),
|
||||
trigger='status_check',
|
||||
)
|
||||
if finalized:
|
||||
payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id)
|
||||
# Acquire FOR UPDATE lock before finalization (status_check path)
|
||||
pal24_lock_crud = import_module('app.database.crud.pal24')
|
||||
locked = await pal24_lock_crud.get_pal24_payment_by_id_for_update(db, payment.id)
|
||||
if locked:
|
||||
payment = locked
|
||||
if not payment.transaction_id:
|
||||
finalized = await self._finalize_pal24_payment(
|
||||
db,
|
||||
payment,
|
||||
payment_id=getattr(payment, 'payment_id', None),
|
||||
trigger='status_check',
|
||||
)
|
||||
if finalized:
|
||||
payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка автоматического начисления по Pal24 статусу', error=error, exc_info=True)
|
||||
|
||||
|
||||
@@ -153,35 +153,38 @@ class PlategaPaymentMixin:
|
||||
logger.warning('Platega webhook: платеж не найден (id=)', transaction_id=transaction_id)
|
||||
return False
|
||||
|
||||
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
|
||||
platega_crud = import_module('app.database.crud.platega')
|
||||
locked = await platega_crud.get_platega_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('Platega: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
status_raw = str(payload.get('status') or '').upper()
|
||||
if not status_raw:
|
||||
logger.warning('Platega webhook без статуса для платежа', payment_id=payment.id)
|
||||
return False
|
||||
|
||||
update_kwargs = {
|
||||
'status': status_raw,
|
||||
'callback_payload': payload,
|
||||
}
|
||||
|
||||
if transaction_id:
|
||||
update_kwargs['platega_transaction_id'] = transaction_id
|
||||
|
||||
if status_raw in self._SUCCESS_STATUSES:
|
||||
if payment.is_paid:
|
||||
logger.info('Platega платеж уже помечен как оплачен', correlation_id=payment.correlation_id)
|
||||
await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
**update_kwargs,
|
||||
is_paid=True,
|
||||
)
|
||||
# Update callback payload without releasing the lock prematurely
|
||||
payment.callback_payload = payload
|
||||
if transaction_id and not payment.platega_transaction_id:
|
||||
payment.platega_transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
payment = await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
**update_kwargs,
|
||||
)
|
||||
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
|
||||
payment.status = status_raw
|
||||
payment.callback_payload = payload
|
||||
if transaction_id and not payment.platega_transaction_id:
|
||||
payment.platega_transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
result = await self._finalize_platega_payment(db, payment, payload)
|
||||
if result is None:
|
||||
logger.error('Platega webhook: финализация не удалась', payment_id=payment.id)
|
||||
@@ -192,7 +195,9 @@ class PlategaPaymentMixin:
|
||||
await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
**update_kwargs,
|
||||
status=status_raw,
|
||||
callback_payload=payload,
|
||||
platega_transaction_id=transaction_id or None,
|
||||
is_paid=False,
|
||||
)
|
||||
logger.info('Platega платеж перешёл в статус', correlation_id=payment.correlation_id, status_raw=status_raw)
|
||||
@@ -201,7 +206,9 @@ class PlategaPaymentMixin:
|
||||
await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
**update_kwargs,
|
||||
status=status_raw,
|
||||
callback_payload=payload,
|
||||
platega_transaction_id=transaction_id or None,
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -231,28 +238,40 @@ class PlategaPaymentMixin:
|
||||
|
||||
if remote_payload:
|
||||
remote_status = str(remote_payload.get('status') or '').upper()
|
||||
if remote_status and remote_status != payment.status:
|
||||
await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
status=remote_status,
|
||||
metadata={
|
||||
**(getattr(payment, 'metadata_json', {}) or {}),
|
||||
'remote_status': remote_payload,
|
||||
},
|
||||
)
|
||||
payment = await payment_module.get_platega_payment_by_id(db, local_payment_id)
|
||||
status_changed = remote_status and remote_status != payment.status
|
||||
|
||||
if remote_status in self._SUCCESS_STATUSES and not payment.is_paid:
|
||||
payment = await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
status=remote_status,
|
||||
callback_payload=remote_payload,
|
||||
)
|
||||
result = await self._finalize_platega_payment(db, payment, remote_payload)
|
||||
if result is not None:
|
||||
payment = result
|
||||
# Lock payment row before finalization to prevent concurrent double-processing
|
||||
platega_crud = import_module('app.database.crud.platega')
|
||||
locked = await platega_crud.get_platega_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('Platega status check: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
elif locked.is_paid:
|
||||
# Another concurrent handler already processed — skip
|
||||
logger.info('Platega платеж уже оплачен после блокировки', correlation_id=locked.correlation_id)
|
||||
payment = locked
|
||||
else:
|
||||
payment = locked
|
||||
payment.status = remote_status
|
||||
payment.callback_payload = remote_payload
|
||||
payment.metadata_json = {
|
||||
**(getattr(payment, 'metadata_json', {}) or {}),
|
||||
'remote_status': remote_payload,
|
||||
}
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
result = await self._finalize_platega_payment(db, payment, remote_payload)
|
||||
if result is not None:
|
||||
payment = result
|
||||
elif status_changed:
|
||||
# Non-success status change — safe to persist without lock
|
||||
payment.status = remote_status
|
||||
payment.metadata_json = {
|
||||
**(getattr(payment, 'metadata_json', {}) or {}),
|
||||
'remote_status': remote_payload,
|
||||
}
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
'payment': payment,
|
||||
@@ -279,14 +298,7 @@ class PlategaPaymentMixin:
|
||||
except ValueError:
|
||||
paid_at = None
|
||||
|
||||
# Lock FIRST, then read fresh state
|
||||
platega_lock_crud = import_module('app.database.crud.platega')
|
||||
locked = await platega_lock_crud.get_platega_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('Platega: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return None
|
||||
payment = locked
|
||||
|
||||
# FOR UPDATE lock already acquired by caller — just check idempotency
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'Platega платеж уже связан с транзакцией',
|
||||
|
||||
@@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.crud.riopay import (
|
||||
create_riopay_payment as crud_create_riopay_payment,
|
||||
get_riopay_payment_by_id_for_update,
|
||||
get_riopay_payment_by_order_id,
|
||||
get_riopay_payment_by_riopay_order_id,
|
||||
update_riopay_payment_status,
|
||||
@@ -202,7 +203,14 @@ class RioPayPaymentMixin:
|
||||
)
|
||||
return False
|
||||
|
||||
# Проверка дублирования
|
||||
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
|
||||
locked = await get_riopay_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('RioPay: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
# Re-check is_paid from the locked row
|
||||
if payment.is_paid:
|
||||
logger.info('RioPay webhook: платеж уже обработан', order_id=payment.order_id)
|
||||
return True
|
||||
@@ -241,23 +249,33 @@ class RioPayPaymentMixin:
|
||||
)
|
||||
return False
|
||||
|
||||
# Обновляем статус платежа только после проверки суммы
|
||||
payment = await update_riopay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
is_paid=is_paid,
|
||||
riopay_order_id=riopay_order_id,
|
||||
payment_method=payload.get('paymentType'),
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
|
||||
# Финализируем платеж если оплачен
|
||||
if is_paid:
|
||||
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
|
||||
payment.status = internal_status
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
if riopay_order_id:
|
||||
payment.riopay_order_id = riopay_order_id
|
||||
if payload.get('paymentType') is not None:
|
||||
payment.payment_method = payload.get('paymentType')
|
||||
payment.callback_payload = callback_payload
|
||||
await db.flush()
|
||||
|
||||
return await self._finalize_riopay_payment(
|
||||
db, payment, riopay_order_id=riopay_order_id, trigger='webhook'
|
||||
)
|
||||
|
||||
# Non-success status — safe to use update with commit
|
||||
await update_riopay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
is_paid=False,
|
||||
riopay_order_id=riopay_order_id,
|
||||
payment_method=payload.get('paymentType'),
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
@@ -272,7 +290,10 @@ class RioPayPaymentMixin:
|
||||
riopay_order_id: str | None,
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления."""
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
|
||||
|
||||
FOR UPDATE lock already acquired by caller — do NOT acquire again here.
|
||||
"""
|
||||
if payment.transaction_id:
|
||||
logger.info('RioPay платеж уже привязан к транзакции', order_id=payment.order_id, trigger=trigger)
|
||||
return True
|
||||
@@ -302,7 +323,7 @@ class RioPayPaymentMixin:
|
||||
)
|
||||
return False
|
||||
|
||||
# Создаем транзакцию
|
||||
# Создаем транзакцию (commit=False to keep FOR UPDATE lock intact)
|
||||
transaction = await create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
@@ -313,15 +334,13 @@ class RioPayPaymentMixin:
|
||||
external_id=str(riopay_order_id) if riopay_order_id else payment.order_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
commit=False,
|
||||
)
|
||||
|
||||
# Связываем платеж с транзакцией
|
||||
await update_riopay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=payment.status,
|
||||
transaction_id=transaction.id,
|
||||
)
|
||||
# Связываем платеж с транзакцией (inline — no commit to preserve lock)
|
||||
payment.transaction_id = transaction.id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
@@ -344,6 +363,22 @@ class RioPayPaymentMixin:
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Emit deferred side-effects after atomic commit (events, promo group checks)
|
||||
try:
|
||||
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.RIOPAY,
|
||||
external_id=str(riopay_order_id) if riopay_order_id else payment.order_id,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка emit_transaction_side_effects RioPay', error=error)
|
||||
|
||||
# Обработка реферального пополнения
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
@@ -478,29 +513,45 @@ class RioPayPaymentMixin:
|
||||
'is_paid': False,
|
||||
}
|
||||
|
||||
logger.info('RioPay payment confirmed via API', order_id=payment.order_id)
|
||||
# Lock payment row before finalization (TOCTOU race protection)
|
||||
locked = await get_riopay_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error(
|
||||
'RioPay status check: не удалось заблокировать платёж',
|
||||
payment_id=payment.id,
|
||||
)
|
||||
elif locked.is_paid:
|
||||
# Another concurrent handler already processed — skip
|
||||
logger.info(
|
||||
'RioPay платеж уже оплачен после блокировки',
|
||||
order_id=locked.order_id,
|
||||
)
|
||||
payment = locked
|
||||
else:
|
||||
payment = locked
|
||||
logger.info('RioPay payment confirmed via API', order_id=payment.order_id)
|
||||
|
||||
callback_payload = {
|
||||
'check_source': 'api',
|
||||
'riopay_order_data': order_data,
|
||||
}
|
||||
callback_payload = {
|
||||
'check_source': 'api',
|
||||
'riopay_order_data': order_data,
|
||||
}
|
||||
|
||||
payment = await update_riopay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='success',
|
||||
is_paid=True,
|
||||
riopay_order_id=payment.riopay_order_id,
|
||||
payment_method=order_data.get('paymentType'),
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
# Inline field updates — NO intermediate commit
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
if order_data.get('paymentType') is not None:
|
||||
payment.payment_method = order_data.get('paymentType')
|
||||
payment.callback_payload = callback_payload
|
||||
await db.flush()
|
||||
|
||||
await self._finalize_riopay_payment(
|
||||
db,
|
||||
payment,
|
||||
riopay_order_id=payment.riopay_order_id,
|
||||
trigger='api_check',
|
||||
)
|
||||
await self._finalize_riopay_payment(
|
||||
db,
|
||||
payment,
|
||||
riopay_order_id=payment.riopay_order_id,
|
||||
trigger='api_check',
|
||||
)
|
||||
elif internal_status != payment.status:
|
||||
# Обновляем статус если изменился
|
||||
payment = await update_riopay_payment_status(
|
||||
|
||||
@@ -217,7 +217,14 @@ class SeverPayPaymentMixin:
|
||||
)
|
||||
return False
|
||||
|
||||
# Проверка дублирования
|
||||
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
|
||||
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
|
||||
|
||||
# Проверка дублирования (re-check from locked row)
|
||||
if payment.is_paid:
|
||||
logger.info('SeverPay webhook: платеж уже обработан', order_id=payment.order_id)
|
||||
return True
|
||||
@@ -259,9 +266,11 @@ class SeverPayPaymentMixin:
|
||||
# Inline field assignments to keep FOR UPDATE lock intact
|
||||
payment.status = internal_status
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.severpay_id = severpay_id or payment.severpay_id
|
||||
payment.callback_payload = callback_payload
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return await self._finalize_severpay_payment(db, payment, severpay_id=severpay_id, trigger='webhook')
|
||||
|
||||
# Для не-success статусов можно безопасно коммитить
|
||||
@@ -290,18 +299,12 @@ class SeverPayPaymentMixin:
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
|
||||
|
||||
Использует FOR UPDATE lock для защиты от race condition.
|
||||
FOR UPDATE lock must be acquired by the caller before invoking this method.
|
||||
"""
|
||||
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
|
||||
|
||||
# FOR UPDATE lock already acquired by caller — just check idempotency
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'SeverPay платеж уже связан с транзакцией',
|
||||
@@ -327,11 +330,12 @@ class SeverPayPaymentMixin:
|
||||
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)
|
||||
# Ensure paid fields are set (idempotent — caller may have already set them)
|
||||
if not payment.is_paid:
|
||||
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'))
|
||||
|
||||
@@ -559,21 +563,33 @@ class SeverPayPaymentMixin:
|
||||
'is_paid': False,
|
||||
}
|
||||
|
||||
# Acquire FOR UPDATE lock before finalization
|
||||
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 None
|
||||
payment = locked
|
||||
|
||||
if payment.is_paid:
|
||||
logger.info('SeverPay платеж уже обработан (api_check)', order_id=payment.order_id)
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'success',
|
||||
'is_paid': True,
|
||||
}
|
||||
|
||||
logger.info('SeverPay payment confirmed via API', order_id=payment.order_id)
|
||||
|
||||
callback_payload = {
|
||||
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
|
||||
payment.status = 'success'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
payment.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,
|
||||
)
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
await self._finalize_severpay_payment(
|
||||
db,
|
||||
|
||||
@@ -223,6 +223,14 @@ class WataPaymentMixin:
|
||||
)
|
||||
return False
|
||||
|
||||
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
|
||||
wata_crud = import_module('app.database.crud.wata')
|
||||
locked = await wata_crud.get_wata_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('WATA: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
status_lower = transaction_status.lower()
|
||||
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
metadata['last_webhook'] = payload
|
||||
@@ -230,17 +238,38 @@ class WataPaymentMixin:
|
||||
payload.get('terminalPublicId') or payload.get('terminal_public_id') or payload.get('terminalPublicID')
|
||||
)
|
||||
|
||||
if status_lower == 'paid':
|
||||
if payment.is_paid:
|
||||
logger.info('WATA платеж уже помечен как оплачен', payment_link_id=payment.payment_link_id)
|
||||
# Update callback payload without releasing the lock prematurely
|
||||
payment.callback_payload = payload
|
||||
payment.metadata_json = metadata
|
||||
if terminal_public_id:
|
||||
payment.terminal_public_id = terminal_public_id
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
|
||||
payment.status = transaction_status
|
||||
payment.last_status = transaction_status
|
||||
payment.callback_payload = payload
|
||||
payment.metadata_json = metadata
|
||||
if terminal_public_id:
|
||||
payment.terminal_public_id = terminal_public_id
|
||||
await db.flush()
|
||||
|
||||
await self._finalize_wata_payment(db, payment, payload)
|
||||
return True
|
||||
|
||||
# Non-success statuses: safe to use update_wata_payment_status (commits)
|
||||
update_kwargs: dict[str, Any] = {
|
||||
'metadata': metadata,
|
||||
'callback_payload': payload,
|
||||
'terminal_public_id': terminal_public_id,
|
||||
'status': transaction_status,
|
||||
'last_status': transaction_status,
|
||||
}
|
||||
|
||||
if transaction_status:
|
||||
update_kwargs['status'] = transaction_status
|
||||
update_kwargs['last_status'] = transaction_status
|
||||
|
||||
if status_lower != 'paid' and not payment.is_paid:
|
||||
if not payment.is_paid:
|
||||
update_kwargs['is_paid'] = False
|
||||
|
||||
payment = await payment_module.update_wata_payment_status(
|
||||
@@ -249,14 +278,6 @@ class WataPaymentMixin:
|
||||
**update_kwargs,
|
||||
)
|
||||
|
||||
if status_lower == 'paid':
|
||||
if payment.is_paid:
|
||||
logger.info('WATA платеж уже помечен как оплачен', payment_link_id=payment.payment_link_id)
|
||||
return True
|
||||
|
||||
await self._finalize_wata_payment(db, payment, payload)
|
||||
return True
|
||||
|
||||
if status_lower == 'declined':
|
||||
logger.info('WATA платеж отклонён', payment_link_id=payment.payment_link_id)
|
||||
|
||||
@@ -364,7 +385,18 @@ class WataPaymentMixin:
|
||||
if raw_status:
|
||||
normalized_status = str(raw_status).lower()
|
||||
if normalized_status == 'paid':
|
||||
payment = await self._finalize_wata_payment(db, payment, transaction_payload)
|
||||
# Lock payment row before finalization to prevent concurrent double-processing
|
||||
wata_crud = import_module('app.database.crud.wata')
|
||||
locked = await wata_crud.get_wata_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('WATA status check: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
elif locked.is_paid:
|
||||
# Another concurrent handler already processed — skip
|
||||
logger.info('WATA платеж уже оплачен после блокировки', payment_link_id=locked.payment_link_id)
|
||||
payment = locked
|
||||
else:
|
||||
payment = locked
|
||||
payment = await self._finalize_wata_payment(db, payment, transaction_payload)
|
||||
else:
|
||||
logger.debug(
|
||||
'WATA транзакция в статусе , повторная обработка не требуется',
|
||||
@@ -399,6 +431,15 @@ class WataPaymentMixin:
|
||||
paid_status=paid_status,
|
||||
)
|
||||
|
||||
# FOR UPDATE lock already acquired by caller — just check idempotency
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'WATA платеж уже привязан к транзакции',
|
||||
payment_link_id=payment.payment_link_id,
|
||||
transaction_id=payment.transaction_id,
|
||||
)
|
||||
return payment
|
||||
|
||||
paid_at = None
|
||||
if isinstance(transaction_payload, dict):
|
||||
paid_at = WataService._parse_datetime(transaction_payload.get('paymentTime'))
|
||||
@@ -420,30 +461,13 @@ class WataPaymentMixin:
|
||||
|
||||
existing_metadata['transaction'] = transaction_payload
|
||||
|
||||
await payment_module.update_wata_payment_status(
|
||||
db,
|
||||
payment=payment,
|
||||
status='Paid',
|
||||
is_paid=True,
|
||||
paid_at=paid_at,
|
||||
callback_payload=transaction_payload,
|
||||
metadata=existing_metadata,
|
||||
)
|
||||
|
||||
wata_lock_crud = import_module('app.database.crud.wata')
|
||||
locked = await wata_lock_crud.get_wata_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('WATA: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return None
|
||||
payment = locked
|
||||
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'WATA платеж уже привязан к транзакции',
|
||||
payment_link_id=payment.payment_link_id,
|
||||
transaction_id=payment.transaction_id,
|
||||
)
|
||||
return payment
|
||||
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
|
||||
payment.status = 'Paid'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = paid_at
|
||||
payment.callback_payload = transaction_payload
|
||||
payment.metadata_json = existing_metadata
|
||||
await db.flush()
|
||||
|
||||
# --- Guest purchase flow (landing page) ---
|
||||
wata_metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
|
||||
@@ -721,7 +721,7 @@ class PaymentService(
|
||||
payment_system_id=ps_id,
|
||||
)
|
||||
if result:
|
||||
await _patch_guest_metadata(result['local_payment_id'], payment_method)
|
||||
await _patch_guest_metadata(result['local_payment_id'], 'kassa_ai')
|
||||
return {
|
||||
'payment_url': result.get('payment_url'),
|
||||
'payment_id': result.get('order_id'),
|
||||
|
||||
@@ -15,16 +15,13 @@ import structlog
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.rbac import AccessPolicyCRUD, AuditLogCRUD, UserRoleCRUD
|
||||
from app.database.crud.rbac import SUPERADMIN_LEVEL, AccessPolicyCRUD, AuditLogCRUD, UserRoleCRUD
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.database.models import AccessPolicy, User
|
||||
|
||||
|
||||
SUPERADMIN_LEVEL = 999
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.config import settings
|
||||
from app.database.crud.transaction import get_user_total_spent_kopeks
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
from app.database.models import PromoGroup, User
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
@@ -30,7 +30,7 @@ async def _notify_admins_about_auto_assignment(
|
||||
logger.debug('BOT_TOKEN не настроен — пропускаем уведомление о промогруппе')
|
||||
return
|
||||
|
||||
bot = Bot(token=bot_token, default=DefaultBotProperties(parse_mode='HTML'))
|
||||
bot = create_bot(token=bot_token)
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
reason = (
|
||||
@@ -90,7 +90,9 @@ async def maybe_assign_promo_group_by_total_spent(
|
||||
) -> PromoGroup | None:
|
||||
from app.database.crud.user_promo_group import (
|
||||
add_user_to_promo_group,
|
||||
get_user_promo_groups,
|
||||
has_user_promo_group,
|
||||
remove_user_from_promo_group,
|
||||
sync_user_primary_promo_group,
|
||||
)
|
||||
|
||||
@@ -99,6 +101,9 @@ async def maybe_assign_promo_group_by_total_spent(
|
||||
logger.debug('Не удалось найти пользователя для автовыдачи промогруппы', user_id=user_id)
|
||||
return None
|
||||
|
||||
# Блокируем строку пользователя для предотвращения гонок при конкурентных вебхуках
|
||||
user = await lock_user_for_update(db, user)
|
||||
|
||||
# Получаем текущую primary промогруппу
|
||||
old_group = user.get_primary_promo_group()
|
||||
|
||||
@@ -108,60 +113,68 @@ async def maybe_assign_promo_group_by_total_spent(
|
||||
|
||||
previous_threshold = user.auto_promo_group_threshold_kopeks or 0
|
||||
|
||||
target_group = await _get_best_group_for_spending(
|
||||
db,
|
||||
total_spent,
|
||||
min_threshold_kopeks=previous_threshold,
|
||||
)
|
||||
# Находим группу, соответствующую текущим тратам (без порогового фильтра,
|
||||
# чтобы промокод-группы всегда очищались при покупке)
|
||||
target_group = await _get_best_group_for_spending(db, total_spent)
|
||||
if not target_group:
|
||||
return None
|
||||
|
||||
try:
|
||||
target_threshold = target_group.auto_assign_total_spent_kopeks or 0
|
||||
|
||||
if target_threshold <= previous_threshold:
|
||||
logger.debug(
|
||||
"Порог промогруппы '' не превышает ранее назначенный для пользователя",
|
||||
target_group_name=target_group.name,
|
||||
target_threshold=target_threshold,
|
||||
previous_threshold=previous_threshold,
|
||||
telegram_id=user.telegram_id,
|
||||
)
|
||||
return None
|
||||
# Фаза 1: Удаляем старые auto/promocode группы, отличные от целевой
|
||||
current_groups = await get_user_promo_groups(db, user_id)
|
||||
removed_any = False
|
||||
for upg in current_groups:
|
||||
if upg.promo_group_id != target_group.id and upg.assigned_by in ('auto', 'promocode'):
|
||||
await remove_user_from_promo_group(db, user_id, upg.promo_group_id, commit=False)
|
||||
removed_any = True
|
||||
logger.info(
|
||||
'Удалена старая промогруппа перед автоназначением',
|
||||
telegram_id=user.telegram_id,
|
||||
old_group_name=upg.promo_group.name if upg.promo_group else upg.promo_group_id,
|
||||
old_assigned_by=upg.assigned_by,
|
||||
)
|
||||
|
||||
# Проверяем, есть ли уже эта группа у пользователя
|
||||
if removed_any:
|
||||
await db.flush()
|
||||
await db.refresh(user)
|
||||
|
||||
# Проверяем, есть ли уже целевая группа у пользователя
|
||||
already_has_group = await has_user_promo_group(db, user_id, target_group.id)
|
||||
|
||||
if user.auto_promo_group_assigned and already_has_group:
|
||||
if user.auto_promo_group_assigned and already_has_group and not removed_any:
|
||||
logger.debug(
|
||||
"Пользователь уже имеет промогруппу '', повторная выдача не требуется",
|
||||
'Пользователь уже имеет промогруппу, повторная выдача не требуется',
|
||||
telegram_id=user.telegram_id,
|
||||
target_group_name=target_group.name,
|
||||
)
|
||||
await sync_user_primary_promo_group(db, user_id)
|
||||
if target_threshold > previous_threshold:
|
||||
user.auto_promo_group_threshold_kopeks = target_threshold
|
||||
user.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return target_group
|
||||
|
||||
user.auto_promo_group_assigned = True
|
||||
user.auto_promo_group_threshold_kopeks = target_threshold
|
||||
if target_threshold > previous_threshold:
|
||||
user.auto_promo_group_threshold_kopeks = target_threshold
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
newly_added = False
|
||||
if not already_has_group:
|
||||
# Добавляем новую промогруппу к существующим
|
||||
await add_user_to_promo_group(db, user_id, target_group.id, assigned_by='auto')
|
||||
await add_user_to_promo_group(db, user_id, target_group.id, assigned_by='auto', commit=False)
|
||||
newly_added = True
|
||||
logger.info(
|
||||
"🤖 Пользователю добавлена промогруппа '' за траты ₽",
|
||||
'Пользователю назначена промогруппа за траты',
|
||||
telegram_id=user.telegram_id,
|
||||
target_group_name=target_group.name,
|
||||
total_spent=total_spent / 100,
|
||||
)
|
||||
else:
|
||||
await sync_user_primary_promo_group(db, user_id)
|
||||
logger.info(
|
||||
"🤖 Пользователь уже имеет промогруппу '', отмечаем автоприсвоение",
|
||||
'Пользователь уже имеет промогруппу, синхронизировано',
|
||||
telegram_id=user.telegram_id,
|
||||
target_group_name=target_group.name,
|
||||
)
|
||||
@@ -169,7 +182,7 @@ async def maybe_assign_promo_group_by_total_spent(
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if not already_has_group:
|
||||
if newly_added:
|
||||
await _notify_admins_about_auto_assignment(
|
||||
db,
|
||||
user,
|
||||
|
||||
@@ -119,7 +119,7 @@ class PromoCodeService:
|
||||
if promo_group:
|
||||
# Add promo group to user
|
||||
await add_user_to_promo_group(
|
||||
db, user_id, promocode.promo_group_id, assigned_by='promocode'
|
||||
db, user_id, promocode.promo_group_id, assigned_by='promocode', commit=False
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -393,7 +393,7 @@ class PromoCodeService:
|
||||
|
||||
has_group = await has_user_promo_group(db, user_id, promocode.promo_group_id)
|
||||
if has_group:
|
||||
await remove_user_from_promo_group(db, user_id, promocode.promo_group_id)
|
||||
await remove_user_from_promo_group(db, user_id, promocode.promo_group_id, commit=False)
|
||||
logger.info(
|
||||
'Снята промогруппа ID у пользователя при деактивации промокода',
|
||||
promo_group_id=promocode.promo_group_id,
|
||||
|
||||
@@ -5,7 +5,6 @@ Auto-assigns the Superadmin role to users listed in ADMIN_IDS / ADMIN_EMAILS
|
||||
config on bot startup. Runs once during the startup sequence.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Final
|
||||
|
||||
import structlog
|
||||
@@ -13,6 +12,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.rbac import SUPERADMIN_LEVEL, UserRoleCRUD
|
||||
from app.database.models import AdminRole, User, UserRole
|
||||
|
||||
|
||||
@@ -129,15 +129,15 @@ async def _ensure_preset_roles(db: AsyncSession) -> AdminRole | None:
|
||||
result = await db.execute(
|
||||
select(AdminRole).where(AdminRole.is_system.is_(True), AdminRole.level == preset['level'])
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
existing = result.scalars().first()
|
||||
|
||||
# Fallback: поиск по имени (для ролей, созданных до этого фикса)
|
||||
if existing is None:
|
||||
result = await db.execute(select(AdminRole).where(AdminRole.name == preset['name']))
|
||||
existing = result.scalar_one_or_none()
|
||||
existing = result.scalars().first()
|
||||
|
||||
if existing is not None:
|
||||
if existing.level == 999: # Superadmin level
|
||||
if existing.level == SUPERADMIN_LEVEL:
|
||||
superadmin_role = existing
|
||||
# Добавить НОВЫЕ permissions из кода, не трогая существующие (админ мог кастомизировать)
|
||||
if existing.is_system:
|
||||
@@ -196,6 +196,8 @@ async def bootstrap_superadmins(db: AsyncSession) -> None:
|
||||
if not admin_ids and not admin_emails:
|
||||
logger.debug('No admin IDs or emails configured, skipping superadmin assignment')
|
||||
await db.commit()
|
||||
# Safety check even when no IDs configured — someone may have cleared them
|
||||
await _warn_if_no_superadmins(db, admin_ids, admin_emails)
|
||||
return
|
||||
|
||||
role_id: int = superadmin_role.id
|
||||
@@ -225,11 +227,34 @@ async def bootstrap_superadmins(db: AsyncSession) -> None:
|
||||
else:
|
||||
logger.debug('Superadmin bootstrap: no new assignments needed')
|
||||
|
||||
# ── 5. Safety: warn if no active superadmins exist ────────────
|
||||
await _warn_if_no_superadmins(db, admin_ids, admin_emails)
|
||||
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
logger.exception('Failed to bootstrap superadmins, continuing startup')
|
||||
|
||||
|
||||
async def _warn_if_no_superadmins(
|
||||
db: AsyncSession,
|
||||
admin_ids: list[int],
|
||||
admin_emails: list[str],
|
||||
) -> None:
|
||||
"""Log critical/warning if no active superadmin RBAC roles exist in DB."""
|
||||
active = await UserRoleCRUD.get_superadmin_count(db)
|
||||
if active > 0:
|
||||
return
|
||||
if not admin_ids and not admin_emails:
|
||||
logger.critical(
|
||||
'No active superadmins exist and no ADMIN_IDS/ADMIN_EMAILS configured. '
|
||||
'Cabinet admin access is not possible until this is resolved.',
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
'No active superadmin RBAC roles in DB. Legacy config admins (ADMIN_IDS/ADMIN_EMAILS) still have access.',
|
||||
)
|
||||
|
||||
|
||||
async def _ensure_role_by_telegram_id(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -277,14 +302,14 @@ async def _assign_if_missing(
|
||||
role_id: int,
|
||||
identifier: str,
|
||||
) -> bool:
|
||||
"""Create or reactivate a UserRole row for this user/role pair.
|
||||
"""Create a UserRole row if none exists for this user/role pair.
|
||||
|
||||
Handles the unique constraint on (user_id, role_id) by checking for
|
||||
ANY existing assignment (active or inactive) and reactivating if needed.
|
||||
If an assignment already exists (active or revoked), it is left as-is.
|
||||
This ensures that an admin-revoked role is NOT silently reactivated
|
||||
on every bot restart.
|
||||
|
||||
Returns True if a new assignment was created or an inactive one was reactivated.
|
||||
Returns True only if a brand-new assignment was created.
|
||||
"""
|
||||
# Check for ANY existing assignment (active or not) to respect unique constraint
|
||||
result = await db.execute(
|
||||
select(UserRole).where(
|
||||
UserRole.user_id == user_id,
|
||||
@@ -300,19 +325,16 @@ async def _assign_if_missing(
|
||||
user_id=user_id,
|
||||
identifier=identifier,
|
||||
)
|
||||
return False
|
||||
# Reactivate previously revoked assignment
|
||||
existing.is_active = True
|
||||
existing.assigned_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
'Reactivated Superadmin role for user',
|
||||
user_id=user_id,
|
||||
role_id=role_id,
|
||||
identifier=identifier,
|
||||
user_role_id=existing.id,
|
||||
)
|
||||
return True
|
||||
else:
|
||||
logger.info(
|
||||
'Superadmin role was previously revoked, not reactivating '
|
||||
'(remove user from ADMIN_IDS to stop this warning, '
|
||||
'or re-assign via cabinet)',
|
||||
user_id=user_id,
|
||||
identifier=identifier,
|
||||
user_role_id=existing.id,
|
||||
)
|
||||
return False
|
||||
|
||||
user_role = UserRole(
|
||||
user_id=user_id,
|
||||
|
||||
@@ -114,10 +114,18 @@ _ADMIN_ERROR_EVENTS: dict[str, str] = {
|
||||
'errors.bandwidth_usage_threshold_reached_max_notifications': '⚠️ Достигнут лимит уведомлений о трафике',
|
||||
}
|
||||
|
||||
_ADMIN_NODE_CONNECTION_EVENTS = frozenset({'node.connection_lost', 'node.connection_restored'})
|
||||
|
||||
|
||||
class RemnaWaveWebhookService:
|
||||
"""Processes incoming webhooks from RemnaWave backend."""
|
||||
|
||||
# In-memory guard: tracks recent panel recreations per subscription_id.
|
||||
# Prevents unbounded user.deleted → recreate → user.deleted loops.
|
||||
# Key: subscription_id, Value: datetime of last recreation attempt.
|
||||
_recent_recreations: dict[int, datetime] = {}
|
||||
_RECREATION_GUARD_SECONDS: int = 120 # 2-minute cooldown
|
||||
|
||||
def __init__(self, bot: Bot) -> None:
|
||||
self.bot = bot
|
||||
self._admin_service = AdminNotificationService(bot)
|
||||
@@ -216,6 +224,10 @@ class RemnaWaveWebhookService:
|
||||
|
||||
async def _process_admin_event(self, event_name: str, data: dict) -> bool:
|
||||
"""Format and send admin notification for infrastructure events."""
|
||||
if event_name in _ADMIN_NODE_CONNECTION_EVENTS and not settings.REMNAWAVE_WEBHOOK_NOTIFY_NODE_CONNECTION_STATUS:
|
||||
logger.debug('RemnaWave node connection notifications disabled, skipping event', event_name=event_name)
|
||||
return True
|
||||
|
||||
if not self._admin_service.is_enabled:
|
||||
logger.debug('Admin notifications disabled, skipping event', event_name=event_name)
|
||||
return True
|
||||
@@ -681,6 +693,36 @@ class RemnaWaveWebhookService:
|
||||
user_id = user.id
|
||||
sub_id = subscription.id if subscription else None
|
||||
|
||||
# Evict stale entries from the recreation loop guard to prevent unbounded growth
|
||||
if self._recent_recreations:
|
||||
now = datetime.now(UTC)
|
||||
expired_keys = [
|
||||
k
|
||||
for k, v in self._recent_recreations.items()
|
||||
if (now - v).total_seconds() >= self._RECREATION_GUARD_SECONDS
|
||||
]
|
||||
for k in expired_keys:
|
||||
del self._recent_recreations[k]
|
||||
|
||||
# Guard against webhook loop: if we recently attempted panel recreation for this
|
||||
# subscription (from a previous user.deleted), skip to prevent unbounded
|
||||
# recreate→delete→recreate cycles. Uses an in-memory guard (not the generic
|
||||
# last_webhook_update_at stamp which fires on ANY webhook event).
|
||||
if sub_id and sub_id in self._recent_recreations:
|
||||
elapsed = (datetime.now(UTC) - self._recent_recreations[sub_id]).total_seconds()
|
||||
if elapsed < self._RECREATION_GUARD_SECONDS:
|
||||
logger.warning(
|
||||
'Webhook user.deleted: skipping — panel recreation was attempted recently (recreation loop guard)',
|
||||
sub_id=sub_id,
|
||||
user_id=user_id,
|
||||
elapsed=round(elapsed, 1),
|
||||
)
|
||||
return
|
||||
|
||||
# Stamp immediately (before any await) so concurrent coroutines see the guard
|
||||
if sub_id:
|
||||
self._recent_recreations[sub_id] = datetime.now(UTC)
|
||||
|
||||
if subscription:
|
||||
self._stamp_webhook_update(subscription)
|
||||
|
||||
@@ -712,23 +754,46 @@ class RemnaWaveWebhookService:
|
||||
logger.error('Webhook: user not found after rollback', user_id=user_id)
|
||||
return
|
||||
|
||||
# Check if subscription has a future end_date — likely a spurious user.deleted
|
||||
# (e.g., RemnaWave sends user.deleted during panel resync when modifying another user)
|
||||
subscription_still_valid = (
|
||||
subscription is not None and subscription.end_date is not None and subscription.end_date > datetime.now(UTC)
|
||||
)
|
||||
|
||||
if subscription:
|
||||
if subscription.status != SubscriptionStatus.EXPIRED.value:
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
logger.info(
|
||||
'Webhook: subscription marked expired (user deleted in panel) for user',
|
||||
if subscription_still_valid:
|
||||
# Subscription is still valid — don't mark as expired.
|
||||
# Clear only panel linkage fields (URLs, UUID) but keep status and squads
|
||||
# so that re-creation can restore VPN access.
|
||||
logger.warning(
|
||||
'Webhook user.deleted: subscription has future end_date, '
|
||||
'keeping active status and attempting panel re-creation',
|
||||
sub_id=sub_id,
|
||||
user_id=user_id,
|
||||
end_date=subscription.end_date,
|
||||
status=subscription.status,
|
||||
)
|
||||
subscription.subscription_url = None
|
||||
subscription.subscription_crypto_link = None
|
||||
subscription.remnawave_short_uuid = None
|
||||
# Keep connected_squads — needed for panel re-creation
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
else:
|
||||
# Subscription expired or has no end_date — safe to mark as expired
|
||||
if subscription.status != SubscriptionStatus.EXPIRED.value:
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
logger.info(
|
||||
'Webhook: subscription marked expired (user deleted in panel) for user',
|
||||
sub_id=sub_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
subscription.subscription_url = None
|
||||
subscription.subscription_crypto_link = None
|
||||
subscription.remnawave_short_uuid = None
|
||||
subscription.connected_squads = []
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
|
||||
# Clear subscription data — panel user no longer exists
|
||||
subscription.subscription_url = None
|
||||
subscription.subscription_crypto_link = None
|
||||
subscription.remnawave_short_uuid = None
|
||||
subscription.connected_squads = []
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
|
||||
# Remove SubscriptionServer link rows
|
||||
# Remove SubscriptionServer link rows (panel user no longer exists)
|
||||
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == sub_id))
|
||||
|
||||
# Clear remnawave linkage
|
||||
@@ -737,7 +802,69 @@ class RemnaWaveWebhookService:
|
||||
|
||||
await db.commit()
|
||||
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_DELETED', reply_markup=self._get_renew_keyboard(user))
|
||||
if subscription_still_valid:
|
||||
# Attempt to re-create user in panel to restore VPN access.
|
||||
# If recreation fails, fall back to expiring the subscription
|
||||
# so it doesn't stay in ACTIVE-but-no-panel limbo.
|
||||
recreated = await self._attempt_panel_recreation(db, user, subscription)
|
||||
if not recreated:
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
subscription.connected_squads = []
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_DELETED', reply_markup=self._get_renew_keyboard(user))
|
||||
else:
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_DELETED', reply_markup=self._get_renew_keyboard(user))
|
||||
|
||||
async def _attempt_panel_recreation(self, db: AsyncSession, user: User, subscription: Subscription) -> bool:
|
||||
"""Re-create user in RemnaWave panel after spurious user.deleted webhook.
|
||||
|
||||
Called when a user.deleted webhook arrives but the subscription still has a
|
||||
future end_date, indicating the deletion was likely spurious (e.g., RemnaWave
|
||||
resync when modifying another user). Attempts to restore VPN access by
|
||||
creating/updating the user in the panel.
|
||||
|
||||
Returns True if recreation succeeded, False otherwise.
|
||||
"""
|
||||
# Update the recreation guard timestamp to the actual recreation start time
|
||||
if subscription.id is not None:
|
||||
self._recent_recreations[subscription.id] = datetime.now(UTC)
|
||||
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
service = SubscriptionService()
|
||||
if not service.is_configured:
|
||||
logger.warning(
|
||||
'RemnaWave not configured, cannot re-create panel user after user.deleted',
|
||||
user_id=user.id,
|
||||
)
|
||||
return False
|
||||
|
||||
remnawave_user = await service.create_remnawave_user(db, subscription)
|
||||
if remnawave_user:
|
||||
logger.info(
|
||||
'Webhook user.deleted: successfully re-created user in panel',
|
||||
user_id=user.id,
|
||||
subscription_id=subscription.id,
|
||||
new_uuid=remnawave_user.uuid,
|
||||
)
|
||||
return True
|
||||
|
||||
logger.error(
|
||||
'Webhook user.deleted: failed to re-create user in panel',
|
||||
user_id=user.id,
|
||||
subscription_id=subscription.id,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Webhook user.deleted: error re-creating user in panel',
|
||||
user_id=user.id,
|
||||
subscription_id=subscription.id,
|
||||
error=e,
|
||||
)
|
||||
return False
|
||||
|
||||
async def _handle_user_revoked(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
|
||||
@@ -476,7 +476,7 @@ class ReportingService:
|
||||
"""
|
||||
return select(
|
||||
func.count(Transaction.id),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
|
||||
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0),
|
||||
).where(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == true(),
|
||||
|
||||
@@ -22,7 +22,7 @@ from app.database.crud.subscription_conversion import (
|
||||
)
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import ServerSquad, Subscription, SubscriptionStatus, TransactionType, User
|
||||
from app.database.models import PaymentMethod, ServerSquad, Subscription, SubscriptionStatus, TransactionType, User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.pricing_utils import (
|
||||
@@ -1109,6 +1109,7 @@ class MiniAppSubscriptionPurchaseService:
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=pricing.final_total,
|
||||
description=f'Подписка на {pricing.selection.period.days} дней ({pricing.months} мес)',
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
@@ -12,6 +12,7 @@ import structlog
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import (
|
||||
add_subscription_servers,
|
||||
@@ -337,7 +338,7 @@ async def with_admin_notification_service(
|
||||
|
||||
bot: Bot | None = None
|
||||
try:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
service = AdminNotificationService(bot)
|
||||
await handler(service)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
@@ -348,13 +348,17 @@ class SubscriptionService:
|
||||
# Определяем актуальный статус для отправки в RemnaWave
|
||||
# НЕ меняем статус подписки здесь - это задача scheduled job
|
||||
is_actually_active = (
|
||||
subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date > current_time
|
||||
subscription.status in (SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value)
|
||||
and subscription.end_date > current_time
|
||||
)
|
||||
|
||||
# Логируем если статус и end_date не согласованы (для отладки)
|
||||
if subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date <= current_time:
|
||||
if (
|
||||
subscription.status in (SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value)
|
||||
and subscription.end_date <= current_time
|
||||
):
|
||||
logger.warning(
|
||||
'⚠️ update_remnawave_user: подписка имеет статус ACTIVE, но end_date <= now . Отправляем в RemnaWave как EXPIRED, но НЕ меняем статус в БД.',
|
||||
'⚠️ update_remnawave_user: подписка имеет статус ACTIVE, но end_date <= now. Отправляем в RemnaWave как DISABLED, но НЕ меняем статус в БД.',
|
||||
subscription_id=subscription.id,
|
||||
end_date=subscription.end_date,
|
||||
current_time=current_time,
|
||||
@@ -370,8 +374,10 @@ class SubscriptionService:
|
||||
|
||||
update_kwargs = dict(
|
||||
uuid=user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
status=UserStatus.ACTIVE if is_actually_active else UserStatus.DISABLED,
|
||||
expire_at=subscription.end_date
|
||||
if is_actually_active
|
||||
else max(subscription.end_date, current_time + timedelta(minutes=1)),
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff),
|
||||
telegram_id=user.telegram_id,
|
||||
@@ -843,7 +849,8 @@ class SubscriptionService:
|
||||
|
||||
current_time = datetime.now(UTC)
|
||||
is_actually_active = (
|
||||
sub.status == SubscriptionStatus.ACTIVE.value and sub.end_date > current_time
|
||||
sub.status in (SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value)
|
||||
and sub.end_date > current_time
|
||||
)
|
||||
|
||||
user_tag = self._resolve_user_tag(sub)
|
||||
@@ -852,8 +859,10 @@ class SubscriptionService:
|
||||
|
||||
update_kwargs = dict(
|
||||
uuid=user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
|
||||
expire_at=sub.end_date,
|
||||
status=UserStatus.ACTIVE if is_actually_active else UserStatus.DISABLED,
|
||||
expire_at=sub.end_date
|
||||
if is_actually_active
|
||||
else max(sub.end_date, current_time + timedelta(minutes=1)),
|
||||
traffic_limit_bytes=self._gb_to_bytes(sub.traffic_limit_gb),
|
||||
traffic_limit_strategy=traffic_strategy,
|
||||
telegram_id=user.telegram_id,
|
||||
|
||||
@@ -218,6 +218,7 @@ class BotConfigurationService:
|
||||
'BOT_USERNAME': 'CORE',
|
||||
'DEFAULT_LANGUAGE': 'LOCALIZATION',
|
||||
'AVAILABLE_LANGUAGES': 'LOCALIZATION',
|
||||
'REMNAWAVE_WEBHOOK_NOTIFY_NODE_CONNECTION_STATUS': 'ADMIN_NOTIFICATIONS',
|
||||
'LANGUAGE_SELECTION_ENABLED': 'LOCALIZATION',
|
||||
'DEFAULT_DEVICE_LIMIT': 'SUBSCRIPTIONS_CORE',
|
||||
'DEFAULT_TRAFFIC_LIMIT_GB': 'SUBSCRIPTIONS_CORE',
|
||||
@@ -843,6 +844,18 @@ class BotConfigurationService:
|
||||
'example': '60',
|
||||
'warning': 'Защита от спама уведомлениями по одному и тому же пользователю.',
|
||||
},
|
||||
'REMNAWAVE_WEBHOOK_NOTIFY_NODE_CONNECTION_STATUS': {
|
||||
'description': (
|
||||
'Уведомления администраторам о потере и восстановлении соединения с нодами из webhook-ов RemnaWave.'
|
||||
),
|
||||
'format': 'Булево значение.',
|
||||
'example': 'false',
|
||||
'warning': (
|
||||
'Отключает только события node.connection_lost и node.connection_restored. '
|
||||
'Остальные инфраструктурные уведомления продолжают отправляться.'
|
||||
),
|
||||
'dependencies': 'REMNAWAVE_WEBHOOK_ENABLED, ADMIN_NOTIFICATIONS_ENABLED',
|
||||
},
|
||||
'WEBHOOK_NOTIFY_USER_ENABLED': {
|
||||
'description': (
|
||||
'Глобальный переключатель уведомлений пользователям от вебхуков RemnaWave. '
|
||||
|
||||
+9
-2
@@ -400,9 +400,16 @@ return c
|
||||
return fail_closed
|
||||
|
||||
@staticmethod
|
||||
async def is_rate_limited(user_id: int, action: str, limit: int, window: int) -> bool:
|
||||
async def is_rate_limited(
|
||||
user_id: int,
|
||||
action: str,
|
||||
limit: int,
|
||||
window: int,
|
||||
*,
|
||||
fail_closed: bool = False,
|
||||
) -> bool:
|
||||
key = cache_key('rate_limit', user_id, action)
|
||||
return await RateLimitCache._atomic_rate_check(key, limit, window)
|
||||
return await RateLimitCache._atomic_rate_check(key, limit, window, fail_closed=fail_closed)
|
||||
|
||||
@staticmethod
|
||||
async def reset_rate_limit(user_id: int, action: str) -> bool:
|
||||
|
||||
@@ -293,6 +293,9 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
|
||||
if 'MESSAGE_ID_INVALID' in str(error) or 'message to edit not found' in str(error).lower():
|
||||
# Сообщение удалено или недоступно — просто игнорируем
|
||||
return None
|
||||
if 'message is not modified' in str(error).lower():
|
||||
# Контент не изменился — безопасно игнорируем
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user