Compare commits

...

139 Commits

Author SHA1 Message Date
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
c0mrade 5bf4aeb31e Merge pull request #2733 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.32.1
2026-03-13 19:17:37 +03:00
github-actions[bot] 7356921eeb chore(main): release 3.32.1 2026-03-13 16:11:39 +00:00
c0mrade f24337fb41 Merge pull request #2732 from BEDOLAGA-DEV/dev
Dev
2026-03-13 19:11:14 +03:00
c0mrade 69a38dad25 fix: invalid ISO date format in node usage stats API call
datetime.now(UTC).isoformat() produces +00:00 suffix, appending Z
created invalid +00:00Z format causing RemnaWave API 500 errors.
Use .replace('+00:00', 'Z') instead of concatenation.
2026-03-13 18:58:36 +03:00
c0mrade aa3459b846 fix: platega webhook ID fallback for SBP and card payments
SBP sends `id`, cards send `transactionId`. Use fallback chain
to resolve transaction ID from all known field variants.
2026-03-13 18:41:14 +03:00
c0mrade 4d695be7d5 fix: resolve MissingGreenlet in switch_tariff endpoint
Use local subscription variable and db.refresh() to avoid lazy-load
of expired relationship after subtract_user_balance invalidates
the User identity map entry.
2026-03-13 18:30:32 +03:00
Egor b8fcbc7661 Merge pull request #2729 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.32.0
2026-03-13 06:19:11 +03:00
github-actions[bot] 96042782d9 chore(main): release 3.32.0 2026-03-13 03:18:41 +00:00
Egor a625eaae4f Merge pull request #2728 from BEDOLAGA-DEV/dev
Dev
2026-03-13 06:18:08 +03:00
Egor 869fe06831 Merge pull request #2727 from BEDOLAGA-DEV/main
w
2026-03-13 06:10:27 +03:00
Fringg a5fbd7400f fix: user deletion FK error + connected_squads None TypeError
Bug 1: DELETE /cabinet/admin/users/{id}/full failed with
"saved_payment_methods_user_id_fkey" FK violation.
Root cause: delete_user_account() didn't clean up saved_payment_methods
and riopay_payments before deleting the user row.
Fix: add DELETE for both tables before final user deletion.

Bug 2: show_user_management crashed with TypeError on
len(subscription.connected_squads) when connected_squads was None.
Root cause: remnawave_webhook_service explicitly set connected_squads=None
when clearing subscription data, but 5 call sites assumed it was always a list.
Fix: change None assignment to [] + add "or []" guards at all 5 call sites.
2026-03-13 06:08:54 +03:00
Egor 995d66483b Delete docs/plans directory 2026-03-13 05:57:37 +03:00
Egor 5c77bd7a0f Merge pull request #2726 from BEDOLAGA-DEV/feat/unified-pricing-engine
Feat/unified pricing engine
2026-03-13 05:55:27 +03:00
Fringg 04697fd4cb style: ruff format 8 files 2026-03-13 05:53:02 +03:00
Fringg c9f2dffabf fix: address 6-agent review findings for PricingEngine
H1: log error when tariff_id set but tariff relationship not loaded
H2: warn on CLASSIC_PERIOD_PRICES→PERIOD_PRICES fallback
M1: fix float division in miniapp tariff purchase (use PricingEngine.apply_discount)
M2: fix format_period Russian pluralization for teen-hundreds (111-119, etc.)
M3: deduplicate _resolve_discount_percent — import from pricing_utils
M4: fix N+1 queries in compute_simple_subscription_price (batch fetch)
M5: add period_days validation tests (negative, zero, float)
M6: add user=None tests for tariff and classic modes
M7: fix float division in calculate_prorated_price (use // instead of /)
L1: add context to _calculate_servers_price error log
L2: add comment clarifying ClassicBreakdown.group_discount_pct type
L3: add test for original_total property
L4: inline _apply_percentage_discount wrapper in subscription_purchase_service
L5: replace global _server_id_counter with itertools.count() in tests
2026-03-13 05:45:46 +03:00
Fringg fe4e6acb53 refactor: unify first-purchase discount algorithm with PricingEngine
apply_percentage_discount now delegates to PricingEngine.apply_discount
(floor division). Removes ruble-rounding that caused inconsistency between
first-purchase and renewal pricing.

subscription_purchase_service._apply_percentage_discount now delegates
to the shared apply_percentage_discount.

All 60+ callers across handlers, keyboards, cabinet, miniapp, balance
automatically use the unified algorithm without code changes.
2026-03-13 05:30:44 +03:00
Fringg e24b911283 refactor: migrate all callers to pricing_engine singleton + fix miniapp discount
- 13 PricingEngine() instantiation sites → import pricing_engine singleton
- miniapp _apply_promo_discount now delegates to PricingEngine.apply_discount
  (fixes float division vs floor division inconsistency)
2026-03-13 05:22:18 +03:00
Fringg b551def340 refactor: add typed breakdowns + module-level singleton to PricingEngine
- TariffBreakdown and ClassicBreakdown frozen dataclasses for type safety
- Module-level `pricing_engine` singleton eliminates repeated instantiation
- breakdown remains dict[str, Any] at runtime for backward compatibility
2026-03-13 05:22:10 +03:00
Fringg 5e9a462261 refactor: extract shared formatting helpers into app/utils/formatting.py
Consolidate duplicated _format_traffic, _format_price_kopeks, _format_period
from tariff_purchase.py and admin/tariffs.py into a shared module.
2026-03-13 05:22:04 +03:00
Fringg 3a3bd9d499 test: expand PricingEngine tests + update CryptoBot payment tests
- Add 45 unit tests covering tariff/classic modes, discounts, edge cases
- Update CryptoBot payment tests for new PricingEngine integration
- Add original_total identity tests for both pricing modes
2026-03-13 05:12:47 +03:00
Fringg 75dbd2b4fc refactor: migrate remaining callers to PricingEngine + cleanup dead CRUD
- Migrate bot purchase handlers, menu, admin users to PricingEngine
- SubscriptionRenewalService.finalize() accepts both old and new pricing types
- Remove dead subscription CRUD pricing functions (get_subscription_renewal_cost etc.)
- Remove dead pricing_utils functions
2026-03-13 05:12:32 +03:00
Fringg b4ef52caa4 fix: payment providers — lock_user_for_update + commit=False atomicity
All payment providers now use lock_user_for_update before balance mutations
and commit=False pattern for atomic payment status + fulfillment.
Tribute service refund also uses proper locking.
2026-03-13 05:12:15 +03:00
Fringg ae99358ae9 fix: pricing audit — display/charge parity, race conditions, balance locks
M-2: tariff_purchase.py — _apply_promo_discount delegates to PricingEngine,
     _get_user_period_discount returns (group_pct, offer_pct, combined),
     all ~15 call sites updated for display/charge price parity

M-4: miniapp switch_tariff — add FOR UPDATE lock on subscription,
     commit=False for atomic balance+transaction, emit_transaction_side_effects

M-6: CryptoBot — defer status commit (commit=False) so webhook retry works
     if fulfillment fails

WARNING: add lock_user_for_update before balance_kopeks mutations in
     contest_attempt_service, wheel_service, admin/referrals,
     account_merge_service, cabinet/routes/contests
2026-03-13 05:11:59 +03:00
Fringg 08bea704de fix: address review findings from 5-agent audit
- Add period_days validation (> 0) in PricingEngine
- Add int() cast for tariff period_prices (prevent JSON type errors)
- Fix structlog.get_logger(__name__) in pricing_engine
- Use pricing.original_total property instead of manual reconstruction
- Add CryptoBot price decrease audit logging
- Remove stale cart price fallback in auto-purchase (fail instead)
- Fix _apply_promo_discount_for_tariff to use PricingEngine.apply_discount
- Remove dead code: _get_tariff_price_for_period, _get_countries_price,
  calculate_addon_price_with_remaining_period, _resolve_addon_discount_percent
2026-03-13 05:11:35 +03:00
Fringg 18e2e7841a fix: add period_days whitelist validation and type annotations
Security fix: cabinet /renew endpoint now validates period_days against
available periods (tariff or settings), preventing arbitrary period abuse.

Also:
- Add proper type annotations (AsyncSession, Subscription, User) to PricingEngine
- Add max(0, final_total) guard in both tariff and classic modes
- Type breakdown field as dict[str, Any]
2026-03-12 23:27:51 +03:00
Fringg 652b6dabde refactor: migrate menu.py renewal pricing to PricingEngine
Replace 3 renewal_service.calculate_pricing() calls with
PricingEngine.calculate_renewal_price() in the balance activation handler.
finalize() already supports RenewalPricing via duck typing.
2026-03-12 23:15:37 +03:00
Fringg c9a9816daa refactor: remove dead pricing code and fix miniapp classic mode
- Remove SubscriptionService.calculate_renewal_price (zero callers, replaced by PricingEngine)
- Remove SubscriptionService.calculate_renewal_price_with_months (zero callers)
- Remove _calculate_subscription_renewal_pricing wrapper in miniapp (zero callers)
- Fix miniapp classic mode: pass PricingEngine result directly to finalize() instead of old wrapper
- Fix potential NameError: pricing_snapshot in cryptobot path used undefined 'pricing' variable
- Net: -396 lines of duplicate pricing logic
2026-03-12 23:12:59 +03:00
Fringg 49c0f3fc10 refactor: migrate admin user price calculation to PricingEngine
Replace SubscriptionService.calculate_renewal_price() with PricingEngine
in _calculate_subscription_period_price for admin panel.
2026-03-12 23:04:29 +03:00
Fringg cb43acab31 refactor: migrate miniapp renewal display + execute to PricingEngine 2026-03-12 23:00:58 +03:00
Fringg f59b215645 style: fix import sorting and formatting after lint
ruff auto-fix for import ordering in cabinet/subscription.py and
formatting adjustments across changed files.
2026-03-12 22:58:35 +03:00
Fringg 3efa24bab3 refactor: make finalize() accept both old and new pricing types
SubscriptionRenewalService.finalize() now supports both
SubscriptionRenewalPricing and RenewalPricing from PricingEngine.
Adapts access to promo_discount_value, server_ids, and
servers_individual_prices via duck typing.
2026-03-12 22:58:08 +03:00
Fringg bd2e93a6a5 refactor: migrate cart auto-purchase to PricingEngine (fresh calc)
Replaces stale cart-based pricing and _apply_promo_discount_for_tariff
(4th discount formula with float division) with fresh PricingEngine
calculation. Falls back to saved cart price on PricingEngine error.
2026-03-12 22:51:03 +03:00
Fringg 978f68e7be refactor: migrate recurrent and monitoring services to PricingEngine
Mechanical re-point of calculate_renewal_price calls to use unified
PricingEngine. Both services now get consistent pricing with correct
discount formulas and server fallback behavior.
2026-03-12 22:50:13 +03:00
Fringg 28fc36dca4 refactor: migrate cabinet renewal display + execute to PricingEngine
Replaces inline pricing logic in get_renewal_options and renew_subscription
with unified PricingEngine.calculate_renewal_price(). Fixes:
- Wrong discount formula (int(p*(100-d)/100) vs integer floor division)
- Missing servers/traffic costs in classic mode display
- Inconsistent discount stacking between display and execute paths
2026-03-12 22:49:22 +03:00
Fringg 1660b24f98 fix: add per-category discounts and months multiplier to classic mode
Classic mode now correctly:
- Applies separate promo group discounts per category (period, servers,
  traffic, devices) via promo_group.get_discount_percent(category, days)
- Multiplies servers/traffic/devices monthly prices by months_in_period
- Applies promo offer discount to entire subtotal after per-category discounts
- Tracks total group discount as sum of per-category discounts
2026-03-12 22:46:34 +03:00
Fringg acf27a1023 refactor: migrate bot renewal execute to PricingEngine
Replace ~95 lines of manual pricing calculation in confirm_extend_subscription
with PricingEngine.calculate_renewal_price. Removes per-component discount
logic (period, servers, devices, traffic with separate category discounts,
months multiplication, and validate_pricing_calculation check). Downstream
logic preserved: balance check, cart save, subtract_user_balance,
subscription update, Remnawave sync, transaction creation, and admin
notification all use pricing.final_total and pricing.promo_offer_discount.

Removes unused imports: _apply_promo_offer_discount, validate_pricing_calculation.
2026-03-12 22:41:41 +03:00
Fringg ce82c2c009 refactor: migrate bot renewal display to PricingEngine
Replace manual per-component price calculation in handle_extend_subscription
with PricingEngine.calculate_renewal_price. This eliminates ~55 lines of
duplicated pricing logic (period, servers, devices, traffic calculations with
separate category-specific promo group discounts and months multiplication)
in favor of a single PricingEngine call per period. Also fixes double-application
of promo offer discount that existed in the old code path.
2026-03-12 22:37:55 +03:00
Fringg e6ebc6722d refactor: migrate try_auto_extend_expired to PricingEngine
Replace SubscriptionService.calculate_renewal_price() call in
try_auto_extend_expired_after_topup with PricingEngine.calculate_renewal_price().
Add structured log with pricing breakdown after calculation.
All downstream business logic (balance check, deduction, extend) unchanged.
2026-03-12 22:32:41 +03:00
Fringg 02e5401327 feat: implement calculate_renewal_price with tariff and classic modes
Add the main public method calculate_renewal_price to PricingEngine,
routing to _calculate_tariff_mode or _calculate_classic_mode based on
whether the subscription has a linked tariff. Both modes apply stacked
discounts (promo-group then promo-offer). Classic mode tries
CLASSIC_PERIOD_PRICES first, falling back to PERIOD_PRICES. Adds 8
new tests covering both modes, discounts, extra devices, and fallback.
2026-03-12 22:29:44 +03:00
Fringg c3bb63ffed feat: add CLASSIC_PERIOD_PRICES to config
Add a standalone dict that always reflects env PRICE_*_DAYS settings,
independent of tariffs mode. Unlike PERIOD_PRICES (which may use DB
tariff prices), CLASSIC_PERIOD_PRICES is the canonical source for
classic (non-tariff) subscription pricing. Includes refresh helper.
2026-03-12 22:29:37 +03:00
Fringg 88369eec50 feat: add _calculate_servers_price (fixed fallback) and _calculate_traffic_price
_calculate_servers_price ALWAYS uses real server.price_kopeks even when
is_available=False or is_full=True, fixing the silent zero-price bug.
_calculate_traffic_price separates base from purchased GB to prevent
purchased top-ups from inflating the tier lookup.
2026-03-12 22:20:48 +03:00
Fringg 83ca51cd5b feat: add RenewalPricing dataclass and PricingEngine discount methods 2026-03-12 22:18:08 +03:00
Egor f9dad615ee Merge pull request #2721 from FireWookie/feature/recurrent_method_inline
Отображение привязанных карт в разделе в боте
2026-03-12 20:28:31 +03:00
Fringg ba049ca017 fix: resolve merge conflict with dev (accept calc_device_limit_on_tariff_switch) 2026-03-12 20:27:48 +03:00
Fringg 585baaf63c fix: harden remnawave API error handling and YooKassa user cross-validation
- remnawave_api: use str() before .lower() to handle non-string API messages
- yookassa recovery: cross-validate user_telegram_id metadata against resolved
  user to prevent misattribution when legacy telegram_id fits in int32 range
2026-03-12 20:17:51 +03:00
Fringg 04197817fe fix: downgrade known-harmless RemnaWave 400s to warning level
"User already enabled" and "User already disabled" are expected
responses when reactivating subscriptions (e.g., traffic top-up on
active subscription with exhausted traffic). These should not
trigger error notifications in the admin chat.
2026-03-12 20:08:53 +03:00
Fringg b2ee6c766a fix: add missing settings import in admin_users tariff switch 2026-03-12 19:59:59 +03:00
Fringg d35ee58aa6 fix: harden YooKassa webhook recovery user lookup
- Reject user_id <= 0 early (corrupted metadata)
- Use `is None` checks instead of `or` to avoid falsy-value collisions
- Separate int parse from DB call in telegram_id fallback
- Move _INT32_MAX to module-level constant
2026-03-12 19:51:52 +03:00
Fringg 815a1d9136 fix: handle legacy telegram_id in YooKassa webhook recovery metadata
Legacy payments may store telegram_id (>int32) in metadata['user_id']
instead of internal User.id. The recovery path now:
- Detects values exceeding int32 range and queries by telegram_id
- Falls back to metadata['user_telegram_id'] if primary lookup fails
- Resolves to internal user.id before creating FK-linked payment record
2026-03-12 19:41:48 +03:00
Fringg b7775b72dc fix: guard rollback on commit flag, add flush to promo_offer_log
- subtract_user_balance: only rollback when commit=True, re-raise when
  commit=False so caller controls transaction lifecycle
- log_promo_offer_action: add db.flush() when commit=False to surface
  constraint errors immediately instead of deferring to caller's commit
2026-03-12 19:33:25 +03:00
Fringg ba54819f9c fix: atomicity refactor, review fixes, and DELETED recovery logging
- subtract_user_balance: add commit=False parameter for atomic balance+subscription ops
- extend_subscription: add commit=False parameter, propagate to clear_notifications
- wata_service: wire _MIN_EXPIRATION_MINUTES constant to actual usage
- admin_users: fix no-op ternary in sync_user_from_panel timezone normalization
- start.py: log warning when DELETED recovery zeros non-zero balance (3 locations)
- remnawave_service: preserve PromoCodeUse records and used_promocodes in force_cleanup
2026-03-12 19:26:36 +03:00
Fringg 266340aad1 fix: prevent balance loss on auto-purchase for DISABLED subscriptions and fix WATA expiration
- Block auto-purchase from stale cart when subscription is DISABLED
  (balance deduction is irreversible, Remnawave update would fail)
- Preserve user balance in force_cleanup_user_data (paid money must not be destroyed)
- Keep has_had_paid_subscription flag on cleanup (prevents promo code abuse)
- Add warning in sync_from_panel when local end_date is newer than panel
- Fix WATA payment expiration: enforce minimum 15 minutes to avoid
  hitting WATA API's exclusive lower bound (now + 10 min)
2026-03-12 19:09:16 +03:00
Fringg 8f434525eb feat: add LIMITED subscription status and preserve extra devices on tariff switch
- Add SubscriptionStatus.LIMITED for traffic-exhausted subscriptions
- Webhook user.limited now sets LIMITED directly instead of DISABLED
- Add LIMITED to reactivation, extend, resume, auto-purchase, contest eligibility
- Add traffic_exhausted error response in miniapp API
- Fix device_limit being overwritten on tariff switch in all code paths:
  admin change_tariff, user switch-tariff, miniapp, bot tariff_purchase,
  auto_purchase_service — now preserves extra purchased devices via
  calc_device_limit_on_tariff_switch() helper
- Fix truthiness checks on device_limit (0 is valid, use `is not None`)
2026-03-12 18:35:59 +03:00
Egor efa1b11db5 Merge pull request #2724 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.31.0
2026-03-12 08:30:35 +03:00
github-actions[bot] d0ce193edb chore(main): release 3.31.0 2026-03-12 05:30:07 +00:00
Egor 92d872236f Merge pull request #2723 from BEDOLAGA-DEV/dev
Dev
2026-03-12 08:29:34 +03:00
Egor a11f492801 Merge pull request #2722 from BEDOLAGA-DEV/main
ц
2026-03-12 08:25:26 +03:00
Fringg c8162505ed chore: apply ruff formatting to 4 files 2026-03-12 08:24:47 +03:00
Fringg 076290e0c1 feat: auto-sync squads to Remnawave when admin updates tariff
When admin changes allowed_squads or external_squad_uuid on a tariff,
automatically sync the new squad config to all active/trial subscriptions
in Remnawave panel via a background task (fire-and-forget).
2026-03-12 08:20:25 +03:00
firewookie 2f5674fcd7 правки линтера 2026-03-12 09:40:29 +05:00
FireWookie b9058e115a Merge pull request #8 from FireWookie/dev
Dev
2026-03-12 09:39:58 +05:00
firewookie 673afccb8c правки линтера 2026-03-12 09:35:54 +05:00
firewookie 1badb39c49 правки по импортам 2026-03-12 09:35:07 +05:00
firewookie 23ff40cd2c Отображение привязанных карт в разделе в боте 2026-03-12 09:31:46 +05:00
Fringg bf72f241d8 fix: preserve purchased devices when admin changes user tariff
Previously subscription.device_limit was blindly overwritten with the new
tariff's base limit, losing any extra devices the user had purchased.
Now extra devices are calculated from the old tariff base and carried over,
capped at tariff.max_device_limit or global MAX_DEVICES_LIMIT.
2026-03-12 06:55:56 +03:00
Fringg 12ae871653 feat: referral links now point to web cabinet instead of bot
Centralized referral link generation into settings.get_referral_link().
When CABINET_URL is configured, links use {CABINET_URL}?ref={code}.
Falls back to Telegram bot deep link when CABINET_URL is not set.

- URL-encodes referral_code for safety
- Handles CABINET_URL with existing query params (uses & vs ?)
- Guards against None referral_code in all call sites
- QR code caching uses link hash for auto-invalidation
2026-03-12 06:45:17 +03:00
Fringg 8a362db783 fix: correct skipped_count in sync-squads circuit breaker and simplify ternary
- Add missing skipped_count increment when early-aborting due to circuit breaker
- Remove redundant `new_squads if new_squads else []` (already [] when empty)
- Add comment explaining asyncio safety of shared counter mutations
2026-03-12 06:28:13 +03:00
Fringg b1e2146254 feat: add sync-squads endpoint for bulk updating subscription squads in Remnawave
POST /admin/tariffs/{tariff_id}/sync-squads updates active_internal_squads
and external_squad_uuid for all active/trial subscriptions on a tariff.

- Concurrent API calls (semaphore=5) with circuit breaker (10 consecutive failures)
- Local DB updated only on successful API response to avoid split-brain
- Error messages sanitized (details in server logs only)
- Uses joinedload to avoid N+1 query for User.remnawave_uuid
2026-03-12 06:17:13 +03:00
Fringg 68bc8eb57c fix: update promo group via M2M table so admin changes persist
The admin endpoint wrote only to the legacy users.promo_group_id FK,
which got overwritten by sync_user_primary_promo_group on the next
transaction. Now writes to user_promo_groups M2M table (authoritative
source) and re-derives the FK via sync.

Also re-raise exceptions in _sync_user_primary_promo_group to prevent
committing inconsistent state between M2M and FK columns.
2026-03-12 06:03:26 +03:00
Fringg 9957259881 fix: add post_update=True to User.referrals self-referential relationship
Without post_update, SQLAlchemy's flush ordering cannot resolve the
circular dependency when both a user and their referrer are in the same
session. This caused referred_by_id to be silently NULLed during flush,
breaking Telegram login (which eagerly loads User.referrer) and causing
apparent admin rights loss in the cabinet.

OAuth login was unaffected because it uses a bare select(User) without
eager loading the referrer relationship.

Root cause confirmed by 6 parallel investigation agents tracing the
exact code paths through get_user_by_telegram_id → selectinload →
flush → circular dependency.
2026-03-12 05:39:29 +03:00
Fringg b3f3eba575 fix: prevent account takeover via auto_login_token, ensure promo group on all purchase paths
- Gate auto_login_token generation behind is_new_account flag in all 3 locations
  (fulfill_purchase PENDING/DELIVERED paths + activate_purchase) — prevents attacker
  from buying cheapest plan with victim's email to get their session token
- Assign default promo group in all _find_or_create_user paths including telegram
  IntegrityError fallbacks (7 return paths total)
- Create transaction records for landing purchases so promo group auto-assignment
  and contest tracking work correctly
- Add _resolve_payment_method helper for enum conversion with sub-option suffix stripping
2026-03-12 05:26:16 +03:00
Fringg a798f1143e refactor: remove estimated price from balance, simplify server sync, fix HTML injection
- Remove estimated renewal price display from balance top-up screen
- Remove country name generation during server/squad sync, use original RemnaWave name as display_name
- Add html.escape() for all display_name/country name values rendered in HTML-parsed Telegram messages
2026-03-12 04:58:49 +03:00
Fringg cb5126aff8 feat: add show_in_gift toggle for tariffs in admin panel
Add a per-tariff visibility flag (show_in_gift) that controls whether
a tariff appears in the /gift section. Enforced server-side in gift
config query, gift purchase endpoint, and landing page gift purchases.

Includes Alembic migration with idempotency guard and server_default.
2026-03-12 04:15:40 +03:00
Fringg 8b35428055 fix: reactivate subscription after traffic top-up when status is EXPIRED
When traffic is exhausted, RemnaWave may send user.expired webhook setting
local status to EXPIRED (not just DISABLED). reactivate_subscription() only
handled DISABLED→ACTIVE, silently ignoring EXPIRED subscriptions. After
purchasing additional GB, the subscription stayed expired and VPN remained
blocked despite payment.

Changes:
- reactivate_subscription() now handles both DISABLED and EXPIRED→ACTIVE
  when end_date is still in the future
- Inverted null end_date guard to block reactivation (defense-in-depth)
- Added enable_remnawave_user() call after update in all traffic/device
  top-up paths to ensure panel exits LIMITED state
- Gated enable call on subscription.status == 'active' to prevent
  enabling when reactivation was a no-op
- Fixed all 12 call sites across bot handlers, cabinet routes,
  miniapp, webapi, and auto-purchase service
2026-03-12 03:57:35 +03:00
Fringg 5424d8c314 fix: add Telegram Stars payment support for gift subscriptions
- Add telegram_stars handler in create_guest_payment() using
  bot.create_invoice_link() with guest_purchase_{token} payload
- Add guest_purchase_ prefix handling in Stars pre-checkout and
  successful_payment handlers with amount tolerance check (±5%)
- Pass Bot instance to PaymentService when payment method is Stars
- Add purchase_token format validation via regex guard
2026-03-12 03:32:08 +03:00
133 changed files with 8443 additions and 5755 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.30.0"
".": "3.33.0"
}
+168
View File
@@ -1,5 +1,173 @@
# Changelog
## [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)
### Bug Fixes
* invalid ISO date format in node usage stats API call ([69a38da](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/69a38dad259bd05f4658e1014ce0bd73fc2e2ac5))
* platega webhook ID fallback for SBP and card payments ([aa3459b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aa3459b8463ce0a54b7709aa3547b2337064fa26))
* resolve MissingGreenlet in switch_tariff endpoint ([4d695be](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4d695be7d51adda40fa72c00c349fb0e1ec4acd2))
## [3.32.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.31.0...v3.32.0) (2026-03-13)
### New Features
* add _calculate_servers_price (fixed fallback) and _calculate_traffic_price ([88369ee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/88369eec5047e733d26d2450a74abd0d600b2e1b))
* add CLASSIC_PERIOD_PRICES to config ([c3bb63f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c3bb63ffed6e0b684c322aa51d70ab7e71c8eb6b))
* add LIMITED subscription status and preserve extra devices on tariff switch ([8f43452](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8f434525eb14618e3c3e26261d443b1632c111bb))
* add RenewalPricing dataclass and PricingEngine discount methods ([83ca51c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/83ca51cd5b040e747c6db904dde0f3a5c59f480f))
* implement calculate_renewal_price with tariff and classic modes ([02e5401](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02e5401327786c9dfe5ae7d4c89624c9455aa53e))
### Bug Fixes
* add missing settings import in admin_users tariff switch ([b2ee6c7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b2ee6c766a1fb0c9a701684a6349b970d12f5e2e))
* add per-category discounts and months multiplier to classic mode ([1660b24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1660b24f9844374bbd156f9202a8e1550a6beb49))
* add period_days whitelist validation and type annotations ([18e2e78](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/18e2e7841a6d614263e7c87db5964916ec869a9d))
* address 6-agent review findings for PricingEngine ([c9f2dff](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c9f2dffabf6369df360c5f9ad7a12c0415026310))
* address review findings from 5-agent audit ([08bea70](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/08bea704ded78102dce29deac8da95c4e4b9d815))
* atomicity refactor, review fixes, and DELETED recovery logging ([ba54819](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba54819f9cd7f60914dd472b68885683f435db4e))
* change None assignment to [] + add "or []" guards at all 5 call sites. ([a5fbd74](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a5fbd7400f828824c5baa520bdaf06023b4caf70))
* downgrade known-harmless RemnaWave 400s to warning level ([0419781](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/04197817fede058dc4688dce2f9877f0fc2a7f7f))
* guard rollback on commit flag, add flush to promo_offer_log ([b7775b7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b7775b72dc7a1b3d18f179c2f247fe9f47023347))
* handle legacy telegram_id in YooKassa webhook recovery metadata ([815a1d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/815a1d9136f39b932d4b369aec9d67034d6785d9))
* harden remnawave API error handling and YooKassa user cross-validation ([585baaf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/585baaf63c9f535e5311a32085d0187d8c854001))
* harden YooKassa webhook recovery user lookup ([d35ee58](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d35ee58aa6f3edc6a9e8ab43025569262acf64a2))
* payment providers — lock_user_for_update + commit=False atomicity ([b4ef52c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b4ef52caa4b324eded8e6c6cb715a09ad59140c1))
* prevent balance loss on auto-purchase for DISABLED subscriptions and fix WATA expiration ([266340a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/266340aad195995f208ed82fc11e0909d34898f4))
* pricing audit — display/charge parity, race conditions, balance locks ([ae99358](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ae99358ae9f35a25370ab127d98a0b630a08e3f2))
* resolve merge conflict with dev (accept calc_device_limit_on_tariff_switch) ([ba049ca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba049ca017e004c25f8738f01b2d5f329a35bb5e))
* user deletion FK error + connected_squads None TypeError ([a5fbd74](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a5fbd7400f828824c5baa520bdaf06023b4caf70))
### Refactoring
* add typed breakdowns + module-level singleton to PricingEngine ([b551def](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b551def3402e2bf762406fb0b374360958231bb3))
* extract shared formatting helpers into app/utils/formatting.py ([5e9a462](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5e9a462261e46ee649de481266a821fd6793bf2e))
* make finalize() accept both old and new pricing types ([3efa24b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3efa24bab3a2bd1d31103b134e502c10af8e41e1))
* migrate admin user price calculation to PricingEngine ([49c0f3f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/49c0f3fc10d27092961601cf7f6a780fb56885fa))
* migrate all callers to pricing_engine singleton + fix miniapp discount ([e24b911](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e24b911283bf4cbee7b18d3e49c935217e4a2863))
* migrate bot renewal display to PricingEngine ([ce82c2c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ce82c2c00988542ac73dc9d2e811711ea9cefebe))
* migrate bot renewal execute to PricingEngine ([acf27a1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/acf27a102308d38676b2ccaef78016b56a80935d))
* migrate cabinet renewal display + execute to PricingEngine ([28fc36d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/28fc36dca41b626430baf823274561269023ac59))
* migrate cart auto-purchase to PricingEngine (fresh calc) ([bd2e93a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bd2e93a6a5076341b104f7dba2b7fc5fdb587e66))
* migrate menu.py renewal pricing to PricingEngine ([652b6da](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/652b6dabde014d19075f13198dd06e5fb8bef380))
* migrate miniapp renewal display + execute to PricingEngine ([cb43aca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cb43acab3194bbf9b6e2c04ca0254ba5b2571b2d))
* migrate recurrent and monitoring services to PricingEngine ([978f68e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/978f68e7be42b0faf92d9ac5bc0bbaa2022ac95b))
* migrate remaining callers to PricingEngine + cleanup dead CRUD ([75dbd2b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/75dbd2b4fcc8ab14ac44d915bf55b10406544bb1))
* migrate try_auto_extend_expired to PricingEngine ([e6ebc67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e6ebc6722d826d291156e6bff3bf86000b32b783))
* remove dead pricing code and fix miniapp classic mode ([c9a9816](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c9a9816daa15a4534a3990822543eeefe1a1631b))
* unify first-purchase discount algorithm with PricingEngine ([fe4e6ac](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe4e6acb5391d0797ea01281eeb2e2ea59a0070f))
## [3.31.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.30.0...v3.31.0) (2026-03-12)
### New Features
* add show_in_gift toggle for tariffs in admin panel ([cb5126a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cb5126aff8c15938a59ea9c4f8e605b250b05dbc))
* add sync-squads endpoint for bulk updating subscription squads in Remnawave ([b1e2146](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b1e2146254255586b5be9bd894ac4d113a0a8cf5))
* auto-sync squads to Remnawave when admin updates tariff ([076290e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/076290e0c1d81b610a7653d6b64ed218e0f124b4))
* referral links now point to web cabinet instead of bot ([12ae871](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/12ae871653399bc4ccd23b6394878e814ce9cd75))
### Bug Fixes
* add post_update=True to User.referrals self-referential relationship ([9957259](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/995725988150f31d193631120a4692e88fa4dd57))
* add Telegram Stars payment support for gift subscriptions ([5424d8c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5424d8c31484873b0adc0bc980abdc51ee81325b))
* correct skipped_count in sync-squads circuit breaker and simplify ternary ([8a362db](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8a362db7833b5b7793b5b52345d227cb84cbc39e))
* preserve purchased devices when admin changes user tariff ([bf72f24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bf72f241d81e4432f50a61ec3bb829d18c92955d))
* prevent account takeover via auto_login_token, ensure promo group on all purchase paths ([b3f3eba](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b3f3eba5756404df9ed0f12d8048244ca536f7d3))
* reactivate subscription after traffic top-up when status is EXPIRED ([8b35428](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8b354280558a5f28d1b99eae55ccd21a4af6a07b))
* update promo group via M2M table so admin changes persist ([68bc8eb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/68bc8eb57c792059d2be8a8fff6bba3254d3773d))
### Refactoring
* remove estimated price from balance, simplify server sync, fix HTML injection ([a798f11](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a798f1143eebf52e18254bddd610f7f14a0c4056))
## [3.30.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.29.0...v3.30.0) (2026-03-11)
+19 -32
View File
@@ -197,28 +197,17 @@ async def create_subscription(
### Документация кода
```python
async def calculate_subscription_price(
period_days: int,
traffic_gb: int,
devices_count: int,
servers_count: int
) -> int:
"""
Рассчитывает стоимость подписки.
Args:
period_days: Период подписки в днях
traffic_gb: Лимит трафика в ГБ (0 = безлимит)
devices_count: Количество устройств
servers_count: Количество серверов
Returns:
Стоимость в копейках
Raises:
ValueError: Если переданы некорректные параметры
"""
# implementation
from app.services.pricing_engine import PricingEngine
pricing = PricingEngine.calculate_renewal_price(
subscription=subscription,
period_days=30,
user=user,
)
# pricing.final_total — стоимость в копейках
# pricing.original_total — цена до скидок
# pricing.promo_group_discount — скидка промогруппы
# pricing.promo_offer_discount — скидка промо-оффера
```
### Обработка ошибок
@@ -341,20 +330,18 @@ python main.py
### Тестирование компонентов
```python
# tests/test_subscription_service.py
# tests/services/test_pricing_engine.py
import pytest
from app.services.subscription_service import SubscriptionService
from app.services.pricing_engine import PricingEngine
@pytest.mark.asyncio
async def test_calculate_price():
price = await SubscriptionService.calculate_subscription_price(
def test_calculate_renewal_price():
pricing = PricingEngine.calculate_renewal_price(
subscription=mock_subscription,
period_days=30,
traffic_gb=100,
devices_count=3,
servers_count=1
user=mock_user,
)
assert price > 0
assert isinstance(price, int)
assert pricing.final_total > 0
assert isinstance(pricing.final_total, int)
```
### Integration тесты
+1 -1
View File
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
FROM python:3.13-slim
ARG VERSION="v3.30.0" # x-release-please-version
ARG VERSION="v3.33.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+44 -6
View File
@@ -170,6 +170,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 +179,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 = (
@@ -290,6 +311,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 +320,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(
+244 -2
View File
@@ -1,9 +1,12 @@
"""Admin routes for managing tariffs in cabinet."""
import asyncio
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from app.database.crud.server_squad import get_all_server_squads
from app.database.crud.tariff import (
@@ -17,7 +20,7 @@ from app.database.crud.tariff import (
set_tariff_promo_groups,
update_tariff,
)
from app.database.models import PromoGroup, Subscription, Tariff, Transaction, TransactionType, User
from app.database.models import PromoGroup, Subscription, SubscriptionStatus, Tariff, Transaction, TransactionType, User
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.tariffs import (
@@ -26,6 +29,7 @@ from ..schemas.tariffs import (
PromoGroupInfo,
ServerInfo,
ServerTrafficLimit,
SyncSquadsResponse,
TariffCreateRequest,
TariffDetailResponse,
TariffListItem,
@@ -127,6 +131,7 @@ async def list_tariffs(
is_daily=tariff.is_daily,
daily_price_kopeks=tariff.daily_price_kopeks,
allow_traffic_topup=tariff.allow_traffic_topup,
show_in_gift=tariff.show_in_gift,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
tier_level=tariff.tier_level,
@@ -265,6 +270,8 @@ async def get_tariff(
traffic_reset_mode=tariff.traffic_reset_mode,
# Внешний сквад
external_squad_uuid=tariff.external_squad_uuid,
# Показывать в подарках
show_in_gift=tariff.show_in_gift,
created_at=tariff.created_at,
updated_at=tariff.updated_at,
)
@@ -321,6 +328,8 @@ async def create_new_tariff(
traffic_reset_mode=request.traffic_reset_mode,
# Внешний сквад
external_squad_uuid=request.external_squad_uuid,
# Показывать в подарках
show_in_gift=request.show_in_gift,
)
logger.info('Admin created tariff', admin_id=admin.id, tariff_id=tariff.id, tariff_name=tariff.name)
@@ -347,6 +356,10 @@ async def update_existing_tariff(
detail='Tariff not found',
)
# Capture old values for change detection
old_squads = list(tariff.allowed_squads) if tariff.allowed_squads else []
old_external_squad = tariff.external_squad_uuid
# Build updates dict
updates = {}
if request.name is not None:
@@ -413,6 +426,9 @@ async def update_existing_tariff(
# Внешний сквад (None допускается для сброса)
if 'external_squad_uuid' in request.model_fields_set:
updates['external_squad_uuid'] = request.external_squad_uuid
# Показывать в подарках
if request.show_in_gift is not None:
updates['show_in_gift'] = request.show_in_gift
if updates:
await update_tariff(db, tariff, **updates)
@@ -426,6 +442,18 @@ async def update_existing_tariff(
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
# Auto-sync squads to active subscriptions in Remnawave when squads changed
new_squads = tariff.allowed_squads or []
squads_changed = request.allowed_squads is not None and sorted(old_squads) != sorted(new_squads)
ext_squad_changed = (
'external_squad_uuid' in request.model_fields_set and tariff.external_squad_uuid != old_external_squad
)
if squads_changed or ext_squad_changed:
asyncio.create_task(
_background_sync_squads(tariff_id, admin.id),
name=f'sync-squads-tariff-{tariff_id}',
)
return await get_tariff(tariff_id, admin, db)
@@ -586,3 +614,217 @@ async def get_tariff_stats(
revenue_kopeks=revenue_kopeks,
revenue_rubles=revenue_kopeks / 100,
)
async def _background_sync_squads(tariff_id: int, admin_id: int) -> None:
"""Run squad sync in background with its own DB session (fire-and-forget)."""
from app.database.database import AsyncSessionLocal
from app.services.remnawave_service import RemnaWaveService
try:
async with AsyncSessionLocal() as db:
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
return
result = await db.execute(
select(Subscription)
.join(User, Subscription.user_id == User.id)
.options(joinedload(Subscription.user))
.where(
and_(
Subscription.tariff_id == tariff_id,
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
User.remnawave_uuid.isnot(None),
)
)
)
subscriptions = list(result.unique().scalars().all())
if not subscriptions:
return
new_squads = tariff.allowed_squads or []
ext_squad_uuid = tariff.external_squad_uuid
service = RemnaWaveService()
updated = 0
failed = 0
async with service.get_api_client() as api:
semaphore = asyncio.Semaphore(5)
async def _sync_one(sub: Subscription) -> None:
nonlocal updated, failed
remnawave_uuid = sub.user.remnawave_uuid if sub.user else None
if not remnawave_uuid:
return
async with semaphore:
try:
await api.update_user(
uuid=remnawave_uuid,
active_internal_squads=new_squads,
external_squad_uuid=ext_squad_uuid,
)
sub.connected_squads = new_squads
updated += 1
except Exception as e:
failed += 1
logger.warning(
'Background sync: failed to sync squads for user',
user_id=sub.user_id,
error=str(e),
)
await asyncio.gather(*[_sync_one(sub) for sub in subscriptions])
await db.commit()
logger.info(
'Background squad sync completed after tariff update',
admin_id=admin_id,
tariff_id=tariff_id,
tariff_name=tariff.name,
total=len(subscriptions),
updated=updated,
failed=failed,
)
except Exception:
logger.exception('Background squad sync failed', tariff_id=tariff_id)
_SYNC_SQUADS_CONCURRENCY = 5
_SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES = 10
@router.post('/{tariff_id}/sync-squads', response_model=SyncSquadsResponse)
async def sync_tariff_squads(
tariff_id: int,
admin: User = Depends(require_permission('tariffs:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Sync squads from tariff to all active/trial subscriptions in Remnawave panel.
Updates connected_squads and external_squad_uuid for every active or trial
subscription linked to this tariff. Only users that have a remnawave_uuid
(i.e. already exist in the panel) are touched.
"""
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tariff not found',
)
# Fetch active + trial subscriptions for this tariff whose users exist in Remnawave
result = await db.execute(
select(Subscription)
.join(User, Subscription.user_id == User.id)
.options(joinedload(Subscription.user))
.where(
and_(
Subscription.tariff_id == tariff_id,
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
User.remnawave_uuid.isnot(None),
)
)
)
subscriptions = list(result.unique().scalars().all())
if not subscriptions:
return SyncSquadsResponse(
tariff_id=tariff_id,
tariff_name=tariff.name,
total_subscriptions=0,
updated_count=0,
failed_count=0,
skipped_count=0,
)
new_squads = tariff.allowed_squads or []
# None means "clear external squad" — intentional when tariff has none
ext_squad_uuid = tariff.external_squad_uuid
# Sync to Remnawave panel with concurrency limit and circuit breaker
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
updated_count = 0
failed_count = 0
skipped_count = 0
consecutive_failures = 0
errors: list[str] = []
aborted = False
async with service.get_api_client() as api:
semaphore = asyncio.Semaphore(_SYNC_SQUADS_CONCURRENCY)
async def _sync_one(sub: Subscription) -> str:
# Counter mutations are safe: no `await` between read-modify-write
# and the check within each branch (single-threaded asyncio event loop).
nonlocal updated_count, failed_count, skipped_count, consecutive_failures, aborted
if aborted:
skipped_count += 1
return 'skipped'
remnawave_uuid = sub.user.remnawave_uuid if sub.user else None
if not remnawave_uuid:
skipped_count += 1
return 'skipped'
async with semaphore:
if aborted:
skipped_count += 1
return 'skipped'
try:
await api.update_user(
uuid=remnawave_uuid,
active_internal_squads=new_squads,
external_squad_uuid=ext_squad_uuid,
)
# Update local DB only on successful API call
sub.connected_squads = new_squads
updated_count += 1
consecutive_failures = 0
return 'ok'
except Exception as e:
failed_count += 1
consecutive_failures += 1
errors.append(f'user_id={sub.user_id}: sync failed')
logger.warning(
'Failed to sync squads for user in Remnawave',
user_id=sub.user_id,
remnawave_uuid=remnawave_uuid,
error=str(e),
)
if consecutive_failures >= _SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES:
aborted = True
errors.append(f'Aborted after {_SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES} consecutive failures')
return 'error'
await asyncio.gather(*[_sync_one(sub) for sub in subscriptions])
# Commit local DB changes only for successfully synced subscriptions
await db.commit()
logger.info(
'Admin synced squads for tariff',
admin_id=admin.id,
tariff_id=tariff_id,
tariff_name=tariff.name,
total=len(subscriptions),
updated=updated_count,
failed=failed_count,
skipped=skipped_count,
)
return SyncSquadsResponse(
tariff_id=tariff_id,
tariff_name=tariff.name,
total_subscriptions=len(subscriptions),
updated_count=updated_count,
failed_count=failed_count,
skipped_count=skipped_count,
errors=errors[:20],
)
+61 -14
View File
@@ -4,10 +4,11 @@ from datetime import UTC, datetime, timedelta
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import Integer, and_, func, or_, select
from sqlalchemy import Integer, and_, delete as sa_delete, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.crud.campaign import get_campaign_registration_by_user
from app.database.crud.subscription import (
extend_subscription,
@@ -25,6 +26,7 @@ from app.database.crud.user import (
get_users_statistics,
subtract_user_balance,
)
from app.database.crud.user_promo_group import sync_user_primary_promo_group
from app.database.models import (
GuestPurchase,
PromoGroup,
@@ -36,6 +38,7 @@ from app.database.models import (
Transaction,
TransactionType,
User,
UserPromoGroup,
UserStatus,
)
from app.utils.timezone import panel_datetime_to_utc
@@ -315,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)
@@ -1119,9 +1121,19 @@ async def update_user_subscription(
detail='Tariff not found',
)
# Preserve extra purchased devices above the old tariff's base limit
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
subscription.tariff_id = request.tariff_id
subscription.traffic_limit_gb = tariff.traffic_limit_gb
subscription.device_limit = tariff.device_limit
subscription.device_limit = calc_device_limit_on_tariff_switch(
current_device_limit=subscription.device_limit,
old_tariff_device_limit=old_tariff.device_limit if old_tariff else None,
new_tariff_device_limit=tariff.device_limit,
max_device_limit=tariff.max_device_limit,
)
# Set squads from tariff
if tariff.allowed_squads:
subscription.connected_squads = tariff.allowed_squads
@@ -1140,8 +1152,6 @@ async def update_user_subscription(
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None
from app.config import settings
if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH:
subscription.traffic_used_gb = 0.0
@@ -1268,7 +1278,7 @@ async def update_user_subscription(
await add_subscription_traffic(db, subscription, request.traffic_gb)
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
await reactivate_subscription(db, subscription)
await db.refresh(subscription)
@@ -1276,6 +1286,13 @@ async def update_user_subscription(
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, subscription)
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
if getattr(user, 'remnawave_uuid', None) and subscription.status == 'active':
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
logger.info('Admin added traffic for user', admin_id=admin.id, traffic_gb=request.traffic_gb, user_id=user_id)
return UpdateSubscriptionResponse(
@@ -1629,8 +1646,22 @@ async def update_user_promo_group(
)
promo_group_name = promo_group.name
user.promo_group_id = new_promo_group_id
user.updated_at = datetime.now(UTC)
# Update M2M table (authoritative source) — not just the legacy FK column.
# Without this, sync_user_primary_promo_group overwrites the admin change
# on the next transaction.
await db.execute(sa_delete(UserPromoGroup).where(UserPromoGroup.user_id == user_id))
if new_promo_group_id is not None:
db.add(
UserPromoGroup(
user_id=user_id,
promo_group_id=new_promo_group_id,
assigned_by='admin',
)
)
await db.flush()
await sync_user_primary_promo_group(db, user_id)
await db.commit()
await db.refresh(user)
@@ -2484,8 +2515,25 @@ async def sync_user_from_panel(
if panel_user.expire_at:
panel_expire_utc = panel_datetime_to_utc(panel_user.expire_at)
sub_end_utc = sub.end_date if sub.end_date and sub.end_date.tzinfo else sub.end_date
sub_end_utc = sub.end_date
if sub_end_utc is not None and sub_end_utc.tzinfo is None:
sub_end_utc = sub_end_utc.replace(tzinfo=UTC)
if sub_end_utc != panel_expire_utc:
# Предупреждаем если локальная дата новее панельной
# (например, автопокупка уже продлила подписку)
if sub_end_utc and panel_expire_utc and sub_end_utc > panel_expire_utc:
logger.warning(
'Sync: локальная end_date новее панельной, перезаписываем. '
'Возможно автопокупка уже продлила подписку.',
user_id=user_id,
local_end_date=sub_end_utc.isoformat(),
panel_expire_at=panel_expire_utc.isoformat(),
)
errors.append(
f'Warning: local end_date ({sub_end_utc.isoformat()}) is newer than '
f'panel expire_at ({panel_expire_utc.isoformat()}). '
f'Panel value applied — check if auto-purchase extended subscription.'
)
changes['end_date'] = {
'old': sub.end_date.isoformat() if sub.end_date else None,
'new': panel_expire_utc.isoformat(),
@@ -2728,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 ============
+4
View File
@@ -86,6 +86,7 @@ def _user_allowed(subscription) -> bool:
return subscription.status in {
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.TRIAL.value,
SubscriptionStatus.LIMITED.value,
}
@@ -121,6 +122,9 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_va
if not user:
return 'Error: user not found'
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
user.balance_kopeks += int(round(amount * 100))
await db.commit()
await db.refresh(user)
+35 -40
View File
@@ -22,7 +22,6 @@ from app.database.models import (
Tariff,
TransactionType,
User,
UserPromoGroup,
)
from app.services.guest_purchase_service import (
GuestPurchaseError,
@@ -82,9 +81,11 @@ async def get_gift_config(
balance_kopeks=user.balance_kopeks,
)
# Load active tariffs
# Load active tariffs visible in gift section
result = await db.execute(
select(Tariff).where(Tariff.is_active.is_(True)).order_by(Tariff.display_order, Tariff.id)
select(Tariff)
.where(Tariff.is_active.is_(True), Tariff.show_in_gift.is_(True))
.order_by(Tariff.display_order, Tariff.id)
)
tariffs_db = result.scalars().all()
@@ -110,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)
@@ -241,49 +244,34 @@ async def create_gift_purchase(
# Find tariff and validate period
tariff = await get_tariff_by_id(db, body.tariff_id)
if tariff is None or not tariff.is_active:
if tariff is None or not tariff.is_active or not tariff.show_in_gift:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
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:
@@ -380,7 +368,14 @@ async def create_gift_purchase(
from app.services.payment_service import PaymentService
payment_service = PaymentService()
# Stars payments need a Bot instance to create invoice links
bot = None
if body.payment_method == 'telegram_stars':
from aiogram import Bot
bot = Bot(token=settings.BOT_TOKEN)
payment_service = PaymentService(bot=bot)
payment_result = await payment_service.create_guest_payment(
db=db,
amount_kopeks=price_kopeks,
@@ -411,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
@@ -476,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,
+10 -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(
@@ -607,6 +609,13 @@ async def create_landing_purchase(
except GuestPurchaseError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
# Gift purchases require the tariff to be visible in the gift section
if body.is_gift and not tariff.show_in_gift:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This tariff is not available for gift purchases',
)
# Validate amount against per-method min/max limits (before creating purchase record)
min_amount = method_config.get('min_amount_kopeks')
max_amount = method_config.get('max_amount_kopeks')
+10 -3
View File
@@ -91,13 +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
bot_username = settings.get_bot_username() or 'bot'
referral_link = f'https://t.me/{bot_username}?start={user.referral_code}'
# 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,
File diff suppressed because it is too large Load Diff
+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
+1
View File
@@ -48,6 +48,7 @@ class SubscriptionData(BaseModel):
hide_subscription_link: bool = False # Скрывать ли отображение ссылки (но кнопки работают)
is_active: bool
is_expired: bool
is_limited: bool = False
traffic_purchases: list[TrafficPurchaseInfo] = []
# Daily tariff fields
is_daily: bool = False
+19
View File
@@ -54,6 +54,7 @@ class TariffListItem(BaseModel):
is_daily: bool = False
daily_price_kopeks: int = 0
allow_traffic_topup: bool = True
show_in_gift: bool = True
traffic_limit_gb: int
device_limit: int
tier_level: int
@@ -114,6 +115,8 @@ class TariffDetailResponse(BaseModel):
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
# Внешний сквад RemnaWave
external_squad_uuid: str | None = None
# Показывать в подарках
show_in_gift: bool = True
created_at: datetime
updated_at: datetime | None = None
@@ -170,6 +173,8 @@ class TariffCreateRequest(BaseModel):
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
# Внешний сквад RemnaWave
external_squad_uuid: str | None = Field(None, pattern=UUID_PATTERN)
# Показывать в подарках
show_in_gift: bool = True
class TariffUpdateRequest(BaseModel):
@@ -209,6 +214,8 @@ class TariffUpdateRequest(BaseModel):
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
# Внешний сквад RemnaWave
external_squad_uuid: str | None = Field(None, pattern=UUID_PATTERN)
# Показывать в подарках
show_in_gift: bool | None = None
class TariffSortOrderRequest(BaseModel):
@@ -243,3 +250,15 @@ class TariffStatsResponse(BaseModel):
trial_subscriptions: int
revenue_kopeks: int
revenue_rubles: float
class SyncSquadsResponse(BaseModel):
"""Response after syncing squads for tariff subscriptions."""
tariff_id: int
tariff_name: str
total_subscriptions: int
updated_count: int
failed_count: int
skipped_count: int
errors: list[str] = Field(default_factory=list)
+1
View File
@@ -22,6 +22,7 @@ class SubscriptionStatusEnum(StrEnum):
ACTIVE = 'active'
EXPIRED = 'expired'
DISABLED = 'disabled'
LIMITED = 'limited'
PENDING = 'pending'
+64
View File
@@ -535,6 +535,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
@@ -1415,6 +1420,26 @@ class Settings(BaseSettings):
return value
return None
_CABINET_URL_DEFAULT = 'https://example.com/cabinet'
def get_referral_link(self, referral_code: str, bot_username: str | None = None) -> str:
"""Build a referral link pointing to the web cabinet.
Falls back to a Telegram bot deep link when CABINET_URL is not configured.
"""
from urllib.parse import quote
if not referral_code:
raise ValueError('referral_code must not be empty or None')
safe_code = quote(referral_code, safe='')
cabinet_url = (self.CABINET_URL or '').strip().rstrip('/')
if cabinet_url and cabinet_url != self._CABINET_URL_DEFAULT:
sep = '&' if '?' in cabinet_url else '?'
return f'{cabinet_url}{sep}ref={safe_code}'
username = bot_username or self.get_bot_username() or 'bot'
return f'https://t.me/{username}?start={safe_code}'
def is_deep_links_enabled(self) -> bool:
return self.ENABLE_DEEP_LINKS
@@ -1830,6 +1855,26 @@ class Settings(BaseSettings):
def get_riopay_display_name_html(self) -> str:
return html.escape(self.get_riopay_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
@@ -2680,6 +2725,25 @@ PERIOD_PRICES: dict[int, int] = {}
refresh_period_prices()
def _build_classic_period_prices() -> dict[int, int]:
"""Build classic-mode period prices directly from PRICE_*_DAYS settings.
Unlike PERIOD_PRICES (which may use DB tariff prices in tariffs mode),
this always reflects the env/settings values the canonical prices for
classic (non-tariff) subscriptions.
"""
return {days: getattr(settings, field_name, 0) for days, field_name in _PERIOD_PRICE_FIELDS.items()}
CLASSIC_PERIOD_PRICES: dict[int, int] = _build_classic_period_prices()
def refresh_classic_period_prices() -> None:
"""Rebuild CLASSIC_PERIOD_PRICES from current settings."""
CLASSIC_PERIOD_PRICES.clear()
CLASSIC_PERIOD_PRICES.update(_build_classic_period_prices())
def get_traffic_prices() -> dict[int, int]:
packages = settings.get_traffic_packages()
return {package['gb']: package['price'] for package in packages}
+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)
+11 -3
View File
@@ -73,7 +73,12 @@ async def get_cryptobot_payment_by_id_for_update(db: AsyncSession, payment_id: i
async def update_cryptobot_payment_status(
db: AsyncSession, invoice_id: str, status: str, paid_at: datetime | None = None
db: AsyncSession,
invoice_id: str,
status: str,
paid_at: datetime | None = None,
*,
commit: bool = True,
) -> CryptoBotPayment | None:
payment = await get_cryptobot_payment_by_invoice_id(db, invoice_id)
@@ -86,8 +91,11 @@ async def update_cryptobot_payment_status(
if status == 'paid' and paid_at:
payment.paid_at = paid_at
await db.commit()
await db.refresh(payment)
if commit:
await db.commit()
await db.refresh(payment)
else:
await db.flush()
logger.info('Обновлен статус CryptoBot платежа', invoice_id=invoice_id, status=status)
return payment
+2
View File
@@ -43,6 +43,8 @@ async def log_promo_offer_action(
except Exception:
logger.exception('Failed to commit promo offer log entry')
raise
else:
await db.flush()
return entry
+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',
+18 -218
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)
@@ -306,15 +310,24 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
await create_server_squad(
db=db,
squad_uuid=squad_uuid,
display_name=_generate_display_name(original_name),
display_name=original_name,
original_name=original_name,
country_code=_extract_country_code(original_name),
price_kopeks=1000,
is_available=False,
)
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]
@@ -482,219 +495,6 @@ async def get_random_trial_squad_uuid(
return None
def _generate_display_name(original_name: str) -> str:
"""Генерирует отображаемое название сервера на основе оригинального имени."""
country_names = {
# Европа
'NL': '🇳🇱 Нидерланды',
'DE': '🇩🇪 Германия',
'FR': '🇫🇷 Франция',
'GB': '🇬🇧 Великобритания',
'UK': '🇬🇧 Великобритания',
'IT': '🇮🇹 Италия',
'ES': '🇪🇸 Испания',
'PT': '🇵🇹 Португалия',
'PL': '🇵🇱 Польша',
'CZ': '🇨🇿 Чехия',
'AT': '🇦🇹 Австрия',
'CH': '🇨🇭 Швейцария',
'SE': '🇸🇪 Швеция',
'NO': '🇳🇴 Норвегия',
'FI': '🇫🇮 Финляндия',
'DK': '🇩🇰 Дания',
'BE': '🇧🇪 Бельгия',
'IE': '🇮🇪 Ирландия',
'RO': '🇷🇴 Румыния',
'BG': '🇧🇬 Болгария',
'HU': '🇭🇺 Венгрия',
'GR': '🇬🇷 Греция',
'LV': '🇱🇻 Латвия',
'LT': '🇱🇹 Литва',
'EE': '🇪🇪 Эстония',
'SK': '🇸🇰 Словакия',
'SI': '🇸🇮 Словения',
'HR': '🇭🇷 Хорватия',
'RS': '🇷🇸 Сербия',
'UA': '🇺🇦 Украина',
'MD': '🇲🇩 Молдова',
'BY': '🇧🇾 Беларусь',
'LU': '🇱🇺 Люксембург',
# СНГ и Азия
'RU': '🇷🇺 Россия',
'KZ': '🇰🇿 Казахстан',
'UZ': '🇺🇿 Узбекистан',
'GE': '🇬🇪 Грузия',
'AM': '🇦🇲 Армения',
'AZ': '🇦🇿 Азербайджан',
# Америка
'US': '🇺🇸 США',
'CA': '🇨🇦 Канада',
'MX': '🇲🇽 Мексика',
'BR': '🇧🇷 Бразилия',
'AR': '🇦🇷 Аргентина',
'CL': '🇨🇱 Чили',
'CO': '🇨🇴 Колумбия',
# Азия
'JP': '🇯🇵 Япония',
'KR': '🇰🇷 Южная Корея',
'CN': '🇨🇳 Китай',
'HK': '🇭🇰 Гонконг',
'TW': '🇹🇼 Тайвань',
'SG': '🇸🇬 Сингапур',
'TH': '🇹🇭 Таиланд',
'VN': '🇻🇳 Вьетнам',
'MY': '🇲🇾 Малайзия',
'ID': '🇮🇩 Индонезия',
'PH': '🇵🇭 Филиппины',
'IN': '🇮🇳 Индия',
'PK': '🇵🇰 Пакистан',
# Ближний Восток
'IL': '🇮🇱 Израиль',
'TR': '🇹🇷 Турция',
'AE': '🇦🇪 ОАЭ',
'SA': '🇸🇦 Саудовская Аравия',
'QA': '🇶🇦 Катар',
'BH': '🇧🇭 Бахрейн',
'KW': '🇰🇼 Кувейт',
# Океания
'AU': '🇦🇺 Австралия',
'NZ': '🇳🇿 Новая Зеландия',
# Африка
'ZA': '🇿🇦 ЮАР',
'EG': '🇪🇬 Египет',
'NG': '🇳🇬 Нигерия',
'KE': '🇰🇪 Кения',
}
name_upper = original_name.upper()
# Сначала ищем код как отдельный элемент (через - или _)
for code, display_name in country_names.items():
if f'-{code}' in name_upper or f'_{code}' in name_upper:
return display_name
if name_upper.startswith(code + '-') or name_upper.startswith(code + '_'):
return display_name
if name_upper.endswith('-' + code) or name_upper.endswith('_' + code):
return display_name
if name_upper == code:
return display_name
# Потом ищем просто вхождение кода
for code, display_name in country_names.items():
if code in name_upper:
return display_name
return f'🌍 {original_name}'
def _extract_country_code(original_name: str) -> str | None:
"""Извлекает код страны из оригинального названия."""
# Полный список кодов стран
codes = [
# Европа
'NL',
'DE',
'FR',
'GB',
'UK',
'IT',
'ES',
'PT',
'PL',
'CZ',
'AT',
'CH',
'SE',
'NO',
'FI',
'DK',
'BE',
'IE',
'RO',
'BG',
'HU',
'GR',
'LV',
'LT',
'EE',
'SK',
'SI',
'HR',
'RS',
'UA',
'MD',
'BY',
'LU',
# СНГ
'RU',
'KZ',
'UZ',
'GE',
'AM',
'AZ',
# Америка
'US',
'CA',
'MX',
'BR',
'AR',
'CL',
'CO',
# Азия
'JP',
'KR',
'CN',
'HK',
'TW',
'SG',
'TH',
'VN',
'MY',
'ID',
'PH',
'IN',
'PK',
# Ближний Восток
'IL',
'TR',
'AE',
'SA',
'QA',
'BH',
'KW',
# Океания
'AU',
'NZ',
# Африка
'ZA',
'EG',
'NG',
'KE',
]
name_upper = original_name.upper()
# Сначала ищем код как отдельный элемент
for code in codes:
if f'-{code}' in name_upper or f'_{code}' in name_upper:
return code
if name_upper.startswith(code + '-') or name_upper.startswith(code + '_'):
return code
if name_upper.endswith('-' + code) or name_upper.endswith('_' + code):
return code
if name_upper == code:
return code
# Потом просто ищем вхождение
for code in codes:
if code in name_upper:
return code
return None
async def get_server_statistics(db: AsyncSession) -> dict:
total_result = await db.execute(select(func.count(ServerSquad.id)))
total_servers = total_result.scalar()
+114 -482
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,17 +10,14 @@ 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,
Transaction,
TransactionType,
User,
UserPromoGroup,
UserStatus,
)
from app.utils.pricing_utils import calculate_months_from_days
from app.utils.timezone import format_local_datetime
@@ -38,6 +34,31 @@ def is_recently_updated_by_webhook(subscription: Subscription) -> bool:
return elapsed < _WEBHOOK_GUARD_SECONDS
def calc_device_limit_on_tariff_switch(
current_device_limit: int | None,
old_tariff_device_limit: int | None,
new_tariff_device_limit: int | None,
max_device_limit: int | None = None,
) -> int:
"""Calculate device_limit preserving extra purchased devices 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.
"""
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
return total
def is_active_paid_subscription(subscription: Subscription | None) -> bool:
"""Return True if subscription is active, paid (non-trial), and not expired."""
if not subscription:
@@ -197,6 +218,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,
@@ -205,7 +243,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,
@@ -225,7 +263,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 (
@@ -275,7 +313,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
@@ -357,6 +413,7 @@ async def extend_subscription(
traffic_limit_gb: int | None = None,
device_limit: int | None = None,
connected_squads: list[str] | None = None,
commit: bool = True,
) -> Subscription:
"""Продлевает подписку на указанное количество дней.
@@ -389,6 +446,7 @@ async def extend_subscription(
was_expired = subscription.status in (
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.LIMITED.value,
) or (subscription.end_date is not None and subscription.end_date <= current_time)
if is_tariff_change:
@@ -445,6 +503,7 @@ async def extend_subscription(
if days > 0 and subscription.status in (
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.LIMITED.value,
):
previous_status = subscription.status
subscription.status = SubscriptionStatus.ACTIVE.value
@@ -522,9 +581,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:
@@ -567,9 +634,13 @@ async def extend_subscription(
subscription.updated_at = current_time
await db.commit()
await db.refresh(subscription, ['tariff'])
await clear_notifications(db, subscription.id)
if commit:
await db.commit()
await db.refresh(subscription, ['tariff'])
else:
await db.flush()
await clear_notifications(db, subscription.id, commit=commit)
logger.info('✅ Подписка продлена до', end_date=subscription.end_date)
logger.info('📊 Новые параметры: статус=, окончание', status=subscription.status, end_date=subscription.end_date)
@@ -772,26 +843,39 @@ async def deactivate_subscription(db: AsyncSession, subscription: Subscription)
async def reactivate_subscription(db: AsyncSession, subscription: Subscription) -> Subscription:
"""Реактивация подписки (например, после повторной подписки на канал).
"""Реактивация подписки (например, после повторной подписки на канал или докупки трафика).
Активирует только если подписка была DISABLED и ещё не истекла.
Активирует если подписка была DISABLED или EXPIRED и ещё не истекла по времени.
Не логирует если реактивация не требуется.
"""
now = datetime.now(UTC)
# Тихо выходим если реактивация не нужна
if subscription.status != SubscriptionStatus.DISABLED.value:
# Тихо выходим если реактивация не нужна (уже активна или другой статус)
reactivatable_statuses = {
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.LIMITED.value,
}
if subscription.status not in reactivatable_statuses:
return subscription
if subscription.end_date and subscription.end_date <= now:
if not subscription.end_date or subscription.end_date <= now:
return subscription
old_status = subscription.status
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.updated_at = now
await db.commit()
await db.refresh(subscription)
logger.info(
'✅ Подписка реактивирована',
subscription_id=subscription.id,
user_id=subscription.user_id,
old_status=old_status,
)
return subscription
@@ -1154,212 +1238,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)
@@ -1367,32 +1245,6 @@ async def get_subscription_server_ids(db: AsyncSession, subscription_id: int) ->
return [row[0] for row in result.fetchall()]
async def get_subscription_servers(db: AsyncSession, subscription_id: int) -> list[dict]:
from app.database.models import ServerSquad
result = await db.execute(
select(SubscriptionServer, ServerSquad)
.join(ServerSquad, SubscriptionServer.server_squad_id == ServerSquad.id)
.where(SubscriptionServer.subscription_id == subscription_id)
)
servers_info = []
for sub_server, server_squad in result.fetchall():
servers_info.append(
{
'server_id': server_squad.id,
'squad_uuid': server_squad.squad_uuid,
'display_name': server_squad.display_name,
'country_code': server_squad.country_code,
'paid_price_kopeks': sub_server.paid_price_kopeks,
'connected_at': sub_server.connected_at,
'is_available': server_squad.is_available,
}
)
return servers_info
async def remove_subscription_servers(db: AsyncSession, subscription_id: int, server_squad_ids: list[int]) -> bool:
try:
from sqlalchemy import delete
@@ -1416,232 +1268,6 @@ async def remove_subscription_servers(db: AsyncSession, subscription_id: int, se
return False
async def get_subscription_renewal_cost(
db: AsyncSession,
subscription_id: int,
period_days: int,
*,
user: User | None = None,
promo_group: PromoGroup | None = None,
) -> int:
try:
from app.config import PERIOD_PRICES
months_in_period = calculate_months_from_days(period_days)
base_price = PERIOD_PRICES.get(period_days, 0)
result = await db.execute(
select(Subscription)
.options(
selectinload(Subscription.user)
.selectinload(User.user_promo_groups)
.selectinload(UserPromoGroup.promo_group),
)
.where(Subscription.id == subscription_id)
)
subscription = result.scalar_one_or_none()
if not subscription:
return base_price
if user is None:
user = subscription.user
promo_group = promo_group or (user.promo_group if user else None)
servers_info = await get_subscription_servers(db, subscription_id)
servers_price_per_month = 0
for server_info in servers_info:
from app.database.models import ServerSquad
result = await db.execute(
select(ServerSquad.price_kopeks).where(ServerSquad.id == server_info['server_id'])
)
current_server_price = result.scalar() or 0
servers_price_per_month += current_server_price
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_cost = discounted_servers_per_month * months_in_period
total_servers_discount = servers_discount_per_month * months_in_period
# В режиме fixed_with_topup при продлении используем фиксированный лимит
purchased_traffic = subscription.purchased_traffic_gb or 0
if settings.is_traffic_fixed():
traffic_price_per_month = settings.get_traffic_price(settings.get_fixed_traffic_limit())
# Separate base traffic from purchased to avoid wrong tier lookup
elif purchased_traffic > 0:
base_traffic_gb = (subscription.traffic_limit_gb or 0) - purchased_traffic
if base_traffic_gb <= 0:
logger.warning(
'Purchased traffic >= total limit, pricing purchased portion only',
subscription_id=subscription.id,
traffic_limit_gb=subscription.traffic_limit_gb,
purchased_traffic_gb=purchased_traffic,
)
traffic_price_per_month = settings.get_traffic_price(purchased_traffic)
else:
traffic_price_per_month = settings.get_traffic_price(base_traffic_gb) + settings.get_traffic_price(
purchased_traffic
)
else:
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_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_cost = discounted_traffic_per_month * months_in_period
total_traffic_discount = traffic_discount_per_month * months_in_period
additional_devices = max(0, subscription.device_limit - 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_cost = discounted_devices_per_month * months_in_period
total_devices_discount = devices_discount_per_month * months_in_period
total_cost = base_price + total_servers_cost + total_traffic_cost + total_devices_cost
logger.info(
'💰 Расчет продления подписки на дней ( мес)',
subscription_id=subscription_id,
period_days=period_days,
months_in_period=months_in_period,
)
logger.info('📅 Период: ₽', base_price=base_price / 100)
if total_servers_cost > 0:
message = f' 🌍 Серверы: {servers_price_per_month / 100}₽/мес × {months_in_period} = {total_servers_cost / 100}'
if total_servers_discount > 0:
message += f' (скидка {servers_discount_percent}%: -{total_servers_discount / 100}₽)'
logger.info(message)
if total_traffic_cost > 0:
message = (
f' 📊 Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period} = {total_traffic_cost / 100}'
)
if total_traffic_discount > 0:
message += f' (скидка {traffic_discount_percent}%: -{total_traffic_discount / 100}₽)'
logger.info(message)
if total_devices_cost > 0:
message = f' 📱 Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period} = {total_devices_cost / 100}'
if total_devices_discount > 0:
message += f' (скидка {devices_discount_percent}%: -{total_devices_discount / 100}₽)'
logger.info(message)
logger.info('💎 ИТОГО: ₽', total_cost=total_cost / 100)
return total_cost
except Exception as e:
logger.error('Ошибка расчета стоимости продления', error=e)
from app.config import PERIOD_PRICES
return PERIOD_PRICES.get(period_days, 0)
async def calculate_addon_cost_for_remaining_period(
db: AsyncSession,
subscription: Subscription,
additional_traffic_gb: int = 0,
additional_devices: int = 0,
additional_server_ids: list[int] = None,
*,
user: User | None = None,
promo_group: PromoGroup | None = None,
) -> int:
if additional_server_ids is None:
additional_server_ids = []
now = datetime.now(UTC)
days_to_pay = max(1, (subscription.end_date - now).days)
period_hint_days = days_to_pay
total_cost = 0
if user is None:
user = getattr(subscription, 'user', None)
promo_group = promo_group or (user.promo_group if user else None)
if additional_traffic_gb > 0:
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
traffic_discount_percent = _get_discount_percent(
user,
promo_group,
'traffic',
period_days=period_hint_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
traffic_total_cost = int(discounted_traffic_per_month * days_to_pay / 30)
total_cost += traffic_total_cost
message = f'Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month / 100}₽/мес × {days_to_pay} дн. = {traffic_total_cost / 100}'
if traffic_discount_per_month > 0:
message += (
f' (скидка {traffic_discount_percent}%: -{int(traffic_discount_per_month * days_to_pay / 30) / 100}₽)'
)
logger.info(message)
if additional_devices > 0:
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_discount_percent = _get_discount_percent(
user,
promo_group,
'devices',
period_days=period_hint_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
devices_total_cost = int(discounted_devices_per_month * days_to_pay / 30)
total_cost += devices_total_cost
message = f'Устройства +{additional_devices}: {devices_price_per_month / 100}₽/мес × {days_to_pay} дн. = {devices_total_cost / 100}'
if devices_discount_per_month > 0:
message += (
f' (скидка {devices_discount_percent}%: -{int(devices_discount_per_month * days_to_pay / 30) / 100}₽)'
)
logger.info(message)
if additional_server_ids:
from app.database.models import ServerSquad
for server_id in additional_server_ids:
result = await db.execute(
select(ServerSquad.price_kopeks, ServerSquad.display_name).where(ServerSquad.id == server_id)
)
server_data = result.first()
if server_data:
server_price_per_month, server_name = server_data
servers_discount_percent = _get_discount_percent(
user,
promo_group,
'servers',
period_days=period_hint_days,
)
server_discount_per_month = server_price_per_month * servers_discount_percent // 100
discounted_server_per_month = server_price_per_month - server_discount_per_month
server_total_cost = int(discounted_server_per_month * days_to_pay / 30)
total_cost += server_total_cost
message = f'Сервер {server_name}: {server_price_per_month / 100}₽/мес × {days_to_pay} дн. = {server_total_cost / 100}'
if server_discount_per_month > 0:
message += f' (скидка {servers_discount_percent}%: -{int(server_discount_per_month * days_to_pay / 30) / 100}₽)'
logger.info(message)
logger.info('💰 Итого доплата за дн.: ₽', days_to_pay=days_to_pay, total_cost=total_cost / 100)
return total_cost
async def expire_subscription(db: AsyncSession, subscription: Subscription) -> Subscription:
subscription.status = SubscriptionStatus.EXPIRED.value
subscription.updated_at = datetime.now(UTC)
@@ -2109,8 +1735,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,
)
)
)
@@ -2155,8 +1782,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,
)
)
)
@@ -2206,8 +1834,12 @@ async def resume_daily_subscription(
subscription.is_daily_paused = False
# Восстанавливаем статус ACTIVE если подписка была DISABLED/EXPIRED
if subscription.status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value):
# Восстанавливаем статус ACTIVE если подписка была DISABLED/EXPIRED/LIMITED
if subscription.status in (
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.LIMITED.value,
):
previous_status = subscription.status
subscription.status = SubscriptionStatus.ACTIVE.value
# Обновляем время последнего списания для корректного расчёта следующего
+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:
+9
View File
@@ -185,6 +185,8 @@ async def create_tariff(
traffic_price_per_gb_kopeks: int = 0,
min_traffic_gb: int = 1,
max_traffic_gb: int = 1000,
# Видимость в разделе подарков
show_in_gift: bool = True,
# Режим сброса трафика
traffic_reset_mode: str | None = None, # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
# Внешний сквад RemnaWave
@@ -223,6 +225,8 @@ async def create_tariff(
traffic_price_per_gb_kopeks=max(0, traffic_price_per_gb_kopeks),
min_traffic_gb=max(1, min_traffic_gb),
max_traffic_gb=max(1, max_traffic_gb),
# Видимость в разделе подарков
show_in_gift=show_in_gift,
# Режим сброса трафика
traffic_reset_mode=traffic_reset_mode,
# Внешний сквад
@@ -290,6 +294,8 @@ async def update_tariff(
traffic_price_per_gb_kopeks: int | None = None,
min_traffic_gb: int | None = None,
max_traffic_gb: int | None = None,
# Видимость в разделе подарков
show_in_gift: bool | None = None,
# Режим сброса трафика
traffic_reset_mode: str | None = ..., # ... = не передан, None = сбросить к глобальной настройке
# Внешний сквад RemnaWave
@@ -354,6 +360,9 @@ async def update_tariff(
tariff.min_traffic_gb = max(1, min_traffic_gb)
if max_traffic_gb is not None:
tariff.max_traffic_gb = max(1, max_traffic_gb)
# Видимость в разделе подарков
if show_in_gift is not None:
tariff.show_in_gift = show_in_gift
# Режим сброса трафика
if traffic_reset_mode is not ...:
tariff.traffic_reset_mode = traffic_reset_mode
+110 -23
View File
@@ -410,6 +410,28 @@ async def update_user(db: AsyncSession, user: User, **kwargs) -> User:
return user
async def lock_user_for_update(db: AsyncSession, user: User) -> User:
"""Lock user row with SELECT FOR UPDATE to prevent concurrent balance modifications.
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)
.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()
async def add_user_balance(
db: AsyncSession,
user: User,
@@ -421,6 +443,22 @@ async def add_user_balance(
payment_method: PaymentMethod | None = None,
) -> 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)
.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()
old_balance = user.balance_kopeks
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.now(UTC)
@@ -490,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,
@@ -501,17 +560,33 @@ async def subtract_user_balance(
transaction_type: TransactionType = TransactionType.WITHDRAWAL,
consume_promo_offer: bool = False,
mark_as_paid_subscription: bool = False,
commit: bool = True,
) -> bool:
user_id_display = user.telegram_id or user.email or f'#{user.id}'
logger.info('💸 ОТЛАДКА subtract_user_balance:')
logger.info('👤 User ID: (ID: )', user_id=user.id, user_id_display=user_id_display)
logger.info('💰 Баланс до списания: копеек', balance_kopeks=user.balance_kopeks)
logger.info('💸 Сумма к списанию: копеек', amount_kopeks=amount_kopeks)
logger.info('📝 Описание', description=description)
if amount_kopeks < 0:
logger.error('subtract_user_balance called with negative amount', amount_kopeks=amount_kopeks, user_id=user.id)
return False
logger.debug(
'subtract_user_balance called',
user_id=user.id,
balance_kopeks=user.balance_kopeks,
amount_kopeks=amount_kopeks,
description=description,
)
# 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()
@@ -572,8 +647,6 @@ async def subtract_user_balance(
create_transaction as create_trans,
)
# create_trans commits the session, atomically persisting
# both the balance change and the transaction record
await create_trans(
db=db,
user_id=user.id,
@@ -581,11 +654,15 @@ async def subtract_user_balance(
amount_kopeks=amount_kopeks,
description=description,
payment_method=payment_method,
commit=commit,
)
else:
elif commit:
await db.commit()
else:
await db.flush()
await db.refresh(user)
if commit:
await db.refresh(user)
if consume_promo_offer and log_context:
try:
@@ -598,26 +675,30 @@ async def subtract_user_balance(
percent=log_context.get('percent'),
effect_type=log_context.get('effect_type'),
details=log_context.get('details'),
commit=commit,
)
except Exception as log_error: # pragma: no cover - defensive logging
logger.warning(
'Failed to record promo offer consumption log for user', user_id=user.id, log_error=log_error
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
'Failed to rollback session after promo offer consumption log failure',
rollback_error=rollback_error,
)
if commit:
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
'Failed to rollback session after promo offer consumption log failure',
rollback_error=rollback_error,
)
logger.info('✅ Средства списаны: →', old_balance=old_balance, balance_kopeks=user.balance_kopeks)
return True
except Exception as e:
logger.error('❌ ОШИБКА СПИСАНИЯ', error=e)
await db.rollback()
return False
if commit:
await db.rollback()
return False
raise
async def cleanup_expired_promo_offer_discounts(db: AsyncSession) -> int:
@@ -1131,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()
@@ -1148,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)
+1
View File
@@ -40,6 +40,7 @@ async def _sync_user_primary_promo_group(
except Exception as error:
logger.error('Ошибка синхронизации primary промогруппы пользователя', user_id=user_id, error=error)
raise
async def sync_user_primary_promo_group(
+16 -3
View File
@@ -123,6 +123,7 @@ class SubscriptionStatus(Enum):
ACTIVE = 'active'
EXPIRED = 'expired'
DISABLED = 'disabled'
LIMITED = 'limited'
PENDING = 'pending'
@@ -755,7 +756,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
@@ -974,6 +975,9 @@ class Tariff(Base):
min_traffic_gb = Column(Integer, default=1, nullable=False) # Минимальный трафик в ГБ
max_traffic_gb = Column(Integer, default=1000, nullable=False) # Максимальный трафик в ГБ
# Видимость в разделе подарков
show_in_gift = Column(Boolean, default=True, server_default='true', nullable=False)
# Режим сброса трафика: DAY, WEEK, MONTH, NO_RESET (по умолчанию берётся из конфига)
traffic_reset_mode = Column(String(20), nullable=True, default=None) # None = использовать глобальную настройку
@@ -1144,7 +1148,9 @@ class User(Base):
discord_id = Column(String(255), unique=True, nullable=True, index=True)
vk_id = Column(BigInteger, unique=True, nullable=True, index=True)
broadcasts = relationship('BroadcastHistory', back_populates='admin')
referrals = relationship('User', backref='referrer', remote_side=[id], foreign_keys='User.referred_by_id')
referrals = relationship(
'User', backref='referrer', remote_side=[id], foreign_keys='User.referred_by_id', post_update=True
)
subscription = relationship('Subscription', back_populates='user', uselist=False)
transactions = relationship('Transaction', back_populates='user')
referral_earnings = relationship('ReferralEarning', foreign_keys='ReferralEarning.user_id', back_populates='user')
@@ -1345,6 +1351,9 @@ class Subscription(Base):
if self.status == SubscriptionStatus.DISABLED.value:
return 'disabled'
if self.status == SubscriptionStatus.LIMITED.value:
return 'limited'
if self.status == SubscriptionStatus.ACTIVE.value:
if end is None or end <= current_time:
return 'expired'
@@ -1369,6 +1378,8 @@ class Subscription(Base):
return '🟢 Активна'
if actual_status == 'disabled':
return '⚫ Отключена'
if actual_status == 'limited':
return '⚠️ Трафик исчерпан'
if actual_status == 'trial':
return '🎯 Тестовая'
@@ -1386,6 +1397,8 @@ class Subscription(Base):
return '💎'
if actual_status == 'disabled':
return ''
if actual_status == 'limited':
return '⚠️'
if actual_status == 'trial':
return '🎁'
@@ -1434,7 +1447,7 @@ class Subscription(Base):
else:
self.end_date = datetime.now(UTC) + timedelta(days=days)
if self.status == SubscriptionStatus.EXPIRED.value:
if self.status in (SubscriptionStatus.EXPIRED.value, SubscriptionStatus.LIMITED.value):
self.status = SubscriptionStatus.ACTIVE.value
def add_traffic(self, gb: int):
+18 -20
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
@@ -400,7 +394,12 @@ class RemnaWaveAPI:
if response.status >= 400:
error_message = response_data.get('message', f'HTTP {response.status}')
log = logger.warning if response.status in (502, 503, 504) else logger.error
# Downgrade known-harmless 400s to warning (caller handles them as success)
error_lower = str(error_message).lower()
is_harmless = response.status == 400 and (
'already enabled' in error_lower or 'already disabled' in error_lower
)
log = logger.warning if response.status in (502, 503, 504) or is_harmless else logger.error
log('API Error %s: %s', response.status, error_message)
log('Response: %s', response_text[:500])
raise RemnaWaveAPIError(error_message, response.status, response_data)
@@ -569,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"}',
+52 -16
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(
@@ -1557,7 +1585,11 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
if target == 'expired':
# Истекшие подписки
now = datetime.now(UTC)
expired_statuses = [SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value]
expired_statuses = [
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.LIMITED.value,
]
query = (
select(sql_func.count(distinct(User.id)))
.outerjoin(Subscription, User.id == Subscription.user_id)
@@ -1576,7 +1608,11 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
if target == 'expired_subscribers':
# То же что и expired
now = datetime.now(UTC)
expired_statuses = [SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value]
expired_statuses = [
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.LIMITED.value,
]
query = (
select(sql_func.count(distinct(User.id)))
.outerjoin(Subscription, User.id == Subscription.user_id)
+1 -1
View File
@@ -977,7 +977,7 @@ async def _render_squad_selection(
if not selected_server:
selected_server = await get_server_squad_by_uuid(db, selected_uuid)
if selected_server:
selected_server_name = selected_server.display_name
selected_server_name = html.escape(selected_server.display_name)
header = texts.t('ADMIN_PROMO_OFFER_SELECT_SQUAD_TITLE', '🌍 <b>Выберите сквад</b>')
if selected_server_name:
+3
View File
@@ -629,6 +629,9 @@ async def process_test_referral_earning(message: types.Message, db_user: User, d
db.add(earning)
# Добавляем на баланс пользователя
from app.database.crud.user import lock_user_for_update
target_user = await lock_user_for_update(db, target_user)
target_user.balance_kopeks += amount_kopeks
await db.commit()
+2 -1
View File
@@ -1,3 +1,4 @@
import html
import math
from datetime import UTC, datetime, timedelta
from typing import Any
@@ -175,7 +176,7 @@ def _format_migration_server_label(texts, server) -> str:
return texts.t(
'ADMIN_SQUAD_MIGRATION_SERVER_LABEL',
'{name} — 👥 {users} ({status})',
).format(name=server.display_name, users=server.current_users, status=status)
).format(name=html.escape(server.display_name), users=server.current_users, status=status)
def _build_migration_keyboard(
+9 -9
View File
@@ -44,8 +44,8 @@ def _build_server_edit_view(server):
<b>Информация:</b>
ID: {server.id}
UUID: <code>{server.squad_uuid}</code>
Название: {server.display_name}
Оригинальное: {server.original_name or 'Не указано'}
Название: {html.escape(server.display_name)}
Оригинальное: {html.escape(server.original_name) if server.original_name else 'Не указано'}
Статус: {status_emoji}
<b>Настройки:</b>
@@ -172,7 +172,7 @@ async def show_servers_list(callback: types.CallbackQuery, db_user: User, db: As
status_emoji = '' if server.is_available else ''
price_text = f'{int(server.price_rubles)}' if server.price_kopeks > 0 else 'Бесплатно'
text += f'{i}. {status_emoji} {server.display_name}\n'
text += f'{i}. {status_emoji} {html.escape(server.display_name)}\n'
text += f' 💰 Цена: {price_text}'
if server.max_users:
@@ -559,7 +559,7 @@ async def start_server_edit_name(callback: types.CallbackQuery, state: FSMContex
await callback.message.edit_text(
f'✏️ <b>Редактирование названия</b>\n\n'
f'Текущее название: <b>{server.display_name}</b>\n\n'
f'Текущее название: <b>{html.escape(server.display_name)}</b>\n\n'
f'Отправьте новое название для сервера:',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
@@ -621,7 +621,7 @@ async def delete_server_confirm(callback: types.CallbackQuery, db_user: User, db
🗑 <b>Удаление сервера</b>
Вы действительно хотите удалить сервер:
<b>{server.display_name}</b>
<b>{html.escape(server.display_name)}</b>
<b>Внимание!</b>
Сервер можно удалить только если к нему нет активных подключений.
@@ -658,7 +658,7 @@ async def delete_server_execute(callback: types.CallbackQuery, db_user: User, db
await cache.delete_pattern('available_countries*')
await callback.message.edit_text(
f'✅ Сервер <b>{server.display_name}</b> успешно удален!',
f'✅ Сервер <b>{html.escape(server.display_name)}</b> успешно удален!',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='📋 К списку серверов', callback_data='admin_servers_list')]
@@ -668,7 +668,7 @@ async def delete_server_execute(callback: types.CallbackQuery, db_user: User, db
)
else:
await callback.message.edit_text(
f'❌ Не удалось удалить сервер <b>{server.display_name}</b>\n\nВозможно, к нему есть активные подключения.',
f'❌ Не удалось удалить сервер <b>{html.escape(server.display_name)}</b>\n\nВозможно, к нему есть активные подключения.',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='🔙 К серверу', callback_data=f'admin_server_edit_{server_id}')]
@@ -706,7 +706,7 @@ async def show_server_detailed_stats(callback: types.CallbackQuery, db_user: Use
for i, server in enumerate(sorted_servers[:5], 1):
price_text = f'{int(server.price_rubles)}' if server.price_kopeks > 0 else 'Бесплатно'
text += f'{i}. {server.display_name} - {price_text}\n'
text += f'{i}. {html.escape(server.display_name)} - {price_text}\n'
if not sorted_servers:
text += 'Нет доступных серверов\n'
@@ -968,7 +968,7 @@ async def start_server_edit_promo_groups(
text = (
'🎯 <b>Настройка промогрупп</b>\n\n'
f'Сервер: <b>{server.display_name}</b>\n\n'
f'Сервер: <b>{html.escape(server.display_name)}</b>\n\n'
'Выберите промогруппы, которым будет доступен этот сервер.\n'
'Должна быть выбрана минимум одна промогруппа.'
)
+21 -50
View File
@@ -22,6 +22,7 @@ from app.database.models import Tariff, User
from app.localization.texts import get_texts
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
from app.utils.formatting import format_period, format_price_kopeks, format_traffic
logger = structlog.get_logger(__name__)
@@ -29,34 +30,6 @@ logger = structlog.get_logger(__name__)
ITEMS_PER_PAGE = 10
def _format_traffic(gb: int) -> str:
"""Форматирует трафик."""
if gb == 0:
return 'Безлимит'
return f'{gb} ГБ'
def _format_price_kopeks(kopeks: int) -> str:
"""Форматирует цену из копеек в рубли."""
rubles = kopeks / 100
if rubles == int(rubles):
return f'{int(rubles)}'
return f'{rubles:.2f}'
def _format_period(days: int) -> str:
"""Форматирует период."""
if days == 1:
return '1 день'
if days < 5:
return f'{days} дня'
if days < 21 or days % 10 >= 5 or days % 10 == 0:
return f'{days} дней'
if days % 10 == 1:
return f'{days} день'
return f'{days} дня'
def _parse_period_prices(text: str) -> dict[str, int]:
"""
Парсит строку с ценами периодов.
@@ -94,7 +67,7 @@ def _format_period_prices_display(prices: dict[str, int]) -> str:
for period_str in sorted(prices.keys(), key=int):
period = int(period_str)
price = prices[period_str]
lines.append(f'{_format_period(period)}: {_format_price_kopeks(price)}')
lines.append(f'{format_period(period)}: {format_price_kopeks(price)}')
return '\n'.join(lines)
@@ -278,7 +251,7 @@ def _format_traffic_topup_packages(tariff: Tariff) -> str:
lines = ['✅ Включено']
for gb in sorted(packages.keys()):
price = packages[gb]
lines.append(f'{gb} ГБ: {_format_price_kopeks(price)}')
lines.append(f'{gb} ГБ: {format_price_kopeks(price)}')
return '\n'.join(lines)
@@ -288,7 +261,7 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st
get_texts(language)
status = '✅ Активен' if tariff.is_active else '❌ Неактивен'
traffic = _format_traffic(tariff.traffic_limit_gb)
traffic = format_traffic(tariff.traffic_limit_gb)
prices_display = _format_period_prices_display(tariff.period_prices or {})
# Форматируем список серверов
@@ -314,7 +287,7 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st
# Форматируем цену за устройство
device_price = getattr(tariff, 'device_price_kopeks', None)
if device_price is not None and device_price > 0:
device_price_display = _format_price_kopeks(device_price) + '/мес'
device_price_display = format_price_kopeks(device_price) + '/мес'
else:
device_price_display = 'Недоступно'
@@ -338,7 +311,7 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st
# Формируем блок цен в зависимости от типа тарифа
if is_daily:
price_block = f'<b>💰 Суточная цена:</b> {_format_price_kopeks(daily_price_kopeks)}/день'
price_block = f'<b>💰 Суточная цена:</b> {format_price_kopeks(daily_price_kopeks)}/день'
tariff_type = '🔄 Суточный'
else:
price_block = f'<b>Цены:</b>\n{prices_display}'
@@ -619,7 +592,7 @@ async def start_edit_daily_price(
await callback.message.edit_text(
f'💰 <b>Редактирование суточной цены</b>\n\n'
f'Тариф: {tariff.name}\n'
f'Текущая цена: {_format_price_kopeks(current_price)}/день\n\n'
f'Текущая цена: {format_price_kopeks(current_price)}/день\n\n'
'Введите новую цену за день в рублях.\n'
'Пример: <code>50</code> или <code>99.90</code>',
reply_markup=InlineKeyboardMarkup(
@@ -698,7 +671,7 @@ async def process_daily_price_input(
subs_count = await get_tariff_subscriptions_count(db, tariff_id)
await message.answer(
f'✅ Суточная цена установлена: {_format_price_kopeks(price_kopeks)}/день\n\n'
f'✅ Суточная цена установлена: {format_price_kopeks(price_kopeks)}/день\n\n'
+ format_tariff_info(tariff, db_user.language, subs_count),
reply_markup=get_tariff_view_keyboard(tariff, db_user.language),
parse_mode='HTML',
@@ -793,7 +766,7 @@ async def process_tariff_traffic(
await state.update_data(tariff_traffic=traffic)
await state.set_state(AdminStates.creating_tariff_devices)
traffic_display = _format_traffic(traffic)
traffic_display = format_traffic(traffic)
await message.answer(
'📦 <b>Создание тарифа</b>\n\n'
@@ -831,7 +804,7 @@ async def process_tariff_devices(
await state.update_data(tariff_devices=devices)
await state.set_state(AdminStates.creating_tariff_tier)
traffic_display = _format_traffic(data['tariff_traffic'])
traffic_display = format_traffic(data['tariff_traffic'])
await message.answer(
'📦 <b>Создание тарифа</b>\n\n'
@@ -871,7 +844,7 @@ async def process_tariff_tier(
data = await state.get_data()
await state.update_data(tariff_tier=tier)
traffic_display = _format_traffic(data['tariff_traffic'])
traffic_display = format_traffic(data['tariff_traffic'])
# Шаг 5/6: Выбор типа тарифа
await message.answer(
@@ -907,7 +880,7 @@ async def select_tariff_type_periodic(
await state.update_data(tariff_is_daily=False)
await state.set_state(AdminStates.creating_tariff_prices)
traffic_display = _format_traffic(data['tariff_traffic'])
traffic_display = format_traffic(data['tariff_traffic'])
await callback.message.edit_text(
'📦 <b>Создание тарифа</b>\n\n'
@@ -945,7 +918,7 @@ async def select_tariff_type_daily(
await state.update_data(tariff_is_daily=True)
await state.set_state(AdminStates.editing_tariff_daily_price)
traffic_display = _format_traffic(data['tariff_traffic'])
traffic_display = format_traffic(data['tariff_traffic'])
await callback.message.edit_text(
'📦 <b>Создание суточного тарифа</b>\n\n'
@@ -989,7 +962,7 @@ async def process_tariff_prices(
data = await state.get_data()
await state.update_data(tariff_prices=prices)
_format_traffic(data['tariff_traffic'])
format_traffic(data['tariff_traffic'])
_format_period_prices_display(prices)
# Создаем тариф
@@ -1170,7 +1143,7 @@ async def start_edit_tariff_traffic(
await state.set_state(AdminStates.editing_tariff_traffic)
await state.update_data(tariff_id=tariff_id, language=db_user.language)
current_traffic = _format_traffic(tariff.traffic_limit_gb)
current_traffic = format_traffic(tariff.traffic_limit_gb)
await callback.message.edit_text(
f'📊 <b>Редактирование трафика</b>\n\n'
@@ -1462,7 +1435,7 @@ async def start_edit_tariff_device_price(
device_price = getattr(tariff, 'device_price_kopeks', None)
if device_price is not None and device_price > 0:
current_price = _format_price_kopeks(device_price) + '/мес'
current_price = format_price_kopeks(device_price) + '/мес'
else:
current_price = 'Недоступно (докупка устройств запрещена)'
@@ -1782,7 +1755,7 @@ async def start_edit_tariff_traffic_topup(
status = '✅ Включено'
if packages:
packages_display = '\n'.join(
f'{gb} ГБ: {_format_price_kopeks(price)}' for gb, price in sorted(packages.items())
f'{gb} ГБ: {format_price_kopeks(price)}' for gb, price in sorted(packages.items())
)
else:
packages_display = ' Пакеты не настроены'
@@ -1871,7 +1844,7 @@ async def toggle_tariff_traffic_topup(
status = '✅ Включено'
if packages:
packages_display = '\n'.join(
f'{gb} ГБ: {_format_price_kopeks(price)}' for gb, price in sorted(packages.items())
f'{gb} ГБ: {format_price_kopeks(price)}' for gb, price in sorted(packages.items())
)
else:
packages_display = ' Пакеты не настроены'
@@ -1951,7 +1924,7 @@ async def start_edit_traffic_topup_packages(
if packages:
packages_display = '\n'.join(
f'{gb} ГБ: {_format_price_kopeks(price)}' for gb, price in sorted(packages.items())
f'{gb} ГБ: {format_price_kopeks(price)}' for gb, price in sorted(packages.items())
)
else:
packages_display = ' Не настроены'
@@ -2020,9 +1993,7 @@ async def process_edit_traffic_topup_packages(
# Показываем обновленное меню
texts = get_texts(db_user.language)
packages_display = '\n'.join(
f'{gb} ГБ: {_format_price_kopeks(price)}' for gb, price in sorted(packages.items())
)
packages_display = '\n'.join(f'{gb} ГБ: {format_price_kopeks(price)}' for gb, price in sorted(packages.items()))
max_topup_traffic = getattr(tariff, 'max_topup_traffic_gb', 0) or 0
max_limit_display = f'{max_topup_traffic} ГБ' if max_topup_traffic > 0 else 'Без ограничений'
@@ -2136,7 +2107,7 @@ async def process_edit_max_topup_traffic(
packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}
if packages:
packages_display = '\n'.join(
f'{gb} ГБ: {_format_price_kopeks(price)}' for gb, price in sorted(packages.items())
f'{gb} ГБ: {format_price_kopeks(price)}' for gb, price in sorted(packages.items())
)
else:
packages_display = ' Пакеты не настроены'
+88 -20
View File
@@ -1,3 +1,4 @@
import html
import re
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
@@ -868,7 +869,7 @@ async def _render_user_subscription_overview(callback: types.CallbackQuery, db:
try:
server = await get_server_squad_by_uuid(db, squad_uuid)
if server:
text += f'{server.display_name}\n'
text += f'{html.escape(server.display_name)}\n'
else:
text += f'{squad_uuid[:8]}... (неизвестный)\n'
except Exception as e:
@@ -1209,7 +1210,7 @@ async def show_user_management(callback: types.CallbackQuery, db_user: User, db:
end_date=format_datetime(subscription.end_date),
traffic=traffic_usage,
devices=subscription.device_limit,
countries=len(subscription.connected_squads),
countries=len(subscription.connected_squads or []),
)
)
else:
@@ -2711,7 +2712,7 @@ async def show_user_statistics(callback: types.CallbackQuery, db_user: User, db:
text += f'• Статус: {sub_status}{sub_type}\n'
text += f'• Трафик: {subscription.traffic_used_gb:.1f}/{subscription.traffic_limit_gb} ГБ\n'
text += f'• Устройства: {subscription.device_limit}\n'
text += f'• Стран: {len(subscription.connected_squads)}\n'
text += f'• Стран: {len(subscription.connected_squads or [])}\n'
else:
text += '• Отсутствует\n'
@@ -4002,12 +4003,20 @@ async def _add_subscription_traffic(db: AsyncSession, user_id: int, gb: int, adm
else:
await add_subscription_traffic(db, subscription, gb)
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
await reactivate_subscription(db, subscription)
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
if subscription.status == 'active':
from app.database.crud.user import get_user_by_id
user = await get_user_by_id(db, user_id)
if user and user.remnawave_uuid:
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
traffic_text = 'безлимитный' if gb == 0 else f'{gb} ГБ'
logger.info('Админ добавил трафик пользователю', admin_id=admin_id, traffic_text=traffic_text, user_id=user_id)
return True
@@ -4164,8 +4173,7 @@ async def _calculate_subscription_period_price(
subscription_service: SubscriptionService | None = None,
) -> int:
"""Рассчитывает стоимость подписки для администратора с учётом всех параметров."""
service = subscription_service or SubscriptionService()
from app.services.pricing_engine import pricing_engine
# Загружаем тариф для корректного расчёта в тарифном режиме
if subscription.tariff_id:
@@ -4174,13 +4182,13 @@ async def _calculate_subscription_period_price(
except Exception as e:
logger.warning('Не удалось загрузить тариф для расчёта цены', error=e)
return await service.calculate_renewal_price(
subscription=subscription,
period_days=period_days,
db=db,
pricing = await pricing_engine.calculate_renewal_price(
db,
subscription,
period_days,
user=target_user,
promo_group=getattr(target_user, 'promo_group', None),
)
return pricing.final_total
@admin_required
@@ -4449,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,
@@ -4577,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:
@@ -4906,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)
@@ -4925,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
@@ -4934,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
@@ -5309,9 +5361,24 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us
try:
old_tariff_id = subscription.tariff_id
# Обновляем параметры подписки в соответствии с тарифом
# Preserve extra purchased devices above the old tariff's base limit
extra_devices = 0
if subscription.tariff_id:
old_tariff = await get_tariff_by_id(db, subscription.tariff_id)
if old_tariff and old_tariff.device_limit:
extra_devices = max(0, (subscription.device_limit or old_tariff.device_limit) - old_tariff.device_limit)
subscription.tariff_id = tariff.id
subscription.device_limit = tariff.device_limit
new_base = tariff.device_limit or 1
new_total = new_base + extra_devices
effective_max = tariff.max_device_limit or (
settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
)
if effective_max and new_total > effective_max:
new_total = effective_max
subscription.device_limit = new_total
subscription.traffic_limit_gb = tariff.traffic_limit_gb
subscription.connected_squads = tariff.allowed_squads or []
subscription.updated_at = datetime.now(UTC)
@@ -5350,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(
@@ -5364,7 +5432,7 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us
await callback.message.edit_text(
f'✅ <b>Тариф успешно изменен</b>\n\n'
f'Новый тариф: <b>{tariff.name}</b>\n'
f'• Устройства: {tariff.device_limit}\n'
f'• Устройства: {subscription.device_limit}\n'
f'• Трафик: {"♾️" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ"}\n'
f'• Серверы: {len(tariff.allowed_squads) if tariff.allowed_squads else 0}',
reply_markup=types.InlineKeyboardMarkup(
+4
View File
@@ -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
@@ -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
+10 -4
View File
@@ -133,11 +133,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 +156,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)
+2
View File
@@ -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
+4 -2
View File
@@ -129,11 +129,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)
+172 -107
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 and quick_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,68 @@ async def start_kassa_ai_topup(
)
async def _process_kassa_ai_sub_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
payment_method: str,
):
"""Generic quick amount handler for any KassaAI sub-method."""
cfg = _KASSA_AI_METHOD_CONFIG[payment_method]
texts = get_texts(db_user.language)
if not cfg['is_enabled']():
await callback.answer(texts.t('KASSA_AI_NOT_AVAILABLE', cfg['unavailable_text']), show_alert=True)
return
try:
parts = callback.data.split('|')
amount_kopeks = int(parts[2]) if len(parts) >= 3 else None
if amount_kopeks is None:
raise ValueError
except (ValueError, IndexError):
await callback.answer('Invalid amount', show_alert=True)
return
if await _check_topup_restriction(callback, db_user):
return
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,
payment_method=payment_method,
)
# --- 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 +344,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,6 +372,7 @@ async def process_kassa_ai_custom_amount(
db=db,
amount_kopeks=amount_kopeks,
state=state,
payment_method=pm,
)
@@ -295,72 +383,49 @@ async def process_kassa_ai_quick_amount(
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)
"""Process quick amount selection for KassaAI payment."""
await _process_kassa_ai_sub_quick_amount(callback, db_user, db, state, 'kassa_ai')
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
@error_handler
async def start_kassa_ai_sbp_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Start KassaAI SBP top-up process."""
await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai_sbp')
# Проверка ограничения на пополнение
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
@error_handler
async def process_kassa_ai_sbp_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Process quick amount for KassaAI SBP."""
await _process_kassa_ai_sub_quick_amount(callback, db_user, db, state, 'kassa_ai_sbp')
# 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
@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')
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 process_kassa_ai_card_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Process quick amount for KassaAI Card."""
await _process_kassa_ai_sub_quick_amount(callback, db_user, db, state, 'kassa_ai_card')
+87 -256
View File
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.transaction import get_user_transactions
from app.database.models import TransactionType, User
from app.handlers.subscription.autopay import handle_confirm_unlink, handle_saved_cards_list, handle_unlink_card
from app.keyboards.inline import (
get_back_keyboard,
get_balance_keyboard,
@@ -17,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__)
@@ -131,11 +131,13 @@ 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 == 'riopay':
@@ -152,7 +154,8 @@ 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.
Uses PricingEngine as the single source of truth for all price calculations,
including base period price, devices, servers, traffic, and per-category discounts.
Args:
language: User's language for formatting
@@ -164,135 +167,77 @@ async def get_quick_amount_buttons(language: str, user: User) -> list:
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
from app.services.pricing_engine import pricing_engine
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
buttons = []
async with AsyncSessionLocal() as db:
subscription = await get_subscription_by_user_id(db, user.id)
# В режиме тарифов получаем цены из тарифа пользователя
tariff = None
tariff_periods = None
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)
tariff = subscription.tariff
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())
tariff_periods = sorted(int(k) for k in tariff.period_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]
if tariff_periods:
periods = tariff_periods[:6]
else:
base_price_kopeks = PERIOD_PRICES.get(period, 0)
periods = settings.get_available_subscription_periods()[:6]
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}'
for period in periods:
try:
if tariff and tariff_periods and period in tariff_periods:
result = await pricing_engine.calculate_tariff_purchase_price(
tariff,
period,
device_limit=subscription.device_limit if subscription else None,
user=user,
)
elif subscription:
result = await pricing_engine.calculate_renewal_price(db, subscription, period, user=user)
else:
result = await pricing_engine.calculate_classic_new_subscription_price(
db,
period,
[],
0,
settings.DEFAULT_DEVICE_LIMIT,
user=user,
)
total_price = result.final_total
original_total = result.original_total
if total_price <= 0:
continue
callback_data = f'quick_amount_{total_price}'
period_label = f'{period} дней'
has_discount = original_total > total_price and original_total > 0
if has_discount:
discount_pct = round((original_total - total_price) * 100 / original_total)
if discount_pct > 0:
button_text = (
f'{texts.format_price(original_total)}'
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}'
else:
button_text = f'{texts.format_price(total_price)}{period_label}'
buttons.append(types.InlineKeyboardButton(text=button_text, callback_data=callback_data))
buttons.append(types.InlineKeyboardButton(text=button_text, callback_data=callback_data))
except Exception:
logger.warning('Failed to calculate price for period', period=period)
continue
keyboard_rows = []
for i in range(0, len(buttons), 2):
@@ -404,10 +349,7 @@ async def handle_balance_history_pagination(callback: types.CallbackQuery, db_us
@error_handler
async def show_payment_methods(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
from app.config import settings
from app.database.crud.subscription import get_subscription_by_user_id
from app.services.subscription_service import SubscriptionService
from app.utils.payment_utils import get_payment_methods_text
from app.utils.pricing_utils import apply_percentage_discount, calculate_months_from_days
texts = get_texts(db_user.language)
@@ -430,139 +372,7 @@ async def show_payment_methods(callback: types.CallbackQuery, db_user: User, db:
payment_text = get_payment_methods_text(db_user.language)
# Добавляем информацию о текущем тарифе пользователя
subscription = await get_subscription_by_user_id(db, db_user.id)
tariff_info = ''
if subscription and not subscription.is_trial:
# Рассчитываем приблизительную стоимость продления на 30 дней
duration_days = 30 # Берем для примера 30 дней
current_traffic = subscription.traffic_limit_gb
current_connected_squads = subscription.connected_squads or []
current_device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
try:
# Получаем цены для текущих параметров
from app.config import PERIOD_PRICES
from app.database.crud.tariff import get_tariff_by_id
# В режиме тарифов берём цену из тарифа пользователя
tariff = None
tariff_price_found = False
base_price_original = 0
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_price_original = tariff.period_prices.get(str(duration_days), 0)
if base_price_original > 0:
tariff_price_found = True
# Если не нашли в тарифе - используем PERIOD_PRICES
if base_price_original <= 0:
base_price_original = PERIOD_PRICES.get(duration_days, 0)
if tariff_price_found:
# Тарифный режим: серверы и трафик включены в цену тарифа.
# Порядок: база + устройства → скидка на полную сумму (как в calculate_renewal_price).
from app.utils.promo_offer import get_user_active_promo_discount_percent
original_price = base_price_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
)
months_in_period = calculate_months_from_days(duration_days)
devices_price = extra_devices * device_price_per_unit * months_in_period
original_price += devices_price
# Скидка промогруппы на полную сумму (база + устройства)
period_discount_percent = db_user.get_promo_discount('period', duration_days)
discount_total = original_price * period_discount_percent // 100
total_price = original_price - discount_total
# Promo-offer скидка (временная)
promo_offer_percent = get_user_active_promo_discount_percent(db_user)
if promo_offer_percent > 0:
promo_offer_discount = total_price * promo_offer_percent // 100
total_price = total_price - promo_offer_discount
else:
# Классический режим: серверы + трафик + устройства считаются отдельно
period_discount_percent = db_user.get_promo_discount('period', duration_days)
base_price, base_discount_total = apply_percentage_discount(
base_price_original,
period_discount_percent,
)
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
(
servers_price_per_month,
per_server_monthly_prices,
) = await subscription_service.get_countries_price_by_uuids(
current_connected_squads,
db,
promo_group_id=db_user.promo_group_id,
)
servers_discount_percent = db_user.get_promo_discount('servers', duration_days)
total_servers_price = 0
for server_price in per_server_monthly_prices:
discounted_per_month, discount_per_month = apply_percentage_discount(
server_price,
servers_discount_percent,
)
total_servers_price += discounted_per_month
traffic_price_per_month = settings.get_traffic_price(current_traffic)
traffic_discount_percent = db_user.get_promo_discount('traffic', duration_days)
traffic_discounted_per_month, traffic_discount_per_month = apply_percentage_discount(
traffic_price_per_month,
traffic_discount_percent,
)
additional_devices = max(0, (current_device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_discount_percent = db_user.get_promo_discount('devices', duration_days)
devices_discounted_per_month, devices_discount_per_month = apply_percentage_discount(
devices_price_per_month,
devices_discount_percent,
)
months_in_period = calculate_months_from_days(duration_days)
total_price = (
base_price
+ total_servers_price * months_in_period
+ traffic_discounted_per_month * months_in_period
+ devices_discounted_per_month * months_in_period
)
traffic_value = current_traffic or 0
if traffic_value <= 0:
traffic_display = texts.t('TRAFFIC_UNLIMITED_SHORT', 'Безлимит')
else:
traffic_display = texts.format_traffic(traffic_value)
current_tariff_desc = (
f'📱 Подписка: {len(current_connected_squads)} серверов, '
f'{traffic_display}, {current_device_limit} устр.'
)
estimated_price_info = (
f'💰 Стоимость продления (примерно): {texts.format_price(total_price)} за {duration_days} дней'
)
tariff_info = f'\n\n📋 <b>Ваш текущий тариф:</b>\n{current_tariff_desc}\n{estimated_price_info}'
except Exception as e:
logger.warning(
'Не удалось рассчитать стоимость текущей подписки для пользователя', db_user_id=db_user.id, error=e
)
tariff_info = ''
full_text = payment_text + tariff_info
full_text = payment_text
keyboard = get_payment_methods_keyboard(0, db_user.language)
@@ -734,11 +544,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)
@@ -748,13 +560,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
@@ -982,10 +798,21 @@ def register_balance_handlers(dp: Dispatcher):
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 (
process_kassa_ai_card_quick_amount,
process_kassa_ai_quick_amount,
process_kassa_ai_sbp_quick_amount,
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(process_kassa_ai_sbp_quick_amount, F.data.startswith('topup_amount|kassa_ai_sbp|'))
dp.callback_query.register(start_kassa_ai_card_topup, F.data == 'topup_kassa_ai_card')
dp.callback_query.register(process_kassa_ai_card_quick_amount, F.data.startswith('topup_amount|kassa_ai_card|'))
from .riopay import process_riopay_quick_amount, start_riopay_topup
@@ -1014,3 +841,7 @@ def register_balance_handlers(dp: Dispatcher):
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')
dp.callback_query.register(handle_unlink_card, F.data.startswith('unlink_card_'))
dp.callback_query.register(handle_confirm_unlink, F.data.startswith('confirm_unlink_'))
+4 -2
View File
@@ -124,13 +124,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 -2
View File
@@ -359,12 +359,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 -2
View File
@@ -250,7 +250,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 +260,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
+2
View File
@@ -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
+4 -2
View File
@@ -120,7 +120,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 +130,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
+16 -4
View File
@@ -178,12 +178,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 +333,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:
+40 -16
View File
@@ -1089,6 +1089,9 @@ def _get_subscription_status(user: User, texts, is_daily_tariff: bool = False) -
if actual_status == 'disabled':
return texts.t('SUB_STATUS_DISABLED', '⚫ Отключена')
if actual_status == 'limited':
return texts.t('SUB_STATUS_LIMITED', '⚠️ Трафик исчерпан')
if actual_status == 'expired':
return texts.t(
'SUB_STATUS_EXPIRED',
@@ -1246,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
@@ -1284,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)
@@ -1294,35 +1299,50 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
# Найти максимальный период <= баланса
best_period = None
best_price = 0
best_pricing = None # Cache pricing result for reuse in finalize()
# PricingEngine — единый расчёт для всех поверхностей (и продление, и новая подписка).
from app.services.pricing_engine import pricing_engine
# Для продления используем тот же сервис, что и при реальном списании,
# чтобы сумма проверки совпадала с суммой списания.
renewal_service = SubscriptionRenewalService() if subscription else None
try:
for period in available_periods:
if subscription and renewal_service:
pricing = await renewal_service.calculate_pricing(db, db_user, subscription, period)
price = pricing.final_total
if subscription:
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
best_pricing = pricing_result if subscription else None
break
if not best_period:
# Показать сколько не хватает для минимального периода
min_period = min(available_periods) if available_periods else 30
if subscription and renewal_service:
pricing = await renewal_service.calculate_pricing(db, db_user, subscription, min_period)
min_price = pricing.final_total
if subscription:
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}'),
@@ -1336,8 +1356,10 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
try:
if subscription:
# Продление существующей подписки
pricing = await renewal_service.calculate_pricing(db, db_user, subscription, best_period)
# Продление существующей подписки (reuse cached pricing from loop above)
if best_pricing is None:
raise ValueError('best_pricing is None despite best_period being set')
pricing = best_pricing
await renewal_service.finalize(
db,
@@ -1357,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)
+20 -6
View File
@@ -1,3 +1,4 @@
import hashlib
import json
from pathlib import Path
@@ -37,10 +38,14 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
texts = get_texts(db_user.language)
if not db_user.referral_code:
await callback.answer(texts.t('REFERRAL_CODE_NOT_ASSIGNED', 'Реферальный код не назначен'), show_alert=True)
return
summary = await get_user_referral_summary(db, db_user.id)
bot_username = (await callback.bot.get_me()).username
referral_link = f'https://t.me/{bot_username}?start={db_user.referral_code}'
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
referral_text = (
texts.t('REFERRAL_PROGRAM_TITLE', '👥 <b>Реферальная программа</b>')
@@ -229,17 +234,22 @@ async def show_referral_qr(
callback: types.CallbackQuery,
db_user: User,
):
await callback.answer()
texts = get_texts(db_user.language)
if not db_user.referral_code:
await callback.answer(texts.t('REFERRAL_CODE_NOT_ASSIGNED', 'Реферальный код не назначен'), show_alert=True)
return
await callback.answer()
bot_username = (await callback.bot.get_me()).username
referral_link = f'https://t.me/{bot_username}?start={db_user.referral_code}'
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
qr_dir = Path('data') / 'referral_qr'
qr_dir.mkdir(parents=True, exist_ok=True)
file_path = qr_dir / f'{db_user.id}.png'
link_hash = hashlib.md5(referral_link.encode()).hexdigest()[:8]
file_path = qr_dir / f'{db_user.id}_{link_hash}.png'
if not file_path.exists():
img = qrcode.make(referral_link)
img.save(file_path)
@@ -470,8 +480,12 @@ async def show_referral_analytics(callback: types.CallbackQuery, db_user: User,
async def create_invite_message(callback: types.CallbackQuery, db_user: User):
texts = get_texts(db_user.language)
if not db_user.referral_code:
await callback.answer(texts.t('REFERRAL_CODE_NOT_ASSIGNED', 'Реферальный код не назначен'), show_alert=True)
return
bot_username = (await callback.bot.get_me()).username
referral_link = f'https://t.me/{bot_username}?start={db_user.referral_code}'
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
invite_text = texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!')
+2 -1
View File
@@ -1,3 +1,4 @@
import html
from datetime import UTC, datetime
import structlog
@@ -189,7 +190,7 @@ def _format_server_lines(
else:
latency_text = texts.t('SERVER_STATUS_OFFLINE', 'нет ответа')
name = server.display_name or server.name
name = html.escape(server.display_name or server.name)
flag_prefix = f'{server.flag} ' if server.flag else ''
server_line = f'{flag_prefix}{name}{latency_text}'
lines.append(f'<blockquote>{server_line}</blockquote>')
+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,
)
+97 -1
View File
@@ -285,6 +285,90 @@ async def _handle_trial_payment(
return False
_PURCHASE_TOKEN_RE = __import__('re').compile(r'^[A-Za-z0-9_\-]{10,100}$')
async def _handle_guest_purchase_payment(
message: types.Message,
db: AsyncSession,
user,
stars_amount: int,
payload: str,
telegram_payment_charge_id: str,
):
"""Обработка Stars платежа для гостевой покупки (подарочная подписка из кабинета)."""
from app.database.crud.landing import get_purchase_by_token
from app.services.payment.common import try_fulfill_guest_purchase
try:
purchase_token = payload[len('guest_purchase_') :]
if not purchase_token or not _PURCHASE_TOKEN_RE.match(purchase_token):
logger.error('Invalid purchase_token format in guest_purchase payload', payload=payload)
await message.answer('❌ Ошибка: неверный формат платежа.')
return
# Verify Stars amount matches expected price (±5% tolerance for conversion rounding)
existing = await get_purchase_by_token(db, purchase_token)
if existing and existing.amount_kopeks:
expected_stars = max(1, settings.rubles_to_stars(existing.amount_kopeks / 100))
tolerance = max(1, round(expected_stars * 0.05))
if abs(stars_amount - expected_stars) > tolerance:
logger.error(
'Stars amount mismatch for guest purchase',
paid_stars=stars_amount,
expected_stars=expected_stars,
purchase_token_prefix=purchase_token[:5],
)
await message.answer('❌ Сумма оплаты не совпадает с ожидаемой.')
return
# Calculate kopeks from stars
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(stars_amount)
amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP))
# Build metadata matching what other providers use
metadata = {
'purpose': 'guest_purchase',
'purchase_token': purchase_token,
}
result = await try_fulfill_guest_purchase(
db,
metadata=metadata,
payment_amount_kopeks=amount_kopeks,
provider_payment_id=telegram_payment_charge_id,
provider_name='telegram_stars',
skip_amount_check=True,
)
if result is True:
await message.answer(
'🎁 <b>Подарочная подписка успешно оплачена!</b>\n\n'
f'⭐ Потрачено: {stars_amount} Stars\n\n'
'Подарок будет доставлен получателю.',
parse_mode='HTML',
)
logger.info(
'✅ Guest purchase fulfilled via Stars',
user_id=user.id,
stars_amount=stars_amount,
purchase_token_prefix=purchase_token[:5],
)
elif result is False:
await message.answer(
'❌ Произошла ошибка при обработке подарочной подписки. Обратитесь в поддержку.',
)
else:
logger.error('try_fulfill_guest_purchase returned None for Stars gift', payload=payload)
await message.answer('❌ Ошибка обработки платежа. Обратитесь в поддержку.')
except Exception as e:
logger.error('Error handling guest purchase Stars payment', error=e, exc_info=True)
await message.answer(
'❌ Произошла ошибка при обработке подарочной подписки. Обратитесь в поддержку.',
)
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
texts = get_texts(DEFAULT_LANGUAGE)
@@ -296,7 +380,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
invoice_payload=query.invoice_payload,
)
allowed_prefixes = ('balance_', 'admin_stars_test_', 'simple_sub_', 'wheel_spin_', 'trial_')
allowed_prefixes = ('balance_', 'admin_stars_test_', 'simple_sub_', 'wheel_spin_', 'trial_', 'guest_purchase_')
if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes):
logger.warning('Невалидный payload', invoice_payload=query.invoice_payload)
@@ -402,6 +486,18 @@ async def handle_successful_payment(message: types.Message, db: AsyncSession, st
)
return
# Обработка оплаты гостевой покупки (подарочная подписка из кабинета)
if payment.invoice_payload and payment.invoice_payload.startswith('guest_purchase_'):
await _handle_guest_purchase_payment(
message=message,
db=db,
user=user,
stars_amount=payment.total_amount,
payload=payment.invoice_payload,
telegram_payment_charge_id=payment.telegram_payment_charge_id,
)
return
payment_service = PaymentService(message.bot)
state_data = await state.get_data()
+225
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:
@@ -754,6 +881,13 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
await db.execute(delete(Transaction).where(Transaction.user_id == user.id))
if user.balance_kopeks > 0:
logger.warning(
'⚠️ DELETED-восстановление: обнуляем ненулевой баланс',
telegram_id=user.telegram_id,
balance_kopeks=user.balance_kopeks,
)
user.status = UserStatus.ACTIVE.value
user.balance_kopeks = 0
user.remnawave_uuid = None
@@ -1179,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()
@@ -1344,6 +1482,13 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
# Prevent self-referral when partner re-registers via own campaign link
safe_referrer_id = referrer_id if referrer_id != existing_user.id else None
if existing_user.balance_kopeks > 0:
logger.warning(
'⚠️ DELETED-восстановление: обнуляем ненулевой баланс',
telegram_id=existing_user.telegram_id,
balance_kopeks=existing_user.balance_kopeks,
)
existing_user.username = callback.from_user.username
existing_user.first_name = callback.from_user.first_name
existing_user.last_name = callback.from_user.last_name
@@ -1639,6 +1784,13 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
# Prevent self-referral when partner re-registers via own campaign link
safe_referrer_id = referrer_id if referrer_id != existing_user.id else None
if existing_user.balance_kopeks > 0:
logger.warning(
'⚠️ DELETED-восстановление: обнуляем ненулевой баланс',
telegram_id=existing_user.telegram_id,
balance_kopeks=existing_user.balance_kopeks,
)
existing_user.username = message.from_user.username
existing_user.first_name = message.from_user.first_name
existing_user.last_name = message.from_user.last_name
@@ -1897,6 +2049,9 @@ def _get_subscription_status(user, texts):
if actual_status == 'disabled':
return texts.t('SUB_STATUS_DISABLED', '⚫ Отключена')
if actual_status == 'limited':
return texts.t('SUB_STATUS_LIMITED', '⚠️ Трафик исчерпан')
if actual_status == 'pending':
return texts.t('SUB_STATUS_PENDING', '⏳ Ожидает активации')
@@ -2317,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)
@@ -2415,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 ===')
@@ -2459,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 ===')
+6
View File
@@ -2,8 +2,11 @@
from .autopay import (
handle_autopay_menu,
handle_confirm_unlink,
handle_saved_cards_list,
handle_subscription_cancel,
handle_subscription_config_back,
handle_unlink_card,
set_autopay_days,
show_autopay_days,
toggle_autopay,
@@ -157,6 +160,7 @@ __all__ = [
'handle_app_selection',
'handle_autopay_menu',
'handle_change_devices',
'handle_confirm_unlink',
'handle_connect_subscription',
'handle_device_guide',
'handle_device_management',
@@ -172,12 +176,14 @@ __all__ = [
'handle_promo_offer_close',
'handle_reset_devices',
'handle_reset_traffic',
'handle_saved_cards_list',
'handle_single_device_reset',
'handle_specific_app_guide',
'handle_subscription_cancel',
'handle_subscription_config_back',
'handle_subscription_settings',
'handle_switch_traffic',
'handle_unlink_card',
'invalidate_app_config_cache',
'load_app_config_async',
'normalize_app',
+98 -1
View File
@@ -1,15 +1,23 @@
from aiogram import types
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.saved_payment_method import (
deactivate_payment_method,
get_active_payment_methods_by_user,
)
from app.database.crud.subscription import update_subscription_autopay
from app.database.models import User
from app.keyboards.inline import (
_get_payment_method_display_name,
get_autopay_days_keyboard,
get_autopay_keyboard,
get_confirm_unlink_keyboard,
get_countries_keyboard,
get_devices_keyboard,
get_saved_cards_keyboard,
get_subscription_period_keyboard,
get_traffic_packages_keyboard,
)
@@ -107,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):
@@ -134,6 +148,89 @@ async def set_autopay_days(callback: types.CallbackQuery, db_user: User, db: Asy
await handle_autopay_menu(callback, db_user, db)
async def handle_saved_cards_list(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
texts = get_texts(db_user.language)
cards = await get_active_payment_methods_by_user(db, db_user.id)
if not cards:
await callback.message.edit_text(
texts.t(
'SAVED_CARDS_EMPTY',
'💳 <b>Привязанные карты</b>\n\nНет привязанных карт.\n'
'Карта привяжется автоматически при следующем пополнении баланса.',
),
reply_markup=get_saved_cards_keyboard([], db_user.language),
parse_mode='HTML',
)
else:
await callback.message.edit_text(
texts.t(
'SAVED_CARDS_TITLE',
'💳 <b>Привязанные карты</b>\n\nВыберите карту для отвязки:',
),
reply_markup=get_saved_cards_keyboard(cards, db_user.language),
parse_mode='HTML',
)
await callback.answer()
async def handle_unlink_card(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
texts = get_texts(db_user.language)
card_id = int(callback.data.split('_')[-1])
cards = await get_active_payment_methods_by_user(db, db_user.id)
card = next((c for c in cards if c.id == card_id), None)
if not card:
await callback.answer(
texts.t('SAVED_CARDS_UNLINK_ERROR', '❌ Не удалось отвязать карту'),
show_alert=True,
)
return
card_label = _get_payment_method_display_name(card, db_user.language)
text = texts.t(
'SAVED_CARDS_CONFIRM_UNLINK',
'Вы уверены, что хотите отвязать карту <b>{card}</b>?\n\n'
'После отвязки автоплатеж не сможет использовать эту карту.',
).format(card=card_label)
if len(cards) == 1:
text += texts.t(
'SAVED_CARDS_LAST_CARD_WARNING',
'\n\n⚠️ <b>Внимание:</b> это ваша последняя привязанная карта. '
'После отвязки автоплатеж не сможет списывать средства.',
)
await callback.message.edit_text(
text,
reply_markup=get_confirm_unlink_keyboard(card_id, db_user.language),
parse_mode='HTML',
)
await callback.answer()
async def handle_confirm_unlink(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
texts = get_texts(db_user.language)
card_id = int(callback.data.split('_')[-1])
success = await deactivate_payment_method(db, card_id, db_user.id)
if success:
await callback.answer(
texts.t('SAVED_CARDS_UNLINKED', '✅ Карта отвязана'),
)
else:
await callback.answer(
texts.t('SAVED_CARDS_UNLINK_ERROR', '❌ Не удалось отвязать карту'),
show_alert=True,
)
return
# Return to the updated cards list
await handle_saved_cards_list(callback, db_user, db)
async def handle_subscription_config_back(
callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession
):
-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)
+40 -50
View File
@@ -1,12 +1,13 @@
import html
from datetime import UTC, datetime
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,
@@ -16,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,
@@ -27,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
@@ -57,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,
@@ -66,7 +68,7 @@ async def handle_add_countries(callback: types.CallbackQuery, db_user: User, db:
current_countries_names = []
for country in countries:
if country['uuid'] in current_countries:
current_countries_names.append(country['name'])
current_countries_names.append(html.escape(country['name']))
current_list = (
'\n'.join(f'{name}' for name in current_countries_names)
@@ -170,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(
@@ -193,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,
@@ -234,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]
@@ -256,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,
@@ -391,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)
@@ -495,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(
@@ -659,8 +649,8 @@ def _build_countries_selection_text(countries: list[dict], base_text: str) -> st
continue
desc = country.get('description', '').strip()
if desc:
name = country.get('name', '')
descriptions.append(f'<b>{name}</b>\n{desc}')
name = html.escape(country.get('name', ''))
descriptions.append(f'<b>{name}</b>\n{html.escape(desc)}')
if not descriptions:
return base_text
@@ -699,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,
@@ -794,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]
@@ -807,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,
@@ -841,9 +831,9 @@ async def confirm_add_countries_to_subscription(
total_price += charged_price
total_discount_value += int(discount_per_month * charged_days / 30)
new_countries_names.append(country['name'])
new_countries_names.append(html.escape(country['name']))
if country['uuid'] in removed_countries:
removed_countries_names.append(country['name'])
removed_countries_names.append(html.escape(country['name']))
if new_countries and db_user.balance_kopeks < total_price:
missing_kopeks = total_price - db_user.balance_kopeks
@@ -908,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)
+54 -12
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,
@@ -82,7 +82,7 @@ async def get_servers_display_names(squad_uuids: list[str]) -> str:
for uuid in squad_uuids:
server = await get_server_squad_by_uuid(db, uuid)
if server:
server_names.append(server.display_name)
server_names.append(html_mod.escape(server.display_name))
logger.debug('Найден сервер в БД', uuid=uuid, display_name=server.display_name)
else:
logger.warning('Сервер с UUID не найден в БД', uuid=uuid)
@@ -92,7 +92,7 @@ async def get_servers_display_names(squad_uuids: list[str]) -> str:
for uuid in squad_uuids:
for country in countries:
if country['uuid'] == uuid:
server_names.append(country['name'])
server_names.append(html_mod.escape(country['name']))
logger.debug('Найден сервер в кэше', uuid=uuid, country=country['name'])
break
@@ -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,7 +492,8 @@ 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
@@ -514,12 +515,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 +537,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(
@@ -606,7 +637,7 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
await db.commit()
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
from app.database.crud.subscription import reactivate_subscription
await reactivate_subscription(db, subscription)
@@ -614,6 +645,10 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
if db_user.remnawave_uuid and subscription.status == 'active':
await subscription_service.enable_remnawave_user(db_user.remnawave_uuid)
# При уменьшении лимита - удалить лишние устройства (последние подключённые)
devices_reset_count = 0
if new_devices_count < current_devices and db_user.remnawave_uuid:
@@ -1144,6 +1179,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)
@@ -1153,7 +1191,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,
@@ -1173,7 +1211,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,
@@ -1285,7 +1323,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
subscription.updated_at = datetime.now(UTC)
await db.commit()
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
from app.database.crud.subscription import reactivate_subscription
await reactivate_subscription(db, subscription)
@@ -1293,6 +1331,10 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
if db_user.remnawave_uuid and subscription.status == 'active':
await subscription_service.enable_remnawave_user(db_user.remnawave_uuid)
await create_transaction(
db=db,
user_id=db_user.id,
+106 -240
View File
@@ -1,20 +1,18 @@
import html
from datetime import UTC, datetime
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
@@ -24,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(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:
@@ -108,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
@@ -163,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 = 'Безлимитный'
@@ -191,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
@@ -211,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,
)
)
@@ -308,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
@@ -472,7 +338,7 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
days_left=max(0, subscription.days_left),
traffic_used=texts.format_traffic(subscription.traffic_used_gb, is_limit=False),
traffic_limit=traffic_text,
countries_count=len(subscription.connected_squads),
countries_count=len(subscription.connected_squads or []),
devices_used=devices_used,
devices_limit=subscription.device_limit,
autopay_status='✅ Включен' if subscription.autopay_enabled else '⌛ Выключен',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+55 -20
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} ГБ трафика',
}
@@ -577,12 +581,16 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
# add_subscription_traffic уже создаёт TrafficPurchase и обновляет все необходимые поля
await add_subscription_traffic(db, subscription, traffic_gb)
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
await reactivate_subscription(db, subscription)
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
if db_user.remnawave_uuid and subscription.status == 'active':
await subscription_service.enable_remnawave_user(db_user.remnawave_uuid)
await create_transaction(
db=db,
user_id=db_user.id,
@@ -615,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))
@@ -664,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,
@@ -718,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,
)
@@ -796,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(
@@ -834,12 +865,16 @@ async def execute_switch_traffic(callback: types.CallbackQuery, db_user: User, d
await db.commit()
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
await reactivate_subscription(db, subscription)
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
if db_user.remnawave_uuid and subscription.status == 'active':
await subscription_service.enable_remnawave_user(db_user.remnawave_uuid)
await db.refresh(db_user)
await db.refresh(subscription)
+106 -3
View File
@@ -1146,7 +1146,11 @@ def get_subscription_keyboard(
sub_status = getattr(subscription, 'status', None)
is_paused = getattr(subscription, 'is_daily_paused', False)
is_inactive = sub_status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value)
is_inactive = sub_status in (
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.LIMITED.value,
)
if is_inactive or is_paused:
# Подписка остановлена (системой или пользователем) — показываем «Возобновить»
@@ -1500,8 +1504,17 @@ def get_balance_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMark
InlineKeyboardButton(text=texts.BALANCE_HISTORY, callback_data='balance_history'),
InlineKeyboardButton(text=texts.BALANCE_TOP_UP, callback_data='balance_topup'),
],
[InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')],
]
if settings.YOOKASSA_RECURRENT_ENABLED:
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('SAVED_CARDS_BUTTON', '💳 Привязанные карты'),
callback_data='saved_cards_list',
)
]
)
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
@@ -1682,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(
[
@@ -1937,6 +1978,68 @@ def get_autopay_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMark
)
_PAYMENT_METHOD_LOCALE_KEYS: dict[str, tuple[str, str]] = {
'bank_card': ('PAYMENT_METHOD_BANK_CARD', '💳 Банковская карта'),
'yoo_money': ('PAYMENT_METHOD_YOO_MONEY', '🟣 ЮMoney'),
'sberbank': ('PAYMENT_METHOD_SBERBANK', '🟢 СберPay'),
'tinkoff_bank': ('PAYMENT_METHOD_TINKOFF_BANK', '🟡 Т-Банк'),
'sbp': ('PAYMENT_METHOD_SBP', '🏦 СБП'),
'mir_pay': ('PAYMENT_METHOD_MIR_PAY', '🟦 Mir Pay'),
}
def _get_payment_method_display_name(card, language: str = DEFAULT_LANGUAGE) -> str:
"""Локализованное название метода оплаты + реквизиты."""
texts = get_texts(language)
# Для банковских карт title уже содержит тип + маску (например "Visa *4444")
if card.method_type == 'bank_card' or (not card.method_type and card.card_last4):
if card.title:
return card.title
if card.card_last4:
return f'{card.card_type or "Card"} *{card.card_last4}'
# Для остальных методов: локализованное название + реквизиты из title
locale_entry = _PAYMENT_METHOD_LOCALE_KEYS.get(card.method_type)
if locale_entry:
key, default = locale_entry
method_name = texts.t(key, default)
else:
method_name = card.method_type or 'Card'
if card.title:
return f'{method_name} {card.title}'
return method_name
def get_saved_cards_keyboard(cards: list, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = []
for card in cards:
card_label = f'🗑 {_get_payment_method_display_name(card, language)}'
keyboard.append([InlineKeyboardButton(text=card_label, callback_data=f'unlink_card_{card.id}')])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_confirm_unlink_keyboard(card_id: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('SAVED_CARDS_CONFIRM_YES', '✅ Да, отвязать'),
callback_data=f'confirm_unlink_{card_id}',
),
InlineKeyboardButton(
text=texts.t('CANCEL', '❌ Отмена'),
callback_data='saved_cards_list',
),
]
]
)
def get_autopay_days_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = []
+14
View File
@@ -899,6 +899,20 @@
"AUTOPAY_TOGGLE_SUCCESS": "✅ Autopay {status}!",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>Auto-payment completed</b>\n\nBalance topped up by {amount} for subscription renewal.",
"RECURRENT_TOPUP_FAILED": "❌ <b>Auto-payment failed</b>\n\nCould not charge {amount} from any saved card for subscription renewal.\n\nPlease top up your balance manually to avoid service interruption.",
"SAVED_CARDS_BUTTON": "💳 Saved cards",
"SAVED_CARDS_TITLE": "💳 <b>Saved cards</b>\n\nSelect a card to unlink:",
"SAVED_CARDS_EMPTY": "💳 <b>Saved cards</b>\n\nNo saved cards.\nA card will be saved automatically on your next balance top-up.",
"SAVED_CARDS_CONFIRM_UNLINK": "Are you sure you want to unlink <b>{card}</b>?\n\nAfter unlinking, autopay won't be able to use this card.",
"SAVED_CARDS_LAST_CARD_WARNING": "\n\n⚠️ <b>Warning:</b> this is your last saved card. After unlinking, autopay won't be able to charge payments.",
"SAVED_CARDS_UNLINKED": "✅ Card unlinked",
"SAVED_CARDS_UNLINK_ERROR": "❌ Failed to unlink card",
"SAVED_CARDS_CONFIRM_YES": "✅ Yes, unlink",
"PAYMENT_METHOD_BANK_CARD": "💳 Bank card",
"PAYMENT_METHOD_YOO_MONEY": "🟣 YooMoney",
"PAYMENT_METHOD_SBERBANK": "🟢 SberPay",
"PAYMENT_METHOD_TINKOFF_BANK": "🟡 T-Bank",
"PAYMENT_METHOD_SBP": "🏦 SBP",
"PAYMENT_METHOD_MIR_PAY": "🟦 Mir Pay",
"BACK": "⬅️ Back",
"BACK_BUTTON": "◀️ Back",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu",
+14
View File
@@ -919,6 +919,20 @@
"AUTOPAY_TOGGLE_SUCCESS": "✅ پرداخت خودکار {status}!",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>پرداخت خودکار انجام شد</b>\n\nموجودی به مبلغ {amount} برای تمدید اشتراک شارژ شد.",
"RECURRENT_TOPUP_FAILED": "❌ <b>پرداخت خودکار ناموفق بود</b>\n\nامکان کسر {amount} از هیچ کارت ذخیره شده‌ای برای تمدید اشتراک وجود نداشت.\n\nلطفاً موجودی را به صورت دستی شارژ کنید.",
"SAVED_CARDS_BUTTON": "💳 کارت‌های ذخیره شده",
"SAVED_CARDS_TITLE": "💳 <b>کارت‌های ذخیره شده</b>\n\nکارت مورد نظر برای حذف را انتخاب کنید:",
"SAVED_CARDS_EMPTY": "💳 <b>کارت‌های ذخیره شده</b>\n\nکارت ذخیره شده‌ای وجود ندارد.\nکارت به صورت خودکار در شارژ بعدی موجودی ذخیره می‌شود.",
"SAVED_CARDS_CONFIRM_UNLINK": "آیا مطمئن هستید که می‌خواهید کارت <b>{card}</b> را حذف کنید؟\n\nپس از حذف، پرداخت خودکار نمی‌تواند از این کارت استفاده کند.",
"SAVED_CARDS_LAST_CARD_WARNING": "\n\n⚠️ <b>توجه:</b> این آخرین کارت ذخیره شده شماست. پس از حذف، پرداخت خودکار امکان برداشت وجه نخواهد داشت.",
"SAVED_CARDS_UNLINKED": "✅ کارت حذف شد",
"SAVED_CARDS_UNLINK_ERROR": "❌ حذف کارت انجام نشد",
"SAVED_CARDS_CONFIRM_YES": "✅ بله، حذف کن",
"PAYMENT_METHOD_BANK_CARD": "💳 کارت بانکی",
"PAYMENT_METHOD_YOO_MONEY": "🟣 ЮMoney",
"PAYMENT_METHOD_SBERBANK": "🟢 СберPay",
"PAYMENT_METHOD_TINKOFF_BANK": "🟡 Т-Банк",
"PAYMENT_METHOD_SBP": "🏦 СБП",
"PAYMENT_METHOD_MIR_PAY": "🟦 Mir Pay",
"BACK": "⬅️ قبلی",
"BACK_BUTTON": "◀️ بازگشت",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ منوی اصلی",
+14
View File
@@ -919,6 +919,20 @@
"AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатеж {status}!",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>Автоплатёж выполнен</b>\n\nБаланс пополнен на {amount} для продления подписки.",
"RECURRENT_TOPUP_FAILED": "❌ <b>Автоплатёж не удался</b>\n\nНе удалось списать {amount} ни с одной сохранённой карты для продления подписки.\n\nПополните баланс вручную, чтобы подписка не прервалась.",
"SAVED_CARDS_BUTTON": "💳 Привязанные карты",
"SAVED_CARDS_TITLE": "💳 <b>Привязанные карты</b>\n\nВыберите карту для отвязки:",
"SAVED_CARDS_EMPTY": "💳 <b>Привязанные карты</b>\n\nНет привязанных карт.\nКарта привяжется автоматически при следующем пополнении баланса.",
"SAVED_CARDS_CONFIRM_UNLINK": "Вы уверены, что хотите отвязать карту <b>{card}</b>?\n\nПосле отвязки автоплатеж не сможет использовать эту карту.",
"SAVED_CARDS_LAST_CARD_WARNING": "\n\n⚠️ <b>Внимание:</b> это ваша последняя привязанная карта. После отвязки автоплатеж не сможет списывать средства.",
"SAVED_CARDS_UNLINKED": "✅ Карта отвязана",
"SAVED_CARDS_UNLINK_ERROR": "❌ Не удалось отвязать карту",
"SAVED_CARDS_CONFIRM_YES": "✅ Да, отвязать",
"PAYMENT_METHOD_BANK_CARD": "💳 Банковская карта",
"PAYMENT_METHOD_YOO_MONEY": "🟣 ЮMoney",
"PAYMENT_METHOD_SBERBANK": "🟢 СберPay",
"PAYMENT_METHOD_TINKOFF_BANK": "🟡 Т-Банк",
"PAYMENT_METHOD_SBP": "🏦 СБП",
"PAYMENT_METHOD_MIR_PAY": "🟦 Mir Pay",
"BACK": "⬅️ Назад",
"BACK_BUTTON": "◀️ Назад",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню",
+14
View File
@@ -841,6 +841,20 @@
"AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатіж {status}!",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>Автоплатіж виконано</b>\n\nБаланс поповнено на {amount} для продовження підписки.",
"RECURRENT_TOPUP_FAILED": "❌ <b>Автоплатіж не вдався</b>\n\nНе вдалося списати {amount} з жодної збереженої картки для продовження підписки.\n\nПоповніть баланс вручну, щоб підписка не перервалася.",
"SAVED_CARDS_BUTTON": "💳 Прив'язані картки",
"SAVED_CARDS_TITLE": "💳 <b>Прив'язані картки</b>\n\nОберіть картку для відв'язки:",
"SAVED_CARDS_EMPTY": "💳 <b>Прив'язані картки</b>\n\nНемає прив'язаних карток.\nКартка прив'яжеться автоматично при наступному поповненні балансу.",
"SAVED_CARDS_CONFIRM_UNLINK": "Ви впевнені, що хочете відв'язати картку <b>{card}</b>?\n\nПісля відв'язки автоплатіж не зможе використовувати цю картку.",
"SAVED_CARDS_LAST_CARD_WARNING": "\n\n⚠️ <b>Увага:</b> це ваша остання прив'язана картка. Після відв'язки автоплатіж не зможе списувати кошти.",
"SAVED_CARDS_UNLINKED": "✅ Картку відв'язано",
"SAVED_CARDS_UNLINK_ERROR": "❌ Не вдалося відв'язати картку",
"SAVED_CARDS_CONFIRM_YES": "✅ Так, відв'язати",
"PAYMENT_METHOD_BANK_CARD": "💳 Банківська картка",
"PAYMENT_METHOD_YOO_MONEY": "🟣 ЮMoney",
"PAYMENT_METHOD_SBERBANK": "🟢 СберPay",
"PAYMENT_METHOD_TINKOFF_BANK": "🟡 Т-Банк",
"PAYMENT_METHOD_SBP": "🏦 СБП",
"PAYMENT_METHOD_MIR_PAY": "🟦 Mir Pay",
"BACK": "⬅️ Назад",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ В головне меню",
"BACK_TO_MENU": "🏠 В головне меню",
+14
View File
@@ -839,6 +839,20 @@
"AUTOPAY_TOGGLE_SUCCESS": "✅自动支付{status}",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>自动扣款成功</b>\n\n余额已充值{amount},用于续订订阅。",
"RECURRENT_TOPUP_FAILED": "❌ <b>自动扣款失败</b>\n\n无法从任何已保存的银行卡中扣除{amount}以续订订阅。\n\n请手动充值余额以避免服务中断。",
"SAVED_CARDS_BUTTON": "💳 已绑定的卡",
"SAVED_CARDS_TITLE": "💳 <b>已绑定的卡</b>\n\n选择要解绑的卡:",
"SAVED_CARDS_EMPTY": "💳 <b>已绑定的卡</b>\n\n没有已绑定的卡。\n下次充值余额时将自动绑定。",
"SAVED_CARDS_CONFIRM_UNLINK": "确定要解绑 <b>{card}</b> 吗?\n\n解绑后自动扣款将无法使用此卡。",
"SAVED_CARDS_LAST_CARD_WARNING": "\n\n⚠️ <b>注意:</b>这是您最后一张绑定的卡。解绑后自动扣款将无法进行。",
"SAVED_CARDS_UNLINKED": "✅ 卡已解绑",
"SAVED_CARDS_UNLINK_ERROR": "❌ 解绑失败",
"SAVED_CARDS_CONFIRM_YES": "✅ 确认解绑",
"PAYMENT_METHOD_BANK_CARD": "💳 银行卡",
"PAYMENT_METHOD_YOO_MONEY": "🟣 ЮMoney",
"PAYMENT_METHOD_SBERBANK": "🟢 СберPay",
"PAYMENT_METHOD_TINKOFF_BANK": "🟡 Т-Банк",
"PAYMENT_METHOD_SBP": "🏦 СБП",
"PAYMENT_METHOD_MIR_PAY": "🟦 Mir Pay",
"BACK": "⬅️返回",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️返回主菜单",
"BACK_TO_MENU": "🏠返回主菜单",
+9
View File
@@ -446,6 +446,15 @@ async def execute_merge(
# 4. Суммируем баланс (включая отрицательный — долг не должен исчезать)
transferred_kopeks = secondary.balance_kopeks
if transferred_kopeks != 0:
from app.database.models import User as UserModel
if isinstance(primary, UserModel):
from app.database.crud.user import lock_user_for_update
primary = await lock_user_for_update(db, primary)
secondary = await lock_user_for_update(db, secondary)
# Re-read after lock in case concurrent payment changed it
transferred_kopeks = secondary.balance_kopeks
primary.balance_kopeks += transferred_kopeks
secondary.balance_kopeks = 0
logger.info(
+28 -6
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:
+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:
+3
View File
@@ -306,6 +306,9 @@ class ContestAttemptService:
return ''
kopeks = int(prize_value) if prize_value.isdigit() else 0
if kopeks > 0:
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
user.balance_kopeks += kopeks
await db.commit()
return texts.t('CONTEST_BALANCE_GRANTED', 'Бонус {amount} зачислен!').format(
+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:
# Недостаточно средств - приостанавливаем подписку
+110 -11
View File
@@ -17,7 +17,17 @@ from app.config import settings
from app.database.crud.landing import create_guest_purchase
from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id, replace_subscription
from app.database.crud.tariff import get_tariff_by_id
from app.database.models import GuestPurchase, GuestPurchaseStatus, LandingPage, Tariff, User
from app.database.crud.transaction import create_transaction
from app.database.crud.user import _get_or_create_default_promo_group
from app.database.models import (
GuestPurchase,
GuestPurchaseStatus,
LandingPage,
PaymentMethod,
Tariff,
TransactionType,
User,
)
from app.services.subscription_service import SubscriptionService
@@ -108,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
@@ -242,7 +254,7 @@ async def fulfill_purchase(
# Active subscription or gift with any existing subscription — hold for manual activation
purchase.status = GuestPurchaseStatus.PENDING_ACTIVATION.value
purchase.user_id = user.id
if recipient_type == 'email' and not purchase.is_gift:
if recipient_type == 'email' and not purchase.is_gift and is_new_account:
purchase.auto_login_token = create_auto_login_token(user.id)
await db.commit()
await db.refresh(purchase, attribute_names=['landing', 'user'])
@@ -273,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
@@ -282,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,
)
@@ -294,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,
)
@@ -310,12 +329,28 @@ async def fulfill_purchase(
purchase.status = GuestPurchaseStatus.DELIVERED.value
purchase.user_id = user.id
purchase.delivered_at = datetime.now(UTC)
if recipient_type == 'email' and not purchase.is_gift:
if recipient_type == 'email' and not purchase.is_gift and is_new_account:
purchase.auto_login_token = create_auto_login_token(user.id)
await db.commit()
await db.refresh(purchase, attribute_names=['landing', 'user'])
# Create transaction so promo group auto-assignment and contest tracking work
try:
payment_method_enum = _resolve_payment_method(purchase.payment_method)
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=purchase.amount_kopeks,
description=f'Покупка подписки через лендинг ({notification_tariff_name}, {purchase.period_days} дн.)',
payment_method=payment_method_enum,
external_id=purchase.payment_id,
is_completed=True,
)
except Exception:
logger.exception('Failed to create transaction for guest purchase', purchase_id=purchase.id)
try:
await send_guest_notification(
purchase,
@@ -357,6 +392,26 @@ async def fulfill_purchase(
return purchase
def _resolve_payment_method(method_str: str | None) -> PaymentMethod | None:
"""Convert payment method string from GuestPurchase to PaymentMethod enum."""
if not method_str:
return None
# Try exact match first (handles 'telegram_stars', 'kassa_ai', 'yookassa', etc.)
try:
return PaymentMethod(method_str)
except ValueError:
pass
# Strip sub-option suffix ('yookassa_sbp' → 'yookassa', 'platega_2' → 'platega')
if '_' in method_str:
base_method = method_str.split('_')[0]
try:
return PaymentMethod(base_method)
except ValueError:
pass
logger.debug('Unknown payment method for transaction', method=method_str)
return None
def _mask_email(email: str) -> str:
"""Mask email for logging: 'user@example.com' -> 'u***@e***.com'."""
if not email:
@@ -399,8 +454,8 @@ async def _find_or_create_user(
user = result.scalars().first()
if user:
is_new_account = False
# Existing user WITHOUT password — generate one and set up cabinet access
if not user.password_hash:
# User without cabinet access — generate credentials
plain_password = secrets.token_urlsafe(12)
user.password_hash = hash_password(plain_password)
if purchase:
@@ -410,16 +465,22 @@ async def _find_or_create_user(
if not user.email_verified:
user.email_verified = True
user.email_verified_at = datetime.now(UTC)
# Ensure default promo group
if not user.promo_group_id:
default_group = await _get_or_create_default_promo_group(db)
user.promo_group_id = default_group.id
return user, is_new_account
# Create new email user with verified cabinet account
plain_password = secrets.token_urlsafe(12)
default_group = await _get_or_create_default_promo_group(db)
user = User(
auth_type='email',
email=contact_value,
email_verified=True,
email_verified_at=datetime.now(UTC),
password_hash=hash_password(plain_password),
promo_group_id=default_group.id,
)
if purchase:
purchase.cabinet_password = plain_password
@@ -431,7 +492,7 @@ async def _find_or_create_user(
result = await db.execute(select(User).where(User.email == contact_value))
user = result.scalars().first()
if user:
# Clear stale password from failed insert, then check if re-fetched user needs one
# Race condition — user was created concurrently
if purchase:
purchase.cabinet_password = None
is_new_account = False
@@ -444,6 +505,9 @@ async def _find_or_create_user(
if not user.email_verified:
user.email_verified = True
user.email_verified_at = datetime.now(UTC)
if not user.promo_group_id:
default_group = await _get_or_create_default_promo_group(db)
user.promo_group_id = default_group.id
return user, is_new_account
raise
logger.info(
@@ -508,13 +572,19 @@ async def _find_or_create_user(
resolved_telegram_id=resolved_telegram_id,
)
await db.refresh(user)
# Ensure default promo group
if not user.promo_group_id:
default_group = await _get_or_create_default_promo_group(db)
user.promo_group_id = default_group.id
return user, False
# Create new telegram user
default_group = await _get_or_create_default_promo_group(db)
user = User(
auth_type='telegram',
username=username,
telegram_id=resolved_telegram_id,
promo_group_id=default_group.id,
)
try:
async with db.begin_nested():
@@ -525,10 +595,16 @@ async def _find_or_create_user(
result = await db.execute(select(User).where(User.telegram_id == resolved_telegram_id))
user = result.scalars().first()
if user:
if not user.promo_group_id:
default_group = await _get_or_create_default_promo_group(db)
user.promo_group_id = default_group.id
return user, False
result = await db.execute(select(User).where(func.lower(User.username) == normalized))
user = result.scalars().first()
if user:
if not user.promo_group_id:
default_group = await _get_or_create_default_promo_group(db)
user.promo_group_id = default_group.id
return user, False
raise
logger.info(
@@ -821,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,
@@ -828,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,
@@ -841,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,
@@ -854,13 +937,29 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
purchase.subscription_crypto_link = subscription.subscription_crypto_link
purchase.status = GuestPurchaseStatus.DELIVERED.value
purchase.delivered_at = datetime.now(UTC)
if user.auth_type == 'email' and not purchase.is_gift:
if user.auth_type == 'email' and not purchase.is_gift and is_new_account:
purchase.auto_login_token = create_auto_login_token(user.id)
# Single atomic commit: subscription + purchase status + user changes
await db.commit()
await db.refresh(purchase, attribute_names=['landing', 'user'])
# Create transaction so promo group auto-assignment and contest tracking work
try:
payment_method_enum = _resolve_payment_method(purchase.payment_method)
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=purchase.amount_kopeks,
description=f'Покупка подписки через лендинг ({notification_tariff_name}, {purchase.period_days} дн.)',
payment_method=payment_method_enum,
external_id=purchase.payment_id,
is_completed=True,
)
except Exception:
logger.exception('Failed to create transaction for activated purchase', purchase_id=purchase.id)
if not skip_notification:
try:
await send_guest_notification(
+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()
+54 -53
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)
@@ -1053,12 +1052,18 @@ class MonitoringService:
autopay_period = 30
try:
renewal_cost = await self.subscription_service.calculate_renewal_price(
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,
autopay_period,
db,
user=user,
)
renewal_cost = pricing.final_total
except Exception as e:
logger.error(
'Ошибка расчёта стоимости автопродления, пропускаем',
@@ -1298,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),
)
)
)
+6 -1
View File
@@ -271,6 +271,11 @@ class CloudPaymentsPaymentMixin:
subscription = getattr(user, 'subscription', None)
referrer_info = format_referrer_info(user)
# 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
@@ -342,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
# ---------------------------------------------------------------------------
+67 -27
View File
@@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.database import AsyncSessionLocal
from app.database.models import PaymentMethod, TransactionType
from app.services.pricing_engine import RenewalPricing, pricing_engine
from app.services.subscription_renewal_service import (
RenewalPaymentDescriptor,
SubscriptionRenewalChargeError,
@@ -163,7 +164,13 @@ class CryptoBotPaymentMixin:
else:
paid_at = datetime.now(UTC)
updated_payment = await cryptobot_crud.update_cryptobot_payment_status(db, invoice_id, status, paid_at)
updated_payment = await cryptobot_crud.update_cryptobot_payment_status(
db,
invoice_id,
status,
paid_at,
commit=False,
)
descriptor = decode_payment_payload(
getattr(updated_payment, 'payload', '') or '',
@@ -290,6 +297,11 @@ class CryptoBotPaymentMixin:
logger.error('Пользователь с ID не найден при пополнении баланса', user_id=updated_payment.user_id)
return False
# 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
@@ -326,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()
@@ -404,7 +416,7 @@ class CryptoBotPaymentMixin:
except Exception as error:
logger.error(
'Не удалось загрузить пользователя для продления через CryptoBot',
getattr=getattr(payment, 'user_id', None),
payment_user_id=getattr(payment, 'user_id', None),
error=error,
)
return False
@@ -412,7 +424,7 @@ class CryptoBotPaymentMixin:
if not user:
logger.error(
'Пользователь не найден при обработке продления через CryptoBot',
getattr=getattr(payment, 'user_id', None),
payment_user_id=getattr(payment, 'user_id', None),
)
return False
@@ -420,12 +432,27 @@ class CryptoBotPaymentMixin:
if not subscription or subscription.id != descriptor.subscription_id:
logger.warning(
'Продление через CryptoBot отклонено: подписка не совпадает с ожидаемой',
getattr=getattr(subscription, 'id', None),
subscription_id=descriptor.subscription_id,
current_subscription_id=getattr(subscription, 'id', None),
expected_subscription_id=descriptor.subscription_id,
)
return False
pricing_model: SubscriptionRenewalPricing | None = None
# Validate period_days against allowed periods
tariff = getattr(subscription, 'tariff', None)
if tariff and tariff.period_prices:
allowed_periods = [int(p) for p in tariff.period_prices.keys()]
else:
allowed_periods = settings.get_available_renewal_periods()
if descriptor.period_days not in allowed_periods:
logger.error(
'CryptoBot renewal rejected: period_days not in allowed periods',
invoice_id=payment.invoice_id,
period_days=descriptor.period_days,
allowed_periods=allowed_periods,
)
return False
pricing_model: SubscriptionRenewalPricing | RenewalPricing | None = None
if descriptor.pricing_snapshot:
try:
pricing_model = SubscriptionRenewalPricing.from_payload(descriptor.pricing_snapshot)
@@ -438,11 +465,11 @@ class CryptoBotPaymentMixin:
if pricing_model is None:
try:
pricing_model = await renewal_service.calculate_pricing(
pricing_model = await pricing_engine.calculate_renewal_price(
db,
user,
subscription,
descriptor.period_days,
user=user,
)
except Exception as error:
logger.error(
@@ -454,27 +481,40 @@ class CryptoBotPaymentMixin:
if pricing_model.final_total != descriptor.total_amount_kopeks:
logger.warning(
'Сумма продления через CryptoBot изменилась (ожидалось , получено)',
'Сумма продления через CryptoBot изменилась',
invoice_id=payment.invoice_id,
total_amount_kopeks=descriptor.total_amount_kopeks,
final_total=pricing_model.final_total,
expected_kopeks=descriptor.total_amount_kopeks,
actual_kopeks=pricing_model.final_total,
)
pricing_model.final_total = descriptor.total_amount_kopeks
pricing_model.per_month = (
descriptor.total_amount_kopeks // pricing_model.months
if pricing_model.months
else descriptor.total_amount_kopeks
if pricing_model.final_total > descriptor.total_amount_kopeks:
# Price increased since invoice creation — user would be undercharged.
# Reject and let the user create a new invoice at the current price.
logger.error(
'CryptoBot renewal rejected: recalculated price exceeds agreed amount',
invoice_id=payment.invoice_id,
agreed_kopeks=descriptor.total_amount_kopeks,
recalculated_kopeks=pricing_model.final_total,
)
return False
# Price decreased — charge recalculated (lower) amount, user benefits
logger.info(
'CryptoBot renewal: price decreased, user benefits',
invoice_id=payment.invoice_id,
agreed_kopeks=descriptor.total_amount_kopeks,
recalculated_kopeks=pricing_model.final_total,
delta_kopeks=descriptor.total_amount_kopeks - pricing_model.final_total,
)
pricing_model.period_days = descriptor.period_days
pricing_model.period_id = build_renewal_period_id(descriptor.period_days)
# Override period_days/period_id only on mutable SubscriptionRenewalPricing
if isinstance(pricing_model, SubscriptionRenewalPricing):
pricing_model.period_days = descriptor.period_days
pricing_model.period_id = build_renewal_period_id(descriptor.period_days)
# When price drops, recalculate balance portion: total minus the fixed external payment
# This ensures the user isn't overcharged from balance when crypto already covers more
required_balance = max(
0,
min(
pricing_model.final_total,
descriptor.balance_component_kopeks,
),
pricing_model.final_total - descriptor.missing_amount_kopeks,
)
current_balance = getattr(user, 'balance_kopeks', 0)
@@ -606,10 +646,10 @@ class CryptoBotPaymentMixin:
reply_markup=payload.reply_markup,
)
logger.info(
'Отправлено уведомление пользователю %s о пополнении на %s₽ (%s)',
payload.telegram_id,
f'{payload.amount_rubles:.2f}',
payload.asset,
'Отправлено уведомление пользователю о пополнении',
telegram_id=payload.telegram_id,
amount_rubles=f'{payload.amount_rubles:.2f}',
asset=payload.asset,
)
except Exception as error:
logger.error('Ошибка отправки уведомления о пополнении CryptoBot', error=error)
+6 -1
View File
@@ -307,6 +307,11 @@ class FreekassaPaymentMixin:
payment.updated_at = datetime.now(UTC)
await db.flush()
# 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
@@ -342,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()
+6 -1
View File
@@ -352,6 +352,11 @@ class HeleketPaymentMixin:
logger.error('Пользователь не найден для Heleket платежа', user_id=updated_payment.user_id)
return None
# 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
@@ -386,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)
+13 -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,
)
@@ -295,6 +300,11 @@ class KassaAiPaymentMixin:
payment.updated_at = datetime.now(UTC)
await db.flush()
# 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
@@ -330,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()
+6 -1
View File
@@ -283,6 +283,11 @@ class MulenPayPaymentMixin:
)
return False
# 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
@@ -317,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()
+6 -1
View File
@@ -396,6 +396,11 @@ class Pal24PaymentMixin:
await payment_module.link_pal24_payment_to_transaction(db, payment, transaction.id)
# 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
@@ -429,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()
+6 -1
View File
@@ -402,6 +402,11 @@ class PlategaPaymentMixin:
logger.info('Platega платеж уже зачислил баланс ранее', correlation_id=payment.correlation_id)
return payment
# 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
@@ -437,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)
+25 -6
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,8 +78,11 @@ 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]}'
@@ -103,8 +108,8 @@ class RioPayPaymentMixin:
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,
fail_url=fail_url or settings.RIOPAY_FAIL_URL,
)
payment_url = result.get('paymentLink')
@@ -275,6 +280,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 +335,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))
+14 -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
@@ -384,6 +392,11 @@ class TelegramStarsMixin:
) -> bool:
"""Начисляет баланс пользователю после оплаты Stars и запускает автопокупку."""
# 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
@@ -429,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()
+6 -1
View File
@@ -482,6 +482,11 @@ class WataPaymentMixin:
await payment_module.link_wata_payment_to_transaction(db, payment, transaction.id)
# 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
@@ -524,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)
+115 -12
View File
@@ -20,7 +20,9 @@ from app.utils.user_utils import format_referrer_info
if TYPE_CHECKING:
from app.database.models import Transaction, YooKassaPayment
from app.database.models import Transaction, User, YooKassaPayment
_INT32_MAX = 2_147_483_647
class YooKassaPaymentMixin:
@@ -617,6 +619,7 @@ class YooKassaPaymentMixin:
external_id=payment.yookassa_payment_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
commit=False,
)
if not getattr(payment, 'transaction_id', None):
@@ -740,8 +743,13 @@ class YooKassaPaymentMixin:
'Ошибка реферального начисления при покупке подписки YooKassa', ref_error=ref_error
)
else:
old_balance = getattr(user, 'balance_kopeks', 0)
was_first_topup = not getattr(user, 'has_made_first_topup', False)
# 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)
@@ -783,6 +791,22 @@ class YooKassaPaymentMixin:
await db.commit()
# Emit deferred side-effects after atomic commit
try:
from app.database.crud.transaction import emit_transaction_side_effects
await emit_transaction_side_effects(
db,
transaction,
amount_kopeks=payment.amount_kopeks,
user_id=payment.user_id,
type=transaction_type,
payment_method=PaymentMethod.YOOKASSA,
external_id=payment.yookassa_payment_id,
)
except Exception as error:
logger.warning('Failed to emit YooKassa transaction side effects', error=error)
try:
from app.services.referral_service import process_referral_topup
@@ -795,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()
@@ -888,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)
@@ -1140,11 +1176,18 @@ class YooKassaPaymentMixin:
expiry_year = str(raw_year) if raw_year is not None else None
method_type = pm.get('type', 'bank_card')
# Формируем название
# Формируем title — только реквизиты без названия метода
# (локализованное название подставляется в UI через _get_payment_method_display_name)
title = None
if card_last4:
type_label = card_type or 'Card'
title = f'{type_label} *{card_last4}'
elif method_type != 'bank_card':
# Для не-карточных методов: yoo_money (account_number), sbp/sberbank (phone) и т.д.
account = pm.get('account_number') or pm.get('phone')
if account:
masked = account[-4:] if len(account) >= 4 else account
title = f'*{masked}'
saved = await create_saved_payment_method(
db=db,
@@ -1334,7 +1377,9 @@ class YooKassaPaymentMixin:
return None
metadata = self._normalise_yookassa_metadata(event_object.get('metadata'))
user_id_raw = metadata.get('user_id') or metadata.get('userId')
user_id_raw = metadata.get('user_id')
if user_id_raw is None:
user_id_raw = metadata.get('userId')
if user_id_raw is None:
logger.error(
@@ -1353,21 +1398,79 @@ class YooKassaPaymentMixin:
)
return None
# Verify user exists before creating FK-linked record
try:
from app.database.crud.user import get_user_by_id
if user_id <= 0:
logger.error(
'Webhook YooKassa содержит неположительный user_id',
yookassa_payment_id=yookassa_payment_id,
user_id=user_id,
)
return None
# Verify user exists before creating FK-linked record.
# Legacy payments may have telegram_id stored in metadata['user_id']
# instead of the internal User.id. Detect by checking int32 range.
user: User | None = None
try:
from app.database.crud.user import get_user_by_id, get_user_by_telegram_id
if user_id <= _INT32_MAX:
user = await get_user_by_id(db, user_id)
# Cross-validate: if metadata also has telegram_id, verify it matches
if user:
meta_tg = metadata.get('user_telegram_id') or metadata.get('userTelegramId')
if meta_tg is not None:
try:
expected_tg = int(meta_tg)
except (TypeError, ValueError):
expected_tg = None
if expected_tg and user.telegram_id != expected_tg:
logger.warning(
'Webhook YooKassa: user_id совпал, но telegram_id не совпадает — '
'вероятно legacy metadata, ищем по telegram_id',
yookassa_payment_id=yookassa_payment_id,
user_id=user_id,
user_telegram_id=user.telegram_id,
expected_telegram_id=expected_tg,
)
user = await get_user_by_telegram_id(db, expected_tg)
else:
# user_id exceeds int32 — это telegram_id из legacy-платежа
logger.warning(
'Webhook YooKassa: metadata[user_id] превышает int32, ищем как telegram_id',
yookassa_payment_id=yookassa_payment_id,
suspected_telegram_id=user_id,
)
user = await get_user_by_telegram_id(db, user_id)
# Fallback: try user_telegram_id from metadata if primary lookup failed
if not user:
tg_id_raw = metadata.get('user_telegram_id')
if tg_id_raw is None:
tg_id_raw = metadata.get('userTelegramId')
if tg_id_raw is not None:
try:
tg_id = int(tg_id_raw)
except (TypeError, ValueError):
tg_id = None
if tg_id and tg_id > 0:
user = await get_user_by_telegram_id(db, tg_id)
user = await get_user_by_id(db, user_id)
if not user:
logger.warning(
'Webhook YooKassa : user_id= не найден в БД, пропускаем восстановление платежа',
'Webhook YooKassa: пользователь не найден, пропускаем восстановление платежа',
yookassa_payment_id=yookassa_payment_id,
user_id=user_id,
user_telegram_id=metadata.get('user_telegram_id'),
)
return None
# Use the resolved internal ID for the FK column
user_id = user.id
except Exception as e:
logger.warning(
'Webhook YooKassa : не удалось проверить user_id',
'Webhook YooKassa: не удалось проверить user_id',
yookassa_payment_id=yookassa_payment_id,
user_id=user_id,
e=e,
@@ -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(),
+79 -3
View File
@@ -666,26 +666,102 @@ 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
# --- Telegram Stars ---------------------------------------------------
if payment_method == 'telegram_stars':
if not settings.TELEGRAM_STARS_ENABLED:
logger.warning('Telegram Stars is not enabled, cannot create guest payment')
return None
if self.bot is None:
logger.warning('Bot instance required for Stars guest payment')
return None
from aiogram.types import LabeledPrice
rate = settings.get_stars_rate()
if rate <= 0:
logger.error('TELEGRAM_STARS_RATE_RUB is not positive, cannot create Stars invoice')
return None
amount_rubles = amount_kopeks / 100
stars_amount = max(1, round(amount_rubles / rate))
payload = f'guest_purchase_{purchase_token}'
try:
invoice_url = await self.bot.create_invoice_link(
title='Подарочная подписка VPN',
description=f'{description} ({stars_amount} ⭐)',
payload=payload,
provider_token='',
currency='XTR',
prices=[LabeledPrice(label='Подарочная подписка', amount=stars_amount)],
)
logger.info(
'Created Stars invoice for guest purchase',
stars_amount=stars_amount,
purchase_token_prefix=purchase_token[:5],
)
return {
'payment_url': invoice_url,
'payment_id': f'stars_{purchase_token[:12]}',
'provider': 'telegram_stars',
}
except Exception as stars_error:
logger.error('Error creating Stars invoice for guest payment', error=stars_error)
return None
# --- Unsupported provider ---------------------------------------------
logger.warning(
'Guest payment requested for unsupported provider',
+83 -6
View File
@@ -27,6 +27,7 @@ from app.database.models import (
Pal24Payment,
PaymentMethod,
PlategaPayment,
RioPayPayment,
Transaction,
TransactionType,
User,
@@ -72,6 +73,7 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
PaymentMethod.CLOUDPAYMENTS,
PaymentMethod.FREEKASSA,
PaymentMethod.KASSA_AI,
PaymentMethod.RIOPAY,
}
)
@@ -90,6 +92,7 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
# Payments are processed via webhook (wata_webhook.py).
PaymentMethod.FREEKASSA,
PaymentMethod.KASSA_AI,
PaymentMethod.RIOPAY,
}
)
@@ -115,6 +118,8 @@ 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.TELEGRAM_STARS:
return 'Telegram Stars'
return method.value
@@ -141,6 +146,8 @@ 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()
return False
@@ -242,12 +249,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 +383,13 @@ def _is_kassa_ai_pending(payment: KassaAiPayment) -> bool:
return status in {'pending', 'created', '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 +707,32 @@ 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_stars_transactions(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
stmt = (
select(Transaction)
@@ -729,6 +780,7 @@ 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_stars_transactions(db, cutoff),
)
@@ -897,6 +949,21 @@ 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.TELEGRAM_STARS:
transaction = await db.get(Transaction, local_payment_id)
if not transaction:
@@ -955,6 +1022,13 @@ 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.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 +1046,9 @@ async def run_manual_check(
error=error,
exc_info=True,
)
# Откатываем сессию чтобы не оставлять её в грязном состоянии
if db.in_transaction():
await db.rollback()
return None

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