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
- Validate message length for media broadcasts (1024 char Telegram limit)
- Add created count per day to landing stats API (separate from successful)
- Fix total_purchases to show total_created instead of total_successful
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add cabinet_email and cabinet_password to context_vars and sample_contexts
for guest_subscription_delivered template type so they appear in the admin
email template editor.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add login/password block to the "subscription ready" email template
so users receive their cabinet credentials in the first email.
The credentials block is only shown when cabinet_password is present
(new accounts). All 5 locales updated (ru, en, zh, ua, fa).
The separate credentials email (GUEST_CABINET_CREDENTIALS) is still
sent as before — this provides redundancy in case one email doesn't
arrive (e.g. due to SMTP quota limits).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Prevents double-counting in revenue: bot users create DEPOSIT (real
money) + SUBSCRIPTION_PAYMENT (balance debit). Without explicit
payment_method, subscription_payment had NULL which was patched to
kassa_ai, causing both to count as real revenue.
Now create_transaction defaults to BALANCE for SUBSCRIPTION_PAYMENT
when no payment_method is specified.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Same fix as transaction.py — sales dashboard summary and deposits
breakdown were only counting DEPOSIT transactions, missing all
landing page purchases (SUBSCRIPTION_PAYMENT).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Guest/landing purchases create SUBSCRIPTION_PAYMENT transactions (not
DEPOSIT), so they were excluded from income_today, total_income,
revenue_by_period, and payment_methods breakdown.
Also use func.abs() for SUBSCRIPTION_PAYMENT amounts since they are
stored as negative values.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Guest/landing purchases create SUBSCRIPTION_PAYMENT transactions (not
DEPOSIT), so they were excluded from income_today, total_income,
revenue_by_period, and payment_methods breakdown.
Also use func.abs() for SUBSCRIPTION_PAYMENT amounts since they are
stored as negative values.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- 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