Когда 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
TRUNCATE 83 таблиц таймаутился из-за command_timeout=30s в asyncpg.
После таймаута в fallback-цикле PendingRollbackError каскадировал
на все остальные таблицы и восстановление данных.
Исправление:
- Выделенный engine с command_timeout=300s и statement_timeout=5min
для TRUNCATE операций (NullPool, без overhead)
- Каждая таблица в fallback очищается в отдельном соединении,
что предотвращает каскад PendingRollbackError
- lock_timeout=2min для ограничения ожидания блокировок
(бот продолжает обрабатывать сообщения во время восстановления)
- 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
- 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
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
- 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
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
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).
- 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
- 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
- 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.