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