5 issues found by review agents and fixed:
1. Intentional deletion guard was at top of process_event, skipping ALL
cleanup (subscription URLs, server counts, expired marking). Moved
check into _handle_user_deleted so cleanup still runs but re-creation
is suppressed via subscription_still_valid=False.
2. Variable shadowing: telegram_id loop var in any() generator shadowed
the outer telegram_id local. Renamed to tid/uid.
3. No hard cap on in-memory dicts: added _MAX_INTENTIONAL_ENTRIES=10000
with early return in mark_intentional_panel_deletion.
4. mark_intentional_panel_deletion called inside for-loop with single
UUID — race window if webhook from first delete arrives before
second UUID is marked. Moved to before the loop with all UUIDs.
5. Tests: @pytest.mark.anyio → @pytest.mark.anyio('asyncio') for
consistency. Replaced process_event(db=None) test with direct
mark+detect unit tests + hard cap test.
The payment_option (card/sbp) was parsed from the request but never
passed to create_pal24_payment as payment_method. Without it,
_normalize_payment_method(None) defaults to 'sbp', so both Card and
SBP buttons always created SBP payments.
user variable was unbound when user_id=None (guest purchase path).
Added user=None in the else branch to prevent NameError when
constructing the fallback email.
Two fixes for channel subscription enforcement:
1. Middleware notification guard: the deactivation notification was sent
even when deactivated_subs was empty (no subs actually deactivated).
Also fixed len(active_subs) -> len(deactivated_subs) for multi-tariff
notification text selection.
2. Webhook echo race condition: when a user quickly leaves and rejoins
a channel, the delayed user.disabled webhook from RemnaWave could
re-deactivate a subscription that was already reactivated.
Fix: stamp last_webhook_update_at on reactivation (both channel_member
handler and middleware), then guard _handle_user_disabled against
re-deactivating recently-reactivated ACTIVE subscriptions using the
existing is_recently_updated_by_webhook (60s window).
In _deactivate_subscription_on_unsubscribe, the loop calling
disable_remnawave_user iterated over all active_subs instead of only
the subscriptions that passed the should_disable_subscription check.
This caused paid subscriptions to be disabled at the RemnaWave panel
level even when disable_paid_on_leave=False, while the DB record
stayed ACTIVE. On rejoin, reactivation found no DISABLED subs in DB
so enable_remnawave_user was never called — VPN stayed off permanently.
Fixed by collecting actually-deactivated subs into a separate list
and using that for both panel disable calls and notifications.
Two bugs found during review of the previous FSM state restore fix:
1. Re-entering promo flow created nested _prev_data (unbounded growth).
Now skips save if already in PromoCodeStates.waiting_for_code and
strips _prev_ keys from saved data to prevent nesting.
2. _restore_previous_state used `if prev_state:` which treated saved
None state (user at menu) same as "no saved state". Now uses a
sentinel to distinguish the two cases, correctly restoring None
state with its data instead of calling state.clear().
When a user was in BalanceStates.waiting_for_amount and activated a
promo code, process_promocode called state.clear() on all exit paths,
wiping the balance state. Typing the amount then hit the fallback
"Не понимаю эту команду" handler.
Now show_promocode_menu saves the previous FSM state/data before
entering promo flow, and _restore_previous_state restores it after
promo code processing completes.
Platega API defines: 2=СБП, 11=Карточный эквайринг, 12=Международная,
13=Крипто. Code 10 does not exist in their API but was defined in our
config as "Банковские карты (RUB)", while real code 11 was mislabeled
as "Банковские карты". This caused two card options to appear in the
admin panel, one of which didn't work.
- Removed code 10 from definitions, defaults, allowed set, .env.example
- Renamed code 11: "Банковские карты" → "Карты (RUB)"
- Removed redundant filter in handlers/balance/platega.py
- Updated tests to match
Two bugs caused users to receive autopay error notifications every
monitoring cycle (hourly) instead of respecting the 6-hour cooldown:
1. subtract_user_balance failure path had no cooldown check at all
2. cache.exists() silently returns False when Redis is disconnected,
bypassing the try/except cooldown guard
Extracted shared _check_autopay_fail_cooldown / _set_autopay_fail_cooldown
methods with in-memory fallback dict that works even without Redis.
Added cleanup of expired in-memory entries in _cleanup_notification_cache.
In multi-tariff mode, remnawave_uuid lives on the subscription object,
not the user. The sync status and user detail endpoints were always
returning user.remnawave_uuid, causing some users to see no UUID.
Replace unsafe getattr(subscription, 'tariff', None) with sa_inspect().dict.get()
to avoid triggering lazy loads after db.commit()/refresh() in async context.
Add 'buyer' to db.refresh attribute_names so the buyer relationship
is available when building the admin notification (prevents expired
attribute access in async context). Restructure recipient icon logic
to only compute when a recipient value exists.
Cabinet gift purchases now show "ПОДАРОК ИЗ КАБИНЕТА" instead of
"ПОКУПКА В ПОДАРОК С ЛЕНДИНГА". Buyer is resolved from the user
relationship with @username. Recipient shows "по коду активации"
when no direct recipient specified. Landing page slug line removed
for cabinet purchases.
- Migrate get_device_reduction_info to get_user_devices_all (was raw _make_request)
- Migrate admin_users and miniapp callers to get_user_devices_all
- Migrate reset_user_devices internal call to paginated version
- Fix tariff_max_devices falsy-zero in handlers (use explicit is not None and > 0)
- Fix device deletion sort: dateless devices now sort last (candidates for removal)
- Use paginated get_user_devices_all in delete_all_devices and get_devices
- Fix tariff_max falsy-zero check: use explicit `is not None and > 0`
- Unify keyboard fallback to 100 in both get_devices_keyboard and change
- Remove dead expression `devices_count - current_devices`
- Fix stale error message referencing tariff minimum
- Migrate f-string logger to structlog kwargs style
1. Device decrease minimum is now always 1 (was incorrectly using
tariff.device_limit as floor, blocking decrease e.g. 3/3)
2. Cabinet "Already at minimum device limit" fixed — same root cause
3. Added get_user_devices_all() with pagination for HWID cleanup
4. add_subscription_devices now caps by tariff.max_device_limit
5. Keyboard range expanded to 100 when no global limit set (was 20)
Gift activation:
- Encode underscores as %5F in share URLs to prevent Telegram markdown corruption
- Strip GIFT-/GIFT_ prefix from URL code params in frontend
- Backend accepts both GIFT- and GIFT_ prefix on activation
- Bot shows feedback on failed gift auto-activation (self-gift, already activated)
Multi-tariff sync (panel↔bot):
- _sync_users_from_panel_multi now creates subscriptions for unmatched panel users
- sync_users_to_panel matches panel users by username suffix (_short_id) instead of taking arbitrary existing_users[0]
- Save sub.remnawave_uuid after update (was pass/noop)
- Generate remnawave_short_id for all new subscriptions
- Include activeInternalSquads in multi-tariff panel dict
- Append _short_id suffix to username in create_kwargs
Email/OAuth sync:
- _sync_subscription_from_panel_by_email loops ALL panel users in multi-tariff
- Auto-verify path now triggers panel subscription sync
- OAuth new users with verified email get panel sync
- cleanup_orphaned_subscriptions skips email-only users (was force-cleaning them)
- Silence "link for new users only" message for no-reward ad campaigns
- Show commission % in referral notifications only when > 0
- Show fixed bonus in referral notifications only when > 0
- When both bonus and commission are 0, don't promise a reward
Cherry-picked logic from PR #2822 (without version bump artifacts)
- Fix subscription tariff conflict during merge: detect
uq_subscriptions_user_tariff_active violations before they happen,
resolve by keeping the subscription with later end_date (NULL=lifetime wins)
- Add merge flow to /auth/email/register: when email belongs to another
active user, return merge_required+token instead of blocking with 400
- Fix missing remnawave_uuid on subscriptions created by panel email sync
in multi-tariff mode (_sync_subscription_from_panel_by_email)
- Add rate limiting (5/60s) to email register endpoint
- Filter deleted users from email existence check
- Reorder "already has verified email" guard before merge branch
- Clear autopay_enabled on expired subscriptions during conflict resolution
- Bot handler: add email and internal ID (#123) lookup to referral editor
(previously only supported telegram_id and @username)
- Cabinet API: add DELETE /{user_id}/referrer endpoint to unbind referrer
- Cabinet API: add DELETE /{user_id}/referrals/{referral_user_id} endpoint
to remove specific referral
- Both endpoints include permission checks and admin action logging
get_nodes_realtime_usage() now preserves the per-inbound and
per-outbound traffic stats from /api/system/nodes/metrics instead
of summing them into node-level totals. Each node in the response
includes inbounds[] and outbounds[] arrays with tag, download,
upload, and total bytes.
When a specific subscription_id is provided but that subscription
has no remnawave_uuid yet, block the sync with a clear error instead
of falling through to the all-UUID iteration which could fetch
panel data from a different subscription.
All 3 sync endpoints now accept optional subscription_id query param:
- GET /sync/status: compares specified subscription with its panel data
- POST /sync/from-panel: syncs panel data to specified subscription
- POST /sync/to-panel: pushes specified subscription to panel
In multi-tariff mode, uses subscription.remnawave_uuid for panel
lookup instead of user.remnawave_uuid. Response includes
subscription_id and subscription_tariff_name for UI context.
When subscription_id is not provided, existing first-active-sub
behavior is preserved for backward compatibility.
- instant_switch handler: redirect legacy users (tariff_id=NULL) to
tariff_switch migration flow instead of dead-end popup
- autopay skip: notify user once (7-day cooldown) when autopay is
skipped for legacy subscription, explaining they need to choose
a tariff for autopay to work
The early-return response for blocked legacy users used wrong field
name 'balance_currency' instead of 'currency' (a required field in
MiniAppSubscriptionRenewalOptionsResponse), causing Pydantic
ValidationError / 500 Internal Server Error.
Fixed to use correct field names and added status_message explaining
why renewal is blocked, plus balance_label and sales_mode fields.
When switching from configurator to tariff mode, users with old
subscriptions (tariff_id=NULL) could still renew them through
unguarded paths, bypassing tariff pricing entirely.
Vulnerable paths fixed:
- MiniApp POST /subscription/renewal/options: returns empty list
for classic subscriptions in tariff mode
- MiniApp POST /subscription/renewal: raises 400 with
classic_subscription_blocked error code
- Bot confirm_extend_subscription: blocks stale extend_period_
callbacks with tariff mode check
- Monitoring _process_autopayments: skips classic subscriptions
(tariff_id=NULL) in autopay loop when tariff mode active
Already protected (no changes needed):
- Cabinet GET/POST renewal endpoints (renewal.py:51,117)
- Auto-purchase service (_prepare_auto_extend_context:244)
- Bot handle_extend_subscription menu (purchase.py:1657)
- Tariff extend flow (tariff_purchase.py:2047)