RemnaWave API requires username >= 3 characters. Users with short
Telegram names (e.g. "Su") produced 2-char usernames that failed
validation, preventing subscription sync after payment.
Now format_remnawave_username pads short results with the user
identifier (telegram_id/email/user_id) to ensure minimum length.
The get_inactive_users query filtered only by last_activity (last bot
interaction), ignoring subscription end dates. Users who bought long
subscriptions (3-6-12 months) but didn't interact with the bot got
flagged as inactive and deleted+banned while their subscription was
still active or recently expired.
Fix: add SQL subquery excluding users who have ANY subscription with
end_date >= threshold_date. A user is now only deletable when BOTH
their last_activity AND their latest subscription end_date are older
than the configured inactivity period.
Previously the bot only responded "Не понимаю эту команду" to text
messages. Photos, videos, documents and stickers sent outside of an
active FSM state were silently ignored, causing users to think their
media was received when it wasn't (e.g. support ticket screenshots
sent as separate messages).
Now the bot replies with the same "use menu buttons" message for any
unhandled media when no FSM state is active.
* fix: устранить MissingGreenlet в автоплатежах и починить traceback в логах
- subtract_user_balance: пишем promo_offer_log в отдельной сессии вместо rollback после commit, который экспайрил объекты основной сессии и ломал последующие обращения к subscription/user attrs
- monitoring_service._process_autopayments: перезагружаем subscription с eager-load user/tariff после списания, оборачиваем каждую итерацию в try/except + rollback, чтобы одна ошибка не валила весь батч
- logging_config: новый processor _auto_capture_exc_info автоматически подтягивает traceback из sys.exc_info() или error-kwarg → полный traceback в файле, консоли и Telegram без exc_info=True на каждом вызове
- logging_handler: дублирующая логика захвата exc_info в TelegramNotifierProcessor как резерв
* fix: устранить root cause MissingGreenlet в автоплатежах через refetch по id
Трейс показал: subscription.user падает на lazy-load → pool._checkout →
do_ping → await_ → MissingGreenlet. SQLAlchemy 2.0 async session не
поддерживает sync-lazy-load для relationships. Причина рассинхрона:
lock_user_for_pricing делает populate_existing=True + selectinload(
User.subscriptions).selectinload(Subscription.tariff), что разгружает
Subscription.user backref для сестринских подписок того же user.
Последующее обращение sub.user у другой подписки падает.
Фикс: захватываем (sub_id, user_id) пары ДО цикла, каждую итерацию
делаем fresh refetch через async select с eager load user+tariff+
promo_group. Никаких lazy access в горячем пути. В except используем
локально захваченные id вместо getattr(subscription, ...), чтобы
логирование не падало каскадом на expired объекте.
* fix: grant all available squads for unrestricted trials (#2897)
* feat: add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook (#2894)
* feat: add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook
* style: ruff format main.py
---------
Co-authored-by: Dmitry Lunin <br@slack.ru>
* fix: do not update first_name/last_name from OIDC claims (#2892)
Co-authored-by: Dmitry Lunin <br@slack.ru>
* fix: do not reset subscription_crypto_link when cryptoLink absent in webhook (#2891)
Co-authored-by: Dmitry Lunin <br@slack.ru>
* fix: FSM state loss on balance topup, PayPear confirmation_url, hidden trial tariff in renewal
- balance/platega: re-set FSM state after min/max validation errors,
set state before pending_amount path, use balance_topup callback for back button
- balance/main: set FSM state and payment_method in handle_topup_amount_callback
for all providers before routing, use balance_topup callback in validation errors
- payment/paypear: fix confirmation_url key (was 'url'), add fallback,
store charged amount with commission for correct webhook amount comparison
- tariff_purchase: redirect to active tariff list when current tariff is
inactive (hidden trial after promo code activation)
- cabinet/renewal: check tariff.is_active in both GET and POST endpoints
to prevent hidden trial tariff periods from appearing
* fix: tariff switch pricing showing free for upgrades, admin duplicate subscription guard
- pricing_engine: use shortest period for daily rate comparison instead
of period closest to remaining_days — fixes incorrect free/zero cost
for upgrades when tariffs have different period sets
- pricing_engine: remove unused target_days parameter from
get_tariff_daily_rate_fraction
- admin_users: add duplicate subscription check before create,
change_tariff and activate actions to prevent UniqueViolationError
on uq_subscriptions_user_tariff_active constraint
- admin_users: add IntegrityError fallback on create as TOCTOU safety net
* feat: tariff switch direction control, fix device pricing within tariff limit
Tariff switch direction:
- Add TARIFF_SWITCH_UPGRADE_ENABLED and TARIFF_SWITCH_DOWNGRADE_ENABLED
settings to control allowed switch directions
- Guard all 10 entry points: instant switch (list, preview, confirm),
legacy switch (list, select, confirm, daily confirm), cabinet (preview,
execute), purchase-options API
- Filter tariff lists by allowed direction, show "unavailable" when
both directions disabled
- Expose settings in cabinet purchase-options response for frontend
Device pricing fix:
- Devices within tariff.device_limit are now free when restoring
(was charging for all devices regardless of tariff inclusion)
- Fix max(100, price) minimum enforcing 1 RUB even when
chargeable_devices is 0
- Apply fix across all endpoints: bot handlers (confirm_change,
execute_change, confirm_add), cabinet API (legacy purchase,
modern purchase, get-price, save-cart), inline keyboard display
* fix: classic mode renewal resets device_limit to 1 via cart key mismatch
- Fix cart key mismatch: extend cart saved 'device_limit' but
confirm_purchase read 'devices' key, falling back to DEFAULT=1.
Now both keys are saved in both cart-save paths
- Fix confirm_purchase device resolution: use explicit is None checks
instead of or-chain to avoid falsy-zero trap
- Fix return_to_saved_cart display: fall back to 'device_limit' and
'traffic_limit_gb' keys when 'devices'/'traffic_gb' are absent
- Fix second cart-save path in _extend_existing_subscription with
same dual-key pattern
- Fix RemnaWaveService import path in renewal service
- Add RESET_DEVICES_ON_RENEWAL setting: resets all connected devices
(hwid) via RemnaWave API on each subscription renewal
* fix: menu layout schema icon limit, traffic_topup_enabled condition, shadowing imports
- Increase icon max_length from 10 to 100 in all three schemas
(MenuButtonConfig, ButtonUpdateRequest, AddCustomButtonRequest)
to support Telegram Custom Emoji IDs
- Add traffic_topup_enabled condition to ButtonConditions schema
- Remove shadowing local imports of MenuLayoutService in
routes/menu_layout.py (top-level import already provides access)
* feat(tickets): multi-media message gallery (media_items JSONB)
- Add media_items JSONB column to TicketMessage model for multi-media
gallery support (photos/videos/documents in one bubble)
- Add TicketMediaItem schema with type validation and shared
_validate_media_bundle helper (max 10 items, legacy field compat)
- Update admin and user ticket handlers to store media_items and
back-fill legacy media_type/media_file_id/media_caption from first
item for backward compatibility
- Update _message_to_response in both admin and user routes to include
media_items in API responses
- Allow empty message text when media is attached (message field now
defaults to empty string with model validator ensuring text or media)
- Add migration 0061 with idempotent column check
Based on PR #2869 by @smediainfo — CI/CD workflow changes excluded
(hardcoded version strings would regress dynamic manifest reading)
* fix: ticket media_items review fixes
- Add if has_media else None guards in user-side ticket handlers
(create_ticket, add_message) matching admin handler pattern
- Fix Telegram notification using resolved primary_file_id/primary_type
instead of raw request fields for gallery messages
- Narrow except Exception to (TypeError, KeyError, ValueError) in
_message_to_response with warning log for debugging
- Add media_items parameter to TicketCRUD.create_ticket and
TicketCRUD.add_message for CRUD layer parity
- Add TicketMediaItemResponse and media_items field to webapi
TicketMessageResponse to prevent data loss on read
* feat: landing page analytics goals and sticky pay button
- Add sticky_pay_button, analytics_view_enabled, analytics_view_goal,
analytics_click_enabled, analytics_click_goal columns to LandingPage
- Add fields to CRUD updatable fields, admin create/update/detail
schemas, create_landing() kwargs, _landing_to_detail() response
- Expose sticky_pay_button and analytics fields in public landing
config response for frontend Yandex Metrika integration
- Add migration 0062 with idempotent column checks
Based on PR #2852 by @smediainfo — CI/CD workflow changes excluded
(hardcoded version strings would regress dynamic manifest reading)
* fix: validate analytics goal is set when analytics is enabled on landing
Prevent enabling analytics_view/click without providing the
corresponding goal identifier, which would result in empty
Yandex Metrika calls on the frontend.
* feat: Yandex Metrika offline conversions + S2S postbacks
- Add YandexClientIdMap model for user → yandex_cid mapping with
upsert-safe CRUD (ON CONFLICT DO UPDATE)
- Add yandex_cid, subid, referrer columns to GuestPurchase
- Add yandex_offline_conv_service: Measurement Protocol integration
with mc.yandex.ru/collect (registration, trial, purchase events),
background task management, CID parsing from /start params
- Add s2s_postback_service: server-to-server affiliate postbacks
with URL template placeholders and URL-safe encoding
- Add analytics offline conversion info to branding API (masked secret)
- Add POST /analytics/yandex-cid endpoint for cabinet CID capture
- Add 11 config settings (YANDEX_OFFLINE_CONV_*, S2S_POSTBACK_*)
- Add migration 0063 (yandex_client_id_map table + guest_purchases cols)
- Fix: mask measurement secret aggressively (show only last 4 chars)
- Fix: always replace {user_id} placeholder in S2S postback URLs
- Fix: use structlog kwargs instead of f-strings with LOG_PREFIX
Based on PR #2851 by @smediainfo — CI/CD workflow changes excluded
---------
Co-authored-by: c0mrade <killmy666@gmail.com>
Co-authored-by: Danila Yudin <danyayudin2012@gmail.com>
Co-authored-by: Dmitry V. Lunin <49199230+BlackRaincoat@users.noreply.github.com>
Co-authored-by: Dmitry Lunin <br@slack.ru>
Prevent enabling analytics_view/click without providing the
corresponding goal identifier, which would result in empty
Yandex Metrika calls on the frontend.
- Add if has_media else None guards in user-side ticket handlers
(create_ticket, add_message) matching admin handler pattern
- Fix Telegram notification using resolved primary_file_id/primary_type
instead of raw request fields for gallery messages
- Narrow except Exception to (TypeError, KeyError, ValueError) in
_message_to_response with warning log for debugging
- Add media_items parameter to TicketCRUD.create_ticket and
TicketCRUD.add_message for CRUD layer parity
- Add TicketMediaItemResponse and media_items field to webapi
TicketMessageResponse to prevent data loss on read
- Add media_items JSONB column to TicketMessage model for multi-media
gallery support (photos/videos/documents in one bubble)
- Add TicketMediaItem schema with type validation and shared
_validate_media_bundle helper (max 10 items, legacy field compat)
- Update admin and user ticket handlers to store media_items and
back-fill legacy media_type/media_file_id/media_caption from first
item for backward compatibility
- Update _message_to_response in both admin and user routes to include
media_items in API responses
- Allow empty message text when media is attached (message field now
defaults to empty string with model validator ensuring text or media)
- Add migration 0061 with idempotent column check
Based on PR #2869 by @smediainfo — CI/CD workflow changes excluded
(hardcoded version strings would regress dynamic manifest reading)
- Increase icon max_length from 10 to 100 in all three schemas
(MenuButtonConfig, ButtonUpdateRequest, AddCustomButtonRequest)
to support Telegram Custom Emoji IDs
- Add traffic_topup_enabled condition to ButtonConditions schema
- Remove shadowing local imports of MenuLayoutService in
routes/menu_layout.py (top-level import already provides access)
- Fix cart key mismatch: extend cart saved 'device_limit' but
confirm_purchase read 'devices' key, falling back to DEFAULT=1.
Now both keys are saved in both cart-save paths
- Fix confirm_purchase device resolution: use explicit is None checks
instead of or-chain to avoid falsy-zero trap
- Fix return_to_saved_cart display: fall back to 'device_limit' and
'traffic_limit_gb' keys when 'devices'/'traffic_gb' are absent
- Fix second cart-save path in _extend_existing_subscription with
same dual-key pattern
- Fix RemnaWaveService import path in renewal service
- Add RESET_DEVICES_ON_RENEWAL setting: resets all connected devices
(hwid) via RemnaWave API on each subscription renewal
Tariff switch direction:
- Add TARIFF_SWITCH_UPGRADE_ENABLED and TARIFF_SWITCH_DOWNGRADE_ENABLED
settings to control allowed switch directions
- Guard all 10 entry points: instant switch (list, preview, confirm),
legacy switch (list, select, confirm, daily confirm), cabinet (preview,
execute), purchase-options API
- Filter tariff lists by allowed direction, show "unavailable" when
both directions disabled
- Expose settings in cabinet purchase-options response for frontend
Device pricing fix:
- Devices within tariff.device_limit are now free when restoring
(was charging for all devices regardless of tariff inclusion)
- Fix max(100, price) minimum enforcing 1 RUB even when
chargeable_devices is 0
- Apply fix across all endpoints: bot handlers (confirm_change,
execute_change, confirm_add), cabinet API (legacy purchase,
modern purchase, get-price, save-cart), inline keyboard display
- pricing_engine: use shortest period for daily rate comparison instead
of period closest to remaining_days — fixes incorrect free/zero cost
for upgrades when tariffs have different period sets
- pricing_engine: remove unused target_days parameter from
get_tariff_daily_rate_fraction
- admin_users: add duplicate subscription check before create,
change_tariff and activate actions to prevent UniqueViolationError
on uq_subscriptions_user_tariff_active constraint
- admin_users: add IntegrityError fallback on create as TOCTOU safety net
- balance/platega: re-set FSM state after min/max validation errors,
set state before pending_amount path, use balance_topup callback for back button
- balance/main: set FSM state and payment_method in handle_topup_amount_callback
for all providers before routing, use balance_topup callback in validation errors
- payment/paypear: fix confirmation_url key (was 'url'), add fallback,
store charged amount with commission for correct webhook amount comparison
- tariff_purchase: redirect to active tariff list when current tariff is
inactive (hidden trial after promo code activation)
- cabinet/renewal: check tariff.is_active in both GET and POST endpoints
to prevent hidden trial tariff periods from appearing
Трейс показал: subscription.user падает на lazy-load → pool._checkout →
do_ping → await_ → MissingGreenlet. SQLAlchemy 2.0 async session не
поддерживает sync-lazy-load для relationships. Причина рассинхрона:
lock_user_for_pricing делает populate_existing=True + selectinload(
User.subscriptions).selectinload(Subscription.tariff), что разгружает
Subscription.user backref для сестринских подписок того же user.
Последующее обращение sub.user у другой подписки падает.
Фикс: захватываем (sub_id, user_id) пары ДО цикла, каждую итерацию
делаем fresh refetch через async select с eager load user+tariff+
promo_group. Никаких lazy access в горячем пути. В except используем
локально захваченные id вместо getattr(subscription, ...), чтобы
логирование не падало каскадом на expired объекте.
- subtract_user_balance: пишем promo_offer_log в отдельной сессии вместо rollback после commit, который экспайрил объекты основной сессии и ломал последующие обращения к subscription/user attrs
- monitoring_service._process_autopayments: перезагружаем subscription с eager-load user/tariff после списания, оборачиваем каждую итерацию в try/except + rollback, чтобы одна ошибка не валила весь батч
- logging_config: новый processor _auto_capture_exc_info автоматически подтягивает traceback из sys.exc_info() или error-kwarg → полный traceback в файле, консоли и Telegram без exc_info=True на каждом вызове
- logging_handler: дублирующая логика захвата exc_info в TelegramNotifierProcessor как резерв
Users created before tariffs were introduced (tariff_id=NULL) got
"Тариф не найден" when pressing "Продлить подписку". Now they see
a tariff selection list instead, allowing them to pick a tariff
and renew with proper parameters (traffic, devices, etc).
Users with daily subscriptions and low balance were getting
"Подписка приостановлена" notification every 30 minutes (on each
charge cycle). Now rate-limited via Redis cache to max 1 notification
per 6 hours per subscription.
Top registrations list was summing DEPOSIT + SUBSCRIPTION_PAYMENT
transactions (total user spending), while period comparison revenue
only counted DEPOSIT with real payment methods (actual money paid in).
Now both use the same calculation: only DEPOSIT transactions with
real payment methods. This fixes the discrepancy where a user showed
500₽ in the list but total revenue was 250₽.
RollyPay was missing:
- 7 CRUD wrapper functions in payment_service.py (create, get, update, link)
- Guest payment block in create_guest_payment()
Both were present for PayPear and AuraPay but omitted for RollyPay.
- bot_configuration.py: added PAYPEAR/ROLLYPAY to payment categories
and test payment buttons
- system_settings_service.py: added category titles, descriptions,
and prefix mappings for PAYPEAR_* and ROLLYPAY_* settings
- Mixin accepts payment_method_type parameter (None = show all on form)
- API service sends payment_method only when specified
- Cabinet route passes payment_option to mixin
- Config service has sub_options: sbp, card, crypto
- Keyboard label no longer hardcodes "СБП"
Changed defaults from 10 attempts × 5min (50min total) to
72 attempts × 10min (12 hours total). When nalog.ru is temporarily
down, receipts now retry for 12 hours before being dropped,
giving the service enough time to recover.
The previous fix only covered /start command paths. The more common
show_main_menu and handle_back_to_menu in menu.py used their own
is_active check which returned False for limited status.
Now both paths treat limited subscriptions as active for UI, matching
the _calculate_subscription_flags fix in start.py.