Compare commits

...

45 Commits

Author SHA1 Message Date
Egor df9985802b Merge pull request #2801 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.41.0
2026-03-22 10:56:39 +03:00
github-actions[bot] c66849db10 chore(main): release 3.41.0 2026-03-22 07:56:12 +00:00
Egor 8a1da85f3e Merge pull request #2800 from BEDOLAGA-DEV/dev
Dev
2026-03-22 10:55:46 +03:00
Fringg 0335f40b47 chore: ruff format rbac_bootstrap_service.py 2026-03-22 10:51:26 +03:00
Fringg 8ac1183670 chore: ruff format admin_referral_network.py 2026-03-22 10:47:01 +03:00
Fringg bcc761f9d3 fix: add missing total_subscription_revenue_kopeks in scoped graph early return
Prevents Pydantic ValidationError (500) when scoped_user_ids is empty
but campaign_ids is present.
2026-03-22 10:46:08 +03:00
Fringg 1eb4e18c17 fix: add abs() to all remaining subscription payment sum queries
Apply func.abs() consistently to all 5 remaining locations that sum
SUBSCRIPTION_PAYMENT amounts: branch revenue, campaign stats,
user detail branch revenue, campaign detail, and search results.
2026-03-22 10:39:20 +03:00
Fringg 056c13bc23 fix: use abs() for subscription payment amounts in referral network
SUBSCRIPTION_PAYMENT transactions are stored as negative values,
causing negative totals in stats panel and user detail card.
2026-03-22 10:36:13 +03:00
Fringg 2bdb7643f8 feat: add total subscription revenue to referral network stats
Expose total_subscription_revenue_kopeks in NetworkGraphResponse,
computed from the existing personal_spent data (sum of all
SUBSCRIPTION_PAYMENT transactions by scoped users).
2026-03-22 10:31:18 +03:00
Fringg 8b8f1b91f3 refactor: extract _compute_subscription_status shared helper
Eliminates duplicated status mapping logic between _fetch_subscription_info
and get_network_user_detail. Single source of truth for mapping
subscription fields to frontend status labels.
2026-03-22 10:08:50 +03:00
Fringg 5ed2f0c958 fix: treat expired and limited subscription statuses as inactive in referral network graph
Previously only disabled and pending statuses were forced to show as
expired in the network graph. Subscriptions with status='expired' or
status='limited' but with end_date > now would incorrectly display as
active. Now all four non-active statuses are treated as expired.
2026-03-22 09:54:59 +03:00
Fringg 454dc9321b fix: consider subscription status field in network graph
The subscription_status computation now checks the Subscription.status
field. Disabled and pending subscriptions are treated as expired
regardless of end_date, preventing incorrect "active" display.
Also added SubscriptionStatus import.
2026-03-22 09:51:59 +03:00
Fringg de91d3282f feat: add subscription status to referral network graph nodes
Add subscription_status field (trial_active, paid_active, trial_expired,
paid_expired) to NetworkUserNode and NetworkUserDetail schemas. Backend
computes status from Subscription.is_trial and end_date using window
function to pick latest subscription per user.
2026-03-22 09:07:29 +03:00
Fringg e0bedc8e78 fix: superadmin role managed exclusively via env config
Superadmin (level 999) assignments are now the sole domain of
ADMIN_IDS/ADMIN_EMAILS environment variables. On startup, bootstrap
reactivates env-listed users and revokes superadmin from users removed
from env. API assign/revoke endpoints return 403 for superadmin-level
roles. _ensure_role_by_email now requires email_verified (symmetric
with revocation check).
2026-03-22 08:41:36 +03:00
Egor 4a48818bc3 Merge pull request #2798 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.40.0
2026-03-22 07:36:14 +03:00
github-actions[bot] ff7388766c chore(main): release 3.40.0 2026-03-22 04:35:51 +00:00
Egor 6b36dc4df1 Merge pull request #2797 from BEDOLAGA-DEV/dev
Dev
2026-03-22 07:35:24 +03:00
Fringg fe847e35f0 chore: ruff format oauth, auth schemas, webhook service 2026-03-22 07:32:04 +03:00
Fringg 4c2cb63cf9 fix: accept stale Telegram initData to prevent MiniApp auth failures
Telegram Desktop/iOS cache initData with stale auth_date (tdesktop#28303).
Increase max_age_seconds from 24h to 30 days for all cabinet login and
account linking endpoints. HMAC signature still validates authenticity,
JWT tokens handle session expiration. Add structured logging for stale
initData acceptance monitoring.
2026-03-22 07:24:27 +03:00
Fringg d3c994083e fix: daily subscription pause not persisting in cabinet and miniapp
lock_user_for_pricing with populate_existing=True was overwriting the
pending is_daily_paused mutation before db.commit(), silently discarding
the pause toggle. Fix moves lock before state reads, uses commit=False
for subtract_user_balance and create_transaction to ensure single atomic
commit, and re-applies is_daily_paused after any populate_existing reload.
2026-03-22 06:54:39 +03:00
Fringg cce3b0c13b feat: allow inactive tariffs for trial subscription activation
Inactive tariffs with is_trial_available=True can now be used for trial
activation across bot, miniapp, and cabinet. This enables dedicated trial
tariffs with custom limits (traffic, devices, servers) without exposing
them in the regular purchase flow. Paid trial paths now properly resolve
trial tariff parameters instead of using global settings defaults.
2026-03-22 06:01:34 +03:00
Fringg 6c208581d9 fix: sanitize email dots in RemnaWave username generation
Email addresses with dots (e.g., john.doe@gmail.com) caused RemnaWave
API validation failure. Now sanitizes email prefix early in the
identifier construction, not just in the final result. Also sanitizes
the fallback username path for defense in depth.
2026-03-22 04:59:30 +03:00
Fringg c307278231 fix: prevent MESSAGE_TOO_LONG in promo groups list
With 20+ promo groups, the full details per group exceeded Telegram's
4096 char limit. Simplified list to one compact line per group with
name and member count. Full details remain in the group detail view.
2026-03-22 04:17:32 +03:00
Fringg 9eab802000 fix: handle spurious user.deleted webhooks — preserve active subscriptions and prevent orphaned panel users
- Smart end_date check: subscriptions with future end_date are preserved (not expired) and panel user is re-created automatically
- Recreation loop guard: in-memory 120s cooldown prevents unbounded recreate→delete→recreate cycles with stale entry eviction
- Race condition protection: guard timestamp stamped before any await point so concurrent coroutines are serialized
- Admin deletion: force_panel_delete=True ensures panel user is always removed, preventing orphaned subscriptions
2026-03-22 03:20:50 +03:00
Fringg 13ea3768b5 feat: custom broadcast buttons and fix home button to use bot menu
- Add custom buttons support: admins can add up to 10 custom buttons
  with callback_data or URL action types to broadcast messages
- CustomBroadcastButton Pydantic model with validation:
  callback_data checked in UTF-8 bytes (Telegram 64-byte limit),
  URLs restricted to https:// and tg:// schemes only
- Fix home button: removed from CABINET_MINIAPP_BUTTON_KEYS so it
  uses back_to_menu callback instead of opening cabinet WebApp
- Both legacy and combined broadcast endpoints pass custom_buttons
2026-03-22 01:54:05 +03:00
Fringg ed5a92ab96 fix: referral system — self-referral protection, race condition fix, deleted user re-registration
- Add telegram_id-based self-referral protection in all 3 Telegram auth endpoints
  (user doesn't exist yet at referral resolution, so telegram_id is used instead of user.id)
- Add SELECT FOR UPDATE + db.refresh in _process_referral_code to prevent TOCTOU race
  on concurrent referral assignment (matches _process_campaign_bonus pattern)
- Fix _process_referral_code to handle two cases: referred_by_id already set by
  create_user() → fire registration event; not set → resolve code, set, fire event
- Fix deleted user re-registration losing referral: keep status=DELETED in preparation
  block so complete_registration enters the DELETED branch (not "already active")
- Remove unused referral_code from DeepLinkPollRequest (deep link = existing users only)
- Fix OIDC exception handling inconsistency (ValueError/LookupError → Exception)
- Fix bare except clauses in start.py → except Exception
- Pass is_new_user to _finalize_oauth_login (only new user path passes True)
2026-03-21 15:06:10 +03:00
Fringg 48265f1cd4 chore: remove redundant comments from DISABLED status fix 2026-03-21 09:07:43 +03:00
Fringg 3b9568fcc1 style: format long lines in monitoring and subscription services 2026-03-21 09:06:01 +03:00
Fringg 79cfcbcece fix: send DISABLED instead of EXPIRED status to RemnaWave API
RemnaWave API only accepts ACTIVE/DISABLED for user status updates —
EXPIRED and LIMITED are managed internally. The bot was sending EXPIRED
status and past expireAt dates, causing 400 validation errors.

- Change UserStatus.EXPIRED → UserStatus.DISABLED in all 5 call sites
- Add 1-minute buffer to expire_at for inactive subscriptions to avoid
  "expiration date in the past" rejections (matches _safe_expire_at_for_panel)
- Include TRIAL status in is_actually_active checks (consistent with
  remnawave_service.py sync_users_to_panel)
2026-03-21 09:00:59 +03:00
Egor 6d5aceb4ca Merge pull request #2793 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.39.0
2026-03-21 07:41:49 +03:00
github-actions[bot] 905dbcc779 chore(main): release 3.39.0 2026-03-21 04:40:13 +00:00
Egor f33dfdf031 Merge pull request #2792 from BEDOLAGA-DEV/dev
Dev
2026-03-21 07:39:51 +03:00
Egor 59cd74d307 Merge pull request #2791 from BEDOLAGA-DEV/main
w
2026-03-21 07:38:22 +03:00
Fringg 90209ebef1 feat: add NaloGO fiscal receipts for code-only gift purchases
- Create NaloGO receipt when code-only gifts (no recipient) are paid via
  any gateway provider, not just directed gifts
- Add receipt_uuid and receipt_created_at columns to guest_purchases for
  persistent DB-level dedup (covers PENDING_ACTIVATION and code-only paths
  where no Transaction exists at receipt time)
- Use SELECT ... FOR UPDATE in try_fulfill_guest_purchase to prevent
  concurrent webhook double-processing race condition
- Expand idempotency guard to include code-only gifts already in PAID status
- Add db.refresh after PENDING_ACTIVATION nalogo call to guard against
  inner rollback expiring the ORM object
2026-03-21 07:37:03 +03:00
Fringg ab43e74ab7 fix: manual admin top-ups missing from sales statistics
Cabinet API and WebAPI created admin balance transactions with
payment_method=NULL instead of 'manual', making them invisible
to sales statistics filters.

Changes:
- Add payment_method=PaymentMethod.MANUAL to Cabinet and WebAPI
  balance update endpoints
- Add func.abs() to all transaction amount aggregations missing it
  across sales stats, dashboard stats, and reporting queries
- Remove redundant Python abs() on addon_revenue (SQL func.abs
  already applied)
- Add data migration 0044 to fix historical NULL payment_method
  records for admin top-ups
2026-03-21 07:01:22 +03:00
Fringg 4244962337 fix: add NaloGO fiscal receipt creation for landing page purchases
Landing page (guest) payments were completely skipping nalogo receipt
generation because the guest purchase flow returned early in payment
webhook handlers before reaching the nalogo code.

Added _create_nalogo_receipt_for_purchase() helper with:
- payment_id null-check (Redis dedup requires it)
- amount validation (skip zero/negative)
- transaction.receipt_uuid duplicate guard
- inner try/except with db.rollback() for receipt_uuid persistence
- sanitize_proxy_error for credential-safe error logging
- privacy: no telegram_user_id in receipt description sent to tax authority

Called in both DELIVERED and PENDING_ACTIVATION paths.
Added db.refresh(purchase) after nalogo call to handle potential
session expiry from rollback inside the helper.
2026-03-21 06:36:21 +03:00
Fringg ba79d03e38 fix: skip non-JSON payload rows in cryptobot payment index and query
payload column in cryptobot_payments contains plain strings like
"balance_2_10000" alongside JSON objects. CAST(payload AS json) fails
on these rows during CREATE INDEX CONCURRENTLY.

- Add AND payload LIKE '{%' to partial index WHERE clause in migration 0042
- Add .payload.like('{%') filter to guest_purchase_service query
2026-03-21 05:43:22 +03:00
Egor 8e4e2ddd1a Update README.md 2026-03-21 05:09:00 +03:00
Egor 38853cdd5a Merge pull request #2790 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.38.0
2026-03-21 04:32:38 +03:00
github-actions[bot] f837c0c244 chore(main): release 3.38.0 2026-03-21 01:32:11 +00:00
Egor 8a7b9cc651 Merge pull request #2789 from BEDOLAGA-DEV/dev
Dev
2026-03-21 04:31:51 +03:00
Fringg 3bf31055e7 fix: sanitize proxy credentials in all nalogo error paths
- Apply sanitize_proxy_error() to all 8 error handlers in nalogo_service
- Remove exc_info=True from error paths that could expose proxy creds
- Fix regex backreference to preserve original SOCKS scheme
- Consolidate proxy utility imports to module level
- Add source indicator (NALOGO_PROXY_URL vs fallback) to startup log
2026-03-21 04:27:15 +03:00
Fringg 3c5bf4fa22 feat: add SOCKS proxy support for nalogo (tax service) module
Route all nalog.ru API traffic through SOCKS proxy. Uses NALOGO_PROXY_URL
env var (falls back to PROXY_URL if not set). Adds httpx[socks] dependency.

- Thread proxy_url through Client → AuthProviderImpl + AsyncHTTPClient
- Extract mask_proxy_url() and sanitize_proxy_error() utilities
- Add socks5h:// scheme support for remote DNS resolution
- Sanitize proxy credentials in error messages
- Log masked proxy URL at startup and service init
2026-03-21 04:21:11 +03:00
Fringg 4990ddf9e4 fix: add diagnostic payload logging in create_user error path
Consistent with update_user — log full payload before re-raising
non-A039 errors to aid debugging.
2026-03-21 04:08:18 +03:00
Fringg de00612965 fix: retry Remnawave API calls without externalSquadUuid on A039 FK violation
When a tariff has a stale external_squad_uuid that no longer exists in
the Remnawave panel, PATCH/POST /api/users fails with A039 (P2003 FK
constraint violation). This caused subscriptions to not sync with the
panel even though balance was already charged.

Now both update_user() and create_user() catch A039 errors and
automatically retry without externalSquadUuid, logging a warning about
the stale UUID. The subscription sync succeeds without the external
squad assignment rather than failing entirely.
2026-03-21 03:58:21 +03:00
50 changed files with 1280 additions and 304 deletions
+1
View File
@@ -522,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 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.37.0"
".": "3.41.0"
}
+70
View File
@@ -1,5 +1,75 @@
# Changelog
## [3.41.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.40.0...v3.41.0) (2026-03-22)
### New Features
* add subscription status to referral network graph nodes ([de91d32](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/de91d3282ffa15c0cec60c0d62871d39e7ee4c05))
* add total subscription revenue to referral network stats ([2bdb764](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2bdb7643f8fd142e99caee0fe989348161377348))
### Bug Fixes
* add abs() to all remaining subscription payment sum queries ([1eb4e18](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1eb4e18c1776b2265a48e0b923a0ca4ee057d912))
* add missing total_subscription_revenue_kopeks in scoped graph early return ([bcc761f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bcc761f9d3f673bd2b404adf817762058d8e0df4))
* consider subscription status field in network graph ([454dc93](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/454dc9321bb9405c5ff0ff559ae4ced15533f3af))
* superadmin role managed exclusively via env config ([e0bedc8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e0bedc8e780a2f91509517110639773e90bb6125))
* treat expired and limited subscription statuses as inactive in referral network graph ([5ed2f0c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5ed2f0c95842a43ab57220dc05ca346748bd6adb))
* use abs() for subscription payment amounts in referral network ([056c13b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/056c13bc23e6737f44bbcb802a66b643349f75a9))
### Refactoring
* extract _compute_subscription_status shared helper ([8b8f1b9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8b8f1b91f37f829528f785a40e3a9cb98c85e043))
## [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)
+1 -1
View File
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
FROM python:3.13-slim
ARG VERSION="v3.37.0" # x-release-please-version
ARG VERSION="v3.41.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+12 -7
View File
@@ -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>
+9 -5
View File
@@ -101,12 +101,16 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
bot = create_bot()
proxy_url = settings.get_proxy_url()
if proxy_url:
from urllib.parse import urlparse
nalogo_proxy_url = settings.get_nalogo_proxy_url()
parsed = urlparse(proxy_url)
masked = f'{parsed.scheme}://***@{parsed.hostname}:{parsed.port}' if parsed.username else proxy_url
logger.info('Proxy configured', proxy_url=masked)
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')
+20
View File
@@ -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
+4 -2
View File
@@ -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',
+2
View File
@@ -453,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
@@ -651,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)
+103 -15
View File
@@ -2,6 +2,7 @@
import re
from collections import defaultdict
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -16,6 +17,7 @@ from app.database.models import (
PartnerStatus,
ReferralEarning,
Subscription,
SubscriptionStatus,
Tariff,
Transaction,
TransactionType,
@@ -78,6 +80,7 @@ class NetworkUserNode(BaseModel):
personal_spent_kopeks: int
subscription_name: str | None
subscription_end: str | None
subscription_status: str | None
registered_at: str | None
@@ -114,6 +117,7 @@ class NetworkGraphResponse(BaseModel):
total_referrers: int
total_campaigns: int
total_earnings_kopeks: int
total_subscription_revenue_kopeks: int
class NetworkUserDetail(BaseModel):
@@ -134,6 +138,7 @@ class NetworkUserDetail(BaseModel):
personal_spent_kopeks: int
subscription_name: str | None
subscription_end: str | None
subscription_status: str | None
registered_at: str | None
@@ -215,6 +220,7 @@ def _build_user_node(
campaign_id: int | None,
subscription_name: str | None,
subscription_end_str: str | None,
subscription_status: str | None,
) -> NetworkUserNode:
return NetworkUserNode(
id=user.id,
@@ -232,6 +238,7 @@ def _build_user_node(
personal_spent_kopeks=personal_spent,
subscription_name=subscription_name,
subscription_end=subscription_end_str,
subscription_status=subscription_status,
registered_at=_format_datetime(user.created_at),
)
@@ -312,7 +319,7 @@ async def _fetch_branch_revenue(db: AsyncSession, user_ids: set[int]) -> dict[in
stmt = (
select(
referred_user.c.referred_by_id,
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0),
)
.join(referred_user, Transaction.user_id == referred_user.c.id)
.where(
@@ -333,7 +340,7 @@ async def _fetch_personal_spent(db: AsyncSession, user_ids: set[int]) -> dict[in
return {}
stmt = (
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
select(Transaction.user_id, func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
.where(
and_(
Transaction.user_id.in_(user_ids),
@@ -376,18 +383,81 @@ async def _fetch_campaign_registrations(db: AsyncSession, user_ids: set[int] | N
return {row[0]: row[1] for row in result}
async def _fetch_subscription_info(db: AsyncSession, user_ids: set[int]) -> dict[int, tuple[str | None, str | None]]:
"""Return {user_id: (tariff_name, end_date_iso)} for given users."""
def _compute_subscription_status(
is_trial: bool | None,
db_status: str | None,
end_date: datetime | None,
now: datetime,
) -> str | None:
"""Map subscription fields to a frontend status label.
Returns one of: 'trial_active', 'trial_expired', 'paid_active', 'paid_expired', or None.
Statuses DISABLED, PENDING, EXPIRED, LIMITED are treated as inactive regardless of end_date.
ACTIVE and TRIAL fall through to a date-based check.
"""
if is_trial is None:
return None
if db_status in (
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.PENDING.value,
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.LIMITED.value,
):
return 'trial_expired' if is_trial else 'paid_expired'
if is_trial:
return 'trial_active' if (end_date and end_date > now) else 'trial_expired'
return 'paid_active' if (end_date and end_date > now) else 'paid_expired'
async def _fetch_subscription_info(
db: AsyncSession,
user_ids: set[int],
) -> dict[int, tuple[str | None, str | None, str | None]]:
"""Return {user_id: (tariff_name, end_date_iso, subscription_status)} for given users."""
if not user_ids:
return {}
stmt = (
select(Subscription.user_id, Tariff.name, Subscription.end_date)
row_num = (
func.row_number()
.over(
partition_by=Subscription.user_id,
order_by=Subscription.end_date.desc().nullslast(),
)
.label('rn')
)
inner = (
select(
Subscription.user_id,
Tariff.name,
Subscription.end_date,
Subscription.is_trial,
Subscription.status,
row_num,
)
.outerjoin(Tariff, Subscription.tariff_id == Tariff.id)
.where(Subscription.user_id.in_(user_ids))
)
subq = inner.subquery()
stmt = select(
subq.c.user_id,
subq.c.name,
subq.c.end_date,
subq.c.is_trial,
subq.c.status,
).where(subq.c.rn == 1)
result = await db.execute(stmt)
return {row[0]: (row[1], _format_datetime(row[2]) if row[2] else None) for row in result}
now = datetime.now(UTC)
out: dict[int, tuple[str | None, str | None, str | None]] = {}
for row in result:
user_id, tariff_name, end_date, is_trial, db_status = row
end_date_iso = _format_datetime(end_date) if end_date else None
sub_status = _compute_subscription_status(is_trial, db_status, end_date, now)
out[user_id] = (tariff_name, end_date_iso, sub_status)
return out
async def _fetch_campaign_stats(
@@ -428,7 +498,7 @@ async def _fetch_campaign_stats(
user_spent: dict[int, int] = {}
if all_campaign_users:
spent_stmt = (
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
select(Transaction.user_id, func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
.where(
and_(
Transaction.user_id.in_(all_campaign_users),
@@ -540,6 +610,7 @@ async def get_referral_network(
total_referrers=0,
total_campaigns=0,
total_earnings_kopeks=0,
total_subscription_revenue_kopeks=0,
)
# Cap to prevent excessive response sizes (deterministic: keep lowest IDs for stability)
@@ -567,7 +638,7 @@ async def get_referral_network(
# Build user nodes
user_nodes: list[NetworkUserNode] = []
for user in users:
sub = sub_info.get(user.id, (None, None))
sub = sub_info.get(user.id, (None, None, None))
user_nodes.append(
_build_user_node(
user,
@@ -578,6 +649,7 @@ async def get_referral_network(
campaign_id=campaign_regs.get(user.id),
subscription_name=sub[0],
subscription_end_str=sub[1],
subscription_status=sub[2],
)
)
@@ -629,6 +701,7 @@ async def get_referral_network(
total_referrers = len([u for u in user_nodes if u.direct_referrals > 0])
total_earnings = sum(personal_revenue.values())
total_subscription_revenue = sum(personal_spent.values())
return NetworkGraphResponse(
users=user_nodes,
@@ -638,6 +711,7 @@ async def get_referral_network(
total_referrers=total_referrers,
total_campaigns=len(campaign_nodes),
total_earnings_kopeks=total_earnings,
total_subscription_revenue_kopeks=total_subscription_revenue,
)
@@ -784,6 +858,7 @@ async def _build_scoped_graph(
total_referrers=0,
total_campaigns=len(campaign_nodes),
total_earnings_kopeks=0,
total_subscription_revenue_kopeks=0,
)
return NetworkGraphResponse(
users=[],
@@ -793,6 +868,7 @@ async def _build_scoped_graph(
total_referrers=0,
total_campaigns=0,
total_earnings_kopeks=0,
total_subscription_revenue_kopeks=0,
)
# Cap to prevent excessive response sizes
@@ -816,7 +892,7 @@ async def _build_scoped_graph(
user_nodes: list[NetworkUserNode] = []
for user in users:
sub = sub_info.get(user.id, (None, None))
sub = sub_info.get(user.id, (None, None, None))
user_nodes.append(
_build_user_node(
user,
@@ -827,6 +903,7 @@ async def _build_scoped_graph(
campaign_id=campaign_regs.get(user.id),
subscription_name=sub[0],
subscription_end_str=sub[1],
subscription_status=sub[2],
)
)
@@ -878,6 +955,7 @@ async def _build_scoped_graph(
total_referrers = len([u for u in user_nodes if u.direct_referrals > 0])
total_earnings = sum(personal_revenue.values())
total_subscription_revenue = sum(personal_spent.values())
return NetworkGraphResponse(
users=user_nodes,
@@ -887,6 +965,7 @@ async def _build_scoped_graph(
total_referrers=total_referrers,
total_campaigns=len(campaign_nodes),
total_earnings_kopeks=total_earnings,
total_subscription_revenue_kopeks=total_subscription_revenue,
)
@@ -1057,7 +1136,7 @@ async def get_network_user_detail(
branch_revenue = 0
# Personal spent
spent_stmt = select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
spent_stmt = select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
and_(
Transaction.user_id == user_id,
Transaction.type.in_(SPENT_TRANSACTION_TYPES),
@@ -1102,7 +1181,7 @@ async def get_network_user_detail(
# Branch revenue: total spent by all users in the branch
branch_user_ids_stmt = select(branch_cte.c.id)
branch_rev_stmt = select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
branch_rev_stmt = select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
and_(
Transaction.user_id.in_(branch_user_ids_stmt),
Transaction.type.in_(SPENT_TRANSACTION_TYPES),
@@ -1124,10 +1203,17 @@ async def get_network_user_detail(
# Subscription info
subscription_name: str | None = None
subscription_end: str | None = None
subscription_status: str | None = None
if user.subscription is not None:
if user.subscription.tariff is not None:
subscription_name = user.subscription.tariff.name
subscription_end = _format_datetime(user.subscription.end_date)
subscription_status = _compute_subscription_status(
user.subscription.is_trial,
user.subscription.status,
user.subscription.end_date,
datetime.now(UTC),
)
return NetworkUserDetail(
id=user.id,
@@ -1147,6 +1233,7 @@ async def get_network_user_detail(
personal_spent_kopeks=personal_spent,
subscription_name=subscription_name,
subscription_end=subscription_end,
subscription_status=subscription_status,
registered_at=_format_datetime(user.created_at),
)
@@ -1215,7 +1302,7 @@ async def get_network_campaign_detail(
total_spent = 0
if campaign_user_ids:
spent_stmt = (
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
select(Transaction.user_id, func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
.where(
and_(
Transaction.user_id.in_(campaign_user_ids),
@@ -1336,7 +1423,7 @@ async def search_referral_network(
sub_info = await _fetch_subscription_info(db, matched_ids)
for user in matched_users:
sub = sub_info.get(user.id, (None, None))
sub = sub_info.get(user.id, (None, None, None))
user_nodes.append(
_build_user_node(
user,
@@ -1347,6 +1434,7 @@ async def search_referral_network(
campaign_id=campaign_regs.get(user.id),
subscription_name=sub[0],
subscription_end_str=sub[1],
subscription_status=sub[2],
)
)
@@ -1400,7 +1488,7 @@ async def search_referral_network(
campaign_user_spent: dict[int, int] = {}
if all_campaign_user_ids:
spent_stmt = (
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
select(Transaction.user_id, func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
.where(
and_(
Transaction.user_id.in_(all_campaign_user_ids),
+18 -44
View File
@@ -441,6 +441,14 @@ async def assign_role(
detail='Role not found',
)
# Superadmin role is managed exclusively via ADMIN_IDS/ADMIN_EMAILS env config
if role.level >= SUPERADMIN_LEVEL:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Superadmin role is managed via ADMIN_IDS/ADMIN_EMAILS environment variables. '
'Add the user there and restart the bot.',
)
admin_level = await _get_admin_level(db, admin)
# Cannot assign a role with level >= own level
@@ -450,13 +458,6 @@ 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
@@ -505,9 +506,7 @@ async def revoke_role(
admin: User = Depends(require_permission('roles:assign')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Revoke a role assignment. Cannot remove the last superadmin."""
from app.config import settings
from app.database.crud.user import get_user_by_id
"""Revoke a role assignment. Superadmin roles are managed via env config."""
from app.database.models import UserRole
# Lock the assignment row (FOR UPDATE held until commit)
@@ -526,6 +525,14 @@ async def revoke_role(
detail='Associated role not found',
)
# Superadmin role is managed exclusively via env config
if role.level >= SUPERADMIN_LEVEL:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Superadmin role is managed via ADMIN_IDS/ADMIN_EMAILS environment variables. '
'Remove the user from env and restart the bot.',
)
admin_level = await _get_admin_level(db, admin)
# Cannot revoke a role at or above own level
@@ -535,33 +542,6 @@ async def revoke_role(
detail='Cannot revoke a role at or above your own 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(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot remove the last superadmin',
)
# 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()
@@ -575,10 +555,4 @@ async def revoke_role(
role_name=role.name,
)
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
return {'message': 'Role revoked', 'assignment_id': assignment_id}
+7 -7
View File
@@ -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,
@@ -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)
+3
View File
@@ -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:
+83 -16
View File
@@ -239,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
@@ -254,8 +282,6 @@ async def _process_referral_code(
user.referred_by_id = referrer.id
await db.flush()
from app.bot_factory import create_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)
@@ -405,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(
@@ -434,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)
@@ -481,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)
@@ -515,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',
@@ -529,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(
@@ -569,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)
@@ -661,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(
@@ -698,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:
@@ -1793,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
+7 -3
View File
@@ -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
)
+46 -24
View File
@@ -1145,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
@@ -1154,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
@@ -1288,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
@@ -1298,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
@@ -4255,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:
@@ -4277,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)
@@ -4295,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
@@ -4311,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:
@@ -4335,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(
@@ -4350,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:
+12 -1
View File
@@ -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',
)
+24 -1
View File
@@ -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
+24 -13
View File
@@ -376,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
@@ -807,7 +808,7 @@ class Settings(BaseSettings):
# Format: socks5://user:password@host:port or socks5://host:port
PROXY_URL: str | None = None
@field_validator('PROXY_URL', mode='before')
@field_validator('PROXY_URL', 'NALOGO_PROXY_URL', mode='before')
@classmethod
def validate_proxy_url(cls, value: str | None) -> str | None:
if not value:
@@ -815,13 +816,13 @@ class Settings(BaseSettings):
from urllib.parse import urlparse
parsed = urlparse(value)
if parsed.scheme not in ('socks5', 'socks4'):
if parsed.scheme not in ('socks5', 'socks5h', 'socks4'):
raise ValueError(
f'PROXY_URL must use socks5:// or socks4:// scheme, got: {parsed.scheme!r}. '
'HTTP proxies are not supported for security reasons (bot token would be exposed).'
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')
raise ValueError('Proxy URL must contain a hostname')
return value
@field_validator('MAIN_MENU_MODE', mode='before')
@@ -954,6 +955,13 @@ class Settings(BaseSettings):
"""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.
@@ -1125,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:
@@ -1144,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]:
+6
View File
@@ -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,
)
+4 -1
View File
@@ -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)
+1 -1
View File
@@ -344,7 +344,7 @@ 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_(
+2
View File
@@ -3288,6 +3288,8 @@ class GuestPurchase(Base):
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')
+30 -5
View File
@@ -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',
+19 -2
View File
@@ -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
+7 -18
View File
@@ -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}',
)
]
+3 -3
View File
@@ -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,
+5 -4
View File
@@ -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)
+118 -27
View File
@@ -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,
+3 -1
View File
@@ -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)
+6 -4
View File
@@ -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,
+4
View File
@@ -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)
+8 -3
View File
@@ -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,
+105 -1
View File
@@ -25,6 +25,7 @@ from app.database.models import (
LandingPage,
PaymentMethod,
Tariff,
Transaction,
TransactionType,
User,
)
@@ -176,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,
@@ -271,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
@@ -335,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,
@@ -363,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
@@ -1344,6 +1447,7 @@ async def _find_succeeded_provider_payment(
result = await db.execute(
select(CryptoBotPayment).where(
CryptoBotPayment.status == 'paid',
CryptoBotPayment.payload.like('{%'),
cast(CryptoBotPayment.payload, SA_JSON)['purchase_token'].as_string() == purchase_token,
)
)
+4 -2
View File
@@ -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(
+19 -11
View File
@@ -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 = ошибка, [] = нет чеков
+36 -7
View File
@@ -483,12 +483,14 @@ async def try_fulfill_guest_purchase(
if purchase_token is None:
return None
from app.database.crud.landing import get_purchase_by_token, update_purchase_status
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:
@@ -502,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',
@@ -536,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)
+93 -22
View File
@@ -10,6 +10,7 @@ from typing import Final
import structlog
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.crud.rbac import SUPERADMIN_LEVEL, UserRoleCRUD
@@ -215,19 +216,28 @@ async def bootstrap_superadmins(db: AsyncSession) -> None:
if assigned:
assigned_count += 1
# ── 4. Commit all changes ──────────────────────────────────────
# ── 4. Revoke superadmin from users NOT in env ───────────────
revoked_count = await _revoke_stale_superadmins(
db,
role_id=role_id,
admin_ids=admin_ids,
admin_emails=admin_emails,
)
# ── 5. Commit all changes ──────────────────────────────────────
await db.commit()
if assigned_count > 0:
if assigned_count > 0 or revoked_count > 0:
logger.info(
'Superadmin bootstrap completed',
assigned_count=assigned_count,
revoked_count=revoked_count,
role_id=role_id,
)
else:
logger.debug('Superadmin bootstrap: no new assignments needed')
logger.debug('Superadmin bootstrap: no changes needed')
# ── 5. Safety: warn if no active superadmins exist ────────────
# ── 6. Safety: warn if no active superadmins exist ────────────
await _warn_if_no_superadmins(db, admin_ids, admin_emails)
except Exception:
@@ -235,6 +245,60 @@ async def bootstrap_superadmins(db: AsyncSession) -> None:
logger.exception('Failed to bootstrap superadmins, continuing startup')
async def _revoke_stale_superadmins(
db: AsyncSession,
*,
role_id: int,
admin_ids: list[int],
admin_emails: list[str],
) -> int:
"""Revoke superadmin from users who are no longer in env config.
Env config (ADMIN_IDS / ADMIN_EMAILS) is the single source of truth.
If a user was removed from env, their superadmin DB role is deactivated
on the next bot restart.
Returns the number of revoked assignments.
"""
result = await db.execute(
select(UserRole)
.options(selectinload(UserRole.user))
.where(
UserRole.role_id == role_id,
UserRole.is_active.is_(True),
)
)
active_assignments = result.scalars().all()
admin_ids_set = set(admin_ids)
admin_emails_set = {e.lower() for e in admin_emails}
revoked = 0
for assignment in active_assignments:
user = assignment.user
if user is None:
continue
# Check if user is still in env config.
# email_verified is required — symmetric with _ensure_role_by_email.
in_env_by_id = user.telegram_id is not None and user.telegram_id in admin_ids_set
in_env_by_email = user.email is not None and user.email_verified and user.email.lower() in admin_emails_set
if not in_env_by_id and not in_env_by_email:
assignment.is_active = False
await db.flush()
revoked += 1
logger.warning(
'Revoked Superadmin role: user removed from env config',
user_id=user.id,
telegram_id=user.telegram_id,
email=user.email,
user_role_id=assignment.id,
)
return revoked
async def _warn_if_no_superadmins(
db: AsyncSession,
admin_ids: list[int],
@@ -281,13 +345,18 @@ async def _ensure_role_by_email(
email: str,
role_id: int,
) -> bool:
"""Assign Superadmin role to user found by email (case-insensitive). Returns True if assigned."""
result = await db.execute(select(User).where(func.lower(User.email) == email.lower()))
"""Assign Superadmin role to user found by verified email (case-insensitive). Returns True if assigned."""
result = await db.execute(
select(User).where(
func.lower(User.email) == email.lower(),
User.email_verified.is_(True),
)
)
user = result.scalar_one_or_none()
if user is None:
logger.debug(
'Admin user (email) not yet registered, skipping',
'Admin user (email) not yet registered or not verified, skipping',
email=email,
)
return False
@@ -302,13 +371,13 @@ async def _assign_if_missing(
role_id: int,
identifier: str,
) -> bool:
"""Create a UserRole row if none exists for this user/role pair.
"""Create or reactivate a UserRole row for this user/role pair.
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.
Env config (ADMIN_IDS / ADMIN_EMAILS) is the source of truth for
Superadmin assignments. If a previously revoked assignment exists,
it is reactivated the env config always wins.
Returns True only if a brand-new assignment was created.
Returns True if a new assignment was created or an inactive one was reactivated.
"""
result = await db.execute(
select(UserRole).where(
@@ -325,16 +394,18 @@ async def _assign_if_missing(
user_id=user_id,
identifier=identifier,
)
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
return False
# Reactivate: env config is the source of truth
existing.is_active = True
await db.flush()
logger.info(
'Reactivated Superadmin role (user is in env config)',
user_id=user_id,
identifier=identifier,
user_role_id=existing.id,
)
return True
user_role = UserRole(
user_id=user_id,
+134 -13
View File
@@ -120,6 +120,12 @@ _ADMIN_NODE_CONNECTION_EVENTS = frozenset({'node.connection_lost', 'node.connect
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)
@@ -687,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)
@@ -718,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
@@ -743,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
+1 -1
View File
@@ -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(),
+18 -9
View File
@@ -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,
+35
View File
@@ -0,0 +1,35 @@
"""Proxy URL utilities for safe logging and error handling."""
import re
from urllib.parse import urlparse
def mask_proxy_url(proxy_url: str) -> str:
"""Mask credentials in a proxy URL for safe logging.
Handles edge cases:
- No credentials: returns URL as-is
- Username + password: masks both with ***
- Password-only: masks as well
- No explicit port: omits :port part
"""
parsed = urlparse(proxy_url)
if not parsed.username and not parsed.password:
return proxy_url
host = parsed.hostname or 'unknown'
port_part = f':{parsed.port}' if parsed.port else ''
return f'{parsed.scheme}://***@{host}{port_part}'
_PROXY_CRED_RE = re.compile(r'(socks[45h]*://)([^@\s]+@)', re.IGNORECASE)
def sanitize_proxy_error(error: Exception) -> str:
"""Strip proxy credentials from exception messages.
httpx/socksio may include the full proxy URL (with credentials)
in connection error messages and tracebacks. This function removes
credentials from the error string while preserving the original scheme.
"""
msg = str(error)
return _PROXY_CRED_RE.sub(r'\1***@', msg)
+46 -23
View File
@@ -3776,14 +3776,13 @@ async def activate_subscription_trial_endpoint(
try:
from app.database.crud.tariff import get_tariff_by_id, 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_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
@@ -7145,7 +7144,16 @@ async def toggle_daily_subscription_pause_endpoint(
detail={'code': 'not_daily_tariff', 'message': 'Subscription is not on a daily tariff'},
)
# Определяем состояние
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)
subscription = user.subscription
# Определяем состояние из LOCKED экземпляра
from app.database.models import SubscriptionStatus
is_currently_paused = getattr(subscription, 'is_daily_paused', False)
@@ -7162,13 +7170,6 @@ async def toggle_daily_subscription_pause_endpoint(
new_paused_state = not is_currently_paused
subscription.is_daily_paused = new_paused_state
raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0)
# Lock user BEFORE price computation to prevent TOCTOU on promo discount
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 resume-after-topup)
from app.services.pricing_engine import PricingEngine
@@ -7178,6 +7179,8 @@ async def toggle_daily_subscription_pause_endpoint(
PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price
)
resume_transaction = None
# Если снимаем с паузы, проверяем баланс и списываем оплату
if not new_paused_state:
if daily_price > 0 and user.balance_kopeks < daily_price:
@@ -7202,6 +7205,7 @@ async def toggle_daily_subscription_pause_endpoint(
daily_price,
f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
mark_as_paid_subscription=True,
commit=False,
)
if not deducted:
raise HTTPException(
@@ -7217,32 +7221,51 @@ async def toggle_daily_subscription_pause_endpoint(
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 in miniapp', 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,
)
# Баланс списан — теперь активируем
now = datetime.now(UTC)
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.last_daily_charge_at = datetime.now(UTC)
subscription.end_date = datetime.now(UTC) + timedelta(days=1)
subscription.last_daily_charge_at = now
subscription.end_date = now + timedelta(days=1)
logger.info(
'Суточная подписка восстановлена в ACTIVE (miniapp)',
'Суточная подписка восстановлена в ACTIVE (miniapp)',
subscription_id=subscription.id,
previous_status='disabled/expired',
)
# Re-apply is_daily_paused on the current identity-mapped instance
# (subtract_user_balance with populate_existing=True may have reloaded it from DB)
subscription.is_daily_paused = new_paused_state
await db.commit()
await db.refresh(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 (miniapp)', error=exc)
# Синхронизация с RemnaWave только при возобновлении из DISABLED/EXPIRED
if not new_paused_state and was_disabled:
try:
+1 -1
View File
@@ -76,7 +76,7 @@ async def _get_overview(db: AsyncSession) -> dict[str, object]:
today = datetime.now(UTC).date()
today_transactions = (
await db.scalar(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
func.date(Transaction.created_at) == today,
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
+2 -1
View File
@@ -24,7 +24,7 @@ from app.database.crud.user import (
get_user_by_telegram_id,
update_user,
)
from app.database.models import PromoGroup, Subscription, User, UserStatus
from app.database.models import PaymentMethod, PromoGroup, Subscription, User, UserStatus
from app.services.subscription_service import SubscriptionService
from ..dependencies import get_db_session, require_api_token
@@ -317,6 +317,7 @@ async def update_balance(
amount_kopeks=payload.amount_kopeks,
description=payload.description or 'Корректировка через веб-API',
create_transaction=payload.create_transaction,
payment_method=PaymentMethod.MANUAL,
)
if not success:
@@ -73,12 +73,13 @@ def upgrade() -> None:
)
)
# CryptoBot: payload (text) column with JSON inside, no metadata_json
# CryptoBot: payload (text) column with JSON inside, no metadata_json.
# Filter payload LIKE '{%' to skip non-JSON values (e.g. "balance_2_10000").
op.execute(
sa.text(
'CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_cryptobot_payments_payload_purchase_token '
"ON cryptobot_payments ((CAST(payload AS json) ->> 'purchase_token')) "
"WHERE status = 'paid'"
"WHERE status = 'paid' AND payload LIKE '{%'"
)
)
@@ -0,0 +1,66 @@
"""fix payment_method=NULL for admin manual top-ups
Revision ID: 0044
Revises: 0043
Create Date: 2026-03-21
Data-only migration: sets payment_method='manual' on deposit transactions
that were created by admin top-ups (Cabinet API, WebAPI, Telegram bot)
but stored with payment_method=NULL due to a bug.
Strategy: exclude all known non-admin deposit patterns that legitimately
have payment_method=NULL (wheel prizes, campaigns, promo codes, referral
purchase commissions, legacy webhook duplicates). Everything remaining
with type='deposit' AND payment_method IS NULL is an admin manual top-up.
"""
from typing import Sequence, Union
from alembic import op
revision: str = '0044'
down_revision: Union[str, None] = '0043'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("""
UPDATE transactions
SET payment_method = 'manual'
WHERE type = 'deposit'
AND payment_method IS NULL
AND is_completed = TRUE
AND (description IS NULL OR (
description NOT LIKE 'Выигрыш в колесе удачи:%'
AND description NOT LIKE 'Бонус за регистрацию по кампании%'
AND description NOT LIKE 'Бонус по промокоду%'
AND description NOT LIKE 'Комиссия %'
AND description NOT LIKE 'Бонус за первое пополнение%'
AND description NOT LIKE 'Бонус за реферала%'
AND description NOT LIKE 'Восстановленный бонус%'
AND description NOT LIKE 'Пополнение через Tribute%'
AND description NOT LIKE 'Пополнение через Telegram Stars%'
))
""")
def downgrade() -> None:
op.execute("""
UPDATE transactions
SET payment_method = NULL
WHERE type = 'deposit'
AND payment_method = 'manual'
AND is_completed = TRUE
AND (description IS NULL OR (
description NOT LIKE 'Выигрыш в колесе удачи:%'
AND description NOT LIKE 'Бонус за регистрацию по кампании%'
AND description NOT LIKE 'Бонус по промокоду%'
AND description NOT LIKE 'Комиссия %'
AND description NOT LIKE 'Бонус за первое пополнение%'
AND description NOT LIKE 'Бонус за реферала%'
AND description NOT LIKE 'Восстановленный бонус%'
AND description NOT LIKE 'Пополнение через Tribute%'
AND description NOT LIKE 'Пополнение через Telegram Stars%'
))
""")
@@ -0,0 +1,35 @@
"""add receipt_uuid and receipt_created_at to guest_purchases
Revision ID: 0045
Revises: 0044
Create Date: 2026-03-21
Adds receipt_uuid and receipt_created_at columns to guest_purchases table
so that NaloGO fiscal receipt UUIDs are persisted on the purchase record
itself (not only on transaction or in Redis). This provides a persistent
DB-level dedup guard and audit trail for receipts created in the
PENDING_ACTIVATION path and code-only gift path where no Transaction
exists at receipt creation time.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0045'
down_revision: str | None = '0044'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('guest_purchases', sa.Column('receipt_uuid', sa.String(255), nullable=True))
op.add_column('guest_purchases', sa.Column('receipt_created_at', sa.DateTime(timezone=True), nullable=True))
op.create_index('ix_guest_purchases_receipt_uuid', 'guest_purchases', ['receipt_uuid'])
def downgrade() -> None:
op.drop_index('ix_guest_purchases_receipt_uuid', table_name='guest_purchases')
op.drop_column('guest_purchases', 'receipt_created_at')
op.drop_column('guest_purchases', 'receipt_uuid')
+2 -1
View File
@@ -1,6 +1,6 @@
[project]
name = 'remnawave-bedolaga-telegram-bot'
version = "3.37.0"
version = "3.41.0"
description = 'Telegram bot for RemnaWave VPN service'
readme = 'README.md'
license = { text = 'MIT' }
@@ -25,6 +25,7 @@ dependencies = [
'structlog>=25.1.0,<26',
'rich>=14.0',
'aiohttp-socks>=0.10.1',
'httpx[socks]>=0.27.0',
]
[dependency-groups]
Generated
+17 -1
View File
@@ -623,6 +623,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[package.optional-dependencies]
socks = [
{ name = "socksio" },
]
[[package]]
name = "idna"
version = "3.11"
@@ -1137,7 +1142,7 @@ wheels = [
[[package]]
name = "remnawave-bedolaga-telegram-bot"
version = "3.36.0"
version = "3.38.0"
source = { virtual = "." }
dependencies = [
{ name = "aiogram" },
@@ -1148,6 +1153,7 @@ dependencies = [
{ name = "bcrypt" },
{ name = "cryptography" },
{ name = "fastapi", extra = ["standard"] },
{ name = "httpx", extra = ["socks"] },
{ name = "packaging" },
{ name = "pyjwt" },
{ name = "python-dateutil" },
@@ -1179,6 +1185,7 @@ requires-dist = [
{ name = "bcrypt", specifier = ">=5.0.0" },
{ name = "cryptography", specifier = ">=44.0.1" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.129.0" },
{ name = "httpx", extras = ["socks"], specifier = ">=0.27.0" },
{ name = "packaging", specifier = ">=26.0" },
{ name = "pyjwt", specifier = ">=2.11.0" },
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },
@@ -1322,6 +1329,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "socksio"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.46"