- Remove unused unittest.mock.patch import (F401)
- Mark hardcoded /tmp/test.p8 path with noqa S108 (only used for
is_apple_iap_enabled check, no file actually accessed)
- Replace pytest.raises(Exception) with pytest.raises(ValidationError)
for Pydantic schema validation tests (B017)
CI ruff format --check failed on 13 files. Applied ruff format to bring
them in line with project formatting (line wrapping, trailing commas,
quote consistency). No functional changes.
The admin Payments page filter and pending payments tab were missing newer
providers (paypear, rollypay, aurapay, etoplatezhi, antilopay, jupiter, donut,
lava) because they were never registered in the search/verification registries.
Customer payments via these providers were invisible in admin filtering.
payment_search_service.py:
- Add 8 _search_<provider> functions matching the existing pattern
- Register them in _PROVIDER_SEARCH_MAP (now 22 methods total)
- Filter dropdown and stats.by_method now include all providers
payment_verification_service.py:
- Add 8 _is_<provider>_pending and _fetch_<provider>_payments functions
- Wire them into list_recent_pending_payments and get_payment_record
- Add display name and is_enabled dispatch branches for all 8
- Register paypear / rollypay / aurapay in SUPPORTED_MANUAL_CHECK_METHODS and
SUPPORTED_AUTO_CHECK_METHODS (they have full API+DB sync via check_*)
- Wire them into run_manual_check
- etoplatezhi / antilopay / jupiter / donut / lava remain webhook-driven and
appear in pending tab without manual-check button (no fake API sync)
- Lava Business via gate.lava.ru (HMAC-SHA256 signed JSON requests)
- Sub-methods: card and SBP via includeService filter
- Webhook signature verified from raw bytes with secret_key_2
- Sticky terminal-status guard (success after amount_mismatch escalates to ERROR)
- Order ID with full uuid4 hex (128-bit entropy)
- Cross-row contamination guard: order_id assertion on invoice_id fallback
- Warning when hook URL cannot be derived from webhook/web_api/cabinet bases
- Explicit failure when Lava response lacks payment_url (no orphan rows)
- Adds LAVA settings category, /lava-webhook endpoint, cabinet topup branch
- Mirrors existing Antilopay/Jupiter/Donut mixin pattern
- 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)
- Fix webhook signature: str(None) produced "None" (4 chars) instead of
"" like PHP implode() does, causing all webhooks with custom_fields=null
to fail signature verification
- Add AURAPAY_SBP_ENABLED / AURAPAY_CARD_ENABLED env vars with display
names, following Freekassa pattern for sub-method selection
- Add aurapay_sbp / aurapay_card buttons in payment keyboard
- Add start_aurapay_sbp_topup / start_aurapay_card_topup handlers
- Route dispatch handles aurapay / aurapay_sbp / aurapay_card
- payment_utils updated with SBP/Card availability checks
- service parameter ("sbp"/"card") now passed through to AuraPay API
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