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
- 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
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
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.
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.
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.
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.
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
- 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.
- 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)
- 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]