Когда 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