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
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.
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
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.
- 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
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
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
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)
- 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
- Статус автоплатежа в уведомлении основан на 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 (создание подписки).
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.
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
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.
- 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
- 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 локации)
- 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
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
- 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