The /latest endpoint was using list_recent_pending_payments which only
returns unpaid payments. By the time the user returns from the payment
provider, the webhook has already marked the payment as paid, so the
endpoint returned 404. Now queries the payment table directly without
filtering by is_paid status.
Payment providers redirect to external browser where sessionStorage is
unavailable. Now includes method in return_url query params and adds
GET /pending-payments/{method}/latest endpoint so TopUpResult can poll
payment status without sessionStorage data.
Payment providers were redirecting users back to the bot after completing
cabinet top-up payments. Now passes CABINET_URL/balance/top-up/result as
return_url to YooKassa, Platega, Heleket, WATA, and CloudPayments.
Migrations 0019, 0022, 0031 crashed with UndefinedTableError when
payment provider tables (e.g. kassa_ai_payments) or contest_templates
did not exist. Added _table_exists() checks before ALTER/DROP operations.
Replace hardcoded raw HTML length checks (len(text) <= 900/1000/1024)
with centralized caption_exceeds_telegram_limit() that strips HTML tags
and unescapes entities before measuring against the real 1024-char limit.
Fixes logo disappearing when promo discounts add HTML markup to captions.
- Fix critical concurrency issue in propagate_tariff_squads: preload
users/tariffs before asyncio.gather, use single API client, no DB
operations inside gather, single commit after all API calls
- Replace all str(e) leaks in admin_users.py with sanitized messages
- Fix double callback.answer by using callback.message.answer for
failure alerts
- Move PropagateSquadsResult to module level, use field(default_factory)
- Compute traffic_strategy once before gather instead of N times
- Add warning logging on tariff refresh failures
- Reset synced counters on commit failure for accurate reporting
- Move _propagate_squads_to_subscriptions from handler to
SubscriptionService.propagate_tariff_squads()
- Use asyncio.gather with semaphore (concurrency=5) for parallel
Remnawave API calls instead of sequential O(N)
- Track failed subscription IDs for better observability
- Fix get_all_server_squads limit=50 default in admin handlers
(now limit=10000 to prevent silent truncation)
- Add docstring to force_panel_delete parameter
- Return PropagateSquadsResult dataclass with total/synced/failed_ids
- Log disable success/failure separately instead of unconditional success
- Sanitize panel_error to not leak internal exception details to API
- Make fallback disable log conditional on actual result
Squad toggle: when admin changes servers for a tariff, the changes now
propagate to all active/trial subscriptions and sync to Remnawave panel.
Previously only took effect on new purchases.
User deletion: full delete from Cabinet now actually deletes from Remnawave
panel. Previously lied about panel deletion status and skipped deletion
for users with active subscriptions.
The _send_success_notification method was closing the DB session (via
break) before calling send_cart_notification_after_topup, causing all
post-topup auto-renewal logic to silently fail for Tribute payments.
Moved break after all work is done so the session stays open during
auto-renewal operations. Added None guard for user lookup.
- balance/main.py: single period discount on combined total (base + devices),
add promo-offer discount, fix device_limit fallback to tariff_device_limit
- pricing.py: same combined discount + promo-offer, proper device_limit
fallback matching reference (is not None check)
- admin/users.py: delegate to calculate_renewal_price() which handles both
tariff and classic modes correctly, removing classic-only calculate_subscription_price
- menu.py: use renewal_service.calculate_pricing() for both price check and
charge to ensure consistency, add try/except with user-facing error,
show actual charged amount in success message
In tariff mode, period_prices already includes servers and traffic costs.
But show_payment_methods() and get_subscription_cost() were using the classic
additive formula, adding server and traffic prices on top of the tariff price.
Example: 49₽ tariff + 150₽ server + 150₽ traffic = 349₽ shown, should be 49₽.
Now both functions detect tariff mode and only add extra device costs beyond
the tariff's device_limit. Classic mode formula unchanged.
The miniapp, legacy cabinet endpoint, auto-purchase service, and Telegram bot
handlers were using only global settings (PRICE_PER_DEVICE, MAX_DEVICES_LIMIT)
for device purchases, completely ignoring tariff-level device_price_kopeks and
max_device_limit. This allowed users to buy devices when tariff price was 0
(should be blocked) and exceed the tariff's max device limit.
Fixed in all 4 code paths:
- miniapp _build_subscription_settings + update_subscription_devices_endpoint
- cabinet legacy POST /devices (+ added subscription status check, RemnaWave sync)
- subscription_auto_purchase_service._auto_add_devices
- telegram bot handlers confirm_change_devices, execute_change_devices, confirm_add_devices
- Add try_resume_disabled_daily_after_topup() for instant resume when balance is topped up
- Fix all 5 resume paths to charge daily fee BEFORE activating subscription
- Remove unsafe inline auto-resume from add_user_balance() that bypassed fee charging
- Add NULL-safe is_daily_paused filter in subscription queries
- Use create_remnawave_user() instead of enable_remnawave_user() for full VPN panel sync
- Add DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP localization key (ru, en, fa, zh, ua)
- Add device_limit and traffic_limit_gb to classic extend cart data in
confirm_extend_subscription handler
- Add classic mode branch in cabinet renew_subscription to save
device_limit and traffic_limit_gb (previously only saved for tariffs)
- Ensure device_limit >= DEFAULT_DEVICE_LIMIT when converting trial
subscription to paid via auto-extend
- Add None guards for subscription.device_limit in both trial and
non-trial branches of _apply_extension_updates
- Quick amount buttons now calculate full renewal cost (base + devices + servers + traffic with discounts)
- Tariff mode uses tariff-specific device pricing (device_price_kopeks, device_limit)
- Broadcast inline buttons no longer crash with "no text in message to edit" on photo/video messages
- Media messages are now handled in _edit_with_photo: delete old message + send new text
All 6 registration paths now check pinned_message.send_before_menu
to send pinned message before or after the menu, matching the
existing user flow behavior.
- CloudPayments: add missing process_referral_topup, has_made_first_topup flag, and admin notification (matching other adapters)
- Promocode: handle TelegramBadRequest for broadcast messages without text (fallback to answer())
- Devices: unify price prorating to day-based calculation (matching cabinet behavior)
- Auth: pass Bot instance to process_referral_registration for campaign referral notifications
- Wata: remove WATA_TERMINAL_PUBLIC_ID from is_wata_enabled() (not used in API calls), make type field conditional, add transactionId webhook fallback
The guard silently blocked admins from deactivating active paid
subscriptions, returning a generic error with no explanation.
Admin deactivation is intentional (with confirmation step) and
should not be prevented. The guard remains in automated processes
(monitoring, broadcast, user_service) where it makes sense.
- Refresh purchase.user relationship after setting user_id to fix
stale None value that prevented Telegram gift notifications
- Route gift purchases with expired subscriptions through
PENDING_ACTIVATION instead of auto-activating
- Hide subscription URL from gift buyer in API response
- New gift_activation handler for gift_activate:{id} callback buttons
- Send Telegram notification to gift recipients with activate button
- Add skip_notification param to activate_purchase to prevent duplicates
- Fix telegram username regex minimum length (4→5 chars) in landing routes
- Add BOT_TOKEN guard in telegram gift notification sender
- Pre-resolve notification params before commit to avoid DetachedInstanceError
- subscription_renewed: new_end_date → new_expires_at
- subscription_activated: end_date → expires_at
- Added traffic_limit_gb and device_limit to both types
- Fixed SAMPLE_CONTEXTS keys to match
- Defense-in-depth: strip \r\n from context values in subject line
substitution to prevent email header injection at composition layer
- Add missing tariff_name to classic mode subscription notification context
(prevents literal {tariff_name} in DB override templates)
- Remove SQLAlchemy model object from notification context dict
(str() on model produces garbage in DB override templates)
Same bug as notification_delivery_service — send_test_email used
get_template_override (raw template) instead of get_rendered_override.
Admins testing custom templates saw literal {days_left} placeholders
instead of sample values.
The send_notification method was calling get_template_override which
returns raw template HTML without variable substitution. Placeholders
like {days_left} and {expires_at} were sent to users as literal text.
Switched to get_rendered_override which properly substitutes context
variables via str.replace before wrapping in the base email template.
Missed the create_paid_subscription branch (line 275) in the previous
commit — now all 4 call sites consistently use `or []` to guard against
None from tariff.allowed_squads JSON column.
- Remove `or settings.DEFAULT_DEVICE_LIMIT` from fulfill_purchase expired
subscription branch — tariff.device_limit is NOT NULL so the `or` pattern
would incorrectly convert 0 (unlimited) to DEFAULT_DEVICE_LIMIT
- Add `or []` guard to connected_squads in activate_purchase to match
replace_subscription's list[str] type contract (tariff.allowed_squads
can be None from JSON column)
When a user with an expired subscription makes a landing page purchase,
the code tried to INSERT a new subscription, violating the user_id
unique constraint. Now uses replace_subscription for expired/inactive
subscriptions instead of create_paid_subscription.
Add total_amount as an alias for cart_total in format() calls so custom
locale overrides using {total_amount} are properly substituted instead
of appearing as literal text in user messages.
Replace pg_class lookup with information_schema.table_constraints query
that is schema-qualified and consistent with migration 0028 pattern.
Fixes constraint detection on fresh installs where create_all() creates
constraints that pg_class lookups could miss.
inspector.get_unique_constraints() fails to detect constraints created
by Base.metadata.create_all() on fresh installs, causing
DuplicateTableError. Query pg_class directly for reliable detection.
The column was left over from the old schema before prize_type/prize_value
refactoring. Its NOT NULL constraint caused INSERT failures since the
SQLAlchemy model no longer includes it.