- 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.
Custom email templates with their own styling (background colors, <style> tags)
were wrapped in a white base template, causing visible white areas around dark-themed
templates. Added three-tier detection: full HTML documents pass through as-is,
styled content gets a minimal wrapper, simple fragments keep the base template.
Platega: handle verification ping POST without auth headers (empty body → 200 OK)
CryptoBot: always use API token for signature verification per docs, not WEBHOOK_SECRET
CryptoBot: reject requests without signature in both FastAPI and aiohttp handlers
Remove dead self.webhook_secret from CryptoBotService
Update tests to match new behavior
Webhook only checked subscriptionCryptoLink field, missing happ.cryptoLink
fallback that sync already used. Also clear stale crypto link when URL
changes but no new crypto link is provided.