Cabinet admin needs target_meta.tariff_id to render the tariff badge in
the tasks list. Adds target_meta and reward_meta fields to the compact
TaskListItem schema (defaults to {} for backward compatibility).
- 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
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
- Add campaign_id and partner_id query params to GET /admin/users
- Filter users by advertising campaign via EXISTS subquery on registrations
- Filter users by partner via JOIN campaign registrations → campaigns
- Add DELETE_USER bulk action type with delete_from_panel param
- Handler calls UserService.delete_user_account for full bot+panel removal
- Permission check: users:delete required for delete_user action
- Add to _USER_LEVEL_ACTIONS (operates on user_ids, not subscription_ids)
- Add replaces_tab column to InfoPage ('faq','rules','privacy','offer')
- Migration 0067: add nullable replaces_tab column
- CRUD: clear_replaces_tab ensures one page per tab, get_tab_replacements
returns {tab: slug} mapping for active pages
- Admin routes: auto-clear old assignment on create/update
- Public route: GET /info-pages/tab-replacements (no auth)
- Schemas: replaces_tab with regex validation in all request/response models
- Add page_type column to InfoPage model ('page' or 'faq')
- Migration 0066: ALTER TABLE ADD COLUMN with server_default='page'
- Update schemas with page_type field and regex validation
- Update CRUD: page_type in create, filter in list
- Update admin/public routes with page_type query filter
- Backward compatible: existing pages default to type 'page'
- Remove triple-redundant slug index: keep only unique=True on column
(PostgreSQL creates unique index automatically), remove __table_args__
index and explicit create_index in migration
- Type ReorderRequest.items with ReorderItem(id: int, sort_order: int)
instead of raw dict — prevents unvalidated input causing 500
- Migration downgrade: just drop_table (unique constraint drops with it)
- InfoPage model: slug, title (JSONB locale dict), content (JSONB),
is_active, sort_order, icon, created_at/updated_at
- CRUD: create, get by id/slug, list, update, delete, reorder
- Admin routes: /admin/info-pages with full CRUD, toggle-active, reorder
(permissions: settings:read/settings:edit)
- Public routes: /info-pages list active, /info-pages/{slug} get by slug
- Migration 0065: create info_pages table with unique slug index
- Custom pages support: admins can create any info page with any slug
parse_mode='HTML' was missing from message sends during /start
registration. HTML tags like <a href="..."> were shown as literal
text instead of rendered links.
Fixed in 4 places:
- Privacy policy edit_text (line 1151)
- Privacy policy fallback answer (line 1158)
- Welcome/offer text in complete_registration_from_callback (line 1729)
- Welcome/offer text in complete_registration (line 2084)
tariff_id query param now accepts comma-separated IDs (e.g.
tariff_id=1,3,5). CRUD functions updated to use IN() operator
for multi-tariff server-side filtering. Pagination works correctly
with multiple tariffs selected.
Deactivates user in RemnaWave panel first, then deletes subscription
with related SubscriptionServer and TrafficPurchase records.
Subscription-level action (works with subscription_ids targeting).
- Add SET_DEVICES bulk action: sets device_limit on subscriptions,
syncs to RemnaWave panel (subscription-level action)
- Add device_limit to SubscriptionListItem and BulkSubscriptionInfo
schemas for frontend display
- Populate device_limit in _build_user_list_item and
_build_subscription_info helpers
subscriptions were only populated when is_multi_tariff_enabled()
was true. Users with multiple subscriptions in regular tariff mode
had empty subscriptions[] — bulk actions couldn't show or select them.
Now subscriptions are always populated regardless of tariff mode.
_execute_for_subscription accessed user.subscriptions after commit,
triggering async lazy load → MissingGreenlet. Actions committed
successfully but reported as failed with cryptic error message.
Fix: use _build_subscription_info([sub]) with the already-loaded
targeted subscription instead of trying to lazy-load the full
user.subscriptions list.
- Add subscription_ids to BulkExecuteRequest (mutually exclusive with
user_ids via model_validator). Admins can now target specific
subscriptions instead of auto-resolving the first active one.
- Add _execute_for_subscription dispatcher that loads subscription by
ID, gets user, and passes sub_override to action handlers
- Add sub_override parameter to all 5 subscription-level handlers
(extend, cancel, activate, change_tariff, add_traffic) — bypasses
_resolve_subscription when a specific subscription is targeted
- Add SubscriptionListItem to UserListItem response — in multi-tariff
mode, each user row includes all their subscriptions with tariff
name, status, days remaining, traffic info
- User-level actions (add_balance, assign_promo_group, grant) reject
subscription_ids with 400
- Add subscription_id field to BulkUserResult and SSE progress events
- Add _stream_bulk_execute_subscriptions SSE generator
- Backward compatible: existing user_ids requests work unchanged
The bulk actions page filters (subscription_status, tariff_id,
promo_group_id) were sent by the frontend but ignored by the
backend — the list_users endpoint had no such parameters.
- Add subscription_status, tariff_id, promo_group_id query params
to GET /cabinet/admin/users
- Add subscription-level filtering via subquery in get_users_list
and get_users_count CRUD functions
- Add tariff_id, tariff_name, traffic_used_gb, traffic_limit_gb,
device_limit, days_remaining to UserListItem response schema
- Populate tariff info from subscription.tariff relationship in
_build_user_list_item
When switching to a tariff with empty/null allowed_squads, the old
tariff's squads were preserved on the subscription. Now always sets
connected_squads from the new tariff (or empty list).
- Add SSE streaming mode (?stream=true): per-user progress events with
real-time success/error counts, final summary event
- Add GRANT_SUBSCRIPTION action: creates new subscription with tariff,
skips users who already have that tariff (multi-tariff aware),
handles IntegrityError with graceful rollback
- Add BulkSubscriptionInfo: returns all user's subscriptions in each
result for multi-tariff visibility
- Refactor: extract _validate_and_prepare and _execute_for_user helpers
shared by streaming and non-streaming paths
- Add db.rollback() in per-user exception handler to prevent session
poisoning (one failed commit would abort all subsequent users)
- Add multi-tariff duplicate subscription check in activate_subscription
(prevents uq_subscriptions_user_tariff_active violation)
- Add multi-tariff duplicate subscription check in change_tariff
(prevents switching to a tariff the user already holds)
Add POST /cabinet/admin/bulk/execute endpoint for applying operations
to multiple users at once (up to 500 per request):
- extend_subscription / add_days: extend subscription by N days
- cancel_subscription: deactivate and expire subscriptions
- activate_subscription: reactivate expired subscriptions
- change_tariff: switch tariff without changing remaining days
- add_traffic: add extra GB to subscription traffic
- add_balance: credit balance with transaction record
- assign_promo_group: set or remove promo group for users
Features: dry_run preview mode, partial success handling (per-user
try/except), param validation before loop, auto-panel sync after
subscription mutations, multi-tariff mode support
On first startup with empty database, ensure_servers_synced() fetched
squads from RemnaWave but create_server_squad() failed with
ValueError('Server squad must be linked to at least one promo group')
because no default promo group existed yet.
Now _get_default_promo_group_id() auto-creates a default promo group
via _get_or_create_default_promo_group() when none exists, matching
the pattern used throughout the codebase for user registration.
RemnaWave API requires username >= 3 characters. Users with short
Telegram names (e.g. "Su") produced 2-char usernames that failed
validation, preventing subscription sync after payment.
Now format_remnawave_username pads short results with the user
identifier (telegram_id/email/user_id) to ensure minimum length.
The get_inactive_users query filtered only by last_activity (last bot
interaction), ignoring subscription end dates. Users who bought long
subscriptions (3-6-12 months) but didn't interact with the bot got
flagged as inactive and deleted+banned while their subscription was
still active or recently expired.
Fix: add SQL subquery excluding users who have ANY subscription with
end_date >= threshold_date. A user is now only deletable when BOTH
their last_activity AND their latest subscription end_date are older
than the configured inactivity period.
Previously the bot only responded "Не понимаю эту команду" to text
messages. Photos, videos, documents and stickers sent outside of an
active FSM state were silently ignored, causing users to think their
media was received when it wasn't (e.g. support ticket screenshots
sent as separate messages).
Now the bot replies with the same "use menu buttons" message for any
unhandled media when no FSM state is active.
Prevent enabling analytics_view/click without providing the
corresponding goal identifier, which would result in empty
Yandex Metrika calls on the frontend.
- Add if has_media else None guards in user-side ticket handlers
(create_ticket, add_message) matching admin handler pattern
- Fix Telegram notification using resolved primary_file_id/primary_type
instead of raw request fields for gallery messages
- Narrow except Exception to (TypeError, KeyError, ValueError) in
_message_to_response with warning log for debugging
- Add media_items parameter to TicketCRUD.create_ticket and
TicketCRUD.add_message for CRUD layer parity
- Add TicketMediaItemResponse and media_items field to webapi
TicketMessageResponse to prevent data loss on read
- Add media_items JSONB column to TicketMessage model for multi-media
gallery support (photos/videos/documents in one bubble)
- Add TicketMediaItem schema with type validation and shared
_validate_media_bundle helper (max 10 items, legacy field compat)
- Update admin and user ticket handlers to store media_items and
back-fill legacy media_type/media_file_id/media_caption from first
item for backward compatibility
- Update _message_to_response in both admin and user routes to include
media_items in API responses
- Allow empty message text when media is attached (message field now
defaults to empty string with model validator ensuring text or media)
- Add migration 0061 with idempotent column check
Based on PR #2869 by @smediainfo — CI/CD workflow changes excluded
(hardcoded version strings would regress dynamic manifest reading)
- Increase icon max_length from 10 to 100 in all three schemas
(MenuButtonConfig, ButtonUpdateRequest, AddCustomButtonRequest)
to support Telegram Custom Emoji IDs
- Add traffic_topup_enabled condition to ButtonConditions schema
- Remove shadowing local imports of MenuLayoutService in
routes/menu_layout.py (top-level import already provides access)
- 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
- pricing_engine: use shortest period for daily rate comparison instead
of period closest to remaining_days — fixes incorrect free/zero cost
for upgrades when tariffs have different period sets
- pricing_engine: remove unused target_days parameter from
get_tariff_daily_rate_fraction
- admin_users: add duplicate subscription check before create,
change_tariff and activate actions to prevent UniqueViolationError
on uq_subscriptions_user_tariff_active constraint
- admin_users: add IntegrityError fallback on create as TOCTOU safety net
- 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
Трейс показал: subscription.user падает на lazy-load → pool._checkout →
do_ping → await_ → MissingGreenlet. SQLAlchemy 2.0 async session не
поддерживает sync-lazy-load для relationships. Причина рассинхрона:
lock_user_for_pricing делает populate_existing=True + selectinload(
User.subscriptions).selectinload(Subscription.tariff), что разгружает
Subscription.user backref для сестринских подписок того же user.
Последующее обращение sub.user у другой подписки падает.
Фикс: захватываем (sub_id, user_id) пары ДО цикла, каждую итерацию
делаем fresh refetch через async select с eager load user+tariff+
promo_group. Никаких lazy access в горячем пути. В except используем
локально захваченные id вместо getattr(subscription, ...), чтобы
логирование не падало каскадом на expired объекте.
- subtract_user_balance: пишем promo_offer_log в отдельной сессии вместо rollback после commit, который экспайрил объекты основной сессии и ломал последующие обращения к subscription/user attrs
- monitoring_service._process_autopayments: перезагружаем subscription с eager-load user/tariff после списания, оборачиваем каждую итерацию в try/except + rollback, чтобы одна ошибка не валила весь батч
- logging_config: новый processor _auto_capture_exc_info автоматически подтягивает traceback из sys.exc_info() или error-kwarg → полный traceback в файле, консоли и Telegram без exc_info=True на каждом вызове
- logging_handler: дублирующая логика захвата exc_info в TelegramNotifierProcessor как резерв
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).
Users with daily subscriptions and low balance were getting
"Подписка приостановлена" notification every 30 minutes (on each
charge cycle). Now rate-limited via Redis cache to max 1 notification
per 6 hours per subscription.
Top registrations list was summing DEPOSIT + SUBSCRIPTION_PAYMENT
transactions (total user spending), while period comparison revenue
only counted DEPOSIT with real payment methods (actual money paid in).
Now both use the same calculation: only DEPOSIT transactions with
real payment methods. This fixes the discrepancy where a user showed
500₽ in the list but total revenue was 250₽.
RollyPay was missing:
- 7 CRUD wrapper functions in payment_service.py (create, get, update, link)
- Guest payment block in create_guest_payment()
Both were present for PayPear and AuraPay but omitted for RollyPay.
- bot_configuration.py: added PAYPEAR/ROLLYPAY to payment categories
and test payment buttons
- system_settings_service.py: added category titles, descriptions,
and prefix mappings for PAYPEAR_* and ROLLYPAY_* settings
- Mixin accepts payment_method_type parameter (None = show all on form)
- API service sends payment_method only when specified
- Cabinet route passes payment_option to mixin
- Config service has sub_options: sbp, card, crypto
- Keyboard label no longer hardcodes "СБП"
Changed defaults from 10 attempts × 5min (50min total) to
72 attempts × 10min (12 hours total). When nalog.ru is temporarily
down, receipts now retry for 12 hours before being dropped,
giving the service enough time to recover.
The previous fix only covered /start command paths. The more common
show_main_menu and handle_back_to_menu in menu.py used their own
is_active check which returned False for limited status.
Now both paths treat limited subscriptions as active for UI, matching
the _calculate_subscription_flags fix in start.py.
When Remnawave webhook set subscription status to 'limited' (traffic
exhausted), the main menu hid ALL buttons (connect, subscription,
buy traffic) because is_active returns False for non-'active' status.
Now 'limited' is treated as active for UI purposes — the subscription
is not expired, just traffic-exhausted. Users can see the "Buy traffic"
button precisely when they need it most.
- Default balance_low_enabled changed to False (opt-in via cabinet)
- Quiet hours: alerts skipped between 22:00-09:00 UTC
- Only alerts when subscription expires within LOW_BALANCE_ALERT_EXPIRY_DAYS (default 3)
- Added inline "Top up" button linking to cabinet miniapp
- Synced defaults across notification_prefs, cabinet notifications route
* fix: update subscription_crypto_link when syncing user from panel (#2867)
* fix: update subscription_crypto_link when syncing user from panel
* fix: update subscription_crypto_link when syncing user from panel
* fix: use PROXY_URL for Telegram OIDC JWKS requests (#2866)
* feat: add TELEGRAM_API_URL for custom Telegram Bot API server
Support custom Telegram Bot API server URL via TELEGRAM_API_URL env var.
Enables bot operation in regions where api.telegram.org is blocked
(Cloudflare Worker, self-hosted telegram-bot-api, nginx reverse proxy).
Uses native aiogram TelegramAPIServer.from_base(), works with PROXY_URL.
* fix(ci): read Docker image version from release-please manifest instead of hardcoding (#2859)
The x-release-please-version markers in workflow files were stuck at v3.7.0
since commit 5070bb34 removed them from extra-files (GitHub Actions returns
403 when release-please tries to modify .github/workflows/ via GITHUB_TOKEN).
Instead of hardcoding the version, read it from .release-please-manifest.json
at build time. This file is always kept in sync by release-please and does
not require workflow file write permissions.
---
Маркеры x-release-please-version в workflow-файлах застряли на v3.7.0
после коммита 5070bb34, который удалил их из extra-files (GitHub Actions
возвращает 403 при попытке release-please изменить .github/workflows/
через GITHUB_TOKEN).
Вместо хардкода версии теперь читаем её из .release-please-manifest.json
во время сборки. Этот файл всегда синхронизируется release-please и не
требует прав на запись в workflow-файлы.
* fix: format telegram_auth.py to use single quotes (ruff)
* fix: remove daily tariff fallback to smallest period discount
Daily tariffs (period_days=1) incorrectly inherited the discount
of the smallest configured period (e.g. 90 days -> 5%). This caused
daily prices to show discounts that were never intended for them.
Now daily tariffs only get a discount if explicitly configured for
period_days=1 in the promo group's period_discounts.
* fix: use CABINET_URL for campaign web links instead of MINIAPP_CUSTOM_URL
Campaign web links were generated from MINIAPP_CUSTOM_URL which is often
empty, causing get_campaign_web_link() to return None. Admins and partners
could only share bot links for campaigns, not cabinet links.
Now prefers CABINET_URL (where the auth flow captures ?campaign= param),
falling back to MINIAPP_CUSTOM_URL for backwards compatibility. This is
consistent with how referral web links already use CABINET_URL.
* feat: add DISPLAY_NAME_RESTRICTION_ENABLED toggle
Allows disabling the display name restriction middleware via .env.
Users with special characters in their Telegram name (e.g. "@")
were blocked from using the bot entirely. Default: true (enabled).
Set DISPLAY_NAME_RESTRICTION_ENABLED=false to disable.
* fix: allow clearing all period discounts from promo groups
Empty period_discounts dict was normalized to None by the schema,
making it indistinguishable from "field absent" (don't update).
Now empty dict passes through to CRUD which correctly sets
period_discounts=None in DB, clearing all discounts.
* fix: create panel user instead of update for new subscriptions in multi-tariff 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"
* fix: apply same create-vs-update fix to renewal and purchase flows
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.
* fix: apply create-vs-update fix to all remaining tariff_purchase flows
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.
* fix: apply create-vs-update fix to cabinet traffic/devices and monitoring
Same multi-tariff create-vs-update bug in 5 more locations:
- cabinet/subscription_modules/traffic.py (2 instances)
- cabinet/subscription_modules/devices.py (2 instances)
- services/monitoring_service.py (1 instance)
All now use _should_create pattern based on subscription.remnawave_uuid
in multi-tariff mode instead of falling back to user.remnawave_uuid.
* fix: ruff format traffic.py and monitoring_service.py
---------
Co-authored-by: Dmitry V. Lunin <49199230+BlackRaincoat@users.noreply.github.com>
Co-authored-by: Gary Jarrel <gary@jarrel.com.au>
* fix: update subscription_crypto_link when syncing user from panel (#2867)
* fix: update subscription_crypto_link when syncing user from panel
* fix: update subscription_crypto_link when syncing user from panel
* fix: use PROXY_URL for Telegram OIDC JWKS requests (#2866)
* feat: add TELEGRAM_API_URL for custom Telegram Bot API server
Support custom Telegram Bot API server URL via TELEGRAM_API_URL env var.
Enables bot operation in regions where api.telegram.org is blocked
(Cloudflare Worker, self-hosted telegram-bot-api, nginx reverse proxy).
Uses native aiogram TelegramAPIServer.from_base(), works with PROXY_URL.
* fix(ci): read Docker image version from release-please manifest instead of hardcoding (#2859)
The x-release-please-version markers in workflow files were stuck at v3.7.0
since commit 5070bb34 removed them from extra-files (GitHub Actions returns
403 when release-please tries to modify .github/workflows/ via GITHUB_TOKEN).
Instead of hardcoding the version, read it from .release-please-manifest.json
at build time. This file is always kept in sync by release-please and does
not require workflow file write permissions.
---
Маркеры x-release-please-version в workflow-файлах застряли на v3.7.0
после коммита 5070bb34, который удалил их из extra-files (GitHub Actions
возвращает 403 при попытке release-please изменить .github/workflows/
через GITHUB_TOKEN).
Вместо хардкода версии теперь читаем её из .release-please-manifest.json
во время сборки. Этот файл всегда синхронизируется release-please и не
требует прав на запись в workflow-файлы.
* fix: format telegram_auth.py to use single quotes (ruff)
* fix: remove daily tariff fallback to smallest period discount
Daily tariffs (period_days=1) incorrectly inherited the discount
of the smallest configured period (e.g. 90 days -> 5%). This caused
daily prices to show discounts that were never intended for them.
Now daily tariffs only get a discount if explicitly configured for
period_days=1 in the promo group's period_discounts.
* fix: use CABINET_URL for campaign web links instead of MINIAPP_CUSTOM_URL
Campaign web links were generated from MINIAPP_CUSTOM_URL which is often
empty, causing get_campaign_web_link() to return None. Admins and partners
could only share bot links for campaigns, not cabinet links.
Now prefers CABINET_URL (where the auth flow captures ?campaign= param),
falling back to MINIAPP_CUSTOM_URL for backwards compatibility. This is
consistent with how referral web links already use CABINET_URL.
* feat: add DISPLAY_NAME_RESTRICTION_ENABLED toggle
Allows disabling the display name restriction middleware via .env.
Users with special characters in their Telegram name (e.g. "@")
were blocked from using the bot entirely. Default: true (enabled).
Set DISPLAY_NAME_RESTRICTION_ENABLED=false to disable.
* fix: allow clearing all period discounts from promo groups
Empty period_discounts dict was normalized to None by the schema,
making it indistinguishable from "field absent" (don't update).
Now empty dict passes through to CRUD which correctly sets
period_discounts=None in DB, clearing all discounts.
* fix: create panel user instead of update for new subscriptions in multi-tariff 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"
* fix: apply same create-vs-update fix to renewal and purchase flows
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.
* fix: apply create-vs-update fix to all remaining tariff_purchase flows
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.
* fix: apply create-vs-update fix to cabinet traffic/devices and monitoring
Same multi-tariff create-vs-update bug in 5 more locations:
- cabinet/subscription_modules/traffic.py (2 instances)
- cabinet/subscription_modules/devices.py (2 instances)
- services/monitoring_service.py (1 instance)
All now use _should_create pattern based on subscription.remnawave_uuid
in multi-tariff mode instead of falling back to user.remnawave_uuid.
* fix: ruff format traffic.py and monitoring_service.py
---------
Co-authored-by: Dmitry V. Lunin <49199230+BlackRaincoat@users.noreply.github.com>
Co-authored-by: Gary Jarrel <gary@jarrel.com.au>
Same multi-tariff create-vs-update bug in 5 more locations:
- cabinet/subscription_modules/traffic.py (2 instances)
- cabinet/subscription_modules/devices.py (2 instances)
- services/monitoring_service.py (1 instance)
All now use _should_create pattern based on subscription.remnawave_uuid
in multi-tariff mode instead of falling back to user.remnawave_uuid.
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"
Empty period_discounts dict was normalized to None by the schema,
making it indistinguishable from "field absent" (don't update).
Now empty dict passes through to CRUD which correctly sets
period_discounts=None in DB, clearing all discounts.
Allows disabling the display name restriction middleware via .env.
Users with special characters in their Telegram name (e.g. "@")
were blocked from using the bot entirely. Default: true (enabled).
Set DISPLAY_NAME_RESTRICTION_ENABLED=false to disable.
Campaign web links were generated from MINIAPP_CUSTOM_URL which is often
empty, causing get_campaign_web_link() to return None. Admins and partners
could only share bot links for campaigns, not cabinet links.
Now prefers CABINET_URL (where the auth flow captures ?campaign= param),
falling back to MINIAPP_CUSTOM_URL for backwards compatibility. This is
consistent with how referral web links already use CABINET_URL.
Daily tariffs (period_days=1) incorrectly inherited the discount
of the smallest configured period (e.g. 90 days -> 5%). This caused
daily prices to show discounts that were never intended for them.
Now daily tariffs only get a discount if explicitly configured for
period_days=1 in the promo group's period_discounts.
The x-release-please-version markers in workflow files were stuck at v3.7.0
since commit 5070bb34 removed them from extra-files (GitHub Actions returns
403 when release-please tries to modify .github/workflows/ via GITHUB_TOKEN).
Instead of hardcoding the version, read it from .release-please-manifest.json
at build time. This file is always kept in sync by release-please and does
not require workflow file write permissions.
---
Маркеры x-release-please-version в workflow-файлах застряли на v3.7.0
после коммита 5070bb34, который удалил их из extra-files (GitHub Actions
возвращает 403 при попытке release-please изменить .github/workflows/
через GITHUB_TOKEN).
Вместо хардкода версии теперь читаем её из .release-please-manifest.json
во время сборки. Этот файл всегда синхронизируется release-please и не
требует прав на запись в workflow-файлы.
Support custom Telegram Bot API server URL via TELEGRAM_API_URL env var.
Enables bot operation in regions where api.telegram.org is blocked
(Cloudflare Worker, self-hosted telegram-bot-api, nginx reverse proxy).
Uses native aiogram TelegramAPIServer.from_base(), works with PROXY_URL.
In multi-subscription mode, a user with an expired trial AND an active
paid subscription was incorrectly included in expired broadcast targets.
Fixed in 3 places:
- get_target_users_count (SQL): added NOT EXISTS active sub subquery
- get_target_users 'expired' (Python): skip if has_active
- get_target_users 'expired_subscribers' (Python): same check
MAX_BUTTONS_PER_ROW was 3, causing Pydantic 422 when adding multiple
custom buttons to a row. Telegram allows up to 8 buttons per row.
Also added tg:// to URL_PATTERN so admins can use Telegram deep links
(tg://resolve, tg://user, etc.) in custom menu buttons. webapp mode
still requires https:// as enforced by existing validation.
Admin user device editor had hardcoded limit of 10 in text, validation,
and inline buttons. Now uses settings.MAX_DEVICES_LIMIT dynamically,
generating buttons 1..N in rows of 4. Falls back to text-only input
if limit exceeds Telegram's 100-button cap.
The _max_attempts property existed but was never checked as a limit.
Receipts were retried indefinitely (55+ attempts observed in logs).
Now receipts exceeding max_attempts are removed from the queue.
Campaign slug was lost during email registration flow — it was only
sent at verification time from localStorage, which is empty if the
user opens the verification link in a different browser/webview.
Now campaign_slug is accepted in the registration request, saved to
user.pending_campaign_slug, and used as fallback during email
verification. Also processed immediately for auto-verified test emails.
- Add _check_low_balance_alerts to monitoring service
- Notify users with autopay when balance drops below their threshold
- Uses notification_prefs helper for per-user settings
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)
Introduces resync_user_subscriptions_with_panel(), a standalone async
helper that re-pushes all active subscriptions to the RemnaWave panel
after any identity change (TG linking, account merge, email verification).
Handles multi-tariff vs. single-tariff mode, create vs. update branching,
tariff eager-loading, and returns a synced/failed/total stats dict.
- 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
- Fix NameError in admin_users.py: re-add get_traffic_reset_strategy import that ruff auto-removed
- user.deleted: remove auto-recreation logic — deleted means deleted, no more recreating users back in panel
- user.deleted: deactivate primary subscription unconditionally (expire + clear all linkage)
- user.deleted: sweep sibling subscriptions — verify each via panel API, deactivate only those whose panel user is gone (safe for multi-tariff where only one of N panel users may be deleted)
- Works across multi-tariff, single-tariff, and classic modes
- torrent_blocker.report is now a dual event: admin notification + user message
- New _handle_torrent_detected user handler sends WEBHOOK_TORRENT_DETECTED
- process_event handles events registered in both admin and user handlers
- Webhook router passes DB session for dual events (needs_db_session check)
- Add WEBHOOK_NOTIFY_TORRENT_DETECTED setting (default: true)
- Add WEBHOOK_TORRENT_DETECTED locale texts (ru/en/ua/zh/fa)
- 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
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)
SeverPay was missing from the cabinet create_topup() endpoint,
causing "This payment method is only available through the Telegram bot"
error when users tried to pay via SeverPay in the web cabinet.
When MULTI_TARIFF_ENABLED=true users can have multiple subscriptions,
so notifications must identify which tariff they relate to.
- Webhook notifications: _notify_user auto-injects tariff_label from
subscription.tariff.name into all 16 webhook notification strings
- Monitoring service: tariff labels in expired, expiring, trial ending,
follow-up waves, autopay success/failed notifications
- Daily subscription: tariff in insufficient balance notification
- Recurrent payments: tariff in card autopay success/failed
- Auto-purchase: tariff in all 4 auto-purchase notification paths,
with pre-captured names to avoid MissingGreenlet after db.commit()
- Channel checker: plural form for multi-subscription deactivation
- Payment providers: tariff in YooKassa and Stars activation messages
- Admin: tariff in bulk expiry reminder
- Localization: {tariff_label} in 20 notification keys across all 5
locales (ru, en, ua, zh, fa) + _MULTI channel keys
- Fix: selectinload(Subscription.tariff) in trial expiring query
- Fix: capture tariff_name before expire_subscription to prevent
MissingGreenlet from db.refresh() expiring ORM relationships
Previously test squad was added to only one subscription. Now iterates
all active non-daily subscriptions and adds the test squad to each,
creating SubscriptionTemporaryAccess entries per subscription and
syncing each with RemnaWave.
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.
- Add tariff_id column to promocodes table (migration 0052)
- Admin can now select any tariff when creating trial_subscription promo
- Activation uses promocode.tariff_id if set, falls back to system
trial tariff
- Multi-tariff: blocks trial only if user already has that specific tariff
- Remove duplicate `from app.config import settings` inside function that
shadowed the module-level import, causing UnboundLocalError for all
SUBSCRIPTION_DAYS and TRIAL_SUBSCRIPTION promo types
- TRIAL_SUBSCRIPTION now resolves trial tariff via get_trial_tariff() /
TRIAL_TARIFF_ID and passes tariff_id, traffic_limit_gb, device_limit,
connected_squads to create_trial_subscription (was creating bare trial
without any tariff params)
- Multi-tariff: trial promo only blocks if user already has the specific
trial tariff, not any active subscription
- Cabinet endpoint now accepts subscription_id and returns
select_subscription response for multi-tariff SUBSCRIPTION_DAYS promos
When subscription.remnawave_uuid was None, the fallback to user.remnawave_uuid
caused the new subscription to overwrite the existing panel user instead of
creating a separate one.
In multi-tariff mode _resolve_admin_subscription was returning any active
subscription regardless of tariff_id, causing new tariff purchases to
overwrite the existing RemnaWave user instead of creating a new one.
- 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)
When user clicks /start ref_CODE, the referral code was stored only in
FSM state. If the user opened miniapp/cabinet before completing bot
registration, the referral was lost.
Now /start immediately saves pending_referral:{telegram_id} to Redis
(7-day TTL). The referral is consumed by whichever path creates the
user first — bot create_user(), cabinet auth (initdata/widget/oidc).
Redis key is cleared after consumption to prevent double-referral.
- referral_service: save/get/clear_pending_referral Redis helpers
- start.py: save pending referral for new users only
- crud/user.py: create_user checks Redis if no referred_by_id
- cabinet/auth.py: initdata/widget/oidc routes check + cleanup Redis
- 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
When partial unique index (user_id + tariff_id) fires on concurrent or
duplicate tariff purchase via cabinet, the balance was already deducted
but no subscription was created. Now catches IntegrityError, rollbacks,
refunds via add_user_balance, and returns HTTP 409.
In multi-tariff mode, _handle_subscription_merge transfers ALL secondary
subscriptions to primary. Step 14 then iterated stale secondary.subscriptions
and nulled their remnawave_uuid, breaking the panel link for transferred subs.
Removed the UUID-nulling loop since all subs are already on primary.
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)
- inline.py: open_subscription_link/subscription_connect callbacks now include
:{subscription_id} suffix in multi-tariff mode. Main menu uses subscription_connect
(picker) instead of bare open_subscription_link.
- guest_purchase_service: activate_purchase non-tariff path uses proper ordering
(non-daily, max days_left) instead of arbitrary _active[0]
- monitoring_service: _send_expired_day1_notification and discount notification
keyboards use se:{subscription.id} in multi-tariff (2 more hardcoded callbacks fixed)
- admin/tariffs: delete_tariff_confirmed now checks active subscription count
before deletion (RESTRICT FK). Prompt shows blocking message when active subs exist.
New CRUD function get_active_subscriptions_count_by_tariff_id.
- 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
- remnawave_service: sync_users_to_panel uses sub.remnawave_uuid in
multi-tariff instead of user.remnawave_uuid (was targeting wrong panel user)
- admin/users: admin_buy_subscription_execute saves UUID to
subscription.remnawave_uuid in multi-tariff (was saving to user)
- wheel_service: _process_days_payment and _apply_prize require subscription
in multi-tariff mode, fallback converts to balance bonus for prizes
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:
- remnawave_service: panel_user.uuid AttributeError (3 places) — dict needs
.get('uuid'), not .uuid attribute access. Silent fail caused duplicate subs.
- remnawave_service: removed traffic_limit_gb, device_limit, connected_squads
overwrites from panel sync — bot is source of truth for these fields
- guest_purchase_service: multi-tariff now checks per-tariff (not any active
sub), allowing purchase of different tariffs simultaneously
- subscription_auto_purchase_service + user_cart_service: per-subscription cart
storage via user_cart:{user_id}:sub:{sub_id} keys. Cart resolution no longer
falls through to heuristic when saved_subscription_id lookup fails.
_delete_cart_for_subscription replaces delete_user_cart in all paths.
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.
When sync finds a panel user whose UUID matches User.remnawave_uuid
(legacy single-tariff) but not any Subscription.remnawave_uuid, it now
auto-links that UUID to the user's best active non-daily subscription.
This handles migration from single-tariff to multi-tariff mode without
losing panel user associations.
get_user_by_remnawave_uuid: fallback query searches Subscription table
when User-level UUID not found (multi-tariff stores UUID per-subscription).
Webhook _resolve_user_and_subscription: direct Subscription lookup before
returning None when user not found by telegram_id or User.remnawave_uuid.
Webhook user.deleted: refresh user.subscriptions before iterating to
ensure relationship is loaded from DB.
Account merge: clear subscription-level remnawave_uuid/short_uuid on
secondary user's subscriptions to prevent orphaned panel users.
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).
The MissingGreenlet fallback path in build_topup_success_keyboard
now queries all active/trial subscriptions and checks if ANY is active
paid, instead of only checking the most recently created one.
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).
Expiry, autopay success, daily charge, and traffic reset notifications
now append tariff name when multi-tariff is enabled, so users know which
subscription the notification is about.
All 4 remaining admin buttons (change server, devices limit, traffic limit,
reset devices) now include _s{subscription_id} suffix in callback_data.
Handlers extract subscription_id and operate on the correct subscription.
miniapp.py: get_subscription_details, get_tariffs, purchase_tariff,
preview_tariff_switch all resolve subscription from subscriptions list
instead of user.subscription property.
subscriptions.py + users.py: replace_existing and deactivation use
smart selection with len==1 guard.
channel_member: warns on UUID fallback in multi-tariff.
start.py: phantom merge checks subscription-level UUIDs before user-level transfer.
yookassa: validates subscription_id from recurrent payment metadata.
contest prize: notification includes tariff name for multi-subscription clarity.
Squad sync, user sync, UUID assignment, force_cleanup all use
subscription.remnawave_uuid in multi-tariff. Fallback _subs[0] replaced
with smart selection. phantom_service refreshes 'subscriptions' (plural).
Instead of skipping when multiple active subscriptions exist, auto-purchase
now selects the subscription with autopay_enabled and most urgent renewal
(fewest days left). Handles single/multiple autopay subscriptions correctly.
promocode, campaign, guest_purchase, subscription_purchase, user_service,
daily_subscription — all replace active_subs[0] with best non-daily selection.
daily_subscription_service uses subscription.remnawave_uuid in multi-tariff.
Centralized admin subscription resolution with smart selection (non-daily,
most days left). All 12+ admin operations use the new helper. UUID operations
(disable, enable, reset devices, update) now use subscription.remnawave_uuid
in multi-tariff mode instead of user-level UUID.
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.
Promocodes with days:
- activate_promocode accepts subscription_id parameter
- Multi-tariff + >1 eligible subs: returns select_subscription for UI
- Bot handler: shows subscription picker keyboard, callback applies to chosen sub
- Single sub: auto-applies as before
Contests:
- _resolve_subscription_for_prize: prefers non-daily sub with most days_left
- All 5 contest endpoints use shared resolver
Phantom service:
- merge_phantom_into_user: uses subscriptions collection instead of single
- sync_remnawave_after_phantom_merge: syncs all subscriptions, not just first
Trial cleanup:
- create_paid_subscription: auto-deactivates all trials when creating paid sub
- extend_subscription: auto-deactivates trials when extending converts to paid
- Works from ALL paths: bot, cabinet, miniapp, webhooks, auto-purchase
Bot handlers:
- Shared resolve_subscription_from_context in common.py with FSM state fallback
- Fixes nested callbacks losing subscription context in multi-tariff
- All 5 handlers (traffic, devices, autopay, links, countries) use shared resolver
- my_subscriptions stores active_subscription_id in FSM state on delegation
- Multi-tariff + has paid subs: only trial subscriptions deleted
- Multi-tariff + all trials: deletes all (correct — no paid to keep)
- Single-tariff: unchanged (deletes all as before)
- SpinAvailability returns eligible_subscriptions (non-daily, enough days)
- spin() accepts subscription_id to target specific subscription
- _process_days_payment and _apply_prize use provided subscription
- WheelConfigResponse includes eligible_subscriptions for frontend picker
- SpinRequest accepts subscription_id in body
- Daily tariffs excluded from wheel eligibility
Trial:
- create_trial_subscription: autopay_enabled=False always
- autopay endpoint: block enabling autopay for trial subscriptions
- Purchase flows: deactivate all user trials on paid purchase,
transfer remaining days if TRIAL_ADD_REMAINING_DAYS_TO_PAID,
disable trials on RemnaWave panel
Purchase options:
- Return is_purchased per tariff and all_tariffs_purchased flag
in multi-tariff mode for frontend filtering
- create_trial_subscription: always set autopay_enabled=False (trial is a
probe, autopay makes no sense regardless of operator default setting)
- autopay endpoint: block enabling autopay on trial subscriptions via API
- purchase-tariff (cabinet): before creating/extending paid subscription,
find and deactivate ALL user's trial subscriptions, collect remaining
time for TRIAL_ADD_REMAINING_DAYS_TO_PAID, disable trials on RemnaWave
panel, decrement server counts — works for both tariff-based and
squad-based trials uniformly
- subscription_purchase_service (miniapp): same trial cleanup logic
- New CRUD: deactivate_user_trial_subscriptions() — finds all active
trials for user, marks them disabled with is_trial=False
Remove fallback to first subscription when panel user UUID doesn't match
any subscription. Previously, _subs_upd[0] was used as fallback, causing
panel data (including traffic_used_gb=0 after reset) from one subscription
to overwrite another subscription's data during periodic sync.
Port validations from dev monolith that were missing after sub-router split:
- Validate period_days against tariff's configured periods
- Allow custom days only if tariff explicitly supports them
- Reject zero-price purchases for non-daily tariffs (defense in depth)
- Add _resolve_panel_uuid helper for per-subscription UUID in multi-tariff mode
- Add user ownership validation (user_id check) to all subscription queries
- Add unique partial index on (user_id, tariff_id) for active subscriptions
- Generate remnawave_short_id for new subscriptions in all creation paths
- Fix trial endpoints to check all user subscriptions, not just first
- Fix channel member handler to enable/disable per-subscription UUIDs
- Fix channel checker middleware for multi-subscription iteration
- Fix tariff switch, traffic, and device endpoints to use correct panel UUID
- Fix monitoring, auto-purchase, renewal services for multi-subscription
- Fix user_service, miniapp, subscriptions and users webapi routes
- Replace fail-open with fail-closed in CryptoBot, Heleket, Tribute webhooks
(missing API key now rejects instead of accepting)
- Use hmac.compare_digest for timing-safe comparison in Freekassa, KassaAI,
Pal24, and Platega webhook verification
- CloudPayments: reject webhooks when signature header is missing but
API_SECRET is configured (check, pay, fail, universal endpoints)
- CloudPayments: return code 13 (reject) instead of code 0 on parse errors
and exception handlers to prevent fail-open
Critical security fix: the POST /cabinet/subscription/purchase-tariff
endpoint accepted arbitrary period_days from client without validating
against the tariff's configured periods. The pricing engine returned 0
for unknown periods, allowing free subscription creation.
Changes:
- Add period_days whitelist validation in /purchase-tariff endpoint
- Add zero-price safety guard as defense in depth
- Add period_days validation in _auto_purchase_tariff (saved cart)
- Add period_days validation in _prepare_auto_extend_context (saved cart)
Show each Platega method (SBP, cards, crypto) as a separate button
on the main payment screen like YooKassa, instead of behind a sub-menu.
Controlled by PLATEGA_INLINE_METHODS env var (default: true).
Set to false to keep the old sub-menu behavior.
- Add news_categories and news_tags tables with case-insensitive unique names
- Add category_id/tag_id FK columns to news_articles (ON DELETE SET NULL)
- CRUD endpoints for categories and tags (admin permissions)
- Sync legacy string fields from FK entities on create/update
- Clear legacy fields on category/tag deletion
- Alembic migration 0049 with backfill from existing article data
- Add telegram_user_id and user_db_id to get_balance_payment_description() across
8 cabinet balance providers (heleket, mulenpay, pal24, wata, cloudpayments,
freekassa, kassa_ai, riopay), all miniapp endpoints, and recurrent payments
- For email/OAuth users without telegram_id, fallback to DB ID with (U{id}) format
- Fix pre-existing tuple bug in bot_configuration.py (trailing comma created tuple)
- Fix typo in nalogo_queue_service.py log message ("Чек уже попыток")
- Extract _claim_phantom_user and _merge_phantom_into_active_user from
start.py into app/services/phantom_service.py
- Replace lightweight 3-4 table merge with full execute_merge (30+ tables)
- Add durable AdminAuditLog records for phantom claims and merges
- Use begin_nested() savepoints for audit log writes (session-safe)
- Move Remnawave panel sync to after commit (no HTTP inside locked txn)
- Fix remnawave_uuid transfer in account_merge_service with two-flush
pattern (clear→flush→assign) to prevent unique constraint violations
- Add db.refresh after rollback in Path A to prevent stale object access
- Fix orphaned subscriptions/GuestPurchase when phantom claim fails with
IntegrityError — now merges phantom into existing user across all 3 call sites
- Add explicit db.commit() after merge in both active-user and registration paths
- Fix remnawave_uuid transfer ordering (clear→flush→assign) to prevent unique
constraint violation during flush
- Clear phantom.referral_code on soft-delete to prevent unique constraint issues
- Add status != DELETED filter to find_phantom_user_by_username (defense in depth)
- Add WARNING-level logging on phantom claims for admin audit trail
- Add functional index on lower(username) for phantom lookup performance (migration 0048)
- Add ON DELETE CASCADE to subscription_servers.subscription_id (migration 0047)
- Add admin endpoint POST /users/{id}/assign-referrer with recursive CTE cycle
detection, self-enrichment prevention, and audit logging
- Harden account_merge_service: add SubscriptionServer, RioPayPayment,
SeverPayPayment, SavedPaymentMethod, GuestPurchase, NewsArticle handling
- Fix logger key typo get= → error= in promocode activation
- Add PIL.Image.DecompressionBombError to except clause (inherits from
Exception, not ValueError/OSError — was escaping as unhandled 500)
- Move _MP4_VIDEO_BRANDS to module-level frozenset for consistency
- Add ftyp brand allowlist to reject HEIC/HEIF files misclassified as MP4
- Close UploadFile after read to release resources during processing
- Add exception chaining (from None) on HTTPException raises
- Narrow except to ValueError/OSError (let programming errors propagate)
- Add exc_info=True to thumbnail failure log for debuggability
- Type detect_file_type return as tuple[MediaType, str]
- SavedMedia.media_type now uses Literal['image', 'video'] matching Pydantic schema
- Explicitly close old Image objects after exif_transpose and convert('RGB')
- PIL Image resource leak: wrap in try/finally with img.close()
- Grayscale images: normalize all to RGB for consistent JPEG output
- exif_transpose: defensive None guard
- Thumbnail: explicit close after save
- media_type: use Literal['image', 'video'] in schema
- ensure_upload_dirs: run in asyncio.to_thread (not sync in event loop)
- _SAFE_FILENAME_RE: remove thumb_ prefix (prevent orphaned files)
- Server-side HTML sanitization for article content
- URL scheme validation for featured_image_url (http/https only)
- Slug sanitization on create/update
- MissingGreenlet fix in delete (capture attrs before commit)
- Missing rollback after IntegrityError in CRUD
- nullslast() for published_at ordering
- asyncio.gather for parallel DB queries
- Removed selectinload(author) from list queries
- increment_views with RETURNING (no extra SELECT)
- Migration-model index alignment
- Pre-compiled regex, structlog.exception pattern
- View counter dedup cache (5min TTL)
When a daily subscription is resumed after user deletion from RemnaWave panel
and deactivation sync, connected_squads were cleared but never restored,
causing internal squads to not be assigned. Also, admin notifications were
missing from the Telegram bot handler path.
Fixed across all 5 resume code paths:
- cabinet /pause endpoint
- miniapp /subscription/daily/toggle-pause endpoint
- bot handle_toggle_daily_subscription_pause handler
- DailySubscriptionService._process_single_charge
- try_resume_disabled_daily_after_topup auto-resume
Changes in each path:
- Restore connected_squads from tariff.allowed_squads (fallback: all available servers)
- Branch create/update based on remnawave_uuid presence
- Follow-up PATCH after POST to ensure internal squads are assigned
- Use limit=10000 in get_all_server_squads to avoid silent truncation
- Separate try/except for squad restore vs RemnaWave sync for resilience
- Add admin notification in bot handler (was missing)
Replace 7 fragmented localization keys with one REFERRAL_INVITE_TEXT
template. Remove Share button (switch_inline_query). Wrap invite text
in blockquote+code for visual quote style with tap-to-copy. Update
instruction text in all 5 locales (ru, en, ua, fa, zh).
Bot uses default HTML parse mode — all messages are HTML-parsed by Telegram.
Added html.escape() to all user-controlled and admin-controlled strings
before interpolation into HTML messages to prevent injection and parse errors.
49 files, ~250+ injection points fixed:
- user.full_name, first_name across all handlers and services
- tariff.name/description in purchase flow, admin panel, auto-purchase service
- campaign.name, start_parameter in admin and user-facing handlers
- group.name, promo_group.name across promo management
- contest.title, prize_text, leaderboard names (including public channels)
- transaction.description (contains raw user.full_name from referral service)
- restriction_reason across all balance and subscription handlers
- ticket.title, message_text, poll.title, poll.description
- welcome text template placeholders (first_name, username)
- maintenance reason, admin_name, selected_prize.display_name
New helpers in app/utils/formatting.py:
- safe_html_name() for escaping display names
- user_html_link() replacing 15+ duplicated inline link patterns
_calculate_traffic_price treated unlimited traffic as free because
base_gb=0 triggered the `if base_gb > 0 else 0` guard, skipping
the price lookup. Added early return for total_gb==0 to use the
configured unlimited tier price.
- Reorder middleware: LoggingMiddleware now outermost, GlobalErrorMiddleware inside — prevents full traceback logging for suppressed errors (message not modified, query too old, bot blocked)
- Fix root cause in message_patch.py: _edit_with_photo missing try/except for _original_edit_text when ENABLE_LOGO_MODE=False
- Add "message is not modified" suppression in AuthMiddleware to prevent unnecessary db.rollback() and ERROR-level logging
- Fix discount promo display in admin notifications: subscription_days shown as hours (not days), balance_bonus_kopeks shown as percentage (not price)
The background monitoring service was deactivating trial subscriptions
when users unsubscribed from channels, ignoring per-channel
disable_trial_on_leave and disable_paid_on_leave settings that the
real-time handler and middleware already respected.
Changes:
- Use shared should_disable_subscription() for all 3 deactivation paths
- Add global CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE override in should_disable_subscription
- Add admin skip in monitoring (consistent with handler/middleware)
- Replace inline reactivation with reactivate_subscription() CRUD
- Switch to enable_remnawave_user() instead of heavy update_remnawave_user()
- Add commit=False to deactivate/reactivate/record/clear_notification for batch atomicity
- Include paid subs in monitoring when any channel has disable_paid_on_leave=True
- Use skip_deactivation flag instead of early return to preserve reactivation path
- Commit batch before create_remnawave_user which internally commits
Apply func.abs() consistently to all 5 remaining locations that sum
SUBSCRIPTION_PAYMENT amounts: branch revenue, campaign stats,
user detail branch revenue, campaign detail, and search results.
Expose total_subscription_revenue_kopeks in NetworkGraphResponse,
computed from the existing personal_spent data (sum of all
SUBSCRIPTION_PAYMENT transactions by scoped users).
Eliminates duplicated status mapping logic between _fetch_subscription_info
and get_network_user_detail. Single source of truth for mapping
subscription fields to frontend status labels.
Previously only disabled and pending statuses were forced to show as
expired in the network graph. Subscriptions with status='expired' or
status='limited' but with end_date > now would incorrectly display as
active. Now all four non-active statuses are treated as expired.
The subscription_status computation now checks the Subscription.status
field. Disabled and pending subscriptions are treated as expired
regardless of end_date, preventing incorrect "active" display.
Also added SubscriptionStatus import.
Add subscription_status field (trial_active, paid_active, trial_expired,
paid_expired) to NetworkUserNode and NetworkUserDetail schemas. Backend
computes status from Subscription.is_trial and end_date using window
function to pick latest subscription per user.
Superadmin (level 999) assignments are now the sole domain of
ADMIN_IDS/ADMIN_EMAILS environment variables. On startup, bootstrap
reactivates env-listed users and revokes superadmin from users removed
from env. API assign/revoke endpoints return 403 for superadmin-level
roles. _ensure_role_by_email now requires email_verified (symmetric
with revocation check).
Telegram Desktop/iOS cache initData with stale auth_date (tdesktop#28303).
Increase max_age_seconds from 24h to 30 days for all cabinet login and
account linking endpoints. HMAC signature still validates authenticity,
JWT tokens handle session expiration. Add structured logging for stale
initData acceptance monitoring.
lock_user_for_pricing with populate_existing=True was overwriting the
pending is_daily_paused mutation before db.commit(), silently discarding
the pause toggle. Fix moves lock before state reads, uses commit=False
for subtract_user_balance and create_transaction to ensure single atomic
commit, and re-applies is_daily_paused after any populate_existing reload.
Inactive tariffs with is_trial_available=True can now be used for trial
activation across bot, miniapp, and cabinet. This enables dedicated trial
tariffs with custom limits (traffic, devices, servers) without exposing
them in the regular purchase flow. Paid trial paths now properly resolve
trial tariff parameters instead of using global settings defaults.
Email addresses with dots (e.g., john.doe@gmail.com) caused RemnaWave
API validation failure. Now sanitizes email prefix early in the
identifier construction, not just in the final result. Also sanitizes
the fallback username path for defense in depth.
With 20+ promo groups, the full details per group exceeded Telegram's
4096 char limit. Simplified list to one compact line per group with
name and member count. Full details remain in the group detail view.
- Smart end_date check: subscriptions with future end_date are preserved (not expired) and panel user is re-created automatically
- Recreation loop guard: in-memory 120s cooldown prevents unbounded recreate→delete→recreate cycles with stale entry eviction
- Race condition protection: guard timestamp stamped before any await point so concurrent coroutines are serialized
- Admin deletion: force_panel_delete=True ensures panel user is always removed, preventing orphaned subscriptions
- Add custom buttons support: admins can add up to 10 custom buttons
with callback_data or URL action types to broadcast messages
- CustomBroadcastButton Pydantic model with validation:
callback_data checked in UTF-8 bytes (Telegram 64-byte limit),
URLs restricted to https:// and tg:// schemes only
- Fix home button: removed from CABINET_MINIAPP_BUTTON_KEYS so it
uses back_to_menu callback instead of opening cabinet WebApp
- Both legacy and combined broadcast endpoints pass custom_buttons
- Add telegram_id-based self-referral protection in all 3 Telegram auth endpoints
(user doesn't exist yet at referral resolution, so telegram_id is used instead of user.id)
- Add SELECT FOR UPDATE + db.refresh in _process_referral_code to prevent TOCTOU race
on concurrent referral assignment (matches _process_campaign_bonus pattern)
- Fix _process_referral_code to handle two cases: referred_by_id already set by
create_user() → fire registration event; not set → resolve code, set, fire event
- Fix deleted user re-registration losing referral: keep status=DELETED in preparation
block so complete_registration enters the DELETED branch (not "already active")
- Remove unused referral_code from DeepLinkPollRequest (deep link = existing users only)
- Fix OIDC exception handling inconsistency (ValueError/LookupError → Exception)
- Fix bare except clauses in start.py → except Exception
- Pass is_new_user to _finalize_oauth_login (only new user path passes True)
RemnaWave API only accepts ACTIVE/DISABLED for user status updates —
EXPIRED and LIMITED are managed internally. The bot was sending EXPIRED
status and past expireAt dates, causing 400 validation errors.
- Change UserStatus.EXPIRED → UserStatus.DISABLED in all 5 call sites
- Add 1-minute buffer to expire_at for inactive subscriptions to avoid
"expiration date in the past" rejections (matches _safe_expire_at_for_panel)
- Include TRIAL status in is_actually_active checks (consistent with
remnawave_service.py sync_users_to_panel)
- Create NaloGO receipt when code-only gifts (no recipient) are paid via
any gateway provider, not just directed gifts
- Add receipt_uuid and receipt_created_at columns to guest_purchases for
persistent DB-level dedup (covers PENDING_ACTIVATION and code-only paths
where no Transaction exists at receipt time)
- Use SELECT ... FOR UPDATE in try_fulfill_guest_purchase to prevent
concurrent webhook double-processing race condition
- Expand idempotency guard to include code-only gifts already in PAID status
- Add db.refresh after PENDING_ACTIVATION nalogo call to guard against
inner rollback expiring the ORM object
Cabinet API and WebAPI created admin balance transactions with
payment_method=NULL instead of 'manual', making them invisible
to sales statistics filters.
Changes:
- Add payment_method=PaymentMethod.MANUAL to Cabinet and WebAPI
balance update endpoints
- Add func.abs() to all transaction amount aggregations missing it
across sales stats, dashboard stats, and reporting queries
- Remove redundant Python abs() on addon_revenue (SQL func.abs
already applied)
- Add data migration 0044 to fix historical NULL payment_method
records for admin top-ups
Landing page (guest) payments were completely skipping nalogo receipt
generation because the guest purchase flow returned early in payment
webhook handlers before reaching the nalogo code.
Added _create_nalogo_receipt_for_purchase() helper with:
- payment_id null-check (Redis dedup requires it)
- amount validation (skip zero/negative)
- transaction.receipt_uuid duplicate guard
- inner try/except with db.rollback() for receipt_uuid persistence
- sanitize_proxy_error for credential-safe error logging
- privacy: no telegram_user_id in receipt description sent to tax authority
Called in both DELIVERED and PENDING_ACTIVATION paths.
Added db.refresh(purchase) after nalogo call to handle potential
session expiry from rollback inside the helper.
payload column in cryptobot_payments contains plain strings like
"balance_2_10000" alongside JSON objects. CAST(payload AS json) fails
on these rows during CREATE INDEX CONCURRENTLY.
- Add AND payload LIKE '{%' to partial index WHERE clause in migration 0042
- Add .payload.like('{%') filter to guest_purchase_service query
- Apply sanitize_proxy_error() to all 8 error handlers in nalogo_service
- Remove exc_info=True from error paths that could expose proxy creds
- Fix regex backreference to preserve original SOCKS scheme
- Consolidate proxy utility imports to module level
- Add source indicator (NALOGO_PROXY_URL vs fallback) to startup log
Route all nalog.ru API traffic through SOCKS proxy. Uses NALOGO_PROXY_URL
env var (falls back to PROXY_URL if not set). Adds httpx[socks] dependency.
- Thread proxy_url through Client → AuthProviderImpl + AsyncHTTPClient
- Extract mask_proxy_url() and sanitize_proxy_error() utilities
- Add socks5h:// scheme support for remote DNS resolution
- Sanitize proxy credentials in error messages
- Log masked proxy URL at startup and service init
When a tariff has a stale external_squad_uuid that no longer exists in
the Remnawave panel, PATCH/POST /api/users fails with A039 (P2003 FK
constraint violation). This caused subscriptions to not sync with the
panel even though balance was already charged.
Now both update_user() and create_user() catch A039 errors and
automatically retry without externalSquadUuid, logging a warning about
the stale UUID. The subscription sync succeeds without the external
squad assignment rather than failing entirely.
- Replace broad `except Exception` with `except TelegramAPIError` in
balance.py and wheel.py Stars invoice creation (prevents masking
programming errors)
- Fix session leak in gift.py telegram_stars path: wrap PaymentService
usage in try/finally to ensure bot.session.close() is called
Replace all ~45 direct Bot() calls across the codebase with a centralized
create_bot() factory function that automatically configures SOCKS5 proxy
session when PROXY_URL is set. This ensures proxy support applies uniformly
to all Telegram API traffic.
Key changes:
- Add app/bot_factory.py with create_bot() factory
- Replace direct Bot() instantiation in 33 files
- Fix session leaks in cloudpayments.py and auth.py (async with)
- Replace 2 direct httpx calls to api.telegram.org with
bot.create_invoice_link() (balance.py, wheel.py)
- Remove now-unused imports (Bot, DefaultBotProperties, ParseMode, httpx)
Route bot traffic through SOCKS5 proxy when PROXY_URL env var is set.
Validates scheme to reject HTTP proxies (would expose bot token).
Credentials are masked in logs.
EmailService singleton cached SMTP settings in __init__ at import time.
is_configured() read live from settings, but self.from_email stayed None
when SMTP was unconfigured at startup → AttributeError on .split('@').
Replace cached attributes with @property accessors, snapshot from_email
once per send_email call with validation guard.
- RioPay: use create_transaction(commit=False) to keep FOR UPDATE lock,
replace update_riopay_payment_status with inline assignment + flush,
add emit_transaction_side_effects after commit
- SeverPay: add db.flush() before _finalize, remove self-assignment,
add paid_at to both webhook and status-check paths
- Freekassa/KassaAI: add is_paid and paid_at to webhook and status-check
inline sections (regression from CRUD→inline migration)
- MulenPay: add is_paid and paid_at to webhook inline section
Apply the same FOR UPDATE locking pattern across 8 providers:
- RioPay: added FOR UPDATE lock (had none at all)
- CryptoBot: moved lock before status check, removed redundant lock
- WATA: moved lock before is_paid commit, removed redundant lock
- Freekassa: moved lock before is_paid commit, removed redundant lock
- KassaAI: moved lock before is_paid commit, removed redundant lock
- MulenPay: moved lock before is_paid commit, removed redundant lock
- Pal24: moved lock before is_paid commit, removed redundant lock
- SeverPay: moved lock before is_paid check, removed redundant lock
Pattern applied to all: acquire FOR UPDATE with populate_existing=True
immediately after finding the payment, replace intermediate commits with
inline assignments + flush(), re-check is_paid from locked row.
- acquire FOR UPDATE lock immediately after payment lookup, before is_paid check
- use populate_existing=True to prevent SQLAlchemy identity map stale reads
- replace intermediate update_platega_payment(commit) with inline assignments + flush
- re-check locked.is_paid after lock in get_platega_payment_status
- guard _finalize_platega_payment: only called when lock held and is_paid=False
- suppress "message is not modified" TelegramBadRequest in message_patch
- bootstrap _assign_if_missing no longer reactivates revoked UserRole rows
- revoke_role uses SELECT FOR UPDATE + pg_advisory_xact_lock to prevent TOCTOU race on last-superadmin check
- block self-revocation of superadmin role
- block is_active/level changes on system roles
- block expires_at on superadmin role assignments
- single SUPERADMIN_LEVEL constant in crud/rbac.py, imported everywhere
- get_superadmin_count excludes expired assignments
- removed dead UserRoleCRUD.revoke_role method
- warn when revoking RBAC role from a legacy ADMIN_IDS user
- added migration 0043: indexes on user_roles.role_id, access_policies.role_id, lower(users.email)
Previously the bot showed only one referral link (cabinet when CABINET_URL
is set, bot otherwise). Users who received the cabinet link were confused —
they opened a web registration form instead of being directed to the bot.
Now the bot, cabinet API, and miniapp API all return both links:
- Bot link (t.me deep link) — always shown
- Cabinet link (web registration) — shown when CABINET_URL is configured
Changes:
- Add get_bot_referral_link() and get_cabinet_referral_link() to config
- Refactor config methods to eliminate code duplication
- Update bot referral handler to display both links
- Fix switch_inline_query 256-char limit with auto-truncation
- Add html_escape() to all user-controlled strings in HTML messages
- Add translations for 5 new keys in all 5 locale files (ru/en/ua/zh/fa)
- Simplify cabinet route to use new methods instead of inline URL construction
- Add bot_referral_link to MiniApp API schema and response
- Validate message length for media broadcasts (1024 char Telegram limit)
- Add created count per day to landing stats API (separate from successful)
- Fix total_purchases to show total_created instead of total_successful
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add cabinet_email and cabinet_password to context_vars and sample_contexts
for guest_subscription_delivered template type so they appear in the admin
email template editor.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add login/password block to the "subscription ready" email template
so users receive their cabinet credentials in the first email.
The credentials block is only shown when cabinet_password is present
(new accounts). All 5 locales updated (ru, en, zh, ua, fa).
The separate credentials email (GUEST_CABINET_CREDENTIALS) is still
sent as before — this provides redundancy in case one email doesn't
arrive (e.g. due to SMTP quota limits).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Prevents double-counting in revenue: bot users create DEPOSIT (real
money) + SUBSCRIPTION_PAYMENT (balance debit). Without explicit
payment_method, subscription_payment had NULL which was patched to
kassa_ai, causing both to count as real revenue.
Now create_transaction defaults to BALANCE for SUBSCRIPTION_PAYMENT
when no payment_method is specified.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Same fix as transaction.py — sales dashboard summary and deposits
breakdown were only counting DEPOSIT transactions, missing all
landing page purchases (SUBSCRIPTION_PAYMENT).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Guest/landing purchases create SUBSCRIPTION_PAYMENT transactions (not
DEPOSIT), so they were excluded from income_today, total_income,
revenue_by_period, and payment_methods breakdown.
Also use func.abs() for SUBSCRIPTION_PAYMENT amounts since they are
stored as negative values.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Guest/landing purchases create SUBSCRIPTION_PAYMENT transactions (not
DEPOSIT), so they were excluded from income_today, total_income,
revenue_by_period, and payment_methods breakdown.
Also use func.abs() for SUBSCRIPTION_PAYMENT amounts since they are
stored as negative values.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Use atomic UPDATE SET retry_count = retry_count + 1 instead of
SELECT+modify+commit to avoid identity map pollution
- Filter retry_count < max_retries in SQL WHERE clause to avoid
wasting LIMIT slots on exhausted purchases
- Extract _fail_exhausted_purchases_batch() — separate pass for
exhausted purchases, alert sent outside session context
- HTML-escape all user-controlled values in admin alert messages
- Mark purchases FAILED on amount mismatch (prevents repeated
error logs every scheduler cycle) with admin alert
- Accept plain dict in _send_stuck_purchase_alert instead of ORM
object (avoids expired-attribute access after commit)
- Add retry_count column to guest_purchases with Alembic migration
- Add expression indexes on metadata_json->>'purchase_token' for all 12
payment provider tables (partial indexes filtered by is_paid/status)
- Implement _find_succeeded_provider_payment() covering all providers:
YooKassa, Heleket, MulenPay, Pal24, Wata, Platega, CloudPayments,
Freekassa, KassaAi, RioPay, SeverPay, and CryptoBot (payload field)
- Add amount verification in _check_and_recover_pending_purchase():
compares provider payment amount with GuestPurchase.amount_kopeks,
skips for CryptoBot (USD conversion imprecision)
- Increment retry_count on each retry attempt in retry_stuck_paid_purchases
and retry_stuck_pending_activation
- Mark purchases as FAILED after 20 retries with admin Telegram alert
via AdminNotificationService (ERRORS category)
- Add FOR UPDATE to recovery path in try_fulfill_guest_purchase to prevent
TOCTOU race that could overwrite DELIVERED back to PAID
- Isolate monitoring phases with independent try/except so Phase 1 failure
does not block Phase 2/3
- Optimize recover_stuck_pending_purchases to select only token and
payment_method columns instead of full ORM objects
- Remove dead elif branch in stars_payments.py (try_fulfill_guest_purchase
no longer returns False)
- Add Phase 3 comment for consistency
- Mark guest purchases as PAID (not FAILED) on transient fulfillment errors
so monitoring service can retry them automatically
- Use fresh AsyncSessionLocal session for recovery to avoid tainted-session
issues after rollback
- Add status guard to prevent overwriting terminal states (DELIVERED, etc.)
- Add recover_stuck_pending_purchases() to detect PENDING purchases where
provider payment already succeeded (checks YooKassa payments table)
- Use SELECT ... FOR UPDATE to prevent TOCTOU races in recovery
- Add 3-phase monitoring pipeline: recover PENDING → retry PAID → retry
PENDING_ACTIVATION
- Extract shared _resolve_base_payment_method() helper
Remove the threshold barrier that prevented re-assignment to the same
promo group tier. Previously, _get_best_group_for_spending was called
with min_threshold_kopeks=previous_threshold, which meant once a user
was auto-assigned to a tier (e.g. 100 kopeks), the check 100 > 100
would fail and the function would skip cleanup of promocode groups.
Now the function always finds the best group for the user's spending
without threshold filtering. The threshold ratchet is preserved only
for the watermark update (auto_promo_group_threshold_kopeks only
increases, never decreases).
Also elevate promo group assignment failure logging from DEBUG to
WARNING across all 3 call sites in transaction.py.
add_user_to_promo_group and remove_user_from_promo_group in
promocode_service used default commit=True, causing mid-transaction
commits that flushed all pending session changes before the outer
db.commit() at lines 163/404.
Root cause: auto-assignment did not remove old auto/promocode groups before
adding new one, causing users to accumulate multiple simultaneous promo groups.
The primary group selection then picked the wrong one.
Changes:
- Remove old auto/promocode groups atomically before adding new one
- Add SELECT FOR UPDATE (lock_user_for_update) to serialize concurrent webhooks
- Fix CRUD rollback when commit=False — re-raise instead of destroying caller tx
- Fix sort order: desc(PromoGroup.id) to match model's get_primary_promo_group()
- Let has_user_promo_group/get_user_promo_groups propagate exceptions (fail-open bug)
- Fix replace_user_promo_groups: remove dead query, add _sync_user_primary_promo_group
- Use SQL COUNT in count_user_promo_groups instead of loading all rows
- Refresh user after removal loop to avoid stale ORM state
Add composite indexes on advertising_campaign_registrations(user_id,
created_at) and transactions(user_id, type, is_completed, amount_kopeks)
to enable index-only scans. Uses CREATE INDEX CONCURRENTLY for zero
downtime. Also enable transaction_per_migration in Alembic env.py.
Support multiple campaigns, partners, and users in a single scoped
graph request. Dedup inputs, soft-skip invalid IDs, and discover
campaign registrations across all scope types.
- GET /scope-options: lightweight campaign/partner lists for selector
- GET /scoped?scope=campaign|partner|user&id=N: returns subgraph
- Recursive CTE helpers for ancestor/descendant traversal
- GRAPH_MAX_NODES cap applied to scoped graphs
- Campaign nodes shown even with zero registrations
kassa_ai_sbp has no separate CRUD module, causing guest purchase
metadata to not be saved, which breaks webhook fulfillment.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Campaign revenue now uses actual subscription payments by campaign
users instead of referral commission earnings (which were often 0).
Branch revenue for user detail now sums subscription payments by
branch users via recursive CTE instead of referral earnings.
Batch branch_revenue helper also updated to use Transaction spending.
- Use UNION ALL + count(distinct) for recursive CTE (faster, cycle-safe)
- Derive total_earnings from personal_revenue dict (remove redundant query)
- Merge duplicate campaign registration queries into single query
- Add MAX_REFERRAL_DEPTH constant, _format_datetime type hint
4 endpoints for referral network analysis: full graph with batched
aggregation queries, user detail with recursive CTE branch counting,
campaign detail with conversion metrics, and search with LIKE escaping.
All endpoints rate-limited, scoped queries to prevent full-table scans,
depth-limited recursive CTE, fail_closed on expensive graph endpoint.
Admin can now attach photos, videos, and documents when replying to tickets
via the cabinet. Media is uploaded through the existing /cabinet/media/upload
endpoint and stored as Telegram file_id references in TicketMessage.
Added media_type, media_file_id, media_caption fields to AdminReplyRequest
with cross-field validation via model_validator.
Добавлены 9 новых env-переменных для маршрутизации уведомлений по отдельным топикам:
- PURCHASES, RENEWALS, TRIALS, BALANCE, ADDONS
- INFRASTRUCTURE, ERRORS, PROMO, PARTNERS
Обратная совместимость: если топик для категории не задан — fallback на ADMIN_NOTIFICATIONS_TOPIC_ID.
Custom email templates with their own styling (background colors, <style> tags)
were wrapped in a white base template, causing visible white areas around dark-themed
templates. Added three-tier detection: full HTML documents pass through as-is,
styled content gets a minimal wrapper, simple fragments keep the base template.
Platega: handle verification ping POST without auth headers (empty body → 200 OK)
CryptoBot: always use API token for signature verification per docs, not WEBHOOK_SECRET
CryptoBot: reject requests without signature in both FastAPI and aiohttp handlers
Remove dead self.webhook_secret from CryptoBotService
Update tests to match new behavior
Webhook only checked subscriptionCryptoLink field, missing happ.cryptoLink
fallback that sync already used. Also clear stale crypto link when URL
changes but no new crypto link is provided.
- Use API token as fallback for webhook signature verification per CryptoBot docs
- Try raw body, re-serialized compact JSON, and ASCII-escaped JSON for signature matching
- Auto-fill payment amount from saved cart in show_payment_methods instead of hardcoded 0
Header was 'x-api-token' but RioPay API expects 'X-Api-Token'
(case-sensitive check on their side), causing 403 Invalid API token.
Also removed undocumented 'currency' and 'failUrl' fields from
create_order payload per official RioPay API docs.
Previously, extra purchased devices were carried over when switching
tariffs, causing incorrect pricing — users upgrading to a more
expensive plan kept the old per-device rate until next renewal.
Now tariff switch resets device_limit to the new tariff's base limit.
Extra purchased devices are not carried over.
Replace 2200-line README with a clean 190-line version:
- Centered header with badges (for-the-badge style)
- Feature grid (2x2 HTML table)
- Payment providers showcase (14 providers)
- Quick start (4 lines → link to full docs)
- Tech stack table
- Cabinet section with link to repo
- Documentation links to docs.bedolagam.ru
- Community section
All setup/config details moved to docs.bedolagam.ru.
Loader strategies for the same ORM path cannot coexist. The
_apply_user_join_filter helper added contains_eager(model.user) on top
of the selectinload(Model.user) already present in each query, causing
InvalidRequestError at runtime. Removed contains_eager — selectinload
handles user loading correctly on its own.
Новый сервис поиска по 13 платёжным провайдерам с ILIKE (escape от инъекций),
фильтрами по статусу/периоду/методу, кастомным диапазоном дат, пагинацией.
Эндпоинты: GET /search, GET /search/stats с валидацией входных данных.
- API клиент (HMAC-SHA256 подпись, создание/получение платежа)
- CRUD операции с FOR UPDATE блокировкой
- Payment mixin с обработкой webhook и финализацией
- Хендлеры бота для пополнения через SeverPay
- Миграция 0040: таблица severpay_payments
- Webhook endpoint (всегда 200 для предотвращения ретраев)
- Интеграция с payment_verification_service
- Поддержка гостевых покупок (лендинги, подарки)
- Модель: user_id nullable=True + ondelete='SET NULL' (не применилось ранее)
- order_id для гостей: 'rpguest_xxx' вместо 'rpNone_xxx'
- Миграция: добавлено пересоздание FK с ON DELETE SET NULL
- get_latest_payment_by_method: добавлен RioPayPayment в model_map
All min/max amount error messages in payment handlers now include
a back button keyboard, so users aren't stuck without navigation.
Fixed 30 message.answer() calls across 12 payment handler files.
Caddy Security expects the caddy token in X-Api-Key and the Remnawave
API key in Authorization: Bearer. The headers were swapped, causing
401 errors for users with Caddy auth type.
When a user purchases on a landing page by username and Bot.get_chat()
fails, a phantom user (telegram_id=NULL) is created. If that user
already has an active bot account, the phantom was never merged,
creating duplicate user records.
Now cmd_start checks for phantom users matching the active user's
username and merges them: transfers GuestPurchase records, balance,
and subscription (if active user has none). Phantom is soft-deleted
(status=DELETED, username=NULL) to preserve payment/transaction audit
trail and avoid CASCADE FK issues.
Previously, users could retain access to servers removed from their
promo group by re-submitting already-connected UUIDs in country
selection requests. The validation allowed any UUID present in
current connected_squads, bypassing promo group checks.
Now all selected server UUIDs must be in the user's allowed promo
group set. Unauthorized servers are rejected (cabinet/bot) or
filtered out (miniapp). Fixes authorization bypass across all 3
surfaces: cabinet, Telegram bot, and miniapp.
Missed in the previous fix — admin tariff change at
handlers/admin/users.py sets connected_squads from tariff but
did not pass sync_squads=True to update_remnawave_user.
When sync_squads parameter was introduced (4aaf0ddd) to prevent FK
violations from stale squad UUIDs, all update_remnawave_user calls
defaulted to sync_squads=False. This broke squad synchronization for
purchase/tariff-change flows where squads are freshly assigned and
must be sent to the panel.
Adds sync_squads=True to all purchase, tariff switch, and country
selection call sites across cabinet, bot handlers, miniapp, and
auto-purchase service.
- Explicit db.commit() for cabinet_last_login before _store_refresh_token
- isinstance(callback.message, types.Message) guard in process_webauth_confirm
- Check UserStatus.ACTIVE (not just DELETED) in bot callback handler
- isinstance guard in consume_web_auth_token for type safety
- Named constants: WEB_AUTH_LINKED_TTL, WEB_AUTH_TOKEN_MIN_LENGTH
- Use str.removeprefix() instead of hardcoded slice
- Move link_web_auth_token import to module level
Когда скрипт Telegram Login Widget не загружается (заблокирован),
фронтенд автоматически переключается на deep link авторизацию:
- POST /cabinet/auth/deeplink/request — генерирует одноразовый токен
- Пользователь открывает t.me/bot?start=webauth_TOKEN
- Бот связывает токен с Telegram-аккаунтом
- POST /cabinet/auth/deeplink/poll — фронтенд получает JWT токены
Новый сервис: app/services/web_auth_service.py (Redis, TTL 5 мин)
- Убран fallback на deprecated поле user_id (удаляется 14 апреля 2026)
- Добавлен парсинг trb_user_id во всех ветках обработки webhook
- trb_user_id прокинут в результат и логи всех хендлеров
lock_user_for_pricing не загружал User.subscription eagerly,
что вызывало lazy load в async контексте при обращении к db_user.subscription
в execute_change_devices.
`_subscription_to_response()` is a sync function that accesses
lazy-loaded relationship attributes (e.g. `subscription.tariff`).
When `send_subscription_purchase_notification()` is called before
building the response, `_record_subscription_event()` internally
calls `create_subscription_event()` which does `db.commit()`.
This expires all ORM objects in the session.
When the sync `_subscription_to_response()` then tries to access
`subscription.tariff`, SQLAlchemy cannot perform the lazy load
outside of an async greenlet context, raising:
MissingGreenlet: greenlet_spawn has not been called;
can't call await_only() here.
The fix adds `await db.refresh(subscription)` (and `user` where
accessed) after the admin notification block and before
`_subscription_to_response()` in three purchase endpoints:
- `submit_purchase` (classic mode)
- `purchase_tariff` (tariffs mode)
- `switch_tariff`
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extract _KASSA_AI_METHOD_CONFIG dict, _check_topup_restriction() helper,
and generic _start_kassa_ai_sub_topup / _process_kassa_ai_sub_quick_amount
implementations. Public handlers become thin wrappers.
608 → 429 lines (-30%), eliminates 5 copies of restriction check block
and 3 pairs of nearly-identical start/quick-amount handlers.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Move KASSA_AI_SUB_METHODS from handler to kassa_ai_service.py (fixes service→handler import violation)
- Remove KASSA_AI_PAYMENT_METHODS set (was defined but unused)
- Import KASSA_AI_SUB_METHODS in payment_service.py from service layer
- Add is_kassa_ai_sbp/card_enabled() checks at start of entry handler functions
- Статус автоплатежа в уведомлении основан на subscription.autopay_enabled, а не на глобальном ENABLE_AUTOPAY
- Продление с баланса (_process_autopayments) работает всегда при autopay_enabled=True
- Рекуррентные карточные платежи по-прежнему за гейтом ENABLE_AUTOPAY + YOOKASSA_RECURRENT_ENABLED
Стейловый externalSquadUuid (c6c0a338-062d-4d3a-826d-7015a24d681c) из тарифа
не существует в таблице ExternalSquads панели → FK violation → A039.
Теперь externalSquadUuid отправляется только при sync_squads=True (создание подписки).
- fix campaign registration not recorded when CHANNEL_IS_REQUIRED_SUB + SKIP_RULES_ACCEPT enabled (missing _apply_campaign_bonus_if_needed in required_sub_channel_check fast path)
- fix revenue calculation counting bonus-funded subscription payments as income (now deposits only via REAL_PAYMENT_METHODS)
- fix backup restore PendingRollbackError cascade on unique constraint violations (savepoint wrapping in _restore_table_records and _restore_users_without_referrals)
- fix AttributeError on message.text.strip() when users send media in referral code handlers
- suppress 'message is not modified' TelegramBadRequest in autopay toggle
- add bot_referral_link to referral API response with URL encoding
CLASSIC_PERIOD_PRICES was built once at import time and never updated,
causing classic mode to always show hardcoded defaults instead of
admin-configured prices.
Updated email queries in authentication routes and user CRUD operations to be case-insensitive. This change ensures that email comparisons ignore case, improving user experience and preventing potential registration/login issues with differently cased emails.
Added selectinload(UserPromoGroup.promo_group) nested under
user_promo_groups to prevent lazy-load in get_primary_promo_group().
Added selectinload(User.referrer) for format_referrer_info().
Broadened except clause in format_referrer_info as safety net.
Handle PARTICIPANT_ID_INVALID same as 'user not found' — expected for
users who authenticated via Telegram Login Widget but never interacted
with the bot or channel directly.
lock_user_for_update, subtract_user_balance, and add_user_balance use
select(User).with_for_update().populate_existing which expires loaded
relationships. Added selectinload for subscription, user_promo_groups
and promo_group to prevent lazy-load in async context.
datetime.now(UTC).isoformat() produces +00:00 suffix, appending Z
created invalid +00:00Z format causing RemnaWave API 500 errors.
Use .replace('+00:00', 'Z') instead of concatenation.
Use local subscription variable and db.refresh() to avoid lazy-load
of expired relationship after subtract_user_balance invalidates
the User identity map entry.
Bug 1: DELETE /cabinet/admin/users/{id}/full failed with
"saved_payment_methods_user_id_fkey" FK violation.
Root cause: delete_user_account() didn't clean up saved_payment_methods
and riopay_payments before deleting the user row.
Fix: add DELETE for both tables before final user deletion.
Bug 2: show_user_management crashed with TypeError on
len(subscription.connected_squads) when connected_squads was None.
Root cause: remnawave_webhook_service explicitly set connected_squads=None
when clearing subscription data, but 5 call sites assumed it was always a list.
Fix: change None assignment to [] + add "or []" guards at all 5 call sites.
apply_percentage_discount now delegates to PricingEngine.apply_discount
(floor division). Removes ruble-rounding that caused inconsistency between
first-purchase and renewal pricing.
subscription_purchase_service._apply_percentage_discount now delegates
to the shared apply_percentage_discount.
All 60+ callers across handlers, keyboards, cabinet, miniapp, balance
automatically use the unified algorithm without code changes.
- Migrate bot purchase handlers, menu, admin users to PricingEngine
- SubscriptionRenewalService.finalize() accepts both old and new pricing types
- Remove dead subscription CRUD pricing functions (get_subscription_renewal_cost etc.)
- Remove dead pricing_utils functions
All payment providers now use lock_user_for_update before balance mutations
and commit=False pattern for atomic payment status + fulfillment.
Tribute service refund also uses proper locking.
Security fix: cabinet /renew endpoint now validates period_days against
available periods (tariff or settings), preventing arbitrary period abuse.
Also:
- Add proper type annotations (AsyncSession, Subscription, User) to PricingEngine
- Add max(0, final_total) guard in both tariff and classic modes
- Type breakdown field as dict[str, Any]
Replace 3 renewal_service.calculate_pricing() calls with
PricingEngine.calculate_renewal_price() in the balance activation handler.
finalize() already supports RenewalPricing via duck typing.
SubscriptionRenewalService.finalize() now supports both
SubscriptionRenewalPricing and RenewalPricing from PricingEngine.
Adapts access to promo_discount_value, server_ids, and
servers_individual_prices via duck typing.
Replaces stale cart-based pricing and _apply_promo_discount_for_tariff
(4th discount formula with float division) with fresh PricingEngine
calculation. Falls back to saved cart price on PricingEngine error.
Mechanical re-point of calculate_renewal_price calls to use unified
PricingEngine. Both services now get consistent pricing with correct
discount formulas and server fallback behavior.
Replaces inline pricing logic in get_renewal_options and renew_subscription
with unified PricingEngine.calculate_renewal_price(). Fixes:
- Wrong discount formula (int(p*(100-d)/100) vs integer floor division)
- Missing servers/traffic costs in classic mode display
- Inconsistent discount stacking between display and execute paths
Classic mode now correctly:
- Applies separate promo group discounts per category (period, servers,
traffic, devices) via promo_group.get_discount_percent(category, days)
- Multiplies servers/traffic/devices monthly prices by months_in_period
- Applies promo offer discount to entire subtotal after per-category discounts
- Tracks total group discount as sum of per-category discounts
Replace manual per-component price calculation in handle_extend_subscription
with PricingEngine.calculate_renewal_price. This eliminates ~55 lines of
duplicated pricing logic (period, servers, devices, traffic calculations with
separate category-specific promo group discounts and months multiplication)
in favor of a single PricingEngine call per period. Also fixes double-application
of promo offer discount that existed in the old code path.
Replace SubscriptionService.calculate_renewal_price() call in
try_auto_extend_expired_after_topup with PricingEngine.calculate_renewal_price().
Add structured log with pricing breakdown after calculation.
All downstream business logic (balance check, deduction, extend) unchanged.
Add the main public method calculate_renewal_price to PricingEngine,
routing to _calculate_tariff_mode or _calculate_classic_mode based on
whether the subscription has a linked tariff. Both modes apply stacked
discounts (promo-group then promo-offer). Classic mode tries
CLASSIC_PERIOD_PRICES first, falling back to PERIOD_PRICES. Adds 8
new tests covering both modes, discounts, extra devices, and fallback.
Add a standalone dict that always reflects env PRICE_*_DAYS settings,
independent of tariffs mode. Unlike PERIOD_PRICES (which may use DB
tariff prices), CLASSIC_PERIOD_PRICES is the canonical source for
classic (non-tariff) subscription pricing. Includes refresh helper.
_calculate_servers_price ALWAYS uses real server.price_kopeks even when
is_available=False or is_full=True, fixing the silent zero-price bug.
_calculate_traffic_price separates base from purchased GB to prevent
purchased top-ups from inflating the tier lookup.
- remnawave_api: use str() before .lower() to handle non-string API messages
- yookassa recovery: cross-validate user_telegram_id metadata against resolved
user to prevent misattribution when legacy telegram_id fits in int32 range
"User already enabled" and "User already disabled" are expected
responses when reactivating subscriptions (e.g., traffic top-up on
active subscription with exhausted traffic). These should not
trigger error notifications in the admin chat.
- Reject user_id <= 0 early (corrupted metadata)
- Use `is None` checks instead of `or` to avoid falsy-value collisions
- Separate int parse from DB call in telegram_id fallback
- Move _INT32_MAX to module-level constant
Legacy payments may store telegram_id (>int32) in metadata['user_id']
instead of internal User.id. The recovery path now:
- Detects values exceeding int32 range and queries by telegram_id
- Falls back to metadata['user_telegram_id'] if primary lookup fails
- Resolves to internal user.id before creating FK-linked payment record
- subtract_user_balance: only rollback when commit=True, re-raise when
commit=False so caller controls transaction lifecycle
- log_promo_offer_action: add db.flush() when commit=False to surface
constraint errors immediately instead of deferring to caller's commit
- Block auto-purchase from stale cart when subscription is DISABLED
(balance deduction is irreversible, Remnawave update would fail)
- Preserve user balance in force_cleanup_user_data (paid money must not be destroyed)
- Keep has_had_paid_subscription flag on cleanup (prevents promo code abuse)
- Add warning in sync_from_panel when local end_date is newer than panel
- Fix WATA payment expiration: enforce minimum 15 minutes to avoid
hitting WATA API's exclusive lower bound (now + 10 min)
- Add SubscriptionStatus.LIMITED for traffic-exhausted subscriptions
- Webhook user.limited now sets LIMITED directly instead of DISABLED
- Add LIMITED to reactivation, extend, resume, auto-purchase, contest eligibility
- Add traffic_exhausted error response in miniapp API
- Fix device_limit being overwritten on tariff switch in all code paths:
admin change_tariff, user switch-tariff, miniapp, bot tariff_purchase,
auto_purchase_service — now preserves extra purchased devices via
calc_device_limit_on_tariff_switch() helper
- Fix truthiness checks on device_limit (0 is valid, use `is not None`)
When admin changes allowed_squads or external_squad_uuid on a tariff,
automatically sync the new squad config to all active/trial subscriptions
in Remnawave panel via a background task (fire-and-forget).
Previously subscription.device_limit was blindly overwritten with the new
tariff's base limit, losing any extra devices the user had purchased.
Now extra devices are calculated from the old tariff base and carried over,
capped at tariff.max_device_limit or global MAX_DEVICES_LIMIT.
Centralized referral link generation into settings.get_referral_link().
When CABINET_URL is configured, links use {CABINET_URL}?ref={code}.
Falls back to Telegram bot deep link when CABINET_URL is not set.
- URL-encodes referral_code for safety
- Handles CABINET_URL with existing query params (uses & vs ?)
- Guards against None referral_code in all call sites
- QR code caching uses link hash for auto-invalidation
POST /admin/tariffs/{tariff_id}/sync-squads updates active_internal_squads
and external_squad_uuid for all active/trial subscriptions on a tariff.
- Concurrent API calls (semaphore=5) with circuit breaker (10 consecutive failures)
- Local DB updated only on successful API response to avoid split-brain
- Error messages sanitized (details in server logs only)
- Uses joinedload to avoid N+1 query for User.remnawave_uuid
The admin endpoint wrote only to the legacy users.promo_group_id FK,
which got overwritten by sync_user_primary_promo_group on the next
transaction. Now writes to user_promo_groups M2M table (authoritative
source) and re-derives the FK via sync.
Also re-raise exceptions in _sync_user_primary_promo_group to prevent
committing inconsistent state between M2M and FK columns.
Without post_update, SQLAlchemy's flush ordering cannot resolve the
circular dependency when both a user and their referrer are in the same
session. This caused referred_by_id to be silently NULLed during flush,
breaking Telegram login (which eagerly loads User.referrer) and causing
apparent admin rights loss in the cabinet.
OAuth login was unaffected because it uses a bare select(User) without
eager loading the referrer relationship.
Root cause confirmed by 6 parallel investigation agents tracing the
exact code paths through get_user_by_telegram_id → selectinload →
flush → circular dependency.
- Gate auto_login_token generation behind is_new_account flag in all 3 locations
(fulfill_purchase PENDING/DELIVERED paths + activate_purchase) — prevents attacker
from buying cheapest plan with victim's email to get their session token
- Assign default promo group in all _find_or_create_user paths including telegram
IntegrityError fallbacks (7 return paths total)
- Create transaction records for landing purchases so promo group auto-assignment
and contest tracking work correctly
- Add _resolve_payment_method helper for enum conversion with sub-option suffix stripping
- Remove estimated renewal price display from balance top-up screen
- Remove country name generation during server/squad sync, use original RemnaWave name as display_name
- Add html.escape() for all display_name/country name values rendered in HTML-parsed Telegram messages
Add a per-tariff visibility flag (show_in_gift) that controls whether
a tariff appears in the /gift section. Enforced server-side in gift
config query, gift purchase endpoint, and landing page gift purchases.
Includes Alembic migration with idempotency guard and server_default.
When traffic is exhausted, RemnaWave may send user.expired webhook setting
local status to EXPIRED (not just DISABLED). reactivate_subscription() only
handled DISABLED→ACTIVE, silently ignoring EXPIRED subscriptions. After
purchasing additional GB, the subscription stayed expired and VPN remained
blocked despite payment.
Changes:
- reactivate_subscription() now handles both DISABLED and EXPIRED→ACTIVE
when end_date is still in the future
- Inverted null end_date guard to block reactivation (defense-in-depth)
- Added enable_remnawave_user() call after update in all traffic/device
top-up paths to ensure panel exits LIMITED state
- Gated enable call on subscription.status == 'active' to prevent
enabling when reactivation was a no-op
- Fixed all 12 call sites across bot handlers, cabinet routes,
miniapp, webapi, and auto-purchase service
- Add telegram_stars handler in create_guest_payment() using
bot.create_invoice_link() with guest_purchase_{token} payload
- Add guest_purchase_ prefix handling in Stars pre-checkout and
successful_payment handlers with amount tolerance check (±5%)
- Pass Bot instance to PaymentService when payment method is Stars
- Add purchase_token format validation via regex guard
- Remove is_active_paid_subscription guard from reset-subscription endpoint
- Add is_trial=False and status=ACTIVE on admin tariff change (guarded by
tariff.is_trial_available and end_date check)
- Eagerly load user_promo_groups and promo_group in gift purchase locked query
- Move user_promo_groups access inside try/except in get_primary_promo_group()
Add GET /{user_id}/gifts endpoint returning sent and received gift
subscriptions with COUNT queries for true totals, token truncation
for security, and noload() optimization for unused relationships.
Apply promo group and active promo offer discounts to gift purchase flow.
Discounts stack multiplicatively with max(1, price) floor. FOR UPDATE
row lock prevents concurrent promo offer double-spend. Promo offers
consumed after purchase in both balance and gateway modes.
- Add transaction records for free tariff switches (downgrade, upgrade_cost=0) in miniapp and cabinet
- Add atomic transaction records for admin tariff changes in bot handler and cabinet API
- Use commit=False for admin flows to ensure subscription change and transaction are committed together
Previously, both webhook (user.modified) and batch sync paths
only updated end_date if the panel date was LATER than the local date.
This silently blocked any date reduction from the panel, causing
the bot to show stale expiry dates after admin changes in the panel.
Now the panel is treated as authoritative — end_date is synced
in both directions (forward and backward) for ACTIVE subscriptions.
- Stars: ceil → round в rubles_to_stars, нормализация kopeks в cabinet invoice
- Устройства/трафик: добавлен transaction_type в subtract_user_balance,
покупки устройств и трафика теперь создают SUBSCRIPTION_PAYMENT (не WITHDRAWAL),
что исправляет отображение в статистике продаж
1. Admin notification showed "renewal" instead of "first purchase" for new
users because has_had_paid_subscription was set before notification.
All 21 call sites now pass explicit purchase_type.
2. Partner referral not counted when mandatory channel subscription enabled.
required_sub_channel_check saved campaign_id but not referrer_id from
campaign.partner_user_id. Also removed duplicate DB query.
3. BOT_USERNAME auto-detection moved before web server start to close
race window on /cabinet/branding/telegram-widget endpoint.
1. Balance-mode gift purchase leaked full 64-char token in response.
Gateway path truncated to [:12] but balance path didn't. Fixed.
2. retry_stuck_pending_activation referenced GuestPurchase.updated_at
which doesn't exist on the model. Changed to paid_at (mirrors
retry_stuck_paid_purchases pattern).
3. clear_notifications() called db.commit() unconditionally, defeating
commit=False in replace_subscription. Added commit parameter with
default=True for backward compat, passed through from caller.
1. Truncate tokens to 12 chars in all API responses (SentGift,
PendingGift, ReceivedGift, PurchaseStatus, PurchaseResponse,
return URL) — full token no longer leaves the server
2. Status endpoint supports prefix-based token lookup
3. create_paid_subscription/replace_subscription accept commit=False
— activate_purchase now uses single atomic commit for subscription
+ purchase status update (fixes double-commit gap)
4. Bot handler uses flush() instead of commit() before svc_activate
— consistent with cabinet endpoint, allows rollback on failure
5. Add retry_stuck_pending_activation() for purchases stuck in
PENDING_ACTIVATION status (10 min threshold)
6. Add varchar_pattern_ops index for prefix queries on token column
Buyer cannot activate their own gift, both via cabinet API
(returns 400 "Cannot activate your own gift") and bot deep link
(silently skips activation).
After svc_activate creates a subscription, the user object still
has stale cached data. Refresh the subscription attribute so the
main menu immediately shows the active subscription status.
activate_purchase -> create_paid_subscription calls db.commit()
internally, which closes the savepoint context and causes
InvalidRequestError on subsequent db.refresh(). Replace savepoint
with a plain commit before calling svc_activate.
Telegram truncates start parameters to 64 chars, so gift_token from
deep link may be a prefix. svc_activate does exact match internally,
so we must pass gift_purchase.token (full token from DB) instead.
Displayed gift codes (GIFT-XXXXXXXXXXXX) are 12-char prefixes of the
full 64-char token. Activation now accepts prefix match (min 8 chars)
so both the short display code and full token work. Also fixes Telegram
deep link truncation (64-char limit cuts the token).
Replace inline gift activation block (30+ lines) with a call to
_activate_pending_gift_after_registration() helper. Eliminates
code duplication between existing-user and new-user activation paths.
- Add code-only gift purchase (no recipient required)
- Gift activate endpoint: accept PAID + PENDING_ACTIVATION statuses
- Bot deep link: /start GIFTCODE_{token} auto-activation for new and existing users
- Add _activate_pending_gift_after_registration() helper with savepoint isolation
- Security: FOR UPDATE on activation queries to prevent race conditions
- Security: rate limiting on activate, ownership check before status leak
- Security: uniform 404 responses to prevent token enumeration
- Add selectinload for tariff/user/buyer relationships in all gift queries
- Add .limit(100) to pending gifts query
- Make recipient_type/recipient_value optional in GiftPurchaseRequest schema
- Add menu_layout_cache.py for CABINET_MENU_LAYOUT in-process cache
- Add admin_menu_layout.py routes (GET/PUT/POST reset) with merged view
- Rewrite _build_cabinet_main_menu_keyboard to use cached row layout
- Support custom URL buttons with style, emoji, labels, enabled toggle
- Atomic dual-key DB writes for layout + button styles
- Add language button to default layout and DEFAULT_BUTTON_STYLES
- Pydantic validation with Literal types, max_length, duplicate ID checks
- Register routes and cache loading in bot startup
- current_tier_name and is_current now determined by highest achieved
tier threshold instead of user's assigned promo group
- Backend update_promo_group converts threshold 0 to NULL for clean state
- YooKassa: return local_payment_id instead of UUID for frontend polling
(parseInt on UUID produced wrong ID → eternal spinner)
- PAL24: remove unsupported payment_method param from API call
(cabinet and miniapp routes — URL selection is client-side)
- Replace 501 stub with full gateway payment flow via PaymentService
- Move telegram username pre-check (DB-first) above gateway/balance branch
- Add recipient_warning column to GuestPurchase model + migration 0034
- Return warning in gift purchase status endpoint
- Add db.refresh(purchase) after commit in gateway branch
Create Pydantic schemas for gift config/purchase/status responses,
FastAPI routes for GET /gift/config, POST /gift/purchase, and
GET /gift/purchase/{token}, update GuestPurchaseService.create_purchase
to accept optional source and buyer_user_id params with nullable landing,
and register the gift router in the cabinet routes.
- Add source column (landing/cabinet) to track purchase origin
- Add buyer_user_id FK to link cabinet gift purchases to authenticated users
- Add GIFT_PAYMENT to TransactionType enum for balance deductions
- Add foreign_keys disambiguation to existing user relationship
- Migration 0032: adds columns, index on source, FK constraint
Email users couldn't link Telegram when OIDC was enabled because
the link_telegram endpoint only accepted init_data and Login Widget
data. Add id_token field to LinkTelegramRequest with JWKS validation,
replay protection, and rate limiting.
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.
event_object was referenced in _process_successful_yookassa_payment
but never passed to the method, causing all YooKassa webhook payments
to fail. Use payment.amount_kopeks from the database model instead.
Migration 0001 uses Base.metadata.create_all() which creates ALL tables
from current models.py, causing subsequent migrations (0015+) to fail
with "already exists" errors when they try to re-create constraints,
indexes, columns, and tables.
Three-layer fix:
1. migrations.py: detect fresh DB (no tables) and bootstrap via
create_all() + stamp head, bypassing all migrations entirely.
2. models.py: add EmailTemplate model, CheckConstraints to LandingPage,
and indexes to GuestPurchase so create_all() produces a complete
schema identical to running all 30 migrations sequentially.
3. Idempotency guards in migrations 0015-0030: _has_unique_constraint,
_has_table, _has_index, _has_column, _has_check_constraint checks
before DDL operations, protecting against re-runs via make migrate.
Add GET /admin/landings/{id}/purchases with offset/limit pagination,
optional status filter (validated against GuestPurchaseStatus enum),
tariff name join, and truncated token display.
Add GET /admin/landings/{id}/stats endpoint returning:
- Summary stats (purchases, revenue, gifts, conversion rate)
- Daily breakdown for last 30 days (purchases, revenue, gifts per day)
- Tariff distribution (purchases and revenue per tariff)
Uses case() expressions for SQLite compatibility, timezone-safe
date grouping via func.timezone('UTC', ...), and composite index
(landing_id, status, paid_at) for query performance.
After removing overrides from the public LandingDiscountInfo response,
_load_landing_tariffs still referenced discount.overrides which no
longer exists. Read from landing.discount_overrides directly.
Add time-bounded percentage discounts with per-tariff overrides
and countdown timer support for landing pages.
- Add 5 discount columns to LandingPage model (percent, overrides,
starts_at, ends_at, badge_text) with Alembic migrations 0027-0028
- Add DB CHECK constraints for discount_percent range and date ordering
- Add discount price calculation in validate_and_calculate() and
public landing config endpoint with consistent formula
- Add admin CRUD with Pydantic validation, cascade-clear on removal,
merged date validation on partial updates, size bounds
- Remove discount_overrides from public API (baked into prices)
- Add size limits for allowed_tariff_ids and allowed_periods
Append ?activate=1 to success page URL in recipient notification email
for gift purchases with pending_activation status, so the frontend can
distinguish buyer from recipient and show the activate button only to
the recipient.
- Add external_squad_uuid column to Tariff model with Alembic migration
- Add external_squad_uuid parameter to RemnaWave API create_user/update_user
- Pass external squad from tariff to RemnaWave on subscription creation/update
- Sync external squad in monitoring service, sync service, admin user management
- Clear external squad when tariff has none (consistent across all call sites)
- Add GET /available-external-squads endpoint with UUID validation and response model
- Update tariff schemas with UUID pattern validation
- Fix db.refresh to include tariff relationship for async safety
- Remove double html.escape on payment_method (already escaped in helper)
- Use attribute_names=['landing'] keyword arg in db.refresh for consistency
- Use async with Bot() for guaranteed session cleanup
- New send_guest_purchase_notification() method in AdminNotificationService
with blockquote for payment details, landing slug, buyer/recipient info
- Called from fulfill_purchase() for both DELIVERED and PENDING_ACTIVATION
- Called from activate_purchase() when pending purchase is activated
- Different titles: regular purchase, gift purchase, pending activation
- Properly typed GuestPurchase, html.escape on all user data
- Refresh purchase with ['landing'] relationship after commit
- html.escape fallback in _get_payment_method_display
- ruff formatting fixes
The security hardening commit changed allow_headers from ['*'] to
['Authorization', 'Content-Type'], but the frontend sends X-CSRF-Token
on all POST/PUT/DELETE/PATCH requests and X-Telegram-Init-Data on all
requests. The missing headers caused preflight OPTIONS requests to fail
with 400 "Disallowed CORS origin".
Admin-created email template overrides were not substituting {tariff_name},
{period_days}, {cabinet_url} etc. because get_template_override returns raw
body_html. Switched to get_rendered_override which performs variable
substitution with html.escape. Also removed dead is_existing_user from
sample context.
- Register 4 guest purchase template types in admin email templates:
guest_subscription_delivered, guest_activation_required,
guest_gift_received, guest_cabinet_credentials
- Add sample contexts with placeholders for preview/test
- Add DB override support to send_guest_notification for all 4 types
Replace VPN subscription URLs with cabinet links in all email templates:
- GUEST_SUBSCRIPTION_DELIVERED: unified to always show cabinet link
- GUEST_GIFT_RECEIVED: replaced subscription URL with cabinet link
Both self-purchase and gift flows now only include cabinet links.
- Format long dict literal in admin_landings.py
- Add blank line after validator in admin_payment_methods.py
- Fix ruff E203 slice spacing in landing.py
- Fix long line wrapping in payment_service.py
- Add pattern=r'^[a-zA-Z0-9_-]+$' to referral_code in TelegramAuthRequest,
TelegramWidgetAuthRequest, and EmailRegisterStandaloneRequest for consistency
with TelegramOIDCAuthRequest
- Add IP-based rate limiting (10 req/min) to /email/login endpoint
- Add Retry-After: 60 header to /login/auto 429 response
- Revert OIDC flush to commit before _store_refresh_token
(matches widget/initData pattern, prevents rollback losing user updates)
- Fix CORS wildcard+credentials in webapi/app.py (same as unified_app fix)
- Add 4 TELEGRAM_WIDGET_* settings to config (size, radius, userpic, request_access)
- Register TELEGRAM_WIDGET category with choices, hints, and prefix mapping
- Add public GET /branding/telegram-widget endpoint returning widget config
- Use Literal type for size validation, Field(ge=0, le=20) for radius bounds
- Clamp radius values from DB to prevent out-of-range values
CloudPayments doesn't support programmatic card/sbp routing — the user
selects the payment method on the provider's payment page. Remove
available_sub_options so no misleading choice is shown on landing pages.
- Add regex pattern + max_length constraint on payment_method field
- Validate sub-option suffix against known available_sub_options
- Validate sub-option is enabled on the landing (not disabled via config)
- Sort methods by ID length desc to prevent freekassa/freekassa_sbp ambiguity
- Accept yookassa_card and cloudpayments_card/sbp in create_guest_payment
- Send single sub-option to frontend (not just when >1) for correct routing
Resolve sub-option display names from payment_method_config_service
and return them as a list of {id, name} in the public landing config.
Accept suffixed payment_method IDs (e.g. platega_2, yookassa_sbp)
in the purchase endpoint for sub-option selection.
Allow per-landing override of payment method sub-options (e.g. Card/SBP
for Yookassa). Add validated sub_options field to admin and public schemas
with opt-out model (missing keys = enabled, null = all available).
Without these RFC 5322 required headers, Postfix sends messages with
empty message-id=<> which triggers spam filters at receiving MTAs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root cause: validate_and_clean_subscription() wiped connected_squads=[]
before create_remnawave_user() could send them to the Remnawave panel.
This caused replaced subscriptions to lose squad (server) assignments.
Also: add update_server_counters=True to all guest purchase flows,
commit tariff_id before create_remnawave_user to ensure fresh ORM state.
- Add PENDING_ACTIVATION status for users with existing subscriptions
- Add activation endpoint POST /landing/activate/{token}
- Send email notifications on delivery and pending activation
- Add 3 email templates (delivered, activation required, gift received) in 5 languages
- Extract purchase status response builder to reusable helper
- Move activation logic to service layer
- Add header injection protection in email service
- Add Literal type guard for contact_type parameter
- Fix _mask_email crash on malformed input
- Pre-resolve notification params before commit to avoid DetachedInstanceError
Platega, Heleket, WATA, CloudPayments now accept optional return_url
and pass it to their APIs. Guest payments redirect back to cabinet
success page instead of Telegram bot.
- Remove `locked.is_paid = True` — is_paid is a read-only @property computed from status
- Change status from 'completed' to 'paid' (matching is_paid property check)
- Use db.commit() instead of db.flush() for guest payment persistence
PostgreSQL не может автоматически привести строковый default к типу JSON.
Решение: убрать default, сменить тип, затем поставить новый default через raw SQL.
Мультиязычность:
- Миграция 0021: текстовые поля лендингов → JSON с ключами локалей
- Утилита resolve_locale_text с fallback-цепочкой (lang → ru → en → first)
- Админ API: dict[str, str] для title/subtitle/footer/meta/features
- Публичный API: ?lang= параметр, резолвит в плоские строки
- Обратная совместимость: plain strings → {"ru": value}
Гостевые платежи:
- Миграция 0022: user_id nullable во всех платёжных таблицах
- Поддержка всех провайдеров кроме Stars
- Общий хелпер try_fulfill_guest_purchase в common.py
- YooKassa переведена на общий хелпер
Исправления по ревью:
- CryptoBot: guest fulfillment после FOR UPDATE lock
- _patch_guest_metadata: commit вместо flush
- Freekassa/KassaAI: metadata как dict вместо JSON-строки
- purchase_token маскирован в логах
- None → "guest" в order_id
- Rate limit на GET /landing/{slug}
- Маскирование contact_value
скрыты когда значение = 0
- Динамический grid-cols в зависимости от количества видимых карточек
- Добавлено поле max_commission_payments в тип ReferralTerms
- Уведомления бота: строки с бонусом нового пользователя и бонусом
пригласившего скрываются если соответствующие настройки = 0
- Раздел "Как работают награды": карточки бонусов скрыты при значении 0
- Invite message: строка про бонус за первое пополнение скрыта при 0
- Текст комиссии: "с каждого пополнения" при без лимита,
"с пополнений" при наличии REFERRAL_MAX_COMMISSION_PAYMENTS- API /terms: добавлено поле max_commission_payments
- Добавлен ключ локали REFERRAL_REWARD_COMMISSION_LIMITED (ru/en/ua/zh/fa)
- Модели LandingPage и GuestPurchase + миграции 0018/0019
- CRUD для лендингов и гостевых покупок
- Публичные роуты: GET /{slug}, POST /{slug}/purchase, GET /purchase/{token}
- Админ-роуты: CRUD лендингов с RBAC (manage_landings)
- Сервис guest_purchase_service: валидация, создание, фулфилмент
- Интеграция с PaymentService (YooKassa card/SBP) для гостевых платежей
- Webhook-обработка с идемпотентностью и атомарными транзакциями
- Rate limiting на публичных эндпоинтах
- YooKassaPayment.user_id теперь nullable для гостевых платежей
Вместо хардкода имён constraint'ов — ищем реальное имя через
pg_constraint. Пропускаем несуществующие таблицы и FK.
Исправляет краш при обновлении у пользователей с неполной схемой.
- SELECT FOR UPDATE блокировка во всех 9 провайдерах (кроме YooKassa — свой паттерн)
- create_transaction(commit=False) + единый db.commit() для атомарности
- emit_transaction_side_effects() для отложенных событий после коммита
- Все link_*_payment_to_transaction используют db.flush() вместо db.commit()
- Freekassa/KassaAI: прямое присвоение transaction_id + flush вместо update_status
- MulenPay: прямая мутация balance_kopeks вместо add_user_balance
- Platega: блокировка перед чтением metadata, инлайн обновления полей
- CloudPayments: int(round(amount * 100)) для корректного округления
- Heleket добавлен в SUPPORTED_AUTO_CHECK_METHODS
- Удалены PII из логов yookassa webhook (заголовки, IP)
- UniqueConstraint(external_id, payment_method) на транзакциях + миграция 0017
- Cabinet: PaymentService(bot=bot) внутри try блока
- verify_payment_amount утилита для проверки суммы webhook
- broadcast_history.admin_id: CASCADE→SET NULL (column is nullable, preserve audit trail)
- Added nullable=True to broadcast_history.admin_id in model
- Added 27 missing FK constraints to _FK_CHANGES (were only cleaned for orphans
but not recreated with ondelete)
- All 53 FK→users.id now consistently handled in both orphan cleanup and constraint recreation
27 FK ссылающихся на users.id не имели ondelete — при физическом удалении
юзера или восстановлении бэкапа с сиротами FK constraints не создавались.
Миграция 0016:
1. Чистит сироты во всех 53 child-таблицах (DELETE для non-nullable, SET NULL для nullable)
2. Пересоздаёт 27 FK с ON DELETE CASCADE (user_id) или SET NULL (created_by, processed_by)
После добавления UniqueConstraint(user_id, promocode_id) на promocode_uses,
простое переназначение user_id при мерже падает с IntegrityError если оба
юзера использовали один промокод. Теперь сначала удаляются дубликаты.
- trial подписки теперь конвертируются в платные вместо отказа (ошибка ~20 из 300 юзеров)
- extend_subscription: добавлен переход TRIAL→ACTIVE
- UniqueConstraint на PromoCodeUse(user_id, promocode_id) + миграция 0015 с дедупликацией
- create_promocode_use: begin_nested()+flush() вместо commit/rollback (без коррупции сессии)
- race condition: create_promocode_use вызывается ДО _apply_promocode_effects
- cleanup: удаление зарезервированной записи при ValueError от эффектов
- atomic SQL increment для current_uses (защита от lost-update)
- mark_user_as_had_paid_subscription: savepoint вместо commit/rollback
- удалён мёртвый код: use_promocode(), trial_subscription_not_eligible из маппингов
- float precision: int(round(amount * 100)) вместо int(amount * 100) для рублей→копейки
- порядок регистрации callback-хендлеров (специфичные startswith первыми)
- FSM state filter на callback хендлере для предотвращения случайных срабатываний
- upsert паттерн в add_contest_event вместо дубликатов
- расширенный SQL фильтр в get_contests_for_events (все активные конкурсы)
- нормализация end-of-day (23:59:59.999999) для границ конкурсных периодов
- guard is_completed в create_transaction
1. Поиск системных ролей по (is_system + level) вместо name —
переименование через UI больше не создаёт дубликаты
2. Bootstrap только добавляет новые permissions из кода,
не перезатирая кастомизацию админа
SQLAlchemy не гарантирует порядок UPDATE при flush — если primary
обновлялся раньше secondary, unique constraint срабатывал до очистки
старого значения. Теперь: очистка secondary → flush → установка primary.
Когда RemnaWave ставит пользователю статус LIMITED (трафик исчерпан),
webhook бота устанавливает локальный статус подписки в DISABLED. При
покупке дополнительного трафика update_remnawave_user() видел DISABLED
и отправлял status=EXPIRED, что RemnaWave отвергал с ошибкой 400.
Добавлен вызов reactivate_subscription() перед синхронизацией с RemnaWave
во всех 8 потоках покупки/переключения трафика:
- handlers/subscription/traffic.py (add_traffic, execute_switch_traffic)
- cabinet/routes/subscription.py (purchase_traffic)
- cabinet/routes/admin_users.py (admin add_traffic)
- handlers/admin/users.py (_add_subscription_traffic)
- webapi/routes/miniapp.py (purchase_traffic_topup)
- subscription_auto_purchase_service.py (_auto_add_traffic, _auto_add_devices)
Также разрешён статус DISABLED в guard автопокупки трафика и устройств,
чтобы LIMITED пользователи могли автоматически докупать ресурсы.
- trial_activation_service: create_transaction после списания за триал
- purchase.py: create_transaction для платного триала через бот
- cabinet/subscription.py: create_transaction для продления и триала,
исправлены transaction=None → реальный объект в 5 уведомлениях
- simple_subscription.py: create_transaction в обоих обработчиках,
transaction передаётся в admin-уведомление вместо None
- monitoring_service: добавлен create_transaction(SUBSCRIPTION_PAYMENT, BALANCE)
и admin-уведомление через with_admin_notification_service
- daily_subscription_service: исправлен PaymentMethod.MANUAL → BALANCE,
добавлено admin-уведомление через with_admin_notification_service
- subscription_auto_purchase_service: admin-уведомления вынесены из блока
if bot: и используют with_admin_notification_service (3 локации)
- send_subscription_purchase_notification: abs(transaction.amount_kopeks) when no explicit amount_kopeks passed
- send_subscription_renewal_notification: abs(transaction.amount_kopeks) for SubscriptionEvent storage
- Prevents negative amounts in admin Telegram messages and SubscriptionEvent records
- expenses_kopeks: func.abs() handles WITHDRAWAL stored as negative by approve_request
- admin_users.py: abs() in display instead of sign flip for mixed-sign WITHDRAWAL/SUBSCRIPTION_PAYMENT
- referral_contest.py: func.abs() on get_contest_payment_stats total_amount sum
- admin_stats.py: abs() on RecentPaymentItem to prevent negative amounts in API
- stored_amount используется только для БД записи, оригинальный
amount_kopeks передаётся в event emitter и contest service через abs()
- Добавлен func.abs() в leaderboard конкурсов (referral_contest.py)
- Предотвращает негативные суммы в событиях и рейтинге конкурсов
- Убран WITHDRAWAL из автонегации в create_transaction (ломал profit,
expenses и display flip в admin_users)
- Добавлен func.abs() в by_type агрегацию (transaction.py)
- Добавлен func.abs() в total_spent user.py (_build_spending_stats_select)
- Исправлен all_time_stats в боте и webapi: передаём явный диапазон дат
вместо дефолтного текущего месяца
get_transactions_statistics() без аргументов по умолчанию возвращает
текущий месяц, а не все время. Передаём явный start_date=2020-01-01
для корректного расчёта общего дохода и дохода от подписок.
- Добавлен abs() на уровне API-ответов для subscription_income (защита от
негативных значений при несогласованных знаках SUBSCRIPTION_PAYMENT)
- Нормализация знаков в create_transaction: SUBSCRIPTION_PAYMENT и WITHDRAWAL
всегда сохраняются как отрицательные (дебет)
- Исправлен income_total в дашборде: показывал месячный доход вместо общего
(теперь используется отдельный запрос all_time_stats)
TRUNCATE 83 таблиц таймаутился из-за command_timeout=30s в asyncpg.
После таймаута в fallback-цикле PendingRollbackError каскадировал
на все остальные таблицы и восстановление данных.
Исправление:
- Выделенный engine с command_timeout=300s и statement_timeout=5min
для TRUNCATE операций (NullPool, без overhead)
- Каждая таблица в fallback очищается в отдельном соединении,
что предотвращает каскад PendingRollbackError
- lock_timeout=2min для ограничения ожидания блокировок
(бот продолжает обрабатывать сообщения во время восстановления)
При нажатии «Продлить подписку» из webhook-уведомления триальный
пользователь получал ошибку «Продление доступно только для платных
подписок». Теперь вместо этого показывается сообщение с кнопкой
«Купить подписку», которая ведёт к выбору тарифа.
Add SELECT FOR UPDATE row lock on subscription before checking device
limit in all 3 device purchase endpoints (cabinet new, cabinet legacy,
miniapp). Without the lock, two concurrent requests both read the old
device_limit, both pass validation, and both increment — resulting in
device count exceeding max_device_limit (e.g., 5 devices when limit is 3).
Also moved max-devices check before balance check in legacy endpoint
to fail fast under lock.
- Add consume_promo_offer to 5 call sites in tariff_purchase.py where
_get_user_period_discount() stacks promo_offer into blended discount
(lines 823, 1134, 1706, 2238, 2971)
- Fix negative amount_kopeks in miniapp.py:5351 transaction record
(was -final_total, all other SUBSCRIPTION_PAYMENT use positive)
- Replace duplicate _get_user_promo_offer_discount_percent in
monitoring_service with shared get_user_active_promo_discount_percent
The tariff-mode renewal in miniapp applied promo_offer_discount_percent
to final_total but never passed consume_promo_offer to subtract_user_balance,
allowing infinite reuse of first-purchase-only promo discounts via miniapp.
- Add mark_as_paid_subscription=True to cabinet trial activation
- Reorder menu.py: charge balance BEFORE creating subscription (prevents orphaned subscription)
- Remove dead _consume_user_promo_offer_discount method from monitoring_service (55 lines)
- Remove unused imports (get_latest_claimed_offer_for_user, log_promo_offer_action)
- Fix inconsistent _get_promo import alias to use full function name
- Replace inline SELECT FOR UPDATE in renew_subscription with subtract_user_balance
- Replace direct balance_kopeks -= in trial activation with subtract_user_balance
- Add success checks to 4 unchecked subtract_user_balance calls (devices x2, menu, tariff switch)
- Add consume_promo_offer to monitoring_service autopay (was non-atomic)
- Add mark_as_paid_subscription=True to trial_activation_service, daily_subscription_service, admin purchase paths
- Remove 3 redundant has_had_paid_subscription assignments in auto_purchase_service
- Fix stale cart consume_promo_offer: compute from live user state instead of cart data
- Add consume_promo_offer flag to all cabinet cart_data dicts (renew, daily tariff, non-daily tariff) so auto-purchase service clears discount fields after purchase
- Add mark_user_as_had_paid_subscription calls after cabinet renew and tariff purchase to prevent re-activation of first_purchase_only promo codes
- Add mark_user_as_had_paid_subscription to auto-purchase service for non-trial purchases
import redis.exceptions overwrites the redis name binding from
import redis.asyncio as redis, causing from_url() to create a
sync client. ping() then returns bool instead of coroutine.
Fix: from redis.exceptions import NoScriptError
Previously preset roles were only seeded on first run. Now if a
system role's permissions differ from the preset definition, they
are updated automatically on startup.
Separate sales statistics permissions from general stats:
- Add sales_stats section to PERMISSION_REGISTRY (read, export)
- Update all 6 sales-stats endpoints to require sales_stats:read
- Add sales_stats:* to Admin preset, sales_stats:read to Marketer preset
Заменён хардкод _ALL_PROVIDERS на _get_active_providers():
- Telegram — всегда
- Email — только при CABINET_EMAIL_AUTH_ENABLED
- OAuth — только включённые в настройках (OAUTH_*_ENABLED)
- Extract _exchange_and_link_oauth() helper to deduplicate link_provider_callback
and link_server_complete (exchange code, fetch user info, check conflict, link)
- Use Literal['google','yandex','discord','vk'] for provider path parameters
(FastAPI validates automatically, removes manual checks)
- Add safe int parsing for user_id from state with proper error handling
- Remove redundant provider validation checks (handled by Literal type)
- Wrap db.commit() in try/except IntegrityError for both
link_provider_callback and link_server_complete (race condition guard)
- Fix ruff format issues (line wrapping)
- Add POST /link/server-complete endpoint (no JWT, auth via state token)
- Make provider optional in ServerCompleteRequest (resolved from Redis)
- Make provider optional in validate_oauth_state (skip check if None)
- Endpoint validates linking state, exchanges code, links or creates merge
- Add `from exc` to IntegrityError raise for consistent exception chaining
- Remove redundant unquote() in validate_telegram_init_data (parse_qsl already decodes)
- Tighten model_validator to require all 3 widget fields (id, auth_date, hash) together
- Extract _MAX_CLOCK_SKEW_SECONDS constant replacing magic number -300
- Use logger.exception() instead of logger.error(exc_info=True) in 2 places
- POST /cabinet/auth/account/link/telegram supporting both initData (Mini App) and Login Widget flows
- Pydantic model_validator enforces mutual exclusivity of init_data vs widget fields
- IntegrityError handling for TOCTOU race on telegram_id UNIQUE constraint
- Username guard: only set if user has no existing username
- Max-length constraints on all string fields
- Future auth_date rejection (< -300s) in both validation functions
- Transfer negative balances on merge (debt must not vanish)
- Validate OAuth state was initiated for account linking flow
- Transfer secondary's referrer to primary when primary has none
- Type MergePreviewSubscription schema (replace dict[str, Any])
- Cap restore_merge_token TTL to prevent clock-skew extension
- Add 4 new tests (negative balance, referrer transfer scenarios)
- Add restore_merge_token() to re-store consumed token if execute_merge
or db.commit fails, allowing the user to retry instead of being stuck
- Fix partner_status priority: PENDING (2) now beats REJECTED (1), so
an active application is not lost during merge
- Add tests for pending-vs-rejected edge cases (47 tests total)
Prevents data corruption when merging accounts that have mutual referral
relationships. Cross-referral ReferralEarning rows are now deleted before
any bulk UPDATE to avoid self-referral records. Secondary's referred_by_id
is cleared during cleanup to prevent orphaned FK references.
- Add User.id != primary.id filter to referred_by_id reassignment to
prevent self-referral loops when primary was referred by secondary
- Clear primary.referred_by_id if it pointed to secondary
- Add exclusion filter to ReferralEarning.referral_id reassignment to
prevent user_id == referral_id rows
- Invalidate refresh tokens for BOTH primary and secondary during merge
(primary gets a fresh session after merge)
- Fix duplicate step 4 comment numbering in execute_merge_endpoint
- Add referred_by_id field to test fixture _make_user
- Validate keep_subscription_from BEFORE consuming merge token (read
first with get_merge_token_data, then consume) — prevents token loss
on validation failure
- Add missing await db.flush() after db.delete(secondary_sub) in
keep_subscription_from='primary' branch (consistency with 'secondary')
- Capture transferred_kopeks in local var before zeroing secondary
balance (defensive against log reordering)
- Rename _compute_auth_methods to compute_auth_methods (public API)
- Add Literal type to _handle_subscription_merge param
- Add Literal type to keep_from in route handler
- Add Path(min_length=32, max_length=64) on merge_token params
- Import Path and Literal in account_linking routes
- Clear ALL unique constraint fields on secondary user after merge
(telegram_id, OAuth IDs, email, referral_code, remnawave_uuid)
- Add Literal type + runtime validation for keep_subscription_from
- Reject merge when primary user is deleted
- Validate OAuth state user_id matches authenticated user in link callback
- Replace leaked ValueError messages with generic error detail
- Fix exc_info usage for idiomatic structlog
- Fix _get_remnawave_api return type to AsyncIterator
- Remove unnecessary from __future__ import annotations
- Add 3 new tests (42 total, all passing)
Add OAuth provider linking/unlinking endpoints, merge token service
(Redis-backed, 30-min TTL), and atomic account merge executor that
transfers OAuth IDs, telegram_id, email, balance, subscriptions,
transactions, payments, referral data, and partner status between
two user accounts. Unchosen subscription is deleted from RemnaWave
with disable as fallback.
Includes 39 unit tests covering all merge scenarios.
Root cause: 7 tables (admin_audit_log, admin_roles, user_roles,
access_policies, partner_applications, required_channels,
user_channel_subscriptions) were missing from backup/restore.
DELETE FROM users hit FK constraint from admin_audit_log, poisoning
the entire PostgreSQL transaction — all subsequent operations failed.
Fixes:
- Add 8 missing models to backup (+ CabinetRefreshToken)
- Replace individual DELETE FROM with TRUNCATE ... CASCADE
(handles FK deps automatically, resets sequences)
- Fallback: per-table TRUNCATE with savepoints if batch fails
- Fix _restore_users_without_referrals: wrap flush in savepoint
instead of db.rollback() which killed entire transaction
- Add sync_postgres_sequences() after ORM restore to prevent
PK conflicts before bot restart
- Check if another user already owns the panel UUID before assigning
- Rollback + refresh user on sync failure instead of leaving session dirty
- Save user.email before try block for safe error logging
Daily subscriptions have end_date = +24h, and between 30-min check cycles
they would get expired by 5 different code paths before DailySubscriptionService
could charge and renew them. Users saw "subscription expired" while having balance.
Root cause fixes (6 paths protected):
- subscription_checker middleware: skip active daily subscriptions
- check_and_update_subscription_status CRUD: skip active daily subscriptions
- monitoring_service: run autopay BEFORE expired check, expand query to
include recently-expired subscriptions (2h window)
- remnawave_webhook_service: _handle_user_expired skips daily tariffs
- remnawave_service: validate_and_fix_subscriptions skips daily tariffs
Recovery mechanisms:
- New get_expired_daily_subscriptions_for_recovery() CRUD function
- DailySubscriptionService.process_auto_resume() restores DISABLED (balance
topped up) and EXPIRED (incorrectly expired) daily subscriptions
- Runs before daily charges in each monitoring cycle
Root cause: ensure_tariffs_synced runs BEFORE bot_configuration_service.initialize(),
so SALES_MODE from system_settings is not yet applied. If SALES_MODE=classic is set
via cabinet (not .env), load_period_prices_from_db sees tariffs mode and loads tariff
prices into _DB_PERIOD_PRICES. Then refresh_period_prices() always prefers
_DB_PERIOD_PRICES over settings.PRICE_*_DAYS, even in classic mode.
Three fixes:
1. refresh_period_prices() now checks settings.is_tariffs_mode() before using
_DB_PERIOD_PRICES — classic mode always uses settings.PRICE_*_DAYS
2. initialize() calls refresh_period_prices() after all DB overrides are applied,
so SALES_MODE is correct when prices are recalculated
3. Switching SALES_MODE to classic via cabinet now clears _DB_PERIOD_PRICES
- Use uv 0.10.7 with pyproject.toml + uv.lock instead of pip + requirements.txt
- Bind mounts for pyproject.toml/uv.lock with BuildKit cache for faster rebuilds
- UV_COMPILE_BYTECODE=1 for pre-compiled .pyc, UV_LINK_MODE=copy for multi-stage
- UV_PYTHON_DOWNLOADS=never to prevent uv from downloading its own Python
- Replace wget healthcheck with Python stdlib (removes apt layer from runtime)
- Increase start-period to 60s for migration headroom
- Fix redundant chown -R on entire /app
- Add .venv, tests, .mypy_cache, .ruff_cache to .dockerignore
Add daily_by_method field to deposits endpoint with GROUP BY
(date, payment_method) query. Uses raw column instead of coalesce
since base_filter already excludes NULLs via .in_(REAL_PAYMENT_METHODS).
Use a single coalesce expression object shared across SELECT, GROUP BY,
and ORDER BY clauses so PostgreSQL sees the same expression reference
instead of separately parameterized literals.
- Add device purchase count and revenue to addons endpoint (filter by 'устройств' in transaction descriptions)
- Add daily_by_tariff series to sales endpoint (group subscriptions by date and tariff name)
- Split trials daily data into separate registrations and trials series with date union merge
- Add total_registrations count to trials stats response
For "all time" period, define renewals as users with >1 subscription
payment (repeat customers) instead of filtering by created_at < 2020
which always yields empty results.
Bug 1 improvement: Replaced double API call pattern (sync + update_remnawave_user)
with single _sync_subscription_to_panel call that accepts reset_traffic parameter.
This prevents TRIAL status being overwritten to EXPIRED by the second call's
different status computation logic.
Bug 2 improvement: Moved keyboard construction inside try block to prevent
AttributeError crash if locale keys are missing. Switched button text from
attribute access (texts.KEY) to defensive texts.get('KEY', fallback).
Added empty template guard to prevent sending empty messages to Telegram API.
Bug 1: Admin tariff change used update_remnawave_user() which returns
early when user has no remnawave_uuid. Restored _sync_subscription_to_panel()
which discovers/creates panel users via telegram_id/email fallback, then
applies traffic reset if RESET_TRAFFIC_ON_TARIFF_SWITCH is enabled.
Bug 2: Post-topup cart reminder in payment/common.py had hardcoded Russian
text sent to all users regardless of language. Replaced with localized
BALANCE_TOPPED_UP_CART_SUFFICIENT/INSUFFICIENT keys and used existing
MY_BALANCE_BUTTON/MAIN_MENU_BUTTON for inline keyboard buttons.
Added new i18n keys to all 5 locales (ru, en, ua, zh, fa).
- Add /cabinet/admin/stats/sales/* endpoints: summary, trials,
subscriptions, renewals, addons, deposits
- Period params: days preset or custom start_date/end_date range
- MAX_PERIOD_DAYS=730 validation with proper date parsing
- Conversion rate capped at 100% to handle cross-period conversions
- Use EXTRACT(epoch)/86400 for accurate interval day calculation
- Consolidated subscription queries with CASE expressions
- Renewals with period-over-period comparison and trend detection
- Permission-gated with require_permission('stats:read')
- Shared link utilities in cabinet/utils/links.py
- Use PartnerStatus.APPROVED.value instead of hardcoded 'approved'
- Extract shared deep_link/web_link helpers to cabinet/utils/links.py
- Add _safe_div() helper for None-safe division
- Add try/except error handling on campaign endpoints
- Use model_fields_set for PATCH-style field detection
- Replace deprecated class Config with ConfigDict(from_attributes=True)
- Remove unnecessary selectinload(registrations) from campaign list
- Extract _calc_change to module-level in partner_stats_service
- Add composite indexes for stats queries on Subscription, Transaction,
SubscriptionConversion, and TrafficPurchase models
- Add get_admin_campaign_chart_data() to PartnerStatsService with daily registrations, revenue trends, period comparison, and top registrations
- Add total_deposits_kopeks and total_spending_kopeks as separate aggregates
- Add 6 Pydantic schemas for admin chart data response
- Add GET /{campaign_id}/chart-data endpoint with campaigns:stats permission
- Add partner application endpoints and schemas for campaign detailed stats
VK deprecated oauth.vk.com on Sep 30, 2025. Migrate to VK ID (id.vk.ru)
with mandatory PKCE S256 and device_id support.
- Rewrite VKProvider: new endpoints, PKCE code_verifier/challenge, user_info format
- Add prepare_auth_state() hook for provider-specific state (PKCE)
- Use atomic Redis GETDEL for OAuth state validation (prevent TOCTOU race)
- Add CacheService.getdel() method
- Check cache.set() result in generate_oauth_state
- Filter ephemeral keys (_prefix) from Redis storage
- Fix garbled log messages, use exc_info for tracebacks
- Add input validation (min_length, max_length on code/state)
- Generic error messages (no provider name leakage)
- Fix balance history display: referral_reward, refund, poll_reward now
shown as credits (💰 +amount) instead of expenses
- Fix double-counting: remove all Transaction-based REFERRAL_REWARD sum
queries from crud/referral.py, admin_stats.py, admin_users.py —
ReferralEarning is now the single source of truth
- Unify "active referrals" definition across cabinet, bot, and admin:
JOIN Subscription WHERE status=ACTIVE AND end_date > now()
- Add payment_method IS NOT NULL guard to get_user_own_deposits() to
exclude referral rewards historically mistyped as deposits
- Replace hardcoded transaction type strings with TransactionType enum
values in referral_withdrawal_service.py
- Add Alembic data migration (0014) to fix historical transactions:
UPDATE deposit → referral_reward WHERE payment_method IS NULL and
description matches referral patterns
The available_referral formula incorrectly treated all post-earning spending
as spent from referral balance, making withdrawable balance stay at 0 even
as earnings increased. Changed to min(wallet_balance, earned - withdrawn - pending).
- Fix available_referral in withdrawal service and referral info endpoint
- Use TransactionType.REFERRAL_REWARD for all commission/bonus balance additions
- Gate create_referral_earning behind add_user_balance success check
- Move notifications inside balance_ok guards to prevent false confirmations
SUBSCRIPTION_DAYS promo codes now require an active or expired non-trial
subscription. Users without any subscription or with a trial subscription
get a clear error message instead of silently creating/extending.
- Remove misleading "Важно" and "При наличии корзины" warnings from all
payment success notifications
- Fix cart total bug: show actual cart price from Redis instead of top-up
amount, and suppress "insufficient funds" when balance is enough
- Extract shared send_cart_notification_after_topup() in common.py to
replace duplicated code across all 10 payment providers
Previously 'Продажи' stats counted by Subscription.created_at which only
reflects initial creation date. Renewals update end_date on existing record
without changing created_at, so renewals were never counted as sales.
Now counts completed SUBSCRIPTION_PAYMENT transactions which are created
for every purchase and renewal. Also standardized date boundaries to use
explicit midnight UTC datetime instead of date objects.
- Add restriction_topup check to POST /cabinet/balance/topup
- Add restriction_subscription check to 6 subscription endpoints:
/renew, /purchase, /purchase-tariff, /traffic, /devices/purchase, /devices (legacy)
- All restricted endpoints return 403 Forbidden
- Fix TypeError in broadcast history when message_text is None (polls)
Root cause: sync uses enrich_happ_links=False so subscription_crypto_link
is empty for 31k+ synced users. RemnaWave config buttons use
{{HAPP_CRYPT4_LINK}} template which stays unresolved, and since
the unresolved template is truthy it prevents the subscriptionUrl fallback
in the frontend — isValidDeepLink fails (no ://) and button is not rendered.
Fixes:
- /app-config endpoint: generate crypto link via encrypt API when missing,
persist to DB so it's only generated once per user
- Template enrichment: skip setting resolvedUrl when templates remain
unresolved, allowing frontend to fall through to subscriptionUrl
- Guard sync update to only overwrite subscription_url when panel_url is non-empty
- Add fallback in /app-config and /subscription endpoints to fetch subscription URL
from RemnaWave panel when missing in local DB (auto-heals synced users on access)
Two fixes for MissingGreenlet during panel user synchronization:
1. _capture_user_state: catch exceptions when reading potentially
expired attributes (updated_at, remnawave_uuid). SQLAlchemy throws
MissingGreenlet, not AttributeError, so getattr default doesn't help.
Use sentinel to skip restoring uncaptured attrs on rollback.
2. Update branch: refresh db_user before sync _ensure_user_remnawave_uuid
call if any attributes are expired (detected via sa_inspect).
Full db.rollback() in _get_or_create_bot_user_from_panel expires ALL
ORM objects in the session, causing MissingGreenlet errors when
subsequent sync iterations access user attributes from synchronous code.
Replace with begin_nested() (SAVEPOINT) so only the failed INSERT is
rolled back while the parent transaction and all cached objects remain
valid.
_apply_extension_updates was setting subscription.tariff_id before
extend_subscription() ran, causing the CRUD's is_tariff_change
detection to always return False. This skipped TrafficPurchase
cleanup and purchased_traffic_gb reset on auto-purchase tariff changes.
extend_subscription() already handles tariff_id assignment internally.
- admin users handler: add reset_traffic param + local traffic_used_gb reset
- confirm_daily_tariff_switch: add local traffic_used_gb reset before commit
- confirm_instant_switch: add local traffic_used_gb reset before commit
Ensures DB traffic counter stays in sync with RemnaWave panel reset.
Throttling:
- Init _last_cleanup with time.monotonic() instead of 0.0
- Use split(maxsplit=1) to avoid unnecessary list allocation
- Downgrade general throttle log from warning to debug
ChannelChecker:
- Guard from_user None in Update branch (lines 98-101)
- Widen TelegramBadRequest → TelegramAPIError to catch 403 Forbidden
Renewal pricing:
- Fix double-charging when base_traffic <= 0: pass purchased_traffic
as sole traffic_limit and clear purchased_traffic flag to prevent
the add-on block from adding it again
When a user has 25GB base + 100GB purchased = 125GB total,
the renewal priced it at the 250GB tier (nearest tier >= 125GB)
instead of pricing each component separately at its own tier:
base 25GB + purchased 100GB.
- Split traffic_limit_gb into base and purchased components
- Price each component at its own tier via get_traffic_price()
- Apply same discount percentage to purchased portion
- Log warning when purchased >= total (data corruption)
- Fix in both subscription_renewal_service and subscription CRUD
- Fix CABINET_EMAIL_VERIFICATION_ENABLED=false not working: auto-verify
users on registration, allow login without verification when disabled
- Fix ban-notifications/send 400 error: paginate get_all_users (size<=1000)
- Add available_balance_kopeks and withdrawn_kopeks to referral info endpoint
Allow partners to specify their desired commission percentage (1-100%)
when applying. Field is optional and shown to admins during review.
Includes DB model, Alembic migration 0013, schema, route, and service changes.
Adds last_webhook_update_at, is_daily_paused, last_daily_charge_at,
remnawave_short_uuid to subscriptions table for databases where
these columns were not created by the initial schema migration.
In tariffs mode, check tariff.can_topup_traffic() instead of just
checking tariff_id existence. Prevents showing a button that leads
to an error when the tariff has traffic limits but no topup packages.
- Replace test@example.com fallback with pool of 20 random emails
to avoid OP-SP-7 duplicate email errors from payment provider
- Fix metadata_json parsing: handle both dict (SQLAlchemy JSON column)
and string cases to prevent json.loads crash on dict input
- Add TypeError to exception handler for robustness
- Fix active_internal_squads sent unconditionally as [] clearing Remnawave squads
- Fix dead code in _change_subscription_type (was_trial saved before mutation)
- Block wheel spins for users without active subscription (API + bot handler)
- Add has_subscription field to wheel config response
- Refund Stars to balance if spin payment arrives without subscription
- Fix SQL injection in promocode lookup (f-string → parameterized query)
- Remove redundant get_or_create_wheel_config call in stars handler
MonitoringService._check_expired_subscriptions() was marking daily
subscriptions as expired before DailySubscriptionService could charge
and extend them. Now get_expired_subscriptions() excludes active
(non-paused) daily subs — they are managed by DailySubscriptionService.
Also fix cabinet "0m until next charge" display: return None when
next_daily_charge_at is in the past instead of a stale datetime.
Split Freekassa into sub-methods: СБП/QR (i=44) and Карты РФ (i=36).
Each method has independent enable/display_name settings, dedicated
handlers, keyboard buttons, and correct payment_system_id routing.
Webhook notifications resolve display name from payment metadata.
Add missing structlog import and logger initialization.
Without this, any code path hitting logger.info/warning/error
would raise NameError at runtime.
1. Remove pointless HWID reset during auto-sync deactivation — user
doesn't exist in panel, API returns 404, UUID is cleaned up below.
2. Clean up RESTRICT FK references (AdminAuditLog, WithdrawalRequest,
AdminRole, UserRole, AccessPolicy) before deleting user to prevent
IntegrityError on admin_audit_log_user_id_fkey.
3. Fix device limit not being sent to RemnaWave when
DEVICES_SELECTION_DISABLED_AMOUNT=0: treat 0 as "no forced override"
instead of sending hwidDeviceLimit:0 (which Remnawave interprets as
unlimited). Now falls through to subscription.device_limit from tariff.
4. Add info-level logging to POST /api/users (was debug) to match
existing PATCH logging for device limit diagnostics.
- Add Literal type whitelist for background type field
- Add settings dict validation (max 20 keys, no nested objects, bounded values)
- Add opacity (0-1) and blur (0-100) bounds with Pydantic Field constraints
- Fix mutable default dict with Field(default_factory=dict)
Split users:edit into fine-grained permissions for balance management,
subscription actions, promo group editing, referral commission, and
sending promo offers.
- Change audit log action filter from exact match to ILIKE substring
search so admins can search by partial action names
- Return level 1000 (not 999) for legacy config-based admins in
/me/permissions so frontend correctly enables role management buttons
Behind Docker reverse proxy, request.client.host always returns
the proxy container IP (172.20.0.2). Now reads X-Forwarded-For
first, then X-Real-IP, falling back to request.client.host.
Legacy admins (ADMIN_IDS/ADMIN_EMAILS) had no RBAC roles in DB,
so check_permission returned 'No active roles assigned' and
role_level was 0, disabling all role management UI.
- check_permission: bypass RBAC for legacy admins
- get_user_permissions: return *:* and level 999 for legacy admins
- _get_admin_level: legacy admins get level 1000 (above superadmin)
- Simplify permission registry to return flat list[PermissionSection] with actions as list[str]
- Add user_first_name and user_email to audit log entries via selectinload
- Fix unused import and naming convention lint warnings
- Campaign notifications: add tariff bonus display, hide empty promo group,
compact format matching purchase notification style
- Ticket notifications: send media (photos) in the same topic as the text
notification instead of separately. Uses caption for short texts, sequential
messages for long texts with correct message_thread_id routing
- Fix critical bug: is_active_paid_subscription() guard was blocking
CHANNEL_REQUIRED_FOR_ALL from disabling paid subscriptions
- Add disable_trial_on_leave and disable_paid_on_leave columns to
RequiredChannel model with Alembic migration 0010
- Refactor enforcement logic in channel_member.py and channel_checker.py
to use per-channel settings instead of global env vars
- Update CRUD, Pydantic schemas, and admin API routes for new fields
- Add should_disable_subscription() and get_channel_settings() to
channel_subscription_service for per-channel decision logic
_sync_subscription_to_panel() discarded the update_user() return value,
leaving subscription_url and subscription_crypto_link as None when
updating existing panel users. This caused "Connect devices" button
and HAPP_CRYPT4_LINK to disappear after admin subscription reset.
Also adds subscription_crypto_link sync to webhook user_modified handler
(was already present in user_revoked but missing from user_modified).
Drop all messages and callback queries from non-private chats
(groups, supergroups with forum topics, channels) before they
reach any handler or heavy middleware (DB, throttle, blacklist).
- Registered after ContextVarsMiddleware, before GlobalErrorMiddleware
- chat_member events intentionally excluded (needed for channel tracking)
- pre_checkout_query excluded (no chat context, always private)
- Uses ChatType.PRIVATE enum for type safety
- Debug logging on dropped events for observability
The previous refactoring accidentally deleted RemnaWave API routes
(/remnawave/status, /uuid, /config, /configs) along with the legacy
file-based CRUD routes. Restore only the RemnaWave endpoints that
the cabinet frontend depends on.
- Escape app names, device names, and other_app_names in
handle_device_guide, handle_app_selection, handle_specific_app_guide
- Redact internal paths and exception details from cabinet API
error responses in _load_config, _save_config, and Remnawave
fetch endpoints
_save_config() in admin_apps.py now calls invalidate_app_config_cache()
after writing app-config.json, so changes via cabinet API are immediately
visible in guide mode without waiting for TTL expiry.
- Add explicit negative filter for app_ vs app_list_ callback routing
to prevent fragile registration-order dependency
- Reorder invalidate_app_config_cache to set timestamp to 0 first,
ensuring fast-path check fails immediately without lock
- Add debug logging to _get_remnawave_config_uuid fallback path
- Fix NameError: texts used before assignment in handle_single_device_reset
(crash on malformed callback_data)
- HTML-escape subscription_link in all <code> tag interpolations
(3 locations in devices.py)
- Replace format_map with regex-based placeholder substitution to
prevent format string injection via attribute traversal (CRITICAL)
- Add UUID format validation in select_remna_config handler
- Redact exception details from user-facing callback answers
- HTML-escape current_uuid in admin config menu
- HTML-escape title/description in format_additional_section
- Add fallback else branch for subscriptionLink in blocks format
(prevents silent button drop when deep link resolution fails)
- Extract render_guide_blocks() helper to eliminate duplicated
block-rendering logic between handle_device_guide and
handle_specific_app_guide
- Add HTML escaping for admin-controlled config text in guide blocks
- Remove unused get_localized_value import from devices.py
- Add async Remnawave config loader with TTL cache and asyncio.Lock
- Normalize both legacy (steps) and Remnawave (blocks) formats to unified structure
- Build dynamic platform selection keyboard from config instead of hardcoded 6-device layout
- Add colored buttons via Bot API 9.4 (green for connect, blue for download)
- Add admin panel handler for selecting Remnawave subscription page config
- Add cache invalidation from both bot admin and cabinet API
- Fix callback data parsing for app IDs with underscores
- Add Linux platform support across all device mappings
- Subscribed channels shown as green (style=success) with checkmark
- Unsubscribed channels shown as blue (style=primary)
- Clicking "I subscribed" now updates keyboard with colored status
instead of just showing error alert
- Extracted _normalize_channels helper for DRY
- All bot handler strings translated from English to Russian
- Back button now correctly navigates to admin_submenu_settings
- Added ADMIN_SETTINGS_REQUIRED_CHANNELS key to all 5 locales
@username resolution via bot.get_chat() was unreliable for subscription
checking. Now only numeric channel IDs are accepted with automatic -100
prefix when entering bare digits (e.g. 1234567890 -> -1001234567890).
- Multi-channel subscription enforcement via middleware, events, and cabinet API
- 3-layer cache architecture: Redis -> PostgreSQL -> rate-limited Telegram API
- ChatMemberUpdated event-driven tracking with automatic VPN access control
- Admin management via bot FSM handler and REST API with full CRUD
- Channel ID normalization: @username resolved to numeric ID at creation time
- Fail-closed error handling: API errors deny access (security-first)
- Background reconciliation with keyset pagination (100 per batch)
- Per-user rate limiting on subscription check button (5s cooldown)
- Redis connection pooling via cache singleton (no per-request connections)
- Database: channel_id index, multi-row upsert optimization
- Localization: en, ru, zh, fa, ua translations for all new strings
- Frontend blocking UI with channel list and subscription status
- Admin channel management page with toggle, delete, and create
Old universal_migration.py created some tables (including email_templates)
with `timestamp` (naive) columns and had a catch-all that converted all
naive columns to `timestamptz` on each startup. After switching to Alembic,
that catch-all stopped running.
Users whose email_templates table was created by universal_migration.py
before the catch-all ran still have naive `timestamp` columns. The code
uses `datetime.now(UTC)` (timezone-aware), causing asyncpg to raise:
"can't subtract offset-naive and offset-aware datetimes"
Migration 0007 finds and converts ALL remaining naive timestamp columns
in public schema to timestamptz, assuming UTC for existing data.
Fixes: email template save returning 503 with DataError
Admin endpoints returned amount_kopeks as always-positive from DB,
causing withdrawals and subscription payments to display as credits
in the admin panel. User-facing balance.py already handled this correctly.
- Add migration 0006 for blocked_count, channel, email_subject,
email_html_content columns missing from broadcast_history table
- Fix infinite trial reactivation loop in monitoring service
- Prevent webhook from overwriting freshly extended end_date
- Use tariff-specific pricing for auto-renewal instead of global config
- Add migration 0005 to re-apply missing columns from 0002-0004
(fixes DBs that were auto-stamped to head without running migrations)
- Add per-table error handling in backup ORM export so one table
failure doesn't break the entire backup
- Escape HTML in error notifications to prevent Telegram parse errors
- Add le= bounds to all user-facing Pydantic int fields (balance, subscription, traffic, devices)
- Add self-referral guard in process_referral_registration
- Add Telegram identity cross-validation to get_optional_cabinet_user
- Log when initData validation fails but header is present
When a partner clicks their own campaign link (any bonus_type), they get
attributed as their own referral — their purchases counted as campaign
revenue and they earn referral commissions on their own payments.
Add self-referral guards in three layers:
- auth.py: early return in _process_campaign_bonus if user is campaign partner
- campaign_service.py: defense-in-depth check in apply_campaign_bonus
- start.py: guards on all referrer_id assignments and process_referral calls
Telegram Mini App WebView shares localStorage across accounts on the
same device. This allows refresh tokens from user A to be reused by
user B if they open the same Mini App.
Add server-side defense: read X-Telegram-Init-Data header (already sent
by the frontend), validate it cryptographically, and reject requests
where the Telegram user ID doesn't match the JWT user's telegram_id.
Catch exceptions from get_all_nodes() in _aggregate_traffic() to prevent
unhandled ASGI errors when RemnaWave returns HTTP 502. Cache empty result
on failure to avoid request storms from parallel frontend calls.
Add is_active_paid_subscription() helper that checks if subscription is
non-trial, active, and not expired. Use it across all disable_remnawave_user
call sites to prevent disabling VPN access for users with paid subscriptions.
Protected paths: block_user, delete_user_account, broadcast cleanup,
channel unsubscribe, admin deactivation, webapi endpoints, cabinet
reset-trial, reset-subscription, and disable-user endpoints.
When ENABLE_LOGO_MODE is on, messages are sent as photos which
naturally don't show URL previews. When off, messages are sent as
text but disable_web_page_preview was never set, causing link
previews in menu, welcome, and other messages.
Always patch Message.answer/edit_text and inject
disable_web_page_preview=True for all text message paths.
Check subscription.end_date <= now instead of remaining_days == 0 to
allow switching when hours remain. The .days property truncates to whole
days, blocking users with a few hours left from switching tariffs.
- Add User.status filter to trial notification SQL queries
- Add pre-send blocked/deleted user check in _send_message_with_logo
- Fix UserStatus import shadowing (alias RemnaWaveUserStatus)
- Remove broadcast cleanup that marked users as BLOCKED in DB
- Remove dead _background_tasks variable
Referral links from cabinet (?ref=CODE) were only tracked for email registration.
Now referral_code is accepted and processed in Telegram initData, Telegram Widget,
and OAuth authentication endpoints. Includes self-referral protection by email
for OAuth, proper error logging, and the missing email_templates table migration.
Telegram limits photo captions to 1024 characters. When menu_text or
rules_text exceeds 900 chars (with promo hints, random messages etc),
bot.send_photo fails with TelegramBadRequest.
Added len() check before each of 3 send_photo calls in
required_sub_channel_check — falls back to send_message when text
is too long, consistent with _answer_with_photo in message_patch.py.
connected_squads JSON contains squad UUIDs like 'b4d782fa-...', not
integer IDs. int() cast fails on these. Now resolves UUIDs to integer
IDs via get_server_ids_by_uuids() before passing to remove_user_from_servers.
When a handler swallows a DB error (e.g. ProgrammingError for missing
column), the transaction is aborted but the handler returns normally.
The auth middleware then tries db.commit() which fails with DBAPIError.
Now catches any exception on commit and does rollback, preventing the
cascade of "current transaction is aborted" errors through all
subsequent middleware layers.
1. connected_squads JSON stores IDs as strings but server_squads.id is
integer — cast to int before passing to remove_user_from_servers
2. Wrap remove_user_from_servers in its own db.begin_nested() so its
failure doesn't abort the parent savepoint (subscription deletion)
3. Pre-fetch admin.id before delete_user_account to avoid MissingGreenlet
when transaction rollback expires the ORM object
When one deletion step fails (e.g. missing campaign_id column in referral_earnings),
PostgreSQL aborts the entire transaction. All subsequent operations then fail with
"current transaction is aborted, commands ignored until end of transaction block".
Each of the 24 try/except blocks now uses `async with db.begin_nested():`
(PostgreSQL SAVEPOINT) so individual failures are isolated and rolled back
without poisoning the outer transaction.
Decrement server_squads.current_users BEFORE deleting subscription
to match lock ordering with webhook handler, preventing deadlocks.
Also made migration 0002 robust with table existence checks to
prevent failures on DBs missing referral_earnings or
advertising_campaign_registrations tables.
Migration was failing on DBs where referral_earnings or
advertising_campaign_registrations tables didn't exist yet,
causing campaign_id column to never be added. Added _has_table
and _has_column guards, wrapped backfill in existence check.
TypeDecorator with process_result_value guarantees naive datetimes
from pre-TIMESTAMPTZ databases are converted to UTC-aware on every
load. Replaces unreliable event listener approach. All 175 DateTime
columns now use AwareDateTime.
SQLAlchemy event listener on Base ensures all DateTime columns are
timezone-aware after loading from DB. Fixes TypeError crashes in
50+ comparison sites across handlers, services, and middlewares
for pre-TIMESTAMPTZ databases.
Databases that haven't run the TIMESTAMPTZ migration return naive
datetimes from end_date. Comparing with datetime.now(UTC) raises
TypeError. Added _aware() helper to normalize naive→aware in
is_active, is_expired, should_be_expired, actual_status, days_left,
time_left_display, and extend_subscription.
Existing databases stamped at 0001 (create_all checkfirst=True) are
missing new columns/tables from the partner system:
- users.partner_status
- broadcast_history.blocked_count
- advertising_campaigns.partner_user_id
- withdrawal_requests table
- partner_applications table
All checks are idempotent — safe for fresh and existing databases.
Adds nullable FK campaign_id to referral_earnings table, enabling
direct campaign ROI analytics without JOINing through registrations.
- Model: campaign_id column + AdvertisingCampaign relationship
- CRUD: get_user_campaign_id() helper, campaign_id param in create_referral_earning
- Service: resolve campaign_id in all earning creation paths
- Cabinet API: campaign_name in earnings response
- Migration 0002: add column + deterministic backfill via DISTINCT ON
Only apply alembic.ini logging config when root logger has no handlers
(CLI mode). When running programmatically, structlog is already configured
and fileConfig would replace its handlers, breaking all logging.
The column existed in the SQLAlchemy model and Alembic migration but was
missing from universal_migration.py which is used for auto-migrations on
startup, causing "column broadcast_history.blocked_count does not exist"
error in the broadcasts admin page.
- Add partner_user_id/partner_name to campaign list and detail responses
- Add partner_user_id to campaign create/update schemas
- Add GET /available-partners endpoint for partner dropdown
- Atomic assign with UPDATE...WHERE to prevent race conditions
- Validate partner exists and is approved in create/update
- Set updated_at on assign/unassign operations
- Eager-load partner relationship in campaign queries
- GET/PATCH /admin/partners/settings endpoints with .env persistence
- New config: REFERRAL_WITHDRAWAL_REQUISITES_TEXT, REFERRAL_PARTNER_SECTION_VISIBLE
- Serve requisites_text in withdrawal balance and partner_section_visible in referral terms
- Sanitize newlines in requisites_text before .env write to prevent injection
Catch NotFoundError (404) separately from generic exceptions.
Old/expired payments return 404 from YooKassa API — this is expected
and should be logged as WARNING without traceback, not ERROR.
- replace unsafe referral code generator with unique DB-checked version
- remove dead code in get_global_partner_stats
- validate status filter params with Literal types in admin routes
- fix N+1 query in money laundering analysis with GROUP BY batch query
- fix N+1 query in cabinet referral earnings with batch user fetch
- eliminate double balance stats computation in withdrawal flow
- replace in-memory referral counting with SQL COUNT/CASE aggregation
- fix HTML injection in admin Telegram notifications via html.escape()
- standardize return types for reject/complete withdrawal methods
- Add SELECT FOR UPDATE locking on all financial state transitions
(withdrawal approve/reject/complete/create, partner approve/reject)
- Add html.escape() on all user-controlled values in email templates
- Wrap sync SMTP send_email in asyncio.to_thread to avoid blocking event loop
- Add missing database indexes on referral_earnings(user_id, referral_id),
users(referred_by_id, partner_status), withdrawal_requests(user_id, status),
advertising_campaigns(partner_user_id)
Two separate fixes for bot and cabinet auth paths:
Bot (start.py): store referrer_id from campaign.partner_user_id in FSM
state, skip referral code prompt when partner already set.
Cabinet (auth.py): in _process_campaign_bonus, set user.referred_by_id
to campaign.partner_user_id and call process_referral_registration.
Both paths now correctly attribute campaign users to the partner,
enabling commission earnings from their future purchases.
When a user registers through a campaign link that has partner_user_id,
store that partner as referrer_id in FSM state. This connects the
campaign system to the referral earning system — the partner now earns
commissions from all purchases made by users who came through their
campaign links.
Changes in all registration paths:
- cmd_start: store referrer_id from campaign.partner_user_id
- language/rules/privacy handlers: skip referral code prompt when
referrer_id already set from campaign
- channel check: pick up referrer_id from state instead of hardcoding None
Previously revoke_partner only changed partner_status and commission,
leaving campaigns orphaned with invalid partner_user_id. Now sets
partner_user_id=NULL on all campaigns belonging to the revoked partner.
ALTER COLUMN user_id TYPE INTEGER failed with "integer out of range"
because the column contained telegram_id values (BIGINT) exceeding
INTEGER max. Swapped order: SET NULL first, then ALTER TYPE.
Caused UnboundLocalError on datetime.now(UTC) at line 209 because
Python treats the function-local `from datetime import UTC` (lines 351, 362)
as a local variable declaration, making UTC unbound before those lines.
- admin_traffic._get_bulk_spending: add func.abs() for SUBSCRIPTION_PAYMENT SUM
- get_user_total_spent_kopeks: move abs() from Python to SQL (per-row func.abs)
- referral_contest.total_outside: add abs() for mixed-type sum
- Revert func.abs() from generic by_type aggregation to preserve refund/withdrawal signs
SUBSCRIPTION_PAYMENT transactions have inconsistent signs in DB
(some negative, some positive). Add func.abs()/abs() to all SUM
queries and display code to ensure correct totals regardless of sign.
Affected: admin statistics, referral contest stats, tariff revenue,
campaign stats, reporting service, admin renewal notifications.
SUBSCRIPTION_PAYMENT transactions are stored with negative amount_kopeks.
- get_user_total_spent_kopeks now returns abs() to fix "Потрачено: -155 ₽"
and broken promo group threshold comparisons
- Balance history uses abs() before format_price to prevent "--85 ₽"
MonitoringService instantiated PaymentService() at module level during
import, triggering a debug log before structlog/logging were configured.
This caused [debug ] with padded spaces (structlog default pad_level)
and appeared 7 seconds before the startup banner. The payment_service
attribute was never used in MonitoringService.
logging.basicConfig() silently does nothing if the root logger already
has handlers. When import-time side effects trigger stdlib logging before
main() configures formatters, our ProcessorFormatter with pad_level=False
never gets applied — producing [debug ] instead of [debug].
1. Add _prefix_logger_name processor that moves [module.name] before
event text for consistent format: timestamp [level] [module] message
2. Fix startup summary table alignment by using display width calculation
instead of len() — properly accounts for wide emoji and variation
selectors that render as 2 terminal cells
Cabinet admin endpoint was setting settings.SUPPORT_SYSTEM_MODE directly
without updating SupportSettingsService JSON, causing bot to show stale
mode. Now routes through set_system_mode() which updates both stores.
When changing SUPPORT_SYSTEM_MODE via system settings admin panel, the
SupportSettingsService JSON cache was not updated, causing the old value
to take priority. Now both services stay in sync bidirectionally.
Add .selectinload(Subscription.tariff) chain to all User queries that
load subscriptions, preventing lazy loading of the tariff relationship
in async context. Also replace unsafe getattr(subscription, 'tariff')
with explicit async get_tariff_by_id() in handle_extend_subscription.
- TelegramNotifierProcessor: resolve exc_info=True → sys.exc_info()
tuple while still in except block, fixing "(no traceback available)"
- Use real exception type (e.g. TelegramBadRequest) instead of LogError
- Include user_id/username in admin notification context
- ConsoleRenderer: pad_level=False removes trailing spaces in [info]
- Strip [__main__] logger name from startup/timeline logs
RichTracebackFormatter defaults (show_locals=True, max_frames=100)
produced 5000+ line tracebacks on chained exceptions with aiogram.
Now: show_locals=False, max_frames=20, suppress aiogram/aiohttp frames.
- LoggingMiddleware: logger.error → logger.exception to include exc_info
so TelegramNotifierProcessor can extract traceback for admin chat
- ConsoleRenderer: pad_event_to=0 to remove excessive whitespace
in short event names (timeline markers like ┃, ┗)
- Add rich dependency for colored tracebacks and console rendering
- Set FORCE_COLOR=1 in docker-compose for color output in containers
- Remove format_exc_info from processor chain — ConsoleRenderer now
handles exc_info directly (Rich tracebacks on console, plain in files)
- Let ConsoleRenderer auto-detect colors via FORCE_COLOR env var
- Add ContextVarsMiddleware for automatic user_id/chat_id/username binding
via structlog contextvars (aiogram) and http_method/http_path (FastAPI)
- Use bound_contextvars() context manager instead of clear_contextvars()
to safely restore previous state instead of wiping all context
- Register ContextVarsMiddleware as outermost middleware (before GlobalError)
so all error logs include user context
- Replace structlog.get_logger() with structlog.get_logger(__name__) across
270 calls in 265 files for meaningful logger names
- Switch wrapper_class from BoundLogger to make_filtering_bound_logger()
for pre-processor level filtering (performance optimization)
- Migrate 1411 %-style positional arg logger calls to structlog kwargs
style across 161 files via AST script
- Migrate log_rotation_service.py from stdlib logging to structlog
- Add payment module prefixes to TelegramNotifierProcessor.IGNORED_LOGGER_PREFIXES
and ExcludePaymentFilter.PAYMENT_MODULES to prevent payment data leaking
to Telegram notifications and general log files
- Fix LoggingMiddleware: add from_user null-safety for channel posts,
switch time.time() to time.monotonic() for duration measurement
- Remove duplicate logger assignments in purchase.py, config.py,
inline.py, and admin/payments.py
Wrap all edit_message_text calls in ticket handlers with try/except
TelegramBadRequest fallback to message.answer(). Fixes crash when
the prompt message was deleted or has no text (e.g. photo message).
- Rate-limit on brute-force: 5 failed attempts per 5 min blocks user
- Daily stacking limit: max 5 promo activations per 24h (in-memory + DB)
- Format validation: only alphanumeric/hyphen/underscore, 3-50 chars
Sliding window limiter: max 3 /start calls per 60 seconds per user.
Runs before the general 0.5s throttle. Shows cooldown timer on block.
Lazy cleanup of start_buckets when size exceeds 500 entries.
The 'Back' button on tariff extend confirmation sends
tariff_extend:{id} without a period segment, which crashed
select_tariff_extend_period with IndexError on parts[2].
Now redirects to show_tariff_extend when period is missing.
Remnawave already sends user.not_connected webhooks, making the
monitoring service's 1h/24h trial inactivity checks redundant.
The monitoring checks caused false positives because they relied on
traffic_used_gb which may not be synced in real-time.
Removed:
- _check_trial_inactivity_notifications from monitoring cycle
- _send_trial_inactive_notification method
- trial_inactive_1h / trial_inactive_24h notification settings
- Admin UI toggles and preview buttons for these notifications
Changed callback_data from 'subscription' (no handler) to 'menu_subscription'
(registered handler) in _get_subscription_keyboard and _get_traffic_keyboard.
In cabinet mode the button opens a WebApp URL so the bug was invisible,
but in default MAIN_MENU_MODE the callback went unhandled.
- Keyboard now shows "Возобновить" for disabled/expired daily tariffs
instead of useless "Приостановить"
- resume_daily_subscription handles EXPIRED→ACTIVE (not only DISABLED)
- Pause handler detects inactive status and calls resume directly
- subscription_extend redirects daily tariffs to subscription info
(daily tariffs have no period_prices, so extend page was empty)
- Add enabled flag to hide/show each button section in main menu
- Add per-locale custom labels (ru, en, ua, zh, fa) for button text
- Deep-copy nested labels dict in cache to prevent reference leaks
- Validate label entries from DB (type + locale key checks)
- Use selective merge in PATCH handler instead of blind .update()
Allow admins to set buttons to Telegram's default style with no color
override. Refactors style resolution from or-chain to explicit if/elif/else
so that 'default' does not fall through to global config or hardcoded defaults.
Add cabinet admin API for configuring button colors (primary/success/danger)
and custom emoji IDs per menu section (home, subscription, balance, referral,
support, info, admin). Styles are stored as JSON in system_settings and cached
in-process for fast resolution.
Style resolution chain: explicit param > per-section DB > global config > defaults.
- Rename mode from 'text' to 'cabinet' (text/text_only/minimal kept as aliases)
- Add build_cabinet_url() for joining MINIAPP_CUSTOM_URL with section paths
- Cabinet main menu now has section-specific buttons: subscription, balance,
referral, support, info — each opens the corresponding cabinet page
- Add CALLBACK_TO_CABINET_PATH mapping for automatic deep-linking from
callback_data to cabinet routes (/subscription, /balance, /referral, etc.)
- Unmapped callback_data gracefully falls back to regular Telegram callbacks
- Add startup validation warning when cabinet mode is active without MINIAPP_CUSTOM_URL
- Update admin broadcast buttons with section-specific routing
- Backward compatible: is_text_main_menu_mode() kept as alias for is_cabinet_mode()
- tickets.py: remove ENABLE_LOGO_MODE branches that used edit_message_caption
on text messages (prompt is always text, not photo with caption)
- webhook_service: add db.rollback() before retrying DB ops in _handle_user_deleted
when subscription was cascade-deleted, catch PendingRollbackError alongside StaleDataError
- Full CRUD + broadcast/unpin/activate/deactivate endpoints
- Admin auth required on all endpoints (get_current_admin_user)
- Broadcast cooldown (60s) on all mass operation endpoints
- Cached Bot singleton to prevent aiohttp session leaks
- Guard against deleting active pinned messages (409 Conflict)
- Route ordering: /active/* before /{message_id}/* to prevent path conflicts
- Pydantic schemas with proper validation (file_id max_length=255)
- Add retry loop with backoff to _unpin_message_for_user (max 3 attempts)
- Add TelegramRetryAfter handling in _send_and_pin_message (unpin + send phases)
- Fix missing failed_count increment when all broadcast retries exhaust (for/else)
- Remove dead code in unpin_active_pinned_message (unreachable TelegramRetryAfter catch)
- Harden sanitize_html: allowlist URI schemes (http/https/tg/mailto/tel), whitelist
tag attributes, strip all attrs from tags without explicit whitelist, full HTML
entity decoding via html.unescape
Catch TelegramBadRequest with "query is too old" before generic Exception handler
to prevent it from being logged as error and triggering error reports.
- YooKassa: SELECT FOR UPDATE on payment row to prevent concurrent double-processing
- subtract_user_balance: row locking to prevent concurrent balance race conditions
- subtract_user_balance: transaction creation before commit for atomicity
- subscription renewal: compensating refund if extend_subscription fails after charge
- StaleDataError: use savepoint instead of full rollback to protect parent transaction
Remove all modem purchase/management code:
- Delete modem handler, service, and tests
- Remove modem button from keyboards and admin panel
- Remove modem pricing from calculations
- Remove modem REST API endpoint and schemas
- Remove modem decorator, config settings, and notification formatting
- Keep DB column and migration for backwards compatibility
When squads are deleted from the RemnaWave panel and servers are synced,
the bot cleaned subscription connected_squads but left stale UUIDs in
tariff.allowed_squads. This caused errors when users tried to purchase
or extend subscriptions with tariffs referencing deleted squads.
Now sync_with_remnawave also removes stale UUIDs from all tariffs.
When a user is deleted from the panel, the subscription may already be
cascade-deleted by the time the webhook handler tries to decrement
server counters. This caused StaleDataError followed by
PendingRollbackError when accessing subscription.id in the error handler.
- Save subscription.id before DB operations to avoid lazy load after rollback
- Catch StaleDataError explicitly and rollback the session
- Re-fetch subscription/user after potential rollback in _handle_user_deleted
- Skip subscription cleanup if it was already cascade-deleted
Unverified email users could not change their email (e.g. to fix a typo)
because the endpoint required email_verified=True. Now unverified emails
are replaced directly without code verification, and a new verification
email is sent to the updated address.
reset_user_subscription and reset_trial endpoints did not clean up
subscription_servers rows before deleting the subscription, causing
ForeignKeyViolationError on subscription_servers.subscription_id_fkey.
Also fixed the same missing cleanup in user_service.hard_delete_user.
- Cabinet API: use get_traffic_topup_packages() instead of
get_traffic_packages() in classic mode endpoints (lines 622, 727, 2410)
to prevent infinite free traffic exploit via initial-purchase packages
- WATA service: add retry logic for 429 rate limit responses with
Retry-After parsing from header and response body, up to 2 retries,
downgrade 429 from error to warning log level
- Wrap unprotected add/remove_user_to/from_servers calls in try/except
in miniapp.py and cabinet subscription.py to prevent 500 errors
- Fix is_tariff_change to include classic-to-tariff transitions
(subscription.tariff_id=None → new tariff_id) so purchased traffic
is properly reset when switching modes
extend_subscription was unconditionally resetting purchased_traffic_gb
and deleting TrafficPurchase records whenever traffic_limit_gb was passed,
even when extending the same tariff (not changing). Now only resets
on actual tariff change (is_tariff_change=True), preserving purchased
traffic on same-tariff extensions.
add_user_to_servers and remove_user_from_servers were calling
db.commit() internally, breaking transaction atomicity for all
callers that perform additional operations afterward. Changed to
db.flush() so the caller controls the commit boundary.
- backup: add DATE column parsing in restore, use is_file() in delete_backup
- updates: add missing callback.answer() in show_updates_menu early return
- webhook: add server counter decrement and SubscriptionServer cleanup on user deletion, use single commit
Previously only status was set to expired and remnawave_uuid cleared.
Now also clears subscription_url, subscription_crypto_link,
remnawave_short_uuid, and connected_squads so the bot correctly
shows no active subscription after panel deletion.
- Remove dangling version_info['repo_url'] expression
- Handle 'message is not modified' in all three update handlers
to prevent error screen on repeated button clicks
- Add 37 missing models to backup (payment providers, polls, contests,
wheel, FAQ, promo offers, webhooks, configs, menu buttons, etc.)
- Add tariff_promo_groups and payment_method_promo_groups association tables
- Replace hardcoded association restore with generic handler
- Fix transaction atomicity: flush instead of commit in inner methods,
remove inner rollback calls, single commit/rollback in outer handler
- Fix composite PK support for UserPromoGroup (was only detecting first PK)
- Fix duplicate insert bug when clear_existing=True and record already exists
- Add cabinet_refresh_tokens to clear list, fix support_audit_logs deletion order
- Add Time column parsing for ReferralContest.daily_summary_time
- Security: tarfile filter='data', path traversal protection in _restore_files
and delete_backup, os.sep in startswith checks
Only block purchase when the price increased (user would overpay).
When a promo discount activates between viewing price and confirming,
the recalculated price is lower — allow the purchase at the new price
instead of forcing the user to restart the checkout flow.
Only consider MINIAPP_CUSTOM_URL for miniapp buttons, not the
purchase-only MINIAPP_PURCHASE_URL which cannot display subscription
info and loads indefinitely. When no custom URL is configured, fall
back to regular callback_data so the bot shows subscription natively.
After db.rollback() all ORM objects expire. Subsequent attribute access
triggers lazy load in async context causing greenlet_spawn errors for
every remaining user. Break the sync loop after rollback instead of
continuing with a corrupted session.
Also downgrade TelegramNetworkError to warning in channel_checker.
When a user is deleted via cabinet, RemnaWave sends user.disabled webhook
but the subscription row is already cascade-deleted. This caused
StaleDataError on commit + PendingRollbackError when logging user.id.
Save user_id before handler call and catch StaleDataError as warning.
Add TelegramNetworkError handling before generic Exception catch in all
notification methods to prevent timeout errors from generating error
reports in chat. Timeouts are transient network issues, not bugs.
Channel checker middleware called bot.get_chat_member() which could
timeout (60s), causing callback.answer() to fail with "query too old".
Skip channel check for lightweight UI callbacks (webhook:close,
ban_notify:delete, noop). Also answer callback before delete attempt
and add fallback to remove keyboard if delete fails.
When subscription was extended in panel, webhook updated end_date but
left status as expired. Now syncs ACTIVE/DISABLED status from panel
payload when end_date is in the future.
_is_valid_url only accepted http(s), silently dropping valid deep links
like happ://, vless://, ss:// from revoked webhook payloads.
Added _is_valid_link that accepts any URI scheme.
Cabinet was calling CryptoBotService.create_invoice() directly without
saving CryptoBotPayment to DB. When webhook arrived, payment lookup
failed and returned HTTP 400, causing infinite retries.
Now cabinet uses PaymentService.create_cryptobot_payment() (same as
miniapp) with proper USD conversion via currency_converter.
Also return HTTP 200 for unknown invoice_ids to stop retry spam.
Add last_webhook_update_at timestamp to Subscription model. When a webhook
handler modifies a subscription, it stamps this field. Auto-sync, monitoring,
and force-check services skip subscriptions updated by webhook within the
last 60 seconds, preventing stale panel data from overwriting fresh
real-time changes.
- Add last_webhook_update_at column + migration
- Stamp all 8 webhook handlers with commit in every code path
- Add is_recently_updated_by_webhook() guard in 12 sync/monitoring paths
- Add REMNAWAVE_WEBHOOK_* variables to .env.example
- Add webhook setup documentation to README with Caddy/nginx examples
- Fix pre-existing yookassa webhook test (mock AsyncSessionLocal)
Add dismissible close button (✖️) to every webhook notification message.
Users can now close any webhook notification by tapping the button,
which deletes the message via webhook:close callback handler.
- Add keyboard buttons to all webhook notifications: renew, connect,
my subscription, buy traffic — context-appropriate per event type
- Extract device name from multiple possible payload fields (deviceName,
tag, hwid, device, platform, name) with fallback to dash
- Log payload keys for device events to identify correct field names
- Add html.escape() to all untrusted webhook data in admin and device
notifications (prevents HTML/Telegram injection)
- Add public send_webhook_notification() and is_enabled property to
AdminNotificationService (eliminates private method access)
- Add dedicated NotificationType enum values for device and not_connected
events (fixes incorrect semantic mapping)
- Extend user resolution to handle nested user objects and userUuid for
device-scope events
- Replace manual __anext__() DB session with AsyncSessionLocal context
manager; skip DB session for admin-only events
- Replace deprecated datetime.utcnow() with datetime.now(UTC)
- Use db.flush() instead of db.commit() in handlers (router commits)
- Wrap _notify_user in try/except to prevent notification failures from
rolling back successful DB mutations
Handle all 44 webhook events: admin alerts for node health (connection
lost/restored), service security (login attempts), CRM billing reminders,
plus user-facing device added/deleted and not_connected notifications
with localized messages across all 5 languages.
RemnaWave sends event as "user.modified", not "modified".
Concatenating scope + event produced "user.user.modified" which
didn't match any handler keys.
- Replace direct bot.send_message with notification_delivery_service
- Email-only and OAuth users now receive webhook notifications via email/WS
- Add 10 new NotificationType enum values for webhook subscription events
- Map all webhook text_keys to NotificationType for unified routing
Transaction created_at and completed_at showed identical timestamps
because webhook handlers created transactions with is_completed=True
in a single step. Now all 10 payment providers pass payment.created_at
to the transaction so created_at reflects when the user initiated
the payment, not when the webhook processed it.
Also: remove duplicate datetime import in inline.py, upgrade button
stats DB error logging from debug to warning, add index on
button_click_logs.button_type for analytics queries.
_process_heleket_payload deleted the invoice message on every call,
including manual "check status" presses. Now only deletes on final
statuses (paid, cancel, fail, etc.) so the payment UI stays visible
while the user is still waiting.
Also includes subscription fallback query fix (actual DB columns).
Subscription.is_active is a Python property, not a column — query
status/end_date/is_trial columns instead. Also restore subscription=None
initialization to avoid UnboundLocalError on line 112.
Rules editor crashed when preview truncated mid-HTML tag (e.g.
<blockquote> cut to <blockquo), causing Telegram parse error.
Strip HTML tags before truncating preview text.
Also fix MissingGreenlet in build_topup_success_keyboard: fall back
to a direct DB query instead of showing wrong button text.
Remove blocking check that prevented tariff deletion when subscriptions
exist. DB schema already supports SET NULL on tariff FK, so subscriptions
gracefully become "legacy" and users pick a new tariff on renewal.
Return affected_subscriptions count in API response.
Delete dead Flask-based PAL24 webhook server (app/external/pal24_webhook.py).
PAL24 webhooks already handled by unified FastAPI server on port 8080.
- Remove flask dependency from pyproject.toml and requirements.txt
- Remove PAL24_WEBHOOK_PORT config (unused, FastAPI uses shared port)
- Remove pal24_webhook module reference from log filter
- Update docs: webhook example rewritten from Flask to FastAPI
- Uninstall flask, werkzeug, blinker, itsdangerous
Remove AUTO_ACTIVATE_AFTER_TOPUP and SHOW_ACTIVATION_PROMPT_AFTER_TOPUP
features from all payment providers, config, system settings, and tests.
Cart auto-purchase (AUTO_PURCHASE_AFTER_TOPUP) is preserved.
Bug fixes:
- fix KeyError 'months' in devices.py for custom locale overrides
- fix IntegrityError on trial subscription retry (update existing PENDING instead of INSERT)
- fix PendingRollbackError cascade by adding db.rollback() before recovery
- fix TelegramForbiddenError not caught in photo_message.py
- fix "query is too old" spam in required_sub_channel_check
- add missing trial locale keys (TRIAL_PAYMENT_DESCRIPTION, TRIAL_REFUND_DESCRIPTION, TRIAL_ACTIVATION_ERROR)
Introduced a new feature for lite mode, including a GET endpoint to retrieve the current lite mode setting and a PATCH endpoint to update it. Added corresponding response and update models for lite mode management.
After first logo upload, Telegram returns a file_id that can be reused
for all subsequent sends. This eliminates 3-4 second delay per message
caused by re-uploading the same file from disk every time.
Translate all bot strings to Persian, including admin panel, user interface, payment flows, contests, monitoring, and promotional features. Add RTL text support and Persian-specific formatting for dates, numbers, and currency displays.
sync_users_to_panel uses _safe_expire_at_for_panel which replaces past
end_dates with now+1min for expired subscriptions. When sync_users_from_panel
reads these artificial dates back, it treated them as legitimate "newer"
dates and overwrote all expired subscriptions' end_date to approximately
current time. This caused all subscription end dates to show as "just now"
after sync.
Fix: only update end_date from panel when the panel user status is ACTIVE.
For EXPIRED/DISABLED users, the panel date may be a _safe_expire_at artifact
and should not override the real expiry date in the local database.
The user restoration flow deleted transactions without first clearing
foreign key references from payment tables (yookassa_payments,
cryptobot_payments, etc.) and referral_earnings. This caused
IntegrityError when a deleted user had payment records linked to
transactions.
- Convert max_uses=0 to 999999 (unlimited) in cabinet and webapi routes,
matching bot handler behavior. Fixes miniapp-created promo codes being
immediately invalid due to is_valid check (current_uses < max_uses).
- Skip trial offer in post-registration keyboard when promo code already
activated a subscription, showing "back to menu" button instead.
PurchaseSelection dataclass has period: PurchasePeriodConfig (with .days),
not period_days. This caused admin notification to fail silently on every
subscription purchase from cabinet.
Admin "Clear all" button was deleting inactive users regardless of
subscription status, destroying paid subscriptions. Now matches the
monitoring service behavior by checking is_active before deletion.
Tariff renewal showed tariff.device_limit (default) instead of
subscription.device_limit (actual) and didn't add extra device
cost to the renewal price. Fixed in show_tariff_extend,
select_tariff_extend_period, and confirm_tariff_extend.
Extract _build_enrichment() helper, reuse in both GET /enrichment
endpoint and CSV export. CSV now includes: Connected Devices,
Total Spent (RUB), Sub Start, Sub End, Last Node columns.
Bulk device endpoint ignores take/skip params, causing duplicates.
Revert to single call. Add logging to discover extra fields in
panel user response that might include device count.
Replace bulk /api/hwid/devices and /api/subscriptions calls with
proven per-user endpoints: get_all_users() (paginated) for last
connected node and get_user_devices() with semaphore for device counts.
Add GET /admin/traffic/enrichment that returns per-user enrichment data
(connected devices, total spending, subscription dates, last connected node)
via bulk panel API calls with 5-min server-side cache.
Add GET/DELETE endpoints for managing user devices from admin panel:
- GET /{user_id}/devices - list connected devices
- DELETE /{user_id}/devices/{hwid} - remove single device
- DELETE /{user_id}/devices - reset all devices
Previously the bot only checked os.getenv('VERSION'), returning
'UNKNOW' when unset. Now falls back to importlib.metadata and
direct pyproject.toml parsing, so the version stays correct after
release-please updates it.
OAuth users registering via cabinet have no telegram_id, causing
panel sync failures. All RemnaWave panel lookups now use a 3-level
chain: UUID → telegram_id → email. Also pass email and user_id to
format_remnawave_username to generate unique panel usernames.
Remnawave API only allows letters, numbers, underscores and dashes in
usernames. The sanitizer regex was also allowing dots, causing OAuth
users with email-based usernames (e.g. john.doe@gmail.com) to fail
subscription creation with "Validation failed: invalid_string".
Catch IntegrityError on INSERT into yookassa_payments when user_id
references a deleted user. Rollback the session and return None instead
of letting the unhandled exception propagate. Protects all callers
(webhook restore, bot handlers, cabinet API, miniapp API).
GitHub Actions cannot modify .github/workflows/ files (403 "Resource not
accessible by integration"), causing "Error adding to tree" failure.
pyproject.toml is already handled natively by python release type.
Only Dockerfile needs the generic updater for x-release-please-version markers.
- tickets.py: guard against non-text messages in waiting_for_title FSM state
- payments.py: fix Wata webhook using wrong field name (order_id vs orderId),
add full payload to error log
- tariff.py: stop overwriting admin tariff settings on every bot restart,
sync_default_tariff_from_config now only creates if no tariff exists
- start.py: catch TelegramBadRequest specifically for "message is not modified"
instead of bare except with useless retry
- admin/tickets.py: downgrade ticket notification log from error to warning
for expected case of OAuth/email users without telegram_id
- pricing.py, countries.py, purchase.py: guard against expired FSM state
causing KeyError on 'period_days'
- blacklist_service.py: add 5-min in-memory cache to is_user_blacklisted()
to reduce DB load from per-request checks
- remnawave_service.py: fix "Session is closed" race condition — create
new RemnaWaveAPI instance per get_api_client() call instead of reusing
shared instance whose aiohttp session gets overwritten by parallel coroutines
- Fix async context manager usage in sync_users: __aenter__() result
was not assigned, so hwid_api_client held the context manager object
instead of the actual API client, causing AttributeError on
reset_user_devices()
- Add user existence check in _restore_missing_yookassa_payment before
INSERT to prevent ForeignKeyViolationError when user_id from payment
metadata no longer exists in users table
- Switch release-please to manifest mode (config-file + manifest-file)
- Add Dockerfile and docker workflow files as generic extra-files
- Add x-release-please-version annotations for automatic version replacement
- Bump hardcoded v3.6.0 to v3.7.0 to match current release
- Add total_threshold_gb and node_threshold_gb to ExportCsvRequest
- Compute GB/day, risk level, risk ratio for each user when thresholds set
- CSV includes Total GB/day, Risk Level, Risk Ratio, Risk GB/day columns
- Add node filter: filter traffic by selected nodes, recalculate totals
- Add status filter: filter by subscription status (active/trial/expired/disabled)
- Add custom date range: support start_date/end_date params alongside period
- Refactor _aggregate_traffic to use date strings with stable 5-min cache keys
- Add cache eviction for expired entries to prevent memory leaks
- CSV export now respects all active filters and custom date range
- Extract _get_status helper, add _compute_date_range helper
- Add node filter (comma-separated UUIDs) and status filter query params
- Add custom date range (start_date/end_date) as alternative to period
- Fetch connected device count per user via HWID API (semaphore=10)
- Cache key changed to (start_str, end_str) tuple for both modes
- CSV export now respects all active filters and date range
- Backend returns available_statuses and filtered nodes list
- Validate future dates, max 31-day range
Cabinet was calling YooKassaService.create_payment() directly, bypassing
PaymentService which saves the payment record to the local database.
When YooKassa webhook arrived, the payment was not found in the DB,
causing payment processing failures.
Now uses PaymentService.create_yookassa_payment() and
create_yookassa_sbp_payment() consistently with all other payment methods.
Also standardizes metadata key from 'type' to 'purpose' to match bot flow.
- Switch from per-user to per-node API strategy in _aggregate_traffic
(O(nodes) calls instead of O(users), ~10 vs ~200 requests)
- Add retry with exponential backoff for 429 in _make_request
- Reduce concurrency limit from 20 to 5 to prevent request bursts
- Switch from get_bandwidth_stats_node_users (broken UUID matching) to
get_bandwidth_stats_user per user (same API as working detail page)
- Add tariff filter with available_tariffs in response
- Add concurrency-limited parallel per-user bandwidth stats fetching
Sort by tariff_name/full_name crashed with TypeError when some values
were None (fallback to 0) mixed with strings. Use empty string fallback
for string fields with case-insensitive comparison.
Add paginated GET /admin/traffic endpoint aggregating per-user traffic
across all nodes with server-side sorting, search, and 5-min in-memory
cache. Add POST /admin/traffic/export-csv to generate CSV and send
to admin via Telegram DM.
Telegram API rejects messages with mismatched HTML tags. When
truncate_for_blockquote cuts the description mid-way, it can leave
tags like <i>, <b> unclosed inside the blockquote. Telegram then
fails with "Unmatched end tag" error.
Add _close_open_tags helper that scans for unclosed tags and appends
closing tags in reverse order. Also ensure the total length with
closing tags still fits within the message budget.
Always fetch 30 days with daily_bytes per node and categories.
Frontend computes period totals locally without extra API calls.
Removes days query param.
Per-node queries (8+ calls) hit Remnawave rate limit. Switch back to
single get_bandwidth_stats_user call with %Y-%m-%d date format (same
as traffic_monitoring_service). Add response logging to debug format.
Also optimize panel-info to use accessible-nodes instead of all-nodes.
The /api/bandwidth-stats/users/{uuid} endpoint rejects date params.
Switch to querying each accessible node via the working legacy
endpoint /api/bandwidth-stats/nodes/{uuid}/users/legacy and finding
the user in the per-node results.
- Add get_user_accessible_nodes() to fetch user's available nodes
- Fix date format from ISO datetime to date-only (Y-m-d) for bandwidth stats
- Show all accessible nodes (with zero traffic if no stats)
- Add country_code to node usage response
- Add OAuth provider config vars and helpers to config.py
- Add google_id, yandex_id, discord_id, vk_id columns to User model
- Create OAuth provider service with state management and 4 providers
- Add CRUD functions for OAuth user lookup, linking, and creation
- Add 3 API endpoints: providers list, authorize URL, callback
- Add alembic migration and universal_migration support
- Fix trial disable logic to cover OAuth auth_types
Add DisposableEmailService that fetches ~72k disposable email domains
from github.com/disposable/disposable-email-domains into an in-memory
frozenset with 24h auto-refresh via asyncio background task.
Integrated into three email entry points in cabinet auth routes:
- POST /email/register (link email to Telegram account)
- POST /email/register/standalone (standalone email registration)
- POST /email/change (change existing email)
Controlled by DISPOSABLE_EMAIL_CHECK_ENABLED setting (default: true).
Falls back to allowing all emails if domain list fetch fails.
New setting allows granular control over trial availability:
- none: trial available for all (default)
- email: trial disabled for email users
- telegram: trial disabled for telegram users
- all: trial disabled for everyone
Enforced in bot handlers, cabinet API, and miniapp routes.
Automatically appears in admin panel as dropdown via CHOICES.
Telegram Bot API 8.0+ adds a `signature` field to WebApp initData.
Per the official spec, both `hash` and `signature` must be excluded
from the data-check-string before HMAC verification. Without this,
users with newer Telegram clients get a hash mismatch and 401.
Also remove redundant `unquote()` in telegram_auth.py — `parse_qsl`
already URL-decodes values, so the extra decode could corrupt user
data containing percent-like sequences.
Add PUT /cabinet/admin/tariffs/order endpoint for drag-and-drop
tariff sorting in admin cabinet. Move db.commit() from CRUD to
route level for consistency.
Add BlacklistMiddleware for aiogram that blocks all message/callback/pre_checkout
from blacklisted users globally. Add blacklist check to cabinet API dependency.
Fix case-insensitive username matching. Remove 10 redundant manual checks from handlers.
- Add {{HAPP_CRYPT3_LINK}} template support in _resolve_button_url
- Only resolve templates for subscriptionLink and copyButton, not external
- Always send subscriptionUrl and subscriptionCryptoLink (hideLink is display-only flag)
- Pass uiConfig from RemnaWave config for block renderer selection
Preserve svgIconKey, displayName and other platform-level fields
instead of only forwarding apps array. Build platformNames from
RemnaWave displayName with English-only fallback.
- Return original blocks/svgLibrary instead of converting to steps
- Enrich apps with deepLink and buttons with resolvedUrl
- Add _resolve_button_url helper for template substitution
- Keep legacy file-based format as fallback
- Add release-please workflow for automated changelog and version bumps
- Add release workflow with categorized changelog (features, fixes, perf)
- Include contributors section and diff stats in release notes
- Add Docker pull instructions in release body
- Configure changelog sections for conventional commits
- Add GitHub Markdown to Telegram HTML converter utility
- Place release description in blockquote expandable
- Auto-truncate description to fit 4096 char message limit
- Clean compact layout with clickable version link
- Convert markdown headers, bold, italic, code, links, strikethrough
- Use Redis key with 6h TTL to prevent notification spam on each monitoring cycle
- Fallback to sending notification if Redis is unavailable
- Key auto-expires when user tops up balance and autopay succeeds
- Skip daily tariff subscriptions in monitoring autopay cycle
- Filter daily subscriptions in get_subscriptions_for_autopay CRUD
- Block autopay menu and toggle for daily tariffs in bot handler
- Reject autopay enable for daily subscriptions in Cabinet API (HTTP 400)
- Reject autopay enable for daily subscriptions in MiniApp API (HTTP 400)
- Add real-time progress bar with updates every 500 msgs / 5 sec
- Fix Telegram rate limiting: batch=25, delay=1.0s (~25 msg/sec)
- Add global flood_wait_until to prevent semaphore slot starvation
- Add parse_mode=HTML for web API broadcasts
- Separate error handling for FloodWait, Forbidden, BadRequest
- Convert ORM objects to scalars before long broadcast operations
- Add email recipients dataclass to prevent detached ORM state
- Add discount calculation for purchase_devices and get_device_price endpoints
- Fix traffic purchase discount to use period-aware calculation
- Apply period discount to tariff switch upgrade_cost
- Return discount info in API responses for frontend display
- Extract scalar values from ORM objects before long operations
- Create fresh DB sessions for persist operations with retry mechanism
- Replace ORM User objects with telegram_id integers in broadcast loops
- Update .gitignore to exclude Python cache, IDE files, and local configs
Fixes: InterfaceError "connection is closed" and MissingGreenlet errors
during mass message broadcasts
- Import and call notify_user_subscription_renewed in auto-extend flows
- Import and call notify_user_subscription_activated for new subscriptions
- Add WebSocket notifications to _auto_purchase_tariff and _auto_purchase_daily_tariff
- Add WebSocket notifications to auto_activate_subscription_after_topup
- Add notify_user_balance_topup call in payment common mixin
- Add pyproject.toml with uv and ruff configuration
- Pin Python version to 3.13 via .python-version
- Add Makefile commands: lint, format, fix
- Apply ruff formatting to entire codebase
- Remove unused imports (base64 in yookassa/simple_subscription)
- Update .gitignore for new config files
Проблема: у некоторых пользователей реферальный код из deep link терялся,
потому что pending_start_payload сохранялся только в FSM state, который
мог быть недоступен (state=None) в edge cases.
Исправления:
- Добавлен Redis fallback для хранения payload (TTL 1 час)
- _capture_start_payload() теперь сохраняет в FSM state И в Redis
- cmd_start() и required_sub_channel_check() проверяют Redis если FSM state
пуст
- Добавлено логирование warning при state=None
- Изменён уровень лога успешного сохранения с debug на info
Изменённые файлы:
- app/middlewares/channel_checker.py — Redis-функции и улучшенное логирование
- app/handlers/start.py — Redis fallback в обработчиках
Добавлены тесты:
- tests/middlewares/test_channel_checker_payload.py (14 тестов)
- Добавлена кнопка "⚙️ Настройки трафика" в меню мониторинга
- Добавлен UI для управления быстрой и суточной проверками трафика
- Можно включать/выключать проверки, менять пороги и интервалы
- Настройки сохраняются в БД через BotConfigurationService
- Добавлены SETTING_HINTS с описаниями параметров
- Moved the notifications router to be included before the tickets router to avoid conflicts.
- Updated comments for clarity regarding the order of router inclusion.
- Added ownership verification for user notifications to ensure only the rightful owner can mark them as read.
- Implemented checks to confirm that admin notifications are correctly identified before allowing them to be marked as read.
- Introduced a new method to retrieve notifications by ID in the TicketNotificationCRUD for improved data handling.
- Added WebSocket notifications for admins on new ticket creation and user replies.
- Implemented notification handling in the ticket management routes.
- Enhanced error logging for notification failures.
- Added a new TicketNotification model to handle notifications for ticket events.
- Implemented user and admin notifications for new tickets and replies in the cabinet.
- Introduced settings to enable or disable notifications for users and admins.
- Enhanced ticket settings to include notification preferences.
- Integrated WebSocket notifications for real-time updates.
- Added functionality to notify admins when a new ticket is created.
- Implemented notification for admins when a user replies to a ticket.
- Included error handling for notification failures.
- Исправлен вызов get_active_rounds в админ-панели (передавалось 2 параметра вместо 1)
- Обновлены кнопки редактирования призов с prize_days на prize_type/prize_value
- Мигрирован Cabinet API с устаревшего prize_days на новые поля
- Добавлена поддержка нескольких типов призов (дни, баланс, кастом)
- Обновлена документация API конкурсов
Добавлены переводы на все 4 языка (ru, en, ua, zh):
- ADMIN_PROMOCODE_TYPE_DISCOUNT - название типа в админке
- PROMOCODE_ACTIVE_DISCOUNT_EXISTS - ошибка при конфликте скидок
Тексты описывают функционал одноразовой процентной скидки.
Добавлена полная поддержка DISCOUNT типа в админке:
- Тип "💸 Одноразовая скидка" в селекторе
- Флоу создания: код → процент (1-100) → макс использований → срок промокода (дни) → срок скидки (часы)
- Валидация процента скидки (1-100)
- Валидация срока действия скидки (0-8760 часов)
- Отображение в списках и странице управления
- Новый стейт setting_discount_hours для ввода срока скидки
Добавлена обработка нового типа промокода DISCOUNT:
- Проверка конфликта с активными скидками пользователя
- Запись скидки в профиль (promo_offer_discount_percent, promo_offer_discount_expires_at)
- Обработка срока действия скидки (0 часов = бессрочно до первой покупки)
- Логирование активации и ошибок
- Выброс ValueError при попытке активировать скидку при наличии активной
Добавлен новый тип промокода для одноразовых скидок.
Использует существующие поля без изменения схемы БД:
- balance_bonus_kopeks для хранения процента скидки (1-100)
- subscription_days для хранения срока действия скидки в часах (0-8760)
- Introduced a new helper function `_parse_setting_response` to streamline the parsing of settings responses from the API.
- Updated the `get_settings`, `get_setting`, `set_setting`, and `toggle_setting` endpoints to utilize the new parsing function, enhancing code readability and maintainability.
- Improved handling of settings data formats, allowing for both detailed metadata and simple values.
1. Traffic (Трафик) - статистика трафика, топ пользователей по трафику, последние нарушения
2. Reports (Отчёты) - отчёты за период (6h, 12h, 24h, 48h, 72h), статистика активных пользователей и IP, топ нарушителей
3. Settings (Настройки) - управление настройками системы банов, группировка по категориям, переключатели для bool, ввод для int
4. Health (Здоровье) - статус системы (healthy/degraded/unhealthy), аптайм, статус компонентов
- Added logging for Ban System API status checks, including whether the system is enabled and its configured URL.
- Introduced a new endpoint `/stats/raw` to fetch raw statistics from the Ban System API for debugging purposes.
- Enhanced logging to capture raw stats response for better monitoring.
- Реализован режим SHOW_ACTIVATION_PROMPT_AFTER_TOPUP для яркого уведомления пользователей
- При пополнении баланса отправляется внимание-привлекающее сообщение с восклицательными знаками
- Динамические кнопки в зависимости от статуса подписки:
* Активная платная подписка: "🔄 Продлить" + "📱 Изменить устройства"
* Нет подписки/истекла/триал: "🔥 Активировать подписку"
- Убраны дублирующие уведомления из yookassa.py (строка 851)
- Убраны дублирующие уведомления из subscription_auto_purchase_service.py (строки 755, 918)
- Режим включается через SHOW_ACTIVATION_PROMPT_AFTER_TOPUP=true в .env
Файлы:
- app/services/payment/common.py: добавлена логика яркого промпта
- app/services/payment/yookassa.py: отключено старое уведомление для корзины
- app/services/subscription_auto_purchase_service.py: отключены 2 блока старых уведомлений
- Исправлен баг с пустым snapshot {} (не распознавался как существующий)
- Исправлено игнорирование комментариев в TRAFFIC_MONITORED_NODES
- Добавлено исключение пользователей по UUID (TRAFFIC_EXCLUDED_USER_UUIDS)
- Добавлены названия нод в уведомления о превышении трафика
- Улучшено логирование: кулдаун, фильтры, исключённые пользователи
- Исправлен баг с блокировкой имён типа "Сейтмеметов" (ложное срабатывание на "тме")
- Разрешён конфликт слияния в display_name_restriction.py
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Added a new handler to delete ban notifications upon user interaction.
- Introduced a delete button in ban notifications for better user experience.
- Updated ban notification messages to include node information more prominently.
- Refactored the BanNotificationService to send messages with the delete button included.
- Updated ban notification messages to provide detailed reasons for account bans, including node information.
- Refactored the BanNotificationService to safely format messages with optional node details.
- Modified API routes and schemas to support the inclusion of node names in ban notifications.
- Introduced new ban notification messages for device limit, WiFi, and mobile network violations in the configuration.
- Refactored the BanNotificationService to utilize the new messages from the configuration for sending notifications.
- Added a new method to handle mobile network ban notifications.
- Updated API routes to support the new notification type for mobile network bans.
- Added logic to calculate and apply discounts based on the selected tariff period.
- Updated state management to store discount percentages for custom days and traffic changes.
- Enhanced the tariff price calculation to incorporate discounts when confirming selections.
- Modified the tariff preview to display applicable discounts for better user clarity.
- Introduced a new function to generate a keyboard for selecting tariff periods with custom traffic.
- Enhanced the tariff price calculation logic to separate period and traffic pricing.
- Updated the custom tariff preview formatting to reflect changes in pricing structure.
- Implemented a new handler for processing the selection of tariff periods with custom traffic.
- Added new states for selecting custom days and traffic in the subscription process.
- Enhanced the tariff purchase handler to support custom days and traffic adjustments.
- Introduced new functions for formatting and displaying custom tariff previews.
- Updated the ban notification service to include a new notification type for WiFi bans.
- Modified API routes and schemas to accommodate the new notification type and its parameters.
- Introduced fields for custom days and traffic in the tariff model, including enabling flags, pricing, and limits.
- Updated relevant routes and schemas to handle new tariff features.
- Implemented logic for purchasing and managing custom days and traffic in subscriptions.
- Added database migration scripts to accommodate new columns for tariffs and subscriptions.
- Реализована возможность докупки трафика для тарифов с новыми параметрами: traffic_topup_enabled, traffic_topup_packages и max_topup_traffic_gb.
- Обновлены схемы и маршруты для управления тарифами и трафиком.
- Добавлены новые эндпоинты для работы с докупкой трафика в мини-приложении.
- Обновлены настройки и логика для проверки доступности докупки трафика в зависимости от тарифа.
- Внедрены улучшения в обработку платежей через Freekassa.
Обновлён .env.example с новыми параметрами для режима тарифов.
- Обновлены схемы и маршруты для поддержки покупки тарифов и управления трафиком.
- Реализована синхронизация тарифов и серверов из RemnaWave при запуске.
- Добавлены новые параметры в тарифы: server_traffic_limits и allow_traffic_topup.
- Обновлены настройки и логика для проверки доступности докупки трафика в зависимости от тарифа.
- Внедрены новые эндпоинты для работы с колесом удачи и обработка платежей через Stars.
Обновлён .env.example с новыми параметрами для режима продаж подписок.
Новый функционал:
- Быстрая проверка (TRAFFIC_FAST_CHECK_*) — отслеживает дельту трафика за интервал через snapshot
- Суточная проверка (TRAFFIC_DAILY_CHECK_*) — анализирует трафик за 24 часа через bandwidth API
- Фильтрация по нодам (TRAFFIC_MONIT
- Передача bot через getattr(self, "bot", None) во всех платёжных провайдерах
- Добавлена отправка предупреждений пользователю при отключенной автоактивации
- Добавлены предупреждения о необходимости активации подписки после пополнения
- Заменён метод send_notification на send_to_admins в AdminNotificationService
- Исправлена настройка NOTIFICATIONS_CHAT_ID на ADMIN_NOTIFICATIONS_CHAT_ID для отправки в топик
1. Исправлена кнопка "Профиль" после тестового начисления
- callback изменён с admin_user_{id} на admin_user_manage_{id}
2. Исправлена логика расчёта доступного баланса
- Добавлен метод get_first_referral_earning_date()
- Добавлен метод get_user_spending_after_first_earning()
- Теперь учитываются только траты ПОСЛЕ первого реф. начисления
- Старые траты больше не уменьшают доступный реферальный баланс
3. Добавлен bypass cooldown в тестовом режиме
- При REFERRAL_WITHDRAWAL_TEST_MODE=true 30-дневный cooldown пропускается
Новая функциональность вывода средств:
- config.py: добавлены настройки вывода (минимальная сумма, кулдаун, анализ подозрительности, тестовый режим)
- models.py: добавлена модель WithdrawalRequest с полями для заявок, анализа рисков и обработки админ
Ручная проверка в админке (monitoring.py):
- Новая кнопка "📊 Проверка трафика" в меню мониторинга
- Проверяет всех юзеров с активной подпиской
- Показывает результат: сколько проверено, сколько превышений
- Отправляет уведомления админам при превышении
Изменения в traffic_monitoring_service.py:
1. Добавлен импорт get_db — для получения сессии БД внутри цикла
2. Добавлен set_bot() — для установки бота
3. Изменён start_monitoring() — не требует db и bot как параметры
4. Добавлен кэш уведомлений — защита от спама (1 уведомление в 24ч на юзера)
5. Добавлена очистка кэша — удаляет записи старше 48ч
Изменения в main.py:
1. Импорт traffic_monitoring_scheduler
2. Переменная traffic_monitoring_task
3. set_bot() при старте
4. Stage "Мониторинг трафика" с логированием интервала и порога
5. Секция "Активные фоновые сервисы" — добавлен статус
6. Перезапуск при ошибке в основном цикле
7. Остановка в блоке finally
---
Как включить
В .env на сервере:
TRAFFIC_MONITORING_ENABLED=true
TRAFFIC_THRESHOLD_GB_PER_DAY=10.0
TRAFFIC_MONITORING_INTERVAL_HOURS=1
SUSPICIOUS_NOTIFICATIONS_TOPIC_ID=14
После перезагрузки бота увидишь в логах:
📊 Мониторинг трафика
├ Интервал проверки: 1 ч
├ Порог трафика: 10.0 ГБ/сутки
└ ✅ Мониторинг трафика запущен
Add configuration options for personal cabinet:
- CABINET_ENABLED, JWT settings, CORS origins
- Email verification settings
- SMTP configuration for email sending
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Ошибка: код итерировал по строке "14,30,60,90,180,360" посимвольно,
что приводило к ValueError: invalid literal for int() with base 10: ','
Заменено на settings.get_available_subscription_periods() который
корректно парсит строку в список [14, 30, 60, 90, 180, 360].
1. app/keyboards/inline.py
- Добавлен параметр callback_data: str = "back_to_menu" в get_back_keyboard()
- Позволяет использовать кнопку "Назад" с разными callback'ами
2. app/services/admin_notification_service.py
- Добавлен тип "modem" в update_types с заголовком "📡 ИЗМЕНЕНИЕ МОД
ПРОБЛЕМА:
При таймауте после успешной авторизации чек мог быть создан на сервере
nalog.ru, но ответ не возвращался. Бот добавлял чек в очередь повторной
отправки → создавался дубликат.
РЕШЕНИЕ:
1. Разделена обработка ошибок на два этапа:
- Аутентификация не прошла → чек точно не создан → в очередь
- Таймаут при создании → чек МОГ быть создан → НЕ в очередь
2. Новая очередь `nalogo:pending_verification` для чеков требующих
ручной проверки (когда таймаут после успешной авторизации)
3. Кнопка в админке: Мониторинг → Статистика → "⚠️ Проверить (N)"
- Показывает список чеков с суммой, датой, payment_id
- "✅ Создан" — чек найден в налоговой, убираем из очереди
- "🔄 Отправить" — чек НЕ найден, отправляем повторно
- "🗑 Очистить всё" — после полной сверки с lknpd.nalog.ru
4. Таймаут увеличен с 10 до 30 секунд (NALOGO_TIMEOUT)
5. Атомарная защита от race condition через cache.setnx()
Изменённые файлы:
- app/utils/cache.py — добавлен метод setnx()
- app/services/nalogo_service.py — разделение ошибок, pending_verification
- app/services/nalogo_queue_service.py — статус pending в get_status()
- app/handlers/admin/monitoring.py — UI для ручной проверки
Исправленные файлы:
1. app/services/traffic_monitoring_service.py — удалены неиспользуемые импорты Decimal, aiohttp
2. app/services/blacklist_service.py — удалён неиспользуемый импорт re
3. app/database/crud/user.py:998 — создана отсутствующая функция get_users_with_active_subscriptions:
async def get_users_with_active_subscriptions(db: AsyncSession) -> List[User]:
3. Функция:
- Возвращает пользователей с активными подписками
- Фильтрует по remnawave_uuid IS NOT NULL (нужен для API Remnawave)
- Проверяет end_date > now и status == ACTIVE
app/database/crud/subscription.py:
Добавлен await db.flush() в create_subscription_no_commit для консистентности с create_user_no_commit:
db.add(subscription)
# Выполняем flush, чтобы получить присвоенный первичный ключ
await db.flush()
# Не коммитим сразу, оставляем для пакетной обработки
1. app/database/crud/subscription.py
Объединены функции create_pending_subscription и create_pending_trial_subscription:
- Добавлен параметр is_trial: bool = False в create_pending_subscription
- create_pending_trial_subscription теперь просто вызывает create_pending_subscription(is_trial=True)
- Сокращено ~75 строк дублированного кода
Удалён лишний импорт:
# Было внутри activate_pending_subscription:
from sqlalchemy import and_ # Удалено — уже импортирован на уровне модуля
2. app/handlers/subscription/purchase.py
Устранено дублирование функций:
- Удалены определения _calculate_simple_subscription_price() и _get_simple_subscription_payment_keyboard() (~75 строк)
- Добавлен импорт из app.handlers.simple_subscription
from app.handlers.simple_subscription import (
_calculate_simple_subscription_price,
_get_simple_subscription_payment_keyboard,
)
Итого сокращено: ~150 строк дублированного кода
1. app/handlers/admin/users.py
- Добавлен параметр parse_mode="HTML" в send_message для поддержки HTML-форматирования
- Добавлен вызов await state.clear() при ошибке BadRequest для очистки состояния FSM
- Добавлен get_user_by_id в импорты
- Перезагрузка user через get_user_by_id после subtract_user_balance
- Восстановление связи user_promo_groups, сбрасываемой после db.refresh() в payment-сервисах
- Добавлен мок get_user_by_id в тесте
Основные исправления:
- Фильтрация событий по дате регистрации реферала (occurred_at)
в период конкурса (start_at - end_at)
- Лидерборд теперь показывает правильные числа (было 21, стало 11)
- Разделение DEPOSIT и SUBSCRIPTION_PAYMENT в статистике:
- Основная метрика: покупки подписок (SUBSCRIPTION_PAYMENT)
- Информационно: пополнения баланса (DEPOSIT)
Новый функционал:
- Кнопка "🔍 Отладка" для просмотра транзакций конкурса
- Разбивка сумм по типам в детальной статистике
- Кнопки "Назад" в синхронизации и отладке
- Логирование дат фильтрации в синхронизации
Также исправлено:
- NaloGO: защита от дублирования чеков в очереди
(проверка nalogo:created и nalogo:queued в Redis)
Сохранение времени оплаты:
- Добавлен параметр operation_time в create_receipt()
- Чеки из очереди создаются с оригинальным временем платежа
- Парсинг created_at из Redis очереди
Защита от дублей (3 уровня):
- Проверка transaction.receipt_uuid перед созданием
- Redis ключ nalogo:created:{payment_id} с TTL 30 дней
- Сохранение receipt_uuid в транзакцию после создания
Бесконечные повторы:
- Убрано удаление чеков после 10 попыток
- Чеки остаются в очереди до успешной отправки
Обработка ошибок:
- Добавлена обработка 500 и "внутренняя ошибка" как временной недоступности
Сверка чеков:
- Заменена API сверка на сверку по логам (logs/current/payments.log)
- Кнопка "Без чеков" → "Сверка чеков" с прямым показом сверки
- Исправлена навигация кнопок "Назад"
Добавлена возможность ограничивать пользователям:
- Пополнение баланса (restriction_topup)
- Покупку/продление подписки (restriction_subscription)
Изменения:
- models.py: добавлены поля restriction_topup, restriction_subscription,
restriction_reason и property has_restrictions
- universal_migration.py: миграция для новых полей
- admin/users.py: меню управления ограничениями в карточке пользователя
- keyboards/admin.py: клавиатура ограничений с toggle-кнопками
- states.py: состояние editing_user_restriction_reason
Проверки ограничений добавлены на двух уровнях:
- start_*_payment: при выборе метода оплаты
- process_*_payment_amount: при создании платежа
Затронутые провайдеры: stars, yookassa, mulenpay, wata, pal24,
cryptobot, heleket, platega, tribute, cloudpayments
При ограничении пользователь видит причину и кнопку "Обжаловать",
ведущую на контакт поддержки из настроек.
- Добавлено восстановление описания чека из настроек при обработке очереди
- Передача telegram_user_id и amount_kopeks через всю цепочку создания чеков
- Переход на локальную исправленную версию библ
- Ежедневная ротация в 00:00 с архивацией в tar.gz
- Разделение по уровням: info.log, warning.log, error.log
- Отдельный payments.log для платежных операций
- Отправка архивов в Telegram-канал бекапов
- Автоочистка архивов старше 7 дней (настраивается)
- Переключатель LOG_ROTATION_ENABLED (по умолчанию выключен)
- Added checks for safe filename to prevent directory traversal attacks.
- Updated file type validation to use the sanitized filename.
- Implemented path resolution to ensure uploaded files are within the backup directory.
- Updated .env.example to include BACKUP_ARCHIVE_PASSWORD variable.
- Added pyzipper to requirements.txt for ZIP file encryption.
- Modified Settings class in config.py to handle BACKUP_ARCHIVE_PASSWORD.
- Enhanced BackupService to create and send password-protected ZIP archives if a password is provided.
Добавлена функция умной автоактивации подписки после пополнения баланса:
- Новая настройка AUTO_ACTIVATE_AFTER_TOPUP_ENABLED в .env
- Функция auto_activate_subscription_after_topup() в subscription_auto_purchase_service.py:
- Автоматически продлевает истёкшую подписку с теми же параметрами
- Создаёт новую подписку с дефолтными параметрами если подписки нет
- Проверяет достаточность баланса перед активацией
- Интеграция с RemnaWave API
- Уведомления пользователю и админам
- Интеграция во все 9 платёжных провайдеров:
- Stars, CryptoBot, YooKassa, CloudPayments
- WATA, Platega, Pal24, MulenPay, Tribute
- Исправлен handle_activate_button в menu.py:
- Полная переработка с интеграцией RemnaWave
- Корректная работа с балансом и транзакциями
- Использование SubscriptionRenewalService
Добавлено округление цен при отображении:
- Новая настройка PRICE_ROUNDING_ENABLED в .env
- Логика: ≤50 коп → вниз, >50 коп → вверх
- Применяется везде: пополнения, партнёрки, скидки, промогруппы
- Кнопки устройств теперь в один столбец (вместо 2 колонок)
- Автоматический предвыбор бесплатных серверов (price_kopeks == 0)
- Вывод описания сквадов в тексте сообщения над кнопками
Изменённые файлы:
- keyboards/inline.py: get_devices_keyboard в 1 столбец
- handlers/subscription/countries.py: хелперы _get_preselected_free_countries и _build_countries_selection_text
- handlers/subscription/purchase.py, traffic.py, autopay.py: применение новой логики
- Рекламные кампании теперь выдают триальную подписку (is_trial=True),
а не платную — пользователь становится платным только после оплаты
- Добавлена настройка CHANNEL_REQUIRED_FOR_ALL для проверки подписки
на канал для ВСЕХ пользователей (платных и триальных)
- Добавлен параметр is_trial в create_paid_subscription для гибкости
При наличии докупленного трафика (например 250 + 10 ГБ = 260 ГБ)
система округляла текущий пакет до ближайшего (500 ГБ) и позволяла
бесплатно переключиться на него.
Исправления:
- confirm_switch_traffic: используется базовый трафик для расчёта цены
- get_traffic_switch_keyboard: добавлен параметр base_traffic_gb
- handle_switch_traffic: показывает информацию о докупленном трафике
- execute_switch_traffic: сбрасывает purchased_traffic_gb при переключении
Добавлена возможность просмотра топа рефереров за неделю/месяц
с сортировкой по количеству приглашённых или по заработку:
- get_top_referrers_by_period() в crud/referral.py
- Интерактивные кнопки выбора периода и критерия сортировки
- Топ-20 рефереров с медалями для первых трёх мест
При уменьшении лимита устройств подключённые устройства не удалялись,
позволяя пользователю продолжать использовать их бесплатно.
Исправления:
- execute_change_devices: сброс всех устройств через API если
подключённых больше чем новый лимит
- confirm_change_devices: предупреждение пользователя о сбросе
устройств перед подтверждением
- Уведомление о количестве сброшенных устройств в результате
Добавлена поддержка указания способа оплаты при пополнении баланса:
- add_user_balance(): новый параметр payment_method для передачи в транзакцию
- add_user_balance_by_id(): поддержка payment_method
- UserService: ручные пополнения админом пом
feat(tickets): добавлены уведомления админам об ответах пользователей на тикеты
Реализована функция notify_admins_about_ticket_reply() для оповещения администраторов:
- Уведомление отправляется после успешного добавления ответа пользователя
- Формат уведомления включает ID тикета, заголовок
Реализована система платного триала с гибким выбором способа оплаты:
- Автоопределение платности: если TRIAL_ACTIVATION_PRICE > 0, триал автоматически платный
- TRIAL_PAYMENT_ENABLED теперь опционален (для обратной совместимости)
- Добавлена функция create
Реализована отказоустойчивая система отправки чеков в налоговую:
- Добавлен NalogoQueueService для фоновой обработки очереди чеков
- При недоступности nalog.ru (503) чеки сохраняются в Redis
- Автоматическая повторная отправка с настраиваемым интервалом
- Защита от DDoS: задержка между чеками (NALOGO_QUEUE_RECEIPT_DELAY)
- Уведомления админам в топик при проблемах и успешной разгрузке
Изменения в файлах:
- app/services/nalogo_queue_service.py: новый фоновый сервис
- app/services/nalogo_service.py: методы очереди, определение 503
- app/utils/cache.py: lpush/rpop/llen/lrange для Redis List
- app/handlers/admin/monitoring.py: статистика чеков в админке
- app/config.py: NALOGO_QUEUE_* и ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID
- main.py: интеграция запуска/остановки сервиса
Новые ENV переменные:
- ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID
- NALOGO_QUEUE_CHECK_INTERVAL (300с)
- NALOGO_QUEUE_RECEIPT_DELAY (3с)
- NALOGO_QUEUE_MAX_ATTEMPTS (10)
Рефакторинг архитектуры управления модемом:
- Создан сервис app/services/modem_service.py:
- ModemService с бизнес-логикой подключения/отключения
- ModemError enum для типизации ошибок
- ModemPriceInfo, ModemOperationResult dataclass'ы
- Константы MODEM_WARNING_DAYS_* для уровней предупреждений
Рефакторинг архитектуры ежедневных конкурсов:
- Создан модуль app/services/contests/ с новой архитектурой:
- enums.py: GameType, RoundStatus, PrizeType enum классы
- games.py: паттерн Стратегия для 7 типов игр
- attempt_service.py: ContestAttemptService для атомарных операций
- Упрощён handlers/contests.py:
- Удалены отдельные _render_* функции (заменены на стратегии)
- Логика обработки попыток вынесена в ContestAttemptService
- Уменьшено с 523 до 342 строк (-35%)
- Обновлён contest_rotation_service.py:
- Заменена if-elif цепочка на get_game_strategy().build_payload()
- Используются enum классы вместо магических строк
- Исправлен handlers/admin/daily_contests.py:
- prize_days → prize_type/prize_value (соответствие модели БД)
- Обновлены EDITABLE_FIELDS и отображение приза
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Создание попытки сразу при показе вопроса (cipher/emoji/anagram)
- Проверка attempt.answer is not None для блокировки повторного ответа
- Обновление существующей попытки вместо создания новой
- Добавлена функция update_attempt() в CRUD
- Добавлен ENV переключатель TRAFFIC_TOPUP_ENABLED для вкл/выкл докупки
- Добавлена отдельная конфигурация пакетов TRAFFIC_TOPUP_PACKAGES_CONFIG
- Добавлено поле purchased_traffic_gb для отслеживания докупленного трафика
- Добавлены режимы расчета цены сброса (period/traffic/traffic_with_purchased)
- Исправлен абьюз: цена сброса теперь учитывает докупленный трафик
- Сброс purchased_traffic_gb при продлении/покупке подписки
- UX: меню сброса теперь показывает цену и баланс вместо alert
- UX: кнопка пополнения если не хватает средств на сброс
- Добавлена миграция для нового поля purchased_traffic_gb
- Добавлена локализация TRAFFIC_TOPUP_DISABLED (ru/en/ua/zh)
Изменения:
- Добавлены настройки модема в .env.example и config.py (MODEM_ENABLED, MODEM_PRICE_PER_MONTH, MODEM_PERIOD_DISCOUNTS)
- Добавлено поле modem_enabled в модель Subscription
- Реализован модуль handlers/subscription/modem.py с обработчиками подключения/отключения модема
- Добавлено управ
Изменения:
- ContestTemplate: prize_days заменен на prize_type и prize_value для поддержки разных типов наград (days, balance, custom)
- _award_prize: обновлена логика выдачи призов для всех типов наград
- DEFAULT_TEMPLATES: обновлены для использования prize_type/prize_value
- upsert_template: обновлена сигнатура для новых полей
- _announce_round_start: добавлена локализация и напоминания о конкурсах
- handle_text_answer: исправлена гонка условий с атомарным инкрементом победителей
- Локализация: добавлены ключи CONTEST_START_ANNOUNCEMENT, CONTEST_PRIZE, DAYS, CONTEST_WINNERS, CONTEST_ATTEMPTS, CONTEST_ELIGIBILITY, REMINDER, CONTEST_REMINDER_TEXT в ru.json и en.json
- API схемы: обновлены ContestTemplateResponse и ContestTemplateUpdateRequest
Требуется миграция БД для новых колонок prize_type и prize_value.
- Добавлена защита от спама: rate limiting для попыток (1 попытка/3-5 сек)
- Усилена валидация входных данных: функция _validate_callback_data для безопасного парсинга callback.data
- Перепроверка авторизации: статус подписки проверяется на каждом шаге
- Атомарные операции победителей: использование select with_for_update для предотвращения гонок условий
- Улучшено логирование: добавлены логи попыток и побед для аудита
- Добавлена кнопка 'Назад' в игру 'Блиц' для предотвращения застревания пользователей
- Исправлены отступы и ошибки линтера в _render_blitz
Все изменения направлены на повышение безопасности, стабильности и UX конкурсов.
# Email отправителя (если не указан, используется SMTP_USER)
SMTP_FROM_EMAIL=
SMTP_FROM_NAME=VPN Service
# Использовать TLS шифрование
SMTP_USE_TLS=true
# Уведомления администраторов
ADMIN_NOTIFICATIONS_ENABLED=true
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
ADMIN_NOTIFICATIONS_TOPIC_ID=123# Опционально: ID топика
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126# Опционально: ID топика для тикетов
ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID=133# Опционально: ID топика для уведомлений о чеках NaloGO
# Автоматические отчеты
ADMIN_REPORTS_ENABLED=false
ADMIN_REPORTS_CHAT_ID=# Опционально: чат для отчетов (по умолчанию ADMIN_NOTIFICATIONS_CHAT_ID)
ADMIN_REPORTS_TOPIC_ID=# ID топика для отчетов
ADMIN_REPORTS_SEND_TIME=10:00 # Время отправки (по МСК) ежедневного отчета
# Обязательная подписка на канал
CHANNEL_SUB_ID=# Опционально ID твоего канала (-100)
# ===== МОНИТОРИНГ ТРАФИКА =====
# Логика: при запуске бота создаётся snapshot трафика всех пользователей.
# Через указанный интервал проверяется дельта (разница) трафика.
# Если дельта превышает порог — отправляется уведомление админам.
TRAFFIC_CHECK_BATCH_SIZE=1000# Размер батча для получения пользователей
TRAFFIC_CHECK_CONCURRENCY=10# Параллельных запросов к API
TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES=60# Кулдаун уведомлений на пользователя (минуты)
TRAFFIC_SNAPSHOT_TTL_HOURS=24# TTL snapshot трафика в Redis (часы, сохраняется при рестарте)
# Черный список
BLACKLIST_CHECK_ENABLED=false# Включить проверку пользователей по черному списку
BLACKLIST_GITHUB_URL=https://raw.githubusercontent.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/refs/heads/main/blacklist.txt # URL к файлу черного списка на GitHub
BLACKLIST_UPDATE_INTERVAL_HOURS=24# Интервал обновления черного списка с GitHub (в часах)
BLACKLIST_IGNORE_ADMINS=true# Игнорировать администраторов (из ADMIN_IDS) при проверке черного списка
SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS=20000# Порог баланса (в копейках) для фильтра «готовы к продлению»
# Channel subscription settings (channels are managed via admin panel)
CHANNEL_IS_REQUIRED_SUB=false# Обязательна ли подписка на канал
CHANNEL_LINK=# Опционально ссылка на канал
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE=true# Отключать триальные подписки при отписке от канала
CHANNEL_REQUIRED_FOR_ALL=false# Требовать подписку на канал для ВСЕХ пользователей (платных и триальных)
# ===== DATABASE CONFIGURATION =====
# Режим базы данных: "auto", "postgresql", "sqlite"
@@ -44,13 +150,16 @@ LOCALES_PATH=./locales
# Redis
REDIS_URL=redis://redis:6379/0
# Время жизни корзины пользователя в Redis (секунды, по умолчанию 1 час)
CART_TTL_SECONDS=3600
# ===== REMNAWAVE API =====
REMNAWAVE_API_URL=https://panel.example.com
REMNAWAVE_API_KEY=your_api_key_here
# Тип авторизации: "api_key", "basic_auth"
# Тип авторизации: "api_key", "basic_auth", "caddy"
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
ENABLE_LOGO_MODE=true
LOGO_FILE=vpn_logo.png
# Режим главного меню (default - классический режим работы бота, text - режим работы с активным ЛК MiniApp, отключает покупку/управление подпиской в меню, заменяет все кнопками открытия в MiniApp ЛК)
# Режим главного меню:
# default - классический режим работы бота (все кнопки внутри Telegram)
# cabinet - режим Cabinet с активным ЛК MiniApp, кнопки ведут на конкретные
# разделы кабинета (/balance, /subscription, /referral и т.д.)
# Требует MINIAPP_CUSTOM_URL
# Алиасы для обратной совместимости: text, text_only, minimal
MAIN_MENU_MODE=default
# Стиль кнопок в режиме Cabinet (Bot API 9.4):
# primary - синий
# success - зелёный
# danger - красный
# (пустое) - цвета по умолчанию для каждой секции
CABINET_BUTTON_STYLE=
# Включить управление меню через API (позволяет динамически менять структуру кнопок)
MENU_LAYOUT_ENABLED=false
# Скрыть блок с ссылкой подключения в разделе с информацией о подписке
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
MINIAPP_CUSTOM_URL=
MINIAPP_STATIC_PATH=miniapp
# URL для редиректа на страницу покупки в мини-приложении (опционально)
# MINIAPP_PURCHASE_URL=
MINIAPP_SERVICE_NAME_EN=Bedolaga VPN
MINIAPP_SERVICE_NAME_RU=Bedolaga VPN
MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection
@@ -381,6 +853,8 @@ HAPP_DOWNLOAD_LINK_IOS=
HAPP_DOWNLOAD_LINK_ANDROID=
HAPP_DOWNLOAD_LINK_MACOS=
HAPP_DOWNLOAD_LINK_WINDOWS=
# Универсальная ссылка для ПК (если MACOS и WINDOWS не заданы отдельно)
HAPP_DOWNLOAD_LINK_PC=
# Кнопка (Подключится) с редиректом (тк ссылки с happ:// тг не поддерживает) - Без установленной ссылки на редирект кнопки (подключится) не будет! Пример: https://sub.domain.sub/redirect-page/?redirect_to=
HAPP_CRYPTOLINK_REDIRECT_TEMPLATE=
@@ -428,18 +902,33 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
# ===== ЛОКАЛИЗАЦИЯ =====
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en
AVAILABLE_LANGUAGES=ru,en,ua,zh,fa
# Включить выбор языка при старте и отображение кнопки в меню
LANGUAGE_SELECTION_ENABLED=true
# Округление цен при отображении (≤50 коп вниз, >50 коп вверх)
# true: 14.78₽ → 15₽, 14.12₽ → 14₽
# false: показывать точные суммы с копейками
PRICE_ROUNDING_ENABLED=true
# Часовой пояс
TZ=Europe/Moscow # или UTC, America/New_York и т.д.
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
# Конфигурация приложений для гайда подключения
APP_CONFIG_PATH=app-config.json
ENABLE_DEEP_LINKS=true
APP_CONFIG_CACHE_TTL=3600
# ===== BAN SYSTEM INTEGRATION (BedolagaBan) =====
# Интеграция с системой мониторинга банов BedolagaBan
# Включить интеграцию с Ban системой
BAN_SYSTEM_ENABLED=false
# URL API сервера Ban системы (например: http://ban-server:8000)
BAN_SYSTEM_API_URL=
# API токен для авторизации в Ban системе
BAN_SYSTEM_API_TOKEN=
# Таймаут запросов к API (секунды)
BAN_SYSTEM_REQUEST_TIMEOUT=30
# ===== СИСТЕМА БЕКАПОВ =====
BACKUP_AUTO_ENABLED=true
BACKUP_INTERVAL_HOURS=24
@@ -456,6 +945,8 @@ BACKUP_SEND_ENABLED=true
BACKUP_SEND_CHAT_ID=-100123456789 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА!
# ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
BACKUP_SEND_TOPIC_ID=123# Опционально: ID топика
# Пароль для архива бекапа (опционально). Если задан - бекап отправляется в зашифрованном ZIP с AES
"en":"Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa":"صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru":"Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh":"在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep":{
"description":{
"en":"Click the button below — the app will open and the subscription will be added automatically",
"fa":"برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru":"Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh":"点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep":{
"description":{
"en":"In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa":"در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru":"В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"en":"Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa":"صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru":"Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh":"在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep":{
"description":{
"en":"Click the button below — the app will open and the subscription will be added automatically",
"fa":"برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru":"Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh":"点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep":{
"description":{
"en":"In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa":"در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru":"В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"en":"Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa":"صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru":"Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh":"在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep":{
"description":{
"en":"Click the button below — the app will open and the subscription will be added automatically",
"fa":"برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru":"Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh":"点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep":{
"description":{
"en":"In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa":"در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru":"В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"en":"Choose the version for your device, click the button below and install the app.",
"fa":"نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
"ru":"Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
"zh":"选择适合您设备的版本,点击下方按钮并安装应用。"
}
},
"additionalBeforeAddSubscriptionStep":{
"buttons":[],
"description":{
"en":"After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
"fa":"پس از راهاندازی برنامه، میتوانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
"ru":"После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
"en":"Click the button below to add subscription",
"fa":"برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru":"Нажмите кнопку ниже, чтобы добавить подписку",
"zh":"点击下方按钮添加订阅"
}
},
"additionalAfterAddSubscriptionStep":{
"buttons":[],
"title":{
"en":"If the subscription is not added",
"fa":"اگر اشتراک در برنامه نصب نشده است",
"ru":"Если подписка не добавилась",
"zh":"如果订阅未添加"
},
"description":{
"en":"If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
"fa":"اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایلها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
"ru":"Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
"en":"In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
"fa":"در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
"ru":"В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
"zh":"在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
}
},
"addSubscriptionStep":{
"description":{
"en":"Click the button below to add subscription",
"fa":"برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru":"Нажмите кнопку ниже, чтобы добавить подписку",
"zh":"点击下方按钮添加订阅"
}
},
"connectAndUseStep":{
"description":{
"en":"In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
"fa":"در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru":"В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"en":"Choose the version for your device, click the button below and install the app.",
"fa":"نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
"ru":"Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
"zh":"选择适合您设备的版本,点击下方按钮并安装应用。"
}
},
"additionalBeforeAddSubscriptionStep":{
"buttons":[],
"description":{
"en":"After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
"fa":"پس از راهاندازی برنامه، میتوانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
"ru":"После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
"en":"Click the button below to add subscription",
"fa":"برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru":"Нажмите кнопку ниже, чтобы добавить подписку",
"zh":"点击下方按钮添加订阅"
}
},
"additionalAfterAddSubscriptionStep":{
"buttons":[],
"title":{
"en":"If the subscription is not added",
"fa":"اگر اشتراک در برنامه نصب نشده است",
"ru":"Если подписка не добавилась",
"zh":"如果订阅未添加"
},
"description":{
"en":"If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
"fa":"اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایلها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
"ru":"Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
"en":"In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
"fa":"در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
"ru":"В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
"zh":"在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
}
},
"addSubscriptionStep":{
"description":{
"en":"Click the button below to add subscription",
"fa":"برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru":"Нажмите кнопку ниже, чтобы добавить подписку",
"zh":"点击下方按钮添加订阅"
}
},
"connectAndUseStep":{
"description":{
"en":"In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
"fa":"در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru":"В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"en":"Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"ru":"Откройте страницу в Google Play и установите приложение",
"zh":"-",
"fa":"-"
}
},
"addSubscriptionStep":{
"description":{
"en":"Click the button below to add subscription",
"ru":"Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
"zh":"-",
"fa":"-"
}
},
"connectAndUseStep":{
"description":{
"en":"Open the app and connect to the server",
"ru":"Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
"en":"Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"ru":"Откройте страницу в Google Play и установите приложение",
"zh":"-",
"fa":"-"
}
},
"addSubscriptionStep":{
"description":{
"en":"Click the button below to add subscription",
"ru":"Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
"zh":"-",
"fa":"-"
}
},
"connectAndUseStep":{
"description":{
"en":"Open the app and connect to the server",
"ru":"Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.