Compare commits

...

113 Commits

Author SHA1 Message Date
Egor 463c5385d6 Merge pull request #2770 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.35.0
2026-03-18 23:35:06 +03:00
github-actions[bot] 41dfe39518 chore(main): release 3.35.0 2026-03-18 20:34:40 +00:00
Egor f44c9b6903 Merge pull request #2769 from BEDOLAGA-DEV/dev
Dev
2026-03-18 23:34:11 +03:00
Egor 1f35d45dc6 Merge pull request #2768 from BEDOLAGA-DEV/main
w
2026-03-18 23:32:42 +03:00
Fringg ef8f6625bf chore: ruff format 2026-03-18 23:31:31 +03:00
Fringg 7101555da0 feat: add user_email to admin payments API response 2026-03-18 23:30:17 +03:00
Fringg e15b18fb41 feat: раздельные топики для админских уведомлений
Добавлены 9 новых env-переменных для маршрутизации уведомлений по отдельным топикам:
- PURCHASES, RENEWALS, TRIALS, BALANCE, ADDONS
- INFRASTRUCTURE, ERRORS, PROMO, PARTNERS

Обратная совместимость: если топик для категории не задан — fallback на ADMIN_NOTIFICATIONS_TOPIC_ID.
2026-03-18 23:16:54 +03:00
Fringg b80eeea089 feat: include manual admin top-ups in sales statistics revenue 2026-03-18 22:39:31 +03:00
Fringg cb61014d9c fix: remove forced white background from custom email template overrides
Custom email templates with their own styling (background colors, <style> tags)
were wrapped in a white base template, causing visible white areas around dark-themed
templates. Added three-tier detection: full HTML documents pass through as-is,
styled content gets a minimal wrapper, simple fragments keep the base template.
2026-03-18 22:24:58 +03:00
Fringg 5b3353433b fix: undefined currency variable in RioPay payment creation
currency was referenced but never defined in create_riopay_payment,
causing NameError. Use settings.RIOPAY_CURRENCY instead.
2026-03-18 22:05:45 +03:00
Fringg f1d45343e9 fix: handle None autopay_days_before in autopayment processing
Existing subscriptions may have NULL autopay_days_before in DB,
causing TypeError in min(None, 3). Default to 3 when None.
2026-03-18 21:16:08 +03:00
Fringg b40a812f3a fix: fix Platega and CryptoBot webhook verification
Platega: handle verification ping POST without auth headers (empty body → 200 OK)
CryptoBot: always use API token for signature verification per docs, not WEBHOOK_SECRET
CryptoBot: reject requests without signature in both FastAPI and aiohttp handlers
Remove dead self.webhook_secret from CryptoBotService
Update tests to match new behavior
2026-03-18 20:11:55 +03:00
Egor ac00434645 Merge pull request #2765 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.34.1
2026-03-18 18:25:15 +03:00
github-actions[bot] 964c33c772 chore(main): release 3.34.1 2026-03-18 15:23:42 +00:00
Egor ec875837a4 Merge pull request #2767 from BEDOLAGA-DEV/dev
Dev
2026-03-18 18:22:52 +03:00
Egor db0e169a41 Merge pull request #2766 from BEDOLAGA-DEV/main
w
2026-03-18 18:21:41 +03:00
Fringg a6dcf26c20 chore: ruff format and cleanup 2026-03-18 18:20:50 +03:00
Fringg 5081debee7 fix: add null check for subscription in execute_change_devices 2026-03-18 18:15:20 +03:00
Fringg 0ceff44c30 fix: sync crypto link from happ.cryptoLink in webhook handlers
Webhook only checked subscriptionCryptoLink field, missing happ.cryptoLink
fallback that sync already used. Also clear stale crypto link when URL
changes but no new crypto link is provided.
2026-03-18 18:05:53 +03:00
Fringg 136f29c1eb refactor: remove quick amount buttons feature entirely
Removed across 16 files: config, all payment handlers, handler registrations, env example.
2026-03-18 17:55:16 +03:00
Fringg d0eab3f7aa fix: disable quick amount buttons in balance topup
Buttons showed incorrect prices. Hardcoded is_quick_amount_buttons_enabled to False.
2026-03-18 17:47:51 +03:00
Fringg d7ad9d7033 fix: correct CryptoBot webhook signature verification and auto-fill topup amount from cart
- Use API token as fallback for webhook signature verification per CryptoBot docs
- Try raw body, re-serialized compact JSON, and ASCII-escaped JSON for signature matching
- Auto-fill payment amount from saved cart in show_payment_methods instead of hardcoded 0
2026-03-18 17:44:52 +03:00
Fringg 1a87d438fe fix: correct RioPay API header case and remove undocumented fields
Header was 'x-api-token' but RioPay API expects 'X-Api-Token'
(case-sensitive check on their side), causing 403 Invalid API token.

Also removed undocumented 'currency' and 'failUrl' fields from
create_order payload per official RioPay API docs.
2026-03-18 17:00:56 +03:00
Fringg aec01ce0d4 fix: reset device limit to new tariff base on tariff switch
Previously, extra purchased devices were carried over when switching
tariffs, causing incorrect pricing — users upgrading to a more
expensive plan kept the old per-device rate until next renewal.

Now tariff switch resets device_limit to the new tariff's base limit.
Extra purchased devices are not carried over.
2026-03-18 16:57:08 +03:00
c0mrade a33a893d1a Update README.md
Фикс WATA в редми
2026-03-18 10:58:18 +03:00
Egor 37c9b931ca Merge pull request #2763 from BEDOLAGA-DEV/dev
Dev
2026-03-18 07:23:12 +03:00
Fringg 22e7f150b3 docs: increase logo size to 800px 2026-03-18 07:22:27 +03:00
Fringg 688882237f docs: replace header logo with new artwork 2026-03-18 07:21:03 +03:00
Egor c14d7ab0af Merge pull request #2761 from BEDOLAGA-DEV/dev
docs: add Redis to tech stack
2026-03-18 07:13:38 +03:00
Fringg e12cc9f248 docs: add Redis to tech stack 2026-03-18 07:12:53 +03:00
Egor 6ff0460607 Merge pull request #2759 from BEDOLAGA-DEV/dev
Dev
2026-03-18 07:07:53 +03:00
Fringg 8d5a002996 docs: WATA partnership block with logo and table card 2026-03-18 07:07:06 +03:00
Fringg 31bdf8a0ae docs: add WATA partnership block to payments section 2026-03-18 07:04:57 +03:00
Egor 1364158e6c Merge pull request #2757 from BEDOLAGA-DEV/dev
Dev
2026-03-18 06:55:47 +03:00
Fringg d7931a2afa docs: add bot preview screenshot to README 2026-03-18 06:53:44 +03:00
Fringg b032c8f354 docs: add cabinet preview screenshot to README 2026-03-18 06:50:00 +03:00
Fringg 1306c24fa3 docs: add icons and list all 14+1 payment providers 2026-03-18 06:41:43 +03:00
Fringg 38deb70f81 docs: redesign README — concise feature showcase, link to docs
Replace 2200-line README with a clean 190-line version:
- Centered header with badges (for-the-badge style)
- Feature grid (2x2 HTML table)
- Payment providers showcase (14 providers)
- Quick start (4 lines → link to full docs)
- Tech stack table
- Cabinet section with link to repo
- Documentation links to docs.bedolagam.ru
- Community section

All setup/config details moved to docs.bedolagam.ru.
2026-03-18 06:37:49 +03:00
Egor c1e015fb6e Merge pull request #2756 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.34.0
2026-03-18 05:58:02 +03:00
github-actions[bot] 0730173e5b chore(main): release 3.34.0 2026-03-18 02:56:31 +00:00
Egor 968f18b6e4 Merge pull request #2755 from BEDOLAGA-DEV/dev
Dev
2026-03-18 05:56:06 +03:00
Egor 7eea35f111 Merge pull request #2754 from BEDOLAGA-DEV/main
w
2026-03-18 05:54:06 +03:00
Fringg 6920e3a0fb style: ruff format 2026-03-18 05:53:31 +03:00
Fringg fddf8ef5eb fix: remove contains_eager conflicting with selectinload on user relationship
Loader strategies for the same ORM path cannot coexist. The
_apply_user_join_filter helper added contains_eager(model.user) on top
of the selectinload(Model.user) already present in each query, causing
InvalidRequestError at runtime. Removed contains_eager — selectinload
handles user loading correctly on its own.
2026-03-18 05:50:00 +03:00
Fringg ad268329be fix: добавлен импорт MAX_ALL_TIME_DAYS в admin_payments routes 2026-03-18 05:43:22 +03:00
Fringg 1804c28f05 feat: поиск платежей в админ-панели с фильтрами и статистикой
Новый сервис поиска по 13 платёжным провайдерам с ILIKE (escape от инъекций),
фильтрами по статусу/периоду/методу, кастомным диапазоном дат, пагинацией.
Эндпоинты: GET /search, GET /search/stats с валидацией входных данных.
2026-03-18 05:40:49 +03:00
Fringg f967c29bd7 fix: добавлены RioPay и SeverPay в REAL_PAYMENT_METHODS
Без этого платежи через RioPay и SeverPay не учитывались
в статистике доходов, разбивке по методам и отчётах
2026-03-18 03:59:33 +03:00
Fringg 06a00e367c feat: добавлен SeverPay в админ-панель и настройки кабинета
- Категория SEVERPAY в настройках бота
- Кнопка тестового платежа
- Конфигурация метода в кабинете
- DEFAULT_METHOD_ORDER обновлён
2026-03-18 03:55:24 +03:00
Fringg abaf279533 feat: добавлена интеграция SeverPay для пополнения баланса
- API клиент (HMAC-SHA256 подпись, создание/получение платежа)
- CRUD операции с FOR UPDATE блокировкой
- Payment mixin с обработкой webhook и финализацией
- Хендлеры бота для пополнения через SeverPay
- Миграция 0040: таблица severpay_payments
- Webhook endpoint (всегда 200 для предотвращения ретраев)
- Интеграция с payment_verification_service
- Поддержка гостевых покупок (лендинги, подарки)
2026-03-18 03:49:19 +03:00
Egor 6d4430c639 Merge pull request #2753 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.33.0
2026-03-18 02:02:09 +03:00
github-actions[bot] 911df7a05c chore(main): release 3.33.0 2026-03-17 23:01:41 +00:00
Egor f106ce8216 Merge pull request #2752 from BEDOLAGA-DEV/dev
Dev
2026-03-18 02:00:59 +03:00
Fringg dcff6947dd style: ruff format 2026-03-18 01:59:12 +03:00
Fringg 4966e39eb9 fix: скрыть плашку верификации email при выключенной верификации
- Добавлен verification_enabled в ответ /cabinet/branding/email-auth
- Фронтенд использует его для скрытия баннера и бейджа
2026-03-18 01:52:54 +03:00
Fringg 4abb8cb1a3 fix: исправлены проблемы RioPay интеграции после ревью
- Модель: user_id nullable=True + ondelete='SET NULL' (не применилось ранее)
- order_id для гостей: 'rpguest_xxx' вместо 'rpNone_xxx'
- Миграция: добавлено пересоздание FK с ON DELETE SET NULL
- get_latest_payment_by_method: добавлен RioPayPayment в model_map
2026-03-18 00:18:15 +03:00
Fringg 04f4e6bf6e feat: добавлена поддержка RioPay для лендингов и подарков
- Добавлен RioPay в create_guest_payment (landing/gift покупки)
- user_id в RioPayPayment теперь nullable (для гостевых платежей)
- Добавлен guest purchase flow в _finalize_riopay_payment
- Миграция 0039: riopay_payments.user_id nullable
2026-03-18 00:10:51 +03:00
Fringg 3d1fbc70f8 feat: добавлена поддержка RioPay в кабинете
- Добавлен RioPay в create_topup endpoint (cabinet balance)
- Добавлен маппинг статусов RioPay в _get_status_info
- Добавлена поддержка ручной проверки RioPay платежей
- Добавлена автопроверка RioPay в payment_verification_service
- Добавлены success_url/fail_url параметры в create_riopay_payment mixin
2026-03-18 00:05:07 +03:00
Fringg 3089c1704b fix: исправлен расчёт конверсии в статистике продаж
- Добавлен fallback через has_had_paid_subscription для подсчёта конверсий
- Исправлен знаменатель: total_trial_starters = new_trials + conversions
- Ограничение conversion_rate до 100% максимум
- Исправлен .is_(True) вместо == True в subscription_conversion.py
2026-03-17 23:46:15 +03:00
Fringg 20eff6170f fix: add back button to payment amount validation errors
All min/max amount error messages in payment handlers now include
a back button keyboard, so users aren't stuck without navigation.
Fixed 30 message.answer() calls across 12 payment handler files.
2026-03-17 23:34:05 +03:00
Fringg 038c34e52a fix: swap Caddy auth headers — api_key to Authorization, caddy_token to X-Api-Key
Caddy Security expects the caddy token in X-Api-Key and the Remnawave
API key in Authorization: Bearer. The headers were swapped, causing
401 errors for users with Caddy auth type.
2026-03-17 23:28:44 +03:00
Fringg 77f1a764d5 fix: merge phantom users into active accounts on /start
When a user purchases on a landing page by username and Bot.get_chat()
fails, a phantom user (telegram_id=NULL) is created. If that user
already has an active bot account, the phantom was never merged,
creating duplicate user records.

Now cmd_start checks for phantom users matching the active user's
username and merges them: transfers GuestPurchase records, balance,
and subscription (if active user has none). Phantom is soft-deleted
(status=DELETED, username=NULL) to preserve payment/transaction audit
trail and avoid CASCADE FK issues.
2026-03-17 23:06:02 +03:00
Fringg 641da949a9 fix: enforce promo group authorization on country/server selection
Previously, users could retain access to servers removed from their
promo group by re-submitting already-connected UUIDs in country
selection requests. The validation allowed any UUID present in
current connected_squads, bypassing promo group checks.

Now all selected server UUIDs must be in the user's allowed promo
group set. Unauthorized servers are rejected (cabinet/bot) or
filtered out (miniapp). Fixes authorization bypass across all 3
surfaces: cabinet, Telegram bot, and miniapp.
2026-03-17 22:30:57 +03:00
Fringg 3f0b24c1ec fix: add sync_squads=True to admin tariff change handler
Missed in the previous fix — admin tariff change at
handlers/admin/users.py sets connected_squads from tariff but
did not pass sync_squads=True to update_remnawave_user.
2026-03-17 22:25:01 +03:00
Fringg c34fdd10a0 fix: sync squads to Remnawave panel on tariff purchase/switch
When sync_squads parameter was introduced (4aaf0ddd) to prevent FK
violations from stale squad UUIDs, all update_remnawave_user calls
defaulted to sync_squads=False. This broke squad synchronization for
purchase/tariff-change flows where squads are freshly assigned and
must be sent to the panel.

Adds sync_squads=True to all purchase, tariff switch, and country
selection call sites across cabinet, bot handlers, miniapp, and
auto-purchase service.
2026-03-17 22:17:35 +03:00
Fringg 72b5305b87 fix: review findings — db.commit, isinstance guard, constants, ACTIVE check
- Explicit db.commit() for cabinet_last_login before _store_refresh_token
- isinstance(callback.message, types.Message) guard in process_webauth_confirm
- Check UserStatus.ACTIVE (not just DELETED) in bot callback handler
- isinstance guard in consume_web_auth_token for type safety
- Named constants: WEB_AUTH_LINKED_TTL, WEB_AUTH_TOKEN_MIN_LENGTH
- Use str.removeprefix() instead of hardcoded slice
- Move link_web_auth_token import to module level
2026-03-17 21:56:52 +03:00
Fringg 099391eb5f fix: deep link auth security and reliability fixes
- Atomic GETDEL in link_web_auth_token to prevent TOCTOU race
- Session fixation protection: inline keyboard confirmation before linking
- Poll rate limit 30→60/min to support 2.5s polling interval
- Fix double commit in poll endpoint (cabinet_last_login before _store_refresh_token)
- Replace magic string 'active' with UserStatus.ACTIVE.value
- Add response_model=AuthResponse to poll endpoint
- Validate bot_username is set (503 if empty)
- Move web_auth imports to module level
2026-03-17 21:46:09 +03:00
Fringg 322d457652 feat: deep link авторизация в кабинете при блокировке oauth.telegram.org
Когда скрипт Telegram Login Widget не загружается (заблокирован),
фронтенд автоматически переключается на deep link авторизацию:
- POST /cabinet/auth/deeplink/request — генерирует одноразовый токен
- Пользователь открывает t.me/bot?start=webauth_TOKEN
- Бот связывает токен с Telegram-аккаунтом
- POST /cabinet/auth/deeplink/poll — фронтенд получает JWT токены

Новый сервис: app/services/web_auth_service.py (Redis, TTL 5 мин)
2026-03-17 21:29:18 +03:00
Egor 5b722c5210 Merge pull request #2746 from smediainfo/pr/kassa-ai-sbp-card
feat: add SBP and Card sub-options for KassaAI payment method
2026-03-17 20:29:03 +03:00
Egor a80a85c2a4 Merge pull request #2748 from smediainfo/fix/missing-greenlet-purchase
fix: MissingGreenlet crash after subscription purchase in cabinet
2026-03-17 20:27:40 +03:00
Egor f84885cc8a Merge pull request #2747 from smediainfo/pr/fix-external-squad-sync
fix: защита внешних сквадов от удаления при синхронизации серверов
2026-03-17 20:26:58 +03:00
Egor 12898b7eab Merge pull request #2751 from SayonaraQ/fix/extend-period-nameerror
Fix/extend period nameerror
2026-03-17 20:25:51 +03:00
Codex Bot 20a6fa1bcf fix(subscription): remove stale extend promo state fields causing NameError 2026-03-17 17:55:14 +03:00
c0mrade 94199413c2 fix: миграция Tribute webhook с deprecated user_id на trb_user_id
- Убран fallback на deprecated поле user_id (удаляется 14 апреля 2026)
- Добавлен парсинг trb_user_id во всех ветках обработки webhook
- trb_user_id прокинут в результат и логи всех хендлеров
2026-03-17 12:43:04 +03:00
Fringg 826accba51 fix: MissingGreenlet при изменении количества устройств на CLASSIC подписках
lock_user_for_pricing не загружал User.subscription eagerly,
что вызывало lazy load в async контексте при обращении к db_user.subscription
в execute_change_devices.
2026-03-16 11:25:33 +03:00
sMedia.tech 1cc687ac15 fix: MissingGreenlet crash after subscription purchase in cabinet
`_subscription_to_response()` is a sync function that accesses
lazy-loaded relationship attributes (e.g. `subscription.tariff`).
When `send_subscription_purchase_notification()` is called before
building the response, `_record_subscription_event()` internally
calls `create_subscription_event()` which does `db.commit()`.
This expires all ORM objects in the session.

When the sync `_subscription_to_response()` then tries to access
`subscription.tariff`, SQLAlchemy cannot perform the lazy load
outside of an async greenlet context, raising:

  MissingGreenlet: greenlet_spawn has not been called;
  can't call await_only() here.

The fix adds `await db.refresh(subscription)` (and `user` where
accessed) after the admin notification block and before
`_subscription_to_response()` in three purchase endpoints:
- `submit_purchase` (classic mode)
- `purchase_tariff` (tariffs mode)
- `switch_tariff`

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 09:16:53 +03:00
sMedia.tech e4bb0430fb refactor: deduplicate KassaAI handlers with config dict and shared helpers
Extract _KASSA_AI_METHOD_CONFIG dict, _check_topup_restriction() helper,
and generic _start_kassa_ai_sub_topup / _process_kassa_ai_sub_quick_amount
implementations. Public handlers become thin wrappers.

608 → 429 lines (-30%), eliminates 5 copies of restriction check block
and 3 pairs of nearly-identical start/quick-amount handlers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 08:53:34 +03:00
root 603b9a1f46 fix: sub-method enabled check, guest payment provider, silent FSM return 2026-03-16 04:40:36 +00:00
root 557af5994d style: ruff format kassa_ai files 2026-03-16 04:31:04 +00:00
root 808818ca2b style: ruff format server_squad.py 2026-03-16 04:30:32 +00:00
root b563796091 fix: protect external squads from deletion during server sync 2026-03-16 04:27:59 +00:00
sMedia.tech 6a3e9d92b5 style: ruff format kassa_ai_service.py 2026-03-16 04:12:41 +00:00
root cda2392411 refactor: move KASSA_AI_SUB_METHODS to service layer, add early enabled checks
- Move KASSA_AI_SUB_METHODS from handler to kassa_ai_service.py (fixes service→handler import violation)
- Remove KASSA_AI_PAYMENT_METHODS set (was defined but unused)
- Import KASSA_AI_SUB_METHODS in payment_service.py from service layer
- Add is_kassa_ai_sbp/card_enabled() checks at start of entry handler functions
2026-03-16 04:12:41 +00:00
root 04419fdff7 feat: add SBP and Card sub-options to kassa_ai payment method
- kassa_ai shows single admin entry with СБП/Карта sub-option checkboxes
- SBP routes to payment_system_id=44, Card to payment_system_id=36
- Bot: added kassa_ai_sbp/card handlers and FSM flow (mirrors freekassa pattern)
- Cabinet: KASSA_AI_OPTION_MAP reads payment_option to select correct ps_id
- Config: KASSA_AI_SBP_ENABLED / KASSA_AI_CARD_ENABLED env vars + helpers
- Guest payments: kassa_ai_sbp/card supported in landing page checkout
- payment_method_config_service: kassa_ai has available_sub_options=[sbp,card]
2026-03-16 04:12:41 +00:00
Egor 713146dd6b Merge pull request #2745 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.32.4
2026-03-16 04:14:18 +03:00
github-actions[bot] 7d41ab44be chore(main): release 3.32.4 2026-03-16 01:13:22 +00:00
Egor 98f6f93487 Merge pull request #2744 from BEDOLAGA-DEV/dev
Dev
2026-03-16 04:12:59 +03:00
Egor 3752b7b067 Merge pull request #2743 from BEDOLAGA-DEV/main
w
2026-03-16 04:10:54 +03:00
Fringg 2f33e55144 fix: режим «Контакт и тикеты» возвращает support_type='both' вместо 'tickets' 2026-03-16 04:09:21 +03:00
Fringg c0b282a189 fix: уведомление об истечении подписки теперь учитывает autopay_enabled пользователя
- Статус автоплатежа в уведомлении основан на subscription.autopay_enabled, а не на глобальном ENABLE_AUTOPAY
- Продление с баланса (_process_autopayments) работает всегда при autopay_enabled=True
- Рекуррентные карточные платежи по-прежнему за гейтом ENABLE_AUTOPAY + YOOKASSA_RECURRENT_ENABLED
2026-03-16 04:04:01 +03:00
Fringg e1bcb1ba91 fix: реферальный бонус инвайтера — сумма вместо максимума, защита флага первого пополнения
- referral_service: inviter_bonus = fixed + commission вместо max(fixed, commission)
- 13 платёжных провайдеров: has_made_first_topup ставится только для нереферальных юзеров
- riopay: критический фикс — флаг ставился до вызова referral_service
- Обновлены уведомления с разбивкой бонуса
- Исправлен и дополнен тест referral_service
2026-03-16 03:57:50 +03:00
Fringg 3d68db0a51 fix: не пересылать externalSquadUuid в рутинных обновлениях RemnaWave
Стейловый externalSquadUuid (c6c0a338-062d-4d3a-826d-7015a24d681c) из тарифа
не существует в таблице ExternalSquads панели → FK violation → A039.
Теперь externalSquadUuid отправляется только при sync_squads=True (создание подписки).
2026-03-16 03:47:13 +03:00
Fringg 8d7f0eea0f fix: лог полного payload при ошибке PATCH /api/users для диагностики A039 2026-03-16 03:44:45 +03:00
Fringg 4aaf0ddd25 fix: не пересылать activeInternalSquads в рутинных обновлениях RemnaWave (A039)
Стейловые UUID сквадов в connected_squads вызывали FK violation в RemnaWave → A039.
- update_remnawave_user: добавлен параметр sync_squads (default=False)
- Сквады шлются только при явном sync_squads=True (promo_offer, countries)
- monitoring_service: убрана пересылка сквадов в рутинном sync
- Расширен лог PATCH payload для диагностики
2026-03-16 03:41:56 +03:00
Fringg db2f0c93f2 fix: расширен лог PATCH /api/users payload для диагностики A039 2026-03-16 03:35:06 +03:00
Fringg 3f8e8993b2 fix: сохранение user_id до rollback чтобы избежать MissingGreenlet при lazy load 2026-03-16 03:28:12 +03:00
Fringg e453521098 fix: устранена отправка externalSquadUuid=null в RemnaWave API (A039) и исправлен reduce_devices
- reduce_devices: убрано молчаливое проглатывание ошибки RemnaWave, теперь при неудаче делается rollback и возвращается HTTP 502
- Убрана отправка external_squad_uuid=None в 8 местах: subscription_service, monitoring_service, remnawave_service, admin/users, cabinet/admin_users
2026-03-16 03:25:24 +03:00
Fringg 8d3cd50098 refactor: централизация всех расчётов цен в PricingEngine
- Мигрирован confirm_purchase() на calculate_classic_new_subscription_price()
- Мигрирован compute_simple_subscription_price на делегацию в PricingEngine
- Мигрирован handle_custom_confirm на calculate_tariff_purchase_price()
- Мигрированы daily confirm handlers (confirm_daily_tariff_purchase,
  confirm_daily_tariff_switch, confirm_instant_switch daily path)
- Мигрирован gift.py на calculate_tariff_purchase_price()
- Мигрированы FSM cache prices (select_period, select_devices, toggle_country)
- Добавлен lock_user_for_pricing в admin_buy_tariff_execute (TOCTOU fix)
- Добавлен lock + recompute в _auto_add_devices и _auto_add_traffic
- Исправлено двойное применение promo-offer в simple_subscription (критический баг)
- Унифицирован daily price display (group+offer) на всех 6 поверхностях
- PricingEngine.get_addon_discount_percent: добавлен promo_group= kwarg
- PricingEngine._calculate_switch_to/from_daily: добавлен promo-offer discount
- Удалён мёртвый код из common.py (_get_addon_discount_percent_for_user)
- Miniapp period_discounts: исправлен доступ через get_discount_percent()
2026-03-16 03:10:22 +03:00
Fringg f80912e444 fix: убрана отправка externalSquadUuid=null в RemnaWave API и исправлен ложный лог синхронизации рулетки
- Не отправляем externalSquadUuid: null — RemnaWave отвечал 500 (A039)
- Проверяем результат update_remnawave_user вместо ложного " синхронизировано"
2026-03-15 17:34:31 +03:00
Egor 484d2f7e34 Merge pull request #2740 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.32.3
2026-03-15 01:24:46 +03:00
github-actions[bot] 842fb697e6 chore(main): release 3.32.3 2026-03-14 22:24:27 +00:00
Egor 3ac3a92e26 Merge pull request #2739 from BEDOLAGA-DEV/dev
Dev
2026-03-15 01:24:05 +03:00
Fringg 7648707ca2 fix: campaign registration, revenue calculation, backup restore, autopay errors, referral links
- fix campaign registration not recorded when CHANNEL_IS_REQUIRED_SUB + SKIP_RULES_ACCEPT enabled (missing _apply_campaign_bonus_if_needed in required_sub_channel_check fast path)
- fix revenue calculation counting bonus-funded subscription payments as income (now deposits only via REAL_PAYMENT_METHODS)
- fix backup restore PendingRollbackError cascade on unique constraint violations (savepoint wrapping in _restore_table_records and _restore_users_without_referrals)
- fix AttributeError on message.text.strip() when users send media in referral code handlers
- suppress 'message is not modified' TelegramBadRequest in autopay toggle
- add bot_referral_link to referral API response with URL encoding
2026-03-15 01:13:50 +03:00
Egor 7e466ef464 Merge pull request #2736 from Legacyyy777/main
fix: implement case-insensitive email checks in authentication and user retrieval
2026-03-14 22:30:56 +03:00
Egor 28321df4d2 Merge pull request #2738 from SayonaraQ/pr/topup-cart-fix
fix(payment): prioritize saved cart after topup over expired auto-extend
2026-03-14 22:27:56 +03:00
Fringg 6adf70b2da fix: refresh CLASSIC_PERIOD_PRICES when admin changes PRICE_*_DAYS or SALES_MODE
CLASSIC_PERIOD_PRICES was built once at import time and never updated,
causing classic mode to always show hardcoded defaults instead of
admin-configured prices.
2026-03-14 22:24:08 +03:00
SayonaraQ 2d204275da Fix race payment cart 2026-03-14 20:11:47 +03:00
Legacyyy777 ebee8348ca fix: implement case-insensitive email checks in authentication and user retrieval
Updated email queries in authentication routes and user CRUD operations to be case-insensitive. This change ensures that email comparisons ignore case, improving user experience and preventing potential registration/login issues with differently cased emails.
2026-03-14 04:39:11 +05:00
c0mrade 06954c1711 Merge pull request #2735 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.32.2
2026-03-14 00:18:51 +03:00
github-actions[bot] 5e04e2a020 chore(main): release 3.32.2 2026-03-13 21:17:32 +00:00
c0mrade 08d69fb47f Merge pull request #2734 from BEDOLAGA-DEV/dev
Dev
2026-03-14 00:17:06 +03:00
c0mrade 3306e02902 fix: add nested selectinload and referrer eager loading to prevent MissingGreenlet
Added selectinload(UserPromoGroup.promo_group) nested under
user_promo_groups to prevent lazy-load in get_primary_promo_group().
Added selectinload(User.referrer) for format_referrer_info().
Broadened except clause in format_referrer_info as safety net.
2026-03-14 00:14:42 +03:00
c0mrade 14dceaa39f fix: silence PARTICIPANT_ID_INVALID error in channel subscription check
Handle PARTICIPANT_ID_INVALID same as 'user not found' — expected for
users who authenticated via Telegram Login Widget but never interacted
with the bot or channel directly.
2026-03-13 21:39:39 +03:00
c0mrade 5442f288d4 fix: add selectinload to user lock queries to prevent MissingGreenlet
lock_user_for_update, subtract_user_balance, and add_user_balance use
select(User).with_for_update().populate_existing which expires loaded
relationships. Added selectinload for subscription, user_promo_groups
and promo_group to prevent lazy-load in async context.
2026-03-13 21:39:31 +03:00
130 changed files with 7019 additions and 5517 deletions
-5
View File
@@ -491,16 +491,11 @@ YOOKASSA_WEBHOOK_PORT=8082
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
YOOKASSA_MAX_AMOUNT_KOPEKS=1000000
# Быстрый выбор суммы пополнения через YooKassa
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
# Рекуррентные платежи YooKassa (автосохранение карты для автоплатежей)
YOOKASSA_RECURRENT_ENABLED=false
# true = карта сохраняется обязательно, false = пользователь решает (чекбокс на стороне YooKassa)
YOOKASSA_RECURRENT_REQUIRED=true
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
DISABLE_TOPUP_BUTTONS=false
# Отключить пополнение баланса через поддержку
SUPPORT_TOPUP_ENABLED=true
Binary file not shown.

After

Width:  |  Height:  |  Size: 850 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.32.1"
".": "3.35.0"
}
+145
View File
@@ -1,5 +1,150 @@
# Changelog
## [3.35.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.34.1...v3.35.0) (2026-03-18)
### New Features
* add user_email to admin payments API response ([7101555](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7101555da0722d1eacd97f40b6b8c8c3a2327a0c))
* include manual admin top-ups in sales statistics revenue ([b80eeea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b80eeea089568c60c20b1ae165b8dbe887bbe378))
* раздельные топики для админских уведомлений ([e15b18f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e15b18fb41b180e7dd3d65f2f058667be321fe85))
### Bug Fixes
* fix Platega and CryptoBot webhook verification ([b40a812](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b40a812f3aa0596bf6c5105008451dd8a17b103f))
* handle None autopay_days_before in autopayment processing ([f1d4534](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f1d45343e941594d69f71e822ecc9b3a7062f4bf))
* remove forced white background from custom email template overrides ([cb61014](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cb61014d9c5a89e3aeafb191f1b9826ca1cbf338))
* undefined currency variable in RioPay payment creation ([5b33534](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5b3353433bc524e3e51f2ae87d26bd162bd9f97b))
## [3.34.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.34.0...v3.34.1) (2026-03-18)
### Bug Fixes
* add null check for subscription in execute_change_devices ([5081deb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5081debee7625954bd7b82f66e09dd58e08e8822))
* correct CryptoBot webhook signature verification and auto-fill topup amount from cart ([d7ad9d7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d7ad9d70330b6ef5599f7a9409cdd16657d61b85))
* correct RioPay API header case and remove undocumented fields ([1a87d43](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1a87d438fe127a0b62bd6ee59887212869a3cb17))
* disable quick amount buttons in balance topup ([d0eab3f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d0eab3f7aacf0249ca244f168c96044347c918e3))
* reset device limit to new tariff base on tariff switch ([aec01ce](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aec01ce0d4a36da5ddd07b56ca4bd5de04735a0b))
* sync crypto link from happ.cryptoLink in webhook handlers ([0ceff44](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ceff44c30cf11466c4cbe51b520558c51c4af4a))
### Refactoring
* remove quick amount buttons feature entirely ([136f29c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/136f29c1eb63778b2b329ed5bf72ab06a4531d0b))
### Documentation
* add bot preview screenshot to README ([d7931a2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d7931a2afaf272aae74453f2bb6d493593895f67))
* add cabinet preview screenshot to README ([b032c8f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b032c8f35435ddd592df04d6352097580f5e9837))
* add icons and list all 14+1 payment providers ([1306c24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1306c24fa36fd3e812c5bec551a0aca450ca7d2c))
* add Redis to tech stack ([c14d7ab](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c14d7ab0af2e5dd4f35213b323a97eedcc99af0e))
* add Redis to tech stack ([e12cc9f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e12cc9f248764104d538db1daaede9ddd8b77b2d))
* add WATA partnership block to payments section ([31bdf8a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/31bdf8a0aeba53fcec40358d0a38b8480130a346))
* increase logo size to 800px ([22e7f15](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/22e7f150b30c35b0419e7923e94c5ebc92c4b61a))
* redesign README — concise feature showcase, link to docs ([38deb70](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/38deb70f8118d371e36ac92a4417bdb543635fc9))
* replace header logo with new artwork ([6888822](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/688882237fe3019bb78d5e1d7ad54faf4cd69c09))
* WATA partnership block with logo and table card ([8d5a002](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8d5a0029964ba52b40e03ebfe8ab6f4146d3aca9))
## [3.34.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.33.0...v3.34.0) (2026-03-18)
### New Features
* добавлен SeverPay в админ-панель и настройки кабинета ([06a00e3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/06a00e367c303b5426008f38a20393ec4f2e07cd))
* добавлена интеграция SeverPay для пополнения баланса ([abaf279](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/abaf279533d31994a8a70346627c962b43000c64))
* поиск платежей в админ-панели с фильтрами и статистикой ([1804c28](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1804c28f0551fbe52883fc36fc4cfcd60d2d6bd6))
### Bug Fixes
* remove contains_eager conflicting with selectinload on user relationship ([fddf8ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fddf8ef5ebc8e5176a75a10e92d59165bdabf2e1))
* добавлен импорт MAX_ALL_TIME_DAYS в admin_payments routes ([ad26832](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad268329be45bdd665ce4a6f142e9762a78e55b5))
* добавлены RioPay и SeverPay в REAL_PAYMENT_METHODS ([f967c29](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f967c29bd7cfe8a66e7e0f492573bb7521fcda22))
## [3.33.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.4...v3.33.0) (2026-03-17)
### New Features
* add SBP and Card sub-options for KassaAI payment method ([5b722c5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5b722c521036befcbbaf6192215f651c2ec9c4fb))
* add SBP and Card sub-options to kassa_ai payment method ([04419fd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/04419fdff7dc244eb2c9553ea1501e2de454010b))
* deep link авторизация в кабинете при блокировке oauth.telegram.org ([322d457](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/322d45765220c854e36ae0b4b862a96d36ae3be8))
* добавлена поддержка RioPay в кабинете ([3d1fbc7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3d1fbc70f8add81d4a3561d63dcd46f385bcc6f1))
* добавлена поддержка RioPay для лендингов и подарков ([04f4e6b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/04f4e6bf6e9031ef8512a07ab36355111c524319))
### Bug Fixes
* add back button to payment amount validation errors ([20eff61](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/20eff6170fb752051022a14fa9d927d59ff1d602))
* add sync_squads=True to admin tariff change handler ([3f0b24c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3f0b24c1ec82ebfb6fdbc801ed6b493ea09c9a2f))
* deep link auth security and reliability fixes ([099391e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/099391eb5f24319703f12ae2cb72d93b26164d99))
* enforce promo group authorization on country/server selection ([641da94](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/641da949a907ade7b870e1a5385fdb71f9e524af))
* merge phantom users into active accounts on /start ([77f1a76](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/77f1a764d59d68236899ec59c884907d3666d3dd))
* MissingGreenlet crash after subscription purchase in cabinet ([a80a85c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a80a85c2a489f397efb4ffb5bd77655420a49adb))
* MissingGreenlet crash after subscription purchase in cabinet ([1cc687a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1cc687ac15ecdf12928e5ce448514c09b6628f65))
* MissingGreenlet при изменении количества устройств на CLASSIC подписках ([826accb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/826accba519f23a687fcc3d387f727fd31d2a88c))
* protect external squads from deletion during server sync ([b563796](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b563796091e83edcc8dfbd6222648b5346f39a9c))
* review findings — db.commit, isinstance guard, constants, ACTIVE check ([72b5305](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/72b5305b870ae9ecdb2672549b9c153dd8b3f7bc))
* sub-method enabled check, guest payment provider, silent FSM return ([603b9a1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/603b9a1f4610a5b288d6fba78c98474c439529aa))
* **subscription:** remove stale extend promo state fields causing NameError ([20a6fa1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/20a6fa1bcf362455623f43838f4ffaaf98b33e76))
* swap Caddy auth headers — api_key to Authorization, caddy_token to X-Api-Key ([038c34e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/038c34e52a863d0c5c6993ea785587ba7e0bc61d))
* sync squads to Remnawave panel on tariff purchase/switch ([c34fdd1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c34fdd10a0a22a85a4e29cbf44da2ac5d4a643b3))
* защита внешних сквадов от удаления при синхронизации серверов ([f84885c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f84885cc8aa0c9a70f916076e987284f1f9c3479))
* исправлен расчёт конверсии в статистике продаж ([3089c17](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3089c1704b54323b4c2a151d40a5b395a427c658))
* исправлены проблемы RioPay интеграции после ревью ([4abb8cb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4abb8cb1a3f21089697199261bcc37e6f6a5c623))
* миграция Tribute webhook с deprecated user_id на trb_user_id ([9419941](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/94199413c283167edd829b337cf9bec0a5414a54))
* скрыть плашку верификации email при выключенной верификации ([4966e39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4966e39eb9b92967ef92c92ae434ca6cdea80c84))
### Refactoring
* deduplicate KassaAI handlers with config dict and shared helpers ([e4bb043](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e4bb0430fb9cc4f55013767ceda6d1c214bd80a6))
* move KASSA_AI_SUB_METHODS to service layer, add early enabled checks ([cda2392](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cda239241122ae1dd02a252b3eadc47453c0c48b))
## [3.32.4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.3...v3.32.4) (2026-03-16)
### Bug Fixes
* лог полного payload при ошибке PATCH /api/users для диагностики A039 ([8d7f0ee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8d7f0eea0fecd9e66bf199bd2c288e073f1354c0))
* не пересылать activeInternalSquads в рутинных обновлениях RemnaWave (A039) ([4aaf0dd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4aaf0ddd25527ec23fa6a479ac3826d6b6266761))
* не пересылать externalSquadUuid в рутинных обновлениях RemnaWave ([3d68db0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3d68db0a51fac55640d44be784c832875ca2da17))
* расширен лог PATCH /api/users payload для диагностики A039 ([db2f0c9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/db2f0c93f2974410e744411fb9111c6de1f0f0be))
* режим «Контакт и тикеты» возвращает support_type='both' вместо 'tickets' ([2f33e55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f33e5514469f2686c4b35e2105f4188a41d4145))
* реферальный бонус инвайтера — сумма вместо максимума, защита флага первого пополнения ([e1bcb1b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e1bcb1ba910ef3a79dec5fa974ae8e6c09494aa7))
* сохранение user_id до rollback чтобы избежать MissingGreenlet при lazy load ([3f8e899](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3f8e8993b2949b5a5e04b1d8a468ef7dc1170e08))
* убрана отправка externalSquadUuid=null в RemnaWave API и исправлен ложный лог синхронизации рулетки ([f80912e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f80912e444ab5706c809e689ca5ed2a38da118d0))
* уведомление об истечении подписки теперь учитывает autopay_enabled пользователя ([c0b282a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c0b282a189a2b761c68fc70886edd91d9c807ff6))
* устранена отправка externalSquadUuid=null в RemnaWave API (A039) и исправлен reduce_devices ([e453521](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e4535210982351413cb82483000fe441e7b7300a))
### Refactoring
* централизация всех расчётов цен в PricingEngine ([8d3cd50](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8d3cd500980f4f640cb1ba493150f1f20e8bd58c))
## [3.32.3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.2...v3.32.3) (2026-03-14)
### Bug Fixes
* campaign registration, revenue calculation, backup restore, autopay errors, referral links ([7648707](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7648707ca26d6cd2703b50b0fe8c4697e6155784))
* implement case-insensitive email checks in authentication and user retrieval ([7e466ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7e466ef464ce918d885bd6297d1e605a633fd43e))
* implement case-insensitive email checks in authentication and user retrieval ([ebee834](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebee8348ca338b9be5f044537e5e2b4740dc6441))
* **payment:** prioritize saved cart after topup over expired auto-extend ([28321df](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/28321df4d274269536efebcf3da870f2e7d07d90))
* refresh CLASSIC_PERIOD_PRICES when admin changes PRICE_*_DAYS or SALES_MODE ([6adf70b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6adf70b2da6e2250cc8e909dbb497b355302e72f))
## [3.32.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.1...v3.32.2) (2026-03-13)
### Bug Fixes
* add nested selectinload and referrer eager loading to prevent MissingGreenlet ([3306e02](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3306e029021c396e13774a205225beece4fbbcfb))
* add selectinload to user lock queries to prevent MissingGreenlet ([5442f28](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5442f288d4c6c3973dd92ac141172a9f0e53a28f))
* silence PARTICIPANT_ID_INVALID error in channel subscription check ([14dceaa](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/14dceaa39ff9faa1c9205483653014a1c5ac73fb))
## [3.32.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.0...v3.32.1) (2026-03-13)
+1 -1
View File
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
FROM python:3.13-slim
ARG VERSION="v3.32.1" # x-release-please-version
ARG VERSION="v3.35.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+191 -2149
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -670,8 +670,8 @@ async def preview_template(
language = data.language if data.language in AVAILABLE_LANGUAGES else 'ru'
if data.body_html:
# Preview custom content wrapped in base template
rendered_html = templates_instance._get_base_template(data.body_html, language)
# Preview custom content — auto-detects styled vs simple HTML
rendered_html = templates_instance._wrap_override_template(data.body_html, language)
subject = data.subject or notification_type
else:
# Preview default template
+159 -3
View File
@@ -1,7 +1,7 @@
"""Admin routes for payment verification in cabinet."""
import math
from datetime import datetime
from datetime import UTC, datetime, timedelta
import structlog
from aiogram import Bot
@@ -13,6 +13,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, User
from app.services.payment_search_service import (
MAX_ALL_TIME_DAYS,
PeriodPreset,
SearchParams,
StatusFilter,
search_payments,
search_payments_stats,
)
from app.services.payment_service import PaymentService
from app.services.payment_verification_service import (
SUPPORTED_MANUAL_CHECK_METHODS,
@@ -54,6 +62,7 @@ class PendingPaymentResponse(BaseModel):
user_id: int | None = None
user_telegram_id: int | None = None
user_username: str | None = None
user_email: str | None = None
class Config:
from_attributes = True
@@ -87,6 +96,16 @@ class PaymentsStatsResponse(BaseModel):
by_method: dict
class SearchStatsResponse(BaseModel):
"""Statistics for payment search results."""
total: int
pending: int
paid: int
cancelled: int
by_method: dict
# ============ Helper functions ============
@@ -241,6 +260,8 @@ def _get_payment_url(record: PendingPayment) -> str | None:
elif record.method == PaymentMethod.CLOUDPAYMENTS or record.method == PaymentMethod.FREEKASSA:
payment_url = getattr(payment, 'payment_url', None) or payment_url
if payment_url and not payment_url.startswith(('https://', 'http://')):
return None
return payment_url
@@ -265,6 +286,7 @@ def _record_to_response(record: PendingPayment) -> PendingPaymentResponse:
user_id=record.user.id if record.user else None,
user_telegram_id=record.user.telegram_id if record.user else None,
user_username=record.user.username if record.user else None,
user_email=record.user.email if record.user else None,
)
@@ -329,6 +351,140 @@ async def get_payments_stats(
)
@router.get('/search', response_model=PendingPaymentListResponse)
async def search_payments_endpoint(
search: str | None = Query(
None, max_length=256, description='Search query (invoice, @username, telegram_id, email)'
),
status_filter: str = Query('all', description='Status filter: all, pending, paid, cancelled'),
method_filter: str | None = Query(None, description='Filter by payment method'),
period: str = Query('24h', description='Period preset: 24h, 7d, 30d, all'),
date_from: datetime | None = Query(None, description='Custom range start (ISO 8601)'),
date_to: datetime | None = Query(None, description='Custom range end (ISO 8601)'),
page: int = Query(1, ge=1, description='Page number'),
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
admin: User = Depends(require_permission('payments:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Search payments across all providers with filters."""
try:
parsed_status = StatusFilter(status_filter)
except ValueError:
parsed_status = StatusFilter.ALL
try:
parsed_period = PeriodPreset(period)
except ValueError:
parsed_period = PeriodPreset.H24
parsed_method: PaymentMethod | None = None
if method_filter:
try:
parsed_method = PaymentMethod(method_filter)
except ValueError:
pass
# Ensure custom dates are timezone-aware
if date_from is not None and date_from.tzinfo is None:
date_from = date_from.replace(tzinfo=UTC)
if date_to is not None and date_to.tzinfo is None:
date_to = date_to.replace(tzinfo=UTC)
# Clamp custom dates to safety limit
min_allowed = datetime.now(UTC) - timedelta(days=MAX_ALL_TIME_DAYS)
if date_from is not None and date_from < min_allowed:
date_from = min_allowed
if date_from is not None and date_to is not None and date_from > date_to:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='date_from must be before date_to')
params = SearchParams(
search=search.strip() if search else None,
status_filter=parsed_status,
method_filter=parsed_method,
period=parsed_period,
date_from=date_from,
date_to=date_to,
page=page,
per_page=per_page,
)
page_items, total = await search_payments(db, params)
pages = math.ceil(total / per_page) if total > 0 else 1
items = [_record_to_response(p) for p in page_items]
return PendingPaymentListResponse(
items=items,
total=total,
page=page,
per_page=per_page,
pages=pages,
)
@router.get('/search/stats', response_model=SearchStatsResponse)
async def search_payments_stats_endpoint(
search: str | None = Query(
None, max_length=256, description='Search query (invoice, @username, telegram_id, email)'
),
status_filter: str = Query('all', description='Status filter: all, pending, paid, cancelled'),
method_filter: str | None = Query(None, description='Filter by payment method'),
period: str = Query('24h', description='Period preset: 24h, 7d, 30d, all'),
date_from: datetime | None = Query(None, description='Custom range start (ISO 8601)'),
date_to: datetime | None = Query(None, description='Custom range end (ISO 8601)'),
admin: User = Depends(require_permission('payments:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get aggregated statistics for payment search results."""
try:
parsed_status = StatusFilter(status_filter)
except ValueError:
parsed_status = StatusFilter.ALL
try:
parsed_period = PeriodPreset(period)
except ValueError:
parsed_period = PeriodPreset.H24
parsed_method: PaymentMethod | None = None
if method_filter:
try:
parsed_method = PaymentMethod(method_filter)
except ValueError:
pass
# Ensure custom dates are timezone-aware
if date_from is not None and date_from.tzinfo is None:
date_from = date_from.replace(tzinfo=UTC)
if date_to is not None and date_to.tzinfo is None:
date_to = date_to.replace(tzinfo=UTC)
# Clamp custom dates to safety limit
min_allowed = datetime.now(UTC) - timedelta(days=MAX_ALL_TIME_DAYS)
if date_from is not None and date_from < min_allowed:
date_from = min_allowed
if date_from is not None and date_to is not None and date_from > date_to:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='date_from must be before date_to')
params = SearchParams(
search=search.strip() if search else None,
status_filter=parsed_status,
method_filter=parsed_method,
period=parsed_period,
date_from=date_from,
date_to=date_to,
)
stats = await search_payments_stats(db, params)
return SearchStatsResponse(
total=stats.total,
pending=stats.pending,
paid=stats.paid,
cancelled=stats.cancelled,
by_method=stats.by_method or {},
)
@router.get('/{method}/{payment_id}', response_model=PendingPaymentResponse)
async def get_pending_payment_details(
method: str,
@@ -342,7 +498,7 @@ async def get_pending_payment_details(
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid payment method: {method}',
detail='Invalid payment method',
)
record = await get_payment_record(db, payment_method, payment_id)
@@ -369,7 +525,7 @@ async def check_payment_status(
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid payment method: {method}',
detail='Invalid payment method',
)
# Get current record
+65 -9
View File
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.transaction import REAL_PAYMENT_METHODS
from app.database.models import (
PaymentMethod,
Subscription,
SubscriptionConversion,
SubscriptionStatus,
@@ -87,6 +88,7 @@ class SalesSummary(BaseModel):
"""Summary stats for the top cards."""
total_revenue_kopeks: int
manual_topup_kopeks: int
active_subscriptions: int
active_trials: int
new_trials: int
@@ -124,6 +126,20 @@ async def get_sales_summary(
)
total_revenue = revenue_result.scalar() or 0
# Manual top-ups by admins
manual_topup_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True,
Transaction.payment_method == PaymentMethod.MANUAL.value,
Transaction.created_at >= period_start,
Transaction.created_at <= period_end,
)
)
)
manual_topup = manual_topup_result.scalar() or 0
# Consolidated subscription counts: active paid, active trial, new trials in period
sub_counts_result = await db.execute(
select(
@@ -170,6 +186,7 @@ async def get_sales_summary(
new_trials = row.new_trials or 0
# Trial-to-paid conversion in period
# Method 1: SubscriptionConversion records (only created by some purchase flows)
conversions_result = await db.execute(
select(func.count(SubscriptionConversion.id)).where(
and_(
@@ -178,9 +195,29 @@ async def get_sales_summary(
)
)
)
conversions = conversions_result.scalar() or 0
# Cap at 100%: conversions from previous periods can exceed current new_trials
conversion_rate = min(round((conversions / new_trials * 100), 1), 100.0) if new_trials > 0 else 0.0
conversion_records = conversions_result.scalar() or 0
# Method 2: Users registered in period who have paid (catches all purchase flows)
converted_users_result = await db.execute(
select(func.count(User.id)).where(
and_(
User.created_at >= period_start,
User.created_at <= period_end,
User.has_had_paid_subscription.is_(True),
)
)
)
converted_users = converted_users_result.scalar() or 0
# Use the higher count to catch conversions from all purchase flows
conversions = max(conversion_records, converted_users)
# new_trials only counts REMAINING trials (is_trial=True), but converted users
# had is_trial flipped to False. Add conversions back to get total trial starters.
total_trial_starters = new_trials + conversions
conversion_rate = (
min(round((conversions / total_trial_starters * 100), 1), 100.0) if total_trial_starters > 0 else 0.0
)
# Renewals count
renewals_subquery = (
@@ -222,7 +259,8 @@ async def get_sales_summary(
addon_revenue = abs(addon_revenue_result.scalar() or 0)
return SalesSummary(
total_revenue_kopeks=total_revenue,
total_revenue_kopeks=total_revenue + manual_topup,
manual_topup_kopeks=manual_topup,
active_subscriptions=active_subs,
active_trials=active_trials,
new_trials=new_trials,
@@ -290,6 +328,7 @@ async def get_trials_stats(
)
total_trials = total_result.scalar() or 0
# Conversion: SubscriptionConversion records + fallback to has_had_paid_subscription
conversions_result = await db.execute(
select(func.count(SubscriptionConversion.id)).where(
and_(
@@ -298,9 +337,25 @@ async def get_trials_stats(
)
)
)
conversions = conversions_result.scalar() or 0
# Cap at 100%: conversions from previous periods can exceed current period trials
conversion_rate = min(round((conversions / total_trials * 100), 1), 100.0) if total_trials > 0 else 0.0
conversion_records = conversions_result.scalar() or 0
converted_users_result = await db.execute(
select(func.count(User.id)).where(
and_(
User.created_at >= period_start,
User.created_at <= period_end,
User.has_had_paid_subscription.is_(True),
)
)
)
converted_users = converted_users_result.scalar() or 0
conversions = max(conversion_records, converted_users)
# total_trials only counts remaining is_trial=True; add conversions for total starters
total_trial_starters = total_trials + conversions
conversion_rate = (
min(round((conversions / total_trial_starters * 100), 1), 100.0) if total_trial_starters > 0 else 0.0
)
avg_duration_result = await db.execute(
select(func.avg(SubscriptionConversion.trial_duration_days)).where(
@@ -1022,10 +1077,11 @@ async def get_deposits_stats(
try:
period_start, period_end = _parse_period(days, start_date, end_date)
methods_with_manual = [*REAL_PAYMENT_METHODS, PaymentMethod.MANUAL.value]
base_filter = and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
Transaction.payment_method.in_(methods_with_manual),
Transaction.created_at >= period_start,
Transaction.created_at <= period_end,
)
@@ -1076,7 +1132,7 @@ async def get_deposits_stats(
]
# Daily deposits grouped by payment method
# base_filter already excludes NULLs via .in_(REAL_PAYMENT_METHODS), no coalesce needed
# base_filter already excludes NULLs via .in_(methods_with_manual), no coalesce needed
daily_by_method_query = await db.execute(
select(
func.date(Transaction.created_at).label('date'),
+4 -6
View File
@@ -318,11 +318,10 @@ async def _sync_subscription_to_panel(
if hwid_limit is not None:
update_kwargs['hwid_device_limit'] = hwid_limit
# Внешний сквад: синхронизируем из тарифа или сбрасываем
# Внешний сквад: синхронизируем из тарифа (если задан)
# Не отправляем null — RemnaWave API не принимает null для externalSquadUuid (A039)
if ext_squad_uuid is not None:
update_kwargs['external_squad_uuid'] = ext_squad_uuid
else:
update_kwargs['external_squad_uuid'] = None
try:
updated_panel_user = await api.update_user(**update_kwargs)
@@ -2777,11 +2776,10 @@ async def sync_user_to_panel(
update_kwargs['hwid_device_limit'] = hwid_limit
changes['device_limit'] = hwid_limit
# Внешний сквад: синхронизируем из тарифа или сбрасываем
# Внешний сквад: синхронизируем из тарифа (если задан)
# Не отправляем null — RemnaWave API не принимает null для externalSquadUuid (A039)
if ext_squad_uuid is not None:
update_kwargs['external_squad_uuid'] = ext_squad_uuid
else:
update_kwargs['external_squad_uuid'] = None
try:
await api.update_user(**update_kwargs)
+145 -14
View File
@@ -6,7 +6,7 @@ from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -28,10 +28,16 @@ from app.database.crud.user import (
set_email_change_pending,
verify_and_apply_email_change,
)
from app.database.models import CabinetRefreshToken, User
from app.database.models import CabinetRefreshToken, User, UserStatus
from app.services.campaign_service import AdvertisingCampaignService
from app.services.disposable_email_service import disposable_email_service
from app.services.referral_service import process_referral_registration
from app.services.web_auth_service import (
WEB_AUTH_TOKEN_TTL,
consume_web_auth_token,
create_web_auth_token,
poll_web_auth_token,
)
from app.utils.cache import RateLimitCache, TokenReplayCache
from app.utils.timezone import panel_datetime_to_utc
@@ -61,6 +67,8 @@ from ..schemas.auth import (
AuthResponse,
AutoLoginRequest,
CampaignBonusInfo,
DeepLinkPollRequest,
DeepLinkTokenResponse,
EmailChangeRequest,
EmailChangeResponse,
EmailChangeVerifyRequest,
@@ -461,7 +469,7 @@ async def auth_telegram(
if updated:
logger.info('User profile updated from initData', user_id=user.id)
if user.status != 'active':
if user.status != UserStatus.ACTIVE.value:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='User account is not active',
@@ -544,7 +552,7 @@ async def auth_telegram_widget(
)
logger.info('User created successfully: id=, telegram_id', user_id=user.id, telegram_id=user.telegram_id)
if user.status != 'active':
if user.status != UserStatus.ACTIVE.value:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='User account is not active',
@@ -673,7 +681,7 @@ async def auth_telegram_oidc(
)
logger.info('User created successfully', user_id=user.id, telegram_id=user.telegram_id)
if user.status != 'active':
if user.status != UserStatus.ACTIVE.value:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='User account is not active',
@@ -721,8 +729,9 @@ async def register_email(
detail='Disposable email addresses are not allowed',
)
# Check if email already exists
existing_user = await db.execute(select(User).where(User.email == request.email))
# Check if email already exists (case-insensitive)
email_lower = (request.email or '').strip().lower()
existing_user = await db.execute(select(User).where(func.lower(User.email) == email_lower))
if existing_user.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -837,8 +846,9 @@ async def register_email_standalone(
detail='Disposable email addresses are not allowed',
)
# Проверить что email не занят
existing = await db.execute(select(User).where(User.email == request.email))
# Проверить что email не занят (без учёта регистра)
email_lower = (request.email or '').strip().lower()
existing = await db.execute(select(User).where(func.lower(User.email) == email_lower))
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -1096,8 +1106,9 @@ async def login_email(
# Check if this is a test email login
is_test_email = settings.is_test_email(request.email)
# Find user by email
result = await db.execute(select(User).where(User.email == request.email))
# Find user by email (case-insensitive)
email_lower = (request.email or '').strip().lower()
result = await db.execute(select(User).where(func.lower(User.email) == email_lower))
user = result.scalar_one_or_none()
if not user:
@@ -1140,7 +1151,7 @@ async def login_email(
detail='Please verify your email first',
)
if user.status != 'active':
if user.status != UserStatus.ACTIVE.value:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='User account is not active',
@@ -1289,7 +1300,7 @@ async def auto_login(
detail='User not found',
)
if user.status != 'active':
if user.status != UserStatus.ACTIVE.value:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Account is deactivated',
@@ -1317,7 +1328,8 @@ async def forgot_password(
detail='Too many requests',
headers={'Retry-After': '60'},
)
result = await db.execute(select(User).where(User.email == request.email))
email_lower = (request.email or '').strip().lower()
result = await db.execute(select(User).where(func.lower(User.email) == email_lower))
user = result.scalar_one_or_none()
# Always return success to prevent email enumeration
@@ -1670,3 +1682,122 @@ async def get_email_change_status(
'new_email': user.email_change_new,
'expires_at': user.email_change_expires.isoformat() if user.email_change_expires else None,
}
# --- Deep link auth (fallback when oauth.telegram.org is blocked) ---
@router.post('/deeplink/request', response_model=DeepLinkTokenResponse)
async def request_deep_link_token(
raw_request: Request,
):
"""Generate a one-time deep link auth token.
Frontend shows t.me/{bot}?start=webauth_{token} to the user.
No auth required (user is not logged in yet).
"""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'deeplink_request', limit=10, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
try:
token = await create_web_auth_token()
except RuntimeError:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail='Service temporarily unavailable',
)
bot_username = settings.get_bot_username()
if not bot_username:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail='Bot not configured',
)
return DeepLinkTokenResponse(
token=token,
bot_username=bot_username,
expires_in=WEB_AUTH_TOKEN_TTL,
)
@router.post('/deeplink/poll', response_model=AuthResponse)
async def poll_deep_link_token(
request: DeepLinkPollRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Poll for deep link auth completion.
Returns 202 if still pending, AuthResponse if completed, 410 if expired.
"""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'deeplink_poll', limit=60, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
data = await poll_web_auth_token(request.token)
if data is None:
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail='Token expired or not found',
)
if data.get('status') == 'pending':
raise HTTPException(
status_code=status.HTTP_202_ACCEPTED,
detail='Waiting for confirmation',
)
if data.get('status') != 'linked':
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail='Invalid token state',
)
# Token is linked - consume it atomically
consumed = await consume_web_auth_token(request.token)
if not consumed:
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail='Token already consumed',
)
user_id = consumed.get('user_id')
if not user_id:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Invalid token data',
)
user = await get_user_by_id(db, int(user_id))
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='User not found',
)
if user.status != UserStatus.ACTIVE.value:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Account is deactivated',
)
user.cabinet_last_login = datetime.now(UTC)
await db.commit()
response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, response.refresh_token, device_info='deep_link')
logger.info('Deep link auth successful', user_id=user.id, telegram_id=user.telegram_id)
return response
+54 -1
View File
@@ -701,6 +701,11 @@ async def create_topup(
detail='KassaAI payment method is unavailable',
)
# Use payment_option to select sbp or card
KASSA_AI_OPTION_MAP = {'sbp': 44, 'card': 36}
option = (request.payment_option or '').strip().lower()
ps_id = KASSA_AI_OPTION_MAP.get(option) # None = use env default
payment_service = PaymentService()
result = await payment_service.create_kassa_ai_payment(
db=db,
@@ -709,6 +714,7 @@ async def create_topup(
description=settings.get_balance_payment_description(request.amount_kopeks),
email=getattr(user, 'email', None),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_system_id=ps_id,
)
if result and result.get('payment_url'):
@@ -720,6 +726,33 @@ async def create_topup(
detail='Failed to create KassaAI payment',
)
elif request.payment_method == 'riopay':
if not settings.is_riopay_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='RioPay payment method is unavailable',
)
payment_service = PaymentService()
result = await payment_service.create_riopay_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
success_url=cabinet_success_url,
fail_url=cabinet_failed_url,
)
if result and result.get('payment_url'):
payment_url = result.get('payment_url')
payment_id = str(result.get('local_payment_id') or result.get('riopay_order_id') or 'pending')
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create RioPay payment',
)
elif request.payment_method == 'tribute':
if not settings.TRIBUTE_ENABLED or not settings.TRIBUTE_DONATE_LINK:
raise HTTPException(
@@ -871,6 +904,17 @@ def _get_status_info(record: PendingPayment) -> tuple[str, str]:
}
return mapping.get(status, ('', 'Неизвестно'))
if record.method == PaymentMethod.RIOPAY:
mapping = {
'pending': ('', 'Ожидает оплаты'),
'success': ('', 'Оплачено'),
'failed': ('', 'Ошибка'),
'canceled': ('', 'Отменено'),
'expired': ('', 'Истёк'),
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
}
return mapping.get(status, ('', 'Неизвестно'))
return '', 'Неизвестно'
@@ -901,6 +945,8 @@ def _is_checkable(record: PendingPayment) -> bool:
return status in {'pending', 'created', 'processing'}
if record.method == PaymentMethod.KASSA_AI:
return status in {'pending', 'created', 'processing'}
if record.method == PaymentMethod.RIOPAY:
return status in {'pending'}
return False
@@ -924,7 +970,12 @@ def _get_payment_url(record: PendingPayment) -> str | None:
)
elif record.method == PaymentMethod.PLATEGA:
payment_url = getattr(payment, 'redirect_url', None) or payment_url
elif record.method in (PaymentMethod.CLOUDPAYMENTS, PaymentMethod.FREEKASSA, PaymentMethod.KASSA_AI):
elif record.method in (
PaymentMethod.CLOUDPAYMENTS,
PaymentMethod.FREEKASSA,
PaymentMethod.KASSA_AI,
PaymentMethod.RIOPAY,
):
payment_url = getattr(payment, 'payment_url', None) or payment_url
return payment_url
@@ -1013,6 +1064,7 @@ async def get_latest_payment_by_method(
MulenPayPayment,
Pal24Payment,
PlategaPayment,
RioPayPayment,
WataPayment,
YooKassaPayment,
)
@@ -1028,6 +1080,7 @@ async def get_latest_payment_by_method(
PaymentMethod.CLOUDPAYMENTS: CloudPaymentsPayment,
PaymentMethod.FREEKASSA: FreekassaPayment,
PaymentMethod.KASSA_AI: KassaAiPayment,
PaymentMethod.RIOPAY: RioPayPayment,
}
model = model_map.get(payment_method)
+13 -3
View File
@@ -244,6 +244,7 @@ class EmailAuthEnabledResponse(BaseModel):
"""Email auth enabled setting."""
enabled: bool = True
verification_enabled: bool = True
class EmailAuthEnabledUpdate(BaseModel):
@@ -838,10 +839,16 @@ async def get_email_auth_enabled(
if email_auth_value is not None:
enabled = email_auth_value.lower() == 'true'
return EmailAuthEnabledResponse(enabled=enabled)
return EmailAuthEnabledResponse(
enabled=enabled,
verification_enabled=settings.is_cabinet_email_verification_enabled(),
)
# Default: check config setting
return EmailAuthEnabledResponse(enabled=settings.is_cabinet_email_auth_enabled())
return EmailAuthEnabledResponse(
enabled=settings.is_cabinet_email_auth_enabled(),
verification_enabled=settings.is_cabinet_email_verification_enabled(),
)
@router.patch('/email-auth', response_model=EmailAuthEnabledResponse)
@@ -855,7 +862,10 @@ async def update_email_auth_enabled(
logger.info('Admin set email auth enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
return EmailAuthEnabledResponse(enabled=payload.enabled)
return EmailAuthEnabledResponse(
enabled=payload.enabled,
verification_enabled=settings.is_cabinet_email_verification_enabled(),
)
# ============ Telegram Widget Config Routes ============
+22 -36
View File
@@ -22,7 +22,6 @@ from app.database.models import (
Tariff,
TransactionType,
User,
UserPromoGroup,
)
from app.services.guest_purchase_service import (
GuestPurchaseError,
@@ -112,15 +111,17 @@ async def get_gift_config(
price = base_price
# Apply promo group discount
from app.services.pricing_engine import PricingEngine
promo_group_discount = 0
if promo_group:
promo_group_discount = promo_group.get_discount_percent('period', days)
if promo_group_discount > 0:
price = int(price * (100 - promo_group_discount) / 100)
price = PricingEngine.apply_discount(price, promo_group_discount)
# Apply active promo offer discount (stacks on top)
if promo_offer_discount_percent > 0:
price = price - price * promo_offer_discount_percent // 100
price = PricingEngine.apply_discount(price, promo_offer_discount_percent)
# Ensure minimum price of 1 kopek after all discounts
price = max(1, price)
@@ -249,43 +250,28 @@ async def create_gift_purchase(
detail='Tariff not found or inactive',
)
price_kopeks = tariff.get_price_for_period(body.period_days)
if price_kopeks is None:
# Validate that period has a configured price before locking
if tariff.get_price_for_period(body.period_days) is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Price is not configured for this period',
)
# Lock user row to prevent concurrent promo offer double-spend
locked_result = await db.execute(
select(User)
.options(
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.promo_group),
)
.where(User.id == user.id)
.with_for_update()
.execution_options(populate_existing=True)
# Lock user BEFORE price computation to prevent TOCTOU on promo offer
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
from app.services.pricing_engine import pricing_engine
pricing_result = await pricing_engine.calculate_tariff_purchase_price(
tariff,
body.period_days,
device_limit=tariff.device_limit,
user=user,
)
user = locked_result.scalar_one()
# Apply promo group discount
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
if promo_group is None:
promo_group = getattr(user, 'promo_group', None)
if promo_group:
discount_percent = promo_group.get_discount_percent('period', body.period_days)
if discount_percent > 0:
price_kopeks = int(price_kopeks * (100 - discount_percent) / 100)
# Apply active promo offer discount (stacks)
promo_offer_discount_percent = get_user_active_promo_discount_percent(user)
if promo_offer_discount_percent > 0:
price_kopeks = price_kopeks - price_kopeks * promo_offer_discount_percent // 100
# Ensure minimum price of 1 kopek after all discounts
price_kopeks = max(1, price_kopeks)
price_kopeks = max(1, pricing_result.final_total)
consume_promo = pricing_result.promo_offer_discount > 0
# Determine buyer contact info
if user.email:
@@ -420,7 +406,7 @@ async def create_gift_purchase(
)
# Consume promo offer discount before committing gateway purchase
if promo_offer_discount_percent > 0 and getattr(user, 'promo_offer_discount_percent', 0):
if consume_promo and getattr(user, 'promo_offer_discount_percent', 0):
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
@@ -485,7 +471,7 @@ async def create_gift_purchase(
price_kopeks,
description=f'Gift: {tariff.name} ({body.period_days}d)',
create_transaction=False,
consume_promo_offer=promo_offer_discount_percent > 0,
consume_promo_offer=consume_promo,
)
if not balance_ok:
await db.rollback()
+2 -2
View File
@@ -91,7 +91,7 @@ class SupportConfigResponse(BaseModel):
"""Support/tickets configuration for miniapp."""
tickets_enabled: bool
support_type: str # "tickets", "profile", "url"
support_type: str # "tickets", "profile", "url", "both"
support_url: str | None = None
support_username: str | None = None
@@ -299,7 +299,7 @@ async def get_support_config():
support_type = 'profile'
else: # both
tickets_enabled = True
support_type = 'tickets'
support_type = 'both'
return SupportConfigResponse(
tickets_enabled=tickets_enabled,
+3 -1
View File
@@ -342,7 +342,9 @@ async def _load_landing_tariffs(
effective_discount = tariff_override if tariff_override is not None else discount.percent
original_price_kopeks = price
original_price_label = settings.format_price(price)
price = max(1, price - (price * effective_discount // 100))
from app.services.pricing_engine import PricingEngine
price = max(1, PricingEngine.apply_discount(price, effective_discount))
periods.append(
LandingTariffPeriod(
+9 -1
View File
@@ -91,12 +91,20 @@ async def get_referral_info(
referral_entitlement = max(0, total_earnings - withdrawn - pending)
available_balance = min(user.balance_kopeks, referral_entitlement)
# Build referral link
# Build referral links
referral_link = settings.get_referral_link(user.referral_code) if user.referral_code else ''
bot_username = settings.get_bot_username()
bot_referral_link = ''
if user.referral_code and bot_username:
from urllib.parse import quote
safe_code = quote(user.referral_code, safe='')
bot_referral_link = f'https://t.me/{bot_username}?start={safe_code}'
return ReferralInfoResponse(
referral_code=user.referral_code or '',
referral_link=referral_link,
bot_referral_link=bot_referral_link,
total_referrals=total_referrals,
active_referrals=active_referrals,
total_earnings_kopeks=total_earnings,
+222 -338
View File
@@ -42,7 +42,6 @@ from app.services.system_settings_service import bot_configuration_service
from app.services.user_cart_service import user_cart_service
from app.utils.cache import RateLimitCache, cache, cache_key
from app.utils.pricing_utils import format_period_description
from app.utils.promo_offer import get_user_active_promo_discount_percent
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.subscription import (
@@ -68,29 +67,14 @@ router = APIRouter(prefix='/subscription', tags=['Cabinet Subscription'])
def _get_addon_discount_percent(
user: User,
user: User | None,
category: str,
period_days: int | None = None,
period_days_hint: int | None = None,
) -> int:
"""Get addon discount percent for user from promo group.
"""Get addon discount percent for user — delegates to PricingEngine."""
from app.services.pricing_engine import PricingEngine
Mirrors logic from app/handlers/subscription/common.py:_get_addon_discount_percent_for_user
"""
promo_group = (
user.get_primary_promo_group()
if hasattr(user, 'get_primary_promo_group')
else getattr(user, 'promo_group', None)
)
if promo_group is None:
return 0
if not getattr(promo_group, 'apply_discounts_to_addons', True):
return 0
try:
return user.get_promo_discount(category, period_days)
except AttributeError:
return 0
return PricingEngine.get_addon_discount_percent(user, category, period_days_hint)
def _apply_addon_discount(
@@ -117,27 +101,12 @@ def _apply_addon_discount(
}
def _get_period_discount_percent(user: User, period_days: int | None = None) -> int:
"""Get period discount percent for tariff switch calculations."""
promo_group = (
user.get_primary_promo_group()
if hasattr(user, 'get_primary_promo_group')
else getattr(user, 'promo_group', None)
)
if promo_group is None:
return 0
try:
return user.get_promo_discount('period', period_days)
except AttributeError:
return 0
def _subscription_to_response(
subscription: Subscription,
servers: list[ServerInfo] | None = None,
tariff_name: str | None = None,
traffic_purchases: list[dict[str, Any]] | None = None,
user: User | None = None,
) -> SubscriptionData:
"""Convert Subscription model to response."""
now = datetime.now(UTC)
@@ -200,6 +169,18 @@ def _subscription_to_response(
traffic_reset_mode = None
if tariff_id and hasattr(subscription, 'tariff') and subscription.tariff:
daily_price_kopeks = getattr(subscription.tariff, 'daily_price_kopeks', None)
# Применяем скидку промогруппы + promo-offer для отображения
if daily_price_kopeks and daily_price_kopeks > 0 and user:
from app.services.pricing_engine import PricingEngine
from app.utils.promo_offer import get_user_active_promo_discount_percent
_promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
_group_pct = _promo_group.get_discount_percent('period', 1) if _promo_group else 0
_offer_pct = get_user_active_promo_discount_percent(user)
if _group_pct > 0 or _offer_pct > 0:
daily_price_kopeks, _, _ = PricingEngine.apply_stacked_discounts(
daily_price_kopeks, _group_pct, _offer_pct
)
if not tariff_name: # Only set if not passed as parameter
tariff_name = getattr(subscription.tariff, 'name', None)
traffic_reset_mode = (
@@ -321,7 +302,9 @@ async def get_subscription(
}
)
subscription_data = _subscription_to_response(fresh_user.subscription, servers, tariff_name, traffic_purchases_data)
subscription_data = _subscription_to_response(
fresh_user.subscription, servers, tariff_name, traffic_purchases_data, user=fresh_user
)
return SubscriptionStatusResponse(has_subscription=True, subscription=subscription_data)
@@ -401,6 +384,11 @@ async def renew_subscription(
detail='Selected renewal period is not available',
)
# Lock user row to prevent TOCTOU on promo-offer state
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
# Unified pricing via PricingEngine
pricing = await pricing_engine.calculate_renewal_price(
db,
@@ -724,6 +712,11 @@ async def purchase_traffic(
subscription.end_date,
)
# Lock user row to prevent TOCTOU on promo-offer state
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
# Apply discount from promo group using proper method
period_hint_days = days_charged if days_charged > 0 else 30
discount_result = _apply_addon_discount(user, 'traffic', prorated_price, period_hint_days)
@@ -923,6 +916,11 @@ async def purchase_devices_legacy(
base_total_price = device_price * request.devices
# Lock user row to prevent TOCTOU on promo-offer state
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
# Apply discount from promo group
discount_result = _apply_addon_discount(user, 'devices', base_total_price, 30)
total_price = discount_result['discounted']
@@ -1363,7 +1361,7 @@ async def activate_trial(
except Exception as e:
logger.error('Failed to send trial activation notification', error=e)
return _subscription_to_response(subscription)
return _subscription_to_response(subscription, user=user)
# ============ Full Purchase Flow (like MiniApp) ============
@@ -1427,17 +1425,29 @@ async def _build_tariff_response(
# Стоимость доп. устройств за этот период
extra_devices_cost = extra_devices_count * extra_device_price_per_month * months
# Apply promo group discount for this period (на базовую цену тарифа)
# Apply per-category promo group discounts
original_price = base_tariff_price + extra_devices_cost
discount_percent = 0
discount_amount = 0
final_price = original_price
if promo_group:
discount_percent = promo_group.get_discount_percent('period', period_days)
if discount_percent > 0:
discount_amount = original_price * discount_percent // 100
final_price = original_price - discount_amount
period_pct = promo_group.get_discount_percent('period', period_days)
devices_pct = promo_group.get_discount_percent('devices', period_days)
discounted_base = (
pricing_engine.apply_discount(base_tariff_price, period_pct)
if period_pct > 0
else base_tariff_price
)
discounted_devices = (
pricing_engine.apply_discount(extra_devices_cost, devices_pct)
if devices_pct > 0
else extra_devices_cost
)
final_price = discounted_base + discounted_devices
discount_amount = original_price - final_price
discount_percent = max(period_pct, devices_pct)
else:
discount_percent = 0
final_price = original_price
per_month = final_price // months if months > 0 else final_price
original_per_month = original_price // months if months > 0 else original_price
@@ -1474,16 +1484,21 @@ async def _build_tariff_response(
traffic_label = '♾️ Безлимит' if tariff.traffic_limit_gb == 0 else f'{tariff.traffic_limit_gb} ГБ'
# Apply discount to daily price if applicable
# Apply discount to daily price if applicable (group + promo-offer)
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
original_daily_price = daily_price
daily_discount_percent = 0
if promo_group and daily_price > 0:
# For daily tariffs, use period discount with period_days=1
daily_discount_percent = promo_group.get_discount_percent('period', 1)
if daily_discount_percent > 0:
discount_amount = daily_price * daily_discount_percent // 100
daily_price = daily_price - discount_amount
if daily_price > 0:
from app.services.pricing_engine import PricingEngine
from app.utils.promo_offer import get_user_active_promo_discount_percent
daily_group_pct = promo_group.get_discount_percent('period', 1) if promo_group else 0
daily_offer_pct = get_user_active_promo_discount_percent(user) if user else 0
if daily_group_pct > 0 or daily_offer_pct > 0:
daily_price, _, _ = PricingEngine.apply_stacked_discounts(daily_price, daily_group_pct, daily_offer_pct)
# Комбинированный процент для отображения
remaining = (100 - daily_group_pct) * (100 - daily_offer_pct)
daily_discount_percent = 100 - remaining // 100
# Apply discount to custom price_per_day if applicable
price_per_day = tariff.price_per_day_kopeks
@@ -1492,18 +1507,16 @@ async def _build_tariff_response(
if promo_group and price_per_day > 0:
custom_days_discount_percent = promo_group.get_discount_percent('period', 30) # Use 30-day rate as base
if custom_days_discount_percent > 0:
discount_amount = price_per_day * custom_days_discount_percent // 100
price_per_day = price_per_day - discount_amount
price_per_day = pricing_engine.apply_discount(price_per_day, custom_days_discount_percent)
# Apply discount to device price if applicable
device_price = tariff.device_price_kopeks if tariff.device_price_kopeks is not None else 0
original_device_price = device_price
device_discount_percent = 0
if promo_group and device_price > 0:
device_discount_percent = promo_group.get_discount_percent('devices')
device_discount_percent = promo_group.get_discount_percent('devices', 30)
if device_discount_percent > 0:
discount_amount = device_price * device_discount_percent // 100
device_price = device_price - discount_amount
device_price = pricing_engine.apply_discount(device_price, device_discount_percent)
# Показываем реальное количество устройств (с докупленными) для текущего тарифа
actual_device_limit = tariff.device_limit
@@ -1703,6 +1716,9 @@ async def submit_purchase(
)
try:
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
context = await purchase_service.build_options(db, user)
# Convert request to dict for parsing
@@ -1771,10 +1787,13 @@ async def submit_purchase(
except Exception as e:
logger.error('Failed to send admin notification for subscription purchase', error=e)
# Refresh expired objects after db.commit() in _record_subscription_event
await db.refresh(subscription)
return {
'success': True,
'message': result['message'],
'subscription': _subscription_to_response(subscription),
'subscription': _subscription_to_response(subscription, user=user),
'was_trial_conversion': result.get('was_trial_conversion', False),
}
@@ -1854,6 +1873,11 @@ async def purchase_tariff(
detail='Tariff not found or inactive',
)
# Lock user BEFORE price computation to prevent TOCTOU on promo offer
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
# Check tariff availability for user's promo group and get promo group for discounts
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
promo_group_id = promo_group.id if promo_group else None
@@ -1865,105 +1889,43 @@ async def purchase_tariff(
# Handle daily tariffs specially
is_daily_tariff = getattr(tariff, 'is_daily', False)
discount_percent = 0
original_price = 0
if is_daily_tariff:
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
if daily_price <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Daily tariff has invalid price',
)
original_price = daily_price
# Apply promo group discount for daily tariff
if promo_group:
discount_percent = promo_group.get_discount_percent('period', 1)
if discount_percent > 0:
discount_amount = daily_price * discount_percent // 100
daily_price = daily_price - discount_amount
# For daily tariffs, charge first day and set period to 1 day
price_kopeks = daily_price
period_days = 1
else:
period_days = request.period_days
# Get price for period (support custom days)
price_kopeks = tariff.get_price_for_period(period_days)
if price_kopeks is None:
# Check for custom days
if tariff.can_purchase_custom_days():
price_kopeks = tariff.get_price_for_custom_days(period_days)
if price_kopeks is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Period must be between {tariff.min_days} and {tariff.max_days} days',
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid period for this tariff',
)
original_price = price_kopeks
# Apply promo group discount for period
if promo_group and price_kopeks > 0:
discount_percent = promo_group.get_discount_percent('period', period_days)
if discount_percent > 0:
discount_amount = price_kopeks * discount_percent // 100
price_kopeks = price_kopeks - discount_amount
# Calculate traffic limit and price
# Determine traffic limit (custom traffic support)
traffic_limit_gb = tariff.traffic_limit_gb
traffic_price_kopeks = 0
custom_traffic_gb = None
if request.traffic_gb is not None and tariff.can_purchase_custom_traffic():
# Custom traffic requested
traffic_price_kopeks = tariff.get_price_for_custom_traffic(request.traffic_gb)
if traffic_price_kopeks is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Traffic must be between {tariff.min_traffic_gb} and {tariff.max_traffic_gb} GB',
)
# Apply traffic discount if promo group has it
if promo_group and traffic_price_kopeks > 0:
traffic_discount_percent = promo_group.get_discount_percent('traffic', period_days)
if traffic_discount_percent > 0:
traffic_discount = traffic_price_kopeks * traffic_discount_percent // 100
traffic_price_kopeks = traffic_price_kopeks - traffic_discount
custom_traffic_gb = request.traffic_gb
traffic_limit_gb = request.traffic_gb
price_kopeks += traffic_price_kopeks
# Проверяем, есть ли докупленные устройства при продлении того же тарифа
# Determine device_limit for renewal pricing
existing_subscription = await get_subscription_by_user_id(db, user.id)
extra_devices = 0
device_limit = None
effective_device_limit = tariff.device_limit
if existing_subscription and existing_subscription.tariff_id == tariff.id:
extra_devices = max(0, (existing_subscription.device_limit or 0) - (tariff.device_limit or 0))
if extra_devices > 0:
device_limit = existing_subscription.device_limit
if (existing_subscription.device_limit or 0) > (tariff.device_limit or 0):
effective_device_limit = existing_subscription.device_limit
if not is_daily_tariff:
from app.utils.pricing_utils import calculate_months_from_days
device_price_per_month = (
tariff.device_price_kopeks
if tariff.device_price_kopeks is not None
else settings.PRICE_PER_DEVICE
)
months = calculate_months_from_days(period_days)
extra_devices_cost = extra_devices * device_price_per_month * months
# Применяем скидку промогруппы на устройства
if promo_group and extra_devices_cost > 0:
devices_discount_pct = promo_group.get_discount_percent('devices', period_days)
if devices_discount_pct > 0:
extra_devices_cost = extra_devices_cost - (extra_devices_cost * devices_discount_pct // 100)
price_kopeks += extra_devices_cost
# Apply promo offer discount (temporary discount from promo offers)
price_before_promo_offer = price_kopeks
promo_offer_discount_percent = get_user_active_promo_discount_percent(user)
promo_offer_discount_value = 0
if promo_offer_discount_percent > 0:
promo_offer_discount_value = price_kopeks * promo_offer_discount_percent // 100
price_kopeks = price_kopeks - promo_offer_discount_value
# Calculate price via PricingEngine (single source of truth)
result = await pricing_engine.calculate_tariff_purchase_price(
tariff,
period_days,
device_limit=device_limit,
custom_traffic_gb=custom_traffic_gb,
user=user,
)
price_kopeks = result.final_total
original_price = result.original_total
bd = result.breakdown
group_pcts = bd.get('group_discount_pct', {})
discount_percent = group_pcts.get('period', 0)
promo_offer_discount_percent = bd.get('offer_discount_pct', 0)
promo_offer_discount_value = result.promo_offer_discount
price_before_promo_offer = price_kopeks + promo_offer_discount_value
# Check balance
if user.balance_kopeks < price_kopeks:
@@ -2109,6 +2071,7 @@ async def purchase_tariff(
subscription,
reset_traffic=True,
reset_reason='покупка тарифа (cabinet)',
sync_squads=True,
)
else:
await service.create_remnawave_user(
@@ -2137,11 +2100,12 @@ async def purchase_tariff(
logger.error('Error saving tariff cart (cabinet)', error=e)
await db.refresh(user)
await db.refresh(subscription)
response = {
'success': True,
'message': f"Тариф '{tariff.name}' успешно активирован",
'subscription': _subscription_to_response(subscription),
'subscription': _subscription_to_response(subscription, user=user),
'tariff_id': tariff.id,
'tariff_name': tariff.name,
'charged_amount': price_kopeks,
@@ -2319,6 +2283,11 @@ async def purchase_devices(
base_price_prorated = int(base_price_per_month * days_left / total_days)
base_price_prorated = max(100, base_price_prorated) # Minimum 1 ruble
# Lock user BEFORE discount computation to prevent TOCTOU on promo group
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
# Apply discount from promo group
period_hint_days = days_left
discount_result = _apply_addon_discount(user, 'devices', base_price_prorated, period_hint_days)
@@ -2977,8 +2946,7 @@ async def get_available_countries(
await db.refresh(user, ['subscription'])
promo_group_id = user.promo_group_id
# Exclude trial-only servers from available servers for purchase
available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id, exclude_trial_only=True)
available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id)
connected_squads = []
days_left = 0
@@ -2989,11 +2957,10 @@ async def get_available_countries(
delta = user.subscription.end_date - datetime.now(UTC)
days_left = max(0, delta.days)
# Get discount from promo group
servers_discount_percent = 0
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
if promo_group:
servers_discount_percent = promo_group.get_discount_percent('servers', None)
# Get discount from promo group via PricingEngine (respects apply_discounts_to_addons flag)
from app.services.pricing_engine import PricingEngine
servers_discount_percent = PricingEngine.get_addon_discount_percent(user, 'servers', None)
countries = []
for server in available_servers:
@@ -3076,13 +3043,12 @@ async def update_countries(
current_countries = user.subscription.connected_squads or []
promo_group_id = user.promo_group_id
# Exclude trial-only servers from available servers for purchase
available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id, exclude_trial_only=True)
available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id)
allowed_country_ids = {server.squad_uuid for server in available_servers}
# Validate selected countries
for country_uuid in selected_countries:
if country_uuid not in allowed_country_ids and country_uuid not in current_countries:
if country_uuid not in allowed_country_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Country {country_uuid} is not available',
@@ -3097,15 +3063,19 @@ async def update_countries(
'connected_squads': current_countries,
}
# Lock user row to prevent TOCTOU on promo-offer state
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
# Calculate cost for added servers
total_cost = 0
added_names = []
removed_names = []
servers_discount_percent = 0
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
if promo_group:
servers_discount_percent = promo_group.get_discount_percent('servers', None)
from app.services.pricing_engine import PricingEngine
servers_discount_percent = PricingEngine.get_addon_discount_percent(user, 'servers', None)
added_server_prices = []
@@ -3175,7 +3145,7 @@ async def update_countries(
try:
subscription_service = SubscriptionService()
if getattr(user, 'remnawave_uuid', None):
await subscription_service.update_remnawave_user(db, user.subscription)
await subscription_service.update_remnawave_user(db, user.subscription, sync_squads=True)
else:
await subscription_service.create_remnawave_user(db, user.subscription)
except Exception as e:
@@ -3796,21 +3766,32 @@ async def reduce_devices(
logger.error('Error checking/removing devices', error=e)
old_device_limit = current_device_limit
user_id = user.id # save before potential rollback (expires ORM objects)
# Update subscription
# Update subscription in memory (will be committed by update_remnawave_user on success)
subscription.device_limit = new_device_limit
subscription.updated_at = datetime.now(UTC)
await db.commit()
# Update RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
except Exception as e:
logger.error('Error updating RemnaWave user', error=e)
# Update RemnaWave — commits on success, returns None on failure
subscription_service = SubscriptionService()
result = await subscription_service.update_remnawave_user(db, subscription)
if result is None:
# RemnaWave update failed — rollback local changes
await db.rollback()
logger.error(
'Failed to update RemnaWave after device limit reduction',
user_id=user_id,
old_device_limit=old_device_limit,
new_device_limit=new_device_limit,
)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail='Не удалось обновить VPN-панель. Попробуйте позже.',
)
logger.info(
f'User {user.id} reduced device limit from {old_device_limit} to {new_device_limit}'
f'User {user_id} reduced device limit from {old_device_limit} to {new_device_limit}'
+ (f' (removed {devices_removed_count} devices)' if devices_removed_count > 0 else '')
)
@@ -3903,82 +3884,18 @@ async def preview_tariff_switch(
delta = user.subscription.end_date - datetime.now(UTC)
remaining_days = max(0, delta.days)
# Calculate switch cost
current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False
new_is_daily = getattr(new_tariff, 'is_daily', False)
switching_to_daily = not current_is_daily and new_is_daily
switching_from_daily = current_is_daily and not new_is_daily
def get_monthly_price(tariff) -> int:
"""Get 30-day price from tariff, or calculate from closest period."""
if not tariff or not tariff.period_prices:
return 0
# Try to get 30-day price directly
if '30' in tariff.period_prices:
return tariff.period_prices['30']
# Find closest period and calculate monthly equivalent
min_period = None
min_price = 0
for period_str, price in tariff.period_prices.items():
period_days = int(period_str)
if min_period is None or period_days < min_period:
min_period = period_days
min_price = price
if min_period and min_period > 0:
return int(min_price * 30 / min_period)
return 0
# Get period discount percent for cost calculation
period_discount_percent = _get_period_discount_percent(user, remaining_days if remaining_days > 0 else 30)
base_upgrade_cost = 0
discount_value = 0
if switching_to_daily:
# Switching TO daily - pay first day price
daily_price = getattr(new_tariff, 'daily_price_kopeks', 0)
base_upgrade_cost = daily_price
# Apply discount to daily price
if period_discount_percent > 0 and base_upgrade_cost > 0:
discount_value = int(base_upgrade_cost * period_discount_percent / 100)
upgrade_cost = base_upgrade_cost - discount_value
else:
upgrade_cost = base_upgrade_cost
is_upgrade = upgrade_cost > 0
elif switching_from_daily:
# Switching FROM daily TO periodic - full payment for new tariff
min_period_price = 0
if new_tariff.period_prices:
min_period_price = min(new_tariff.period_prices.values())
base_upgrade_cost = min_period_price
# Apply discount
if period_discount_percent > 0 and base_upgrade_cost > 0:
discount_value = int(base_upgrade_cost * period_discount_percent / 100)
upgrade_cost = base_upgrade_cost - discount_value
else:
upgrade_cost = base_upgrade_cost
is_upgrade = upgrade_cost > 0
else:
# Calculate proportional cost difference using monthly prices
current_monthly = get_monthly_price(current_tariff)
new_monthly = get_monthly_price(new_tariff)
price_diff = new_monthly - current_monthly
if price_diff > 0:
# Upgrade - pay proportional difference
base_upgrade_cost = int(price_diff * remaining_days / 30)
# Apply discount to upgrade cost
if period_discount_percent > 0 and base_upgrade_cost > 0:
discount_value = int(base_upgrade_cost * period_discount_percent / 100)
upgrade_cost = base_upgrade_cost - discount_value
else:
upgrade_cost = base_upgrade_cost
is_upgrade = True
else:
# Downgrade or same - free
upgrade_cost = 0
base_upgrade_cost = 0
is_upgrade = False
# Calculate switch cost (PricingEngine handles all cases: periodic↔periodic, daily→periodic, periodic→daily)
switch_result = pricing_engine.calculate_tariff_switch_cost(
current_tariff,
new_tariff,
remaining_days,
user=user,
)
upgrade_cost = switch_result.upgrade_cost
is_upgrade = switch_result.is_upgrade
base_upgrade_cost = switch_result.raw_cost
discount_value = switch_result.discount_value
period_discount_percent = switch_result.effective_discount_pct
balance = user.balance_kopeks or 0
has_enough = balance >= upgrade_cost
@@ -4090,88 +4007,41 @@ async def switch_tariff(
detail='Tariff not available',
)
# Lock user BEFORE price computation to prevent TOCTOU on promo offer
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
# Calculate remaining days
remaining_days = 0
if user.subscription.end_date and user.subscription.end_date > datetime.now(UTC):
delta = user.subscription.end_date - datetime.now(UTC)
if subscription.end_date and subscription.end_date > datetime.now(UTC):
delta = subscription.end_date - datetime.now(UTC)
remaining_days = max(0, delta.days)
# Calculate cost
current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False
# Calculate cost (PricingEngine handles all cases: periodic↔periodic, daily→periodic, periodic→daily)
switch_result = pricing_engine.calculate_tariff_switch_cost(
current_tariff,
new_tariff,
remaining_days,
user=user,
)
upgrade_cost = switch_result.upgrade_cost
base_upgrade_cost = switch_result.raw_cost
discount_value = switch_result.discount_value
period_discount_percent = switch_result.effective_discount_pct
new_period_days = switch_result.new_period_days
# Validate daily price for switching TO daily
new_is_daily = getattr(new_tariff, 'is_daily', False)
switching_from_daily = current_is_daily and not new_is_daily
current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False
switching_to_daily = not current_is_daily and new_is_daily
switching_from_daily = current_is_daily and not new_is_daily
# Get period discount percent for cost calculation
period_discount_percent = _get_period_discount_percent(user, remaining_days if remaining_days > 0 else 30)
base_upgrade_cost = 0
discount_value = 0
if switching_to_daily:
# Switching TO daily tariff - charge first day price
daily_price = getattr(new_tariff, 'daily_price_kopeks', 0)
if daily_price <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Daily tariff has invalid price',
)
base_upgrade_cost = daily_price
# Apply discount
if period_discount_percent > 0 and base_upgrade_cost > 0:
discount_value = int(base_upgrade_cost * period_discount_percent / 100)
upgrade_cost = base_upgrade_cost - discount_value
else:
upgrade_cost = base_upgrade_cost
new_period_days = 1 # Daily tariff starts with 1 day
elif switching_from_daily:
# Switch FROM daily to regular tariff - pay for minimum period
min_period_days = 30
min_period_price = 0
if new_tariff.period_prices:
min_period_days = min(int(k) for k in new_tariff.period_prices.keys())
min_period_price = new_tariff.period_prices.get(str(min_period_days), 0)
base_upgrade_cost = min_period_price
# Apply discount
if period_discount_percent > 0 and base_upgrade_cost > 0:
discount_value = int(base_upgrade_cost * period_discount_percent / 100)
upgrade_cost = base_upgrade_cost - discount_value
else:
upgrade_cost = base_upgrade_cost
new_period_days = min_period_days
else:
# Regular tariff switch - calculate proportional cost difference using monthly prices
def get_monthly_price(tariff) -> int:
if not tariff or not tariff.period_prices:
return 0
if '30' in tariff.period_prices:
return tariff.period_prices['30']
min_period = None
min_price = 0
for period_str, price in tariff.period_prices.items():
period_days = int(period_str)
if min_period is None or period_days < min_period:
min_period = period_days
min_price = price
if min_period and min_period > 0:
return int(min_price * 30 / min_period)
return 0
current_monthly = get_monthly_price(current_tariff)
new_monthly = get_monthly_price(new_tariff)
price_diff = new_monthly - current_monthly
if price_diff > 0:
base_upgrade_cost = int(price_diff * remaining_days / 30)
# Apply discount
if period_discount_percent > 0 and base_upgrade_cost > 0:
discount_value = int(base_upgrade_cost * period_discount_percent / 100)
upgrade_cost = base_upgrade_cost - discount_value
else:
upgrade_cost = base_upgrade_cost
else:
upgrade_cost = 0
base_upgrade_cost = 0
new_period_days = 0
if switching_to_daily and (getattr(new_tariff, 'daily_price_kopeks', 0) or 0) <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Daily tariff has invalid price',
)
# Charge if upgrade
if upgrade_cost > 0:
@@ -4202,6 +4072,7 @@ async def switch_tariff(
user,
upgrade_cost,
description,
consume_promo_offer=switch_result.offer_discount_pct > 0,
mark_as_paid_subscription=True,
commit=False,
)
@@ -4236,7 +4107,7 @@ async def switch_tariff(
# Update subscription
old_tariff_name = current_tariff.name if current_tariff else 'Unknown'
# Preserve extra purchased devices above the old tariff's base limit
# Reset device limit to new tariff base (extra purchased devices are not carried over)
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
# Re-load subscription to avoid MissingGreenlet from expired lazy relationship
@@ -4304,6 +4175,7 @@ async def switch_tariff(
subscription,
reset_traffic=should_reset_traffic,
reset_reason='смена тарифа',
sync_squads=True,
)
else:
await subscription_service.create_remnawave_user(
@@ -4355,11 +4227,15 @@ async def switch_tariff(
except Exception as e:
logger.error('Failed to send admin notification for tariff switch', error=e)
# Refresh expired objects after db.commit() in _record_subscription_event
await db.refresh(subscription)
await db.refresh(user)
response = {
'success': True,
'message': f"Switched from '{old_tariff_name}' to '{new_tariff.name}'"
+ (' (devices reset)' if devices_reset else ''),
'subscription': _subscription_to_response(subscription),
'subscription': _subscription_to_response(subscription, user=user),
'old_tariff_name': old_tariff_name,
'new_tariff_id': new_tariff.id,
'new_tariff_name': new_tariff.name,
@@ -4426,7 +4302,21 @@ async def toggle_subscription_pause(
new_paused_state = not is_currently_paused
user.subscription.is_daily_paused = new_paused_state
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0)
# Lock user BEFORE discount computation to prevent TOCTOU on promo group
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
# Apply group discount to daily price (consistent with DailySubscriptionService and miniapp resume)
from app.services.pricing_engine import PricingEngine
promo_group = PricingEngine.resolve_promo_group(user)
daily_group_pct = promo_group.get_discount_percent('period', 1) if promo_group else 0
daily_price = (
PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price
)
# If resuming, check balance and charge
if not new_paused_state:
@@ -4568,22 +4458,16 @@ async def switch_traffic_package(
# Upgrade - charge difference
price_diff = new_price - current_price
# Apply promo discount
traffic_discount_percent = 0
promo_group = (
user.get_primary_promo_group()
if hasattr(user, 'get_primary_promo_group')
else getattr(user, 'promo_group', None)
)
if promo_group:
apply_to_addons = getattr(promo_group, 'apply_discounts_to_addons', True)
if apply_to_addons:
traffic_discount_percent = max(
0, min(100, int(getattr(promo_group, 'traffic_discount_percent', 0) or 0))
)
# Lock user row to prevent TOCTOU on promo-offer state
from app.database.crud.user import lock_user_for_pricing
if traffic_discount_percent > 0:
price_diff = int(price_diff * (100 - traffic_discount_percent) / 100)
user = await lock_user_for_pricing(db, user.id)
# Apply promo discount via PricingEngine
price_diff, _discount_val, traffic_discount_percent = pricing_engine.calculate_traffic_discount(
price_diff,
user,
)
# Prorated calculation
final_price, days_charged = calculate_prorated_price(price_diff, user.subscription.end_date)
+14
View File
@@ -187,3 +187,17 @@ class EmailChangeResponse(BaseModel):
message: str = Field(..., description='Success message')
new_email: str = Field(..., description='New email address pending verification')
expires_in_minutes: int = Field(..., description='Code expiration time in minutes')
class DeepLinkTokenResponse(BaseModel):
"""Response with deep link auth token."""
token: str = Field(..., description='One-time auth token')
bot_username: str = Field(..., description='Bot username for deep link')
expires_in: int = Field(..., description='Token TTL in seconds')
class DeepLinkPollRequest(BaseModel):
"""Request to poll deep link auth status."""
token: str = Field(..., min_length=16, max_length=128, description='Deep link auth token')
+1
View File
@@ -10,6 +10,7 @@ class ReferralInfoResponse(BaseModel):
referral_code: str
referral_link: str
bot_referral_link: str = ''
total_referrals: int
active_referrals: int
total_earnings_kopeks: int
@@ -198,7 +198,7 @@ async def get_rendered_override(
for key, value in context.items():
body_html = body_html.replace(f'{{{key}}}', html.escape(str(value)))
rendered = templates._get_base_template(body_html, language)
rendered = templates._wrap_override_template(body_html, language)
subject = override['subject']
# Also substitute in subject
+33
View File
@@ -74,6 +74,39 @@ class EmailNotificationTemplates:
return template_func(language, context)
def _wrap_override_template(self, content: str, language: str = 'ru') -> str:
"""Wrap override template content appropriately based on its structure.
Three-tier detection:
1. Full HTML document (<!DOCTYPE or <html>) return as-is, no wrapping
2. Styled content (has <style> tag or background CSS) minimal HTML wrapper
without forced colors, headers, or footers
3. Simple HTML fragment wrap with base template (header, footer, white bg)
for backward compatibility
"""
content_stripped = content.strip()
content_lower = content_stripped.lower()
# Tier 1: Full HTML document — return as-is
if content_lower.startswith('<!doctype') or content_lower.startswith('<html'):
return content_stripped
# Tier 2: Styled content — minimal wrapper without forced styling
if '<style' in content_lower or 'background' in content_lower:
return f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0;">
{content}
</body>
</html>"""
# Tier 3: Simple HTML fragment — use base template for structure
return self._get_base_template(content, language)
def _get_base_template(self, content: str, language: str = 'ru') -> str:
"""Wrap content in base HTML template."""
footer_texts = {
+58 -6
View File
@@ -56,6 +56,17 @@ class Settings(BaseSettings):
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID: int | None = None
ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID: int | None = None
# Раздельные топики для уведомлений (если не задано — fallback на ADMIN_NOTIFICATIONS_TOPIC_ID)
ADMIN_NOTIFICATIONS_PURCHASES_TOPIC_ID: int | None = None # Покупки подписок
ADMIN_NOTIFICATIONS_RENEWALS_TOPIC_ID: int | None = None # Продления
ADMIN_NOTIFICATIONS_TRIALS_TOPIC_ID: int | None = None # Триалы
ADMIN_NOTIFICATIONS_BALANCE_TOPIC_ID: int | None = None # Пополнение баланса
ADMIN_NOTIFICATIONS_ADDONS_TOPIC_ID: int | None = None # Докупка трафика/устройств/серверов
ADMIN_NOTIFICATIONS_INFRASTRUCTURE_TOPIC_ID: int | None = None # Ноды, техработы, статус панели
ADMIN_NOTIFICATIONS_ERRORS_TOPIC_ID: int | None = None # Ошибки бота
ADMIN_NOTIFICATIONS_PROMO_TOPIC_ID: int | None = None # Промокоды, кампании, промогруппы
ADMIN_NOTIFICATIONS_PARTNERS_TOPIC_ID: int | None = None # Партнёрки, выводы, админ-действия
# Настройки очереди чеков NaloGO
NALOGO_QUEUE_CHECK_INTERVAL: int = 300 # Интервал проверки очереди (секунды)
NALOGO_QUEUE_RECEIPT_DELAY: int = 3 # Задержка между отправкой чеков (секунды)
@@ -353,10 +364,8 @@ class Settings(BaseSettings):
YOOKASSA_TRUSTED_PROXY_NETWORKS: str = ''
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
YOOKASSA_RECURRENT_ENABLED: bool = False
YOOKASSA_RECURRENT_REQUIRED: bool = False
DISABLE_TOPUP_BUTTONS: bool = False
SUPPORT_TOPUP_ENABLED: bool = True
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10
@@ -535,6 +544,11 @@ class Settings(BaseSettings):
KASSA_AI_WEBHOOK_PORT: int = 8089
# Способ оплаты: 44 = СБП (QR код), 36 = Карты РФ, 43 = SberPay
KASSA_AI_PAYMENT_SYSTEM_ID: int = 44
# Раздельные методы оплаты KassaAI (отображаются как отдельные кнопки)
KASSA_AI_SBP_ENABLED: bool = False # СБП — payment_system_id=44
KASSA_AI_SBP_DISPLAY_NAME: str = 'СБП (KassaAI)'
KASSA_AI_CARD_ENABLED: bool = False # Карты РФ — payment_system_id=36
KASSA_AI_CARD_DISPLAY_NAME: str = 'Карта (KassaAI)'
# RioPay (api.riopay.online) v2.0.1
RIOPAY_ENABLED: bool = False
@@ -548,6 +562,18 @@ class Settings(BaseSettings):
RIOPAY_SUCCESS_URL: str | None = None
RIOPAY_FAIL_URL: str | None = None
# SeverPay (severpay.io)
SEVERPAY_ENABLED: bool = False
SEVERPAY_MID: int | None = None # Merchant ID
SEVERPAY_TOKEN: str | None = None # Secret token for HMAC-SHA256
SEVERPAY_DISPLAY_NAME: str = 'SeverPay'
SEVERPAY_CURRENCY: str = 'RUB'
SEVERPAY_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
SEVERPAY_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
SEVERPAY_WEBHOOK_PATH: str = '/severpay-webhook'
SEVERPAY_RETURN_URL: str | None = None
SEVERPAY_LIFETIME: int = 1440 # minutes, 30-4320
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
CABINET_BUTTON_STYLE: str = ''
@@ -1237,10 +1263,6 @@ class Settings(BaseSettings):
return bool(value)
def is_quick_amount_buttons_enabled(self) -> bool:
"""Показывать ли кнопки быстрого выбора суммы пополнения."""
return self.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not self.DISABLE_TOPUP_BUTTONS
def get_available_languages(self) -> list[str]:
defaults = ['ru', 'en', 'ua', 'zh', 'fa']
@@ -1850,6 +1872,36 @@ class Settings(BaseSettings):
def get_riopay_display_name_html(self) -> str:
return html.escape(self.get_riopay_display_name())
def is_severpay_enabled(self) -> bool:
return self.SEVERPAY_ENABLED and self.SEVERPAY_MID is not None and self.SEVERPAY_TOKEN is not None
def get_severpay_display_name(self) -> str:
name = (self.SEVERPAY_DISPLAY_NAME or '').strip()
return name if name else 'SeverPay'
def get_severpay_display_name_html(self) -> str:
return html.escape(self.get_severpay_display_name())
def is_kassa_ai_sbp_enabled(self) -> bool:
return self.KASSA_AI_SBP_ENABLED and self.is_kassa_ai_enabled()
def get_kassa_ai_sbp_display_name(self) -> str:
name = (self.KASSA_AI_SBP_DISPLAY_NAME or '').strip()
return name if name else 'СБП (KassaAI)'
def get_kassa_ai_sbp_display_name_html(self) -> str:
return html.escape(self.get_kassa_ai_sbp_display_name())
def is_kassa_ai_card_enabled(self) -> bool:
return self.KASSA_AI_CARD_ENABLED and self.is_kassa_ai_enabled()
def get_kassa_ai_card_display_name(self) -> str:
name = (self.KASSA_AI_CARD_DISPLAY_NAME or '').strip()
return name if name else 'Карта (KassaAI)'
def get_kassa_ai_card_display_name_html(self) -> str:
return html.escape(self.get_kassa_ai_card_display_name())
def is_payment_verification_auto_check_enabled(self) -> bool:
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
+2 -1
View File
@@ -366,7 +366,8 @@ async def get_campaign_statistics(
first_payment_amount_by_user[user_id] = amount_value
first_payment_time_by_user[user_id] = created_at
total_revenue = deposits_total + subscription_payments_total
# Revenue = only real deposits (exclude bonus-funded subscription spending)
total_revenue = deposits_total
paid_user_ids = set(paid_users_from_transactions)
paid_user_ids.update(conversion_user_ids)
+1 -1
View File
@@ -15,7 +15,7 @@ logger = structlog.get_logger(__name__)
async def create_riopay_payment(
db: AsyncSession,
*,
user_id: int,
user_id: int | None,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
+17 -3
View File
@@ -141,8 +141,12 @@ async def get_available_server_squads(
.order_by(ServerSquad.sort_order, ServerSquad.display_name)
)
if exclude_trial_only:
query = query.where(ServerSquad.is_trial_eligible.is_(False))
# НЕ фильтруем по is_trial_eligible — это поле означает "доступен для триала",
# а НЕ "только для триала". Сквад может быть одновременно триальным и платным.
# Фильтр exclude_trial_only убирал единственный доступный сквад, из-за чего
# пользователи без триала получали пустой connected_squads при покупке.
# Параметр exclude_trial_only сохранён для обратной совместимости, но не используется.
# TODO: если нужна логика "только для триала", добавить отдельное поле is_trial_only
if promo_group_id is not None:
query = query.join(ServerSquad.allowed_promo_groups).where(PromoGroup.id == promo_group_id)
@@ -313,7 +317,17 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
)
created += 1
removed_servers = [server for uuid, server in existing_servers.items() if uuid not in remnawave_uuids]
# Protect external squads referenced by tariffs from being removed during sync
tariff_ext_uuids_result = await db.execute(
select(Tariff.external_squad_uuid).where(Tariff.external_squad_uuid.isnot(None))
)
protected_uuids = {row[0] for row in tariff_ext_uuids_result.fetchall()}
removed_servers = [
server
for uuid, server in existing_servers.items()
if uuid not in remnawave_uuids and uuid not in protected_uuids
]
if removed_servers:
removed_ids = [server.id for server in removed_servers]
+157
View File
@@ -0,0 +1,157 @@
"""CRUD операции для платежей SeverPay."""
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import SeverPayPayment
logger = structlog.get_logger(__name__)
async def create_severpay_payment(
db: AsyncSession,
*,
user_id: int | None,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
payment_method: str | None = None,
severpay_id: str | None = None,
severpay_uid: str | None = None,
expires_at: datetime | None = None,
metadata_json: dict | None = None,
) -> SeverPayPayment:
"""Создает запись о платеже SeverPay."""
payment = SeverPayPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method,
severpay_id=severpay_id,
severpay_uid=severpay_uid,
expires_at=expires_at,
metadata_json=metadata_json,
status='pending',
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info('Создан платеж SeverPay', order_id=order_id, user_id=user_id)
return payment
async def get_severpay_payment_by_order_id(db: AsyncSession, order_id: str) -> SeverPayPayment | None:
"""Получает платеж по order_id (internal)."""
result = await db.execute(select(SeverPayPayment).where(SeverPayPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_severpay_payment_by_severpay_id(db: AsyncSession, severpay_id: str) -> SeverPayPayment | None:
"""Получает платеж по ID от SeverPay."""
result = await db.execute(select(SeverPayPayment).where(SeverPayPayment.severpay_id == severpay_id))
return result.scalar_one_or_none()
async def get_severpay_payment_by_id(db: AsyncSession, payment_id: int) -> SeverPayPayment | None:
"""Получает платеж по ID."""
result = await db.execute(select(SeverPayPayment).where(SeverPayPayment.id == payment_id))
return result.scalar_one_or_none()
async def get_severpay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> SeverPayPayment | None:
"""Получает платеж по ID с блокировкой FOR UPDATE."""
result = await db.execute(select(SeverPayPayment).where(SeverPayPayment.id == payment_id).with_for_update())
return result.scalar_one_or_none()
async def update_severpay_payment_status(
db: AsyncSession,
payment: SeverPayPayment,
*,
status: str,
is_paid: bool | None = None,
severpay_id: str | None = None,
severpay_uid: str | None = None,
payment_method: str | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> SeverPayPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.updated_at = datetime.now(UTC)
if is_paid is not None:
payment.is_paid = is_paid
if is_paid:
payment.paid_at = datetime.now(UTC)
if severpay_id is not None:
payment.severpay_id = severpay_id
if severpay_uid is not None:
payment.severpay_uid = severpay_uid
if payment_method is not None:
payment.payment_method = payment_method
if callback_payload is not None:
payment.callback_payload = callback_payload
if transaction_id is not None:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(
'Обновлен статус платежа SeverPay',
order_id=payment.order_id,
status=status,
is_paid=payment.is_paid,
)
return payment
async def get_pending_severpay_payments(db: AsyncSession, user_id: int) -> list[SeverPayPayment]:
"""Получает незавершенные платежи пользователя."""
result = await db.execute(
select(SeverPayPayment).where(
SeverPayPayment.user_id == user_id,
SeverPayPayment.status == 'pending',
SeverPayPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_expired_pending_severpay_payments(
db: AsyncSession,
) -> list[SeverPayPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.now(UTC)
result = await db.execute(
select(SeverPayPayment).where(
SeverPayPayment.status == 'pending',
SeverPayPayment.is_paid == False,
SeverPayPayment.expires_at < now,
)
)
return list(result.scalars().all())
async def link_severpay_payment_to_transaction(
db: AsyncSession,
*,
payment: SeverPayPayment,
transaction_id: int,
) -> SeverPayPayment:
"""Связывает платеж с транзакцией."""
payment.transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(payment)
return payment
+61 -230
View File
@@ -1,6 +1,5 @@
from collections.abc import Iterable
from datetime import UTC, datetime, timedelta
from typing import Optional
import structlog
from sqlalchemy import and_, delete, func, select
@@ -11,7 +10,6 @@ from sqlalchemy.orm.exc import StaleDataError
from app.config import settings
from app.database.crud.notification import clear_notifications
from app.database.models import (
PromoGroup,
Subscription,
SubscriptionServer,
SubscriptionStatus,
@@ -20,7 +18,6 @@ from app.database.models import (
User,
UserStatus,
)
from app.utils.pricing_utils import calculate_months_from_days
from app.utils.timezone import format_local_datetime
@@ -43,23 +40,18 @@ def calc_device_limit_on_tariff_switch(
new_tariff_device_limit: int | None,
max_device_limit: int | None = None,
) -> int:
"""Calculate device_limit preserving extra purchased devices when switching tariffs.
"""Calculate device_limit when switching tariffs.
Extra devices = current_device_limit - old_tariff_device_limit (clamped to 0).
Result = new_tariff_device_limit + extra_devices, capped at max_device_limit.
Resets to new tariff base device limit previously purchased
extra devices are NOT carried over. Capped at max_device_limit.
"""
old_base = old_tariff_device_limit if old_tariff_device_limit is not None else 0
current = current_device_limit if current_device_limit is not None else old_base
extra = max(0, current - old_base)
new_base = new_tariff_device_limit if new_tariff_device_limit is not None else 1
total = new_base + extra
effective_max = max_device_limit or (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
if effective_max and total > effective_max:
total = effective_max
if effective_max and new_base > effective_max:
new_base = effective_max
return total
return new_base
def is_active_paid_subscription(subscription: Subscription | None) -> bool:
@@ -221,6 +213,23 @@ async def create_paid_subscription(
if device_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
# Fallback: если connected_squads пустой — берём первый доступный сквад
final_squads = list(connected_squads or [])
if not final_squads:
try:
from app.database.crud.server_squad import get_available_server_squads
available = await get_available_server_squads(db)
if available:
final_squads = [available[0].squad_uuid]
logger.warning(
'⚠️ connected_squads пустой при создании подписки, используем fallback сквад',
user_id=user_id,
fallback_squad=final_squads[0],
)
except Exception as error:
logger.error('❌ Не удалось получить fallback сквад', user_id=user_id, error=error)
subscription = Subscription(
user_id=user_id,
status=SubscriptionStatus.ACTIVE.value,
@@ -229,7 +238,7 @@ async def create_paid_subscription(
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=connected_squads or [],
connected_squads=final_squads,
autopay_enabled=settings.is_autopay_enabled_by_default(),
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
tariff_id=tariff_id,
@@ -249,7 +258,7 @@ async def create_paid_subscription(
status=subscription.status,
)
squad_uuids = list(connected_squads or [])
squad_uuids = list(final_squads)
if update_server_counters and squad_uuids:
try:
from app.database.crud.server_squad import (
@@ -299,7 +308,25 @@ async def replace_subscription(
current_time = datetime.now(UTC)
old_squads = set(subscription.connected_squads or [])
new_squads = set(connected_squads or [])
# Fallback: если connected_squads пустой — берём первый доступный сквад
final_connected = list(connected_squads or [])
if not final_connected:
try:
from app.database.crud.server_squad import get_available_server_squads
available = await get_available_server_squads(db)
if available:
final_connected = [available[0].squad_uuid]
logger.warning(
'⚠️ connected_squads пустой при замене подписки, используем fallback сквад',
subscription_id=subscription.id,
fallback_squad=final_connected[0],
)
except Exception as error:
logger.error('❌ Не удалось получить fallback сквад', subscription_id=subscription.id, error=error)
new_squads = set(final_connected)
new_autopay_enabled = subscription.autopay_enabled if autopay_enabled is None else autopay_enabled
new_autopay_days_before = subscription.autopay_days_before if autopay_days_before is None else autopay_days_before
@@ -549,9 +576,17 @@ async def extend_subscription(
logger.info('📱 Обновлен лимит устройств: →', old_devices=old_devices, device_limit=device_limit)
if connected_squads is not None:
old_squads = subscription.connected_squads
subscription.connected_squads = connected_squads
logger.info('🌍 Обновлены сквады: →', old_squads=old_squads, connected_squads=connected_squads)
# Не перезаписываем существующие сквады пустым списком
if connected_squads or not subscription.connected_squads:
old_squads = subscription.connected_squads
subscription.connected_squads = connected_squads
logger.info('🌍 Обновлены сквады: →', old_squads=old_squads, connected_squads=connected_squads)
else:
logger.warning(
'⚠️ Попытка перезаписать сквады пустым списком, сохраняем текущие',
subscription_id=subscription.id,
current_squads=subscription.connected_squads,
)
# Обработка daily полей при смене тарифа
if is_tariff_change and tariff_id is not None:
@@ -1198,212 +1233,6 @@ async def add_subscription_servers(
return subscription
async def get_server_monthly_price(db: AsyncSession, server_squad_id: int) -> int:
from app.database.models import ServerSquad
result = await db.execute(select(ServerSquad.price_kopeks).where(ServerSquad.id == server_squad_id))
return result.scalar() or 0
async def get_servers_monthly_prices(
db: AsyncSession,
server_squad_ids: list[int],
*,
user: Optional['User'] = None,
) -> list[int]:
"""Получает месячные цены серверов с проверкой доступности для промогруппы пользователя."""
from sqlalchemy.orm import selectinload
from app.database.models import ServerSquad
prices = []
# Загружаем промогруппы пользователя если нужно
user_promo_group = None
user_promo_group_id = None
if user:
try:
# Пробуем загрузить промогруппы если ещё не загружены
await db.refresh(user, ['user_promo_groups', 'promo_group'])
except Exception:
pass
try:
user_promo_group = user.get_primary_promo_group()
user_promo_group_id = user_promo_group.id if user_promo_group else None
except Exception as e:
logger.warning('Не удалось получить промогруппу пользователя', error=e)
for server_id in server_squad_ids:
# Загружаем сервер с промогруппами
result = await db.execute(
select(ServerSquad)
.options(selectinload(ServerSquad.allowed_promo_groups))
.where(ServerSquad.id == server_id)
)
server = result.scalar_one_or_none()
if not server:
prices.append(0)
continue
# Проверяем доступность сервера для промогруппы пользователя
is_allowed = True
if user_promo_group_id is not None and server.allowed_promo_groups:
allowed_ids = {pg.id for pg in server.allowed_promo_groups}
is_allowed = user_promo_group_id in allowed_ids
if server.is_available and is_allowed:
prices.append(server.price_kopeks)
else:
# Сервер недоступен для промогруппы пользователя
logger.warning(
'⚠️ Сервер (id=) недоступен для промогруппы пользователя (promo_group_id=), allowed_promo_groups',
display_name=server.display_name,
server_id=server_id,
user_promo_group_id=user_promo_group_id,
value=[pg.id for pg in server.allowed_promo_groups] if server.allowed_promo_groups else [],
)
prices.append(server.price_kopeks) # Всё равно берём реальную цену
return prices
def _get_discount_percent(
user: User | None,
promo_group: PromoGroup | None,
category: str,
*,
period_days: int | None = None,
) -> int:
if user is not None:
try:
return user.get_promo_discount(category, period_days)
except AttributeError:
pass
if promo_group is not None:
return promo_group.get_discount_percent(category, period_days)
return 0
async def calculate_subscription_total_cost(
db: AsyncSession,
period_days: int,
traffic_gb: int,
server_squad_ids: list[int],
devices: int,
*,
user: User | None = None,
promo_group: PromoGroup | None = None,
) -> tuple[int, dict]:
from app.config import PERIOD_PRICES
months_in_period = calculate_months_from_days(period_days)
base_price_original = PERIOD_PRICES.get(period_days, 0)
period_discount_percent = _get_discount_percent(
user,
promo_group,
'period',
period_days=period_days,
)
base_discount_total = base_price_original * period_discount_percent // 100
base_price = base_price_original - base_discount_total
promo_group = promo_group or (user.promo_group if user else None)
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
traffic_discount_percent = _get_discount_percent(
user,
promo_group,
'traffic',
period_days=period_days,
)
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
total_traffic_price = discounted_traffic_per_month * months_in_period
total_traffic_discount = traffic_discount_per_month * months_in_period
servers_prices = await get_servers_monthly_prices(db, server_squad_ids, user=user)
servers_price_per_month = sum(servers_prices)
servers_discount_percent = _get_discount_percent(
user,
promo_group,
'servers',
period_days=period_days,
)
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
total_servers_price = discounted_servers_per_month * months_in_period
total_servers_discount = servers_discount_per_month * months_in_period
additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_discount_percent = _get_discount_percent(
user,
promo_group,
'devices',
period_days=period_days,
)
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
total_devices_price = discounted_devices_per_month * months_in_period
total_devices_discount = devices_discount_per_month * months_in_period
total_cost = base_price + total_traffic_price + total_servers_price + total_devices_price
details = {
'base_price': base_price,
'base_price_original': base_price_original,
'base_discount_percent': period_discount_percent,
'base_discount_total': base_discount_total,
'traffic_price_per_month': traffic_price_per_month,
'traffic_discount_percent': traffic_discount_percent,
'traffic_discount_total': total_traffic_discount,
'total_traffic_price': total_traffic_price,
'servers_price_per_month': servers_price_per_month,
'servers_discount_percent': servers_discount_percent,
'servers_discount_total': total_servers_discount,
'total_servers_price': total_servers_price,
'devices_price_per_month': devices_price_per_month,
'devices_discount_percent': devices_discount_percent,
'devices_discount_total': total_devices_discount,
'total_devices_price': total_devices_price,
'months_in_period': months_in_period,
'servers_individual_prices': [
(price - (price * servers_discount_percent // 100)) * months_in_period for price in servers_prices
],
}
logger.debug(
'📊 Расчет стоимости подписки на дней ( мес)', period_days=period_days, months_in_period=months_in_period
)
logger.debug('Базовый период: ₽', base_price=base_price / 100)
if total_traffic_price > 0:
message = f' Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period} = {total_traffic_price / 100}'
if total_traffic_discount > 0:
message += f' (скидка {traffic_discount_percent}%: -{total_traffic_discount / 100}₽)'
logger.debug(message)
if total_servers_price > 0:
message = (
f' Серверы: {servers_price_per_month / 100}₽/мес × {months_in_period} = {total_servers_price / 100}'
)
if total_servers_discount > 0:
message += f' (скидка {servers_discount_percent}%: -{total_servers_discount / 100}₽)'
logger.debug(message)
if total_devices_price > 0:
message = (
f' Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period} = {total_devices_price / 100}'
)
if total_devices_discount > 0:
message += f' (скидка {devices_discount_percent}%: -{total_devices_discount / 100}₽)'
logger.debug(message)
logger.debug('ИТОГО: ₽', total_cost=total_cost / 100)
return total_cost, details
async def get_subscription_server_ids(db: AsyncSession, subscription_id: int) -> list[int]:
result = await db.execute(
select(SubscriptionServer.server_squad_id).where(SubscriptionServer.subscription_id == subscription_id)
@@ -1901,8 +1730,9 @@ async def get_disabled_daily_subscriptions_for_resume(
# Не возобновляем подписки, приостановленные пользователем вручную
# is_(False) не ловит NULL, поэтому добавляем OR is_(None)
(Subscription.is_daily_paused.is_(False) | Subscription.is_daily_paused.is_(None)),
# Баланс пользователя >= суточной цены тарифа
User.balance_kopeks >= Tariff.daily_price_kopeks,
# Баланс пользователя > 0 (permissive pre-filter;
# actual discounted price check happens in _process_single_charge)
User.balance_kopeks > 0,
)
)
)
@@ -1947,8 +1777,9 @@ async def get_expired_daily_subscriptions_for_recovery(db: AsyncSession) -> list
Subscription.is_trial.is_(False),
# Только недавно экспайренные
Subscription.updated_at >= recovery_threshold,
# Баланс достаточен для списания
User.balance_kopeks >= Tariff.daily_price_kopeks,
# Баланс > 0 (permissive pre-filter;
# actual discounted price check happens in _process_single_charge)
User.balance_kopeks > 0,
)
)
)
+4 -3
View File
@@ -61,16 +61,17 @@ async def get_conversion_statistics(db: AsyncSession) -> dict:
total_conversions = total_conversions_result.scalar() or 0
# Подсчитываем пользователей с платными подписками
users_with_paid_result = await db.execute(select(func.count(User.id)).where(User.has_had_paid_subscription == True))
users_with_paid_result = await db.execute(
select(func.count(User.id)).where(User.has_had_paid_subscription.is_(True))
)
users_with_paid = users_with_paid_result.scalar() or 0
# Подсчитываем всех пользователей с подписками (использовавших триал)
# Считаем что все новые пользователи начинают с триала
total_users_with_subscriptions_result = await db.execute(select(func.count(func.distinct(Subscription.user_id))))
total_users_with_subscriptions = total_users_with_subscriptions_result.scalar() or 0
# Расчёт конверсии: (оплатившие) / (всего с подписками) * 100
# Это показывает какой % пользователей, получивших подписку, в итоге оплатили
# Знаменатель = все юзеры с подписками (включая уже конвертированных)
if total_users_with_subscriptions > 0:
conversion_rate = round((users_with_paid / total_users_with_subscriptions) * 100, 1)
else:
+2
View File
@@ -25,6 +25,8 @@ REAL_PAYMENT_METHODS = [
PaymentMethod.CLOUDPAYMENTS.value,
PaymentMethod.FREEKASSA.value,
PaymentMethod.KASSA_AI.value,
PaymentMethod.RIOPAY.value,
PaymentMethod.SEVERPAY.value,
]
+63 -6
View File
@@ -415,9 +415,19 @@ async def lock_user_for_update(db: AsyncSession, user: User) -> User:
Returns the refreshed user object with current DB values.
Must be called within an active transaction before modifying balance_kopeks.
Eagerly loads key relationships to avoid MissingGreenlet in async context.
"""
result = await db.execute(
select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True)
select(User)
.where(User.id == user.id)
.options(
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.promo_group),
selectinload(User.referrer),
)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one()
@@ -434,8 +444,18 @@ async def add_user_balance(
) -> bool:
try:
# Lock the user row to prevent concurrent balance race conditions
# Eagerly load key relationships to avoid MissingGreenlet in async context
locked_result = await db.execute(
select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True)
select(User)
.where(User.id == user.id)
.options(
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.promo_group),
selectinload(User.referrer),
)
.with_for_update()
.execution_options(populate_existing=True)
)
user = locked_result.scalar_one()
@@ -508,6 +528,27 @@ async def add_user_balance_by_id(
return False
async def lock_user_for_pricing(db: AsyncSession, user_id: int) -> User:
"""Lock user row with FOR UPDATE and return refreshed instance.
Call BEFORE computing prices that depend on promo offer state
to prevent TOCTOU race conditions where two concurrent requests
both read the same promo offer discount and charge a discounted price.
"""
result = await db.execute(
select(User)
.where(User.id == user_id)
.options(
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.promo_group),
selectinload(User.subscription).selectinload(Subscription.tariff),
)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one()
async def subtract_user_balance(
db: AsyncSession,
user: User,
@@ -534,8 +575,18 @@ async def subtract_user_balance(
)
# Lock the user row to prevent concurrent balance race conditions
# Eagerly load key relationships to avoid MissingGreenlet in async context
locked_result = await db.execute(
select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True)
select(User)
.where(User.id == user.id)
.options(
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.promo_group),
selectinload(User.referrer),
)
.with_for_update()
.execution_options(populate_existing=True)
)
user = locked_result.scalar_one()
@@ -1161,8 +1212,11 @@ async def create_user_by_email(
async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
"""Get user by email address."""
result = await db.execute(select(User).where(User.email == email))
"""Get user by email address (case-insensitive)."""
if not email or not email.strip():
return None
email_lower = email.strip().lower()
result = await db.execute(select(User).where(func.lower(User.email) == email_lower))
return result.scalar_one_or_none()
@@ -1178,7 +1232,10 @@ async def is_email_taken(db: AsyncSession, email: str, exclude_user_id: int | No
Returns:
True if email is taken, False otherwise
"""
query = select(User.id).where(User.email == email)
if not email or not email.strip():
return False
email_lower = email.strip().lower()
query = select(User.id).where(func.lower(User.email) == email_lower)
if exclude_user_id:
query = query.where(User.id != exclude_user_id)
result = await db.execute(query)
+65 -1
View File
@@ -159,6 +159,7 @@ class PaymentMethod(Enum):
FREEKASSA = 'freekassa'
KASSA_AI = 'kassa_ai'
RIOPAY = 'riopay'
SEVERPAY = 'severpay'
MANUAL = 'manual'
BALANCE = 'balance'
@@ -756,7 +757,7 @@ class RioPayPayment(Base):
__tablename__ = 'riopay_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
# Идентификаторы
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
@@ -812,6 +813,69 @@ class RioPayPayment(Base):
return f'<RioPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class SeverPayPayment(Base):
"""Платежи через SeverPay (severpay.io)."""
__tablename__ = 'severpay_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
# Идентификаторы
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
severpay_id = Column(String(64), unique=True, nullable=True, index=True) # ID от SeverPay
severpay_uid = Column(String(64), unique=True, nullable=True, index=True) # UID от SeverPay
# Суммы
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default='RUB')
description = Column(Text, nullable=True)
# Статусы
status = Column(String(32), nullable=False, default='pending')
is_paid = Column(Boolean, default=False)
# Данные платежа
payment_url = Column(Text, nullable=True)
payment_method = Column(String(32), nullable=True)
# Метаданные
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
# Временные метки
paid_at = Column(AwareDateTime(), nullable=True)
expires_at = Column(AwareDateTime(), nullable=True)
created_at = Column(AwareDateTime(), default=func.now())
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
# Связь с транзакцией
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
# Relationships
user = relationship('User', backref='severpay_payments')
transaction = relationship('Transaction', backref='severpay_payment')
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
@property
def is_pending(self) -> bool:
return self.status == 'pending'
@property
def is_success(self) -> bool:
return self.status == 'success' and self.is_paid
@property
def is_failed(self) -> bool:
return self.status in ['failed', 'expired', 'declined', 'amount_mismatch']
def __repr__(self) -> str: # pragma: no cover - debug helper
return f'<SeverPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class PromoGroup(Base):
__tablename__ = 'promo_groups'
+35 -11
View File
@@ -1,5 +1,6 @@
import hashlib
import hmac
import json
from typing import Any
import aiohttp
@@ -15,7 +16,6 @@ class CryptoBotService:
def __init__(self):
self.api_token = settings.CRYPTOBOT_API_TOKEN
self.base_url = settings.get_cryptobot_base_url()
self.webhook_secret = settings.CRYPTOBOT_WEBHOOK_SECRET
async def _make_request(
self,
@@ -122,22 +122,46 @@ class CryptoBotService:
return await self._make_request('GET', 'getExchangeRates')
def verify_webhook_signature(self, body: str, signature: str) -> bool:
if not self.webhook_secret:
logger.warning('CryptoBot webhook secret не настроен')
# По документации CryptoBot, ключ ВСЕГДА SHA256 от API токена
token = self.api_token
if not token:
logger.warning('CryptoBot API token не настроен, пропуск проверки подписи')
return True
try:
secret_hash = hashlib.sha256(self.webhook_secret.encode()).digest()
expected_signature = hmac.new(secret_hash, body.encode(), hashlib.sha256).hexdigest()
secret_hash = hashlib.sha256(token.encode()).digest()
is_valid = hmac.compare_digest(signature, expected_signature)
# 1. Raw body — CryptoBot шлёт compact JSON
expected = hmac.new(secret_hash, body.encode('utf-8'), hashlib.sha256).hexdigest()
if hmac.compare_digest(signature, expected):
logger.info('CryptoBot webhook подпись валидна (raw body)')
return True
if is_valid:
logger.info('✅ CryptoBot webhook подпись валидна')
else:
logger.error('❌ Неверная подпись CryptoBot webhook')
# 2. Fallback: re-serialize compact JSON
parsed = json.loads(body)
check_string = json.dumps(parsed, separators=(',', ':'), ensure_ascii=False)
expected_reserialized = hmac.new(secret_hash, check_string.encode('utf-8'), hashlib.sha256).hexdigest()
if hmac.compare_digest(signature, expected_reserialized):
logger.info('CryptoBot webhook подпись валидна (re-serialized)')
return True
return is_valid
# 3. Fallback: ensure_ascii=True
check_string_ascii = json.dumps(parsed, separators=(',', ':'), ensure_ascii=True)
expected_ascii = hmac.new(secret_hash, check_string_ascii.encode('utf-8'), hashlib.sha256).hexdigest()
if hmac.compare_digest(signature, expected_ascii):
logger.info('CryptoBot webhook подпись валидна (ascii-escaped)')
return True
logger.error(
'Неверная подпись CryptoBot webhook',
received_signature=signature,
expected_raw=expected,
expected_reserialized=expected_reserialized,
body_length=len(body),
token_length=len(token),
token_prefix=token[:4] + '...',
)
return False
except Exception as e:
logger.error('Ошибка проверки подписи CryptoBot webhook', error=e)
+12 -19
View File
@@ -280,12 +280,6 @@ class RemnaWaveAPI:
'X-Real-IP': '127.0.0.1',
}
# Caddy авторизация — добавляется поверх основной
if self.caddy_token:
# Caddy Security: готовый base64 токен используется как есть
headers['Authorization'] = f'Basic {self.caddy_token}'
logger.debug('Используем Caddy Basic Auth')
# Основная авторизация RemnaWave API
if self.auth_type == 'basic' and self.username and self.password:
credentials = f'{self.username}:{self.password}'
@@ -293,16 +287,16 @@ class RemnaWaveAPI:
headers['X-Api-Key'] = f'Basic {encoded_credentials}'
logger.debug('Используем Basic Auth в X-Api-Key заголовке')
elif self.auth_type == 'caddy':
# Для caddy auth_type основная авторизация уже в Authorization header
# Но API ключ всё равно нужен для RemnaWave
# Caddy Security: caddy_token → X-Api-Key, api_key → Authorization: Bearer
if self.api_key:
headers['X-Api-Key'] = self.api_key
logger.debug('Используем API ключ для RemnaWave + Caddy авторизацию')
headers['Authorization'] = f'Bearer {self.api_key}'
if self.caddy_token:
headers['X-Api-Key'] = self.caddy_token
logger.debug('Используем Caddy авторизацию')
else:
# api_key или bearer — стандартный режим
headers['X-Api-Key'] = self.api_key
if not self.caddy_token:
headers['Authorization'] = f'Bearer {self.api_key}'
headers['Authorization'] = f'Bearer {self.api_key}'
logger.debug('Используем API ключ в X-Api-Key заголовке')
return headers
@@ -574,13 +568,12 @@ class RemnaWaveAPI:
if external_squad_uuid is not ...:
data['externalSquadUuid'] = external_squad_uuid
logger.info(
'PATCH /api/users payload',
uuid=uuid,
hwidDeviceLimit=data.get('hwidDeviceLimit'),
status=data.get('status'),
)
response = await self._make_request('PATCH', '/api/users', data)
try:
response = await self._make_request('PATCH', '/api/users', data)
except Exception:
# Логируем полный payload при ошибке для диагностики A039
logger.error('PATCH /api/users FAILED — full payload', payload=data)
raise
user = self._parse_user(response['response'])
logger.info(
'PATCH /api/users response',
+13 -4
View File
@@ -72,18 +72,21 @@ class TributeService:
status = None
amount_kopeks = 0
telegram_user_id = None
trb_user_id = None
payment_id = webhook_data.get('id') or webhook_data.get('payment_id')
status = webhook_data.get('status')
amount_kopeks = webhook_data.get('amount', 0)
telegram_user_id = webhook_data.get('telegram_user_id') or webhook_data.get('user_id')
telegram_user_id = webhook_data.get('telegram_user_id')
trb_user_id = webhook_data.get('trb_user_id')
if not payment_id and 'payload' in webhook_data:
data = webhook_data['payload']
payment_id = data.get('id') or data.get('payment_id')
status = data.get('status')
amount_kopeks = data.get('amount', 0)
telegram_user_id = data.get('telegram_user_id') or data.get('user_id')
telegram_user_id = data.get('telegram_user_id')
trb_user_id = data.get('trb_user_id')
if not payment_id and 'name' in webhook_data:
event_name = webhook_data.get('name')
@@ -91,6 +94,7 @@ class TributeService:
payment_id = str(data.get('donation_request_id'))
amount_kopeks = data.get('amount', 0)
telegram_user_id = data.get('telegram_user_id')
trb_user_id = data.get('trb_user_id')
if event_name in ('new_donation', 'recurrent_donation'):
status = 'paid'
@@ -100,15 +104,19 @@ class TributeService:
status = 'unknown'
logger.info(
'📝 Извлеченные данные: payment_id=, status=, amount_kopeks=, user_id',
'📝 Извлеченные данные: payment_id=, status=, amount_kopeks=, telegram_user_id=, trb_user_id=',
payment_id=payment_id,
status=status,
amount_kopeks=amount_kopeks,
telegram_user_id=telegram_user_id,
trb_user_id=trb_user_id,
)
if not telegram_user_id:
logger.error('❌ Не найден telegram_user_id в webhook данных')
logger.error(
'❌ Не найден telegram_user_id в webhook данных',
trb_user_id=trb_user_id,
)
logger.error(
'🔍 Полные данные для отладки', dumps=json.dumps(webhook_data, ensure_ascii=False, indent=2)
)
@@ -124,6 +132,7 @@ class TributeService:
'event_type': 'payment',
'payment_id': payment_id or f'tribute_{telegram_user_id}_{amount_kopeks}',
'user_id': telegram_user_id,
'trb_user_id': trb_user_id,
'amount_kopeks': int(amount_kopeks) if amount_kopeks else 0,
'status': status or 'paid',
'external_id': f'donation_{payment_id or "unknown"}',
+4 -1
View File
@@ -377,7 +377,10 @@ class WebhookServer:
signature = request.headers.get('Crypto-Pay-API-Signature')
logger.info('CryptoBot Signature', signature=signature)
if signature and settings.CRYPTOBOT_WEBHOOK_SECRET:
if settings.CRYPTOBOT_API_TOKEN:
if not signature:
logger.error('CryptoBot webhook без подписи')
return web.json_response({'status': 'error', 'reason': 'missing_signature'}, status=401)
from app.external.cryptobot import CryptoBotService
cryptobot_service = CryptoBotService()
+5 -1
View File
@@ -63,7 +63,7 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
},
'payments': {
'title': '💳 Платежные системы',
'description': 'YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute, Kassa AI, RioPay и Telegram Stars.',
'description': 'YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute, Kassa AI, RioPay, SeverPay и Telegram Stars.',
'icon': '💳',
'categories': (
'PAYMENT',
@@ -75,6 +75,7 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
'FREEKASSA',
'KASSA_AI',
'RIOPAY',
'SEVERPAY',
'MULENPAY',
'PAL24',
'WATA',
@@ -1256,6 +1257,9 @@ def _build_settings_keyboard(
elif category_key == 'RIOPAY':
label = texts.t('PAYMENT_RIOPAY', f'💳 {settings.get_riopay_display_name()}')
test_payment_buttons.append([_test_button(f'{label} · тест', 'riopay')])
elif category_key == 'SEVERPAY':
label = texts.t('PAYMENT_SEVERPAY', f'💳 {settings.get_severpay_display_name()}')
test_payment_buttons.append([_test_button(f'{label} · тест', 'severpay')])
if test_payment_buttons:
rows.extend(test_payment_buttons)
+42 -14
View File
@@ -1103,13 +1103,27 @@ async def confirm_button_selection(callback: types.CallbackQuery, db_user: User,
await callback.message.delete()
except Exception:
pass
await callback.bot.send_photo(
chat_id=callback.message.chat.id,
photo=media_file_id,
caption=preview_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode='HTML',
)
# Telegram ограничивает caption до 1024 символов
if len(preview_text) <= 1024:
await callback.bot.send_photo(
chat_id=callback.message.chat.id,
photo=media_file_id,
caption=preview_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode='HTML',
)
else:
# Фото без caption + текст отдельным сообщением
await callback.bot.send_photo(
chat_id=callback.message.chat.id,
photo=media_file_id,
)
await callback.bot.send_message(
chat_id=callback.message.chat.id,
text=preview_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode='HTML',
)
else:
# Если нет file_id, используем safe редактирование
await safe_edit_or_send_text(
@@ -1244,13 +1258,27 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
'video': 'video',
'document': 'document',
}[media_type]
await send_method(
chat_id=telegram_id,
**{media_kwarg: media_file_id},
caption=message_text,
parse_mode='HTML',
reply_markup=broadcast_keyboard,
)
# Telegram ограничивает caption до 1024 символов
if len(message_text) <= 1024:
await send_method(
chat_id=telegram_id,
**{media_kwarg: media_file_id},
caption=message_text,
parse_mode='HTML',
reply_markup=broadcast_keyboard,
)
else:
# Медиа без caption + текст отдельным сообщением
await send_method(
chat_id=telegram_id,
**{media_kwarg: media_file_id},
)
await callback.bot.send_message(
chat_id=telegram_id,
text=message_text,
parse_mode='HTML',
reply_markup=broadcast_keyboard,
)
else:
# Неизвестный media_type — отправляем как текст
await callback.bot.send_message(
+51 -6
View File
@@ -4457,6 +4457,11 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
subscription_service = SubscriptionService()
# TOCTOU protection: lock user row before pricing to prevent concurrent balance modifications
from app.database.crud.user import lock_user_for_pricing
target_user = await lock_user_for_pricing(db, target_user.id)
try:
price_kopeks = await _calculate_subscription_period_price(
db,
@@ -4585,11 +4590,10 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
if hwid_limit is not None:
update_kwargs['hwid_device_limit'] = hwid_limit
# Внешний сквад: синхронизируем из тарифа или сбрасываем
# Внешний сквад: синхронизируем из тарифа (если задан)
# Не отправляем null — RemnaWave API не принимает null для externalSquadUuid (A039)
if ext_squad_uuid is not None:
update_kwargs['external_squad_uuid'] = ext_squad_uuid
else:
update_kwargs['external_squad_uuid'] = None
remnawave_user = await api.update_user(**update_kwargs)
else:
@@ -4914,7 +4918,7 @@ async def admin_buy_tariff_execute(callback: types.CallbackQuery, db_user: User,
user_id = int(parts[4])
tariff_id = int(parts[5])
period = int(parts[6])
price_kopeks = int(parts[7])
price_kopeks_from_callback = int(parts[7])
user_service = UserService()
profile = await user_service.get_user_profile(db, user_id)
@@ -4933,7 +4937,48 @@ async def admin_buy_tariff_execute(callback: types.CallbackQuery, db_user: User,
await callback.answer('❌ Тариф недоступен', show_alert=True)
return
# Проверяем баланс ещё раз
# TOCTOU protection: lock user row before pricing to prevent concurrent balance modifications
from app.database.crud.user import lock_user_for_pricing
target_user = await lock_user_for_pricing(db, target_user.id)
from app.database.crud.subscription import get_subscription_by_user_id
existing_subscription = await get_subscription_by_user_id(db, target_user.id)
# Recalculate price from locked state (callback data may be stale)
from app.services.pricing_engine import PricingEngine
pricing_engine = PricingEngine()
device_limit = None
if existing_subscription and existing_subscription.tariff_id == tariff_id:
device_limit = existing_subscription.device_limit
try:
result = await pricing_engine.calculate_tariff_purchase_price(
tariff,
period,
device_limit=device_limit,
user=target_user,
)
price_kopeks = result.final_total
except Exception as e:
logger.error(
'Ошибка расчёта стоимости тарифа при списании средств админом для пользователя',
telegram_id=target_user.telegram_id,
e=e,
)
await callback.answer('❌ Не удалось рассчитать стоимость тарифа', show_alert=True)
return
if price_kopeks_from_callback != price_kopeks:
logger.info(
'Стоимость тарифа для пользователя изменилась перед списанием',
telegram_id=target_user.telegram_id,
price_kopeks_from_callback=price_kopeks_from_callback,
price_kopeks=price_kopeks,
)
if target_user.balance_kopeks < price_kopeks:
await callback.answer('❌ Недостаточно средств на балансе', show_alert=True)
return
@@ -4942,7 +4987,6 @@ async def admin_buy_tariff_execute(callback: types.CallbackQuery, db_user: User,
from app.database.crud.subscription import (
create_paid_subscription,
extend_subscription,
get_subscription_by_user_id,
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
@@ -5373,6 +5417,7 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='смена тарифа (админ)',
sync_squads=True,
)
logger.info(
+7 -123
View File
@@ -129,9 +129,9 @@ async def process_cloudpayments_payment_amount(
state: FSMContext,
):
"""
Process payment amount directly (called from quick_amount handlers).
Process payment amount directly.
Similar to process_heleket_payment_amount and other payment handlers.
Similar to other payment amount handlers.
"""
texts = get_texts(db_user.language)
@@ -167,6 +167,7 @@ async def process_cloudpayments_payment_amount(
'AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount:.0f}',
).format(min_amount=min_rub),
reply_markup=get_back_keyboard(db_user.language),
)
return
@@ -177,6 +178,7 @@ async def process_cloudpayments_payment_amount(
'AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount:,.0f}',
).format(max_amount=max_rub),
reply_markup=get_back_keyboard(db_user.language),
)
return
@@ -195,7 +197,7 @@ async def start_cloudpayments_payment(
"""
Start CloudPayments payment flow.
Shows amount input prompt or quick amount buttons.
Shows amount input prompt.
"""
texts = get_texts(db_user.language)
@@ -290,6 +292,7 @@ async def process_cloudpayments_amount(
'AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount:.0f}',
).format(min_amount=min_rub),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
@@ -301,6 +304,7 @@ async def process_cloudpayments_amount(
'AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount:,.0f}',
).format(max_amount=max_rub),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
@@ -371,123 +375,3 @@ async def process_cloudpayments_amount(
)
logger.info('CloudPayments payment created: user amount=₽', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
@error_handler
async def handle_cloudpayments_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Handle quick amount selection for CloudPayments.
Called when user clicks a predefined amount button.
"""
texts = get_texts(db_user.language)
if not settings.is_cloudpayments_enabled():
await callback.answer(
texts.t('CLOUDPAYMENTS_NOT_AVAILABLE', 'CloudPayments временно недоступен'),
show_alert=True,
)
return
# Extract amount from callback data: topup_amount|cloudpayments|{amount_kopeks}
try:
parts = callback.data.split('|')
if len(parts) >= 3:
amount_kopeks = int(parts[2])
else:
await callback.answer('Invalid callback data', show_alert=True)
return
except (ValueError, IndexError):
await callback.answer('Invalid amount', show_alert=True)
return
amount_rub = amount_kopeks / 100
# Validate amount
if amount_kopeks < settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS:
await callback.answer(
texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'),
show_alert=True,
)
return
if amount_kopeks > settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS:
await callback.answer(
texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'),
show_alert=True,
)
return
await callback.answer()
# Create payment
payment_service = PaymentService()
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
service_name=settings.PAYMENT_SERVICE_NAME,
description=settings.CLOUDPAYMENTS_DESCRIPTION,
)
result = await payment_service.create_cloudpayments_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=description,
telegram_id=db_user.telegram_id,
language=db_user.language,
)
if not result:
await callback.message.edit_text(
texts.t(
'PAYMENT_CREATE_ERROR',
'Не удалось создать платёж. Попробуйте позже.',
),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
payment_url = result.get('payment_url')
# Create keyboard with payment button
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t(
'PAY_BUTTON',
'💳 Оплатить {amount}',
).format(amount=f'{amount_rub:.0f}'),
url=payment_url,
)
],
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '◀️ Назад'),
callback_data='menu_balance',
)
],
]
)
await callback.message.edit_text(
texts.t(
'CLOUDPAYMENTS_PAYMENT_CREATED',
'💳 <b>Оплата банковской картой</b>\n\n'
'Сумма: <b>{amount}₽</b>\n\n'
'Нажмите кнопку ниже для оплаты.\n'
'После успешной оплаты баланс будет пополнен автоматически.',
).format(amount=f'{amount_rub:.2f}'),
reply_markup=keyboard,
parse_mode='HTML',
)
logger.info(
'CloudPayments payment created (quick): user amount=₽', telegram_id=db_user.telegram_id, amount_rub=amount_rub
)
+19 -36
View File
@@ -53,41 +53,18 @@ async def start_cryptobot_payment(callback: types.CallbackQuery, db_user: User,
available_assets = settings.get_cryptobot_assets()
assets_text = ', '.join(available_assets)
# Формируем текст сообщения в зависимости от настройки
if settings.is_quick_amount_buttons_enabled():
message_text = (
f'🪙 <b>Пополнение криптовалютой</b>\n\n'
f'Выберите сумму пополнения или введите вручную сумму '
f'от 100 до 100,000 ₽:\n\n'
f'💰 Доступные активы: {assets_text}\n'
f'⚡ Мгновенное зачисление на баланс\n'
f'🔒 Безопасная оплата через CryptoBot\n\n'
f'{rate_text}\n'
f'Сумма будет автоматически конвертирована в USD для оплаты.'
)
else:
message_text = (
f'🪙 <b>Пополнение криптовалютой</b>\n\n'
f'Введите сумму для пополнения от 100 до 100,000 ₽:\n\n'
f'💰 Доступные активы: {assets_text}\n'
f'⚡ Мгновенное зачисление на баланс\n'
f'🔒 Безопасная оплата через CryptoBot\n\n'
f'{rate_text}\n'
f'Сумма будет автоматически конвертирована в USD для оплаты.'
)
message_text = (
f'🪙 <b>Пополнение криптовалютой</b>\n\n'
f'Введите сумму для пополнения от 100 до 100,000 ₽:\n\n'
f'💰 Доступные активы: {assets_text}\n'
f'⚡ Мгновенное зачисление на баланс\n'
f'🔒 Безопасная оплата через CryptoBot\n\n'
f'{rate_text}\n'
f'Сумма будет автоматически конвертирована в USD для оплаты.'
)
# Создаем клавиатуру
keyboard = get_back_keyboard(db_user.language)
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(message_text, reply_markup=keyboard, parse_mode='HTML')
await state.set_state(BalanceStates.waiting_for_amount)
@@ -133,11 +110,13 @@ async def process_cryptobot_payment_amount(
amount_rubles = amount_kopeks / 100
if amount_rubles < 100:
await message.answer('Минимальная сумма пополнения: 100 ₽')
await message.answer('Минимальная сумма пополнения: 100 ₽', reply_markup=get_back_keyboard(db_user.language))
return
if amount_rubles > 100000:
await message.answer('Максимальная сумма пополнения: 100,000 ₽')
await message.answer(
'Максимальная сумма пополнения: 100,000 ₽', reply_markup=get_back_keyboard(db_user.language)
)
return
try:
@@ -154,11 +133,15 @@ async def process_cryptobot_payment_amount(
amount_usd = round(amount_usd, 2)
if amount_usd < 1:
await message.answer('❌ Минимальная сумма для оплаты в USD: 1.00 USD')
await message.answer(
'❌ Минимальная сумма для оплаты в USD: 1.00 USD', reply_markup=get_back_keyboard(db_user.language)
)
return
if amount_usd > 1000:
await message.answer('❌ Максимальная сумма для оплаты в USD: 1,000 USD')
await message.answer(
'❌ Максимальная сумма для оплаты в USD: 1,000 USD', reply_markup=get_back_keyboard(db_user.language)
)
return
payment_service = PaymentService(message.bot)
+3 -125
View File
@@ -154,7 +154,7 @@ async def process_freekassa_payment_amount(
payment_method: str | None = None,
):
"""
Process payment amount directly (called from quick_amount handlers).
Process payment amount directly.
payment_method: 'freekassa', 'freekassa_sbp', 'freekassa_card'
"""
texts = get_texts(db_user.language)
@@ -186,6 +186,7 @@ async def process_freekassa_payment_amount(
'PAYMENT_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount}',
).format(min_amount=min_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
@@ -196,6 +197,7 @@ async def process_freekassa_payment_amount(
'PAYMENT_AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount}',
).format(max_amount=max_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
@@ -368,127 +370,3 @@ async def process_freekassa_custom_amount(
state=state,
payment_method=data.get('payment_method'),
)
async def _process_freekassa_quick_amount_impl(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
payment_method: str,
):
"""
Process quick amount selection for Freekassa payment.
Called when user clicks a predefined amount button.
payment_method: 'freekassa', 'freekassa_sbp', 'freekassa_card'
"""
texts = get_texts(db_user.language)
if not settings.is_freekassa_enabled():
await callback.answer(
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
show_alert=True,
)
return
if payment_method == 'freekassa_sbp' and not settings.is_freekassa_sbp_enabled():
await callback.answer(
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
show_alert=True,
)
return
if payment_method == 'freekassa_card' and not settings.is_freekassa_card_enabled():
await callback.answer(
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
show_alert=True,
)
return
# Extract amount from callback data: topup_amount|{method}|{amount_kopeks}
try:
parts = callback.data.split('|')
if len(parts) >= 3:
amount_kopeks = int(parts[2])
else:
await callback.answer('Invalid callback data', show_alert=True)
return
except (ValueError, IndexError):
await callback.answer('Invalid amount', show_alert=True)
return
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
await callback.message.edit_text(
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
)
return
# Validate amount
min_amount = settings.FREEKASSA_MIN_AMOUNT_KOPEKS
max_amount = settings.FREEKASSA_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await callback.answer(
texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'),
show_alert=True,
)
return
if amount_kopeks > max_amount:
await callback.answer(
texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'),
show_alert=True,
)
return
await callback.answer()
await state.clear()
await _create_freekassa_payment_and_respond(
message_or_callback=callback.message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=True,
payment_method=payment_method,
)
@error_handler
async def process_freekassa_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa')
@error_handler
async def process_freekassa_sbp_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa_sbp')
@error_handler
async def process_freekassa_card_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa_card')
+4 -9
View File
@@ -72,13 +72,6 @@ async def start_heleket_payment(
keyboard = get_back_keyboard(db_user.language)
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_buttons = await get_quick_amount_buttons(db_user.language, db_user)
if quick_buttons:
keyboard.inline_keyboard = quick_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
'\n'.join(filter(None, message_lines)),
reply_markup=keyboard,
@@ -129,11 +122,13 @@ async def process_heleket_payment_amount(
amount_rubles = amount_kopeks / 100
if amount_rubles < 100:
await message.answer('Минимальная сумма пополнения: 100 ₽')
await message.answer('Минимальная сумма пополнения: 100 ₽', reply_markup=get_back_keyboard(db_user.language))
return
if amount_rubles > 100000:
await message.answer('Максимальная сумма пополнения: 100,000 ₽')
await message.answer(
'Максимальная сумма пополнения: 100,000 ₽', reply_markup=get_back_keyboard(db_user.language)
)
return
payment_service = PaymentService(message.bot)
+98 -114
View File
@@ -10,6 +10,7 @@ from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.kassa_ai_service import KASSA_AI_SUB_METHODS
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
@@ -18,23 +19,55 @@ from app.utils.decorators import error_handler
logger = structlog.get_logger(__name__)
# --- Enabled check + display name lookup by payment method ---
_KASSA_AI_METHOD_CONFIG = {
'kassa_ai': {
'is_enabled': settings.is_kassa_ai_enabled,
'display_name': settings.get_kassa_ai_display_name,
'unavailable_text': 'KassaAI временно недоступен',
},
'kassa_ai_sbp': {
'is_enabled': settings.is_kassa_ai_sbp_enabled,
'display_name': settings.get_kassa_ai_sbp_display_name,
'unavailable_text': 'KassaAI СБП временно недоступен',
},
'kassa_ai_card': {
'is_enabled': settings.is_kassa_ai_card_enabled,
'display_name': settings.get_kassa_ai_card_display_name,
'unavailable_text': 'KassaAI Карта временно недоступна',
},
}
async def _check_topup_restriction(callback: types.CallbackQuery, db_user: User) -> bool:
"""Check if user has topup restriction. Returns True if restricted (handler should abort)."""
if not getattr(db_user, 'restriction_topup', False):
return False
texts = get_texts(db_user.language)
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
await callback.message.edit_text(
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
)
return True
async def _create_kassa_ai_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
payment_method: str = 'kassa_ai',
):
"""
Common logic for creating KassaAI payment and sending response.
Args:
message_or_callback: Either a Message or CallbackQuery object
db_user: User object
db: Database session
amount_kopeks: Amount in kopeks
edit_message: Whether to edit existing message or send new one
"""
"""Common logic for creating KassaAI payment and sending response."""
texts = get_texts(db_user.language)
amount_rub = amount_kopeks / 100
@@ -46,6 +79,9 @@ async def _create_kassa_ai_payment_and_respond(
description='Пополнение баланса',
)
sub = KASSA_AI_SUB_METHODS.get(payment_method)
payment_system_id = sub['payment_system_id'] if sub else settings.KASSA_AI_PAYMENT_SYSTEM_ID
result = await payment_service.create_kassa_ai_payment(
db=db,
user_id=db_user.id,
@@ -53,6 +89,7 @@ async def _create_kassa_ai_payment_and_respond(
description=description,
email=getattr(db_user, 'email', None),
language=db_user.language,
payment_system_id=payment_system_id,
)
if not result:
@@ -74,7 +111,8 @@ async def _create_kassa_ai_payment_and_respond(
return
payment_url = result.get('payment_url')
display_name = settings.get_kassa_ai_display_name()
cfg = _KASSA_AI_METHOD_CONFIG.get(payment_method, _KASSA_AI_METHOD_CONFIG['kassa_ai'])
display_name = cfg['display_name']()
# Create keyboard with payment button
keyboard = InlineKeyboardMarkup(
@@ -128,10 +166,9 @@ async def process_kassa_ai_payment_amount(
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
payment_method: str = 'kassa_ai',
):
"""
Process payment amount directly (called from quick_amount handlers).
"""
"""Process payment amount directly (called from custom_amount handlers)."""
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
@@ -161,6 +198,7 @@ async def process_kassa_ai_payment_amount(
'PAYMENT_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount}',
).format(min_amount=min_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
@@ -171,6 +209,7 @@ async def process_kassa_ai_payment_amount(
'PAYMENT_AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount}',
).format(max_amount=max_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
@@ -183,53 +222,40 @@ async def process_kassa_ai_payment_amount(
db=db,
amount_kopeks=amount_kopeks,
edit_message=False,
payment_method=payment_method,
)
@error_handler
async def start_kassa_ai_topup(
# --- Generic start/quick-amount implementations ---
async def _start_kassa_ai_sub_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
payment_method: str,
):
"""
Start KassaAI top-up process - ask for amount.
"""
"""Generic start topup handler for any KassaAI sub-method."""
cfg = _KASSA_AI_METHOD_CONFIG[payment_method]
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
if not cfg['is_enabled']():
await callback.answer(texts.t('KASSA_AI_NOT_AVAILABLE', cfg['unavailable_text']), show_alert=True)
return
await callback.message.edit_text(
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
)
if await _check_topup_restriction(callback, db_user):
return
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method='kassa_ai')
await state.update_data(payment_method=payment_method)
min_amount = settings.KASSA_AI_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.KASSA_AI_MAX_AMOUNT_KOPEKS // 100
display_name = settings.get_kassa_ai_display_name()
display_name = cfg['display_name']()
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '◀️ Назад'),
callback_data='menu_balance',
)
]
]
inline_keyboard=[[InlineKeyboardButton(text=texts.t('BACK_BUTTON', '◀️ Назад'), callback_data='menu_balance')]]
)
await callback.message.edit_text(
@@ -249,6 +275,20 @@ async def start_kassa_ai_topup(
)
# --- Public handler functions (registered in main.py) ---
@error_handler
async def start_kassa_ai_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Start KassaAI top-up process - ask for amount."""
await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai')
@error_handler
async def process_kassa_ai_custom_amount(
message: types.Message,
@@ -256,11 +296,10 @@ async def process_kassa_ai_custom_amount(
db: AsyncSession,
state: FSMContext,
):
"""
Process custom amount input for KassaAI payment.
"""
"""Process custom amount input for KassaAI payment."""
data = await state.get_data()
if data.get('payment_method') != 'kassa_ai':
pm = data.get('payment_method', 'kassa_ai')
if pm not in _KASSA_AI_METHOD_CONFIG:
return
texts = get_texts(db_user.language)
@@ -285,82 +324,27 @@ async def process_kassa_ai_custom_amount(
db=db,
amount_kopeks=amount_kopeks,
state=state,
payment_method=pm,
)
@error_handler
async def process_kassa_ai_quick_amount(
async def start_kassa_ai_sbp_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Process quick amount selection for KassaAI payment.
Called when user clicks a predefined amount button.
"""
texts = get_texts(db_user.language)
"""Start KassaAI SBP top-up process."""
await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai_sbp')
if not settings.is_kassa_ai_enabled():
await callback.answer(
texts.t('KASSA_AI_NOT_AVAILABLE', 'KassaAI временно недоступен'),
show_alert=True,
)
return
# Extract amount from callback data: topup_amount|kassa_ai|{amount_kopeks}
try:
parts = callback.data.split('|')
if len(parts) >= 3:
amount_kopeks = int(parts[2])
else:
await callback.answer('Invalid callback data', show_alert=True)
return
except (ValueError, IndexError):
await callback.answer('Invalid amount', show_alert=True)
return
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
await callback.message.edit_text(
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
)
return
# Validate amount
min_amount = settings.KASSA_AI_MIN_AMOUNT_KOPEKS
max_amount = settings.KASSA_AI_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await callback.answer(
texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'),
show_alert=True,
)
return
if amount_kopeks > max_amount:
await callback.answer(
texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'),
show_alert=True,
)
return
await callback.answer()
await state.clear()
await _create_kassa_ai_payment_and_respond(
message_or_callback=callback.message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=True,
)
@error_handler
async def start_kassa_ai_card_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Start KassaAI Card top-up process."""
await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai_card')
+48 -207
View File
@@ -18,7 +18,6 @@ from app.keyboards.inline import (
from app.localization.texts import get_texts
from app.states import BalanceStates
from app.utils.decorators import error_handler
from app.utils.price_display import calculate_user_price
logger = structlog.get_logger(__name__)
@@ -132,11 +131,20 @@ async def route_payment_by_method(
)
return True
if payment_method == 'kassa_ai':
if payment_method in ('kassa_ai', 'kassa_ai_sbp', 'kassa_ai_card'):
from .kassa_ai import process_kassa_ai_payment_amount
async with AsyncSessionLocal() as db:
await process_kassa_ai_payment_amount(message, db_user, db, amount_kopeks, state)
await process_kassa_ai_payment_amount(
message, db_user, db, amount_kopeks, state, payment_method=payment_method
)
return True
if payment_method == 'severpay':
from .severpay import process_severpay_payment_amount
async with AsyncSessionLocal() as db:
await process_severpay_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == 'riopay':
@@ -149,159 +157,6 @@ async def route_payment_by_method(
return False
async def get_quick_amount_buttons(language: str, user: User) -> list:
"""
Generate quick amount buttons with user-specific pricing and discounts.
Includes full subscription cost: base period price + devices + servers + traffic.
Args:
language: User's language for formatting
user: User object to calculate personalized discounts
Returns:
List of button rows for inline keyboard
"""
if not settings.is_quick_amount_buttons_enabled():
return []
from app.config import PERIOD_PRICES
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.database import AsyncSessionLocal
from app.utils.pricing_utils import apply_percentage_discount, calculate_months_from_days
texts = get_texts(language)
tariff = None
tariff_prices = None
tariff_periods = None
devices_price_per_month = 0
servers_per_month_prices: list[int] = []
traffic_price_per_month = 0
async with AsyncSessionLocal() as db:
subscription = await get_subscription_by_user_id(db, user.id)
# В режиме тарифов получаем цены из тарифа пользователя
if settings.is_tariffs_mode() and subscription and subscription.tariff_id:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff and tariff.period_prices:
tariff_prices = {int(k): v for k, v in tariff.period_prices.items()}
tariff_periods = sorted(tariff_prices.keys())
# Получаем стоимость устройств, серверов и трафика из подписки
if subscription and not subscription.is_trial:
# Устройства: в режиме тарифов используем цену и базовый лимит из тарифа
if settings.is_tariffs_mode() and tariff and tariff_prices:
tariff_device_price = getattr(tariff, 'device_price_kopeks', None)
if tariff_device_price and tariff_device_price > 0:
device_unit_price = tariff_device_price
base_device_limit = tariff.device_limit or 0
else:
device_unit_price = settings.PRICE_PER_DEVICE
base_device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
device_unit_price = settings.PRICE_PER_DEVICE
base_device_limit = settings.DEFAULT_DEVICE_LIMIT
device_limit = subscription.device_limit or base_device_limit
additional_devices = max(0, device_limit - base_device_limit)
if additional_devices > 0:
devices_price_per_month = additional_devices * device_unit_price
# Серверы
connected_squads = subscription.connected_squads or []
if connected_squads:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
_, servers_per_month_prices = await subscription_service.get_countries_price_by_uuids(
connected_squads, db, promo_group_id=user.promo_group_id
)
# Трафик
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
buttons = []
# Используем периоды тарифа в режиме тарифов, иначе стандартные
if tariff_periods:
periods = tariff_periods[:6]
else:
periods = settings.get_available_subscription_periods()[:6]
for period in periods:
# Получаем цену из тарифа или из PERIOD_PRICES
if tariff_prices and period in tariff_prices:
base_price_kopeks = tariff_prices[period]
else:
base_price_kopeks = PERIOD_PRICES.get(period, 0)
if base_price_kopeks > 0:
# Базовая цена периода с промо-скидками
price_info = calculate_user_price(user, base_price_kopeks, period, 'period')
months = calculate_months_from_days(period)
# Стоимость устройств со скидкой
devices_addon = 0
if devices_price_per_month > 0:
devices_discount = user.get_promo_discount('devices', period)
devices_discounted, _ = apply_percentage_discount(devices_price_per_month, devices_discount)
devices_addon = devices_discounted * months
# Стоимость серверов со скидкой
servers_addon = 0
if servers_per_month_prices:
servers_discount = user.get_promo_discount('servers', period)
for server_price in servers_per_month_prices:
discounted, _ = apply_percentage_discount(server_price, servers_discount)
servers_addon += discounted
servers_addon *= months
# Стоимость трафика со скидкой
traffic_addon = 0
if traffic_price_per_month > 0:
traffic_discount = user.get_promo_discount('traffic', period)
traffic_discounted, _ = apply_percentage_discount(traffic_price_per_month, traffic_discount)
traffic_addon = traffic_discounted * months
total_price = price_info.final_price + devices_addon + servers_addon + traffic_addon
callback_data = f'quick_amount_{total_price}'
period_label = f'{period} дней'
# Скидка считается от полной базовой стоимости (период + аддоны без скидок)
total_base = (
base_price_kopeks
+ (devices_price_per_month + sum(servers_per_month_prices) + traffic_price_per_month) * months
)
has_discount = total_base > total_price and total_base > 0
if has_discount:
discount_pct = round((total_base - total_price) * 100 / total_base)
if discount_pct > 0:
button_text = (
f'{texts.format_price(total_base)}'
f'{texts.format_price(total_price)} '
f'(-{discount_pct}%) • {period_label}'
)
else:
button_text = f'{texts.format_price(total_price)}{period_label}'
else:
button_text = f'{texts.format_price(total_price)}{period_label}'
buttons.append(types.InlineKeyboardButton(text=button_text, callback_data=callback_data))
keyboard_rows = []
for i in range(0, len(buttons), 2):
keyboard_rows.append(buttons[i : i + 2])
return keyboard_rows
@error_handler
async def show_balance_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
# Проверяем, доступно ли сообщение
@@ -428,9 +283,22 @@ async def show_payment_methods(callback: types.CallbackQuery, db_user: User, db:
payment_text = get_payment_methods_text(db_user.language)
# Проверяем сохранённую корзину для автоподстановки суммы пополнения
amount_kopeks = 0
try:
from app.services.user_cart_service import user_cart_service
cart_data = await user_cart_service.get_user_cart(db_user.id)
if cart_data and cart_data.get('saved_cart'):
missing = cart_data.get('missing_amount', 0)
if missing > 0:
amount_kopeks = missing
except Exception:
pass
full_text = payment_text
keyboard = get_payment_methods_keyboard(0, db_user.language)
keyboard = get_payment_methods_keyboard(amount_kopeks, db_user.language)
# Если сообщение недоступно, отправляем новое
if isinstance(callback.message, InaccessibleMessage):
@@ -600,11 +468,13 @@ async def process_topup_amount(message: types.Message, db_user: User, state: FSM
amount_rubles = float(amount_text.replace(',', '.'))
if amount_rubles < 1:
await message.answer('Минимальная сумма пополнения: 1 ₽')
await message.answer('Минимальная сумма пополнения: 1 ₽', reply_markup=get_back_keyboard(db_user.language))
return
if amount_rubles > 50000:
await message.answer('Максимальная сумма пополнения: 50,000 ₽')
await message.answer(
'Максимальная сумма пополнения: 50,000 ₽', reply_markup=get_back_keyboard(db_user.language)
)
return
amount_kopeks = int(amount_rubles * 100)
@@ -614,13 +484,17 @@ async def process_topup_amount(message: types.Message, db_user: User, state: FSM
if payment_method in ['yookassa', 'yookassa_sbp']:
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
await message.answer(f'❌ Минимальная сумма для оплаты через YooKassa: {min_rubles:.0f}')
await message.answer(
f'❌ Минимальная сумма для оплаты через YooKassa: {min_rubles:.0f}',
reply_markup=get_back_keyboard(db_user.language),
)
return
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
await message.answer(
f'❌ Максимальная сумма для оплаты через YooKassa: {max_rubles:,.0f}'.replace(',', ' ')
f'❌ Максимальная сумма для оплаты через YooKassa: {max_rubles:,.0f}'.replace(',', ' '),
reply_markup=get_back_keyboard(db_user.language),
)
return
@@ -670,37 +544,6 @@ async def handle_sbp_payment(callback: types.CallbackQuery, db: AsyncSession):
await callback.answer('❌ Ошибка обработки платежа', show_alert=True)
@error_handler
async def handle_quick_amount_selection(callback: types.CallbackQuery, db_user: User, state: FSMContext):
"""
Обработчик выбора суммы через кнопки быстрого выбора
"""
# Проверяем, что пользователь в правильном состоянии FSM
current_state = await state.get_state()
if current_state != BalanceStates.waiting_for_amount:
await callback.answer('❌ Сначала выберите способ оплаты', show_alert=True)
return
# Извлекаем сумму из callback_data
try:
amount_kopeks = int(callback.data.split('_')[-1])
# Получаем метод оплаты из состояния
data = await state.get_data()
payment_method = data.get('payment_method', 'yookassa')
# Роутим платеж на соответствующий обработчик
if not await route_payment_by_method(callback.message, db_user, amount_kopeks, state, payment_method):
await callback.answer('❌ Неизвестный способ оплаты', show_alert=True)
return
except ValueError:
await callback.answer('❌ Ошибка обработки суммы', show_alert=True)
except Exception as e:
logger.error('Ошибка обработки быстрого выбора суммы', error=e)
await callback.answer('❌ Ошибка обработки запроса', show_alert=True)
@error_handler
async def handle_topup_amount_callback(
callback: types.CallbackQuery,
@@ -827,36 +670,37 @@ def register_balance_handlers(dp: Dispatcher):
dp.callback_query.register(start_heleket_payment, F.data == 'topup_heleket')
dp.callback_query.register(check_heleket_payment_status, F.data.startswith('check_heleket_'))
from .cloudpayments import handle_cloudpayments_quick_amount, start_cloudpayments_payment
from .cloudpayments import start_cloudpayments_payment
dp.callback_query.register(start_cloudpayments_payment, F.data == 'topup_cloudpayments')
dp.callback_query.register(handle_cloudpayments_quick_amount, F.data.startswith('topup_amount|cloudpayments|'))
from .freekassa import (
process_freekassa_card_quick_amount,
process_freekassa_quick_amount,
process_freekassa_sbp_quick_amount,
start_freekassa_card_topup,
start_freekassa_sbp_topup,
start_freekassa_topup,
)
dp.callback_query.register(start_freekassa_topup, F.data == 'topup_freekassa')
dp.callback_query.register(process_freekassa_quick_amount, F.data.startswith('topup_amount|freekassa|'))
dp.callback_query.register(start_freekassa_sbp_topup, F.data == 'topup_freekassa_sbp')
dp.callback_query.register(process_freekassa_sbp_quick_amount, F.data.startswith('topup_amount|freekassa_sbp|'))
dp.callback_query.register(start_freekassa_card_topup, F.data == 'topup_freekassa_card')
dp.callback_query.register(process_freekassa_card_quick_amount, F.data.startswith('topup_amount|freekassa_card|'))
from .kassa_ai import process_kassa_ai_quick_amount, start_kassa_ai_topup
from .kassa_ai import (
start_kassa_ai_card_topup,
start_kassa_ai_sbp_topup,
start_kassa_ai_topup,
)
dp.callback_query.register(start_kassa_ai_topup, F.data == 'topup_kassa_ai')
dp.callback_query.register(process_kassa_ai_quick_amount, F.data.startswith('topup_amount|kassa_ai|'))
dp.callback_query.register(start_kassa_ai_sbp_topup, F.data == 'topup_kassa_ai_sbp')
dp.callback_query.register(start_kassa_ai_card_topup, F.data == 'topup_kassa_ai_card')
from .riopay import process_riopay_quick_amount, start_riopay_topup
from .riopay import start_riopay_topup
dp.callback_query.register(start_riopay_topup, F.data == 'topup_riopay')
dp.callback_query.register(process_riopay_quick_amount, F.data.startswith('topup_amount|riopay|'))
from .severpay import start_severpay_topup
dp.callback_query.register(start_severpay_topup, F.data == 'topup_severpay')
from .mulenpay import check_mulenpay_payment_status
@@ -876,9 +720,6 @@ def register_balance_handlers(dp: Dispatcher):
dp.callback_query.register(handle_payment_methods_unavailable, F.data == 'payment_methods_unavailable')
# Регистрируем обработчик для кнопок быстрого выбора суммы
dp.callback_query.register(handle_quick_amount_selection, F.data.startswith('quick_amount_'))
dp.callback_query.register(handle_topup_amount_callback, F.data.startswith('topup_amount|'))
dp.callback_query.register(handle_saved_cards_list, F.data == 'saved_cards_list')
+4 -9
View File
@@ -65,13 +65,6 @@ async def start_mulenpay_payment(
keyboard = get_back_keyboard(db_user.language)
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
@@ -124,13 +117,15 @@ async def process_mulenpay_payment_amount(
if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
await message.answer(
f'Минимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MIN_AMOUNT_KOPEKS)}'
f'Минимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MIN_AMOUNT_KOPEKS)}',
reply_markup=get_back_keyboard(db_user.language),
)
return
if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS:
await message.answer(
f'Максимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MAX_AMOUNT_KOPEKS)}'
f'Максимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MAX_AMOUNT_KOPEKS)}',
reply_markup=get_back_keyboard(db_user.language),
)
return
+8 -9
View File
@@ -303,13 +303,6 @@ async def start_pal24_payment(
keyboard = get_back_keyboard(db_user.language)
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
@@ -359,12 +352,18 @@ async def process_pal24_payment_amount(
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
min_rubles = settings.PAL24_MIN_AMOUNT_KOPEKS / 100
await message.answer(f'❌ Минимальная сумма для оплаты через PayPalych: {min_rubles:.0f}')
await message.answer(
f'❌ Минимальная сумма для оплаты через PayPalych: {min_rubles:.0f}',
reply_markup=get_back_keyboard(db_user.language),
)
return
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
max_rubles = settings.PAL24_MAX_AMOUNT_KOPEKS / 100
await message.answer(f'❌ Максимальная сумма для оплаты через PayPalych: {max_rubles:,.0f}'.replace(',', ' '))
await message.answer(
f'❌ Максимальная сумма для оплаты через PayPalych: {max_rubles:,.0f}'.replace(',', ' '),
reply_markup=get_back_keyboard(db_user.language),
)
return
available_methods = _get_available_pal24_methods()
+4 -9
View File
@@ -71,13 +71,6 @@ async def _prompt_amount(
keyboard = get_back_keyboard(db_user.language)
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await message.edit_text(
prompt_template.format(
method_name=method_name,
@@ -250,7 +243,8 @@ async def process_platega_payment_amount(
texts.t(
'PLATEGA_AMOUNT_TOO_LOW',
'Минимальная сумма для оплаты через Platega: {amount}',
).format(amount=settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS))
).format(amount=settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS)),
reply_markup=get_back_keyboard(db_user.language),
)
return
@@ -259,7 +253,8 @@ async def process_platega_payment_amount(
texts.t(
'PLATEGA_AMOUNT_TOO_HIGH',
'Максимальная сумма для оплаты через Platega: {amount}',
).format(amount=settings.format_price(settings.PLATEGA_MAX_AMOUNT_KOPEKS))
).format(amount=settings.format_price(settings.PLATEGA_MAX_AMOUNT_KOPEKS)),
reply_markup=get_back_keyboard(db_user.language),
)
return
+3 -73
View File
@@ -136,7 +136,7 @@ async def process_riopay_payment_amount(
state: FSMContext,
):
"""
Process payment amount directly (called from quick_amount handlers).
Process payment amount directly.
"""
texts = get_texts(db_user.language)
@@ -161,6 +161,7 @@ async def process_riopay_payment_amount(
'PAYMENT_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount}',
).format(min_amount=min_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
@@ -171,6 +172,7 @@ async def process_riopay_payment_amount(
'PAYMENT_AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount}',
).format(max_amount=max_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
@@ -280,75 +282,3 @@ async def process_riopay_custom_amount(
amount_kopeks=amount_kopeks,
state=state,
)
@error_handler
async def process_riopay_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Process quick amount selection for RioPay payment.
Called when user clicks a predefined amount button.
"""
texts = get_texts(db_user.language)
if not settings.is_riopay_enabled():
await callback.answer(
texts.t('RIOPAY_NOT_AVAILABLE', 'RioPay временно недоступен'),
show_alert=True,
)
return
# Extract amount from callback data: topup_amount|riopay|{amount_kopeks}
try:
parts = callback.data.split('|')
if len(parts) >= 3:
amount_kopeks = int(parts[2])
else:
await callback.answer('Invalid callback data', show_alert=True)
return
except (ValueError, IndexError):
await callback.answer('Invalid amount', show_alert=True)
return
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
await callback.message.edit_text(
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
return
# Validate amount
min_amount = settings.RIOPAY_MIN_AMOUNT_KOPEKS
max_amount = settings.RIOPAY_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await callback.answer(
texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'),
show_alert=True,
)
return
if amount_kopeks > max_amount:
await callback.answer(
texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'),
show_alert=True,
)
return
await callback.answer()
await state.clear()
await _create_riopay_payment_and_respond(
message_or_callback=callback.message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=True,
)
+245
View File
@@ -0,0 +1,245 @@
"""Handler for SeverPay balance top-up."""
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = structlog.get_logger(__name__)
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
if not getattr(db_user, 'restriction_topup', False):
return None
keyboard = []
support_url = settings.get_support_contact_url()
if support_url:
keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
async def _create_severpay_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
):
"""
Common logic for creating SeverPay payment and sending response.
"""
texts = get_texts(db_user.language)
amount_rub = amount_kopeks / 100
# Create payment
payment_service = PaymentService()
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
service_name=settings.PAYMENT_SERVICE_NAME,
description='Пополнение баланса',
)
result = await payment_service.create_severpay_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=description,
email=getattr(db_user, 'email', None),
language=db_user.language,
)
if not result:
error_text = texts.t(
'PAYMENT_CREATE_ERROR',
'Не удалось создать платёж. Попробуйте позже.',
)
if edit_message:
await message_or_callback.edit_text(
error_text,
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
else:
await message_or_callback.answer(
error_text,
parse_mode='HTML',
)
return
payment_url = result.get('payment_url')
display_name = settings.get_severpay_display_name()
# Create keyboard with payment button
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t(
'PAY_BUTTON',
'💳 Оплатить {amount}',
).format(amount=f'{amount_rub:.0f}'),
url=payment_url,
)
],
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '◀️ Назад'),
callback_data='menu_balance',
)
],
]
)
response_text = texts.t(
'SEVERPAY_PAYMENT_CREATED',
'💳 <b>Оплата через {name}</b>\n\n'
'Сумма: <b>{amount}₽</b>\n\n'
'Нажмите кнопку ниже для оплаты.\n'
'После успешной оплаты баланс будет пополнен автоматически.',
).format(name=display_name, amount=f'{amount_rub:.2f}')
if edit_message:
await message_or_callback.edit_text(
response_text,
reply_markup=keyboard,
parse_mode='HTML',
)
else:
await message_or_callback.answer(
response_text,
reply_markup=keyboard,
parse_mode='HTML',
)
logger.info('SeverPay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
@error_handler
async def process_severpay_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
"""
Process payment amount directly.
"""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
await message.answer(
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
await state.clear()
return
# Validate amount
min_amount = settings.SEVERPAY_MIN_AMOUNT_KOPEKS
max_amount = settings.SEVERPAY_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount}',
).format(min_amount=min_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
if amount_kopeks > max_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount}',
).format(max_amount=max_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
await state.clear()
await _create_severpay_payment_and_respond(
message_or_callback=message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=False,
)
@error_handler
async def start_severpay_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Start SeverPay top-up process - ask for amount.
"""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
await callback.message.edit_text(
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
return
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method='severpay')
min_amount = settings.SEVERPAY_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.SEVERPAY_MAX_AMOUNT_KOPEKS // 100
display_name = settings.get_severpay_display_name()
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '◀️ Назад'),
callback_data='menu_balance',
)
]
]
)
await callback.message.edit_text(
texts.t(
'SEVERPAY_ENTER_AMOUNT',
'💳 <b>Пополнение через {name}</b>\n\n'
'Введите сумму пополнения в рублях.\n\n'
'Минимум: {min_amount}\n'
'Максимум: {max_amount}',
).format(
name=display_name,
min_amount=min_amount,
max_amount=f'{max_amount:,}'.replace(',', ' '),
),
parse_mode='HTML',
reply_markup=keyboard,
)
+1 -15
View File
@@ -40,24 +40,10 @@ async def start_stars_payment(callback: types.CallbackQuery, db_user: User, stat
await callback.answer()
return
# Формируем текст сообщения в зависимости от настройки
if settings.is_quick_amount_buttons_enabled():
message_text = '⭐ <b>Пополнение через Telegram Stars</b>\n\nВыберите сумму пополнения или введите вручную:'
else:
message_text = texts.TOP_UP_AMOUNT
message_text = texts.TOP_UP_AMOUNT
# Создаем клавиатуру
keyboard = get_back_keyboard(db_user.language)
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(message_text, reply_markup=keyboard)
await state.update_data(
+4 -9
View File
@@ -61,13 +61,6 @@ async def start_wata_payment(
keyboard = get_back_keyboard(db_user.language)
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
@@ -120,7 +113,8 @@ async def process_wata_payment_amount(
texts.t(
'WATA_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {amount}',
).format(amount=settings.format_price(settings.WATA_MIN_AMOUNT_KOPEKS))
).format(amount=settings.format_price(settings.WATA_MIN_AMOUNT_KOPEKS)),
reply_markup=get_back_keyboard(db_user.language),
)
return
@@ -129,7 +123,8 @@ async def process_wata_payment_amount(
texts.t(
'WATA_AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {amount}',
).format(amount=settings.format_price(settings.WATA_MAX_AMOUNT_KOPEKS))
).format(amount=settings.format_price(settings.WATA_MAX_AMOUNT_KOPEKS)),
reply_markup=get_back_keyboard(db_user.language),
)
return
+24 -48
View File
@@ -46,31 +46,13 @@ async def start_yookassa_payment(callback: types.CallbackQuery, db_user: User, s
min_amount_rub = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
# Формируем текст сообщения в зависимости от настройки
if settings.is_quick_amount_buttons_enabled():
message_text = (
f'💳 <b>Оплата банковской картой</b>\n\n'
f'Выберите сумму пополнения или введите вручную сумму '
f'от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
)
else:
message_text = (
f'💳 <b>Оплата банковской картой</b>\n\n'
f'Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
)
message_text = (
f'💳 <b>Оплата банковской картой</b>\n\n'
f'Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
)
# Создаем клавиатуру
keyboard = get_back_keyboard(db_user.language)
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(message_text, reply_markup=keyboard, parse_mode='HTML')
await state.set_state(BalanceStates.waiting_for_amount)
@@ -110,31 +92,13 @@ async def start_yookassa_sbp_payment(callback: types.CallbackQuery, db_user: Use
min_amount_rub = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
# Формируем текст сообщения в зависимости от настройки
if settings.is_quick_amount_buttons_enabled():
message_text = (
f'🏦 <b>Оплата через СБП</b>\n\n'
f'Выберите сумму пополнения или введите вручную сумму '
f'от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
)
else:
message_text = (
f'🏦 <b>Оплата через СБП</b>\n\n'
f'Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
)
message_text = (
f'🏦 <b>Оплата через СБП</b>\n\n'
f'Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
)
# Создаем клавиатуру
keyboard = get_back_keyboard(db_user.language)
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(message_text, reply_markup=keyboard, parse_mode='HTML')
await state.set_state(BalanceStates.waiting_for_amount)
@@ -178,12 +142,18 @@ async def process_yookassa_payment_amount(
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
await message.answer(f'❌ Минимальная сумма для оплаты картой: {min_rubles:.0f}')
await message.answer(
f'❌ Минимальная сумма для оплаты картой: {min_rubles:.0f}',
reply_markup=get_back_keyboard(db_user.language),
)
return
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
await message.answer(f'❌ Максимальная сумма для оплаты картой: {max_rubles:,.0f}'.replace(',', ' '))
await message.answer(
f'❌ Максимальная сумма для оплаты картой: {max_rubles:,.0f}'.replace(',', ' '),
reply_markup=get_back_keyboard(db_user.language),
)
return
try:
@@ -327,12 +297,18 @@ async def process_yookassa_sbp_payment_amount(
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
await message.answer(f'❌ Минимальная сумма для оплаты через СБП: {min_rubles:.0f}')
await message.answer(
f'❌ Минимальная сумма для оплаты через СБП: {min_rubles:.0f}',
reply_markup=get_back_keyboard(db_user.language),
)
return
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
await message.answer(f'❌ Максимальная сумма для оплаты через СБП: {max_rubles:,.0f}'.replace(',', ' '))
await message.answer(
f'❌ Максимальная сумма для оплаты через СБП: {max_rubles:,.0f}'.replace(',', ' '),
reply_markup=get_back_keyboard(db_user.language),
)
return
try:
+23 -7
View File
@@ -1249,7 +1249,7 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
"""
texts = get_texts(db_user.language)
from app.database.crud.server_squad import get_available_server_squads, get_server_ids_by_uuids
from app.database.crud.server_squad import get_available_server_squads
from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
@@ -1287,7 +1287,9 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
if not connected_squads and available_servers:
connected_squads = [available_servers[0].squad_uuid]
server_ids = await get_server_ids_by_uuids(db, connected_squads) if connected_squads else []
from app.database.crud.user import lock_user_for_pricing
db_user = await lock_user_for_pricing(db, db_user.id)
balance = db_user.balance_kopeks
available_periods = sorted(settings.get_available_subscription_periods(), reverse=True)
@@ -1299,7 +1301,7 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
best_price = 0
best_pricing = None # Cache pricing result for reuse in finalize()
# Для продления используем PricingEngine (единый расчёт для всех поверхностей).
# PricingEngine единый расчёт для всех поверхностей (и продление, и новая подписка).
from app.services.pricing_engine import pricing_engine
renewal_service = SubscriptionRenewalService() if subscription else None
@@ -1310,9 +1312,15 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
pricing_result = await pricing_engine.calculate_renewal_price(db, subscription, period, user=db_user)
price = pricing_result.final_total
else:
price, _ = await subscription_service.calculate_subscription_price_with_months(
period, traffic_limit_gb, server_ids, device_limit, db, user=db_user
new_pricing = await pricing_engine.calculate_classic_new_subscription_price(
db,
period,
connected_squads,
traffic_limit_gb,
device_limit,
user=db_user,
)
price = new_pricing.final_total
if price <= balance:
best_period = period
best_price = price
@@ -1326,9 +1334,15 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
min_pricing = await pricing_engine.calculate_renewal_price(db, subscription, min_period, user=db_user)
min_price = min_pricing.final_total
else:
min_price, _ = await subscription_service.calculate_subscription_price_with_months(
min_period, traffic_limit_gb, server_ids, device_limit, db, user=db_user
min_new_pricing = await pricing_engine.calculate_classic_new_subscription_price(
db,
min_period,
connected_squads,
traffic_limit_gb,
device_limit,
user=db_user,
)
min_price = min_new_pricing.final_total
missing = min_price - balance
await callback.answer(
texts.t('INSUFFICIENT_FUNDS_DETAILED', f'❌ Недостаточно средств. Не хватает {missing // 100}'),
@@ -1365,12 +1379,14 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
)
else:
# Списать баланс ДО создания подписки (чтобы не было orphaned subscription при неудаче)
consume_promo = get_user_active_promo_discount_percent(db_user) > 0
success = await subtract_user_balance(
db,
db_user,
best_price,
f'Активация подписки на {best_period} дней',
mark_as_paid_subscription=True,
consume_promo_offer=consume_promo,
)
if not success:
await callback.answer('❌ Недостаточно средств', show_alert=True)
+4 -2
View File
@@ -14,7 +14,7 @@ from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_referral_keyboard
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.admin_notification_service import AdminNotificationService, NotificationCategory
from app.services.referral_withdrawal_service import referral_withdrawal_service
from app.states import ReferralWithdrawalStates
from app.utils.photo_message import edit_or_answer_photo
@@ -825,7 +825,9 @@ async def confirm_withdrawal_request(callback: types.CallbackQuery, db_user: Use
try:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_admin_notification(admin_text, reply_markup=admin_keyboard)
await notification_service.send_admin_notification(
admin_text, reply_markup=admin_keyboard, category=NotificationCategory.PARTNERS
)
except Exception as e:
logger.error('Ошибка отправки уведомления админам о заявке на вывод', error=e)
+37 -9
View File
@@ -401,13 +401,25 @@ async def handle_simple_subscription_pay_with_balance(
state_data=data,
)
# Рассчитываем цену подписки
# Lock user BEFORE pricing to prevent TOCTOU
from app.database.crud.user import lock_user_for_pricing, subtract_user_balance
db_user = await lock_user_for_pricing(db, db_user.id)
# Рассчитываем цену подписки (group discounts per-category)
price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
db,
subscription_params,
user=db_user,
resolved_squad_uuid=resolved_squad_uuid,
)
# PricingEngine already applies promo-offer discount inside calculate_classic_new_subscription_price.
# Only determine whether to consume the offer (zero it out after use).
from app.utils.promo_offer import get_user_active_promo_discount_percent
consume_promo = get_user_active_promo_discount_percent(db_user) > 0
total_required = price_kopeks
logger.warning(
'SIMPLE_SUBSCRIPTION_DEBUG_PAY_BALANCE | user= | period= | base= | traffic= | devices= | servers= | discount= | total_required= | balance',
@@ -431,15 +443,13 @@ async def handle_simple_subscription_pay_with_balance(
try:
# Списываем средства с баланса пользователя
from app.database.crud.user import subtract_user_balance
purchase_description = f'Оплата подписки на {subscription_params["period_days"]} дней'
success = await subtract_user_balance(
db,
db_user,
price_kopeks,
purchase_description,
consume_promo_offer=False,
consume_promo_offer=consume_promo,
mark_as_paid_subscription=True,
)
@@ -840,7 +850,7 @@ async def handle_simple_subscription_payment_method(
state_data=data,
)
# Рассчитываем цену подписки
# Рассчитываем цену подписки (group discounts per-category)
price_kopeks, _ = await _calculate_simple_subscription_price(
db,
subscription_params,
@@ -848,6 +858,14 @@ async def handle_simple_subscription_payment_method(
resolved_squad_uuid=resolved_squad_uuid,
)
# Apply promo-offer discount on top of group discounts (consistent with balance-pay path)
from app.services.pricing_engine import PricingEngine
from app.utils.promo_offer import get_user_active_promo_discount_percent
offer_pct = get_user_active_promo_discount_percent(db_user)
if offer_pct > 0:
price_kopeks = PricingEngine.apply_discount(price_kopeks, offer_pct)
if payment_method == 'stars':
# Оплата через Telegram Stars
order = await purchase_service.create_subscription_order(
@@ -2121,13 +2139,25 @@ async def confirm_simple_subscription_purchase(
state_data=data,
)
# Рассчитываем цену подписки
# Lock user BEFORE pricing to prevent TOCTOU
from app.database.crud.user import lock_user_for_pricing, subtract_user_balance
db_user = await lock_user_for_pricing(db, db_user.id)
# Рассчитываем цену подписки (group discounts per-category)
price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
db,
subscription_params,
user=db_user,
resolved_squad_uuid=resolved_squad_uuid,
)
# PricingEngine already applies promo-offer discount inside calculate_classic_new_subscription_price.
# Only determine whether to consume the offer (zero it out after use).
from app.utils.promo_offer import get_user_active_promo_discount_percent
consume_promo = get_user_active_promo_discount_percent(db_user) > 0
total_required = price_kopeks
logger.warning(
'SIMPLE_SUBSCRIPTION_DEBUG_CONFIRM | user= | period= | base= | traffic= | devices= | servers= | discount= | total_required= | balance',
@@ -2151,15 +2181,13 @@ async def confirm_simple_subscription_purchase(
try:
# Списываем средства с баланса пользователя
from app.database.crud.user import subtract_user_balance
purchase_description = f'Оплата подписки на {subscription_params["period_days"]} дней'
success = await subtract_user_balance(
db,
db_user,
price_kopeks,
purchase_description,
consume_promo_offer=False,
consume_promo_offer=consume_promo,
mark_as_paid_subscription=True,
)
+201
View File
@@ -51,6 +51,7 @@ from app.services.privacy_policy_service import PrivacyPolicyService
from app.services.referral_service import process_referral_registration
from app.services.subscription_service import SubscriptionService
from app.services.support_settings_service import SupportSettingsService
from app.services.web_auth_service import WEB_AUTH_TOKEN_MIN_LENGTH, link_web_auth_token
from app.states import RegistrationStates
from app.utils.promo_offer import (
build_promo_offer_hint,
@@ -197,6 +198,80 @@ async def _claim_phantom_user(
return True, phantom
async def _merge_phantom_into_active_user(
db: AsyncSession,
phantom: 'User',
active_user: 'User',
) -> None:
"""Merge a phantom user (created by guest landing purchase) into an existing active user.
Transfers GuestPurchase records and handles subscription conflict.
The phantom is soft-deleted (status=DELETED, username cleared) to preserve
audit trail and avoid CASCADE deletion of payment/transaction records.
"""
from sqlalchemy import update
logger.info(
'Merging phantom user into active user',
phantom_id=phantom.id,
active_user_id=active_user.id,
phantom_username=phantom.username,
)
# Transfer GuestPurchase.user_id references
await db.execute(update(GuestPurchase).where(GuestPurchase.user_id == phantom.id).values(user_id=active_user.id))
# Transfer GuestPurchase.buyer_user_id references
await db.execute(
update(GuestPurchase).where(GuestPurchase.buyer_user_id == phantom.id).values(buyer_user_id=active_user.id)
)
# Transfer balance
if phantom.balance_kopeks and phantom.balance_kopeks > 0:
active_user.balance_kopeks = (active_user.balance_kopeks or 0) + phantom.balance_kopeks
logger.info('Transferred balance from phantom', amount_kopeks=phantom.balance_kopeks)
# Handle subscription
await db.refresh(phantom, ['subscription'])
await db.refresh(active_user, ['subscription'])
if phantom.subscription and not active_user.subscription:
# Transfer subscription from phantom to active user
phantom.subscription.user_id = active_user.id
# Transfer remnawave_uuid
if phantom.remnawave_uuid and not active_user.remnawave_uuid:
active_user.remnawave_uuid = phantom.remnawave_uuid
phantom.remnawave_uuid = None
await db.flush()
logger.info(
'Transferred subscription from phantom to active user',
subscription_id=phantom.subscription.id,
)
elif phantom.subscription:
# Both have subscriptions — disable phantom's Remnawave user and free server slots
logger.warning(
'Both phantom and active user have subscriptions, disabling phantom',
phantom_subscription_id=phantom.subscription.id,
active_subscription_id=active_user.subscription.id,
)
if phantom.remnawave_uuid:
try:
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(phantom.remnawave_uuid)
except Exception as exc:
logger.warning('Failed to disable phantom Remnawave user', error=str(exc))
await decrement_subscription_server_counts(db, phantom.subscription)
# Soft-delete phantom: clear identifiers to prevent future matches,
# preserve record for audit trail and avoid CASCADE deletion of payments/transactions
phantom.status = UserStatus.DELETED.value
phantom.username = None
phantom.remnawave_uuid = None
await db.flush()
logger.info('Phantom user merged and soft-deleted', phantom_id=phantom.id, active_user_id=active_user.id)
def _calculate_subscription_flags(subscription):
if not subscription:
return False, False
@@ -302,6 +377,9 @@ async def handle_potential_referral_code(message: types.Message, state: FSMConte
language = data.get('language') or (getattr(user, 'language', None) if user else None) or DEFAULT_LANGUAGE
texts = get_texts(language)
if not message.text:
return False
from app.utils.promo_rate_limiter import promo_limiter, validate_promo_format
potential_code = message.text.strip()
@@ -530,6 +608,40 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
await state.update_data(pending_gift_token=gift_token)
start_parameter = None # Don't treat as campaign or referral
# Handle web auth deep links: /start webauth_{token}
if start_parameter and start_parameter.startswith('webauth_'):
web_auth_token = start_parameter.removeprefix('webauth_')
if len(web_auth_token) >= WEB_AUTH_TOKEN_MIN_LENGTH:
user = db_user or await get_user_by_telegram_id(db, message.from_user.id)
if user and user.status != UserStatus.DELETED.value:
texts = get_texts(user.language)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t('WEB_AUTH_CONFIRM_YES', '✅ Да, войти'),
callback_data=f'webauth_confirm:{web_auth_token}',
),
types.InlineKeyboardButton(
text=texts.t('WEB_AUTH_CONFIRM_NO', '❌ Нет'),
callback_data='webauth_deny',
),
],
]
)
await message.answer(
texts.t(
'WEB_AUTH_CONFIRM_PROMPT',
'🔐 Подтвердите вход в личный кабинет. Если вы не запрашивали вход — нажмите «Нет».',
),
reply_markup=keyboard,
)
else:
logger.warning('Web auth attempt from unregistered user', telegram_id=message.from_user.id)
await message.answer('❌ Сначала зарегистрируйтесь в боте, затем попробуйте войти в кабинет.')
return
start_parameter = None # Invalid token, ignore
if start_parameter:
campaign = await get_campaign_by_start_parameter(
db,
@@ -586,6 +698,21 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
if user and user.status != UserStatus.DELETED.value:
logger.info('✅ Активный пользователь найден', telegram_id=user.telegram_id)
# Check for phantom user created by guest landing purchase and merge
if message.from_user.username:
phantom = await find_phantom_user_by_username(db, message.from_user.username)
if phantom and phantom.id != user.id:
try:
await _merge_phantom_into_active_user(db, phantom, user)
await db.refresh(user, ['subscription'])
except Exception:
await db.rollback()
logger.exception(
'Failed to merge phantom user',
phantom_id=phantom.id,
active_user_id=user.id,
)
profile_updated = False
if user.username != message.from_user.username:
@@ -1186,6 +1313,10 @@ async def process_referral_code_input(message: types.Message, state: FSMContext,
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
if not message.text:
await message.answer(texts.t('REFERRAL_OR_PROMO_CODE_INVALID', '❌ Неверный реферальный код или промокод'))
return
from app.utils.promo_rate_limiter import promo_limiter, validate_promo_format
code = message.text.strip()
@@ -2341,6 +2472,33 @@ async def required_sub_channel_check(
except Exception as e:
logger.error('Ошибка при обработке реферальной регистрации', error=e)
# Применяем бонус рекламной кампании (record_campaign_registration)
campaign_message = await _apply_campaign_bonus_if_needed(db, user, state_data, texts)
try:
await db.refresh(user)
except Exception as refresh_error:
logger.error(
'Ошибка обновления данных пользователя после бонуса кампании',
telegram_id=user.telegram_id,
refresh_error=refresh_error,
)
try:
await db.refresh(user, ['subscription'])
except Exception as refresh_sub_error:
logger.error(
'Ошибка обновления подписки после бонуса кампании',
telegram_id=user.telegram_id,
refresh_sub_error=refresh_sub_error,
)
if campaign_message:
try:
await bot.send_message(
chat_id=query.from_user.id,
text=campaign_message,
)
except Exception as e:
logger.error('Ошибка отправки сообщения о бонусе кампании', error=e)
# Показываем главное меню после создания пользователя
has_active_subscription, subscription_is_active = _calculate_subscription_flags(user.subscription)
@@ -2439,6 +2597,43 @@ async def required_sub_channel_check(
pass
async def process_webauth_confirm(
callback: types.CallbackQuery,
db: AsyncSession,
):
"""Handle web auth confirmation or denial."""
await callback.answer()
if not isinstance(callback.message, types.Message):
return
if callback.data == 'webauth_deny':
await callback.message.edit_text('❌ Вход отменён.')
return
# Extract token from callback_data: "webauth_confirm:{token}"
token = callback.data.split(':', 1)[1] if ':' in callback.data else ''
if len(token) < WEB_AUTH_TOKEN_MIN_LENGTH:
await callback.message.edit_text('❌ Ошибка: неверный токен.')
return
user = await get_user_by_telegram_id(db, callback.from_user.id)
if not user or user.status != UserStatus.ACTIVE.value:
await callback.message.edit_text('❌ Учётная запись неактивна.')
return
linked = await link_web_auth_token(token, callback.from_user.id, user.id)
texts = get_texts(user.language)
if linked:
await callback.message.edit_text(
texts.t('WEB_AUTH_SUCCESS', '✅ Авторизация в кабинете подтверждена! Вернитесь в браузер.'),
)
else:
await callback.message.edit_text(
texts.t('WEB_AUTH_EXPIRED', '❌ Ссылка для входа истекла. Попробуйте снова.'),
)
def register_handlers(dp: Dispatcher):
logger.debug('=== НАЧАЛО регистрации обработчиков start.py ===')
@@ -2483,4 +2678,10 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(required_sub_channel_check, F.data.in_(['sub_channel_check']))
logger.debug('Зарегистрирован required_sub_channel_check')
dp.callback_query.register(
process_webauth_confirm,
F.data.startswith('webauth_confirm:') | F.data.in_(['webauth_deny']),
)
logger.debug('Зарегистрирован process_webauth_confirm')
logger.debug('=== КОНЕЦ регистрации обработчиков start.py ===')
+8 -1
View File
@@ -1,4 +1,5 @@
from aiogram import types
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
@@ -114,7 +115,13 @@ async def toggle_autopay(callback: types.CallbackQuery, db_user: User, db: Async
status = texts.t('AUTOPAY_STATUS_ENABLED', 'включен') if enable else texts.t('AUTOPAY_STATUS_DISABLED', 'выключен')
await callback.answer(texts.t('AUTOPAY_TOGGLE_SUCCESS', '✅ Автоплатеж {status}!').format(status=status))
await handle_autopay_menu(callback, db_user, db)
try:
await handle_autopay_menu(callback, db_user, db)
except TelegramBadRequest as e:
if 'message is not modified' in str(e):
pass
else:
raise
async def show_autopay_days(callback: types.CallbackQuery, db_user: User):
-37
View File
@@ -56,43 +56,6 @@ def _format_text_with_placeholders(template: str, values: dict[str, Any]) -> str
return template
def _get_addon_discount_percent_for_user(
user: User | None,
category: str,
period_days_hint: int | None = None,
) -> int:
if user is None:
return 0
promo_group = user.get_primary_promo_group()
if promo_group is None:
return 0
if not getattr(promo_group, 'apply_discounts_to_addons', True):
return 0
try:
return user.get_promo_discount(category, period_days_hint)
except AttributeError:
return 0
def _apply_addon_discount(
user: User | None,
category: str,
amount: int,
period_days_hint: int | None = None,
) -> dict[str, int]:
percent = _get_addon_discount_percent_for_user(user, category, period_days_hint)
discounted_amount, discount_value = apply_percentage_discount(amount, percent)
return {
'discounted': discounted_amount,
'discount': discount_value,
'percent': percent,
}
def _get_promo_offer_discount_percent(user: User | None) -> int:
return get_user_active_promo_discount_percent(user)
+34 -45
View File
@@ -5,9 +5,9 @@ from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import PERIOD_PRICES, settings
from app.config import settings
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.crud.user import lock_user_for_pricing, subtract_user_balance
from app.database.models import TransactionType, User
from app.keyboards.inline import (
get_back_keyboard,
@@ -17,6 +17,7 @@ from app.keyboards.inline import (
get_manage_countries_keyboard,
)
from app.localization.texts import get_texts
from app.services.pricing_engine import PricingEngine, pricing_engine
from app.services.subscription_checkout_service import (
save_subscription_checkout_draft,
should_offer_checkout_resume,
@@ -28,7 +29,7 @@ from app.utils.pricing_utils import (
calculate_prorated_price,
)
from .common import _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, logger
from .common import _get_period_hint_from_subscription, logger
from .summary import present_subscription_summary
@@ -58,7 +59,7 @@ async def handle_add_countries(callback: types.CallbackQuery, db_user: User, db:
current_countries = subscription.connected_squads
period_hint_days = _get_period_hint_from_subscription(subscription)
servers_discount_percent = _get_addon_discount_percent_for_user(
servers_discount_percent = PricingEngine.get_addon_discount_percent(
db_user,
'servers',
period_hint_days,
@@ -171,7 +172,7 @@ async def handle_manage_country(callback: types.CallbackQuery, db_user: User, db
countries = await _get_available_countries(db_user.promo_group_id)
allowed_country_ids = {country['uuid'] for country in countries}
if country_uuid not in allowed_country_ids and country_uuid not in current_selected:
if country_uuid not in allowed_country_ids:
texts = get_texts(db_user.language)
await callback.answer(
texts.t(
@@ -194,7 +195,7 @@ async def handle_manage_country(callback: types.CallbackQuery, db_user: User, db
await state.update_data(countries=current_selected)
period_hint_days = _get_period_hint_from_subscription(subscription)
servers_discount_percent = _get_addon_discount_percent_for_user(
servers_discount_percent = PricingEngine.get_addon_discount_percent(
db_user,
'servers',
period_hint_days,
@@ -235,11 +236,7 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User,
countries = await _get_available_countries(db_user.promo_group_id)
allowed_country_ids = {country['uuid'] for country in countries}
selected_countries = [
country_uuid
for country_uuid in selected_countries
if country_uuid in allowed_country_ids or country_uuid in current_countries
]
selected_countries = [country_uuid for country_uuid in selected_countries if country_uuid in allowed_country_ids]
added = [c for c in selected_countries if c not in current_countries]
removed = [c for c in current_countries if c not in selected_countries]
@@ -257,7 +254,12 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User,
days_to_pay = max(1, (subscription.end_date - now).days)
period_hint_days = days_to_pay if days_to_pay > 0 else None
servers_discount_percent = _get_addon_discount_percent_for_user(
# TOCTOU protection: lock user row before reading discount and charging balance
db_user = await lock_user_for_pricing(db, db_user.id)
subscription = db_user.subscription
servers_discount_percent = PricingEngine.get_addon_discount_percent(
db_user,
'servers',
period_hint_days,
@@ -392,7 +394,7 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User,
await db.commit()
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
await subscription_service.update_remnawave_user(db, subscription, sync_squads=True)
await db.refresh(subscription)
@@ -496,31 +498,18 @@ async def select_country(callback: types.CallbackQuery, state: FSMContext, db_us
await callback.answer('❌ Сервер недоступен для вашей промогруппы', show_alert=True)
return
period_base_price = PERIOD_PRICES.get(data['period_days'], 0)
discounted_base_price, _ = apply_percentage_discount(
period_base_price,
db_user.get_promo_discount('period', data['period_days']),
)
base_price = discounted_base_price + settings.get_traffic_price(data['traffic_gb'])
try:
subscription_service = SubscriptionService()
countries_price, _ = await subscription_service.get_countries_price_by_uuids(
selected_countries,
db,
promo_group_id=db_user.promo_group_id,
)
except AttributeError:
logger.warning('Используем fallback функцию для расчета цен стран')
countries_price, _ = await get_countries_price_by_uuids_fallback(
selected_countries,
db,
promo_group_id=db_user.promo_group_id,
)
data['countries'] = selected_countries
data['total_price'] = base_price + countries_price
# Вычисляем цену через PricingEngine с актуальными FSM-данными
pricing_result = await pricing_engine.calculate_classic_new_subscription_price(
db,
data['period_days'],
list(selected_countries),
data.get('traffic_gb', 0) or 0,
data.get('devices', settings.DEFAULT_DEVICE_LIMIT),
user=db_user,
)
data['total_price'] = pricing_result.final_total
await state.set_data(data)
await callback.message.edit_reply_markup(
@@ -700,7 +689,7 @@ async def handle_add_country_to_subscription(
total_price = 0
subscription = db_user.subscription
period_hint_days = _get_period_hint_from_subscription(subscription)
servers_discount_percent = _get_addon_discount_percent_for_user(
servers_discount_percent = PricingEngine.get_addon_discount_percent(
db_user,
'servers',
period_hint_days,
@@ -795,11 +784,7 @@ async def confirm_add_countries_to_subscription(
countries = await _get_available_countries(db_user.promo_group_id)
allowed_country_ids = {country['uuid'] for country in countries}
selected_countries = [
country_uuid
for country_uuid in selected_countries
if country_uuid in allowed_country_ids or country_uuid in current_countries
]
selected_countries = [country_uuid for country_uuid in selected_countries if country_uuid in allowed_country_ids]
new_countries = [c for c in selected_countries if c not in current_countries]
removed_countries = [c for c in current_countries if c not in selected_countries]
@@ -808,12 +793,16 @@ async def confirm_add_countries_to_subscription(
await callback.answer('⚠️ Изменения не обнаружены', show_alert=True)
return
# TOCTOU protection: lock user row before reading discount and charging balance
db_user = await lock_user_for_pricing(db, db_user.id)
subscription = db_user.subscription
total_price = 0
new_countries_names = []
removed_countries_names = []
period_hint_days = _get_period_hint_from_subscription(subscription)
servers_discount_percent = _get_addon_discount_percent_for_user(
servers_discount_percent = PricingEngine.get_addon_discount_percent(
db_user,
'servers',
period_hint_days,
@@ -909,7 +898,7 @@ async def confirm_add_countries_to_subscription(
await db.commit()
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
await subscription_service.update_remnawave_user(db, subscription, sync_squads=True)
await db.refresh(db_user)
await db.refresh(subscription)
+48 -8
View File
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.crud.user import lock_user_for_pricing, subtract_user_balance
from app.database.models import Subscription, TransactionType, User
from app.keyboards.inline import (
get_app_selection_keyboard,
@@ -21,6 +21,7 @@ from app.keyboards.inline import (
get_specific_app_keyboard,
)
from app.localization.texts import get_texts
from app.services.pricing_engine import PricingEngine
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_service import SubscriptionService
from app.services.user_cart_service import user_cart_service
@@ -33,7 +34,6 @@ from app.utils.subscription_utils import (
)
from .common import (
_get_addon_discount_percent_for_user,
_get_period_hint_from_subscription,
get_apps_for_platform_async,
get_device_name,
@@ -174,7 +174,7 @@ async def handle_change_devices(callback: types.CallbackQuery, db_user: User, db
current_devices = subscription.device_limit
period_hint_days = _get_period_hint_from_subscription(subscription)
devices_discount_percent = _get_addon_discount_percent_for_user(
devices_discount_percent = PricingEngine.get_addon_discount_percent(
db_user,
'devices',
period_hint_days,
@@ -325,7 +325,7 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
days_left = max(1, (subscription.end_date - now).days)
period_hint_days = days_left
devices_discount_percent = _get_addon_discount_percent_for_user(
devices_discount_percent = PricingEngine.get_addon_discount_percent(
db_user,
'devices',
period_hint_days,
@@ -345,7 +345,7 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
days_left = max(1, (subscription.end_date - now).days)
period_hint_days = days_left
devices_discount_percent = _get_addon_discount_percent_for_user(
devices_discount_percent = PricingEngine.get_addon_discount_percent(
db_user,
'devices',
period_hint_days,
@@ -492,10 +492,17 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
async def execute_change_devices(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
callback_parts = callback.data.split('_')
new_devices_count = int(callback_parts[3])
price = int(callback_parts[4])
db_user = await lock_user_for_pricing(db, db_user.id)
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not subscription:
await callback.answer(
texts.t('NO_ACTIVE_SUBSCRIPTION', '⚠️ У вас нет активной подписки'),
show_alert=True,
)
return
current_devices = subscription.device_limit
# Проверяем тариф подписки
@@ -514,12 +521,15 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
show_alert=True,
)
return
price_per_device = tariff_device_price
elif not settings.is_devices_selection_enabled():
await callback.answer(
texts.t('DEVICES_SELECTION_DISABLED', '⚠️ Изменение количества устройств недоступно'),
show_alert=True,
)
return
else:
price_per_device = settings.PRICE_PER_DEVICE
# Проверяем минимальное количество устройств на тарифе
tariff_min_devices = (getattr(tariff, 'device_limit', 1) or 1) if tariff else 1
@@ -533,6 +543,33 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
)
return
# Recompute price under lock (callback-baked value may be stale)
devices_difference = new_devices_count - current_devices
if devices_difference > 0:
if tariff:
chargeable_devices = devices_difference
elif current_devices < settings.DEFAULT_DEVICE_LIMIT:
free_devices = settings.DEFAULT_DEVICE_LIMIT - current_devices
chargeable_devices = max(0, devices_difference - free_devices)
else:
chargeable_devices = devices_difference
devices_price_per_month = chargeable_devices * price_per_device
days_left = max(1, (subscription.end_date - datetime.now(UTC)).days)
devices_discount_percent = PricingEngine.get_addon_discount_percent(
db_user,
'devices',
days_left,
)
discounted_per_month, _ = apply_percentage_discount(
devices_price_per_month,
devices_discount_percent,
)
price = int(discounted_per_month * days_left / 30)
price = max(100, price)
else:
price = 0
try:
if price > 0:
success = await subtract_user_balance(
@@ -1148,6 +1185,9 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
devices_price_per_month = devices_count * price_per_device
# TOCTOU: lock user row before reading promo/discount state
db_user = await lock_user_for_pricing(db, db_user.id)
# Проверяем является ли тариф суточным
is_daily_tariff = tariff and getattr(tariff, 'is_daily', False)
@@ -1157,7 +1197,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
days_left = max(1, (subscription.end_date - now).days)
period_hint_days = days_left
devices_discount_percent = _get_addon_discount_percent_for_user(
devices_discount_percent = PricingEngine.get_addon_discount_percent(
db_user,
'devices',
period_hint_days,
@@ -1177,7 +1217,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
days_left = max(1, (subscription.end_date - now).days)
period_hint_days = days_left
devices_discount_percent = _get_addon_discount_percent_for_user(
devices_discount_percent = PricingEngine.get_addon_discount_percent(
db_user,
'devices',
period_hint_days,
+104 -239
View File
@@ -4,18 +4,15 @@ from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import PERIOD_PRICES, settings
from app.config import settings
from app.database.models import User
from app.utils.pricing_utils import (
apply_percentage_discount,
calculate_months_from_days,
format_period_description,
validate_pricing_calculation,
)
from app.utils.timezone import format_local_datetime
from .common import _apply_discount_to_monthly_component, _apply_promo_offer_discount, logger
from .countries import _get_available_countries, _get_countries_info, get_countries_price_by_uuids_fallback
from .common import logger
from .countries import _get_available_countries, _get_countries_info
from .devices import get_current_devices_count
from .promo import _build_promo_group_discount_text, _get_promo_offer_hint
@@ -25,82 +22,18 @@ async def _prepare_subscription_summary(
data: dict[str, Any],
texts,
) -> tuple[str, dict[str, Any]]:
from app.database.database import AsyncSessionLocal
from app.services.pricing_engine import PricingEngine, pricing_engine
summary_data = dict(data)
if 'period_days' not in summary_data:
raise KeyError('period_days missing from subscription data — FSM state likely expired')
countries = await _get_available_countries(db_user.promo_group_id)
months_in_period = calculate_months_from_days(summary_data['period_days'])
period_display = format_period_description(summary_data['period_days'], db_user.language)
base_price_original = PERIOD_PRICES.get(summary_data['period_days'], 0)
period_discount_percent = db_user.get_promo_discount(
'period',
summary_data['period_days'],
)
base_price, base_discount_total = apply_percentage_discount(
base_price_original,
period_discount_percent,
)
if settings.is_traffic_fixed():
traffic_limit = settings.get_fixed_traffic_limit()
traffic_price_per_month = settings.get_traffic_price(traffic_limit)
final_traffic_gb = traffic_limit
else:
traffic_gb = summary_data.get('traffic_gb', 0)
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
final_traffic_gb = traffic_gb
traffic_discount_percent = db_user.get_promo_discount(
'traffic',
summary_data['period_days'],
)
traffic_component = _apply_discount_to_monthly_component(
traffic_price_per_month,
traffic_discount_percent,
months_in_period,
)
total_traffic_price = traffic_component['total']
countries_price_per_month = 0
selected_countries_names: list[str] = []
selected_server_prices: list[int] = []
server_monthly_prices: list[int] = []
selected_country_ids = set(summary_data.get('countries', []))
for country in countries:
if country['uuid'] in selected_country_ids:
server_price_per_month = country['price_kopeks']
countries_price_per_month += server_price_per_month
selected_countries_names.append(html.escape(country['name']))
server_monthly_prices.append(server_price_per_month)
servers_discount_percent = db_user.get_promo_discount(
'servers',
summary_data['period_days'],
)
total_countries_price = 0
total_servers_discount = 0
discounted_servers_price_per_month = 0
for server_price_per_month in server_monthly_prices:
discounted_per_month, discount_per_month = apply_percentage_discount(
server_price_per_month,
servers_discount_percent,
)
total_price_for_server = discounted_per_month * months_in_period
total_discount_for_server = discount_per_month * months_in_period
discounted_servers_price_per_month += discounted_per_month
total_countries_price += total_price_for_server
total_servers_discount += total_discount_for_server
selected_server_prices.append(total_price_for_server)
period_days = summary_data['period_days']
# --- Resolve device limit (same logic as before) ---
devices_selection_enabled = settings.is_devices_selection_enabled()
forced_disabled_limit: int | None = None
if devices_selection_enabled:
devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
else:
@@ -109,54 +42,75 @@ async def _prepare_subscription_summary(
devices_selected = settings.DEFAULT_DEVICE_LIMIT
else:
devices_selected = forced_disabled_limit
summary_data['devices'] = devices_selected
additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_discount_percent = db_user.get_promo_discount(
'devices',
summary_data['period_days'],
)
devices_component = _apply_discount_to_monthly_component(
devices_price_per_month,
devices_discount_percent,
months_in_period,
)
total_devices_price = devices_component['total']
total_price = base_price + total_traffic_price + total_countries_price + total_devices_price
# --- Resolve traffic ---
if settings.is_traffic_fixed():
final_traffic_gb = settings.get_fixed_traffic_limit()
else:
final_traffic_gb = summary_data.get('traffic_gb', 0)
# --- Resolve connected squads ---
connected_squads = list(summary_data.get('countries', []))
# --- Delegate pricing to PricingEngine ---
async with AsyncSessionLocal() as db:
pricing = await pricing_engine.calculate_classic_new_subscription_price(
db,
period_days,
connected_squads,
final_traffic_gb,
devices_selected,
user=db_user,
)
# --- Build legacy dict from PricingEngine result ---
details = PricingEngine.classic_pricing_to_purchase_details(pricing)
bd = pricing.breakdown
months_in_period = details['months_in_period']
base_price = details['base_price']
base_price_original = details['base_price_original']
base_discount_total = details['base_discount_total']
period_discount_percent = details['base_discount_percent']
traffic_price_per_month = details['traffic_price_per_month']
traffic_discount_percent = details['traffic_discount_percent']
traffic_discount_total = details['traffic_discount_total']
total_traffic_price = details['total_traffic_price']
servers_price_per_month = details['servers_price_per_month']
servers_discount_percent = details['servers_discount_percent']
servers_discount_total = details['servers_discount_total']
total_servers_price = details['total_servers_price']
devices_price_per_month = details['devices_price_per_month']
devices_discount_percent = details['devices_discount_percent']
devices_discount_total = details['devices_discount_total']
total_devices_price = details['total_devices_price']
# Compute discounted per-month values (not in classic_pricing_to_purchase_details)
traffic_discounted_per_month = PricingEngine.apply_discount(traffic_price_per_month, traffic_discount_percent)
servers_discounted_per_month = PricingEngine.apply_discount(servers_price_per_month, servers_discount_percent)
devices_discounted_per_month = PricingEngine.apply_discount(devices_price_per_month, devices_discount_percent)
discounted_monthly_additions = (
traffic_component['discounted_per_month']
+ discounted_servers_price_per_month
+ devices_component['discounted_per_month']
traffic_discounted_per_month + servers_discounted_per_month + devices_discounted_per_month
)
is_valid = validate_pricing_calculation(
base_price,
discounted_monthly_additions,
months_in_period,
total_price,
)
if not is_valid:
raise ValueError('Subscription price calculation validation failed')
original_total_price = total_price
promo_offer_component = _apply_promo_offer_discount(db_user, total_price)
if promo_offer_component['discount'] > 0:
total_price = promo_offer_component['discounted']
# --- Promo offer discount (already computed by PricingEngine) ---
promo_offer_discount = pricing.promo_offer_discount
offer_pct = bd.get('offer_discount_pct', 0)
# subtotal before promo offer = final_total + promo_offer_discount
subtotal_before_offer = pricing.final_total + promo_offer_discount
total_price = pricing.final_total
summary_data['total_price'] = total_price
if promo_offer_component['discount'] > 0:
summary_data['promo_offer_discount_percent'] = promo_offer_component['percent']
summary_data['promo_offer_discount_value'] = promo_offer_component['discount']
summary_data['total_price_before_promo_offer'] = original_total_price
if promo_offer_discount > 0:
summary_data['promo_offer_discount_percent'] = offer_pct
summary_data['promo_offer_discount_value'] = promo_offer_discount
summary_data['total_price_before_promo_offer'] = subtotal_before_offer
else:
summary_data.pop('promo_offer_discount_percent', None)
summary_data.pop('promo_offer_discount_value', None)
summary_data.pop('total_price_before_promo_offer', None)
summary_data['server_prices_for_period'] = selected_server_prices
summary_data['server_prices_for_period'] = details['servers_individual_prices']
summary_data['months_in_period'] = months_in_period
summary_data['base_price'] = base_price
summary_data['base_price_original'] = base_price_original
@@ -164,24 +118,27 @@ async def _prepare_subscription_summary(
summary_data['base_discount_total'] = base_discount_total
summary_data['final_traffic_gb'] = final_traffic_gb
summary_data['traffic_price_per_month'] = traffic_price_per_month
summary_data['traffic_discount_percent'] = traffic_component['discount_percent']
summary_data['traffic_discount_total'] = traffic_component['discount_total']
summary_data['traffic_discounted_price_per_month'] = traffic_component['discounted_per_month']
summary_data['traffic_discount_percent'] = traffic_discount_percent
summary_data['traffic_discount_total'] = traffic_discount_total
summary_data['traffic_discounted_price_per_month'] = traffic_discounted_per_month
summary_data['total_traffic_price'] = total_traffic_price
summary_data['servers_price_per_month'] = countries_price_per_month
summary_data['countries_price_per_month'] = countries_price_per_month
summary_data['servers_price_per_month'] = servers_price_per_month
summary_data['countries_price_per_month'] = servers_price_per_month
summary_data['servers_discount_percent'] = servers_discount_percent
summary_data['servers_discount_total'] = total_servers_discount
summary_data['servers_discounted_price_per_month'] = discounted_servers_price_per_month
summary_data['total_servers_price'] = total_countries_price
summary_data['total_countries_price'] = total_countries_price
summary_data['servers_discount_total'] = servers_discount_total
summary_data['servers_discounted_price_per_month'] = servers_discounted_per_month
summary_data['total_servers_price'] = total_servers_price
summary_data['total_countries_price'] = total_servers_price
summary_data['devices_price_per_month'] = devices_price_per_month
summary_data['devices_discount_percent'] = devices_component['discount_percent']
summary_data['devices_discount_total'] = devices_component['discount_total']
summary_data['devices_discounted_price_per_month'] = devices_component['discounted_per_month']
summary_data['devices_discount_percent'] = devices_discount_percent
summary_data['devices_discount_total'] = devices_discount_total
summary_data['devices_discounted_price_per_month'] = devices_discounted_per_month
summary_data['total_devices_price'] = total_devices_price
summary_data['discounted_monthly_additions'] = discounted_monthly_additions
# --- Build display text ---
period_display = format_period_description(period_days, db_user.language)
if settings.is_traffic_fixed():
if final_traffic_gb == 0:
traffic_display = 'Безлимитный'
@@ -192,6 +149,13 @@ async def _prepare_subscription_summary(
else:
traffic_display = f'{summary_data.get("traffic_gb", 0)} ГБ'
# Resolve country display names (still needed for the summary text)
countries = await _get_available_countries(db_user.promo_group_id)
selected_country_ids = set(connected_squads)
selected_countries_names: list[str] = [
html.escape(country['name']) for country in countries if country['uuid'] in selected_country_ids
]
details_lines = []
# Добавляем строку базового периода только если цена не равна 0
@@ -212,40 +176,34 @@ async def _prepare_subscription_summary(
f'- Трафик: {texts.format_price(traffic_price_per_month)}/мес × {months_in_period}'
f' = {texts.format_price(total_traffic_price)}'
)
if traffic_component['discount_total'] > 0:
traffic_line += (
f' (скидка {traffic_component["discount_percent"]}%:'
f' -{texts.format_price(traffic_component["discount_total"])})'
)
if traffic_discount_total > 0:
traffic_line += f' (скидка {traffic_discount_percent}%: -{texts.format_price(traffic_discount_total)})'
details_lines.append(traffic_line)
if total_countries_price > 0:
if total_servers_price > 0:
servers_line = (
f'- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period}'
f' = {texts.format_price(total_countries_price)}'
f'- Серверы: {texts.format_price(servers_price_per_month)}/мес × {months_in_period}'
f' = {texts.format_price(total_servers_price)}'
)
if total_servers_discount > 0:
servers_line += f' (скидка {servers_discount_percent}%: -{texts.format_price(total_servers_discount)})'
if servers_discount_total > 0:
servers_line += f' (скидка {servers_discount_percent}%: -{texts.format_price(servers_discount_total)})'
details_lines.append(servers_line)
if devices_selection_enabled and total_devices_price > 0:
devices_line = (
f'- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period}'
f' = {texts.format_price(total_devices_price)}'
)
if devices_component['discount_total'] > 0:
devices_line += (
f' (скидка {devices_component["discount_percent"]}%:'
f' -{texts.format_price(devices_component["discount_total"])})'
)
if devices_discount_total > 0:
devices_line += f' (скидка {devices_discount_percent}%: -{texts.format_price(devices_discount_total)})'
details_lines.append(devices_line)
if promo_offer_component['discount'] > 0:
if promo_offer_discount > 0:
details_lines.append(
texts.t(
'SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT',
'- Промо-предложение: -{amount} ({percent}% дополнительно)',
).format(
amount=texts.format_price(promo_offer_component['discount']),
percent=promo_offer_component['percent'],
amount=texts.format_price(promo_offer_discount),
percent=offer_pct,
)
)
@@ -309,114 +267,21 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
if subscription.is_trial:
return 0
from app.config import settings
from app.database.crud.tariff import get_tariff_by_id
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
from app.services.pricing_engine import pricing_engine
try:
owner = subscription.user
except AttributeError:
owner = None
promo_group_id = getattr(owner, 'promo_group_id', None) if owner else None
# В тарифном режиме цена тарифа уже включает серверы и трафик
tariff = None
tariff_price_found = False
if settings.is_tariffs_mode() and subscription.tariff_id:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff and tariff.period_prices:
base_cost_original = tariff.period_prices.get('30', 0) or tariff.period_prices.get(30, 0)
if base_cost_original > 0:
tariff_price_found = True
if not tariff_price_found:
base_cost_original = PERIOD_PRICES.get(30, 0)
if tariff_price_found:
# Тарифный режим: серверы и трафик включены в цену.
# Порядок: база + устройства → скидка на полную сумму (как в calculate_renewal_price).
from app.utils.promo_offer import get_user_active_promo_discount_percent
original_price = base_cost_original
tariff_device_limit = tariff.device_limit if tariff.device_limit is not None else 0
device_limit = subscription.device_limit if subscription.device_limit is not None else tariff_device_limit
extra_devices = max(0, device_limit - tariff_device_limit)
device_price_per_unit = (
tariff.device_price_kopeks
if tariff and tariff.device_price_kopeks is not None
else settings.PRICE_PER_DEVICE
)
devices_price = extra_devices * device_price_per_unit
original_price += devices_price
# Скидка промогруппы на полную сумму (база + устройства)
period_discount_percent = 0
if owner:
try:
period_discount_percent = owner.get_promo_discount('period', 30)
except AttributeError:
pass
discount_total = original_price * period_discount_percent // 100
total_cost = original_price - discount_total
# Promo-offer скидка (временная)
promo_offer_percent = get_user_active_promo_discount_percent(owner)
if promo_offer_percent > 0:
promo_offer_discount = total_cost * promo_offer_percent // 100
total_cost = total_cost - promo_offer_discount
else:
# Классический режим: серверы + трафик + устройства считаются отдельно
period_discount_percent = 0
if owner:
try:
period_discount_percent = owner.get_promo_discount('period', 30)
except AttributeError:
period_discount_percent = 0
base_cost, _ = apply_percentage_discount(
base_cost_original,
period_discount_percent,
)
try:
servers_cost, _ = await subscription_service.get_countries_price_by_uuids(
subscription.connected_squads,
db,
promo_group_id=promo_group_id,
)
except AttributeError:
servers_cost, _ = await get_countries_price_by_uuids_fallback(
subscription.connected_squads,
db,
promo_group_id=promo_group_id,
)
traffic_cost = settings.get_traffic_price(subscription.traffic_limit_gb)
device_limit = subscription.device_limit
if device_limit is None:
if settings.is_devices_selection_enabled():
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
forced_limit = settings.get_disabled_mode_device_limit()
if forced_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
device_limit = forced_limit
devices_cost = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
total_cost = base_cost + servers_cost + traffic_cost + devices_cost
logger.info('Месячная стоимость подписки', subscription_id=subscription.id, total_cost_kopeks=total_cost)
result = await pricing_engine.calculate_renewal_price(db, subscription, 30, user=owner)
total_cost = result.final_total
logger.info('Monthly subscription cost', subscription_id=subscription.id, total_cost_kopeks=total_cost)
return total_cost
except Exception as e:
logger.error('Ошибка расчета стоимости подписки', error=e)
logger.error('Error calculating subscription cost', error=e)
return 0
+129 -186
View File
@@ -9,7 +9,7 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import InaccessibleMessage, InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import PERIOD_PRICES, settings
from app.config import settings
from app.database.crud.subscription import (
create_paid_subscription,
create_pending_trial_subscription,
@@ -37,6 +37,7 @@ from app.keyboards.inline import (
)
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.pricing_engine import pricing_engine
from app.services.remnawave_service import RemnaWaveConfigurationError
from app.services.subscription_checkout_service import (
clear_subscription_checkout_draft,
@@ -99,7 +100,6 @@ from app.handlers.simple_subscription import (
from app.states import SubscriptionStates
from app.utils.price_display import PriceInfo, format_price_text
from app.utils.pricing_utils import (
apply_percentage_discount,
calculate_months_from_days,
format_period_description,
)
@@ -343,8 +343,23 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d
]
if is_daily:
# Для суточного тарифа показываем цену и прогресс-бар
daily_price = getattr(tariff, 'daily_price_kopeks', 0) / 100
# Для суточного тарифа показываем цену с учётом скидки промогруппы + promo-offer
raw_daily_kopeks = getattr(tariff, 'daily_price_kopeks', 0)
promo_group = (
db_user.get_primary_promo_group() if hasattr(db_user, 'get_primary_promo_group') else None
)
daily_group_pct = promo_group.get_discount_percent('period', 1) if promo_group else 0
from app.services.pricing_engine import PricingEngine
from app.utils.promo_offer import get_user_active_promo_discount_percent
daily_offer_pct = get_user_active_promo_discount_percent(db_user)
if daily_group_pct > 0 or daily_offer_pct > 0:
daily_kopeks, _, _ = PricingEngine.apply_stacked_discounts(
raw_daily_kopeks, daily_group_pct, daily_offer_pct
)
else:
daily_kopeks = raw_daily_kopeks
daily_price = daily_kopeks / 100
tariff_info_lines.append(f'Цена: {daily_price:.2f} ₽/день')
# Прогресс-бар до следующего списания
@@ -1735,9 +1750,11 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us
await callback.answer('⚠ У вас нет активной подписки', show_alert=True)
return
from app.database.crud.user import lock_user_for_pricing
from app.services.pricing_engine import pricing_engine
from app.services.subscription_renewal_service import SubscriptionRenewalChargeError, SubscriptionRenewalService
db_user = await lock_user_for_pricing(db, db_user.id)
months_in_period = calculate_months_from_days(days)
try:
@@ -1884,7 +1901,7 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us
await callback.answer()
async def select_period(callback: types.CallbackQuery, state: FSMContext, db_user: User):
async def select_period(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession):
period_days = int(callback.data.split('_')[1])
texts = get_texts(db_user.language)
@@ -1894,18 +1911,23 @@ async def select_period(callback: types.CallbackQuery, state: FSMContext, db_use
await callback.answer(texts.t('PERIOD_NOT_AVAILABLE', '❌ Этот период больше недоступен'), show_alert=True)
return
# Получаем цену с защитой от KeyError
period_price = PERIOD_PRICES.get(period_days, 0)
data = await state.get_data()
data['period_days'] = period_days
data['total_price'] = period_price
if settings.is_traffic_fixed():
fixed_traffic_price = settings.get_traffic_price(settings.get_fixed_traffic_limit())
data['total_price'] += fixed_traffic_price
data['traffic_gb'] = settings.get_fixed_traffic_limit()
# Вычисляем промежуточную цену через PricingEngine (countries/devices ещё не выбраны)
pricing_result = await pricing_engine.calculate_classic_new_subscription_price(
db,
period_days,
list(data.get('countries', [])),
data.get('traffic_gb', 0) or 0,
data.get('devices', settings.DEFAULT_DEVICE_LIMIT),
user=db_user,
)
data['total_price'] = pricing_result.final_total
await state.set_data(data)
if settings.is_traffic_selectable():
@@ -1958,7 +1980,7 @@ async def select_period(callback: types.CallbackQuery, state: FSMContext, db_use
await callback.answer()
async def select_devices(callback: types.CallbackQuery, state: FSMContext, db_user: User):
async def select_devices(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession):
texts = get_texts(db_user.language)
if not settings.is_devices_selection_enabled():
@@ -1980,27 +2002,27 @@ async def select_devices(callback: types.CallbackQuery, state: FSMContext, db_us
data = await state.get_data()
# Получаем цену периода с защитой от KeyError
period_days = data.get('period_days')
if not period_days or period_days not in PERIOD_PRICES:
if not period_days:
await callback.answer(
texts.t('PERIOD_NOT_AVAILABLE', '❌ Период больше недоступен, начните заново'), show_alert=True
)
return
base_price = PERIOD_PRICES.get(period_days, 0) + settings.get_traffic_price(data.get('traffic_gb', 0))
countries = await _get_available_countries(db_user.promo_group_id)
# Проверяем, что ключ 'countries' существует в данных перед доступом к нему
selected_countries = data.get('countries', [])
countries_price = sum(c['price_kopeks'] for c in countries if c['uuid'] in selected_countries)
devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
previous_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
data['devices'] = devices
data['total_price'] = base_price + countries_price + devices_price
# Вычисляем цену через PricingEngine с актуальными FSM-данными
pricing_result = await pricing_engine.calculate_classic_new_subscription_price(
db,
period_days,
list(data.get('countries', [])),
data.get('traffic_gb', 0) or 0,
devices,
user=db_user,
)
data['total_price'] = pricing_result.final_total
await state.set_data(data)
if devices != previous_devices:
@@ -2049,8 +2071,6 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
await save_subscription_checkout_draft(db_user.id, dict(data))
resume_callback = 'subscription_resume_checkout' if should_offer_checkout_resume(db_user, True) else None
countries = await _get_available_countries(db_user.promo_group_id)
period_days = data.get('period_days')
if period_days is None:
await callback.message.edit_text(
@@ -2059,62 +2079,8 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
)
await callback.answer()
return
months_in_period = data.get('months_in_period', calculate_months_from_days(period_days))
# Всегда пересчитываем base_price из PERIOD_PRICES для безопасности
# (не доверяем кэшированным значениям из FSM данных)
base_price_original = PERIOD_PRICES.get(period_days, 0)
base_discount_percent = db_user.get_promo_discount(
'period',
period_days,
)
base_price, base_discount_total = apply_percentage_discount(
base_price_original,
base_discount_percent,
)
server_prices = data.get('server_prices_for_period', [])
if not server_prices:
countries_price_per_month = 0
per_month_prices: list[int] = []
for country in countries:
# Проверяем, что ключ 'countries' существует в данных перед доступом к нему
selected_countries = data.get('countries', [])
if country['uuid'] in selected_countries:
server_price_per_month = country['price_kopeks']
countries_price_per_month += server_price_per_month
per_month_prices.append(server_price_per_month)
servers_discount_percent = db_user.get_promo_discount(
'servers',
period_days,
)
total_servers_price = 0
total_servers_discount = 0
discounted_servers_price_per_month = 0
server_prices = []
for server_price_per_month in per_month_prices:
discounted_per_month, discount_per_month = apply_percentage_discount(
server_price_per_month,
servers_discount_percent,
)
total_price_for_server = discounted_per_month * months_in_period
total_discount_for_server = discount_per_month * months_in_period
discounted_servers_price_per_month += discounted_per_month
total_servers_price += total_price_for_server
total_servers_discount += total_discount_for_server
server_prices.append(total_price_for_server)
total_countries_price = total_servers_price
else:
total_countries_price = data.get('total_servers_price', sum(server_prices))
countries_price_per_month = data.get('servers_price_per_month', 0)
discounted_servers_price_per_month = data.get('servers_discounted_price_per_month', countries_price_per_month)
total_servers_discount = data.get('servers_discount_total', 0)
servers_discount_percent = data.get('servers_discount_percent', 0)
# --- Resolve device limit (needed for PricingEngine and subscription creation) ---
devices_selection_enabled = settings.is_devices_selection_enabled()
forced_disabled_limit: int | None = None
if devices_selection_enabled:
@@ -2126,95 +2092,42 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
else:
devices_selected = forced_disabled_limit
additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = data.get('devices_price_per_month', additional_devices * settings.PRICE_PER_DEVICE)
devices_discount_percent = 0
discounted_devices_price_per_month = 0
devices_discount_total = 0
total_devices_price = 0
if devices_selection_enabled and additional_devices > 0:
if 'devices_discount_percent' in data:
devices_discount_percent = data.get('devices_discount_percent', 0)
discounted_devices_price_per_month = data.get('devices_discounted_price_per_month', devices_price_per_month)
devices_discount_total = data.get('devices_discount_total', 0)
total_devices_price = data.get('total_devices_price', discounted_devices_price_per_month * months_in_period)
else:
devices_discount_percent = db_user.get_promo_discount(
'devices',
period_days,
)
discounted_devices_price_per_month, discount_per_month = apply_percentage_discount(
devices_price_per_month,
devices_discount_percent,
)
devices_discount_total = discount_per_month * months_in_period
total_devices_price = discounted_devices_price_per_month * months_in_period
# --- Resolve traffic ---
if settings.is_traffic_fixed():
final_traffic_gb = settings.get_fixed_traffic_limit()
traffic_price_per_month = data.get('traffic_price_per_month', settings.get_traffic_price(final_traffic_gb))
else:
final_traffic_gb = data.get('final_traffic_gb', data.get('traffic_gb'))
traffic_gb = data.get('traffic_gb')
if traffic_gb is not None:
traffic_price_per_month = data.get('traffic_price_per_month', settings.get_traffic_price(traffic_gb))
else:
traffic_price_per_month = data.get('traffic_price_per_month', 0)
final_traffic_gb = data.get('final_traffic_gb', data.get('traffic_gb', 0))
if 'traffic_discount_percent' in data:
traffic_discount_percent = data.get('traffic_discount_percent', 0)
discounted_traffic_price_per_month = data.get('traffic_discounted_price_per_month', traffic_price_per_month)
traffic_discount_total = data.get('traffic_discount_total', 0)
total_traffic_price = data.get('total_traffic_price', discounted_traffic_price_per_month * months_in_period)
else:
traffic_discount_percent = db_user.get_promo_discount(
'traffic',
period_days,
)
discounted_traffic_price_per_month, discount_per_month = apply_percentage_discount(
traffic_price_per_month,
traffic_discount_percent,
)
traffic_discount_total = discount_per_month * months_in_period
total_traffic_price = discounted_traffic_price_per_month * months_in_period
total_servers_price = data.get('total_servers_price', total_countries_price)
# --- Resolve connected squads ---
connected_squads = list(data.get('countries', []))
cached_total_price = data.get('total_price', 0)
cached_promo_discount_value = data.get('promo_offer_discount_value', 0)
# Всегда пересчитываем monthly_additions из компонентов для безопасности
discounted_monthly_additions = (
discounted_traffic_price_per_month + discounted_servers_price_per_month + discounted_devices_price_per_month
# Lock user BEFORE promo-offer read to prevent TOCTOU
from app.database.crud.user import lock_user_for_pricing
db_user = await lock_user_for_pricing(db, db_user.id)
# --- Delegate pricing to PricingEngine ---
from app.services.pricing_engine import PricingEngine, pricing_engine
pricing_result = await pricing_engine.calculate_classic_new_subscription_price(
db,
period_days,
connected_squads,
final_traffic_gb,
devices_selected,
user=db_user,
)
details = PricingEngine.classic_pricing_to_purchase_details(pricing_result)
# Вычисляем ожидаемую цену до промо-скидки из компонентов
calculated_total_before_promo = base_price + (discounted_monthly_additions * months_in_period)
final_price = pricing_result.final_total
server_prices = details['servers_individual_prices']
months_in_period = details['months_in_period']
promo_offer_discount_value = pricing_result.promo_offer_discount
promo_offer_discount_percent = pricing_result.breakdown.get('offer_discount_pct', 0)
# Получаем сохраненную цену до промо-скидки или используем вычисленную
validation_total_price = data.get('total_price_before_promo_offer')
if validation_total_price is None and cached_promo_discount_value > 0:
validation_total_price = cached_total_price + cached_promo_discount_value
if validation_total_price is None:
validation_total_price = cached_total_price
current_promo_offer_percent = _get_promo_offer_discount_percent(db_user)
if current_promo_offer_percent > 0:
final_price, promo_offer_discount_value = apply_percentage_discount(
calculated_total_before_promo,
current_promo_offer_percent,
)
promo_offer_discount_percent = current_promo_offer_percent
else:
final_price = calculated_total_before_promo
promo_offer_discount_value = 0
promo_offer_discount_percent = 0
# Валидация: проверяем что cached_total_price соответствует ожидаемой финальной цене
# Блокируем только если цена ВЫРОСЛА (пользователь переплатит).
# Если цена снизилась (промо-скидка активировалась) — разрешаем покупку по новой цене.
# --- Price validation: block if price increased significantly vs cached FSM price ---
price_difference = final_price - cached_total_price
if price_difference > 0:
max_allowed_increase = max(500, int(final_price * 0.05)) # 5% или минимум 5₽
@@ -2244,36 +2157,50 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
final_price=final_price / 100,
)
# Используем пересчитанную цену
validation_total_price = calculated_total_before_promo
# --- Logging ---
base_price_original = details['base_price_original']
base_price = details['base_price']
base_discount_total = details['base_discount_total']
base_discount_percent = details['base_discount_percent']
logger.info('Расчет покупки подписки на дней ( мес)', data=data['period_days'], months_in_period=months_in_period)
base_log = f' Период: {base_price_original / 100}'
if base_discount_total and base_discount_total > 0:
base_log += f'{base_price / 100}₽ (скидка {base_discount_percent}%: -{base_discount_total / 100}₽)'
logger.info(base_log)
if total_traffic_price > 0:
message = f' Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period} = {total_traffic_price / 100}'
if traffic_discount_total > 0:
message += f' (скидка {traffic_discount_percent}%: -{traffic_discount_total / 100})'
logger.info(message)
if total_servers_price > 0:
message = (
f' Серверы: {countries_price_per_month / 100}₽/мес × {months_in_period} = {total_servers_price / 100}'
if details['total_traffic_price'] > 0:
traffic_msg = (
f' Трафик: {details["traffic_price_per_month"] / 100}₽/мес'
f' × {months_in_period} = {details["total_traffic_price"] / 100}'
)
if total_servers_discount > 0:
message += f' (скидка {servers_discount_percent}%: -{total_servers_discount / 100}₽)'
logger.info(message)
if total_devices_price > 0:
message = (
f' Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period} = {total_devices_price / 100}'
if details['traffic_discount_total'] > 0:
traffic_msg += (
f' (скидка {details["traffic_discount_percent"]}%: -{details["traffic_discount_total"] / 100}₽)'
)
logger.info(traffic_msg)
if details['total_servers_price'] > 0:
servers_msg = (
f' Серверы: {details["servers_price_per_month"] / 100}₽/мес'
f' × {months_in_period} = {details["total_servers_price"] / 100}'
)
if devices_discount_total > 0:
message += f' (скидка {devices_discount_percent}%: -{devices_discount_total / 100}₽)'
logger.info(message)
if details['servers_discount_total'] > 0:
servers_msg += (
f' (скидка {details["servers_discount_percent"]}%: -{details["servers_discount_total"] / 100}₽)'
)
logger.info(servers_msg)
if details['total_devices_price'] > 0:
devices_msg = (
f' Устройства: {details["devices_price_per_month"] / 100}₽/мес'
f' × {months_in_period} = {details["total_devices_price"] / 100}'
)
if details['devices_discount_total'] > 0:
devices_msg += (
f' (скидка {details["devices_discount_percent"]}%: -{details["devices_discount_total"] / 100}₽)'
)
logger.info(devices_msg)
if promo_offer_discount_value > 0:
logger.info(
'🎯 Промо-предложение: -₽ (%)',
'Промо-предложение: -₽ (%)',
promo_offer_discount_value=promo_offer_discount_value / 100,
promo_offer_discount_percent=promo_offer_discount_percent,
)
@@ -2521,6 +2448,7 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
subscription,
reset_traffic=True,
reset_reason='покупка подписки',
sync_squads=True,
)
else:
remnawave_user = await subscription_service.create_remnawave_user(
@@ -2953,7 +2881,16 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
# При возобновлении проверяем баланс
if needs_resume:
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0)
from app.database.crud.user import lock_user_for_pricing
from app.services.pricing_engine import PricingEngine
db_user = await lock_user_for_pricing(db, db_user.id)
promo_group = PricingEngine.resolve_promo_group(db_user)
daily_group_pct = promo_group.get_discount_percent('period', 1) if promo_group else 0
daily_price = (
PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price
)
if daily_price > 0 and db_user.balance_kopeks < daily_price:
await callback.answer(
texts.t(
@@ -2966,7 +2903,6 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
if needs_resume:
# Списываем суточную оплату ДО активации (чтобы не было бесплатного дня)
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
if daily_price > 0 and is_inactive:
from app.database.crud.user import subtract_user_balance
@@ -4147,13 +4083,14 @@ async def _extend_existing_subscription(
):
"""Продлевает существующую подписку."""
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.crud.user import lock_user_for_pricing, subtract_user_balance
from app.database.models import TransactionType
from app.services.subscription_service import SubscriptionService
db_user = await lock_user_for_pricing(db, db_user.id)
texts = get_texts(db_user.language)
# Рассчитываем цену подписки
# Рассчитываем цену подписки (group discounts per-category)
subscription_params = {
'period_days': period_days,
'device_limit': device_limit,
@@ -4166,6 +4103,12 @@ async def _extend_existing_subscription(
user=db_user,
resolved_squad_uuid=squad_uuid,
)
# PricingEngine already applies promo-offer discount inside calculate_classic_new_subscription_price.
# Only determine whether to consume the offer (zero it out after use).
from app.utils.promo_offer import get_user_active_promo_discount_percent
consume_promo = get_user_active_promo_discount_percent(db_user) > 0
logger.warning(
'SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user= | total= | base= | traffic= | devices= | servers= | discount= | device_limit',
db_user_id=db_user.id,
@@ -4212,7 +4155,7 @@ async def _extend_existing_subscription(
'device_limit': device_limit,
'traffic_limit_gb': traffic_limit_gb,
'squad_uuid': squad_uuid,
'consume_promo_offer': False,
'consume_promo_offer': consume_promo,
}
await user_cart_service.save_user_cart(db_user.id, cart_data)
@@ -4233,7 +4176,7 @@ async def _extend_existing_subscription(
db_user,
price_kopeks,
f'Продление подписки на {period_days} дней',
consume_promo_offer=False, # Простая покупка не использует промо-скидки
consume_promo_offer=consume_promo,
mark_as_paid_subscription=True,
)
+357 -193
View File
@@ -79,9 +79,14 @@ def format_tariffs_list_text(
discount_icon = ''
if is_daily:
# Для суточных тарифов показываем цену за день
# Для суточных тарифов показываем цену за день с учётом скидки промогруппы
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
price_text = f'🔄 {format_price_kopeks(daily_price, compact=True)}/день'
if db_user:
group_pct, offer_pct, daily_discount = _get_user_period_discount(db_user, 1)
if daily_discount > 0:
daily_price = _apply_promo_discount(daily_price, group_pct, offer_pct)
discount_icon = '🔥'
price_text = f'🔄 {format_price_kopeks(daily_price, compact=True)}/день{discount_icon}'
else:
# Для периодных тарифов показываем минимальную цену
prices = tariff.period_prices or {}
@@ -394,21 +399,42 @@ def _calculate_custom_tariff_price(
return period_price, traffic_price, total_price
def format_custom_tariff_preview(
async def format_custom_tariff_preview(
tariff: Tariff,
days: int,
traffic_gb: int,
user_balance: int,
db_user: User | None = None,
discount_percent: int = 0,
group_pct: int = 0,
offer_pct: int = 0,
) -> str:
"""Форматирует предпросмотр покупки с кастомными параметрами."""
period_price, traffic_price, total_price = _calculate_custom_tariff_price(tariff, days, traffic_gb)
"""Форматирует предпросмотр покупки с кастомными параметрами.
# Применяем скидку
if discount_percent > 0:
total_price = _apply_promo_discount(total_price, group_pct, offer_pct)
Uses PricingEngine when db_user is provided for accurate per-category discounts
(period, traffic addon). Falls back to manual calculation otherwise.
"""
if db_user is not None:
# Use PricingEngine — single source of truth for all discounts
from app.services.pricing_engine import pricing_engine
result = await pricing_engine.calculate_tariff_purchase_price(
tariff,
days,
device_limit=tariff.device_limit,
custom_traffic_gb=traffic_gb if tariff.can_purchase_custom_traffic() else None,
user=db_user,
)
period_price = result.base_price
traffic_price = result.traffic_price
total_price = result.final_total
has_discount = result.promo_group_discount > 0 or result.promo_offer_discount > 0
else:
# Fallback: raw prices without discounts
period_price, traffic_price, total_price = _calculate_custom_tariff_price(tariff, days, traffic_gb)
has_discount = discount_percent > 0
if has_discount:
total_price = _apply_promo_discount(total_price, group_pct, offer_pct)
traffic_display = f'{traffic_gb} ГБ' if traffic_gb > 0 else format_traffic(tariff.traffic_limit_gb)
@@ -433,7 +459,7 @@ def format_custom_tariff_preview(
text += f'📱 Устройств: {tariff.device_limit}\n'
if discount_percent > 0:
if has_discount:
text += f'\n🎁 <b>Скидка: {discount_percent}%</b>\n'
text += f"""
@@ -477,7 +503,9 @@ async def show_tariffs_list(
return
# Проверяем есть ли у пользователя скидки по периодам
promo_group = getattr(db_user, 'promo_group', None)
promo_group = db_user.get_primary_promo_group() if hasattr(db_user, 'get_primary_promo_group') else None
if promo_group is None:
promo_group = getattr(db_user, 'promo_group', None)
has_period_discounts = False
if promo_group:
period_discounts = getattr(promo_group, 'period_discounts', None)
@@ -514,7 +542,12 @@ async def select_tariff(
if is_daily:
# Для суточного тарифа показываем подтверждение без выбора периода
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0)
group_pct, offer_pct, daily_discount = _get_user_period_discount(db_user, 1)
daily_price = (
_apply_promo_discount(raw_daily_price, group_pct, offer_pct) if daily_discount > 0 else raw_daily_price
)
discount_text = f'\n💎 Скидка: {daily_discount}%' if daily_discount > 0 else ''
user_balance = db_user.balance_kopeks or 0
traffic = format_traffic(tariff.traffic_limit_gb)
@@ -525,7 +558,8 @@ async def select_tariff(
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n'
f'🔄 Тип: <b>Суточный</b>\n\n'
f'💰 <b>Цена: {format_price_kopeks(daily_price)}/день</b>\n\n'
f'💰 <b>Цена: {format_price_kopeks(daily_price)}/день</b>'
f'{discount_text}\n\n'
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n\n'
f'ℹ️ Средства будут списываться автоматически раз в сутки.\n'
f'Вы можете приостановить подписку в любой момент.',
@@ -557,7 +591,8 @@ async def select_tariff(
f'❌ <b>Недостаточно средств</b>\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n'
f'🔄 Тип: Суточный\n'
f'💰 Цена: {format_price_kopeks(daily_price)}/день\n\n'
f'💰 Цена: {format_price_kopeks(daily_price)}/день'
f'{discount_text}\n\n'
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n'
f'⚠️ Не хватает: <b>{format_price_kopeks(missing)}</b>\n\n'
f'🛒 <i>Корзина сохранена! После пополнения баланса подписка будет оформлена автоматически.</i>',
@@ -588,14 +623,13 @@ async def select_tariff(
period_offer_pct=offer_pct,
)
preview_text = format_custom_tariff_preview(
preview_text = await format_custom_tariff_preview(
tariff=tariff,
days=initial_days,
traffic_gb=initial_traffic,
user_balance=user_balance,
db_user=db_user,
discount_percent=discount_percent,
group_pct=group_pct,
offer_pct=offer_pct,
)
await callback.message.edit_text(
@@ -672,14 +706,13 @@ async def handle_custom_days_change(
user_balance = db_user.balance_kopeks or 0
preview_text = format_custom_tariff_preview(
preview_text = await format_custom_tariff_preview(
tariff=tariff,
days=new_days,
traffic_gb=current_traffic,
user_balance=user_balance,
db_user=db_user,
discount_percent=discount_percent,
group_pct=group_pct,
offer_pct=offer_pct,
)
await callback.message.edit_text(
@@ -722,8 +755,6 @@ async def handle_custom_traffic_change(
current_days = state_data.get('custom_days', tariff.min_days)
current_traffic = state_data.get('custom_traffic_gb', tariff.min_traffic_gb)
discount_percent = state_data.get('period_discount_percent', 0)
group_pct = state_data.get('period_group_pct', 0)
offer_pct = state_data.get('period_offer_pct', 0)
# Применяем изменение
new_traffic = current_traffic + delta
@@ -733,14 +764,13 @@ async def handle_custom_traffic_change(
user_balance = db_user.balance_kopeks or 0
preview_text = format_custom_tariff_preview(
preview_text = await format_custom_tariff_preview(
tariff=tariff,
days=current_days,
traffic_gb=new_traffic,
user_balance=user_balance,
db_user=db_user,
discount_percent=discount_percent,
group_pct=group_pct,
offer_pct=offer_pct,
)
await callback.message.edit_text(
@@ -777,28 +807,33 @@ async def handle_custom_confirm(
await callback.answer('Тариф недоступен', show_alert=True)
return
# Lock user BEFORE price computation to prevent TOCTOU on promo offer
from app.database.crud.user import lock_user_for_pricing
db_user = await lock_user_for_pricing(db, db_user.id)
state_data = await state.get_data()
custom_days = state_data.get('custom_days', tariff.min_days)
custom_traffic = state_data.get('custom_traffic_gb', tariff.min_traffic_gb)
discount_percent = state_data.get('period_discount_percent', 0)
group_pct = state_data.get('period_group_pct', 0)
offer_pct = state_data.get('period_offer_pct', 0)
# Рассчитываем цену (используем общую функцию)
period_price, traffic_price, total_price = _calculate_custom_tariff_price(tariff, custom_days, custom_traffic)
# Calculate price via PricingEngine (single source of truth for all discounts)
from app.services.pricing_engine import pricing_engine
result = await pricing_engine.calculate_tariff_purchase_price(
tariff,
custom_days,
device_limit=tariff.device_limit,
custom_traffic_gb=custom_traffic if tariff.can_purchase_custom_traffic() else None,
user=db_user,
)
total_price = result.final_total
# Проверяем, что цена за период валидна
if period_price == 0 and not tariff.can_purchase_custom_days():
# Период не найден в period_prices - ошибка
if result.base_price == 0 and not tariff.can_purchase_custom_days():
await callback.answer('Выбранный период недоступен для этого тарифа', show_alert=True)
return
# Применяем скидку к цене периода (не к трафику)
if discount_percent > 0:
period_price = _apply_promo_discount(period_price, group_pct, offer_pct)
total_price = period_price + traffic_price
# Проверяем баланс
# Проверяем баланс (user already locked, balance is fresh)
user_balance = db_user.balance_kopeks or 0
if user_balance < total_price:
await callback.answer('Недостаточно средств на балансе', show_alert=True)
@@ -807,7 +842,7 @@ async def handle_custom_confirm(
texts = get_texts(db_user.language)
# Save promo offer state before deduction (for restore on failure)
consume_promo = get_user_active_promo_discount_percent(db_user) > 0
consume_promo = result.promo_offer_discount > 0
saved_promo_percent = int(getattr(db_user, 'promo_offer_discount_percent', 0) or 0) if consume_promo else 0
saved_promo_source = getattr(db_user, 'promo_offer_discount_source', None) if consume_promo else None
saved_promo_expires = getattr(db_user, 'promo_offer_discount_expires_at', None) if consume_promo else None
@@ -1014,14 +1049,13 @@ async def select_tariff_period_with_traffic(
period_offer_pct=offer_pct,
)
preview_text = format_custom_tariff_preview(
preview_text = await format_custom_tariff_preview(
tariff=tariff,
days=period,
traffic_gb=initial_traffic,
user_balance=user_balance,
db_user=db_user,
discount_percent=discount_percent,
group_pct=group_pct,
offer_pct=offer_pct,
)
await callback.message.edit_text(
@@ -1152,34 +1186,28 @@ async def confirm_tariff_purchase(
await callback.answer('Тариф недоступен', show_alert=True)
return
# Получаем цену
prices = tariff.period_prices or {}
base_price = prices.get(str(period), 0)
# Lock user BEFORE price computation to prevent TOCTOU on promo offer
from app.database.crud.user import lock_user_for_pricing
db_user = await lock_user_for_pricing(db, db_user.id)
# Calculate price via PricingEngine (single source of truth)
from app.services.pricing_engine import pricing_engine
# Add extra device cost if user has more devices than tariff's included limit
existing_sub = await get_subscription_by_user_id(db, db_user.id)
device_price_per_unit = (
tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE
)
extra_devices = 0
device_limit = None
if existing_sub and existing_sub.tariff_id == tariff.id:
extra_devices = max(0, (existing_sub.device_limit or 0) - (tariff.device_limit or 0))
devices_price = extra_devices * device_price_per_unit
device_limit = existing_sub.device_limit
# Apply discounts sequentially (matching PricingEngine): group first, then offer
subtotal = base_price + devices_price
promo_group = db_user.get_primary_promo_group()
group_discount_pct = promo_group.get_discount_percent('period', period) if promo_group else 0
if group_discount_pct > 0:
subtotal = subtotal - subtotal * group_discount_pct // 100
result = await pricing_engine.calculate_tariff_purchase_price(
tariff,
period,
device_limit=device_limit,
user=db_user,
)
final_price = result.final_total
offer_discount_pct = get_user_active_promo_discount_percent(db_user)
if offer_discount_pct > 0:
subtotal = subtotal - subtotal * offer_discount_pct // 100
final_price = max(0, subtotal)
# Проверяем баланс
# Проверяем баланс (user already locked, balance is fresh)
user_balance = db_user.balance_kopeks or 0
if user_balance < final_price:
await callback.answer('Недостаточно средств на балансе', show_alert=True)
@@ -1188,7 +1216,7 @@ async def confirm_tariff_purchase(
texts = get_texts(db_user.language)
# Списываем баланс
consume_promo = get_user_active_promo_discount_percent(db_user) > 0
consume_promo = result.promo_offer_discount > 0
# Save promo offer state before deduction (for restore on failure)
saved_promo_percent = int(getattr(db_user, 'promo_offer_discount_percent', 0) or 0) if consume_promo else 0
saved_promo_source = getattr(db_user, 'promo_offer_discount_source', None) if consume_promo else None
@@ -1382,9 +1410,26 @@ async def confirm_daily_tariff_purchase(
await callback.answer('Некорректная цена тарифа', show_alert=True)
return
# Проверяем баланс
# Lock user BEFORE price computation to prevent TOCTOU on promo offer
from app.database.crud.user import lock_user_for_pricing
db_user = await lock_user_for_pricing(db, db_user.id)
# Apply group + promo-offer discounts via PricingEngine (single source of truth)
from app.services.pricing_engine import pricing_engine
pricing_result = await pricing_engine.calculate_tariff_purchase_price(
tariff,
period_days=1,
device_limit=tariff.device_limit,
user=db_user,
)
final_daily_price = pricing_result.final_total
consume_promo = pricing_result.breakdown.get('offer_discount_pct', 0) > 0
# Проверяем баланс (user already locked, balance is fresh)
user_balance = db_user.balance_kopeks or 0
if user_balance < daily_price:
if user_balance < final_daily_price:
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
@@ -1395,8 +1440,9 @@ async def confirm_daily_tariff_purchase(
success = await subtract_user_balance(
db,
db_user,
daily_price,
final_daily_price,
f'Покупка суточного тарифа {tariff.name} (первый день)',
consume_promo_offer=consume_promo,
mark_as_paid_subscription=True,
)
if not success:
@@ -1423,7 +1469,7 @@ async def confirm_daily_tariff_purchase(
try:
if existing_subscription:
# Обновляем существующую подписку на суточный тариф
# Сохраняем докупленные устройства при смене тарифа
# Сбрасываем лимит устройств на базу нового тарифа (докупленные не переносятся)
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
old_tariff = (
@@ -1485,7 +1531,7 @@ async def confirm_daily_tariff_purchase(
await add_user_balance(
db,
db_user,
daily_price,
final_daily_price,
'Возврат: ошибка покупки суточного тарифа',
create_transaction=True,
transaction_type=TransactionType.REFUND,
@@ -1494,7 +1540,7 @@ async def confirm_daily_tariff_purchase(
logger.critical(
'CRITICAL: не удалось вернуть средства после ошибки покупки суточного тарифа',
user_id=db_user.id,
price_kopeks=daily_price,
price_kopeks=final_daily_price,
refund_error=refund_error,
)
await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True)
@@ -1518,7 +1564,7 @@ async def confirm_daily_tariff_purchase(
db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=daily_price,
amount_kopeks=final_daily_price,
description=f'Покупка суточного тарифа {tariff.name} (первый день)',
)
@@ -1532,7 +1578,7 @@ async def confirm_daily_tariff_purchase(
None,
1, # 1 день
was_trial_conversion=False,
amount_kopeks=daily_price,
amount_kopeks=final_daily_price,
purchase_type='renewal' if existing_subscription else 'first_purchase',
)
except Exception as e:
@@ -1555,7 +1601,7 @@ async def confirm_daily_tariff_purchase(
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n'
f'🔄 Тип: Суточный\n'
f'💰 Списано: {format_price_kopeks(daily_price)}\n\n'
f'💰 Списано: {format_price_kopeks(final_daily_price)}\n\n'
f'ℹ️ Следующее списание через 24 часа.\n'
f'Перейдите в раздел «Подписка» для подключения.',
reply_markup=InlineKeyboardMarkup(
@@ -1591,26 +1637,39 @@ def get_tariff_extend_keyboard(
subscription_device_limit: int | None = None,
) -> InlineKeyboardMarkup:
"""Создает клавиатуру выбора периода для продления по тарифу с учетом скидок по периодам."""
from app.services.pricing_engine import PricingEngine
texts = get_texts(language)
buttons = []
promo_group = PricingEngine.resolve_promo_group(db_user) if db_user else None
prices = tariff.period_prices or {}
for period_str in sorted(prices.keys(), key=int):
period = int(period_str)
price = prices[period_str]
base_price = prices[period_str]
# Добавляем стоимость дополнительных устройств
# Стоимость дополнительных устройств
devices_cost = 0
if subscription_device_limit is not None:
price += _calc_extra_devices_cost(tariff, subscription_device_limit, period)
devices_cost = _calc_extra_devices_cost(tariff, subscription_device_limit, period)
# Получаем скидку для конкретного периода
group_pct, offer_pct, discount_percent = 0, 0, 0
if db_user:
group_pct, offer_pct, discount_percent = _get_user_period_discount(db_user, period)
# Per-category group discounts (period + devices separately, like PricingEngine)
period_pct = promo_group.get_discount_percent('period', period) if promo_group else 0
devices_pct = promo_group.get_discount_percent('devices', period) if promo_group else 0
offer_pct = get_user_active_promo_discount_percent(db_user) if db_user else 0
if discount_percent > 0:
price = _apply_promo_discount(price, group_pct, offer_pct)
price_text = f'{format_price_kopeks(price)} 🔥−{discount_percent}%'
discounted_base = PricingEngine.apply_discount(base_price, period_pct)
discounted_devices = PricingEngine.apply_discount(devices_cost, devices_pct)
subtotal = discounted_base + discounted_devices
price = PricingEngine.apply_discount(subtotal, offer_pct)
# Combined display discount
total_original = base_price + devices_cost
has_discount = price < total_original and total_original > 0
if has_discount:
combined_pct = round((1 - price / total_original) * 100)
price_text = f'{format_price_kopeks(price)} 🔥−{combined_pct}%'
else:
price_text = format_price_kopeks(price)
@@ -1662,7 +1721,9 @@ async def show_tariff_extend(
traffic = format_traffic(tariff.traffic_limit_gb)
# Проверяем есть ли у пользователя скидки по периодам
promo_group = getattr(db_user, 'promo_group', None)
promo_group = db_user.get_primary_promo_group() if hasattr(db_user, 'get_primary_promo_group') else None
if promo_group is None:
promo_group = getattr(db_user, 'promo_group', None)
has_period_discounts = False
if promo_group:
period_discounts = getattr(promo_group, 'period_discounts', None)
@@ -1716,14 +1777,21 @@ async def select_tariff_extend_period(
subscription = await get_subscription_by_user_id(db, db_user.id)
actual_device_limit = (subscription.device_limit if subscription else None) or tariff.device_limit
# Получаем скидку для выбранного периода
group_pct, offer_pct, discount_percent = _get_user_period_discount(db_user, period)
# Calculate price via PricingEngine (per-category discounts: period + devices)
from app.services.pricing_engine import pricing_engine
# Получаем цену (тариф + дополнительные устройства)
prices = tariff.period_prices or {}
base_price = prices.get(str(period), 0)
base_price += _calc_extra_devices_cost(tariff, actual_device_limit, period)
final_price = _apply_promo_discount(base_price, group_pct, offer_pct)
result = await pricing_engine.calculate_tariff_purchase_price(
tariff,
period,
device_limit=actual_device_limit,
user=db_user,
)
final_price = result.final_total
original_price = result.original_total
total_discount = result.promo_group_discount + result.promo_offer_discount
discount_percent = (
round((1 - final_price / original_price) * 100) if original_price > 0 and total_discount > 0 else 0
)
# Проверяем баланс
user_balance = db_user.balance_kopeks or 0
@@ -1733,7 +1801,7 @@ async def select_tariff_extend_period(
if user_balance >= final_price:
discount_text = ''
if discount_percent > 0:
discount_text = f'\n🎁 Скидка: {discount_percent}% (-{format_price_kopeks(base_price - final_price)})'
discount_text = f'\n🎁 Скидка: {discount_percent}% (-{format_price_kopeks(total_discount)})'
await callback.message.edit_text(
f'✅ <b>Подтверждение продления</b>\n\n'
@@ -1791,8 +1859,6 @@ async def select_tariff_extend_period(
extend_tariff_id=tariff_id,
extend_period=period,
extend_discount_percent=discount_percent,
extend_group_pct=group_pct,
extend_offer_pct=offer_pct,
)
await callback.answer()
@@ -1821,15 +1887,21 @@ async def confirm_tariff_extend(
actual_device_limit = subscription.device_limit or tariff.device_limit
data = await state.get_data()
group_pct = data.get('extend_group_pct', 0)
offer_pct = data.get('extend_offer_pct', 0)
from app.database.crud.user import lock_user_for_pricing
# Получаем цену (тариф + дополнительные устройства)
prices = tariff.period_prices or {}
base_price = prices.get(str(period), 0)
base_price += _calc_extra_devices_cost(tariff, actual_device_limit, period)
final_price = _apply_promo_discount(base_price, group_pct, offer_pct)
db_user = await lock_user_for_pricing(db, db_user.id)
# Calculate price via PricingEngine (handles per-category discounts: period + devices)
from app.services.pricing_engine import pricing_engine
result = await pricing_engine.calculate_tariff_purchase_price(
tariff,
period,
device_limit=actual_device_limit,
user=db_user,
)
final_price = result.final_total
consume_promo = result.promo_offer_discount > 0
# Проверяем баланс
user_balance = db_user.balance_kopeks or 0
@@ -1846,7 +1918,7 @@ async def confirm_tariff_extend(
db_user,
final_price,
f'Продление тарифа {tariff.name} на {period} дней',
consume_promo_offer=get_user_active_promo_discount_percent(db_user) > 0,
consume_promo_offer=consume_promo,
mark_as_paid_subscription=True,
)
if not success:
@@ -1966,9 +2038,14 @@ def format_tariff_switch_list_text(
discount_icon = ''
if is_daily:
# Для суточных тарифов показываем цену за день
# Для суточных тарифов показываем цену за день с учётом скидки промогруппы
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
price_text = f'🔄 {format_price_kopeks(daily_price, compact=True)}/день'
if db_user:
group_pct, offer_pct, daily_discount = _get_user_period_discount(db_user, 1)
if daily_discount > 0:
daily_price = _apply_promo_discount(daily_price, group_pct, offer_pct)
discount_icon = '🔥'
price_text = f'🔄 {format_price_kopeks(daily_price, compact=True)}/день{discount_icon}'
else:
prices = tariff.period_prices or {}
if prices:
@@ -2124,7 +2201,9 @@ async def show_tariff_switch_list(
current_tariff_name = current_tariff.name
# Проверяем есть ли у пользователя скидки по периодам
promo_group = getattr(db_user, 'promo_group', None)
promo_group = db_user.get_primary_promo_group() if hasattr(db_user, 'get_primary_promo_group') else None
if promo_group is None:
promo_group = getattr(db_user, 'promo_group', None)
has_period_discounts = False
if promo_group:
period_discounts = getattr(promo_group, 'period_discounts', None)
@@ -2170,7 +2249,12 @@ async def select_tariff_switch(
if is_daily:
# Для суточного тарифа показываем подтверждение без выбора периода
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0)
group_pct, offer_pct, daily_discount = _get_user_period_discount(db_user, 1)
daily_price = (
_apply_promo_discount(raw_daily_price, group_pct, offer_pct) if daily_discount > 0 else raw_daily_price
)
discount_text = f'\n💎 Скидка: {daily_discount}%' if daily_discount > 0 else ''
user_balance = db_user.balance_kopeks or 0
# Проверяем текущую подписку на оставшиеся дни
@@ -2189,7 +2273,8 @@ async def select_tariff_switch(
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n'
f'🔄 Тип: <b>Суточный</b>\n\n'
f'💰 <b>Цена: {format_price_kopeks(daily_price)}/день</b>\n\n'
f'💰 <b>Цена: {format_price_kopeks(daily_price)}/день</b>'
f'{discount_text}\n\n'
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}'
f'{days_warning}\n\n'
f'ℹ️ Средства будут списываться автоматически раз в сутки.\n'
@@ -2212,7 +2297,8 @@ async def select_tariff_switch(
f'❌ <b>Недостаточно средств</b>\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n'
f'🔄 Тип: Суточный\n'
f'💰 Цена: {format_price_kopeks(daily_price)}/день\n\n'
f'💰 Цена: {format_price_kopeks(daily_price)}/день'
f'{discount_text}\n\n'
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n'
f'⚠️ Не хватает: <b>{format_price_kopeks(missing)}</b>'
f'{days_warning}',
@@ -2269,13 +2355,21 @@ async def select_tariff_switch_period(
data = await state.get_data()
current_tariff_id = data.get('current_tariff_id')
# Получаем скидку для выбранного периода
group_pct, offer_pct, discount_percent = _get_user_period_discount(db_user, period)
# Calculate price via PricingEngine (per-category discounts: period + devices for new tariff)
from app.services.pricing_engine import pricing_engine
# Получаем цену
prices = tariff.period_prices or {}
base_price = prices.get(str(period), 0)
final_price = _apply_promo_discount(base_price, group_pct, offer_pct)
result = await pricing_engine.calculate_tariff_purchase_price(
tariff,
period,
device_limit=tariff.device_limit or 0,
user=db_user,
)
final_price = result.final_total
original_price = result.original_total
total_discount = result.promo_group_discount + result.promo_offer_discount
discount_percent = (
round((1 - final_price / original_price) * 100) if original_price > 0 and total_discount > 0 else 0
)
# Проверяем баланс
user_balance = db_user.balance_kopeks or 0
@@ -2300,7 +2394,7 @@ async def select_tariff_switch_period(
if user_balance >= final_price:
discount_text = ''
if discount_percent > 0:
discount_text = f'\n🎁 Скидка: {discount_percent}% (-{format_price_kopeks(base_price - final_price)})'
discount_text = f'\n🎁 Скидка: {discount_percent}% (-{format_price_kopeks(total_discount)})'
await callback.message.edit_text(
f'✅ <b>Подтверждение переключения тарифа</b>\n\n'
@@ -2354,13 +2448,30 @@ async def confirm_tariff_switch(
await callback.answer('Тариф недоступен', show_alert=True)
return
# Получаем скидку для выбранного периода
group_pct, offer_pct, discount_percent = _get_user_period_discount(db_user, period)
from app.database.crud.user import lock_user_for_pricing
# Получаем цену
prices = tariff.period_prices or {}
base_price = prices.get(str(period), 0)
final_price = _apply_promo_discount(base_price, group_pct, offer_pct)
db_user = await lock_user_for_pricing(db, db_user.id)
# Проверяем наличие подписки (need device_limit for pricing)
subscription = await get_subscription_by_user_id(db, db_user.id)
if not subscription:
await callback.answer('У вас нет активной подписки', show_alert=True)
return
# Calculate price via PricingEngine (handles per-category discounts + extra devices)
from app.services.pricing_engine import pricing_engine
effective_device_limit = (
subscription.device_limit if subscription.tariff_id == tariff.id else (tariff.device_limit or 0)
)
result = await pricing_engine.calculate_tariff_purchase_price(
tariff,
period,
device_limit=effective_device_limit,
user=db_user,
)
final_price = result.final_total
consume_promo = result.promo_offer_discount > 0
# Проверяем баланс
user_balance = db_user.balance_kopeks or 0
@@ -2368,12 +2479,6 @@ async def confirm_tariff_switch(
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
# Проверяем наличие подписки
subscription = await get_subscription_by_user_id(db, db_user.id)
if not subscription:
await callback.answer('У вас нет активной подписки', show_alert=True)
return
texts = get_texts(db_user.language)
try:
@@ -2383,7 +2488,7 @@ async def confirm_tariff_switch(
db_user,
final_price,
f'Смена тарифа на {tariff.name} ({period} дней)',
consume_promo_offer=get_user_active_promo_discount_percent(db_user) > 0,
consume_promo_offer=consume_promo,
mark_as_paid_subscription=True,
)
if not success:
@@ -2536,9 +2641,26 @@ async def confirm_daily_tariff_switch(
await callback.answer('Некорректная цена тарифа', show_alert=True)
return
# Проверяем баланс
# Lock user BEFORE price computation to prevent TOCTOU on promo offer
from app.database.crud.user import lock_user_for_pricing
db_user = await lock_user_for_pricing(db, db_user.id)
# Apply group + promo-offer discounts via PricingEngine (single source of truth)
from app.services.pricing_engine import pricing_engine
pricing_result = await pricing_engine.calculate_tariff_purchase_price(
tariff,
period_days=1,
device_limit=tariff.device_limit,
user=db_user,
)
final_daily_price = pricing_result.final_total
consume_promo = pricing_result.breakdown.get('offer_discount_pct', 0) > 0
# Проверяем баланс (user already locked, balance is fresh)
user_balance = db_user.balance_kopeks or 0
if user_balance < daily_price:
if user_balance < final_daily_price:
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
@@ -2555,8 +2677,9 @@ async def confirm_daily_tariff_switch(
success = await subtract_user_balance(
db,
db_user,
daily_price,
final_daily_price,
f'Смена на суточный тариф {tariff.name} (первый день)',
consume_promo_offer=consume_promo,
mark_as_paid_subscription=True,
)
if not success:
@@ -2574,7 +2697,7 @@ async def confirm_daily_tariff_switch(
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
# Обновляем подписку на суточный тариф
# Сохраняем докупленные устройства при смене тарифа
# Сбрасываем лимит устройств на базу нового тарифа (докупленные не переносятся)
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
old_tariff = await get_tariff_by_id(db, subscription.tariff_id) if subscription.tariff_id else None
@@ -2639,7 +2762,7 @@ async def confirm_daily_tariff_switch(
db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=daily_price,
amount_kopeks=final_daily_price,
description=f'Смена на суточный тариф {tariff.name} (первый день)',
)
@@ -2653,7 +2776,7 @@ async def confirm_daily_tariff_switch(
None,
1, # 1 день
was_trial_conversion=False,
amount_kopeks=daily_price,
amount_kopeks=final_daily_price,
purchase_type='tariff_switch',
)
except Exception as e:
@@ -2669,7 +2792,7 @@ async def confirm_daily_tariff_switch(
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n'
f'🔄 Тип: Суточный\n'
f'💰 Списано: {format_price_kopeks(daily_price)}\n\n'
f'💰 Списано: {format_price_kopeks(final_daily_price)}\n\n'
f'ℹ️ Следующее списание через 24 часа.',
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
@@ -2683,65 +2806,53 @@ async def confirm_daily_tariff_switch(
except Exception as e:
logger.error('Ошибка при смене на суточный тариф', error=e, exc_info=True)
await db.rollback()
# Compensating refund: balance was already committed by subtract_user_balance
try:
from app.database.crud.user import add_user_balance
await add_user_balance(
db,
db_user,
final_daily_price,
'Возврат: ошибка смены на суточный тариф',
create_transaction=True,
transaction_type=TransactionType.REFUND,
)
except Exception as refund_error:
logger.critical(
'CRITICAL: не удалось вернуть средства после ошибки смены на суточный тариф',
user_id=db_user.id,
price_kopeks=final_daily_price,
refund_error=refund_error,
)
await callback.answer('Произошла ошибка при смене тарифа', show_alert=True)
# ==================== Мгновенное переключение тарифов (без выбора периода) ====================
def _get_tariff_monthly_price(tariff: Tariff) -> int:
"""Получает месячную цену тарифа (30 дней) с fallback на пропорциональный расчёт."""
price = tariff.get_price_for_period(30)
if price is not None:
return price
# Fallback: пропорционально пересчитываем из первого доступного периода
periods = tariff.get_available_periods()
if periods:
first_period = periods[0]
first_price = tariff.get_price_for_period(first_period)
if first_price:
return int(first_price * 30 / first_period)
return 0
def _calculate_instant_switch_cost(
current_tariff: Tariff,
new_tariff: Tariff,
remaining_days: int,
db_user: User | None = None,
) -> tuple[int, bool]:
"""
Рассчитывает стоимость мгновенного переключения тарифа.
Если новый тариф дороже - доплата пропорционально оставшимся дням.
Если дешевле или равен - бесплатно.
Формула: (new_monthly - current_monthly) * remaining_days / 30
Скидка применяется к обоим тарифам одинаково.
"""Рассчитывает стоимость мгновенного переключения тарифа.
Делегирует расчёт в PricingEngine.calculate_tariff_switch_cost().
Returns:
(upgrade_cost_kopeks, is_upgrade)
"""
current_monthly = _get_tariff_monthly_price(current_tariff)
new_monthly = _get_tariff_monthly_price(new_tariff)
from app.services.pricing_engine import pricing_engine
group_pct, offer_pct, discount_percent = 0, 0, 0
if db_user:
group_pct, offer_pct, discount_percent = _get_user_period_discount(db_user, 30)
if discount_percent > 0:
current_monthly = _apply_promo_discount(current_monthly, group_pct, offer_pct)
new_monthly = _apply_promo_discount(new_monthly, group_pct, offer_pct)
price_diff = new_monthly - current_monthly
if price_diff <= 0:
return 0, False
upgrade_cost = int(price_diff * remaining_days / 30)
return upgrade_cost, True
result = pricing_engine.calculate_tariff_switch_cost(
current_tariff,
new_tariff,
remaining_days,
user=db_user,
)
return result.upgrade_cost, result.is_upgrade
def format_instant_switch_list_text(
@@ -2984,7 +3095,15 @@ async def preview_instant_switch(
# Для суточного тарифа особая логика показа
if is_new_daily:
daily_price = getattr(new_tariff, 'daily_price_kopeks', 0)
raw_daily_price = getattr(new_tariff, 'daily_price_kopeks', 0)
# Применяем групповую скидку + promo-offer для отображения
daily_group_pct, daily_offer_pct, daily_discount = _get_user_period_discount(db_user, 1)
daily_price = (
_apply_promo_discount(raw_daily_price, daily_group_pct, daily_offer_pct)
if daily_discount > 0
else raw_daily_price
)
discount_text = f'\n💎 Скидка: {daily_discount}%' if daily_discount > 0 else ''
user_balance = db_user.balance_kopeks or 0
if user_balance >= daily_price:
@@ -2997,7 +3116,8 @@ async def preview_instant_switch(
f' • Трафик: {traffic}\n'
f' • Устройств: {new_tariff.device_limit}\n'
f' • Тип: 🔄 Суточный\n\n'
f'💰 <b>Цена: {format_price_kopeks(daily_price)}/день</b>\n\n'
f'💰 <b>Цена: {format_price_kopeks(daily_price)}/день</b>'
f'{discount_text}\n\n'
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}'
f'{daily_warning}\n\n'
f'ℹ️ Средства будут списываться автоматически раз в сутки.',
@@ -3010,7 +3130,8 @@ async def preview_instant_switch(
f'❌ <b>Недостаточно средств</b>\n\n'
f'📦 Тариф: <b>{new_tariff.name}</b>\n'
f'🔄 Тип: Суточный\n'
f'💰 Цена: {format_price_kopeks(daily_price)}/день\n\n'
f'💰 Цена: {format_price_kopeks(daily_price)}/день'
f'{discount_text}\n\n'
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n'
f'⚠️ Не хватает: <b>{format_price_kopeks(missing)}</b>'
f'{daily_warning}',
@@ -3099,19 +3220,37 @@ async def confirm_instant_switch(
await callback.answer('Тариф недоступен', show_alert=True)
return
# Получаем данные из состояния
data = await state.get_data()
upgrade_cost = data.get('upgrade_cost', 0)
is_upgrade = data.get('is_upgrade', False)
remaining_days = data.get('remaining_days', 0)
# Проверяем подписку
subscription = await get_subscription_by_user_id(db, db_user.id)
if not subscription:
await callback.answer('Подписка не найдена', show_alert=True)
return
# Проверяем баланс если это upgrade
from app.database.crud.user import lock_user_for_pricing
db_user = await lock_user_for_pricing(db, db_user.id)
# Recompute upgrade_cost under lock (FSM-stored value may be stale)
current_tariff = await get_tariff_by_id(db, subscription.tariff_id) if subscription.tariff_id else None
if not current_tariff:
await callback.answer('Текущий тариф не найден', show_alert=True)
return
remaining_days = max(0, (subscription.end_date - datetime.now(UTC)).days) if subscription.end_date else 0
# Use full TariffSwitchResult to access offer_discount_pct for consume_promo_offer flag
from app.services.pricing_engine import pricing_engine
switch_result = pricing_engine.calculate_tariff_switch_cost(
current_tariff,
new_tariff,
remaining_days,
user=db_user,
)
upgrade_cost = switch_result.upgrade_cost
is_upgrade = switch_result.is_upgrade
consume_promo = switch_result.offer_discount_pct > 0
# Проверяем баланс если это upgrade (use locked user's fresh balance)
user_balance = db_user.balance_kopeks or 0
if is_upgrade and user_balance < upgrade_cost:
await callback.answer('Недостаточно средств на балансе', show_alert=True)
@@ -3121,13 +3260,14 @@ async def confirm_instant_switch(
try:
# Списываем баланс если это upgrade
# upgrade_cost includes both group + offer discounts from PricingEngine
if is_upgrade and upgrade_cost > 0:
success = await subtract_user_balance(
db,
db_user,
upgrade_cost,
f'Переключение на тариф {new_tariff.name}',
consume_promo_offer=get_user_active_promo_discount_percent(db_user) > 0,
consume_promo_offer=consume_promo,
mark_as_paid_subscription=True,
)
if not success:
@@ -3148,7 +3288,7 @@ async def confirm_instant_switch(
is_new_daily = getattr(new_tariff, 'is_daily', False)
# Обновляем подписку с новыми параметрами тарифа
# Сохраняем докупленные устройства при смене тарифа
# Сбрасываем лимит устройств на базу нового тарифа (докупленные не переносятся)
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
old_tariff = await get_tariff_by_id(db, subscription.tariff_id) if subscription.tariff_id else None
@@ -3176,7 +3316,15 @@ async def confirm_instant_switch(
if is_new_daily:
# Для суточного тарифа - сбрасываем на 1 день и настраиваем суточные параметры
daily_price = getattr(new_tariff, 'daily_price_kopeks', 0)
# Apply group + promo-offer discounts via PricingEngine (single source of truth)
daily_pricing = await pricing_engine.calculate_tariff_purchase_price(
new_tariff,
period_days=1,
device_limit=new_tariff.device_limit,
user=db_user,
)
daily_price = daily_pricing.final_total
consume_promo_for_daily = daily_pricing.breakdown.get('offer_discount_pct', 0) > 0
# Списываем первый день если ещё не списано (upgrade_cost был 0)
if upgrade_cost == 0 and daily_price > 0:
@@ -3186,6 +3334,7 @@ async def confirm_instant_switch(
db_user,
daily_price,
f'Переключение на суточный тариф {new_tariff.name} (первый день)',
consume_promo_offer=consume_promo_for_daily,
mark_as_paid_subscription=True,
)
if not success:
@@ -3199,6 +3348,22 @@ async def confirm_instant_switch(
description=f'Переключение на суточный тариф {new_tariff.name} (первый день)',
)
# Уведомление админу о списании за первый день суточного тарифа
try:
admin_notification_service = AdminNotificationService(callback.bot)
await admin_notification_service.send_subscription_purchase_notification(
db,
db_user,
subscription,
None,
1,
was_trial_conversion=False,
amount_kopeks=daily_price,
purchase_type='tariff_switch',
)
except Exception as e:
logger.error('Ошибка отправки уведомления админу', error=e)
subscription.end_date = datetime.now(UTC) + timedelta(days=1)
subscription.is_trial = False
subscription.is_daily_paused = False
@@ -3266,7 +3431,6 @@ async def confirm_instant_switch(
# Для суточного тарифа другое сообщение об успехе
if is_new_daily:
daily_price = getattr(new_tariff, 'daily_price_kopeks', 0)
await callback.message.edit_text(
f'🎉 <b>Тариф успешно изменён!</b>\n\n'
f'📦 Новый тариф: <b>{new_tariff.name}</b>\n'
+45 -18
View File
@@ -19,18 +19,16 @@ from app.keyboards.inline import (
get_reset_traffic_confirm_keyboard,
)
from app.localization.texts import get_texts
from app.services.pricing_engine import PricingEngine
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_service import SubscriptionService
from app.services.user_cart_service import user_cart_service
from app.states import SubscriptionStates
from app.utils.pricing_utils import (
apply_percentage_discount,
calculate_prorated_price,
)
from .common import (
_apply_addon_discount,
_get_addon_discount_percent_for_user,
_get_period_hint_from_subscription,
get_confirm_switch_traffic_keyboard,
get_traffic_switch_keyboard,
@@ -84,7 +82,7 @@ async def handle_add_traffic(callback: types.CallbackQuery, db_user: User, db: A
packages = tariff.get_traffic_topup_packages()
period_hint_days = _get_period_hint_from_subscription(subscription)
traffic_discount_percent = _get_addon_discount_percent_for_user(
traffic_discount_percent = PricingEngine.get_addon_discount_percent(
db_user,
'traffic',
period_hint_days,
@@ -136,7 +134,7 @@ async def handle_add_traffic(callback: types.CallbackQuery, db_user: User, db: A
current_traffic = subscription.traffic_limit_gb
period_hint_days = _get_period_hint_from_subscription(subscription)
traffic_discount_percent = _get_addon_discount_percent_for_user(
traffic_discount_percent = PricingEngine.get_addon_discount_percent(
db_user,
'traffic',
period_hint_days,
@@ -261,6 +259,10 @@ async def confirm_reset_traffic(callback: types.CallbackQuery, db_user: User, db
await callback.answer('⚠️ В текущем режиме трафик фиксированный', show_alert=True)
return
from app.database.crud.user import lock_user_for_pricing
db_user = await lock_user_for_pricing(db, db_user.id)
texts = get_texts(db_user.language)
subscription = db_user.subscription
@@ -471,16 +473,18 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
await callback.answer('⚠️ Цена для этого пакета не настроена', show_alert=True)
return
# Lock user BEFORE price computation to prevent TOCTOU on group discount
from app.database.crud.user import lock_user_for_pricing
db_user = await lock_user_for_pricing(db, db_user.id)
subscription = db_user.subscription
period_hint_days = _get_period_hint_from_subscription(subscription)
discount_result = _apply_addon_discount(
db_user,
'traffic',
discounted_per_month, discount_per_month, traffic_discount_pct = PricingEngine.calculate_traffic_discount(
base_price,
db_user,
period_hint_days,
)
discounted_per_month = discount_result['discounted']
discount_per_month = discount_result['discount']
charged_days = 30
# На тарифах пакеты трафика покупаются на 1 месяц (30 дней),
@@ -510,7 +514,7 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
'traffic_gb': traffic_gb,
'price_kopeks': price,
'base_price_kopeks': discounted_per_month,
'discount_percent': discount_result['percent'],
'discount_percent': traffic_discount_pct,
'source': 'bot',
'description': f'Докупка {traffic_gb} ГБ трафика',
}
@@ -619,7 +623,7 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
if price > 0:
success_text += f'\n💰 Списано: {texts.format_price(price)}'
if total_discount_value > 0:
success_text += f' (скидка {discount_result["percent"]}%: -{texts.format_price(total_discount_value)})'
success_text += f' (скидка {traffic_discount_pct}%: -{texts.format_price(total_discount_value)})'
await callback.message.edit_text(success_text, reply_markup=get_back_keyboard(db_user.language))
@@ -668,7 +672,7 @@ async def handle_switch_traffic(callback: types.CallbackQuery, db_user: User, db
base_traffic = current_traffic - purchased_traffic
period_hint_days = _get_period_hint_from_subscription(subscription)
traffic_discount_percent = _get_addon_discount_percent_for_user(
traffic_discount_percent = PricingEngine.get_addon_discount_percent(
db_user,
'traffic',
period_hint_days,
@@ -722,17 +726,17 @@ async def confirm_switch_traffic(callback: types.CallbackQuery, db_user: User, d
now = datetime.now(UTC)
days_remaining = max(1, (subscription.end_date - now).days)
period_hint_days = days_remaining if days_remaining > 0 else None
traffic_discount_percent = _get_addon_discount_percent_for_user(
traffic_discount_percent = PricingEngine.get_addon_discount_percent(
db_user,
'traffic',
period_hint_days,
)
discounted_old_per_month, _ = apply_percentage_discount(
discounted_old_per_month = PricingEngine.apply_discount(
old_price_per_month,
traffic_discount_percent,
)
discounted_new_per_month, _ = apply_percentage_discount(
discounted_new_per_month = PricingEngine.apply_discount(
new_price_per_month,
traffic_discount_percent,
)
@@ -800,12 +804,35 @@ async def confirm_switch_traffic(callback: types.CallbackQuery, db_user: User, d
async def execute_switch_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
callback_parts = callback.data.split('_')
new_traffic_gb = int(callback_parts[3])
price_difference = int(callback_parts[4])
from app.database.crud.user import lock_user_for_pricing
db_user = await lock_user_for_pricing(db, db_user.id)
texts = get_texts(db_user.language)
subscription = db_user.subscription
current_traffic = subscription.traffic_limit_gb
# Recompute price under lock (callback-baked value may be stale)
purchased_traffic = getattr(subscription, 'purchased_traffic_gb', 0) or 0
base_traffic = current_traffic - purchased_traffic
old_price_per_month = settings.get_traffic_price(base_traffic)
new_price_per_month = settings.get_traffic_price(new_traffic_gb)
days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days)
traffic_discount_percent = PricingEngine.get_addon_discount_percent(
db_user,
'traffic',
days_remaining,
)
discounted_old = PricingEngine.apply_discount(old_price_per_month, traffic_discount_percent)
discounted_new = PricingEngine.apply_discount(new_price_per_month, traffic_discount_percent)
price_diff_per_month = discounted_new - discounted_old
if price_diff_per_month > 0:
price_difference = int(price_diff_per_month * days_remaining / 30)
price_difference = max(100, price_difference)
else:
price_difference = 0
try:
if price_difference > 0:
success = await subtract_user_balance(
+41 -1
View File
@@ -1695,7 +1695,35 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
has_direct_payment_methods = True
if settings.is_kassa_ai_enabled():
if settings.is_kassa_ai_sbp_enabled():
sbp_name = settings.get_kassa_ai_sbp_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_KASSA_AI_SBP', f'📱 {sbp_name}'),
callback_data=_build_callback('kassa_ai_sbp'),
)
]
)
has_direct_payment_methods = True
if settings.is_kassa_ai_card_enabled():
card_name = settings.get_kassa_ai_card_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_KASSA_AI_CARD', f'💳 {card_name}'),
callback_data=_build_callback('kassa_ai_card'),
)
]
)
has_direct_payment_methods = True
if (
settings.is_kassa_ai_enabled()
and not settings.is_kassa_ai_sbp_enabled()
and not settings.is_kassa_ai_card_enabled()
):
kassa_ai_name = settings.get_kassa_ai_display_name()
keyboard.append(
[
@@ -1718,6 +1746,18 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
has_direct_payment_methods = True
if settings.is_severpay_enabled():
severpay_name = settings.get_severpay_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_SEVERPAY', f'💳 Банковская карта ({severpay_name})'),
callback_data=_build_callback('severpay'),
)
]
)
has_direct_payment_methods = True
if settings.is_support_topup_enabled():
keyboard.append(
[
+4 -1
View File
@@ -219,7 +219,10 @@ async def send_error_to_admin_chat(
global _last_error_notification
chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None)
topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None)
# Используем топик для ошибок, если настроен, иначе общий
topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_ERRORS_TOPIC_ID', None) or getattr(
settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None
)
enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
if not enabled or not chat_id:
+86 -31
View File
@@ -1,5 +1,6 @@
import html
from datetime import UTC, datetime
from enum import StrEnum
from typing import Any
import structlog
@@ -26,6 +27,21 @@ from app.utils.message_patch import caption_exceeds_telegram_limit
from app.utils.timezone import format_local_datetime
class NotificationCategory(StrEnum):
"""Категории уведомлений для маршрутизации по топикам."""
PURCHASES = 'purchases' # Покупки подписок, покупки с лендинга
RENEWALS = 'renewals' # Продления
TRIALS = 'trials' # Триалы
BALANCE = 'balance' # Пополнение баланса
ADDONS = 'addons' # Докупка трафика/устройств/серверов
INFRASTRUCTURE = 'infrastructure' # Ноды, техработы, статус панели, вебхуки
ERRORS = 'errors' # Ошибки бота, краши
PROMO = 'promo' # Промокоды, кампании, промогруппы
PARTNERS = 'partners' # Партнёрки, выводы, админ-действия
TICKETS = 'tickets' # Тикеты (уже существует)
logger = structlog.get_logger(__name__)
@@ -37,6 +53,20 @@ class AdminNotificationService:
self.ticket_topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID', None)
self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
# Маппинг категорий на topic_id (None = fallback на self.topic_id)
self.category_topics: dict[NotificationCategory, int | None] = {
NotificationCategory.PURCHASES: getattr(settings, 'ADMIN_NOTIFICATIONS_PURCHASES_TOPIC_ID', None),
NotificationCategory.RENEWALS: getattr(settings, 'ADMIN_NOTIFICATIONS_RENEWALS_TOPIC_ID', None),
NotificationCategory.TRIALS: getattr(settings, 'ADMIN_NOTIFICATIONS_TRIALS_TOPIC_ID', None),
NotificationCategory.BALANCE: getattr(settings, 'ADMIN_NOTIFICATIONS_BALANCE_TOPIC_ID', None),
NotificationCategory.ADDONS: getattr(settings, 'ADMIN_NOTIFICATIONS_ADDONS_TOPIC_ID', None),
NotificationCategory.INFRASTRUCTURE: getattr(settings, 'ADMIN_NOTIFICATIONS_INFRASTRUCTURE_TOPIC_ID', None),
NotificationCategory.ERRORS: getattr(settings, 'ADMIN_NOTIFICATIONS_ERRORS_TOPIC_ID', None),
NotificationCategory.PROMO: getattr(settings, 'ADMIN_NOTIFICATIONS_PROMO_TOPIC_ID', None),
NotificationCategory.PARTNERS: getattr(settings, 'ADMIN_NOTIFICATIONS_PARTNERS_TOPIC_ID', None),
NotificationCategory.TICKETS: self.ticket_topic_id,
}
async def _get_referrer_info(self, db: AsyncSession, referred_by_id: int | None) -> str:
if not referred_by_id:
return 'Нет'
@@ -371,7 +401,7 @@ class AdminNotificationService:
message_lines.append('')
message_lines.append(f'⏰ <i>{format_local_datetime(datetime.now(UTC), "%d.%m.%Y %H:%M:%S")}</i>')
return await self._send_message('\n'.join(message_lines))
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.TRIALS)
except Exception as e:
logger.error('Ошибка отправки уведомления о триале', error=e)
@@ -502,7 +532,15 @@ class AdminNotificationService:
]
)
return await self._send_message('\n'.join(message_lines))
# Маршрутизация по категориям (зеркалит логику заголовков выше)
if purchase_type == 'renewal' or (
not was_trial_conversion and purchase_type is None and user.has_had_paid_subscription
):
cat = NotificationCategory.RENEWALS
else:
cat = NotificationCategory.PURCHASES
return await self._send_message('\n'.join(message_lines), category=cat)
except Exception as e:
logger.error('Ошибка отправки уведомления о покупке', error=e)
@@ -565,7 +603,7 @@ class AdminNotificationService:
else:
message = f'{message_prefix}{message_suffix}'
return await self._send_message(message)
return await self._send_message(message, category=NotificationCategory.INFRASTRUCTURE)
except Exception as e:
logger.error('Ошибка отправки уведомления об обновлении', error=e)
@@ -586,7 +624,7 @@ class AdminNotificationService:
<i>Система автоматических обновлений {format_local_datetime(datetime.now(UTC), '%d.%m.%Y %H:%M:%S')}</i>"""
return await self._send_message(message)
return await self._send_message(message, category=NotificationCategory.ERRORS)
except Exception as e:
logger.error('Ошибка отправки уведомления об ошибке проверки версий', error=e)
@@ -824,7 +862,7 @@ class AdminNotificationService:
return False
try:
return await self._send_message(message)
return await self._send_message(message, category=NotificationCategory.BALANCE)
except Exception as e:
logger.error('Ошибка отправки уведомления о пополнении', error=e, exc_info=True)
return False
@@ -901,7 +939,7 @@ class AdminNotificationService:
<i>{format_local_datetime(datetime.now(UTC), '%d.%m.%Y %H:%M:%S')}</i>"""
return await self._send_message(message)
return await self._send_message(message, category=NotificationCategory.RENEWALS)
except Exception as e:
logger.error('Ошибка отправки уведомления о продлении', error=e)
@@ -1008,7 +1046,7 @@ class AdminNotificationService:
]
)
return await self._send_message('\n'.join(message_lines))
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PROMO)
except Exception as e:
logger.error('Ошибка отправки уведомления об активации промокода', error=e)
@@ -1097,7 +1135,7 @@ class AdminNotificationService:
]
)
return await self._send_message('\n'.join(message_lines))
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PROMO)
except Exception as e:
logger.error('Ошибка отправки уведомления о переходе по кампании', error=e)
@@ -1187,14 +1225,30 @@ class AdminNotificationService:
]
)
return await self._send_message('\n'.join(message_lines))
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PROMO)
except Exception as e:
logger.error('Ошибка отправки уведомления о смене промогруппы', error=e)
return False
def _resolve_topic_id(self, category: NotificationCategory | None = None) -> int | None:
"""Определяет topic_id для сообщения.
Если указана category и для неё настроен топик возвращает его.
Иначе fallback на self.topic_id (общий топик).
"""
if category:
topic = self.category_topics.get(category)
if topic is not None:
return topic
return self.topic_id
async def _send_message(
self, text: str, reply_markup: types.InlineKeyboardMarkup | None = None, *, ticket_event: bool = False
self,
text: str,
reply_markup: types.InlineKeyboardMarkup | None = None,
*,
category: NotificationCategory | None = None,
) -> bool:
if not self.chat_id:
logger.warning('ADMIN_NOTIFICATIONS_CHAT_ID не настроен')
@@ -1208,19 +1262,14 @@ class AdminNotificationService:
'disable_web_page_preview': True,
}
# route to ticket-specific topic if provided
thread_id = None
if ticket_event and self.ticket_topic_id:
thread_id = self.ticket_topic_id
elif self.topic_id:
thread_id = self.topic_id
thread_id = self._resolve_topic_id(category)
if thread_id:
message_kwargs['message_thread_id'] = thread_id
if reply_markup is not None:
message_kwargs['reply_markup'] = reply_markup
await self.bot.send_message(**message_kwargs)
logger.info('Уведомление отправлено в чат', chat_id=self.chat_id)
logger.info('Уведомление отправлено в чат', chat_id=self.chat_id, category=category)
return True
except TelegramForbiddenError:
@@ -1241,11 +1290,17 @@ class AdminNotificationService:
"""Public check for whether admin notifications are configured and active."""
return self._is_enabled()
async def send_admin_notification(self, text: str, reply_markup: types.InlineKeyboardMarkup | None = None) -> bool:
async def send_admin_notification(
self,
text: str,
reply_markup: types.InlineKeyboardMarkup | None = None,
*,
category: NotificationCategory | None = None,
) -> bool:
"""Send a generic notification to admin chat with optional inline keyboard."""
if not self._is_enabled():
return False
return await self._send_message(text, reply_markup=reply_markup)
return await self._send_message(text, reply_markup=reply_markup, category=category)
async def send_guest_purchase_notification(
self,
@@ -1316,7 +1371,7 @@ class AdminNotificationService:
message_lines.append(f'<i>{format_local_datetime(datetime.now(UTC), "%d.%m.%Y %H:%M")}</i>')
return await self._send_message('\n'.join(message_lines))
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PURCHASES)
except Exception as e:
logger.error('Ошибка отправки уведомления о гостевой покупке', error=e)
@@ -1330,7 +1385,7 @@ class AdminNotificationService:
"""
if not self._is_enabled():
return False
return await self._send_message(text)
return await self._send_message(text, category=NotificationCategory.INFRASTRUCTURE)
def _get_payment_method_display(self, payment_method: str | None) -> str:
if not payment_method:
@@ -1516,7 +1571,7 @@ class AdminNotificationService:
message = '\n'.join(message_parts)
return await self._send_message(message)
return await self._send_message(message, category=NotificationCategory.INFRASTRUCTURE)
except Exception as e:
logger.error('Ошибка отправки уведомления о техработах', error=e)
@@ -1601,7 +1656,7 @@ class AdminNotificationService:
message = '\n'.join(message_parts)
return await self._send_message(message)
return await self._send_message(message, category=NotificationCategory.INFRASTRUCTURE)
except Exception as e:
logger.error('Ошибка отправки уведомления о статусе панели Remnawave', error=e)
@@ -1694,7 +1749,7 @@ class AdminNotificationService:
]
)
return await self._send_message('\n'.join(message_lines))
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.ADDONS)
except Exception as e:
logger.error('Ошибка отправки уведомления об изменении подписки', error=e)
@@ -1778,7 +1833,7 @@ class AdminNotificationService:
]
)
return await self._send_message('\n'.join(message_lines))
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PARTNERS)
except Exception as e:
logger.error('Ошибка отправки уведомления о заявке на партнёрку', error=e)
@@ -1829,7 +1884,7 @@ class AdminNotificationService:
]
)
return await self._send_message('\n'.join(message_lines))
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PARTNERS)
except Exception as e:
logger.error('Ошибка отправки уведомления о запросе на вывод', error=e)
@@ -1873,7 +1928,7 @@ class AdminNotificationService:
)
message = '\n'.join(message_lines)
return await self._send_message(message)
return await self._send_message(message, category=NotificationCategory.PARTNERS)
except Exception as e:
logger.error('Ошибка отправки уведомления о массовой блокировке', error=e)
@@ -1910,7 +1965,7 @@ class AdminNotificationService:
if media_file_id and media_type == 'photo':
return await self._send_ticket_photo_notification(text, media_file_id, keyboard)
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
return await self._send_message(text, reply_markup=keyboard, category=NotificationCategory.TICKETS)
async def _send_ticket_photo_notification(
self,
@@ -1925,7 +1980,7 @@ class AdminNotificationService:
if not self.chat_id:
return False
thread_id = self.ticket_topic_id or self.topic_id
thread_id = self._resolve_topic_id(category=NotificationCategory.TICKETS)
try:
if not caption_exceeds_telegram_limit(text):
@@ -1943,7 +1998,7 @@ class AdminNotificationService:
await self.bot.send_photo(**photo_kwargs)
else:
# Текст отдельно, фото следом в тот же топик
await self._send_message(text, reply_markup=keyboard, ticket_event=True)
await self._send_message(text, reply_markup=keyboard, category=NotificationCategory.TICKETS)
photo_kwargs = {
'chat_id': self.chat_id,
'photo': photo_file_id,
@@ -1956,7 +2011,7 @@ class AdminNotificationService:
except Exception as e:
logger.error('Ошибка отправки фото-уведомления тикета', error=e)
# Fallback: отправляем хотя бы текст
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
return await self._send_message(text, reply_markup=keyboard, category=NotificationCategory.TICKETS)
async def send_suspicious_traffic_notification(self, message: str, bot: Bot, topic_id: int | None = None) -> bool:
"""
+32 -8
View File
@@ -1105,9 +1105,20 @@ class BackupService:
existing = existing_user.scalar_one_or_none()
if existing:
for key, value in processed_data.items():
if key != 'id':
setattr(existing, key, value)
try:
async with db.begin_nested():
for key, value in processed_data.items():
if key != 'id':
setattr(existing, key, value)
await db.flush()
except IntegrityError:
db.expire(existing)
logger.warning(
'Конфликт уникального ключа при обновлении пользователя, пропускаем',
user_id=processed_data.get('id'),
telegram_id=processed_data.get('telegram_id'),
)
continue
else:
instance = User(**processed_data)
try:
@@ -1376,9 +1387,20 @@ class BackupService:
existing = existing_record.scalar_one_or_none()
if existing:
for key, value in processed_data.items():
if key not in pk_cols:
setattr(existing, key, value)
try:
async with db.begin_nested():
for key, value in processed_data.items():
if key not in pk_cols:
setattr(existing, key, value)
await db.flush()
except IntegrityError:
db.expire(existing)
logger.warning(
'Конфликт уникального ключа при обновлении записи, пропускаем',
table_name=table_name,
pk={col: processed_data.get(col) for col in pk_cols},
)
continue
else:
instance = model(**processed_data)
try:
@@ -1788,10 +1810,12 @@ class BackupService:
notification_text += f'\n\n⏰ <i>{datetime.now(UTC).strftime("%d.%m.%Y %H:%M:%S")}</i>'
try:
from app.services.admin_notification_service import AdminNotificationService
from app.services.admin_notification_service import AdminNotificationService, NotificationCategory
admin_service = AdminNotificationService(self.bot)
await admin_service._send_message(notification_text)
await admin_service.send_admin_notification(
notification_text, category=NotificationCategory.INFRASTRUCTURE
)
except Exception as e:
logger.error('Ошибка отправки уведомления через AdminNotificationService', error=e)
+3 -2
View File
@@ -274,8 +274,9 @@ class ChannelSubscriptionService:
)
return False # Fail-closed -- bot cannot verify membership
except TelegramBadRequest as e:
if 'user not found' in str(e).lower():
return False # User never interacted with bot in that context
err_msg = str(e).lower()
if 'user not found' in err_msg or 'participant_id_invalid' in err_msg:
return False # User never interacted with bot/channel
logger.error('Bad request checking channel', channel_id=channel_id, error=str(e))
return False # Fail-closed
except TelegramNetworkError:
+16 -2
View File
@@ -123,11 +123,25 @@ class DailySubscriptionService:
logger.warning('Тариф не найден для подписки', subscription_id=subscription.id)
return 'error'
daily_price = tariff.daily_price_kopeks
if daily_price <= 0:
raw_daily_price = tariff.daily_price_kopeks
if raw_daily_price <= 0:
logger.warning('Некорректная суточная цена для тарифа', tariff_id=tariff.id)
return 'error'
# Lock user row to prevent TOCTOU between discount read and balance charge
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
# Apply group discount to daily price (consistent with PricingEngine._calculate_switch_to_daily)
from app.services.pricing_engine import PricingEngine
promo_group = PricingEngine.resolve_promo_group(user)
daily_group_pct = promo_group.get_discount_percent('period', 1) if promo_group else 0
daily_price = (
PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price
)
# Проверяем баланс
if user.balance_kopeks < daily_price:
# Недостаточно средств - приостанавливаем подписку
+21 -5
View File
@@ -118,7 +118,9 @@ async def validate_and_calculate(
overrides = landing.discount_overrides or {}
tariff_override = overrides.get(str(tariff_id))
effective_discount = tariff_override if tariff_override is not None else landing.discount_percent
price_kopeks = max(1, price_kopeks - (price_kopeks * effective_discount // 100))
from app.services.pricing_engine import PricingEngine
price_kopeks = max(1, PricingEngine.apply_discount(price_kopeks, effective_discount))
return tariff, price_kopeks
@@ -283,6 +285,13 @@ async def fulfill_purchase(
)
return purchase
squads = list(tariff.allowed_squads or [])
if not squads:
from app.database.crud.server_squad import get_all_server_squads
all_servers, _ = await get_all_server_squads(db, available_only=True)
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
if existing_subscription is not None:
# Expired/inactive subscription — replace it
existing_subscription.tariff_id = tariff.id
@@ -292,7 +301,7 @@ async def fulfill_purchase(
duration_days=purchase.period_days,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
connected_squads=tariff.allowed_squads or [],
connected_squads=squads,
is_trial=False,
update_server_counters=True,
)
@@ -304,7 +313,7 @@ async def fulfill_purchase(
duration_days=purchase.period_days,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
connected_squads=tariff.allowed_squads or [],
connected_squads=squads,
tariff_id=tariff.id,
update_server_counters=True,
)
@@ -888,6 +897,13 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
existing_subscription = await get_subscription_by_user_id(db, user.id)
subscription_service = SubscriptionService()
squads = list(tariff.allowed_squads or [])
if not squads:
from app.database.crud.server_squad import get_all_server_squads
all_servers, _ = await get_all_server_squads(db, available_only=True)
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
if existing_subscription is not None:
subscription = await replace_subscription(
db,
@@ -895,7 +911,7 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
duration_days=purchase.period_days,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
connected_squads=tariff.allowed_squads or [],
connected_squads=squads,
is_trial=False,
update_server_counters=True,
commit=False,
@@ -908,7 +924,7 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
duration_days=purchase.period_days,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
connected_squads=tariff.allowed_squads or [],
connected_squads=squads,
tariff_id=tariff.id,
update_server_counters=True,
commit=False,
+6
View File
@@ -14,6 +14,12 @@ from app.config import settings
logger = structlog.get_logger(__name__)
# Sub-method to payment_system_id mapping
KASSA_AI_SUB_METHODS = {
'kassa_ai_sbp': {'payment_system_id': 44},
'kassa_ai_card': {'payment_system_id': 36},
}
# Кэш для публичного IP
_cached_public_ip: str | None = None
_ip_fetch_lock = asyncio.Lock()
+5 -3
View File
@@ -65,11 +65,11 @@ class MaintenanceService:
return False
try:
from app.services.admin_notification_service import AdminNotificationService
from app.services.admin_notification_service import AdminNotificationService, NotificationCategory
notification_service = AdminNotificationService(self._bot)
if not notification_service._is_enabled():
if not notification_service.is_enabled:
logger.debug('Уведомления администраторов отключены')
return False
@@ -79,7 +79,9 @@ class MaintenanceService:
timestamp = format_local_datetime(datetime.now(UTC), '%d.%m.%Y %H:%M:%S %Z')
formatted_message = f'{emoji} <b>ТЕХНИЧЕСКИЕ РАБОТЫ</b>\n\n{message}\n\n⏰ <i>{timestamp}</i>'
return await notification_service._send_message(formatted_message)
return await notification_service.send_admin_notification(
formatted_message, category=NotificationCategory.INFRASTRUCTURE
)
except Exception as e:
logger.error('Ошибка отправки уведомления через AdminNotificationService', error=e)
+50 -52
View File
@@ -223,20 +223,20 @@ class MonitoringService:
# ВАЖНО: autopay ПЕРЕД check_expired — иначе подписки с автоплатой
# экспайрятся до того, как autopay успеет их продлить
if settings.ENABLE_AUTOPAY:
await self._process_autopayments(db)
# Рекуррентные автоплатежи: пополнение баланса с сохранённой карты
if settings.YOOKASSA_RECURRENT_ENABLED:
try:
from app.services.recurrent_payment_service import process_recurrent_payments
# Продление с баланса работает всегда, если у подписки autopay_enabled=True
await self._process_autopayments(db)
# Рекуррентные автоплатежи с карты: требуют ENABLE_AUTOPAY + YOOKASSA_RECURRENT_ENABLED
if settings.ENABLE_AUTOPAY and settings.YOOKASSA_RECURRENT_ENABLED:
try:
from app.services.recurrent_payment_service import process_recurrent_payments
await process_recurrent_payments(db=db, bot=self.bot)
except Exception as recurrent_error:
logger.error(
'Ошибка рекуррентных автоплатежей',
error=recurrent_error,
exc_info=True,
)
await process_recurrent_payments(db=db, bot=self.bot)
except Exception as recurrent_error:
logger.error(
'Ошибка рекуррентных автоплатежей',
error=recurrent_error,
exc_info=True,
)
await self._check_expired_subscriptions(db)
await self._check_expiring_subscriptions(db)
await self._check_trial_expiring_soon(db)
@@ -384,17 +384,16 @@ class MonitoringService:
description=settings.format_remnawave_user_description(
full_name=user.full_name, username=user.username, telegram_id=user.telegram_id
),
active_internal_squads=subscription.connected_squads,
)
# Не пересылаем activeInternalSquads в рутинном sync — сквады уже назначены
# при создании подписки, пересылка стейловых UUID вызывает FK violation → A039
if hwid_limit is not None:
update_kwargs['hwid_device_limit'] = hwid_limit
# Внешний сквад: синхронизируем из тарифа или сбрасываем
if subscription.tariff and subscription.tariff.external_squad_uuid:
update_kwargs['external_squad_uuid'] = subscription.tariff.external_squad_uuid
else:
update_kwargs['external_squad_uuid'] = None
# Внешний сквад НЕ пересылаем в рутинном sync — стейловый UUID
# вызывает FK violation → A039. Назначается при создании подписки.
updated_user = await api.update_user(**update_kwargs)
@@ -1024,7 +1023,7 @@ class MonitoringService:
continue
days_before_expiry = (sub.end_date - current_time).days
if days_before_expiry <= min(sub.autopay_days_before, 3):
if days_before_expiry <= min(sub.autopay_days_before or 3, 3):
autopay_subscriptions.append(sub)
processed_count = 0
@@ -1053,8 +1052,11 @@ class MonitoringService:
autopay_period = 30
try:
from app.database.crud.user import lock_user_for_pricing
from app.services.pricing_engine import pricing_engine
user = await lock_user_for_pricing(db, user.id)
pricing = await pricing_engine.calculate_renewal_price(
db,
subscription,
@@ -1301,43 +1303,39 @@ class MonitoringService:
texts = get_texts(user.language)
days_text = format_days_declension(days, user.language)
if settings.ENABLE_AUTOPAY:
if subscription.autopay_enabled and has_saved_card:
autopay_status = texts.t(
'AUTOPAY_STATUS_CARD_ACTIVE',
'✅ Включен — будет автоматическое списание с карты',
)
action_text = texts.t(
'AUTOPAY_ACTION_CHECK_BALANCE',
'💰 Убедитесь, что на балансе достаточно средств: {balance}',
).format(balance=texts.format_price(user.balance_kopeks))
elif subscription.autopay_enabled:
autopay_status = texts.t(
'AUTOPAY_STATUS_NO_CARD',
'✅ Включен — подписка продлится автоматически',
)
action_text = texts.t(
'AUTOPAY_ACTION_CHECK_BALANCE',
'💰 Убедитесь, что на балансе достаточно средств: {balance}',
).format(balance=texts.format_price(user.balance_kopeks))
else:
autopay_status = texts.t(
'AUTOPAY_STATUS_OFF',
'❌ Отключен — не забудьте продлить вручную!',
)
action_text = texts.t(
'AUTOPAY_ACTION_ENABLE',
'💡 Включите автоплатеж или продлите подписку вручную',
)
if subscription.autopay_enabled and has_saved_card:
autopay_status = texts.t(
'AUTOPAY_STATUS_CARD_ACTIVE',
'✅ Включен — будет автоматическое списание с карты',
)
action_text = texts.t(
'AUTOPAY_ACTION_CHECK_BALANCE',
'💰 Убедитесь, что на балансе достаточно средств: {balance}',
).format(balance=texts.format_price(user.balance_kopeks))
elif subscription.autopay_enabled:
autopay_status = texts.t(
'AUTOPAY_STATUS_NO_CARD',
'✅ Включен — подписка продлится автоматически',
)
action_text = texts.t(
'AUTOPAY_ACTION_CHECK_BALANCE',
'💰 Убедитесь, что на балансе достаточно средств: {balance}',
).format(balance=texts.format_price(user.balance_kopeks))
else:
autopay_status = texts.t(
'AUTOPAY_STATUS_OFF',
'❌ Отключен — не забудьте продлить вручную!',
)
action_text = texts.t(
'AUTOPAY_ACTION_RENEW',
'💡 Продлите подписку вручную',
)
if settings.ENABLE_AUTOPAY:
action_text = texts.t(
'AUTOPAY_ACTION_ENABLE',
'💡 Включите автоплатеж или продлите подписку вручную',
)
else:
action_text = texts.t(
'AUTOPAY_ACTION_RENEW',
'💡 Продлите подписку вручную',
)
end_date = format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')
message = texts.t(
+7 -24
View File
@@ -939,8 +939,7 @@ class PartnerStatsService:
registrations_dict = {str(row.date): int(row.count) for row in registrations_by_day.all()}
# --- Daily revenue (DAILY_STATS_DAYS days) ---
# Revenue = real deposits (positive) + abs(subscription_payments) (stored negative)
# Exclude promo/bonus deposits (payment_method IS NULL) from revenue
# Revenue = real deposits only (exclude bonus/promo balance spending on subscriptions)
revenue_amount_expr = func.coalesce(
func.sum(
case(
@@ -951,10 +950,6 @@ class PartnerStatsService:
),
Transaction.amount_kopeks,
),
(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
func.abs(Transaction.amount_kopeks),
),
else_=0,
)
),
@@ -971,12 +966,8 @@ class PartnerStatsService:
Transaction.user_id.in_(campaign_user_ids_sq),
Transaction.is_completed.is_(True),
Transaction.created_at >= start_date,
Transaction.type.in_(
[
TransactionType.DEPOSIT.value,
TransactionType.SUBSCRIPTION_PAYMENT.value,
]
),
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
)
)
.group_by(func.date(Transaction.created_at))
@@ -1027,12 +1018,8 @@ class PartnerStatsService:
Transaction.user_id.in_(campaign_user_ids_sq),
Transaction.is_completed.is_(True),
Transaction.created_at >= week_ago,
Transaction.type.in_(
[
TransactionType.DEPOSIT.value,
TransactionType.SUBSCRIPTION_PAYMENT.value,
]
),
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
)
)
)
@@ -1046,12 +1033,8 @@ class PartnerStatsService:
Transaction.is_completed.is_(True),
Transaction.created_at >= previous_start,
Transaction.created_at < week_ago,
Transaction.type.in_(
[
TransactionType.DEPOSIT.value,
TransactionType.SUBSCRIPTION_PAYMENT.value,
]
),
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
)
)
)
+2
View File
@@ -14,6 +14,7 @@ from .mulenpay import MulenPayPaymentMixin
from .pal24 import Pal24PaymentMixin
from .platega import PlategaPaymentMixin
from .riopay import RioPayPaymentMixin
from .severpay import SeverPayPaymentMixin
from .stars import TelegramStarsMixin
from .tribute import TributePaymentMixin
from .wata import WataPaymentMixin
@@ -31,6 +32,7 @@ __all__ = [
'PaymentCommonMixin',
'PlategaPaymentMixin',
'RioPayPaymentMixin',
'SeverPayPaymentMixin',
'TelegramStarsMixin',
'TributePaymentMixin',
'WataPaymentMixin',
+1 -1
View File
@@ -347,7 +347,7 @@ class CloudPaymentsPaymentMixin:
except Exception as error:
logger.error('Ошибка обработки реферального пополнения CloudPayments', error=error)
if was_first_topup and not user.has_made_first_topup:
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
+106 -99
View File
@@ -325,7 +325,111 @@ async def send_cart_notification_after_topup(
exc_info=True,
)
# Try to auto-extend expired subscription (works without cart)
cart_data = await user_cart_service.get_user_cart(user.id)
# В приоритете всегда сохраненная корзина: она отражает явный выбор пользователя
# (период/тариф/сумма). Автопродление expired — только когда корзины нет.
if cart_data:
cart_total = cart_data.get('total_price', 0)
if not cart_total:
logger.warning(
'Сохраненная корзина найдена, но total_price отсутствует или некорректен',
user_id=user.id,
cart_total=cart_total,
)
return False
# Try auto-purchase first
auto_purchase_success = False
try:
auto_purchase_success = await auto_purchase_saved_cart_after_topup(db, user, bot=bot)
except Exception as auto_error:
logger.error(
'Ошибка автоматической покупки подписки для пользователя',
user_id=user.id,
auto_error=auto_error,
exc_info=True,
)
if auto_purchase_success:
return False
if not bot or not getattr(user, 'telegram_id', None):
return False
# Refresh balance from DB to account for any changes during auto-purchase attempt
refreshed_user = await get_user_by_id(db, user.id)
balance = getattr(refreshed_user or user, 'balance_kopeks', 0)
texts = get_texts(getattr(user, 'language', 'ru'))
# Build message based on whether balance is sufficient
fmt = settings.format_price
cart_total_formatted = fmt(cart_total)
if balance >= cart_total:
template = texts.get('BALANCE_TOPPED_UP_CART_SUFFICIENT', '')
message_text = template.format(
amount=fmt(amount_kopeks),
balance=fmt(balance),
cart_total=cart_total_formatted,
total_amount=cart_total_formatted,
)
else:
missing = cart_total - balance
template = texts.get('BALANCE_TOPPED_UP_CART_INSUFFICIENT', '')
message_text = template.format(
amount=fmt(amount_kopeks),
balance=fmt(balance),
cart_total=cart_total_formatted,
total_amount=cart_total_formatted,
missing=fmt(missing),
)
if not message_text:
logger.warning('Missing cart notification template', language=getattr(user, 'language', 'ru'))
return False
sent = False
try:
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.get('RETURN_TO_SUBSCRIPTION_CHECKOUT', '⬅️ Checkout'),
callback_data='return_to_saved_cart',
)
],
[
types.InlineKeyboardButton(
text=texts.get('MY_BALANCE_BUTTON', '💰 Balance'),
callback_data='menu_balance',
)
],
[
types.InlineKeyboardButton(
text=texts.get('MAIN_MENU_BUTTON', '🏠 Menu'),
callback_data='back_to_menu',
)
],
]
)
await bot.send_message(
chat_id=user.telegram_id,
text=message_text,
reply_markup=keyboard,
parse_mode='HTML',
)
sent = True
logger.info('Sent cart notification to user', user_id=user.id)
except Exception as send_error:
logger.error(
'Failed to send cart notification to user',
user_id=user.id,
error=send_error,
)
return sent
# Try to auto-extend expired subscription only when there is no saved cart.
try:
auto_extended = await try_auto_extend_expired_after_topup(db, user, bot=bot)
if auto_extended:
@@ -338,104 +442,7 @@ async def send_cart_notification_after_topup(
exc_info=True,
)
cart_data = await user_cart_service.get_user_cart(user.id)
if not cart_data:
return False
cart_total = cart_data.get('total_price', 0)
if not cart_total:
return False
# Try auto-purchase first
auto_purchase_success = False
try:
auto_purchase_success = await auto_purchase_saved_cart_after_topup(db, user, bot=bot)
except Exception as auto_error:
logger.error(
'Ошибка автоматической покупки подписки для пользователя',
user_id=user.id,
auto_error=auto_error,
exc_info=True,
)
if auto_purchase_success:
return False
if not bot or not getattr(user, 'telegram_id', None):
return False
# Refresh balance from DB to account for any changes during auto-purchase attempt
refreshed_user = await get_user_by_id(db, user.id)
balance = getattr(refreshed_user or user, 'balance_kopeks', 0)
texts = get_texts(getattr(user, 'language', 'ru'))
# Build message based on whether balance is sufficient
fmt = settings.format_price
cart_total_formatted = fmt(cart_total)
if balance >= cart_total:
template = texts.get('BALANCE_TOPPED_UP_CART_SUFFICIENT', '')
message_text = template.format(
amount=fmt(amount_kopeks),
balance=fmt(balance),
cart_total=cart_total_formatted,
total_amount=cart_total_formatted,
)
else:
missing = cart_total - balance
template = texts.get('BALANCE_TOPPED_UP_CART_INSUFFICIENT', '')
message_text = template.format(
amount=fmt(amount_kopeks),
balance=fmt(balance),
cart_total=cart_total_formatted,
total_amount=cart_total_formatted,
missing=fmt(missing),
)
if not message_text:
logger.warning('Missing cart notification template', language=getattr(user, 'language', 'ru'))
return False
sent = False
try:
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.get('RETURN_TO_SUBSCRIPTION_CHECKOUT', '⬅️ Checkout'),
callback_data='return_to_saved_cart',
)
],
[
types.InlineKeyboardButton(
text=texts.get('MY_BALANCE_BUTTON', '💰 Balance'),
callback_data='menu_balance',
)
],
[
types.InlineKeyboardButton(
text=texts.get('MAIN_MENU_BUTTON', '🏠 Menu'),
callback_data='back_to_menu',
)
],
]
)
await bot.send_message(
chat_id=user.telegram_id,
text=message_text,
reply_markup=keyboard,
parse_mode='HTML',
)
sent = True
logger.info('Sent cart notification to user', user_id=user.id)
except Exception as send_error:
logger.error(
'Failed to send cart notification to user',
user_id=user.id,
error=send_error,
)
return sent
return False
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -338,7 +338,7 @@ class CryptoBotPaymentMixin:
except Exception as error:
logger.error('Ошибка обработки реферального пополнения CryptoBot', error=error)
if was_first_topup and not user.has_made_first_topup:
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
+1 -1
View File
@@ -347,7 +347,7 @@ class FreekassaPaymentMixin:
except Exception as error:
logger.error('Ошибка обработки реферального пополнения Freekassa', error=error)
if was_first_topup and not user.has_made_first_topup:
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
+1 -1
View File
@@ -391,7 +391,7 @@ class HeleketPaymentMixin:
except Exception as error: # pragma: no cover - defensive
logger.error('Ошибка реферального начисления Heleket', error=error)
if was_first_topup and not user.has_made_first_topup:
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
+8 -3
View File
@@ -28,6 +28,7 @@ class KassaAiPaymentMixin:
description: str = 'Пополнение баланса',
email: str | None = None,
language: str = 'ru',
payment_system_id: int | None = None,
) -> dict[str, Any] | None:
"""
Создает платеж KassaAI.
@@ -96,7 +97,9 @@ class KassaAiPaymentMixin:
amount=amount_rubles,
currency=currency,
email=email,
payment_system_id=settings.KASSA_AI_PAYMENT_SYSTEM_ID,
payment_system_id=payment_system_id
if payment_system_id is not None
else settings.KASSA_AI_PAYMENT_SYSTEM_ID,
)
payment_url = result.get('location')
@@ -118,7 +121,9 @@ class KassaAiPaymentMixin:
currency=currency,
description=description,
payment_url=payment_url,
payment_system_id=settings.KASSA_AI_PAYMENT_SYSTEM_ID,
payment_system_id=payment_system_id
if payment_system_id is not None
else settings.KASSA_AI_PAYMENT_SYSTEM_ID,
expires_at=expires_at,
metadata_json=metadata,
)
@@ -335,7 +340,7 @@ class KassaAiPaymentMixin:
except Exception as error:
logger.error('Ошибка обработки реферального пополнения KassaAI', error=error)
if was_first_topup and not user.has_made_first_topup:
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
+1 -1
View File
@@ -322,7 +322,7 @@ class MulenPayPaymentMixin:
except Exception as error:
logger.error('Ошибка обработки реферального пополнения', display_name=display_name, error=error)
if was_first_topup and not user.has_made_first_topup:
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
+1 -1
View File
@@ -434,7 +434,7 @@ class Pal24PaymentMixin:
except Exception as error:
logger.error('Ошибка обработки реферального пополнения Pal24', error=error)
if was_first_topup and not user.has_made_first_topup:
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
+1 -1
View File
@@ -442,7 +442,7 @@ class PlategaPaymentMixin:
except Exception as error:
logger.error('Ошибка обработки реферального пополнения Platega', error=error)
if was_first_topup and not user.has_made_first_topup:
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
+27 -11
View File
@@ -42,11 +42,13 @@ class RioPayPaymentMixin:
self,
db: AsyncSession,
*,
user_id: int,
user_id: int | None,
amount_kopeks: int,
description: str = 'Пополнение баланса',
email: str | None = None,
language: str = 'ru',
success_url: str | None = None,
fail_url: str | None = None,
) -> dict[str, Any] | None:
"""
Создает платеж RioPay.
@@ -76,13 +78,15 @@ class RioPayPaymentMixin:
return None
# Получаем telegram_id пользователя для order_id
user = await get_user_by_id(db, user_id)
tg_id = user.telegram_id if user else user_id
if user_id is not None:
user = await get_user_by_id(db, user_id)
tg_id = user.telegram_id if user else user_id
else:
tg_id = 'guest'
# Генерируем уникальный order_id с telegram_id для удобного поиска
order_id = f'rp{tg_id}_{uuid.uuid4().hex[:6]}'
amount_rubles = amount_kopeks / 100
currency = settings.RIOPAY_CURRENCY
# Срок действия платежа (1 час по умолчанию)
expires_at = datetime.now(UTC) + timedelta(hours=1)
@@ -100,11 +104,9 @@ class RioPayPaymentMixin:
# Используем API для создания заказа
result = await riopay_service.create_order(
amount=amount_rubles,
currency=currency,
external_id=order_id,
purpose=description,
success_url=settings.RIOPAY_SUCCESS_URL,
fail_url=settings.RIOPAY_FAIL_URL,
success_url=success_url or settings.RIOPAY_SUCCESS_URL,
)
payment_url = result.get('paymentLink')
@@ -124,7 +126,7 @@ class RioPayPaymentMixin:
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
currency=settings.RIOPAY_CURRENCY,
description=description,
payment_url=payment_url,
riopay_order_id=riopay_order_id,
@@ -138,7 +140,7 @@ class RioPayPaymentMixin:
order_id=order_id,
user_id=user_id,
amount_rubles=amount_rubles,
currency=currency,
currency=settings.RIOPAY_CURRENCY,
)
return {
@@ -146,7 +148,7 @@ class RioPayPaymentMixin:
'riopay_order_id': riopay_order_id,
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_rubles,
'currency': currency,
'currency': settings.RIOPAY_CURRENCY,
'payment_url': payment_url,
'expires_at': expires_at.isoformat(),
'local_payment_id': local_payment.id,
@@ -275,6 +277,20 @@ class RioPayPaymentMixin:
logger.info('RioPay платеж уже привязан к транзакции', order_id=payment.order_id, trigger=trigger)
return True
# --- Guest purchase flow (landing page / gift) ---
riopay_metadata = dict(getattr(payment, 'metadata_json', {}) or {})
from app.services.payment.common import try_fulfill_guest_purchase
guest_result = await try_fulfill_guest_purchase(
db,
metadata=riopay_metadata,
payment_amount_kopeks=payment.amount_kopeks,
provider_payment_id=str(riopay_order_id) if riopay_order_id else payment.order_id,
provider_name='riopay',
)
if guest_result is not None:
return True
# Получаем пользователя
user = await get_user_by_id(db, payment.user_id)
if not user:
@@ -316,7 +332,7 @@ class RioPayPaymentMixin:
UserModel.balance_kopeks: UserModel.balance_kopeks + payment.amount_kopeks,
UserModel.updated_at: datetime.now(UTC),
}
if was_first_topup:
if was_first_topup and not user.referred_by_id:
update_values[UserModel.has_made_first_topup] = True
await db.execute(update(UserModel).where(UserModel.id == user.id).values(update_values))
+603
View File
@@ -0,0 +1,603 @@
"""Mixin для интеграции с SeverPay (severpay.io)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from importlib import import_module
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.severpay_service import severpay_service
from app.utils.payment_logger import payment_logger as logger
from app.utils.user_utils import format_referrer_info
# Маппинг статусов SeverPay -> internal
SEVERPAY_STATUS_MAP: dict[str, tuple[str, bool]] = {
'new': ('pending', False),
'process': ('processing', False),
'success': ('success', True),
'decline': ('declined', False),
'fail': ('failed', False),
}
class SeverPayPaymentMixin:
"""Mixin для работы с платежами SeverPay."""
async def create_severpay_payment(
self,
db: AsyncSession,
*,
user_id: int | None,
amount_kopeks: int,
description: str = 'Пополнение баланса',
email: str | None = None,
language: str = 'ru',
return_url: str | None = None,
) -> dict[str, Any] | None:
"""
Создает платеж SeverPay.
Returns:
Словарь с данными платежа или None при ошибке
"""
if not settings.is_severpay_enabled():
logger.error('SeverPay не настроен')
return None
# Валидация лимитов
if amount_kopeks < settings.SEVERPAY_MIN_AMOUNT_KOPEKS:
logger.warning(
'SeverPay: сумма меньше минимальной',
amount_kopeks=amount_kopeks,
SEVERPAY_MIN_AMOUNT_KOPEKS=settings.SEVERPAY_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.SEVERPAY_MAX_AMOUNT_KOPEKS:
logger.warning(
'SeverPay: сумма больше максимальной',
amount_kopeks=amount_kopeks,
SEVERPAY_MAX_AMOUNT_KOPEKS=settings.SEVERPAY_MAX_AMOUNT_KOPEKS,
)
return None
# Получаем telegram_id пользователя для order_id
payment_module = import_module('app.services.payment_service')
if user_id is not None:
user = await payment_module.get_user_by_id(db, user_id)
tg_id = user.telegram_id if user else user_id
else:
tg_id = 'guest'
# Генерируем уникальный order_id с telegram_id для удобного поиска
order_id = f'sp{tg_id}_{uuid.uuid4().hex[:6]}'
amount_rubles = amount_kopeks / 100
currency = settings.SEVERPAY_CURRENCY
# Срок действия платежа
lifetime = settings.SEVERPAY_LIFETIME
expires_at = datetime.now(UTC) + timedelta(minutes=lifetime)
# Метаданные
metadata = {
'user_id': user_id,
'amount_kopeks': amount_kopeks,
'description': description,
'language': language,
'type': 'balance_topup',
}
try:
# Используем API для создания платежа
result = await severpay_service.create_payment(
order_id=order_id,
amount=amount_rubles,
currency=currency,
client_email=email or '',
client_id=str(tg_id),
url_return=return_url or settings.SEVERPAY_RETURN_URL,
lifetime=lifetime,
)
payment_url = result.get('url')
severpay_id = str(result.get('id', '')) if result.get('id') else None
severpay_uid = result.get('uid')
if not payment_url:
logger.error('SeverPay API не вернул URL платежа', result=result)
return None
logger.info(
'SeverPay API: создан платеж',
order_id=order_id,
severpay_id=severpay_id,
payment_url=payment_url,
)
# Вычисляем expires_at из ответа API если доступен
expire_at_raw = result.get('expire_at')
if expire_at_raw:
try:
expires_at = datetime.fromtimestamp(int(expire_at_raw), tz=UTC)
except (ValueError, TypeError):
pass
# Сохраняем в БД
severpay_crud = import_module('app.database.crud.severpay')
local_payment = await severpay_crud.create_severpay_payment(
db=db,
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
severpay_id=severpay_id,
severpay_uid=severpay_uid,
expires_at=expires_at,
metadata_json=metadata,
)
logger.info(
'SeverPay: создан платеж',
order_id=order_id,
user_id=user_id,
amount_rubles=amount_rubles,
currency=currency,
)
return {
'order_id': order_id,
'severpay_id': severpay_id,
'severpay_uid': severpay_uid,
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_rubles,
'currency': currency,
'payment_url': payment_url,
'expires_at': expires_at.isoformat(),
'local_payment_id': local_payment.id,
}
except Exception as e:
logger.exception('SeverPay: ошибка создания платежа', error=e)
return None
async def process_severpay_webhook(
self,
db: AsyncSession,
payload: dict[str, Any],
) -> bool:
"""
Обрабатывает webhook от SeverPay.
Подпись проверяется в webserver/payments.py до вызова этого метода.
Args:
db: Сессия БД
payload: JSON тело webhook (sign проверен в webserver, тело передаётся как есть)
Returns:
True если платеж успешно обработан
"""
try:
webhook_type = payload.get('type')
if webhook_type != 'payin':
logger.info('SeverPay webhook: пропускаем тип', webhook_type=webhook_type)
return True
data = payload.get('data', {})
severpay_id = str(data.get('id', '')) if data.get('id') else None
order_id = data.get('order_id')
severpay_status = data.get('status')
amount = data.get('amount')
if not severpay_id or not severpay_status:
logger.warning('SeverPay webhook: отсутствуют обязательные поля', payload=payload)
return False
# Ищем платеж по order_id (наш) или severpay_id
severpay_crud = import_module('app.database.crud.severpay')
payment = None
if order_id:
payment = await severpay_crud.get_severpay_payment_by_order_id(db, order_id)
if not payment and severpay_id:
payment = await severpay_crud.get_severpay_payment_by_severpay_id(db, severpay_id)
if not payment:
logger.warning(
'SeverPay webhook: платеж не найден',
order_id=order_id,
severpay_id=severpay_id,
)
return False
# Проверка дублирования
if payment.is_paid:
logger.info('SeverPay webhook: платеж уже обработан', order_id=payment.order_id)
return True
# Маппинг статуса
status_info = SEVERPAY_STATUS_MAP.get(severpay_status, ('pending', False))
internal_status, is_paid = status_info
callback_payload = {
'severpay_id': severpay_id,
'order_id': order_id,
'status': severpay_status,
'amount': amount,
'currency': data.get('currency'),
}
# Проверка суммы ДО обновления статуса
if is_paid and amount is not None:
received_kopeks = round(float(amount) * 100)
if abs(received_kopeks - payment.amount_kopeks) > 1:
logger.error(
'SeverPay amount mismatch',
expected_kopeks=payment.amount_kopeks,
received_kopeks=received_kopeks,
order_id=payment.order_id,
)
await severpay_crud.update_severpay_payment_status(
db=db,
payment=payment,
status='amount_mismatch',
is_paid=False,
severpay_id=severpay_id,
callback_payload=callback_payload,
)
return False
# Финализируем платеж если оплачен — без промежуточного commit
if is_paid:
# Inline field assignments to keep FOR UPDATE lock intact
payment.status = internal_status
payment.is_paid = True
payment.severpay_id = severpay_id or payment.severpay_id
payment.callback_payload = callback_payload
payment.updated_at = datetime.now(UTC)
return await self._finalize_severpay_payment(db, payment, severpay_id=severpay_id, trigger='webhook')
# Для не-success статусов можно безопасно коммитить
payment = await severpay_crud.update_severpay_payment_status(
db=db,
payment=payment,
status=internal_status,
is_paid=False,
severpay_id=severpay_id,
callback_payload=callback_payload,
)
return True
except Exception as e:
logger.exception('SeverPay webhook: ошибка обработки', error=e)
return False
async def _finalize_severpay_payment(
self,
db: AsyncSession,
payment: Any,
*,
severpay_id: str | None,
trigger: str,
) -> bool:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
Использует FOR UPDATE lock для защиты от race condition.
"""
payment_module = import_module('app.services.payment_service')
severpay_crud = import_module('app.database.crud.severpay')
# Lock FIRST, then read fresh state
locked = await severpay_crud.get_severpay_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('SeverPay: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
if payment.transaction_id:
logger.info(
'SeverPay платеж уже связан с транзакцией',
order_id=payment.order_id,
transaction_id=payment.transaction_id,
trigger=trigger,
)
return True
# Read fresh metadata AFTER lock to avoid stale data
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
# --- Guest purchase flow ---
from app.services.payment.common import try_fulfill_guest_purchase
guest_result = await try_fulfill_guest_purchase(
db,
metadata=metadata,
payment_amount_kopeks=payment.amount_kopeks,
provider_payment_id=str(severpay_id) if severpay_id else payment.order_id,
provider_name='severpay',
)
if guest_result is not None:
return True
# Inline field assignments to keep FOR UPDATE lock
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.updated_at = datetime.now(UTC)
balance_already_credited = bool(metadata.get('balance_credited'))
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error('Пользователь не найден для SeverPay', user_id=payment.user_id)
return False
# Загружаем промогруппы в асинхронном контексте
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
for user_promo_group in getattr(user, 'user_promo_groups', []):
await db.refresh(user_promo_group, attribute_names=['promo_group'])
promo_group = user.get_primary_promo_group()
subscription = getattr(user, 'subscription', None)
referrer_info = format_referrer_info(user)
transaction_external_id = str(severpay_id) if severpay_id else payment.order_id
# Проверяем дупликат транзакции
existing_transaction = None
if transaction_external_id:
existing_transaction = await payment_module.get_transaction_by_external_id(
db,
transaction_external_id,
PaymentMethod.SEVERPAY,
)
display_name = settings.get_severpay_display_name()
description = f'Пополнение через {display_name}'
transaction = existing_transaction
created_transaction = False
if not transaction:
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=description,
payment_method=PaymentMethod.SEVERPAY,
external_id=transaction_external_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
commit=False,
)
created_transaction = True
await severpay_crud.link_severpay_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
should_credit_balance = created_transaction or not balance_already_credited
if not should_credit_balance:
logger.info('SeverPay платеж уже зачислил баланс ранее', order_id=payment.order_id)
return True
# Lock user row to prevent concurrent balance race conditions
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
# Emit deferred side-effects after atomic commit
from app.database.crud.transaction import emit_transaction_side_effects
await emit_transaction_side_effects(
db,
transaction,
amount_kopeks=payment.amount_kopeks,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
payment_method=PaymentMethod.SEVERPAY,
external_id=transaction_external_id,
)
topup_status = '🆕 Первое пополнение' if was_first_topup else '🔄 Пополнение'
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
payment.amount_kopeks,
getattr(self, 'bot', None),
)
except Exception as error:
logger.error('Ошибка обработки реферального пополнения SeverPay', error=error)
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
if getattr(self, 'bot', None):
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error('Ошибка отправки админ уведомления SeverPay', error=error)
if getattr(self, 'bot', None) and user.telegram_id:
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
'✅ <b>Пополнение успешно!</b>\n\n'
f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
f'💳 Способ: {display_name}\n'
f'🆔 Транзакция: {transaction.id}\n\n'
'Баланс пополнен автоматически!'
),
parse_mode='HTML',
reply_markup=keyboard,
)
except Exception as error:
logger.error('Ошибка отправки уведомления пользователю SeverPay', error=error)
try:
from app.services.payment.common import send_cart_notification_after_topup
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
except Exception as error:
logger.error(
'Ошибка при работе с сохраненной корзиной для пользователя',
user_id=payment.user_id,
error=error,
exc_info=True,
)
metadata['balance_change'] = {
'old_balance': old_balance,
'new_balance': user.balance_kopeks,
'credited_at': datetime.now(UTC).isoformat(),
}
metadata['balance_credited'] = True
payment.metadata_json = metadata
await db.commit()
logger.info(
'Обработан SeverPay платеж',
order_id=payment.order_id,
user_id=payment.user_id,
trigger=trigger,
)
return True
async def check_severpay_payment_status(
self,
db: AsyncSession,
order_id: str,
) -> dict[str, Any] | None:
"""Проверяет статус платежа через API."""
try:
severpay_crud = import_module('app.database.crud.severpay')
payment = await severpay_crud.get_severpay_payment_by_order_id(db, order_id)
if not payment:
logger.warning('SeverPay payment not found', order_id=order_id)
return None
if payment.is_paid:
return {
'payment': payment,
'status': 'success',
'is_paid': True,
}
# Проверяем через API по severpay_id
if payment.severpay_id:
try:
order_data = await severpay_service.get_payment(payment.severpay_id)
severpay_status = order_data.get('status')
if severpay_status:
status_info = SEVERPAY_STATUS_MAP.get(severpay_status, ('pending', False))
internal_status, is_paid = status_info
if is_paid:
# Проверка суммы
api_amount = order_data.get('amount')
if api_amount is not None:
received_kopeks = round(float(api_amount) * 100)
if abs(received_kopeks - payment.amount_kopeks) > 1:
logger.error(
'SeverPay amount mismatch (API check)',
expected_kopeks=payment.amount_kopeks,
received_kopeks=received_kopeks,
order_id=payment.order_id,
)
await severpay_crud.update_severpay_payment_status(
db=db,
payment=payment,
status='amount_mismatch',
is_paid=False,
severpay_id=payment.severpay_id,
callback_payload={
'check_source': 'api',
'severpay_order_data': order_data,
},
)
return {
'payment': payment,
'status': 'amount_mismatch',
'is_paid': False,
}
logger.info('SeverPay payment confirmed via API', order_id=payment.order_id)
callback_payload = {
'check_source': 'api',
'severpay_order_data': order_data,
}
payment = await severpay_crud.update_severpay_payment_status(
db=db,
payment=payment,
status='success',
is_paid=True,
severpay_id=payment.severpay_id,
callback_payload=callback_payload,
)
await self._finalize_severpay_payment(
db,
payment,
severpay_id=payment.severpay_id,
trigger='api_check',
)
elif internal_status != payment.status:
# Обновляем статус если изменился
payment = await severpay_crud.update_severpay_payment_status(
db=db,
payment=payment,
status=internal_status,
)
except Exception as e:
logger.error('Error checking SeverPay payment status via API', error=e)
return {
'payment': payment,
'status': payment.status or 'pending',
'is_paid': payment.is_paid,
}
except Exception as e:
logger.exception('SeverPay: ошибка проверки статуса', error=e)
return None
+9 -1
View File
@@ -260,6 +260,14 @@ class TelegramStarsMixin:
logger.error('Не удалось активировать pending подписку пользователя', user_id=user.id)
return False
# Consume promo-offer discount (invoice was created with discounted price)
try:
from app.utils.promo_offer import consume_user_promo_offer
await consume_user_promo_offer(db, user.id)
except Exception as promo_error:
logger.warning('Ошибка потребления промо-оффера при Stars оплате', user_id=user.id, error=promo_error)
try:
from app.services.subscription_service import SubscriptionService
@@ -434,7 +442,7 @@ class TelegramStarsMixin:
"❌ Описание '' не подходит для реферальной логики", description_for_referral=description_for_referral
)
if was_first_topup and not user.has_made_first_topup:
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
+1 -1
View File
@@ -529,7 +529,7 @@ class WataPaymentMixin:
except Exception as error:
logger.error('Ошибка обработки реферального пополнения WATA', error=error)
if was_first_topup and not user.has_made_first_topup:
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
+13 -1
View File
@@ -819,7 +819,7 @@ class YooKassaPaymentMixin:
except Exception as error:
logger.error('Ошибка обработки реферального пополнения YooKassa', error=error)
if was_first_topup and not getattr(user, 'has_made_first_topup', False):
if was_first_topup and not getattr(user, 'has_made_first_topup', False) and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
@@ -912,6 +912,18 @@ class YooKassaPaymentMixin:
if subscription:
logger.info('Подписка успешно активирована для пользователя', user_id=user.id)
# Consume promo-offer discount (invoice was created with discounted price)
try:
from app.utils.promo_offer import consume_user_promo_offer
await consume_user_promo_offer(db, user.id)
except Exception as promo_error:
logger.warning(
'Ошибка потребления промо-оффера при YooKassa оплате',
user_id=user.id,
error=promo_error,
)
# Обновляем данные подписки в RemnaWave, чтобы получить актуальные ссылки
try:
remnawave_user = await subscription_service.create_remnawave_user(db, subscription)
+12 -1
View File
@@ -126,7 +126,10 @@ def _get_method_defaults() -> dict:
'is_configured': settings.is_kassa_ai_enabled(),
'default_min': settings.KASSA_AI_MIN_AMOUNT_KOPEKS,
'default_max': settings.KASSA_AI_MAX_AMOUNT_KOPEKS,
'available_sub_options': None,
'available_sub_options': [
{'id': 'sbp', 'name': 'СБП'},
{'id': 'card', 'name': 'Карта'},
],
},
'riopay': {
'default_display_name': settings.get_riopay_display_name(),
@@ -135,6 +138,13 @@ def _get_method_defaults() -> dict:
'default_max': settings.RIOPAY_MAX_AMOUNT_KOPEKS,
'available_sub_options': None,
},
'severpay': {
'default_display_name': settings.get_severpay_display_name(),
'is_configured': settings.is_severpay_enabled(),
'default_min': settings.SEVERPAY_MIN_AMOUNT_KOPEKS,
'default_max': settings.SEVERPAY_MAX_AMOUNT_KOPEKS,
'available_sub_options': None,
},
}
@@ -176,6 +186,7 @@ DEFAULT_METHOD_ORDER = [
'cloudpayments',
'kassa_ai',
'riopay',
'severpay',
]
+811
View File
@@ -0,0 +1,811 @@
"""Search service for querying payments across all provider tables."""
from __future__ import annotations
import enum
from collections import Counter
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any
import structlog
from sqlalchemy import cast, desc, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy.types import String as SAString
from app.database.models import (
CloudPaymentsPayment,
CryptoBotPayment,
FreekassaPayment,
HeleketPayment,
KassaAiPayment,
MulenPayPayment,
Pal24Payment,
PaymentMethod,
PlategaPayment,
RioPayPayment,
SeverPayPayment,
Transaction,
TransactionType,
User,
WataPayment,
YooKassaPayment,
)
from app.services.payment_verification_service import (
PendingPayment,
_build_record,
_metadata_is_balance,
_parse_cryptobot_amount_kopeks,
)
logger = structlog.get_logger(__name__)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
MAX_ALL_TIME_DAYS: int = 365
"""Safety limit for 'all time' queries to prevent unbounded scans."""
MAX_RECORDS_PER_PROVIDER: int = 5000
"""Hard limit on rows fetched from each provider table to prevent memory exhaustion."""
DEFAULT_PER_PAGE: int = 20
MAX_PER_PAGE: int = 100
def _escape_like(value: str) -> str:
"""Escape LIKE/ILIKE wildcard characters to prevent pattern injection."""
return value.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
class StatusFilter(str, enum.Enum):
"""Supported status filter values."""
ALL = 'all'
PENDING = 'pending'
PAID = 'paid'
CANCELLED = 'cancelled'
class PeriodPreset(str, enum.Enum):
"""Predefined period presets."""
H24 = '24h'
D7 = '7d'
D30 = '30d'
ALL = 'all'
_PERIOD_DELTAS: dict[PeriodPreset, timedelta] = {
PeriodPreset.H24: timedelta(hours=24),
PeriodPreset.D7: timedelta(days=7),
PeriodPreset.D30: timedelta(days=30),
PeriodPreset.ALL: timedelta(days=MAX_ALL_TIME_DAYS),
}
# Sets of provider-specific statuses used for classification.
_CANCELLED_STATUSES: frozenset[str] = frozenset(
{
'cancel',
'canceled',
'cancelled',
'declined',
'error',
'expired',
'fail',
'failed',
'amount_mismatch',
}
)
# ---------------------------------------------------------------------------
# Search params
# ---------------------------------------------------------------------------
@dataclass(slots=True)
class SearchParams:
"""Encapsulates validated search parameters."""
search: str | None = None
status_filter: StatusFilter = StatusFilter.ALL
method_filter: PaymentMethod | None = None
period: PeriodPreset = PeriodPreset.H24
date_from: datetime | None = None
date_to: datetime | None = None
page: int = 1
per_page: int = DEFAULT_PER_PAGE
@property
def cutoff(self) -> datetime:
"""Calculate the earliest datetime to consider."""
if self.date_from is not None:
return self.date_from
return datetime.now(UTC) - _PERIOD_DELTAS.get(self.period, _PERIOD_DELTAS[PeriodPreset.H24])
@property
def upper_bound(self) -> datetime | None:
"""Upper datetime bound (only set for custom ranges)."""
return self.date_to
@dataclass(slots=True)
class SearchStats:
"""Aggregated search statistics."""
total: int = 0
pending: int = 0
paid: int = 0
cancelled: int = 0
by_method: dict[str, int] | None = None
# ---------------------------------------------------------------------------
# Status classification
# ---------------------------------------------------------------------------
_PAID_STATUSES: frozenset[str] = frozenset(
{
'completed',
'confirmed',
'paid',
'paid_over',
'succeeded',
'success',
}
)
def _classify_status(record: PendingPayment) -> StatusFilter:
"""Classify a payment record into one of the three buckets."""
if record.is_paid:
return StatusFilter.PAID
status_lower = (record.status or '').lower()
if status_lower in _PAID_STATUSES:
return StatusFilter.PAID
if status_lower in _CANCELLED_STATUSES:
return StatusFilter.CANCELLED
return StatusFilter.PENDING
# ---------------------------------------------------------------------------
# User search type detection
# ---------------------------------------------------------------------------
class _UserSearchKind(enum.Enum):
USERNAME = 'username'
TELEGRAM_ID = 'telegram_id'
EMAIL = 'email'
INVOICE = 'invoice'
def _detect_user_search_kind(query: str) -> _UserSearchKind:
"""Auto-detect the type of user search query."""
stripped = query.strip()
if stripped.startswith('@'):
return _UserSearchKind.USERNAME
if stripped.isdigit():
return _UserSearchKind.TELEGRAM_ID
if '@' in stripped:
return _UserSearchKind.EMAIL
return _UserSearchKind.INVOICE
# ---------------------------------------------------------------------------
# Per-provider search functions
# ---------------------------------------------------------------------------
def _apply_date_filter(
stmt: Any,
created_at_col: Any,
cutoff: datetime,
upper_bound: datetime | None,
) -> Any:
"""Apply date range filters to a select statement."""
stmt = stmt.where(created_at_col >= cutoff)
if upper_bound is not None:
stmt = stmt.where(created_at_col <= upper_bound)
return stmt
def _apply_user_join_filter(
stmt: Any,
model: type,
search_kind: _UserSearchKind,
search_value: str,
) -> Any:
"""Apply user-based search filters by joining the User table."""
stmt = stmt.join(User, model.user_id == User.id)
if search_kind == _UserSearchKind.USERNAME:
username = search_value.lstrip('@')
stmt = stmt.where(User.username.ilike(f'%{_escape_like(username)}%'))
elif search_kind == _UserSearchKind.TELEGRAM_ID:
stmt = stmt.where(User.telegram_id == int(search_value))
elif search_kind == _UserSearchKind.EMAIL:
stmt = stmt.where(User.email.ilike(f'%{_escape_like(search_value)}%'))
return stmt
async def _search_yookassa(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = (
select(YooKassaPayment).options(selectinload(YooKassaPayment.user)).order_by(desc(YooKassaPayment.created_at))
)
stmt = _apply_date_filter(stmt, YooKassaPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
stmt = stmt.where(YooKassaPayment.yookassa_payment_id.ilike(f'%{_escape_like(params.search)}%'))
else:
stmt = _apply_user_join_filter(stmt, YooKassaPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
if not _metadata_is_balance(payment):
continue
record = _build_record(
PaymentMethod.YOOKASSA,
payment,
identifier=payment.yookassa_payment_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(getattr(payment, 'is_paid', False)),
)
if record:
records.append(record)
return records
async def _search_cryptobot(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = (
select(CryptoBotPayment)
.options(selectinload(CryptoBotPayment.user))
.order_by(desc(CryptoBotPayment.created_at))
)
stmt = _apply_date_filter(stmt, CryptoBotPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
stmt = stmt.where(CryptoBotPayment.invoice_id.ilike(f'%{_escape_like(params.search)}%'))
else:
stmt = _apply_user_join_filter(stmt, CryptoBotPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
amount_kopeks = _parse_cryptobot_amount_kopeks(payment)
record = _build_record(
PaymentMethod.CRYPTOBOT,
payment,
identifier=payment.invoice_id,
amount_kopeks=amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
)
if record:
records.append(record)
return records
async def _search_heleket(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = select(HeleketPayment).options(selectinload(HeleketPayment.user)).order_by(desc(HeleketPayment.created_at))
stmt = _apply_date_filter(stmt, HeleketPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
stmt = stmt.where(
or_(
HeleketPayment.uuid.ilike(f'%{_escape_like(params.search)}%'),
HeleketPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
)
)
else:
stmt = _apply_user_join_filter(stmt, HeleketPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
record = _build_record(
PaymentMethod.HELEKET,
payment,
identifier=payment.uuid,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _search_mulenpay(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = (
select(MulenPayPayment).options(selectinload(MulenPayPayment.user)).order_by(desc(MulenPayPayment.created_at))
)
stmt = _apply_date_filter(stmt, MulenPayPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
conditions = [MulenPayPayment.uuid.ilike(f'%{_escape_like(params.search)}%')]
# mulen_payment_id is Integer -- cast for ILIKE
if params.search.isdigit():
conditions.append(MulenPayPayment.mulen_payment_id == int(params.search))
else:
conditions.append(
cast(MulenPayPayment.mulen_payment_id, SAString).ilike(f'%{_escape_like(params.search)}%')
)
stmt = stmt.where(or_(*conditions))
else:
stmt = _apply_user_join_filter(stmt, MulenPayPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
record = _build_record(
PaymentMethod.MULENPAY,
payment,
identifier=payment.uuid,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
)
if record:
records.append(record)
return records
async def _search_pal24(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = select(Pal24Payment).options(selectinload(Pal24Payment.user)).order_by(desc(Pal24Payment.created_at))
stmt = _apply_date_filter(stmt, Pal24Payment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
conditions = [
Pal24Payment.bill_id.ilike(f'%{_escape_like(params.search)}%'),
Pal24Payment.order_id.ilike(f'%{_escape_like(params.search)}%'),
]
stmt = stmt.where(or_(*conditions))
else:
stmt = _apply_user_join_filter(stmt, Pal24Payment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
record = _build_record(
PaymentMethod.PAL24,
payment,
identifier=payment.bill_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _search_wata(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = select(WataPayment).options(selectinload(WataPayment.user)).order_by(desc(WataPayment.created_at))
stmt = _apply_date_filter(stmt, WataPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
conditions = [
WataPayment.payment_link_id.ilike(f'%{_escape_like(params.search)}%'),
WataPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
]
stmt = stmt.where(or_(*conditions))
else:
stmt = _apply_user_join_filter(stmt, WataPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
record = _build_record(
PaymentMethod.WATA,
payment,
identifier=payment.payment_link_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _search_platega(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = select(PlategaPayment).options(selectinload(PlategaPayment.user)).order_by(desc(PlategaPayment.created_at))
stmt = _apply_date_filter(stmt, PlategaPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
conditions = [
PlategaPayment.correlation_id.ilike(f'%{_escape_like(params.search)}%'),
PlategaPayment.platega_transaction_id.ilike(f'%{_escape_like(params.search)}%'),
]
stmt = stmt.where(or_(*conditions))
else:
stmt = _apply_user_join_filter(stmt, PlategaPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
identifier = payment.platega_transaction_id or payment.correlation_id or str(payment.id)
record = _build_record(
PaymentMethod.PLATEGA,
payment,
identifier=identifier,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _search_cloudpayments(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = (
select(CloudPaymentsPayment)
.options(selectinload(CloudPaymentsPayment.user))
.order_by(desc(CloudPaymentsPayment.created_at))
)
stmt = _apply_date_filter(stmt, CloudPaymentsPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
conditions = [CloudPaymentsPayment.invoice_id.ilike(f'%{_escape_like(params.search)}%')]
# transaction_id_cp is BigInteger -- cast for ILIKE
if params.search.isdigit():
conditions.append(CloudPaymentsPayment.transaction_id_cp == int(params.search))
else:
conditions.append(
cast(CloudPaymentsPayment.transaction_id_cp, SAString).ilike(f'%{_escape_like(params.search)}%')
)
stmt = stmt.where(or_(*conditions))
else:
stmt = _apply_user_join_filter(stmt, CloudPaymentsPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
record = _build_record(
PaymentMethod.CLOUDPAYMENTS,
payment,
identifier=payment.invoice_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
)
if record:
records.append(record)
return records
async def _search_freekassa(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = (
select(FreekassaPayment)
.options(selectinload(FreekassaPayment.user))
.order_by(desc(FreekassaPayment.created_at))
)
stmt = _apply_date_filter(stmt, FreekassaPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
conditions = [
FreekassaPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
FreekassaPayment.freekassa_order_id.ilike(f'%{_escape_like(params.search)}%'),
]
stmt = stmt.where(or_(*conditions))
else:
stmt = _apply_user_join_filter(stmt, FreekassaPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
record = _build_record(
PaymentMethod.FREEKASSA,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
)
if record:
records.append(record)
return records
async def _search_kassa_ai(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = select(KassaAiPayment).options(selectinload(KassaAiPayment.user)).order_by(desc(KassaAiPayment.created_at))
stmt = _apply_date_filter(stmt, KassaAiPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
conditions = [
KassaAiPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
KassaAiPayment.kassa_ai_order_id.ilike(f'%{_escape_like(params.search)}%'),
]
stmt = stmt.where(or_(*conditions))
else:
stmt = _apply_user_join_filter(stmt, KassaAiPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
record = _build_record(
PaymentMethod.KASSA_AI,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
)
if record:
records.append(record)
return records
async def _search_riopay(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = select(RioPayPayment).options(selectinload(RioPayPayment.user)).order_by(desc(RioPayPayment.created_at))
stmt = _apply_date_filter(stmt, RioPayPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
conditions = [
RioPayPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
RioPayPayment.riopay_order_id.ilike(f'%{_escape_like(params.search)}%'),
]
stmt = stmt.where(or_(*conditions))
else:
stmt = _apply_user_join_filter(stmt, RioPayPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
record = _build_record(
PaymentMethod.RIOPAY,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _search_severpay(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = (
select(SeverPayPayment).options(selectinload(SeverPayPayment.user)).order_by(desc(SeverPayPayment.created_at))
)
stmt = _apply_date_filter(stmt, SeverPayPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
conditions = [
SeverPayPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
SeverPayPayment.severpay_id.ilike(f'%{_escape_like(params.search)}%'),
SeverPayPayment.severpay_uid.ilike(f'%{_escape_like(params.search)}%'),
]
stmt = stmt.where(or_(*conditions))
else:
stmt = _apply_user_join_filter(stmt, SeverPayPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
record = _build_record(
PaymentMethod.SEVERPAY,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _search_stars(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = (
select(Transaction)
.options(selectinload(Transaction.user))
.where(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.payment_method == PaymentMethod.TELEGRAM_STARS.value,
)
.order_by(desc(Transaction.created_at))
)
stmt = _apply_date_filter(stmt, Transaction.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
stmt = stmt.where(Transaction.external_id.ilike(f'%{_escape_like(params.search)}%'))
else:
stmt = _apply_user_join_filter(stmt, Transaction, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for transaction in result.scalars().all():
record = _build_record(
PaymentMethod.TELEGRAM_STARS,
transaction,
identifier=transaction.external_id or str(transaction.id),
amount_kopeks=transaction.amount_kopeks,
status='paid' if transaction.is_completed else 'pending',
is_paid=bool(transaction.is_completed),
)
if record:
records.append(record)
return records
# ---------------------------------------------------------------------------
# Provider -> search function mapping
# ---------------------------------------------------------------------------
_PROVIDER_SEARCH_MAP: dict[PaymentMethod, Any] = {
PaymentMethod.YOOKASSA: _search_yookassa,
PaymentMethod.CRYPTOBOT: _search_cryptobot,
PaymentMethod.HELEKET: _search_heleket,
PaymentMethod.MULENPAY: _search_mulenpay,
PaymentMethod.PAL24: _search_pal24,
PaymentMethod.WATA: _search_wata,
PaymentMethod.PLATEGA: _search_platega,
PaymentMethod.CLOUDPAYMENTS: _search_cloudpayments,
PaymentMethod.FREEKASSA: _search_freekassa,
PaymentMethod.KASSA_AI: _search_kassa_ai,
PaymentMethod.RIOPAY: _search_riopay,
PaymentMethod.SEVERPAY: _search_severpay,
PaymentMethod.TELEGRAM_STARS: _search_stars,
}
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def search_payments(
db: AsyncSession,
params: SearchParams,
) -> tuple[list[PendingPayment], int]:
"""Search payments across all (or filtered) providers.
Returns:
Tuple of ``(page_items, total_count)`` where *page_items* is
a slice according to ``params.page`` / ``params.per_page``.
"""
# Determine which providers to query
if params.method_filter is not None:
search_fn = _PROVIDER_SEARCH_MAP.get(params.method_filter)
if search_fn is None:
return [], 0
provider_results: list[list[PendingPayment]] = [await search_fn(db, params)]
else:
provider_results = []
for search_fn in _PROVIDER_SEARCH_MAP.values():
provider_results.append(await search_fn(db, params))
# Flatten
all_records: list[PendingPayment] = []
for batch in provider_results:
all_records.extend(batch)
# Apply status filter in Python (status classification depends on provider logic)
if params.status_filter != StatusFilter.ALL:
all_records = [r for r in all_records if _classify_status(r) == params.status_filter]
# Sort globally by created_at desc
all_records.sort(key=lambda r: r.created_at, reverse=True)
total = len(all_records)
# Paginate
start_idx = (params.page - 1) * params.per_page
page_items = all_records[start_idx : start_idx + params.per_page]
return page_items, total
async def search_payments_stats(
db: AsyncSession,
params: SearchParams,
) -> SearchStats:
"""Compute aggregated statistics for the given search filters.
Pagination params are ignored -- stats cover the full result set.
"""
# Reuse the same search logic but force ALL statuses for counting
stats_params = SearchParams(
search=params.search,
status_filter=StatusFilter.ALL,
method_filter=params.method_filter,
period=params.period,
date_from=params.date_from,
date_to=params.date_to,
page=1,
per_page=MAX_PER_PAGE,
)
# Query all providers
if stats_params.method_filter is not None:
search_fn = _PROVIDER_SEARCH_MAP.get(stats_params.method_filter)
if search_fn is None:
return SearchStats()
all_records: list[PendingPayment] = await search_fn(db, stats_params)
else:
all_records = []
for search_fn in _PROVIDER_SEARCH_MAP.values():
all_records.extend(await search_fn(db, stats_params))
# Classify
pending_count = 0
paid_count = 0
cancelled_count = 0
method_counter: Counter[str] = Counter()
for record in all_records:
status = _classify_status(record)
if status == StatusFilter.PAID:
paid_count += 1
elif status == StatusFilter.CANCELLED:
cancelled_count += 1
else:
pending_count += 1
method_counter[record.method.value] += 1
return SearchStats(
total=len(all_records),
pending=pending_count,
paid=paid_count,
cancelled=cancelled_count,
by_method=dict(method_counter),
)
+91 -3
View File
@@ -34,6 +34,7 @@ from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin
from app.services.payment.freekassa import FreekassaPaymentMixin
from app.services.payment.kassa_ai import KassaAiPaymentMixin
from app.services.payment.riopay import RioPayPaymentMixin
from app.services.payment.severpay import SeverPayPaymentMixin
from app.services.platega_service import PlategaService
from app.services.wata_service import WataService
from app.services.yookassa_service import YooKassaService
@@ -296,6 +297,41 @@ async def update_cloudpayments_payment(*args, **kwargs):
return await cloudpayments_crud.update_cloudpayments_payment(*args, **kwargs)
async def create_severpay_payment(*args, **kwargs):
severpay_crud = import_module('app.database.crud.severpay')
return await severpay_crud.create_severpay_payment(*args, **kwargs)
async def get_severpay_payment_by_order_id(*args, **kwargs):
severpay_crud = import_module('app.database.crud.severpay')
return await severpay_crud.get_severpay_payment_by_order_id(*args, **kwargs)
async def get_severpay_payment_by_severpay_id(*args, **kwargs):
severpay_crud = import_module('app.database.crud.severpay')
return await severpay_crud.get_severpay_payment_by_severpay_id(*args, **kwargs)
async def get_severpay_payment_by_id(*args, **kwargs):
severpay_crud = import_module('app.database.crud.severpay')
return await severpay_crud.get_severpay_payment_by_id(*args, **kwargs)
async def get_severpay_payment_by_id_for_update(*args, **kwargs):
severpay_crud = import_module('app.database.crud.severpay')
return await severpay_crud.get_severpay_payment_by_id_for_update(*args, **kwargs)
async def update_severpay_payment_status(*args, **kwargs):
severpay_crud = import_module('app.database.crud.severpay')
return await severpay_crud.update_severpay_payment_status(*args, **kwargs)
async def link_severpay_payment_to_transaction(*args, **kwargs):
severpay_crud = import_module('app.database.crud.severpay')
return await severpay_crud.link_severpay_payment_to_transaction(*args, **kwargs)
# Mapping from model_name to getter function name for providers
# where it differs from the standard get_{model_name}_payment_by_id pattern.
_GETTER_OVERRIDES: dict[str, str] = {
@@ -318,6 +354,7 @@ class PaymentService(
FreekassaPaymentMixin,
KassaAiPaymentMixin,
RioPayPaymentMixin,
SeverPayPaymentMixin,
):
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
@@ -666,23 +703,74 @@ class PaymentService(
return None
# --- KassaAI ----------------------------------------------------------
if payment_method == 'kassa_ai':
if payment_method in ('kassa_ai', 'kassa_ai_sbp', 'kassa_ai_card'):
if not settings.is_kassa_ai_enabled():
logger.warning('KassaAI is not enabled, cannot create guest payment')
return None
from app.services.kassa_ai_service import KASSA_AI_SUB_METHODS
sub = KASSA_AI_SUB_METHODS.get(payment_method)
ps_id = sub['payment_system_id'] if sub else None
result = await self.create_kassa_ai_payment(
db=db,
user_id=None,
amount_kopeks=amount_kopeks,
description=description,
payment_system_id=ps_id,
)
if result:
await _patch_guest_metadata(result['local_payment_id'], 'kassa_ai')
await _patch_guest_metadata(result['local_payment_id'], payment_method)
return {
'payment_url': result.get('payment_url'),
'payment_id': result.get('order_id'),
'provider': 'kassa_ai',
'provider': payment_method,
}
return None
# --- RioPay -----------------------------------------------------------
if payment_method == 'riopay':
if not settings.is_riopay_enabled():
logger.warning('RioPay is not enabled, cannot create guest payment')
return None
result = await self.create_riopay_payment(
db=db,
user_id=None,
amount_kopeks=amount_kopeks,
description=description,
success_url=return_url,
fail_url=return_url,
)
if result:
await _patch_guest_metadata(result['local_payment_id'], 'riopay')
return {
'payment_url': result.get('payment_url'),
'payment_id': result.get('riopay_order_id') or result.get('order_id'),
'provider': 'riopay',
}
return None
# --- SeverPay ---------------------------------------------------------
if payment_method == 'severpay':
if not settings.is_severpay_enabled():
logger.warning('SeverPay is not enabled, cannot create guest payment')
return None
result = await self.create_severpay_payment(
db=db,
user_id=None,
amount_kopeks=amount_kopeks,
description=description,
return_url=return_url,
)
if result:
await _patch_guest_metadata(result['local_payment_id'], 'severpay')
return {
'payment_url': result.get('payment_url'),
'payment_id': result.get('severpay_id') or result.get('order_id'),
'provider': 'severpay',
}
return None
+146 -6
View File
@@ -27,6 +27,8 @@ from app.database.models import (
Pal24Payment,
PaymentMethod,
PlategaPayment,
RioPayPayment,
SeverPayPayment,
Transaction,
TransactionType,
User,
@@ -72,6 +74,8 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
PaymentMethod.CLOUDPAYMENTS,
PaymentMethod.FREEKASSA,
PaymentMethod.KASSA_AI,
PaymentMethod.RIOPAY,
PaymentMethod.SEVERPAY,
}
)
@@ -90,6 +94,8 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
# Payments are processed via webhook (wata_webhook.py).
PaymentMethod.FREEKASSA,
PaymentMethod.KASSA_AI,
PaymentMethod.RIOPAY,
PaymentMethod.SEVERPAY,
}
)
@@ -115,6 +121,10 @@ def method_display_name(method: PaymentMethod) -> str:
return 'Freekassa'
if method == PaymentMethod.KASSA_AI:
return settings.get_kassa_ai_display_name()
if method == PaymentMethod.RIOPAY:
return settings.get_riopay_display_name()
if method == PaymentMethod.SEVERPAY:
return settings.get_severpay_display_name()
if method == PaymentMethod.TELEGRAM_STARS:
return 'Telegram Stars'
return method.value
@@ -141,6 +151,10 @@ def _method_is_enabled(method: PaymentMethod) -> bool:
return settings.is_freekassa_enabled()
if method == PaymentMethod.KASSA_AI:
return settings.is_kassa_ai_enabled()
if method == PaymentMethod.RIOPAY:
return settings.is_riopay_enabled()
if method == PaymentMethod.SEVERPAY:
return settings.is_severpay_enabled()
return False
@@ -242,12 +256,23 @@ class AutoPaymentVerificationService:
)
for record in candidates:
refreshed = await run_manual_check(
session,
record.method,
record.local_id,
self._payment_service,
)
try:
refreshed = await run_manual_check(
session,
record.method,
record.local_id,
self._payment_service,
)
except Exception as check_error:
logger.error(
'Ошибка проверки платежа, откатываем сессию',
method_display_name=method_display_name(record.method),
identifier=record.identifier,
error=check_error,
)
if session.in_transaction():
await session.rollback()
continue
if not refreshed:
logger.debug(
@@ -365,6 +390,20 @@ def _is_kassa_ai_pending(payment: KassaAiPayment) -> bool:
return status in {'pending', 'created', 'processing'}
def _is_severpay_pending(payment: SeverPayPayment) -> bool:
if payment.is_paid:
return False
status = (payment.status or '').lower()
return status in {'pending', 'processing'}
def _is_riopay_pending(payment: RioPayPayment) -> bool:
if payment.is_paid:
return False
status = (payment.status or '').lower()
return status in {'pending'}
def _parse_cryptobot_amount_kopeks(payment: CryptoBotPayment) -> int:
payload = payment.payload or ''
match = re.search(r'_(\d+)$', payload)
@@ -682,6 +721,58 @@ async def _fetch_kassa_ai_payments(db: AsyncSession, cutoff: datetime) -> list[P
return records
async def _fetch_riopay_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
stmt = (
select(RioPayPayment)
.options(selectinload(RioPayPayment.user))
.where(RioPayPayment.created_at >= cutoff)
.order_by(desc(RioPayPayment.created_at))
)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
if not _is_riopay_pending(payment):
continue
record = _build_record(
PaymentMethod.RIOPAY,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _fetch_severpay_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
stmt = (
select(SeverPayPayment)
.options(selectinload(SeverPayPayment.user))
.where(SeverPayPayment.created_at >= cutoff)
.order_by(desc(SeverPayPayment.created_at))
)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
if not _is_severpay_pending(payment):
continue
record = _build_record(
PaymentMethod.SEVERPAY,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _fetch_stars_transactions(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
stmt = (
select(Transaction)
@@ -729,6 +820,8 @@ async def list_recent_pending_payments(
await _fetch_cloudpayments_payments(db, cutoff),
await _fetch_freekassa_payments(db, cutoff),
await _fetch_kassa_ai_payments(db, cutoff),
await _fetch_riopay_payments(db, cutoff),
await _fetch_severpay_payments(db, cutoff),
await _fetch_stars_transactions(db, cutoff),
)
@@ -897,6 +990,36 @@ async def get_payment_record(
is_paid=bool(payment.is_paid),
)
if method == PaymentMethod.RIOPAY:
payment = await db.get(RioPayPayment, local_payment_id)
if not payment:
return None
await db.refresh(payment, attribute_names=['user'])
return _build_record(
method,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if method == PaymentMethod.SEVERPAY:
payment = await db.get(SeverPayPayment, local_payment_id)
if not payment:
return None
await db.refresh(payment, attribute_names=['user'])
return _build_record(
method,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if method == PaymentMethod.TELEGRAM_STARS:
transaction = await db.get(Transaction, local_payment_id)
if not transaction:
@@ -955,6 +1078,20 @@ async def run_manual_check(
elif method == PaymentMethod.KASSA_AI:
result = await payment_service.get_kassa_ai_payment_status(db, local_payment_id)
payment = result.get('payment') if result else None
elif method == PaymentMethod.SEVERPAY:
severpay_payment = await db.get(SeverPayPayment, local_payment_id)
if severpay_payment:
result = await payment_service.check_severpay_payment_status(db, severpay_payment.order_id)
payment = result.get('payment') if result else None
else:
payment = None
elif method == PaymentMethod.RIOPAY:
riopay_payment = await db.get(RioPayPayment, local_payment_id)
if riopay_payment:
result = await payment_service.check_riopay_payment_status(db, riopay_payment.order_id)
payment = result.get('payment') if result else None
else:
payment = None
else:
logger.warning('Manual check requested for unsupported method', method=method)
return None
@@ -972,6 +1109,9 @@ async def run_manual_check(
error=error,
exc_info=True,
)
# Откатываем сессию чтобы не оставлять её в грязном состоянии
if db.in_transaction():
await db.rollback()
return None
+548 -41
View File
@@ -15,7 +15,7 @@ from app.utils.promo_offer import get_user_active_promo_discount_percent
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import Subscription, User
from app.database.models import Subscription, Tariff, User
logger = structlog.get_logger(__name__)
@@ -27,8 +27,9 @@ class TariffBreakdown:
tariff_id: int
extra_devices: int
group_discount_pct: int
group_discount_pct: dict[str, int]
offer_discount_pct: int
months_in_period: int = 1
@dataclass(frozen=True)
@@ -42,9 +43,14 @@ class ClassicBreakdown:
base_traffic_gb: int
purchased_traffic_gb: int
extra_devices: int
# NB: dict[str, int] per-category (period/servers/traffic/devices), unlike TariffBreakdown's single int
# Per-category discount percents (period/servers/traffic/devices)
group_discount_pct: dict[str, int]
offer_discount_pct: int
# Original (pre-discount) prices — used by classic_pricing_to_purchase_details()
base_price_original: int = 0
traffic_price_per_month: int = 0
servers_price_per_month: int = 0
devices_price_per_month: int = 0
@dataclass(frozen=True)
@@ -68,6 +74,30 @@ class RenewalPricing:
return self.final_total + self.promo_group_discount + self.promo_offer_discount
@dataclass(frozen=True)
class TariffSwitchResult:
"""Immutable result of a tariff switch cost calculation."""
upgrade_cost: int # kopeks — amount to charge (0 if downgrade/same)
is_upgrade: bool # True if new tariff is more expensive
raw_cost: int # kopeks — cost before discounts (for UI display)
group_discount_pct: int
offer_discount_pct: int
new_period_days: int = 0 # 0 = keep current end date, >0 = set new subscription period
@property
def discount_value(self) -> int:
"""Сумма скидки в копейках."""
return self.raw_cost - self.upgrade_cost
@property
def effective_discount_pct(self) -> int:
"""Эффективный процент скидки (стекинг group + offer)."""
if self.raw_cost <= 0:
return 0
return round(self.discount_value * 100 / self.raw_cost)
class PricingEngine:
"""Unified pricing engine for all subscription renewal calculations."""
@@ -93,6 +123,286 @@ class PricingEngine:
offer_discount_value = after_group - after_offer
return after_offer, group_discount_value, offer_discount_value
@staticmethod
def resolve_promo_group(user: User | None):
"""Resolve primary promo group: get_primary_promo_group() first, fallback to user.promo_group."""
if not user:
return None
if hasattr(user, 'get_primary_promo_group'):
pg = user.get_primary_promo_group()
if pg is not None:
return pg
return getattr(user, 'promo_group', None)
@staticmethod
def get_addon_discount_percent(
user: User | None,
category: str,
period_days_hint: int | None = None,
*,
promo_group: PromoGroup | None = None,
) -> int:
"""Return addon discount percent for a given category.
Uses promo_group.get_discount_percent() which handles is_default fallback.
Checks apply_discounts_to_addons flag. Returns 0 if no discount.
If promo_group is provided explicitly, it takes precedence over
resolving from user (useful when caller already resolved the group).
"""
if promo_group is None:
if not user:
return 0
promo_group = PricingEngine.resolve_promo_group(user)
if not promo_group:
return 0
if not getattr(promo_group, 'apply_discounts_to_addons', True):
return 0
if hasattr(promo_group, 'get_discount_percent'):
return promo_group.get_discount_percent(category, period_days_hint)
# Fallback for promo groups without get_discount_percent
mapping = {
'traffic': 'traffic_discount_percent',
'servers': 'server_discount_percent',
'devices': 'device_discount_percent',
}
attr = mapping.get(category)
if attr:
return max(0, min(100, int(getattr(promo_group, attr, 0) or 0)))
return 0
@staticmethod
def calculate_traffic_discount(
base_price: int,
user: User | None,
period_days_hint: int | None = None,
) -> tuple[int, int, int]:
"""Apply traffic addon discount from user's promo group.
Checks apply_discounts_to_addons flag. Uses integer arithmetic.
Uses get_discount_percent() for correct is_default fallback.
Returns: (final_price, discount_value, discount_percent).
"""
if not user or base_price <= 0:
return base_price, 0, 0
pct = PricingEngine.get_addon_discount_percent(user, 'traffic', period_days_hint)
if pct <= 0:
return base_price, 0, 0
final = PricingEngine.apply_discount(base_price, pct)
return final, base_price - final, pct
# ------------------------------------------------------------------
# Tariff switch
# ------------------------------------------------------------------
@staticmethod
def get_tariff_daily_rate_fraction(tariff: Tariff, target_days: int) -> tuple[int, int]:
"""Дневная ставка тарифа как (price, period_days) для целочисленных вычислений.
Возвращает числитель и знаменатель дроби price/period_days,
чтобы избежать float-ошибок в финансовых расчётах.
"""
periods = tariff.get_available_periods()
if not periods:
return 0, 1
best_period = min(periods, key=lambda p: abs(p - target_days))
price = tariff.get_price_for_period(best_period)
if not price or best_period <= 0:
return 0, 1
return price, best_period
def calculate_tariff_switch_cost(
self,
current_tariff: Tariff,
new_tariff: Tariff,
remaining_days: int,
*,
user: User | None = None,
) -> TariffSwitchResult:
"""Рассчитывает стоимость переключения тарифа.
Автоматически определяет тип переключения:
- periodicdaily: оплата первого дня (daily_price_kopeks)
- dailyperiodic: оплата кратчайшего периода нового тарифа
- periodicperiodic: пропорциональная разница дневных ставок × remaining_days
Для всех типов переключений скидки (group + offer) применяются stacked.
"""
current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False
new_is_daily = getattr(new_tariff, 'is_daily', False)
# Daily tariff edge cases
if not current_is_daily and new_is_daily:
return self._calculate_switch_to_daily(new_tariff, remaining_days, user=user)
if current_is_daily and not new_is_daily:
return self._calculate_switch_from_daily(new_tariff, remaining_days, user=user)
if current_is_daily and new_is_daily:
# Daily → Daily: бесплатное переключение, cron начислит новую цену завтра
return TariffSwitchResult(
upgrade_cost=0,
is_upgrade=False,
raw_cost=0,
group_discount_pct=0,
offer_discount_pct=0,
new_period_days=1,
)
# --- Periodic → Periodic ---
# Early return: нечего считать при нулевом остатке
if remaining_days <= 0:
return TariffSwitchResult(
upgrade_cost=0,
is_upgrade=False,
raw_cost=0,
group_discount_pct=0,
offer_discount_pct=0,
new_period_days=0,
)
# Целочисленная арифметика (без float round-trip):
# raw_cost = (new_p/new_d - cur_p/cur_d) * remaining
# = (new_p * cur_d - cur_p * new_d) * remaining / (new_d * cur_d)
# Floor division (//) округляет дробные копейки вниз — в пользу пользователя.
cur_price, cur_period = self.get_tariff_daily_rate_fraction(current_tariff, remaining_days)
new_price, new_period = self.get_tariff_daily_rate_fraction(new_tariff, remaining_days)
numerator = (new_price * cur_period - cur_price * new_period) * remaining_days
denominator = new_period * cur_period
raw_cost = max(0, numerator // denominator)
if numerator <= 0:
return TariffSwitchResult(
upgrade_cost=0,
is_upgrade=False,
raw_cost=0,
group_discount_pct=0,
offer_discount_pct=0,
new_period_days=0,
)
# Resolve discounts via resolve_promo_group (get_primary_promo_group first)
group_pct = 0
offer_pct = 0
if user:
promo_group = self.resolve_promo_group(user)
if promo_group is not None:
best_period = min(
current_tariff.get_available_periods() or [30],
key=lambda p: abs(p - remaining_days),
)
group_pct = promo_group.get_discount_percent('period', best_period)
offer_pct = get_user_active_promo_discount_percent(user)
# Применяем stacked скидки к итоговой сумме напрямую (без float round-trip)
if group_pct > 0 or offer_pct > 0:
upgrade_cost, _, _ = self.apply_stacked_discounts(raw_cost, group_pct, offer_pct)
else:
upgrade_cost = raw_cost
return TariffSwitchResult(
upgrade_cost=upgrade_cost,
is_upgrade=True,
raw_cost=raw_cost,
group_discount_pct=group_pct,
offer_discount_pct=offer_pct,
new_period_days=0,
)
def _calculate_switch_to_daily(
self,
new_tariff: Tariff,
remaining_days: int,
*,
user: User | None = None,
) -> TariffSwitchResult:
"""Periodic → Daily: оплата первого дня с group + offer discount."""
daily_price = getattr(new_tariff, 'daily_price_kopeks', 0) or 0
if daily_price <= 0:
return TariffSwitchResult(
upgrade_cost=0,
is_upgrade=False,
raw_cost=0,
group_discount_pct=0,
offer_discount_pct=0,
new_period_days=1,
)
group_pct = 0
offer_pct = 0
if user:
promo_group = self.resolve_promo_group(user)
if promo_group:
period_hint = remaining_days if remaining_days > 0 else 30
group_pct = promo_group.get_discount_percent('period', period_hint)
offer_pct = get_user_active_promo_discount_percent(user)
if group_pct > 0 or offer_pct > 0:
upgrade_cost, _, _ = self.apply_stacked_discounts(daily_price, group_pct, offer_pct)
else:
upgrade_cost = daily_price
return TariffSwitchResult(
upgrade_cost=upgrade_cost,
is_upgrade=upgrade_cost > 0,
raw_cost=daily_price,
group_discount_pct=group_pct,
offer_discount_pct=offer_pct,
new_period_days=1,
)
def _calculate_switch_from_daily(
self,
new_tariff: Tariff,
remaining_days: int,
*,
user: User | None = None,
) -> TariffSwitchResult:
"""Daily → Periodic: оплата кратчайшего периода нового тарифа с group + offer discount."""
min_period_days = 30
min_period_price = 0
if new_tariff.period_prices:
min_period_days = min(int(k) for k in new_tariff.period_prices.keys())
min_period_price = new_tariff.period_prices.get(str(min_period_days), 0) or 0
if min_period_price <= 0:
return TariffSwitchResult(
upgrade_cost=0,
is_upgrade=False,
raw_cost=0,
group_discount_pct=0,
offer_discount_pct=0,
new_period_days=min_period_days,
)
group_pct = 0
offer_pct = 0
if user:
promo_group = self.resolve_promo_group(user)
if promo_group:
group_pct = promo_group.get_discount_percent('period', min_period_days)
offer_pct = get_user_active_promo_discount_percent(user)
if group_pct > 0 or offer_pct > 0:
upgrade_cost, _, _ = self.apply_stacked_discounts(min_period_price, group_pct, offer_pct)
else:
upgrade_cost = min_period_price
return TariffSwitchResult(
upgrade_cost=upgrade_cost,
is_upgrade=upgrade_cost > 0,
raw_cost=min_period_price,
group_discount_pct=group_pct,
offer_discount_pct=offer_pct,
new_period_days=min_period_days,
)
async def _calculate_servers_price(
self,
country_uuids: list[str],
@@ -222,37 +532,99 @@ class PricingEngine:
) -> RenewalPricing:
"""Price calculation when subscription is linked to a Tariff."""
tariff = subscription.tariff
period_prices: dict = tariff.period_prices or {}
base_price = int(period_prices.get(str(period_days), 0) or 0)
device_limit = subscription.device_limit or 0
return await self._calculate_tariff_core(
tariff,
period_days,
device_limit,
user=user,
)
# Extra devices above the tariff's included limit
async def _calculate_tariff_core(
self,
tariff: Tariff,
period_days: int,
device_limit: int,
*,
custom_traffic_gb: int | None = None,
user: User | None = None,
) -> RenewalPricing:
"""Core tariff pricing logic (raw params, no Subscription needed).
Per-category discounts:
- 'period' base tariff price
- 'devices' extra device cost
Promo-offer discount applied on the discounted subtotal.
Device cost is monthly × months_in_period.
"""
months = calculate_months_from_days(period_days)
# --- Base price ---
is_daily = getattr(tariff, 'is_daily', False)
if is_daily and period_days <= 1:
base_price = int(getattr(tariff, 'daily_price_kopeks', 0) or 0)
else:
period_prices: dict = tariff.period_prices or {}
base_price = int(period_prices.get(str(period_days), 0) or 0)
if base_price == 0 and hasattr(tariff, 'get_price_for_custom_days'):
if hasattr(tariff, 'can_purchase_custom_days') and tariff.can_purchase_custom_days():
custom_price = tariff.get_price_for_custom_days(period_days)
if custom_price is not None:
base_price = int(custom_price)
# --- Extra devices (monthly × months) ---
device_price_per_unit = (
tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE
)
extra_devices = max(0, (subscription.device_limit or 0) - (tariff.device_limit or 0))
devices_price = extra_devices * device_price_per_unit
tariff_device_limit = tariff.device_limit or 0
extra_devices = max(0, (device_limit or 0) - tariff_device_limit)
if is_daily and period_days <= 1:
devices_price = extra_devices * device_price_per_unit
else:
devices_price = extra_devices * device_price_per_unit * months
subtotal = base_price + devices_price
# --- Custom traffic (tariff add-on, uses addon discount path) ---
traffic_price = 0
if custom_traffic_gb is not None and hasattr(tariff, 'get_price_for_custom_traffic'):
raw_traffic = tariff.get_price_for_custom_traffic(custom_traffic_gb)
if raw_traffic and raw_traffic > 0:
traffic_price = int(raw_traffic)
# Resolve discounts
group_pct = 0
if user and getattr(user, 'promo_group', None) is not None:
group_pct = user.promo_group.get_discount_percent('period', period_days)
# --- Per-category group discounts ---
period_pct = 0
devices_pct = 0
promo_group = self.resolve_promo_group(user)
if promo_group is not None:
period_pct = promo_group.get_discount_percent('period', period_days)
devices_pct = promo_group.get_discount_percent('devices', period_days)
offer_pct = get_user_active_promo_discount_percent(user) if user else 0
final_total, group_discount, offer_discount = self.apply_stacked_discounts(
subtotal,
group_pct,
offer_pct,
)
discounted_base = self.apply_discount(base_price, period_pct)
discounted_devices = self.apply_discount(devices_price, devices_pct)
# Traffic uses addon discount (checks apply_discounts_to_addons flag)
discounted_traffic = traffic_price
if traffic_price > 0 and user:
discounted_traffic, _, _ = self.calculate_traffic_discount(traffic_price, user)
base_group_disc = base_price - discounted_base
devices_group_disc = devices_price - discounted_devices
traffic_group_disc = traffic_price - discounted_traffic
total_group_discount = base_group_disc + devices_group_disc + traffic_group_disc
subtotal = discounted_base + discounted_devices + discounted_traffic
after_offer = self.apply_discount(subtotal, offer_pct)
offer_discount = subtotal - after_offer
final_total = after_offer
breakdown = dataclasses.asdict(
TariffBreakdown(
tariff_id=tariff.id,
extra_devices=extra_devices,
group_discount_pct=group_pct,
group_discount_pct={'period': period_pct, 'devices': devices_pct},
offer_discount_pct=offer_pct,
months_in_period=months,
)
)
@@ -261,16 +633,17 @@ class PricingEngine:
'Negative final_total in tariff mode, clamping to 0',
final_total=final_total,
subtotal=subtotal,
group_pct=group_pct,
period_pct=period_pct,
devices_pct=devices_pct,
offer_pct=offer_pct,
)
return RenewalPricing(
base_price=base_price,
base_price=discounted_base,
servers_price=0,
traffic_price=0,
devices_price=devices_price,
promo_group_discount=group_discount,
traffic_price=discounted_traffic,
devices_price=discounted_devices,
promo_group_discount=total_group_discount,
promo_offer_discount=offer_discount,
final_total=max(0, final_total),
period_days=period_days,
@@ -278,19 +651,45 @@ class PricingEngine:
breakdown=breakdown,
)
async def calculate_tariff_purchase_price(
self,
tariff: Tariff,
period_days: int,
*,
device_limit: int | None = None,
custom_traffic_gb: int | None = None,
user: User | None = None,
) -> RenewalPricing:
"""Calculate price for a tariff purchase (new or renewal).
Public method that delegates to _calculate_tariff_core.
If device_limit is None, uses the tariff's included limit (no extra devices).
"""
effective_device_limit = device_limit if device_limit is not None else (tariff.device_limit or 0)
return await self._calculate_tariff_core(
tariff,
period_days,
effective_device_limit,
custom_traffic_gb=custom_traffic_gb,
user=user,
)
# ------------------------------------------------------------------
# Classic mode
# ------------------------------------------------------------------
async def _calculate_classic_mode(
async def _calculate_classic_core(
self,
db: AsyncSession,
subscription: Subscription,
period_days: int,
connected_squads: list[str],
traffic_limit_gb: int,
device_limit: int,
*,
purchased_traffic_gb: int = 0,
user: User | None = None,
) -> RenewalPricing:
"""Price calculation for legacy (non-tariff) subscriptions.
"""Core classic-mode pricing logic (raw params, no Subscription needed).
Uses CLASSIC_PERIOD_PRICES from settings, falling back to the
global PERIOD_PRICES dict during migration.
@@ -298,6 +697,7 @@ class PricingEngine:
Per-category discounts (period, servers, traffic, devices) are
applied separately to each component. Servers, traffic, and
devices are monthly prices multiplied by months_in_period.
Promo-offer discount is applied on the subtotal.
"""
months = calculate_months_from_days(period_days)
@@ -312,14 +712,13 @@ class PricingEngine:
fallback_price_kopeks=base_price_original,
)
# --- Per-category discount percents ---
# --- Per-category discount percents (resolve_promo_group: get_primary_promo_group first) ---
period_pct = 0
servers_pct = 0
traffic_pct = 0
devices_pct = 0
promo_group = None
if user and getattr(user, 'promo_group', None) is not None:
promo_group = user.promo_group
promo_group = self.resolve_promo_group(user)
if promo_group is not None:
period_pct = promo_group.get_discount_percent('period', period_days)
servers_pct = promo_group.get_discount_percent('servers', period_days)
traffic_pct = promo_group.get_discount_percent('traffic', period_days)
@@ -331,7 +730,6 @@ class PricingEngine:
base_price = self.apply_discount(base_price_original, period_pct)
# --- Servers (monthly × months, with servers discount) ---
connected_squads: list[str] = subscription.connected_squads or []
promo_group_id = getattr(user, 'promo_group_id', None) if user else None
servers_price_per_month, server_details = await self._calculate_servers_price(
connected_squads,
@@ -345,13 +743,6 @@ class PricingEngine:
if settings.is_traffic_fixed():
traffic_limit_gb = settings.get_fixed_traffic_limit()
purchased_traffic_gb = 0
else:
traffic_limit_gb = (
subscription.traffic_limit_gb
if subscription.traffic_limit_gb is not None
else settings.DEFAULT_TRAFFIC_LIMIT_GB
)
purchased_traffic_gb = subscription.purchased_traffic_gb or 0
traffic_price_per_month = self._calculate_traffic_price(traffic_limit_gb, purchased_traffic_gb)
discounted_traffic_per_month = self.apply_discount(traffic_price_per_month, traffic_pct)
traffic_price = discounted_traffic_per_month * months
@@ -359,7 +750,7 @@ class PricingEngine:
# --- Devices (monthly × months, with devices discount) ---
default_device_limit = settings.DEFAULT_DEVICE_LIMIT
device_price_per_unit = settings.PRICE_PER_DEVICE
extra_devices = max(0, (subscription.device_limit or 0) - default_device_limit)
extra_devices = max(0, (device_limit or 0) - default_device_limit)
devices_price_per_month = extra_devices * device_price_per_unit
discounted_devices_per_month = self.apply_discount(devices_price_per_month, devices_pct)
devices_price = discounted_devices_per_month * months
@@ -386,7 +777,9 @@ class PricingEngine:
ClassicBreakdown(
months_in_period=months,
servers=server_details,
servers_individual_prices=[d['price'] * months for d in valid_servers],
servers_individual_prices=[
self.apply_discount(d['price'], servers_pct) * months for d in valid_servers
],
server_ids=[d['id'] for d in valid_servers],
base_traffic_gb=max(0, traffic_limit_gb - purchased_traffic_gb),
purchased_traffic_gb=purchased_traffic_gb,
@@ -398,6 +791,10 @@ class PricingEngine:
'devices': devices_pct,
},
offer_discount_pct=offer_pct,
base_price_original=base_price_original,
traffic_price_per_month=traffic_price_per_month,
servers_price_per_month=servers_price_per_month,
devices_price_per_month=devices_price_per_month,
)
)
@@ -422,6 +819,116 @@ class PricingEngine:
breakdown=breakdown,
)
async def _calculate_classic_mode(
self,
db: AsyncSession,
subscription: Subscription,
period_days: int,
*,
user: User | None = None,
) -> RenewalPricing:
"""Price calculation for legacy (non-tariff) subscriptions.
Thin wrapper that extracts raw params from a Subscription
and delegates to _calculate_classic_core.
"""
connected_squads: list[str] = subscription.connected_squads or []
traffic_limit_gb = (
subscription.traffic_limit_gb
if subscription.traffic_limit_gb is not None
else settings.DEFAULT_TRAFFIC_LIMIT_GB
)
purchased_traffic_gb = subscription.purchased_traffic_gb or 0
device_limit = subscription.device_limit or 0
return await self._calculate_classic_core(
db,
period_days,
connected_squads,
traffic_limit_gb,
device_limit,
purchased_traffic_gb=purchased_traffic_gb,
user=user,
)
async def calculate_classic_new_subscription_price(
self,
db: AsyncSession,
period_days: int,
connected_squads: list[str],
traffic_limit_gb: int,
device_limit: int,
*,
user: User | None = None,
) -> RenewalPricing:
"""Calculate price for a NEW classic (non-tariff) subscription.
Like calculate_renewal_price but without requiring an existing
Subscription object. purchased_traffic_gb is always 0.
"""
return await self._calculate_classic_core(
db,
period_days,
connected_squads,
traffic_limit_gb,
device_limit,
purchased_traffic_gb=0,
user=user,
)
@staticmethod
def classic_pricing_to_purchase_details(pricing: RenewalPricing) -> dict[str, Any]:
"""Convert RenewalPricing to the legacy details dict format.
The returned dict is compatible with build_preview_payload
in SubscriptionPurchaseService.
"""
bd = pricing.breakdown
months = bd.get('months_in_period', 1) or 1
group_pct = bd.get('group_discount_pct', {})
base_price_original = bd.get('base_price_original', 0)
traffic_price_per_month = bd.get('traffic_price_per_month', 0)
servers_price_per_month = bd.get('servers_price_per_month', 0)
devices_price_per_month = bd.get('devices_price_per_month', 0)
period_pct = group_pct.get('period', 0)
traffic_pct = group_pct.get('traffic', 0)
servers_pct = group_pct.get('servers', 0)
devices_pct = group_pct.get('devices', 0)
base_discount_total = base_price_original - pricing.base_price
traffic_discount_total = (
traffic_price_per_month - PricingEngine.apply_discount(traffic_price_per_month, traffic_pct)
) * months
servers_discount_total = (
servers_price_per_month - PricingEngine.apply_discount(servers_price_per_month, servers_pct)
) * months
devices_discount_total = (
devices_price_per_month - PricingEngine.apply_discount(devices_price_per_month, devices_pct)
) * months
return {
'base_price': pricing.base_price,
'base_price_original': base_price_original,
'base_discount_percent': period_pct,
'base_discount_total': base_discount_total,
'traffic_price_per_month': traffic_price_per_month,
'traffic_discount_percent': traffic_pct,
'traffic_discount_total': traffic_discount_total,
'total_traffic_price': pricing.traffic_price,
'servers_price_per_month': servers_price_per_month,
'servers_discount_percent': servers_pct,
'servers_discount_total': servers_discount_total,
'total_servers_price': pricing.servers_price,
'devices_price_per_month': devices_price_per_month,
'devices_discount_percent': devices_pct,
'devices_discount_total': devices_discount_total,
'total_devices_price': pricing.devices_price,
'months_in_period': months,
'servers_individual_prices': bd.get('servers_individual_prices', []),
}
# Module-level singleton — use this instead of PricingEngine()
pricing_engine = PricingEngine()
+2 -1
View File
@@ -115,6 +115,7 @@ class PromoOfferService:
remnawave_user = await self.subscription_service.update_remnawave_user(
db,
subscription,
sync_squads=True,
)
if remnawave_user is None:
await db.rollback()
@@ -188,7 +189,7 @@ class PromoOfferService:
subscription.connected_squads = list(updated)
subscription.updated_at = now
try:
await self.subscription_service.update_remnawave_user(db, subscription)
await self.subscription_service.update_remnawave_user(db, subscription, sync_squads=True)
except Exception as exc: # pragma: no cover - defensive logging
logger.error(
'Ошибка обновления Remnawave при отзыве тестового доступа подписки',

Some files were not shown because too many files have changed in this diff Show More