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
The old code hashed the full raw body INCLUDING the 'signature' field
itself — a circular computation that can never match (you can't include
the signature in the data being signed).
Fix:
1. Strip 'signature' key from payload before HMAC-SHA256 computation
2. Try both sorted and unsorted keys (PayPear docs don't specify)
3. Fallback to IP allowlist check (158.160.85.101 per PayPear docs)
4. Pass client_ip from request headers to the verification function
4 bugs fixed:
1. block_user() called with User object instead of int user_id, missing admin_id
2. Response used wrong fields (user_id/status instead of old_status/new_status)
3. Return value not checked — reported success even on failure
4. unblock endpoint used DB-only update_user_status instead of UserService.unblock_user
The upload endpoint sent files to the admin notification chat to obtain
a Telegram file_id, but never deleted the staging message. Admins saw
uncontextualized images in their chat before any ticket was created.
Fix: send with disable_notification=True and immediately delete the
staging message after capturing the file_id. Telegram persists file_ids
even after message deletion.
mark_intentional_panel_deletion was called before api.delete_user,
but _is_intentional_panel_deletion_event was never called in the
webhook handler — it was dead code. The user.deleted webhook processed
unconditionally, causing a deadlock between delete_user_account (Tx1
holding subscription row locks) and the webhook handler (Tx2 trying
to lock the same rows via decrement_subscription_server_counts).
Fix: check _is_intentional_panel_deletion_event at the top of
_handle_user_deleted — if True, log and return immediately without
touching the DB.
RollyPay (and 5 others) showed buttons but triggered "payment methods
unavailable" because get_available_payment_methods() was missing them.
The keyboard builder (inline.py) had all providers, but the text
generator (payment_utils.py) did not — divergent hand-maintained lists.
Added to all 4 functions: get_available_payment_methods,
is_payment_method_available, get_payment_method_status,
get_enabled_payment_methods_count:
- SeverPay, PayPear, RollyPay, Overpay, AuraPay (new)
- RioPay (was in methods list but missing from status/count)
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.
Two bugs caused "RemnaWave UUID не найден" when a user repurchased
after their panel user was deleted (expired user cleanup):
1. Webhook handler only cleared subscription.remnawave_uuid in
multi-tariff mode. In single-tariff mode the stale UUID remained,
causing the cabinet to try update_remnawave_user on a deleted
panel user instead of creating a new one.
2. Cabinet purchase-tariff used subscription.remnawave_uuid for the
create/update decision. In single-tariff mode this was stale.
Now mirrors the bot handler logic: checks user.remnawave_uuid
in single-tariff mode (correctly cleared by webhook).
The invite message wrapped the entire text including the referral URL
in <blockquote><code>...</code></blockquote>. The <code> tag made the
URL non-clickable — Telegram renders it as monospace copyable text.
Recipients couldn't tap the link to open it.
- Invite message: removed <code> from blockquote, Telegram now auto-links the URL
- Stats panel: removed <code> from bot/cabinet referral links, URLs are now clickable
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)
The pricing engine applied promo group discounts unconditionally,
without checking if the tariff is available for the user's promo group.
In autopay: user with VIP group (60% discount, restricted to Premium
tariff) would get 60% off when auto-renewing a Basic tariff that their
group should not cover.
Fix: in _calculate_tariff_core, check tariff.is_available_for_promo_group
before applying group discounts. If tariff is not available for the
user's promo group, the discount is zeroed — subscription renews at
full price. Protects ALL pricing paths (autopay, recurrent, manual).
Add ADMIN_NOTIFICATIONS_{CATEGORY}_ENABLED settings (default True) for
all 10 notification categories: purchases, renewals, trials, balance,
addons, infrastructure, errors, promo, partners, tickets.
Setting ADMIN_NOTIFICATIONS_PROMO_ENABLED=false now completely suppresses
promo notifications (promocode activations, campaign visits, promo group
changes) instead of silently falling back to the general topic.
Also fix referral_contest_service direct bot.send_message bypass —
now respects ADMIN_NOTIFICATIONS_PROMO_ENABLED setting.
- Add get_subscription_request_history to RemnaWave API client
(GET /api/users/{uuid}/subscription-request-history with pagination)
- Add GET /admin/users/{user_id}/subscription-request-history endpoint
with subscription_id param for multi-tariff support
1. _check_expired_subscription_followups: added Subscription.status=EXPIRED
filter (was matching ALL statuses including ACTIVE), User.status=ACTIVE
filter, and 30-day lookback window to stop scanning ancient subscriptions
2. _get_expiring_paid_subscriptions: added User.status=ACTIVE filter to
prevent sending "expiring" notifications to blocked/deleted users
3. Multi-tariff: before sending expired/followup notifications, check if
user has another ACTIVE subscription with end_date > now — skip if they
still have service through another tariff
4. Multi-tariff: same check for _check_expired_subscriptions — don't send
"subscription expired" if user has another active sub
- 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().
The "Unpin all" button called deactivate_active_pinned_message() first,
then looped over users to unpin. If Telegram API calls failed or timed
out, the message was already marked inactive in the DB with no way to
retry. Now: get active message → unpin from all chats → deactivate in DB.
Restore integration hooks dropped in PR #2851 merge:
- PurchaseRequest accepts yandex_cid, referrer, subid from frontend
- Cache yandex_cid and subid in Redis at purchase creation (24h TTL)
- On fulfill_purchase: extract subid from cache, persist to DB
- Save Yandex CID from Redis to yandex_client_id_map
- Fire on_registration + S2S postback for new accounts
- Fire on_purchase + S2S postback for all paid purchases
- All hooks wrapped in try/except — failures never block delivery
Two bugs caused max promo group assignment on gift send/activate:
1. Buyer: GIFT_PAYMENT was counted in get_user_total_spent_kopeks
alongside SUBSCRIPTION_PAYMENT. Now only SUBSCRIPTION_PAYMENT
counts as personal spending for promo group auto-assignment.
2. Recipient: fulfill_purchase and activate_purchase created a
SUBSCRIPTION_PAYMENT transaction for the recipient with the full
gift price. Now skipped for gift recipients — they didn't pay.
Replace str(exc) with generic 'Action failed: internal error' in both
_execute_for_user and _execute_for_subscription catch-all blocks.
Prevents leaking internal paths, SQL details, or connection strings.
- Thread admin_id through _execute_for_user to _do_delete_user for audit trail
- Replace raw exception str(e) with generic error message in client response
- Add subscriptions=[] to failure paths to prevent MissingGreenlet