- Add revoke handler for classic and multi-tariff modes with 2-step
confirmation dialog and TOCTOU-safe cooldown enforcement
- Add cabinet API endpoint POST /subscription/revoke with 429 + Retry-After
for cooldown, IDOR protection via resolve_subscription
- Add last_revoke_at column to subscriptions (Alembic migration 0071)
- Add SUBSCRIPTION_REVOKE_ENABLED and COOLDOWN_SECONDS config settings
- Add revoke button to classic subscription settings keyboard and
multi-tariff detail keyboard (gated by feature toggle)
- Add locale keys for revoke UI in all 5 languages (ru, en, ua, zh, fa)
From PR #2923 by @dotX12, with improvements:
- Support type: "external" as alias for "externalLink" in app config
- Extract urlScheme from subscriptionLink buttons in blocks[] when not at root
- Wrap custom URL schemes in HTTPS redirect for Telegram compatibility
- Fallback to plain subscription URL when no redirect template configured
Improvements over original PR:
- Also check btn.get('url') not just btn.get('link') for scheme extraction
- Validate extracted scheme contains :// before accepting
- Skip redundant redirect wrapping when create_deep_link already wrapped
Three bugs caused trial subscriptions to be auto-renewed without a
tariff at arbitrary prices:
1. try_auto_extend_expired_after_topup: is_trial guard used truthiness
check — NULL (legacy rows) passed as falsy. Changed to
`is_trial is not False` (NULL-safe).
2. Multi-tariff branch: `not s.is_trial` treated NULL as not-trial.
Changed to `s.is_trial is False`.
3. Telegram bot autopay toggle: no is_trial guard — users could enable
autopay on trial subscriptions. Added trial check before enabling.
timedelta.days is integer floor: 29 days 23 hours = 29, not 30.
When a user bought extra devices on the same day as their subscription,
they were charged for ~1 day instead of the full remaining period.
Fix: math.ceil(total_seconds / 86400) rounds partial days UP.
Applied to all 11 locations across 4 files:
- app/handlers/subscription/devices.py (5 spots)
- app/cabinet/routes/subscription_modules/devices.py (3 spots)
- app/keyboards/inline.py (3 spots — display pricing)
- app/utils/pricing_utils.py (1 spot — traffic prorated pricing)
- Fix 7 intermediate error paths (balance deduction failures) that used
callback.answer() after the early answer was already consumed — user
got no error feedback at all
- Fix 2 unfixed handlers: confirm_tariff_purchase, confirm_daily_tariff_purchase
— same early-answer pattern applied
- All 7 purchase/extend/switch handlers now consistently use early
callback.answer() + edit_text for errors
Telegram invalidates callback queries after 30 seconds. When the bot
performed panel sync, DB transactions, and admin notifications before
answering, callback.answer() threw TelegramBadRequest: query is too old.
Moved callback.answer() to immediately after guard checks (balance,
tariff availability) in 5 handlers:
- confirm_tariff_extend
- confirm_custom_tariff_purchase
- confirm_tariff_switch
- confirm_daily_tariff_switch
- confirm_instant_switch
Error feedback now uses callback.message.edit_text() instead of the
expired callback.answer().
- 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
- 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
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).
Fixed 6 more locations in tariff_purchase.py that had the same broken
pattern (custom purchase, daily purchase, trial conversion, tariff
switch, daily switch, instant switch). All now use _should_create
based on multi-tariff mode instead of falling back to user UUID.
Same bug as the tariff purchase fix: in multi-tariff mode, new
subscriptions without remnawave_uuid incorrectly fell back to
user.remnawave_uuid and called update instead of create.
Fixed in subscription_renewal_service.py and purchase.py to use
the same _should_create pattern based on mode.
In multi-tariff mode, new subscriptions have remnawave_uuid=None.
The old logic fell back to user.remnawave_uuid (from a previous
subscription) and called update_remnawave_user(), which refused
to work because the NEW subscription had no UUID.
Now correctly: in multi-tariff mode, always CREATE if subscription
has no remnawave_uuid. In single-tariff mode, use user-level UUID.
Fixes: "subscription has no remnawave_uuid, cannot update panel"
Add remnawave_retry_queue.enqueue() calls in all 10 purchase error handlers
where RemnaWave API failure was caught and swallowed without scheduling a retry:
- cabinet purchase.py: purchase_tariff() and activate_trial() (2 places)
- subscription_purchase_service.py: miniapp purchase flow (1 place)
- tariff_purchase.py: custom, standard, daily, renewal, switch, daily-switch,
and instant-switch flows (7 places)
- 100% discount: daily tariff fallback to smallest configured period discount
- 100% discount: purchase blocked by safety guard (base_price → original_total in 6 guards)
- Gift subscription reset existing days (replace → extend for active/trial subs)
- Cabinet broadcast: target alias active_subscribers not mapped to active
- Promo code: error always "expired" — split into inactive/not_yet_valid/expired
- Multi-tariff: add delete subscription button in admin bot
- Multi-tariff → single: select subscription with most remaining time (end_date DESC)
- Gift purchases not counted in total spent (added GIFT_PAYMENT type)
- Remnawave API: retry on 502/503/504 (was only 429)
- Heleket: add from_referral_code to invoice payload
- Whitespace fix in blacklist_service
- Include LIMITED status in subscription lookups (get_active_subscriptions_by_user_id, get_subscription_by_user_and_tariff) — fixes duplicate subscriptions when traffic exhausted
- Migration 0053: update partial unique index to include LIMITED
- Trial subscriptions no longer block tariff purchase — excluded from purchased_tariff_ids, handle_extend_subscription routes trial+tariff to tariff extend flow
- Replace hardcoded TrafficLimitStrategy.MONTH with get_traffic_reset_strategy() across all sync/create paths (remnawave_service, monitoring_service, admin_users)
- Subscriptions with tariff_id always use tariff pricing flow regardless of global sales mode — fixes 0₽ renewal in classic mode
- Support 100% promo group discount across all purchase/renewal flows — balance checks skip when price=0, validation allows final_total=0 when base_price>0
- 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)
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)
- 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
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)
Classic subscriptions (without tariff_id) now cannot be renewed or
auto-renewed when tariff mode is active. Users must purchase a tariff.
Blocked in: cabinet renewal endpoints, cabinet autopay, bot autopay
toggle, and auto-purchase service.
- Replace disable_remnawave_user() with delete_remnawave_user() on subscription deletion
so the panel stops sending webhooks for deleted subscriptions
- Add early return in all webhook handlers when subscription is None (already deleted from DB):
expired, disabled, enabled, limited, traffic_reset, revoked, expiring reminders
- Add "Delete subscription" button in Telegram bot for expired/disabled subscriptions
with confirmation step and full cleanup (panel delete + server counts + DB hard delete)
- renewal.py: block renew/renewal-options for PENDING/DISABLED subscriptions
(extend_subscription doesn't transition these to ACTIVE — user would pay
for nothing)
- autopay.py: wrap 2x bare int() card_id parsing in try/except
- devices.py: wrap 2x bare int() device_count parsing in try/except
- daily_subscription_service: atomic daily charge — subtract_user_balance,
create_transaction, update_daily_charge_time all use commit=False, single
db.commit() after all three succeed. Prevents re-charge on partial failure.
- subscription.py: update_daily_charge_time accepts commit=False kwarg
HIGH fixes:
- auth.py: profile description sync now iterates all per-subscription
remnawave_uuids in multi-tariff mode
- admin_users: sync_from_panel uses subscription UUIDs for panel lookup,
does not overwrite user.remnawave_uuid in multi-tariff
MEDIUM fixes:
- monitoring_service: _send_subscription_expired_notification now takes
subscription param, uses se:{sub_id} in multi-tariff
- remnawave_webhook_service: _get_renew_keyboard accepts subscription_id,
all 7 callers pass it
- recurrent_payment_service: _build_extend_keyboard with subscription_id
- user_service: balance notification keyboards use menu_subscription in
multi-tariff instead of bare subscription_extend
- autopay.py + purchase.py: per-subscription cart deletion instead of
global delete_user_cart where subscription context available
- subscription_auto_purchase_service: 60-sec race guard changed from
per-user to per-subscription (checks subscription.updated_at)
- phantom_service: iterate all subscriptions for panel sync after claim
(was using deprecated user.subscription singular property)
- tariff_purchase: 6x delete_user_cart replaced with per-subscription
delete_subscription_cart in multi-tariff mode
- yookassa: recurrent payment subscription_id mismatch now resolves
correct subscription from metadata instead of just logging warning
- subscription_auto_purchase: try_auto_extend_expired and
try_resume_disabled_daily now query ALL subs (not just active) to find
expired/disabled subscriptions that need processing
Bot handlers (H1-H5):
- confirm_extend_subscription: error alert instead of wrong sub fallback
- open_subscription_link/subscription_connect: startswith registration
- handle_subscription_settings: multi-tariff guard
- confirm_reset_traffic: FSM state check in multi-tariff
Services (H6-H12):
- subscription_service: 5 UUID fallback fixes — no user.remnawave_uuid in
multi-tariff, return None if subscription.remnawave_uuid missing
- auto_purchase: use cart subscription_id for tariff match
- remnawave_service: migrate_squad_users checks subscription.remnawave_uuid
- campaign_service: extend existing sub or create new in multi-tariff
- broadcast_service: check ALL subs for paid-subscription guard
- blocked_users_service: remnawave_uuids list, iterate in cleanup
- user_service: log sub.remnawave_uuid in multi-tariff
Admin (H13-H16):
- grant_trial/paid_subscription: allow in multi-tariff mode
- promo_offers: pick sub with URL, aggregate squads from all subs
CRUD/Frontend (H17-H18):
- get_users_list: .unique() for outerjoin dedup
- refreshTraffic: withSubId in params instead of body
CRITICAL fixes:
- promocode_service: NameError (subscription_id not passed), TypeError (dict
returns), savepoint without commit, dead else branch
- cabinet status/autopay/renewal: resolve_subscription() instead of
user.subscription fallback in multi-tariff mode
- cabinet devices: MultipleResultsFound crash on 3 POST endpoints
- webhook service: IDOR returning cross-user subscription
- monitoring_service: real expiring notification keyboard with se:{sub_id}
HIGH fixes:
- subscription_purchase_service: FOR UPDATE on both branches of submit_purchase
- miniapp: 8 endpoints now pass subscription_id to _ensure_paid_subscription
- inline.py: se:{subscription_id} callback for expiring keyboard
- tariff_purchase: TransactionType.FAILED_REFUND + _persist_failed_refund()
- account_merge_service: panel sync after subscription transfer
- webhook service: .limit(1) on fallback queries to prevent MultipleResultsFound
servers.py + tariff_switch.py: use subscription.remnawave_uuid in multi-tariff
instead of user.remnawave_uuid to prevent duplicate panel users.
start.py: exception handler uses ['subscriptions'] (plural) for db.refresh.
my_subscriptions.py: delegation handlers pass state to downstream so
_resolve_subscription can read active_subscription_id from FSM.
admin_tariffs.py: removed trailing `or (sub.user.remnawave_uuid ...)`
that silently used wrong user-level UUID when subscription UUID was None.
tariff_purchase.py: IntegrityError handler in confirm_tariff_purchase now
restores consumed promo offer discount, matching the generic Exception handler.
select_tariff_extend_period and confirm_tariff_extend now use
_resolve_subscription instead of next() by tariff_id, preventing
wrong subscription selection with duplicate tariff_ids.
FSM state now stores active_subscription_id alongside extend_tariff_id.
confirm_daily_tariff_switch was searching for subscription matching the
TARGET tariff_id (the one being switched TO), which always returns None
since user doesn't have that tariff yet. Now uses _resolve_subscription
to get the source subscription (the one being switched FROM).
tariff_purchase.py:
- Switch lists filter ALL purchased tariffs, not just current one
- Switch handlers use _resolve_subscription (FSM state) instead of
searching by new tariff_id
- Extend shows subscription picker when >1 active subs
- All success screens return to sm:{sub_id} in multi-tariff
purchase.py:
- confirm_extend_subscription reads active_subscription_id from FSM state
common.py:
- get_reset_devices_confirm_keyboard accepts back_callback param
- get_confirm_switch_traffic_keyboard accepts back_callback param
traffic.py:
- confirm_switch_traffic passes dynamic back_callback
show_tariffs_list now fetches purchased_tariff_ids and passes them to
format_tariffs_list_text (marks with ✅) and get_tariffs_keyboard (marks button).
select_tariff blocks purchase of already-active tariff with alert showing
days remaining and directing user to "Мои подписки" for renewal.
When user clicks "Устройства" from subscription detail, shows intermediate
menu with two options:
- "Докупить устройства" (if tariff allows) → change device limit flow
- "Управление устройствами" → view/reset connected devices
Back button returns to subscription detail.
All keyboard builders (change_devices, confirm_change_devices,
devices_management, traffic_switch) now accept back_callback parameter.
In multi-tariff mode, back button returns to subscription detail (sm:{sub_id})
instead of legacy subscription_settings screen.
When user has >1 active subscription and clicks "Докупить трафик" or
"Подключиться" from main menu, now shows inline subscription picker
instead of just an alert. User selects subscription, then proceeds
to the corresponding flow. Removed redundant parse_mode (set globally).
All bot subscription handlers (traffic, autopay, devices, links, countries,
purchase, tariff_purchase) now pass state: FSMContext to _resolve_subscription
so multi-tariff subscription context is preserved from my_subscriptions flow.