Compare commits

...

61 Commits

Author SHA1 Message Date
Egor 55f386d7e8 Merge pull request #2774 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.36.0
2026-03-20 07:23:48 +03:00
github-actions[bot] e6a310dc32 chore(main): release 3.36.0 2026-03-20 04:21:42 +00:00
Egor ccd8f86e96 Merge pull request #2773 from BEDOLAGA-DEV/dev
Dev
2026-03-20 07:21:11 +03:00
Egor b6d4373933 Merge pull request #2771 from smediainfo/fix/kassa-ai-guest-metadata
fix: use base model name for KassaAI guest metadata patch
2026-03-20 07:20:25 +03:00
Egor 266620904d Merge pull request #2772 from BEDOLAGA-DEV/main
w
2026-03-20 07:18:45 +03:00
Fringg 479af5741a style: ruff format 2026-03-20 07:15:15 +03:00
Fringg 79c110ff41 fix: address review findings for multi-provider recovery
- 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)
2026-03-20 07:02:47 +03:00
Fringg 3d78974af7 feat: add multi-provider recovery, retry_count, amount verification, and indexes
- 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)
2026-03-20 06:53:49 +03:00
Fringg 57c5c679ee fix: address review findings for guest purchase recovery
- 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
2026-03-20 06:45:21 +03:00
Fringg 2781236011 fix: prevent guest purchases from getting stuck in PENDING/FAILED status
- 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
2026-03-20 06:39:01 +03:00
Fringg 4a002b7db1 fix: allow repeated auto-assignment of promo groups on each purchase
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.
2026-03-20 05:46:59 +03:00
Fringg 8b2668087b fix: prevent premature commits in promocode promo group operations
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.
2026-03-20 05:33:30 +03:00
Fringg 3ec9e71de7 fix: propagate exceptions from get_primary_user_promo_group
Consistent with has_user_promo_group and get_user_promo_groups which
now propagate exceptions instead of masking them with default returns.
2026-03-20 05:30:42 +03:00
Fringg da7a9cc3c5 fix: prevent duplicate promo groups during auto-assignment after purchase
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
2026-03-20 05:25:41 +03:00
Fringg b5471b7720 perf: add covering indexes for referral network queries
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.
2026-03-20 02:31:03 +03:00
Fringg 6a4ce3dd38 feat: multi-select scope for referral network graph API
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.
2026-03-20 02:30:56 +03:00
Fringg df086b09c7 feat: add scoped referral network graph with scope selector API
- 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
2026-03-20 01:49:59 +03:00
Fringg 01132a7bc7 feat: add partner → campaign edges to referral network graph 2026-03-20 01:16:57 +03:00
sMedia.tech 182667ecb8 fix: use 'kassa_ai' base model name for guest metadata patch
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>
2026-03-19 22:56:39 +03:00
Fringg c8f4cca340 fix: correct revenue calculations in referral network
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.
2026-03-19 08:51:52 +03:00
Fringg ac9fcd8d30 fix: improve referral network query correctness and cleanup
- 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
2026-03-19 08:08:16 +03:00
Fringg c08c903e8f feat: add referral network graph visualization admin API
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.
2026-03-19 07:55:51 +03:00
Fringg 69bb399b63 feat: add media attachment support for admin ticket replies
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.
2026-03-19 06:38:54 +03:00
Egor 463c5385d6 Merge pull request #2770 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.35.0
2026-03-18 23:35:06 +03:00
github-actions[bot] 41dfe39518 chore(main): release 3.35.0 2026-03-18 20:34:40 +00:00
Egor f44c9b6903 Merge pull request #2769 from BEDOLAGA-DEV/dev
Dev
2026-03-18 23:34:11 +03:00
Egor 1f35d45dc6 Merge pull request #2768 from BEDOLAGA-DEV/main
w
2026-03-18 23:32:42 +03:00
Fringg ef8f6625bf chore: ruff format 2026-03-18 23:31:31 +03:00
Fringg 7101555da0 feat: add user_email to admin payments API response 2026-03-18 23:30:17 +03:00
Fringg e15b18fb41 feat: раздельные топики для админских уведомлений
Добавлены 9 новых env-переменных для маршрутизации уведомлений по отдельным топикам:
- PURCHASES, RENEWALS, TRIALS, BALANCE, ADDONS
- INFRASTRUCTURE, ERRORS, PROMO, PARTNERS

Обратная совместимость: если топик для категории не задан — fallback на ADMIN_NOTIFICATIONS_TOPIC_ID.
2026-03-18 23:16:54 +03:00
Fringg b80eeea089 feat: include manual admin top-ups in sales statistics revenue 2026-03-18 22:39:31 +03:00
Fringg cb61014d9c fix: remove forced white background from custom email template overrides
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.
2026-03-18 22:24:58 +03:00
Fringg 5b3353433b fix: undefined currency variable in RioPay payment creation
currency was referenced but never defined in create_riopay_payment,
causing NameError. Use settings.RIOPAY_CURRENCY instead.
2026-03-18 22:05:45 +03:00
Fringg f1d45343e9 fix: handle None autopay_days_before in autopayment processing
Existing subscriptions may have NULL autopay_days_before in DB,
causing TypeError in min(None, 3). Default to 3 when None.
2026-03-18 21:16:08 +03:00
Fringg b40a812f3a fix: fix Platega and CryptoBot webhook verification
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
2026-03-18 20:11:55 +03:00
Egor ac00434645 Merge pull request #2765 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.34.1
2026-03-18 18:25:15 +03:00
github-actions[bot] 964c33c772 chore(main): release 3.34.1 2026-03-18 15:23:42 +00:00
Egor ec875837a4 Merge pull request #2767 from BEDOLAGA-DEV/dev
Dev
2026-03-18 18:22:52 +03:00
Egor db0e169a41 Merge pull request #2766 from BEDOLAGA-DEV/main
w
2026-03-18 18:21:41 +03:00
Fringg a6dcf26c20 chore: ruff format and cleanup 2026-03-18 18:20:50 +03:00
Fringg 5081debee7 fix: add null check for subscription in execute_change_devices 2026-03-18 18:15:20 +03:00
Fringg 0ceff44c30 fix: sync crypto link from happ.cryptoLink in webhook handlers
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.
2026-03-18 18:05:53 +03:00
Fringg 136f29c1eb refactor: remove quick amount buttons feature entirely
Removed across 16 files: config, all payment handlers, handler registrations, env example.
2026-03-18 17:55:16 +03:00
Fringg d0eab3f7aa fix: disable quick amount buttons in balance topup
Buttons showed incorrect prices. Hardcoded is_quick_amount_buttons_enabled to False.
2026-03-18 17:47:51 +03:00
Fringg d7ad9d7033 fix: correct CryptoBot webhook signature verification and auto-fill topup amount from cart
- Use API token as fallback for webhook signature verification per CryptoBot docs
- Try raw body, re-serialized compact JSON, and ASCII-escaped JSON for signature matching
- Auto-fill payment amount from saved cart in show_payment_methods instead of hardcoded 0
2026-03-18 17:44:52 +03:00
Fringg 1a87d438fe fix: correct RioPay API header case and remove undocumented fields
Header was 'x-api-token' but RioPay API expects 'X-Api-Token'
(case-sensitive check on their side), causing 403 Invalid API token.

Also removed undocumented 'currency' and 'failUrl' fields from
create_order payload per official RioPay API docs.
2026-03-18 17:00:56 +03:00
Fringg aec01ce0d4 fix: reset device limit to new tariff base on tariff switch
Previously, extra purchased devices were carried over when switching
tariffs, causing incorrect pricing — users upgrading to a more
expensive plan kept the old per-device rate until next renewal.

Now tariff switch resets device_limit to the new tariff's base limit.
Extra purchased devices are not carried over.
2026-03-18 16:57:08 +03:00
c0mrade a33a893d1a Update README.md
Фикс WATA в редми
2026-03-18 10:58:18 +03:00
Egor 37c9b931ca Merge pull request #2763 from BEDOLAGA-DEV/dev
Dev
2026-03-18 07:23:12 +03:00
Fringg 22e7f150b3 docs: increase logo size to 800px 2026-03-18 07:22:27 +03:00
Fringg 688882237f docs: replace header logo with new artwork 2026-03-18 07:21:03 +03:00
Egor c14d7ab0af Merge pull request #2761 from BEDOLAGA-DEV/dev
docs: add Redis to tech stack
2026-03-18 07:13:38 +03:00
Fringg e12cc9f248 docs: add Redis to tech stack 2026-03-18 07:12:53 +03:00
Egor 6ff0460607 Merge pull request #2759 from BEDOLAGA-DEV/dev
Dev
2026-03-18 07:07:53 +03:00
Fringg 8d5a002996 docs: WATA partnership block with logo and table card 2026-03-18 07:07:06 +03:00
Fringg 31bdf8a0ae docs: add WATA partnership block to payments section 2026-03-18 07:04:57 +03:00
Egor 1364158e6c Merge pull request #2757 from BEDOLAGA-DEV/dev
Dev
2026-03-18 06:55:47 +03:00
Fringg d7931a2afa docs: add bot preview screenshot to README 2026-03-18 06:53:44 +03:00
Fringg b032c8f354 docs: add cabinet preview screenshot to README 2026-03-18 06:50:00 +03:00
Fringg 1306c24fa3 docs: add icons and list all 14+1 payment providers 2026-03-18 06:41:43 +03:00
Fringg 38deb70f81 docs: redesign README — concise feature showcase, link to docs
Replace 2200-line README with a clean 190-line version:
- Centered header with badges (for-the-badge style)
- Feature grid (2x2 HTML table)
- Payment providers showcase (14 providers)
- Quick start (4 lines → link to full docs)
- Tech stack table
- Cabinet section with link to repo
- Documentation links to docs.bedolagam.ru
- Community section

All setup/config details moved to docs.bedolagam.ru.
2026-03-18 06:37:49 +03:00
67 changed files with 2781 additions and 3143 deletions
-5
View File
@@ -491,16 +491,11 @@ YOOKASSA_WEBHOOK_PORT=8082
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
YOOKASSA_MAX_AMOUNT_KOPEKS=1000000
# Быстрый выбор суммы пополнения через YooKassa
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
# Рекуррентные платежи YooKassa (автосохранение карты для автоплатежей)
YOOKASSA_RECURRENT_ENABLED=false
# true = карта сохраняется обязательно, false = пользователь решает (чекбокс на стороне YooKassa)
YOOKASSA_RECURRENT_REQUIRED=true
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
DISABLE_TOPUP_BUTTONS=false
# Отключить пополнение баланса через поддержку
SUPPORT_TOPUP_ENABLED=true
Binary file not shown.

After

Width:  |  Height:  |  Size: 850 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.34.0"
".": "3.36.0"
}
+80
View File
@@ -1,5 +1,85 @@
# Changelog
## [3.36.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.35.0...v3.36.0) (2026-03-20)
### New Features
* add media attachment support for admin ticket replies ([69bb399](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/69bb399b63d6e1d761cc5518e6272917fc5a6ae7))
* add multi-provider recovery, retry_count, amount verification, and indexes ([3d78974](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3d78974af70b360449d9cf634e09a79821cdc7c0))
* add partner → campaign edges to referral network graph ([01132a7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/01132a7bc77b07eaaaf876c05d639bfed83e5324))
* add referral network graph visualization admin API ([c08c903](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c08c903e8f94f3730872b19de904ce166ba35b98))
* add scoped referral network graph with scope selector API ([df086b0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/df086b09c75a9157cdc558fb610b617a2d49deaf))
* multi-select scope for referral network graph API ([6a4ce3d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6a4ce3dd38dc3cf2e9db08322093bfd0b84f1e1c))
### Bug Fixes
* address review findings for guest purchase recovery ([57c5c67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/57c5c679eef987e9bacee1a9d420ac3c0ff69ff7))
* address review findings for multi-provider recovery ([79c110f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79c110ff41659ff225164c13690bafc859212d05))
* allow repeated auto-assignment of promo groups on each purchase ([4a002b7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4a002b7db1bfa8fd1149b0e3dfa469e8912a0e3b))
* correct revenue calculations in referral network ([c8f4cca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c8f4cca34053713eb2793bbb82e74cbb9a6f893c))
* improve referral network query correctness and cleanup ([ac9fcd8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ac9fcd8d30dd64fdc7363e689e9ffaf03700976e))
* prevent duplicate promo groups during auto-assignment after purchase ([da7a9cc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da7a9cc3c5fd771b932a2bfd154400f6f923a4d1))
* prevent guest purchases from getting stuck in PENDING/FAILED status ([2781236](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2781236011942e794949544b9c5422aa8679e5eb))
* prevent premature commits in promocode promo group operations ([8b26680](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8b2668087b4831c08474846b20591b2158d607fc))
* propagate exceptions from get_primary_user_promo_group ([3ec9e71](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3ec9e71de7d8d40e969f9ea738455445d04d983a))
* use 'kassa_ai' base model name for guest metadata patch ([182667e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/182667ecb86f9bbbe87d640865dcbcaadf9e72f6))
* use base model name for KassaAI guest metadata patch ([b6d4373](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b6d43739337cd2e2cfd61ac33398eb6def5b28b6))
### Performance
* add covering indexes for referral network queries ([b5471b7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b5471b7720213c217fc452dc1234a7d3c53447d5))
## [3.35.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.34.1...v3.35.0) (2026-03-18)
### New Features
* add user_email to admin payments API response ([7101555](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7101555da0722d1eacd97f40b6b8c8c3a2327a0c))
* include manual admin top-ups in sales statistics revenue ([b80eeea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b80eeea089568c60c20b1ae165b8dbe887bbe378))
* раздельные топики для админских уведомлений ([e15b18f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e15b18fb41b180e7dd3d65f2f058667be321fe85))
### Bug Fixes
* fix Platega and CryptoBot webhook verification ([b40a812](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b40a812f3aa0596bf6c5105008451dd8a17b103f))
* handle None autopay_days_before in autopayment processing ([f1d4534](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f1d45343e941594d69f71e822ecc9b3a7062f4bf))
* remove forced white background from custom email template overrides ([cb61014](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cb61014d9c5a89e3aeafb191f1b9826ca1cbf338))
* undefined currency variable in RioPay payment creation ([5b33534](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5b3353433bc524e3e51f2ae87d26bd162bd9f97b))
## [3.34.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.34.0...v3.34.1) (2026-03-18)
### Bug Fixes
* add null check for subscription in execute_change_devices ([5081deb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5081debee7625954bd7b82f66e09dd58e08e8822))
* correct CryptoBot webhook signature verification and auto-fill topup amount from cart ([d7ad9d7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d7ad9d70330b6ef5599f7a9409cdd16657d61b85))
* correct RioPay API header case and remove undocumented fields ([1a87d43](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1a87d438fe127a0b62bd6ee59887212869a3cb17))
* disable quick amount buttons in balance topup ([d0eab3f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d0eab3f7aacf0249ca244f168c96044347c918e3))
* reset device limit to new tariff base on tariff switch ([aec01ce](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aec01ce0d4a36da5ddd07b56ca4bd5de04735a0b))
* sync crypto link from happ.cryptoLink in webhook handlers ([0ceff44](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ceff44c30cf11466c4cbe51b520558c51c4af4a))
### Refactoring
* remove quick amount buttons feature entirely ([136f29c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/136f29c1eb63778b2b329ed5bf72ab06a4531d0b))
### Documentation
* add bot preview screenshot to README ([d7931a2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d7931a2afaf272aae74453f2bb6d493593895f67))
* add cabinet preview screenshot to README ([b032c8f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b032c8f35435ddd592df04d6352097580f5e9837))
* add icons and list all 14+1 payment providers ([1306c24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1306c24fa36fd3e812c5bec551a0aca450ca7d2c))
* add Redis to tech stack ([c14d7ab](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c14d7ab0af2e5dd4f35213b323a97eedcc99af0e))
* add Redis to tech stack ([e12cc9f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e12cc9f248764104d538db1daaede9ddd8b77b2d))
* add WATA partnership block to payments section ([31bdf8a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/31bdf8a0aeba53fcec40358d0a38b8480130a346))
* increase logo size to 800px ([22e7f15](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/22e7f150b30c35b0419e7923e94c5ebc92c4b61a))
* redesign README — concise feature showcase, link to docs ([38deb70](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/38deb70f8118d371e36ac92a4417bdb543635fc9))
* replace header logo with new artwork ([6888822](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/688882237fe3019bb78d5e1d7ad54faf4cd69c09))
* WATA partnership block with logo and table card ([8d5a002](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8d5a0029964ba52b40e03ebfe8ab6f4146d3aca9))
## [3.34.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.33.0...v3.34.0) (2026-03-18)
+1 -1
View File
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
FROM python:3.13-slim
ARG VERSION="v3.34.0" # x-release-please-version
ARG VERSION="v3.36.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+191 -2149
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -20,6 +20,7 @@ from .admin_pinned_messages import router as admin_pinned_messages_router
from .admin_policies import router as admin_policies_router
from .admin_promo_offers import router as admin_promo_offers_router
from .admin_promocodes import promo_groups_router as admin_promo_groups_router, router as admin_promocodes_router
from .admin_referral_network import router as admin_referral_network_router
from .admin_remnawave import router as admin_remnawave_router
from .admin_roles import router as admin_roles_router
from .admin_sales_stats import router as admin_sales_stats_router
@@ -99,6 +100,7 @@ router.include_router(admin_wheel_router)
router.include_router(admin_tariffs_router)
router.include_router(admin_servers_router)
router.include_router(admin_stats_router)
router.include_router(admin_referral_network_router)
router.include_router(admin_sales_stats_router)
router.include_router(admin_ban_system_router)
router.include_router(admin_broadcasts_router)
+2 -2
View File
@@ -670,8 +670,8 @@ async def preview_template(
language = data.language if data.language in AVAILABLE_LANGUAGES else 'ru'
if data.body_html:
# Preview custom content wrapped in base template
rendered_html = templates_instance._get_base_template(data.body_html, language)
# Preview custom content — auto-detects styled vs simple HTML
rendered_html = templates_instance._wrap_override_template(data.body_html, language)
subject = data.subject or notification_type
else:
# Preview default template
+2
View File
@@ -62,6 +62,7 @@ class PendingPaymentResponse(BaseModel):
user_id: int | None = None
user_telegram_id: int | None = None
user_username: str | None = None
user_email: str | None = None
class Config:
from_attributes = True
@@ -285,6 +286,7 @@ def _record_to_response(record: PendingPayment) -> PendingPaymentResponse:
user_id=record.user.id if record.user else None,
user_telegram_id=record.user.telegram_id if record.user else None,
user_username=record.user.username if record.user else None,
user_email=record.user.email if record.user else None,
)
File diff suppressed because it is too large Load Diff
+21 -3
View File
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.transaction import REAL_PAYMENT_METHODS
from app.database.models import (
PaymentMethod,
Subscription,
SubscriptionConversion,
SubscriptionStatus,
@@ -87,6 +88,7 @@ class SalesSummary(BaseModel):
"""Summary stats for the top cards."""
total_revenue_kopeks: int
manual_topup_kopeks: int
active_subscriptions: int
active_trials: int
new_trials: int
@@ -124,6 +126,20 @@ async def get_sales_summary(
)
total_revenue = revenue_result.scalar() or 0
# Manual top-ups by admins
manual_topup_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True,
Transaction.payment_method == PaymentMethod.MANUAL.value,
Transaction.created_at >= period_start,
Transaction.created_at <= period_end,
)
)
)
manual_topup = manual_topup_result.scalar() or 0
# Consolidated subscription counts: active paid, active trial, new trials in period
sub_counts_result = await db.execute(
select(
@@ -243,7 +259,8 @@ async def get_sales_summary(
addon_revenue = abs(addon_revenue_result.scalar() or 0)
return SalesSummary(
total_revenue_kopeks=total_revenue,
total_revenue_kopeks=total_revenue + manual_topup,
manual_topup_kopeks=manual_topup,
active_subscriptions=active_subs,
active_trials=active_trials,
new_trials=new_trials,
@@ -1060,10 +1077,11 @@ async def get_deposits_stats(
try:
period_start, period_end = _parse_period(days, start_date, end_date)
methods_with_manual = [*REAL_PAYMENT_METHODS, PaymentMethod.MANUAL.value]
base_filter = and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
Transaction.payment_method.in_(methods_with_manual),
Transaction.created_at >= period_start,
Transaction.created_at <= period_end,
)
@@ -1114,7 +1132,7 @@ async def get_deposits_stats(
]
# Daily deposits grouped by payment method
# base_filter already excludes NULLs via .in_(REAL_PAYMENT_METHODS), no coalesce needed
# base_filter already excludes NULLs via .in_(methods_with_manual), no coalesce needed
daily_by_method_query = await db.execute(
select(
func.date(Transaction.created_at).label('date'),
+19 -1
View File
@@ -5,7 +5,7 @@ from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -90,6 +90,19 @@ class AdminReplyRequest(BaseModel):
"""Admin reply to ticket."""
message: str = Field(..., min_length=1, max_length=4000, description='Reply message')
media_type: str | None = Field(None, description='Media type: photo, video, or document')
media_file_id: str | None = Field(None, max_length=255, description='Telegram file_id from media upload')
media_caption: str | None = Field(None, max_length=1000, description='Caption for media')
@model_validator(mode='after')
def validate_media_fields(self) -> 'AdminReplyRequest':
if self.media_file_id and not self.media_type:
raise ValueError('media_type is required when media_file_id is provided')
if self.media_type and not self.media_file_id:
raise ValueError('media_file_id is required when media_type is provided')
if self.media_type and self.media_type not in {'photo', 'video', 'document'}:
raise ValueError('media_type must be one of: photo, video, document')
return self
class AdminStatusUpdateRequest(BaseModel):
@@ -443,11 +456,16 @@ async def reply_to_ticket(
)
# Create admin message
has_media = bool(request.media_file_id)
message = TicketMessage(
ticket_id=ticket.id,
user_id=ticket.user_id,
message_text=request.message,
is_from_admin=True,
has_media=has_media,
media_type=request.media_type if has_media else None,
media_file_id=request.media_file_id if has_media else None,
media_caption=request.media_caption if has_media else None,
created_at=datetime.now(UTC),
)
db.add(message)
+1 -1
View File
@@ -4107,7 +4107,7 @@ async def switch_tariff(
# Update subscription
old_tariff_name = current_tariff.name if current_tariff else 'Unknown'
# Preserve extra purchased devices above the old tariff's base limit
# Reset device limit to new tariff base (extra purchased devices are not carried over)
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
# Re-load subscription to avoid MissingGreenlet from expired lazy relationship
@@ -198,7 +198,7 @@ async def get_rendered_override(
for key, value in context.items():
body_html = body_html.replace(f'{{{key}}}', html.escape(str(value)))
rendered = templates._get_base_template(body_html, language)
rendered = templates._wrap_override_template(body_html, language)
subject = override['subject']
# Also substitute in subject
+33
View File
@@ -74,6 +74,39 @@ class EmailNotificationTemplates:
return template_func(language, context)
def _wrap_override_template(self, content: str, language: str = 'ru') -> str:
"""Wrap override template content appropriately based on its structure.
Three-tier detection:
1. Full HTML document (<!DOCTYPE or <html>) return as-is, no wrapping
2. Styled content (has <style> tag or background CSS) minimal HTML wrapper
without forced colors, headers, or footers
3. Simple HTML fragment wrap with base template (header, footer, white bg)
for backward compatibility
"""
content_stripped = content.strip()
content_lower = content_stripped.lower()
# Tier 1: Full HTML document — return as-is
if content_lower.startswith('<!doctype') or content_lower.startswith('<html'):
return content_stripped
# Tier 2: Styled content — minimal wrapper without forced styling
if '<style' in content_lower or 'background' in content_lower:
return f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0;">
{content}
</body>
</html>"""
# Tier 3: Simple HTML fragment — use base template for structure
return self._get_base_template(content, language)
def _get_base_template(self, content: str, language: str = 'ru') -> str:
"""Wrap content in base HTML template."""
footer_texts = {
+11 -6
View File
@@ -56,6 +56,17 @@ class Settings(BaseSettings):
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID: int | None = None
ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID: int | None = None
# Раздельные топики для уведомлений (если не задано — fallback на ADMIN_NOTIFICATIONS_TOPIC_ID)
ADMIN_NOTIFICATIONS_PURCHASES_TOPIC_ID: int | None = None # Покупки подписок
ADMIN_NOTIFICATIONS_RENEWALS_TOPIC_ID: int | None = None # Продления
ADMIN_NOTIFICATIONS_TRIALS_TOPIC_ID: int | None = None # Триалы
ADMIN_NOTIFICATIONS_BALANCE_TOPIC_ID: int | None = None # Пополнение баланса
ADMIN_NOTIFICATIONS_ADDONS_TOPIC_ID: int | None = None # Докупка трафика/устройств/серверов
ADMIN_NOTIFICATIONS_INFRASTRUCTURE_TOPIC_ID: int | None = None # Ноды, техработы, статус панели
ADMIN_NOTIFICATIONS_ERRORS_TOPIC_ID: int | None = None # Ошибки бота
ADMIN_NOTIFICATIONS_PROMO_TOPIC_ID: int | None = None # Промокоды, кампании, промогруппы
ADMIN_NOTIFICATIONS_PARTNERS_TOPIC_ID: int | None = None # Партнёрки, выводы, админ-действия
# Настройки очереди чеков NaloGO
NALOGO_QUEUE_CHECK_INTERVAL: int = 300 # Интервал проверки очереди (секунды)
NALOGO_QUEUE_RECEIPT_DELAY: int = 3 # Задержка между отправкой чеков (секунды)
@@ -353,10 +364,8 @@ class Settings(BaseSettings):
YOOKASSA_TRUSTED_PROXY_NETWORKS: str = ''
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
YOOKASSA_RECURRENT_ENABLED: bool = False
YOOKASSA_RECURRENT_REQUIRED: bool = False
DISABLE_TOPUP_BUTTONS: bool = False
SUPPORT_TOPUP_ENABLED: bool = True
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10
@@ -1254,10 +1263,6 @@ class Settings(BaseSettings):
return bool(value)
def is_quick_amount_buttons_enabled(self) -> bool:
"""Показывать ли кнопки быстрого выбора суммы пополнения."""
return self.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not self.DISABLE_TOPUP_BUTTONS
def get_available_languages(self) -> list[str]:
defaults = ['ru', 'en', 'ua', 'zh', 'fa']
+6 -11
View File
@@ -40,23 +40,18 @@ def calc_device_limit_on_tariff_switch(
new_tariff_device_limit: int | None,
max_device_limit: int | None = None,
) -> int:
"""Calculate device_limit preserving extra purchased devices when switching tariffs.
"""Calculate device_limit when switching tariffs.
Extra devices = current_device_limit - old_tariff_device_limit (clamped to 0).
Result = new_tariff_device_limit + extra_devices, capped at max_device_limit.
Resets to new tariff base device limit previously purchased
extra devices are NOT carried over. Capped at max_device_limit.
"""
old_base = old_tariff_device_limit if old_tariff_device_limit is not None else 0
current = current_device_limit if current_device_limit is not None else old_base
extra = max(0, current - old_base)
new_base = new_tariff_device_limit if new_tariff_device_limit is not None else 1
total = new_base + extra
effective_max = max_device_limit or (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
if effective_max and total > effective_max:
total = effective_max
if effective_max and new_base > effective_max:
new_base = effective_max
return total
return new_base
def is_active_paid_subscription(subscription: Subscription | None) -> bool:
+3 -3
View File
@@ -108,7 +108,7 @@ async def create_transaction(
await maybe_assign_promo_group_by_total_spent(db, user_id)
except Exception as exc:
logger.debug('Не удалось проверить автовыдачу промогруппы для пользователя', user_id=user_id, exc=exc)
logger.warning('Не удалось проверить автовыдачу промогруппы для пользователя', user_id=user_id, exc=exc)
if type == TransactionType.SUBSCRIPTION_PAYMENT and is_completed:
try:
from app.services.referral_contest_service import referral_contest_service
@@ -168,7 +168,7 @@ async def emit_transaction_side_effects(
await maybe_assign_promo_group_by_total_spent(db, user_id)
except Exception as exc:
logger.debug('Не удалось проверить автовыдачу промогруппы для пользователя', user_id=user_id, exc=exc)
logger.warning('Не удалось проверить автовыдачу промогруппы для пользователя', user_id=user_id, exc=exc)
if type == TransactionType.SUBSCRIPTION_PAYMENT and is_completed:
try:
@@ -253,7 +253,7 @@ async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Tr
await maybe_assign_promo_group_by_total_spent(db, transaction.user_id)
except Exception as exc:
logger.debug(
logger.warning(
'Не удалось проверить автовыдачу промогруппы для пользователя', user_id=transaction.user_id, exc=exc
)
+56 -47
View File
@@ -3,7 +3,7 @@
from datetime import UTC, datetime
import structlog
from sqlalchemy import and_, desc, select
from sqlalchemy import and_, desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -24,7 +24,7 @@ async def _sync_user_primary_promo_group(
select(UserPromoGroup.promo_group_id)
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
.where(UserPromoGroup.user_id == user_id)
.order_by(desc(PromoGroup.priority), PromoGroup.id)
.order_by(desc(PromoGroup.priority), desc(PromoGroup.id))
)
first = result.first()
@@ -53,7 +53,12 @@ async def sync_user_primary_promo_group(
async def add_user_to_promo_group(
db: AsyncSession, user_id: int, promo_group_id: int, assigned_by: str = 'admin'
db: AsyncSession,
user_id: int,
promo_group_id: int,
assigned_by: str = 'admin',
*,
commit: bool = True,
) -> UserPromoGroup | None:
"""
Добавляет пользователю промогруппу.
@@ -63,6 +68,7 @@ async def add_user_to_promo_group(
user_id: ID пользователя
promo_group_id: ID промогруппы
assigned_by: Кто назначил ('admin', 'system', 'auto', 'promocode')
commit: Коммитить транзакцию (False для батчевых операций)
Returns:
UserPromoGroup или None если уже существует
@@ -85,8 +91,9 @@ async def add_user_to_promo_group(
await _sync_user_primary_promo_group(db, user_id)
await db.commit()
await db.refresh(user_promo_group)
if commit:
await db.commit()
await db.refresh(user_promo_group)
logger.info(
'Пользователю добавлена промогруппа',
@@ -98,11 +105,19 @@ async def add_user_to_promo_group(
except Exception as error:
logger.error('Ошибка добавления промогруппы пользователю', error=error)
await db.rollback()
return None
if commit:
await db.rollback()
return None
raise
async def remove_user_from_promo_group(db: AsyncSession, user_id: int, promo_group_id: int) -> bool:
async def remove_user_from_promo_group(
db: AsyncSession,
user_id: int,
promo_group_id: int,
*,
commit: bool = True,
) -> bool:
"""
Удаляет промогруппу у пользователя.
@@ -110,6 +125,7 @@ async def remove_user_from_promo_group(db: AsyncSession, user_id: int, promo_gro
db: Сессия БД
user_id: ID пользователя
promo_group_id: ID промогруппы
commit: Коммитить транзакцию (False для батчевых операций)
Returns:
True если удалено, False если связи не было
@@ -133,15 +149,18 @@ async def remove_user_from_promo_group(db: AsyncSession, user_id: int, promo_gro
await _sync_user_primary_promo_group(db, user_id)
await db.commit()
if commit:
await db.commit()
logger.info('У пользователя удалена промогруппа', user_id=user_id, promo_group_id=promo_group_id)
return True
except Exception as error:
logger.error('Ошибка удаления промогруппы у пользователя', error=error)
await db.rollback()
return False
if commit:
await db.rollback()
return False
raise
async def get_user_promo_groups(db: AsyncSession, user_id: int) -> list[UserPromoGroup]:
@@ -155,19 +174,14 @@ async def get_user_promo_groups(db: AsyncSession, user_id: int) -> list[UserProm
Returns:
Список UserPromoGroup с загруженными PromoGroup, отсортированный по приоритету DESC
"""
try:
result = await db.execute(
select(UserPromoGroup)
.options(selectinload(UserPromoGroup.promo_group))
.where(UserPromoGroup.user_id == user_id)
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
.order_by(desc(PromoGroup.priority), PromoGroup.id)
)
return list(result.scalars().all())
except Exception as error:
logger.error('Ошибка получения промогрупп пользователя', user_id=user_id, error=error)
return []
result = await db.execute(
select(UserPromoGroup)
.options(selectinload(UserPromoGroup.promo_group))
.where(UserPromoGroup.user_id == user_id)
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
.order_by(desc(PromoGroup.priority), desc(PromoGroup.id))
)
return list(result.scalars().all())
async def get_primary_user_promo_group(db: AsyncSession, user_id: int) -> PromoGroup | None:
@@ -181,19 +195,14 @@ async def get_primary_user_promo_group(db: AsyncSession, user_id: int) -> PromoG
Returns:
PromoGroup с максимальным приоритетом или None
"""
try:
user_promo_groups = await get_user_promo_groups(db, user_id)
user_promo_groups = await get_user_promo_groups(db, user_id)
if not user_promo_groups:
return None
# Первая в списке имеет максимальный приоритет (список уже отсортирован)
return user_promo_groups[0].promo_group or None
except Exception as error:
logger.error('Ошибка получения primary промогруппы пользователя', user_id=user_id, error=error)
if not user_promo_groups:
return None
# Первая в списке имеет максимальный приоритет (список уже отсортирован)
return user_promo_groups[0].promo_group or None
async def has_user_promo_group(db: AsyncSession, user_id: int, promo_group_id: int) -> bool:
"""
@@ -207,17 +216,12 @@ async def has_user_promo_group(db: AsyncSession, user_id: int, promo_group_id: i
Returns:
True если пользователь уже имеет эту промогруппу
"""
try:
result = await db.execute(
select(UserPromoGroup).where(
and_(UserPromoGroup.user_id == user_id, UserPromoGroup.promo_group_id == promo_group_id)
)
result = await db.execute(
select(UserPromoGroup).where(
and_(UserPromoGroup.user_id == user_id, UserPromoGroup.promo_group_id == promo_group_id)
)
return result.scalar_one_or_none() is not None
except Exception as error:
logger.error('Ошибка проверки промогруппы пользователя', error=error)
return False
)
return result.scalar_one_or_none() is not None
async def count_user_promo_groups(db: AsyncSession, user_id: int) -> int:
@@ -232,8 +236,10 @@ async def count_user_promo_groups(db: AsyncSession, user_id: int) -> int:
Количество промогрупп
"""
try:
result = await db.execute(select(UserPromoGroup).where(UserPromoGroup.user_id == user_id))
return len(list(result.scalars().all()))
result = await db.execute(
select(func.count()).select_from(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
)
return result.scalar_one()
except Exception as error:
logger.error('Ошибка подсчета промогрупп пользователя', error=error)
@@ -257,15 +263,18 @@ async def replace_user_promo_groups(
"""
try:
# Удаляем все текущие промогруппы
await db.execute(select(UserPromoGroup).where(UserPromoGroup.user_id == user_id))
result = await db.execute(select(UserPromoGroup).where(UserPromoGroup.user_id == user_id))
for upg in result.scalars().all():
await db.delete(upg)
await db.flush()
# Добавляем новые
for promo_group_id in promo_group_ids:
user_promo_group = UserPromoGroup(user_id=user_id, promo_group_id=promo_group_id, assigned_by=assigned_by)
db.add(user_promo_group)
await db.flush()
await _sync_user_primary_promo_group(db, user_id)
await db.commit()
logger.info('Промогруппы пользователя заменены на', user_id=user_id, promo_group_ids=promo_group_ids)
+6 -1
View File
@@ -1574,6 +1574,7 @@ class Transaction(Base):
Index('ix_transactions_type_created_completed', 'type', 'created_at', 'is_completed'),
Index('ix_transactions_user_created', 'user_id', 'created_at'),
Index('ix_transactions_type_method_created', 'type', 'payment_method', 'created_at'),
Index('ix_transactions_user_type_completed_amount', 'user_id', 'type', 'is_completed', 'amount_kopeks'),
)
id = Column(Integer, primary_key=True, index=True)
@@ -2490,7 +2491,10 @@ class AdvertisingCampaign(Base):
class AdvertisingCampaignRegistration(Base):
__tablename__ = 'advertising_campaign_registrations'
__table_args__ = (UniqueConstraint('campaign_id', 'user_id', name='uq_campaign_user'),)
__table_args__ = (
UniqueConstraint('campaign_id', 'user_id', name='uq_campaign_user'),
Index('ix_campaign_reg_user_created', 'user_id', 'created_at'),
)
id = Column(Integer, primary_key=True, index=True)
campaign_id = Column(Integer, ForeignKey('advertising_campaigns.id', ondelete='CASCADE'), nullable=False)
@@ -3283,6 +3287,7 @@ class GuestPurchase(Base):
cabinet_password = Column(Text, nullable=True)
auto_login_token = Column(Text, nullable=True)
recipient_warning = Column(String(50), nullable=True)
retry_count = Column(Integer, nullable=False, default=0, server_default='0')
landing = relationship('LandingPage', back_populates='guest_purchases', lazy='selectin')
tariff = relationship('Tariff', lazy='selectin')
+35 -11
View File
@@ -1,5 +1,6 @@
import hashlib
import hmac
import json
from typing import Any
import aiohttp
@@ -15,7 +16,6 @@ class CryptoBotService:
def __init__(self):
self.api_token = settings.CRYPTOBOT_API_TOKEN
self.base_url = settings.get_cryptobot_base_url()
self.webhook_secret = settings.CRYPTOBOT_WEBHOOK_SECRET
async def _make_request(
self,
@@ -122,22 +122,46 @@ class CryptoBotService:
return await self._make_request('GET', 'getExchangeRates')
def verify_webhook_signature(self, body: str, signature: str) -> bool:
if not self.webhook_secret:
logger.warning('CryptoBot webhook secret не настроен')
# По документации CryptoBot, ключ ВСЕГДА SHA256 от API токена
token = self.api_token
if not token:
logger.warning('CryptoBot API token не настроен, пропуск проверки подписи')
return True
try:
secret_hash = hashlib.sha256(self.webhook_secret.encode()).digest()
expected_signature = hmac.new(secret_hash, body.encode(), hashlib.sha256).hexdigest()
secret_hash = hashlib.sha256(token.encode()).digest()
is_valid = hmac.compare_digest(signature, expected_signature)
# 1. Raw body — CryptoBot шлёт compact JSON
expected = hmac.new(secret_hash, body.encode('utf-8'), hashlib.sha256).hexdigest()
if hmac.compare_digest(signature, expected):
logger.info('CryptoBot webhook подпись валидна (raw body)')
return True
if is_valid:
logger.info('✅ CryptoBot webhook подпись валидна')
else:
logger.error('❌ Неверная подпись CryptoBot webhook')
# 2. Fallback: re-serialize compact JSON
parsed = json.loads(body)
check_string = json.dumps(parsed, separators=(',', ':'), ensure_ascii=False)
expected_reserialized = hmac.new(secret_hash, check_string.encode('utf-8'), hashlib.sha256).hexdigest()
if hmac.compare_digest(signature, expected_reserialized):
logger.info('CryptoBot webhook подпись валидна (re-serialized)')
return True
return is_valid
# 3. Fallback: ensure_ascii=True
check_string_ascii = json.dumps(parsed, separators=(',', ':'), ensure_ascii=True)
expected_ascii = hmac.new(secret_hash, check_string_ascii.encode('utf-8'), hashlib.sha256).hexdigest()
if hmac.compare_digest(signature, expected_ascii):
logger.info('CryptoBot webhook подпись валидна (ascii-escaped)')
return True
logger.error(
'Неверная подпись CryptoBot webhook',
received_signature=signature,
expected_raw=expected,
expected_reserialized=expected_reserialized,
body_length=len(body),
token_length=len(token),
token_prefix=token[:4] + '...',
)
return False
except Exception as e:
logger.error('Ошибка проверки подписи CryptoBot webhook', error=e)
+4 -1
View File
@@ -377,7 +377,10 @@ class WebhookServer:
signature = request.headers.get('Crypto-Pay-API-Signature')
logger.info('CryptoBot Signature', signature=signature)
if signature and settings.CRYPTOBOT_WEBHOOK_SECRET:
if settings.CRYPTOBOT_API_TOKEN:
if not signature:
logger.error('CryptoBot webhook без подписи')
return web.json_response({'status': 'error', 'reason': 'missing_signature'}, status=401)
from app.external.cryptobot import CryptoBotService
cryptobot_service = CryptoBotService()
+3 -123
View File
@@ -129,9 +129,9 @@ async def process_cloudpayments_payment_amount(
state: FSMContext,
):
"""
Process payment amount directly (called from quick_amount handlers).
Process payment amount directly.
Similar to process_heleket_payment_amount and other payment handlers.
Similar to other payment amount handlers.
"""
texts = get_texts(db_user.language)
@@ -197,7 +197,7 @@ async def start_cloudpayments_payment(
"""
Start CloudPayments payment flow.
Shows amount input prompt or quick amount buttons.
Shows amount input prompt.
"""
texts = get_texts(db_user.language)
@@ -375,123 +375,3 @@ async def process_cloudpayments_amount(
)
logger.info('CloudPayments payment created: user amount=₽', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
@error_handler
async def handle_cloudpayments_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Handle quick amount selection for CloudPayments.
Called when user clicks a predefined amount button.
"""
texts = get_texts(db_user.language)
if not settings.is_cloudpayments_enabled():
await callback.answer(
texts.t('CLOUDPAYMENTS_NOT_AVAILABLE', 'CloudPayments временно недоступен'),
show_alert=True,
)
return
# Extract amount from callback data: topup_amount|cloudpayments|{amount_kopeks}
try:
parts = callback.data.split('|')
if len(parts) >= 3:
amount_kopeks = int(parts[2])
else:
await callback.answer('Invalid callback data', show_alert=True)
return
except (ValueError, IndexError):
await callback.answer('Invalid amount', show_alert=True)
return
amount_rub = amount_kopeks / 100
# Validate amount
if amount_kopeks < settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS:
await callback.answer(
texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'),
show_alert=True,
)
return
if amount_kopeks > settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS:
await callback.answer(
texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'),
show_alert=True,
)
return
await callback.answer()
# Create payment
payment_service = PaymentService()
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
service_name=settings.PAYMENT_SERVICE_NAME,
description=settings.CLOUDPAYMENTS_DESCRIPTION,
)
result = await payment_service.create_cloudpayments_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=description,
telegram_id=db_user.telegram_id,
language=db_user.language,
)
if not result:
await callback.message.edit_text(
texts.t(
'PAYMENT_CREATE_ERROR',
'Не удалось создать платёж. Попробуйте позже.',
),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
payment_url = result.get('payment_url')
# Create keyboard with payment button
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t(
'PAY_BUTTON',
'💳 Оплатить {amount}',
).format(amount=f'{amount_rub:.0f}'),
url=payment_url,
)
],
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '◀️ Назад'),
callback_data='menu_balance',
)
],
]
)
await callback.message.edit_text(
texts.t(
'CLOUDPAYMENTS_PAYMENT_CREATED',
'💳 <b>Оплата банковской картой</b>\n\n'
'Сумма: <b>{amount}₽</b>\n\n'
'Нажмите кнопку ниже для оплаты.\n'
'После успешной оплаты баланс будет пополнен автоматически.',
).format(amount=f'{amount_rub:.2f}'),
reply_markup=keyboard,
parse_mode='HTML',
)
logger.info(
'CloudPayments payment created (quick): user amount=₽', telegram_id=db_user.telegram_id, amount_rub=amount_rub
)
+9 -32
View File
@@ -53,41 +53,18 @@ async def start_cryptobot_payment(callback: types.CallbackQuery, db_user: User,
available_assets = settings.get_cryptobot_assets()
assets_text = ', '.join(available_assets)
# Формируем текст сообщения в зависимости от настройки
if settings.is_quick_amount_buttons_enabled():
message_text = (
f'🪙 <b>Пополнение криптовалютой</b>\n\n'
f'Выберите сумму пополнения или введите вручную сумму '
f'от 100 до 100,000 ₽:\n\n'
f'💰 Доступные активы: {assets_text}\n'
f'⚡ Мгновенное зачисление на баланс\n'
f'🔒 Безопасная оплата через CryptoBot\n\n'
f'{rate_text}\n'
f'Сумма будет автоматически конвертирована в USD для оплаты.'
)
else:
message_text = (
f'🪙 <b>Пополнение криптовалютой</b>\n\n'
f'Введите сумму для пополнения от 100 до 100,000 ₽:\n\n'
f'💰 Доступные активы: {assets_text}\n'
f'⚡ Мгновенное зачисление на баланс\n'
f'🔒 Безопасная оплата через CryptoBot\n\n'
f'{rate_text}\n'
f'Сумма будет автоматически конвертирована в USD для оплаты.'
)
message_text = (
f'🪙 <b>Пополнение криптовалютой</b>\n\n'
f'Введите сумму для пополнения от 100 до 100,000 ₽:\n\n'
f'💰 Доступные активы: {assets_text}\n'
f'⚡ Мгновенное зачисление на баланс\n'
f'🔒 Безопасная оплата через CryptoBot\n\n'
f'{rate_text}\n'
f'Сумма будет автоматически конвертирована в USD для оплаты.'
)
# Создаем клавиатуру
keyboard = get_back_keyboard(db_user.language)
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(message_text, reply_markup=keyboard, parse_mode='HTML')
await state.set_state(BalanceStates.waiting_for_amount)
+1 -125
View File
@@ -154,7 +154,7 @@ async def process_freekassa_payment_amount(
payment_method: str | None = None,
):
"""
Process payment amount directly (called from quick_amount handlers).
Process payment amount directly.
payment_method: 'freekassa', 'freekassa_sbp', 'freekassa_card'
"""
texts = get_texts(db_user.language)
@@ -370,127 +370,3 @@ async def process_freekassa_custom_amount(
state=state,
payment_method=data.get('payment_method'),
)
async def _process_freekassa_quick_amount_impl(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
payment_method: str,
):
"""
Process quick amount selection for Freekassa payment.
Called when user clicks a predefined amount button.
payment_method: 'freekassa', 'freekassa_sbp', 'freekassa_card'
"""
texts = get_texts(db_user.language)
if not settings.is_freekassa_enabled():
await callback.answer(
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
show_alert=True,
)
return
if payment_method == 'freekassa_sbp' and not settings.is_freekassa_sbp_enabled():
await callback.answer(
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
show_alert=True,
)
return
if payment_method == 'freekassa_card' and not settings.is_freekassa_card_enabled():
await callback.answer(
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
show_alert=True,
)
return
# Extract amount from callback data: topup_amount|{method}|{amount_kopeks}
try:
parts = callback.data.split('|')
if len(parts) >= 3:
amount_kopeks = int(parts[2])
else:
await callback.answer('Invalid callback data', show_alert=True)
return
except (ValueError, IndexError):
await callback.answer('Invalid amount', show_alert=True)
return
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
await callback.message.edit_text(
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
)
return
# Validate amount
min_amount = settings.FREEKASSA_MIN_AMOUNT_KOPEKS
max_amount = settings.FREEKASSA_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await callback.answer(
texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'),
show_alert=True,
)
return
if amount_kopeks > max_amount:
await callback.answer(
texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'),
show_alert=True,
)
return
await callback.answer()
await state.clear()
await _create_freekassa_payment_and_respond(
message_or_callback=callback.message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=True,
payment_method=payment_method,
)
@error_handler
async def process_freekassa_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa')
@error_handler
async def process_freekassa_sbp_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa_sbp')
@error_handler
async def process_freekassa_card_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa_card')
-7
View File
@@ -72,13 +72,6 @@ async def start_heleket_payment(
keyboard = get_back_keyboard(db_user.language)
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_buttons = await get_quick_amount_buttons(db_user.language, db_user)
if quick_buttons:
keyboard.inline_keyboard = quick_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
'\n'.join(filter(None, message_lines)),
reply_markup=keyboard,
+1 -82
View File
@@ -168,7 +168,7 @@ async def process_kassa_ai_payment_amount(
state: FSMContext,
payment_method: str = 'kassa_ai',
):
"""Process payment amount directly (called from custom_amount and quick_amount handlers)."""
"""Process payment amount directly (called from custom_amount handlers)."""
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
@@ -275,54 +275,6 @@ async def _start_kassa_ai_sub_topup(
)
async def _process_kassa_ai_sub_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
payment_method: str,
):
"""Generic quick amount handler for any KassaAI sub-method."""
cfg = _KASSA_AI_METHOD_CONFIG[payment_method]
texts = get_texts(db_user.language)
if not cfg['is_enabled']():
await callback.answer(texts.t('KASSA_AI_NOT_AVAILABLE', cfg['unavailable_text']), show_alert=True)
return
try:
parts = callback.data.split('|')
amount_kopeks = int(parts[2]) if len(parts) >= 3 else None
if amount_kopeks is None:
raise ValueError
except (ValueError, IndexError):
await callback.answer('Invalid amount', show_alert=True)
return
if await _check_topup_restriction(callback, db_user):
return
min_amount = settings.KASSA_AI_MIN_AMOUNT_KOPEKS
max_amount = settings.KASSA_AI_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await callback.answer(texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'), show_alert=True)
return
if amount_kopeks > max_amount:
await callback.answer(texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'), show_alert=True)
return
await callback.answer()
await state.clear()
await _create_kassa_ai_payment_and_respond(
message_or_callback=callback.message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=True,
payment_method=payment_method,
)
# --- Public handler functions (registered in main.py) ---
@@ -376,17 +328,6 @@ async def process_kassa_ai_custom_amount(
)
@error_handler
async def process_kassa_ai_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Process quick amount selection for KassaAI payment."""
await _process_kassa_ai_sub_quick_amount(callback, db_user, db, state, 'kassa_ai')
@error_handler
async def start_kassa_ai_sbp_topup(
callback: types.CallbackQuery,
@@ -398,17 +339,6 @@ async def start_kassa_ai_sbp_topup(
await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai_sbp')
@error_handler
async def process_kassa_ai_sbp_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Process quick amount for KassaAI SBP."""
await _process_kassa_ai_sub_quick_amount(callback, db_user, db, state, 'kassa_ai_sbp')
@error_handler
async def start_kassa_ai_card_topup(
callback: types.CallbackQuery,
@@ -418,14 +348,3 @@ async def start_kassa_ai_card_topup(
):
"""Start KassaAI Card top-up process."""
await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai_card')
@error_handler
async def process_kassa_ai_card_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Process quick amount for KassaAI Card."""
await _process_kassa_ai_sub_quick_amount(callback, db_user, db, state, 'kassa_ai_card')
+17 -149
View File
@@ -157,102 +157,6 @@ async def route_payment_by_method(
return False
async def get_quick_amount_buttons(language: str, user: User) -> list:
"""
Generate quick amount buttons with user-specific pricing and discounts.
Uses PricingEngine as the single source of truth for all price calculations,
including base period price, devices, servers, traffic, and per-category discounts.
Args:
language: User's language for formatting
user: User object to calculate personalized discounts
Returns:
List of button rows for inline keyboard
"""
if not settings.is_quick_amount_buttons_enabled():
return []
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.database import AsyncSessionLocal
from app.services.pricing_engine import pricing_engine
texts = get_texts(language)
buttons = []
async with AsyncSessionLocal() as db:
subscription = await get_subscription_by_user_id(db, user.id)
tariff = None
tariff_periods = None
if settings.is_tariffs_mode() and subscription and subscription.tariff_id:
tariff = subscription.tariff
if tariff and tariff.period_prices:
tariff_periods = sorted(int(k) for k in tariff.period_prices.keys())
if tariff_periods:
periods = tariff_periods[:6]
else:
periods = settings.get_available_subscription_periods()[:6]
for period in periods:
try:
if tariff and tariff_periods and period in tariff_periods:
result = await pricing_engine.calculate_tariff_purchase_price(
tariff,
period,
device_limit=subscription.device_limit if subscription else None,
user=user,
)
elif subscription:
result = await pricing_engine.calculate_renewal_price(db, subscription, period, user=user)
else:
result = await pricing_engine.calculate_classic_new_subscription_price(
db,
period,
[],
0,
settings.DEFAULT_DEVICE_LIMIT,
user=user,
)
total_price = result.final_total
original_total = result.original_total
if total_price <= 0:
continue
callback_data = f'quick_amount_{total_price}'
period_label = f'{period} дней'
has_discount = original_total > total_price and original_total > 0
if has_discount:
discount_pct = round((original_total - total_price) * 100 / original_total)
if discount_pct > 0:
button_text = (
f'{texts.format_price(original_total)}'
f'{texts.format_price(total_price)} '
f'(-{discount_pct}%) • {period_label}'
)
else:
button_text = f'{texts.format_price(total_price)}{period_label}'
else:
button_text = f'{texts.format_price(total_price)}{period_label}'
buttons.append(types.InlineKeyboardButton(text=button_text, callback_data=callback_data))
except Exception:
logger.warning('Failed to calculate price for period', period=period)
continue
keyboard_rows = []
for i in range(0, len(buttons), 2):
keyboard_rows.append(buttons[i : i + 2])
return keyboard_rows
@error_handler
async def show_balance_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
# Проверяем, доступно ли сообщение
@@ -379,9 +283,22 @@ async def show_payment_methods(callback: types.CallbackQuery, db_user: User, db:
payment_text = get_payment_methods_text(db_user.language)
# Проверяем сохранённую корзину для автоподстановки суммы пополнения
amount_kopeks = 0
try:
from app.services.user_cart_service import user_cart_service
cart_data = await user_cart_service.get_user_cart(db_user.id)
if cart_data and cart_data.get('saved_cart'):
missing = cart_data.get('missing_amount', 0)
if missing > 0:
amount_kopeks = missing
except Exception:
pass
full_text = payment_text
keyboard = get_payment_methods_keyboard(0, db_user.language)
keyboard = get_payment_methods_keyboard(amount_kopeks, db_user.language)
# Если сообщение недоступно, отправляем новое
if isinstance(callback.message, InaccessibleMessage):
@@ -627,37 +544,6 @@ async def handle_sbp_payment(callback: types.CallbackQuery, db: AsyncSession):
await callback.answer('❌ Ошибка обработки платежа', show_alert=True)
@error_handler
async def handle_quick_amount_selection(callback: types.CallbackQuery, db_user: User, state: FSMContext):
"""
Обработчик выбора суммы через кнопки быстрого выбора
"""
# Проверяем, что пользователь в правильном состоянии FSM
current_state = await state.get_state()
if current_state != BalanceStates.waiting_for_amount:
await callback.answer('❌ Сначала выберите способ оплаты', show_alert=True)
return
# Извлекаем сумму из callback_data
try:
amount_kopeks = int(callback.data.split('_')[-1])
# Получаем метод оплаты из состояния
data = await state.get_data()
payment_method = data.get('payment_method', 'yookassa')
# Роутим платеж на соответствующий обработчик
if not await route_payment_by_method(callback.message, db_user, amount_kopeks, state, payment_method):
await callback.answer('❌ Неизвестный способ оплаты', show_alert=True)
return
except ValueError:
await callback.answer('❌ Ошибка обработки суммы', show_alert=True)
except Exception as e:
logger.error('Ошибка обработки быстрого выбора суммы', error=e)
await callback.answer('❌ Ошибка обработки запроса', show_alert=True)
@error_handler
async def handle_topup_amount_callback(
callback: types.CallbackQuery,
@@ -784,52 +670,37 @@ def register_balance_handlers(dp: Dispatcher):
dp.callback_query.register(start_heleket_payment, F.data == 'topup_heleket')
dp.callback_query.register(check_heleket_payment_status, F.data.startswith('check_heleket_'))
from .cloudpayments import handle_cloudpayments_quick_amount, start_cloudpayments_payment
from .cloudpayments import start_cloudpayments_payment
dp.callback_query.register(start_cloudpayments_payment, F.data == 'topup_cloudpayments')
dp.callback_query.register(handle_cloudpayments_quick_amount, F.data.startswith('topup_amount|cloudpayments|'))
from .freekassa import (
process_freekassa_card_quick_amount,
process_freekassa_quick_amount,
process_freekassa_sbp_quick_amount,
start_freekassa_card_topup,
start_freekassa_sbp_topup,
start_freekassa_topup,
)
dp.callback_query.register(start_freekassa_topup, F.data == 'topup_freekassa')
dp.callback_query.register(process_freekassa_quick_amount, F.data.startswith('topup_amount|freekassa|'))
dp.callback_query.register(start_freekassa_sbp_topup, F.data == 'topup_freekassa_sbp')
dp.callback_query.register(process_freekassa_sbp_quick_amount, F.data.startswith('topup_amount|freekassa_sbp|'))
dp.callback_query.register(start_freekassa_card_topup, F.data == 'topup_freekassa_card')
dp.callback_query.register(process_freekassa_card_quick_amount, F.data.startswith('topup_amount|freekassa_card|'))
from .kassa_ai import (
process_kassa_ai_card_quick_amount,
process_kassa_ai_quick_amount,
process_kassa_ai_sbp_quick_amount,
start_kassa_ai_card_topup,
start_kassa_ai_sbp_topup,
start_kassa_ai_topup,
)
dp.callback_query.register(start_kassa_ai_topup, F.data == 'topup_kassa_ai')
dp.callback_query.register(process_kassa_ai_quick_amount, F.data.startswith('topup_amount|kassa_ai|'))
dp.callback_query.register(start_kassa_ai_sbp_topup, F.data == 'topup_kassa_ai_sbp')
dp.callback_query.register(process_kassa_ai_sbp_quick_amount, F.data.startswith('topup_amount|kassa_ai_sbp|'))
dp.callback_query.register(start_kassa_ai_card_topup, F.data == 'topup_kassa_ai_card')
dp.callback_query.register(process_kassa_ai_card_quick_amount, F.data.startswith('topup_amount|kassa_ai_card|'))
from .riopay import process_riopay_quick_amount, start_riopay_topup
from .riopay import start_riopay_topup
dp.callback_query.register(start_riopay_topup, F.data == 'topup_riopay')
dp.callback_query.register(process_riopay_quick_amount, F.data.startswith('topup_amount|riopay|'))
from .severpay import process_severpay_quick_amount, start_severpay_topup
from .severpay import start_severpay_topup
dp.callback_query.register(start_severpay_topup, F.data == 'topup_severpay')
dp.callback_query.register(process_severpay_quick_amount, F.data.startswith('topup_amount|severpay|'))
from .mulenpay import check_mulenpay_payment_status
@@ -849,9 +720,6 @@ def register_balance_handlers(dp: Dispatcher):
dp.callback_query.register(handle_payment_methods_unavailable, F.data == 'payment_methods_unavailable')
# Регистрируем обработчик для кнопок быстрого выбора суммы
dp.callback_query.register(handle_quick_amount_selection, F.data.startswith('quick_amount_'))
dp.callback_query.register(handle_topup_amount_callback, F.data.startswith('topup_amount|'))
dp.callback_query.register(handle_saved_cards_list, F.data == 'saved_cards_list')
-7
View File
@@ -65,13 +65,6 @@ async def start_mulenpay_payment(
keyboard = get_back_keyboard(db_user.language)
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
-7
View File
@@ -303,13 +303,6 @@ async def start_pal24_payment(
keyboard = get_back_keyboard(db_user.language)
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
-7
View File
@@ -71,13 +71,6 @@ async def _prompt_amount(
keyboard = get_back_keyboard(db_user.language)
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await message.edit_text(
prompt_template.format(
method_name=method_name,
+1 -73
View File
@@ -136,7 +136,7 @@ async def process_riopay_payment_amount(
state: FSMContext,
):
"""
Process payment amount directly (called from quick_amount handlers).
Process payment amount directly.
"""
texts = get_texts(db_user.language)
@@ -282,75 +282,3 @@ async def process_riopay_custom_amount(
amount_kopeks=amount_kopeks,
state=state,
)
@error_handler
async def process_riopay_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Process quick amount selection for RioPay payment.
Called when user clicks a predefined amount button.
"""
texts = get_texts(db_user.language)
if not settings.is_riopay_enabled():
await callback.answer(
texts.t('RIOPAY_NOT_AVAILABLE', 'RioPay временно недоступен'),
show_alert=True,
)
return
# Extract amount from callback data: topup_amount|riopay|{amount_kopeks}
try:
parts = callback.data.split('|')
if len(parts) >= 3:
amount_kopeks = int(parts[2])
else:
await callback.answer('Invalid callback data', show_alert=True)
return
except (ValueError, IndexError):
await callback.answer('Invalid amount', show_alert=True)
return
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
await callback.message.edit_text(
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
return
# Validate amount
min_amount = settings.RIOPAY_MIN_AMOUNT_KOPEKS
max_amount = settings.RIOPAY_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await callback.answer(
texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'),
show_alert=True,
)
return
if amount_kopeks > max_amount:
await callback.answer(
texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'),
show_alert=True,
)
return
await callback.answer()
await state.clear()
await _create_riopay_payment_and_respond(
message_or_callback=callback.message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=True,
)
+1 -73
View File
@@ -136,7 +136,7 @@ async def process_severpay_payment_amount(
state: FSMContext,
):
"""
Process payment amount directly (called from quick_amount handlers).
Process payment amount directly.
"""
texts = get_texts(db_user.language)
@@ -243,75 +243,3 @@ async def start_severpay_topup(
parse_mode='HTML',
reply_markup=keyboard,
)
@error_handler
async def process_severpay_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Process quick amount selection for SeverPay payment.
Called when user clicks a predefined amount button.
"""
texts = get_texts(db_user.language)
if not settings.is_severpay_enabled():
await callback.answer(
texts.t('SEVERPAY_NOT_AVAILABLE', 'SeverPay временно недоступен'),
show_alert=True,
)
return
# Extract amount from callback data: topup_amount|severpay|{amount_kopeks}
try:
parts = callback.data.split('|')
if len(parts) >= 3:
amount_kopeks = int(parts[2])
else:
await callback.answer('Invalid callback data', show_alert=True)
return
except (ValueError, IndexError):
await callback.answer('Invalid amount', show_alert=True)
return
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
await callback.message.edit_text(
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
return
# Validate amount
min_amount = settings.SEVERPAY_MIN_AMOUNT_KOPEKS
max_amount = settings.SEVERPAY_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await callback.answer(
texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'),
show_alert=True,
)
return
if amount_kopeks > max_amount:
await callback.answer(
texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'),
show_alert=True,
)
return
await callback.answer()
await state.clear()
await _create_severpay_payment_and_respond(
message_or_callback=callback.message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=True,
)
+1 -15
View File
@@ -40,24 +40,10 @@ async def start_stars_payment(callback: types.CallbackQuery, db_user: User, stat
await callback.answer()
return
# Формируем текст сообщения в зависимости от настройки
if settings.is_quick_amount_buttons_enabled():
message_text = '⭐ <b>Пополнение через Telegram Stars</b>\n\nВыберите сумму пополнения или введите вручную:'
else:
message_text = texts.TOP_UP_AMOUNT
message_text = texts.TOP_UP_AMOUNT
# Создаем клавиатуру
keyboard = get_back_keyboard(db_user.language)
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(message_text, reply_markup=keyboard)
await state.update_data(
-7
View File
@@ -61,13 +61,6 @@ async def start_wata_payment(
keyboard = get_back_keyboard(db_user.language)
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
+8 -44
View File
@@ -46,31 +46,13 @@ async def start_yookassa_payment(callback: types.CallbackQuery, db_user: User, s
min_amount_rub = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
# Формируем текст сообщения в зависимости от настройки
if settings.is_quick_amount_buttons_enabled():
message_text = (
f'💳 <b>Оплата банковской картой</b>\n\n'
f'Выберите сумму пополнения или введите вручную сумму '
f'от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
)
else:
message_text = (
f'💳 <b>Оплата банковской картой</b>\n\n'
f'Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
)
message_text = (
f'💳 <b>Оплата банковской картой</b>\n\n'
f'Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
)
# Создаем клавиатуру
keyboard = get_back_keyboard(db_user.language)
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(message_text, reply_markup=keyboard, parse_mode='HTML')
await state.set_state(BalanceStates.waiting_for_amount)
@@ -110,31 +92,13 @@ async def start_yookassa_sbp_payment(callback: types.CallbackQuery, db_user: Use
min_amount_rub = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
# Формируем текст сообщения в зависимости от настройки
if settings.is_quick_amount_buttons_enabled():
message_text = (
f'🏦 <b>Оплата через СБП</b>\n\n'
f'Выберите сумму пополнения или введите вручную сумму '
f'от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
)
else:
message_text = (
f'🏦 <b>Оплата через СБП</b>\n\n'
f'Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
)
message_text = (
f'🏦 <b>Оплата через СБП</b>\n\n'
f'Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
)
# Создаем клавиатуру
keyboard = get_back_keyboard(db_user.language)
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(message_text, reply_markup=keyboard, parse_mode='HTML')
await state.set_state(BalanceStates.waiting_for_amount)
+4 -2
View File
@@ -14,7 +14,7 @@ from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_referral_keyboard
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.admin_notification_service import AdminNotificationService, NotificationCategory
from app.services.referral_withdrawal_service import referral_withdrawal_service
from app.states import ReferralWithdrawalStates
from app.utils.photo_message import edit_or_answer_photo
@@ -825,7 +825,9 @@ async def confirm_withdrawal_request(callback: types.CallbackQuery, db_user: Use
try:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_admin_notification(admin_text, reply_markup=admin_keyboard)
await notification_service.send_admin_notification(
admin_text, reply_markup=admin_keyboard, category=NotificationCategory.PARTNERS
)
except Exception as e:
logger.error('Ошибка отправки уведомления админам о заявке на вывод', error=e)
-4
View File
@@ -354,10 +354,6 @@ async def _handle_guest_purchase_payment(
stars_amount=stars_amount,
purchase_token_prefix=purchase_token[:5],
)
elif result is False:
await message.answer(
'❌ Произошла ошибка при обработке подарочной подписки. Обратитесь в поддержку.',
)
else:
logger.error('try_fulfill_guest_purchase returned None for Stars gift', payload=payload)
await message.answer('❌ Ошибка обработки платежа. Обратитесь в поддержку.')
+6
View File
@@ -497,6 +497,12 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not subscription:
await callback.answer(
texts.t('NO_ACTIVE_SUBSCRIPTION', '⚠️ У вас нет активной подписки'),
show_alert=True,
)
return
current_devices = subscription.device_limit
# Проверяем тариф подписки
+3 -3
View File
@@ -1469,7 +1469,7 @@ async def confirm_daily_tariff_purchase(
try:
if existing_subscription:
# Обновляем существующую подписку на суточный тариф
# Сохраняем докупленные устройства при смене тарифа
# Сбрасываем лимит устройств на базу нового тарифа (докупленные не переносятся)
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
old_tariff = (
@@ -2697,7 +2697,7 @@ async def confirm_daily_tariff_switch(
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
# Обновляем подписку на суточный тариф
# Сохраняем докупленные устройства при смене тарифа
# Сбрасываем лимит устройств на базу нового тарифа (докупленные не переносятся)
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
old_tariff = await get_tariff_by_id(db, subscription.tariff_id) if subscription.tariff_id else None
@@ -3288,7 +3288,7 @@ async def confirm_instant_switch(
is_new_daily = getattr(new_tariff, 'is_daily', False)
# Обновляем подписку с новыми параметрами тарифа
# Сохраняем докупленные устройства при смене тарифа
# Сбрасываем лимит устройств на базу нового тарифа (докупленные не переносятся)
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
old_tariff = await get_tariff_by_id(db, subscription.tariff_id) if subscription.tariff_id else None
+4 -1
View File
@@ -219,7 +219,10 @@ async def send_error_to_admin_chat(
global _last_error_notification
chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None)
topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None)
# Используем топик для ошибок, если настроен, иначе общий
topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_ERRORS_TOPIC_ID', None) or getattr(
settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None
)
enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
if not enabled or not chat_id:
+86 -31
View File
@@ -1,5 +1,6 @@
import html
from datetime import UTC, datetime
from enum import StrEnum
from typing import Any
import structlog
@@ -26,6 +27,21 @@ from app.utils.message_patch import caption_exceeds_telegram_limit
from app.utils.timezone import format_local_datetime
class NotificationCategory(StrEnum):
"""Категории уведомлений для маршрутизации по топикам."""
PURCHASES = 'purchases' # Покупки подписок, покупки с лендинга
RENEWALS = 'renewals' # Продления
TRIALS = 'trials' # Триалы
BALANCE = 'balance' # Пополнение баланса
ADDONS = 'addons' # Докупка трафика/устройств/серверов
INFRASTRUCTURE = 'infrastructure' # Ноды, техработы, статус панели, вебхуки
ERRORS = 'errors' # Ошибки бота, краши
PROMO = 'promo' # Промокоды, кампании, промогруппы
PARTNERS = 'partners' # Партнёрки, выводы, админ-действия
TICKETS = 'tickets' # Тикеты (уже существует)
logger = structlog.get_logger(__name__)
@@ -37,6 +53,20 @@ class AdminNotificationService:
self.ticket_topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID', None)
self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
# Маппинг категорий на topic_id (None = fallback на self.topic_id)
self.category_topics: dict[NotificationCategory, int | None] = {
NotificationCategory.PURCHASES: getattr(settings, 'ADMIN_NOTIFICATIONS_PURCHASES_TOPIC_ID', None),
NotificationCategory.RENEWALS: getattr(settings, 'ADMIN_NOTIFICATIONS_RENEWALS_TOPIC_ID', None),
NotificationCategory.TRIALS: getattr(settings, 'ADMIN_NOTIFICATIONS_TRIALS_TOPIC_ID', None),
NotificationCategory.BALANCE: getattr(settings, 'ADMIN_NOTIFICATIONS_BALANCE_TOPIC_ID', None),
NotificationCategory.ADDONS: getattr(settings, 'ADMIN_NOTIFICATIONS_ADDONS_TOPIC_ID', None),
NotificationCategory.INFRASTRUCTURE: getattr(settings, 'ADMIN_NOTIFICATIONS_INFRASTRUCTURE_TOPIC_ID', None),
NotificationCategory.ERRORS: getattr(settings, 'ADMIN_NOTIFICATIONS_ERRORS_TOPIC_ID', None),
NotificationCategory.PROMO: getattr(settings, 'ADMIN_NOTIFICATIONS_PROMO_TOPIC_ID', None),
NotificationCategory.PARTNERS: getattr(settings, 'ADMIN_NOTIFICATIONS_PARTNERS_TOPIC_ID', None),
NotificationCategory.TICKETS: self.ticket_topic_id,
}
async def _get_referrer_info(self, db: AsyncSession, referred_by_id: int | None) -> str:
if not referred_by_id:
return 'Нет'
@@ -371,7 +401,7 @@ class AdminNotificationService:
message_lines.append('')
message_lines.append(f'⏰ <i>{format_local_datetime(datetime.now(UTC), "%d.%m.%Y %H:%M:%S")}</i>')
return await self._send_message('\n'.join(message_lines))
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.TRIALS)
except Exception as e:
logger.error('Ошибка отправки уведомления о триале', error=e)
@@ -502,7 +532,15 @@ class AdminNotificationService:
]
)
return await self._send_message('\n'.join(message_lines))
# Маршрутизация по категориям (зеркалит логику заголовков выше)
if purchase_type == 'renewal' or (
not was_trial_conversion and purchase_type is None and user.has_had_paid_subscription
):
cat = NotificationCategory.RENEWALS
else:
cat = NotificationCategory.PURCHASES
return await self._send_message('\n'.join(message_lines), category=cat)
except Exception as e:
logger.error('Ошибка отправки уведомления о покупке', error=e)
@@ -565,7 +603,7 @@ class AdminNotificationService:
else:
message = f'{message_prefix}{message_suffix}'
return await self._send_message(message)
return await self._send_message(message, category=NotificationCategory.INFRASTRUCTURE)
except Exception as e:
logger.error('Ошибка отправки уведомления об обновлении', error=e)
@@ -586,7 +624,7 @@ class AdminNotificationService:
<i>Система автоматических обновлений {format_local_datetime(datetime.now(UTC), '%d.%m.%Y %H:%M:%S')}</i>"""
return await self._send_message(message)
return await self._send_message(message, category=NotificationCategory.ERRORS)
except Exception as e:
logger.error('Ошибка отправки уведомления об ошибке проверки версий', error=e)
@@ -824,7 +862,7 @@ class AdminNotificationService:
return False
try:
return await self._send_message(message)
return await self._send_message(message, category=NotificationCategory.BALANCE)
except Exception as e:
logger.error('Ошибка отправки уведомления о пополнении', error=e, exc_info=True)
return False
@@ -901,7 +939,7 @@ class AdminNotificationService:
<i>{format_local_datetime(datetime.now(UTC), '%d.%m.%Y %H:%M:%S')}</i>"""
return await self._send_message(message)
return await self._send_message(message, category=NotificationCategory.RENEWALS)
except Exception as e:
logger.error('Ошибка отправки уведомления о продлении', error=e)
@@ -1008,7 +1046,7 @@ class AdminNotificationService:
]
)
return await self._send_message('\n'.join(message_lines))
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PROMO)
except Exception as e:
logger.error('Ошибка отправки уведомления об активации промокода', error=e)
@@ -1097,7 +1135,7 @@ class AdminNotificationService:
]
)
return await self._send_message('\n'.join(message_lines))
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PROMO)
except Exception as e:
logger.error('Ошибка отправки уведомления о переходе по кампании', error=e)
@@ -1187,14 +1225,30 @@ class AdminNotificationService:
]
)
return await self._send_message('\n'.join(message_lines))
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PROMO)
except Exception as e:
logger.error('Ошибка отправки уведомления о смене промогруппы', error=e)
return False
def _resolve_topic_id(self, category: NotificationCategory | None = None) -> int | None:
"""Определяет topic_id для сообщения.
Если указана category и для неё настроен топик возвращает его.
Иначе fallback на self.topic_id (общий топик).
"""
if category:
topic = self.category_topics.get(category)
if topic is not None:
return topic
return self.topic_id
async def _send_message(
self, text: str, reply_markup: types.InlineKeyboardMarkup | None = None, *, ticket_event: bool = False
self,
text: str,
reply_markup: types.InlineKeyboardMarkup | None = None,
*,
category: NotificationCategory | None = None,
) -> bool:
if not self.chat_id:
logger.warning('ADMIN_NOTIFICATIONS_CHAT_ID не настроен')
@@ -1208,19 +1262,14 @@ class AdminNotificationService:
'disable_web_page_preview': True,
}
# route to ticket-specific topic if provided
thread_id = None
if ticket_event and self.ticket_topic_id:
thread_id = self.ticket_topic_id
elif self.topic_id:
thread_id = self.topic_id
thread_id = self._resolve_topic_id(category)
if thread_id:
message_kwargs['message_thread_id'] = thread_id
if reply_markup is not None:
message_kwargs['reply_markup'] = reply_markup
await self.bot.send_message(**message_kwargs)
logger.info('Уведомление отправлено в чат', chat_id=self.chat_id)
logger.info('Уведомление отправлено в чат', chat_id=self.chat_id, category=category)
return True
except TelegramForbiddenError:
@@ -1241,11 +1290,17 @@ class AdminNotificationService:
"""Public check for whether admin notifications are configured and active."""
return self._is_enabled()
async def send_admin_notification(self, text: str, reply_markup: types.InlineKeyboardMarkup | None = None) -> bool:
async def send_admin_notification(
self,
text: str,
reply_markup: types.InlineKeyboardMarkup | None = None,
*,
category: NotificationCategory | None = None,
) -> bool:
"""Send a generic notification to admin chat with optional inline keyboard."""
if not self._is_enabled():
return False
return await self._send_message(text, reply_markup=reply_markup)
return await self._send_message(text, reply_markup=reply_markup, category=category)
async def send_guest_purchase_notification(
self,
@@ -1316,7 +1371,7 @@ class AdminNotificationService:
message_lines.append(f'<i>{format_local_datetime(datetime.now(UTC), "%d.%m.%Y %H:%M")}</i>')
return await self._send_message('\n'.join(message_lines))
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PURCHASES)
except Exception as e:
logger.error('Ошибка отправки уведомления о гостевой покупке', error=e)
@@ -1330,7 +1385,7 @@ class AdminNotificationService:
"""
if not self._is_enabled():
return False
return await self._send_message(text)
return await self._send_message(text, category=NotificationCategory.INFRASTRUCTURE)
def _get_payment_method_display(self, payment_method: str | None) -> str:
if not payment_method:
@@ -1516,7 +1571,7 @@ class AdminNotificationService:
message = '\n'.join(message_parts)
return await self._send_message(message)
return await self._send_message(message, category=NotificationCategory.INFRASTRUCTURE)
except Exception as e:
logger.error('Ошибка отправки уведомления о техработах', error=e)
@@ -1601,7 +1656,7 @@ class AdminNotificationService:
message = '\n'.join(message_parts)
return await self._send_message(message)
return await self._send_message(message, category=NotificationCategory.INFRASTRUCTURE)
except Exception as e:
logger.error('Ошибка отправки уведомления о статусе панели Remnawave', error=e)
@@ -1694,7 +1749,7 @@ class AdminNotificationService:
]
)
return await self._send_message('\n'.join(message_lines))
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.ADDONS)
except Exception as e:
logger.error('Ошибка отправки уведомления об изменении подписки', error=e)
@@ -1778,7 +1833,7 @@ class AdminNotificationService:
]
)
return await self._send_message('\n'.join(message_lines))
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PARTNERS)
except Exception as e:
logger.error('Ошибка отправки уведомления о заявке на партнёрку', error=e)
@@ -1829,7 +1884,7 @@ class AdminNotificationService:
]
)
return await self._send_message('\n'.join(message_lines))
return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PARTNERS)
except Exception as e:
logger.error('Ошибка отправки уведомления о запросе на вывод', error=e)
@@ -1873,7 +1928,7 @@ class AdminNotificationService:
)
message = '\n'.join(message_lines)
return await self._send_message(message)
return await self._send_message(message, category=NotificationCategory.PARTNERS)
except Exception as e:
logger.error('Ошибка отправки уведомления о массовой блокировке', error=e)
@@ -1910,7 +1965,7 @@ class AdminNotificationService:
if media_file_id and media_type == 'photo':
return await self._send_ticket_photo_notification(text, media_file_id, keyboard)
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
return await self._send_message(text, reply_markup=keyboard, category=NotificationCategory.TICKETS)
async def _send_ticket_photo_notification(
self,
@@ -1925,7 +1980,7 @@ class AdminNotificationService:
if not self.chat_id:
return False
thread_id = self.ticket_topic_id or self.topic_id
thread_id = self._resolve_topic_id(category=NotificationCategory.TICKETS)
try:
if not caption_exceeds_telegram_limit(text):
@@ -1943,7 +1998,7 @@ class AdminNotificationService:
await self.bot.send_photo(**photo_kwargs)
else:
# Текст отдельно, фото следом в тот же топик
await self._send_message(text, reply_markup=keyboard, ticket_event=True)
await self._send_message(text, reply_markup=keyboard, category=NotificationCategory.TICKETS)
photo_kwargs = {
'chat_id': self.chat_id,
'photo': photo_file_id,
@@ -1956,7 +2011,7 @@ class AdminNotificationService:
except Exception as e:
logger.error('Ошибка отправки фото-уведомления тикета', error=e)
# Fallback: отправляем хотя бы текст
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
return await self._send_message(text, reply_markup=keyboard, category=NotificationCategory.TICKETS)
async def send_suspicious_traffic_notification(self, message: str, bot: Bot, topic_id: int | None = None) -> bool:
"""
+4 -2
View File
@@ -1810,10 +1810,12 @@ class BackupService:
notification_text += f'\n\n⏰ <i>{datetime.now(UTC).strftime("%d.%m.%Y %H:%M:%S")}</i>'
try:
from app.services.admin_notification_service import AdminNotificationService
from app.services.admin_notification_service import AdminNotificationService, NotificationCategory
admin_service = AdminNotificationService(self.bot)
await admin_service._send_message(notification_text)
await admin_service.send_admin_notification(
notification_text, category=NotificationCategory.INFRASTRUCTURE
)
except Exception as e:
logger.error('Ошибка отправки уведомления через AdminNotificationService', error=e)
+415 -16
View File
@@ -7,7 +7,7 @@ from datetime import UTC, datetime, timedelta
from typing import Literal
import structlog
from sqlalchemy import func, or_, select
from sqlalchemy import func, or_, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -392,24 +392,41 @@ async def fulfill_purchase(
return purchase
def _resolve_base_payment_method(method_str: str | None) -> str:
"""Resolve base payment method string by stripping sub-option suffixes.
'yookassa_sbp' 'yookassa', 'kassa_ai' 'kassa_ai' (enum match keeps it),
'platega_2' 'platega'.
"""
if not method_str:
return ''
# If exact enum match, return as-is (handles 'telegram_stars', 'kassa_ai', etc.)
try:
PaymentMethod(method_str)
return method_str
except ValueError:
pass
# Strip sub-option suffix
if '_' in method_str:
base = method_str.rsplit('_', 1)[0]
try:
PaymentMethod(base)
return base
except ValueError:
pass
return method_str
def _resolve_payment_method(method_str: str | None) -> PaymentMethod | None:
"""Convert payment method string from GuestPurchase to PaymentMethod enum."""
if not method_str:
return None
# Try exact match first (handles 'telegram_stars', 'kassa_ai', 'yookassa', etc.)
base = _resolve_base_payment_method(method_str)
try:
return PaymentMethod(method_str)
return PaymentMethod(base)
except ValueError:
pass
# Strip sub-option suffix ('yookassa_sbp' → 'yookassa', 'platega_2' → 'platega')
if '_' in method_str:
base_method = method_str.split('_')[0]
try:
return PaymentMethod(base_method)
except ValueError:
pass
logger.debug('Unknown payment method for transaction', method=method_str)
return None
logger.debug('Unknown payment method for transaction', method=method_str)
return None
def _mask_email(email: str) -> str:
@@ -1002,14 +1019,16 @@ async def retry_stuck_paid_purchases(
stale_minutes: int = 5,
limit: int = 10,
max_age_hours: int = 24,
max_retries: int = 20,
) -> int:
"""Retry fulfillment for purchases stuck in PAID status.
Finds purchases that have been in PAID status for longer than stale_minutes
(but not older than max_age_hours) and attempts to fulfill them in isolated
sessions. Returns the number of successfully retried purchases.
(but not older than max_age_hours, and with retry_count < max_retries) and
attempts to fulfill them in isolated sessions.
Purchases older than max_age_hours are left for manual investigation.
Purchases exceeding max_retries are marked FAILED and an admin alert is sent.
Returns the number of successfully retried purchases.
"""
from app.database.database import AsyncSessionLocal
@@ -1018,10 +1037,12 @@ async def retry_stuck_paid_purchases(
# Collect tokens only — each retry gets its own session.
# NULL paid_at is included via or_() as a safety net for data anomalies.
# Filter retry_count < max_retries in SQL to avoid wasting LIMIT slots.
result = await db.execute(
select(GuestPurchase.token)
.where(
GuestPurchase.status == GuestPurchaseStatus.PAID.value,
GuestPurchase.retry_count < max_retries,
or_(GuestPurchase.paid_at < cutoff, GuestPurchase.paid_at.is_(None)),
or_(GuestPurchase.paid_at > max_age, GuestPurchase.paid_at.is_(None)),
# Exclude code-only gifts — they stay PAID intentionally until activated
@@ -1032,6 +1053,9 @@ async def retry_stuck_paid_purchases(
)
tokens = result.scalars().all()
# Separately fail exhausted purchases (retry_count >= max_retries)
await _fail_exhausted_purchases_batch(db, GuestPurchaseStatus.PAID, max_retries, max_age)
if not tokens:
return 0
@@ -1039,6 +1063,7 @@ async def retry_stuck_paid_purchases(
for token in tokens:
try:
async with AsyncSessionLocal() as retry_db:
await _increment_retry_count(retry_db, token)
await fulfill_purchase(retry_db, token)
retried += 1
logger.info('Retried stuck purchase successfully', token_prefix=token[:5])
@@ -1053,12 +1078,15 @@ async def retry_stuck_pending_activation(
stale_minutes: int = 10,
limit: int = 10,
max_age_hours: int = 24,
max_retries: int = 20,
) -> int:
"""Retry activation for purchases stuck in PENDING_ACTIVATION status.
This handles the case where activate_purchase() failed after the status
was already transitioned to PENDING_ACTIVATION (e.g., Remnawave panel was
temporarily down). Each retry runs in an isolated session.
Purchases exceeding max_retries are marked FAILED and an admin alert is sent.
"""
from app.database.database import AsyncSessionLocal
@@ -1069,6 +1097,7 @@ async def retry_stuck_pending_activation(
select(GuestPurchase.token)
.where(
GuestPurchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value,
GuestPurchase.retry_count < max_retries,
or_(GuestPurchase.paid_at < cutoff, GuestPurchase.paid_at.is_(None)),
or_(GuestPurchase.paid_at > max_age, GuestPurchase.paid_at.is_(None)),
GuestPurchase.user_id.isnot(None),
@@ -1078,6 +1107,9 @@ async def retry_stuck_pending_activation(
)
tokens = result.scalars().all()
# Separately fail exhausted purchases (retry_count >= max_retries)
await _fail_exhausted_purchases_batch(db, GuestPurchaseStatus.PENDING_ACTIVATION, max_retries, max_age)
if not tokens:
return 0
@@ -1085,6 +1117,7 @@ async def retry_stuck_pending_activation(
for token in tokens:
try:
async with AsyncSessionLocal() as retry_db:
await _increment_retry_count(retry_db, token)
await activate_purchase(retry_db, token)
retried += 1
logger.info('Retried stuck pending_activation successfully', token_prefix=token[:5])
@@ -1092,3 +1125,369 @@ async def retry_stuck_pending_activation(
logger.exception('Failed to retry stuck pending_activation', token_prefix=token[:5])
return retried
async def _increment_retry_count(db: AsyncSession, purchase_token: str) -> None:
"""Atomically increment retry_count via UPDATE statement (no SELECT, no identity map pollution)."""
await db.execute(
update(GuestPurchase)
.where(GuestPurchase.token == purchase_token)
.values(retry_count=GuestPurchase.retry_count + 1)
)
await db.commit()
async def _fail_exhausted_purchases_batch(
db: AsyncSession,
status: GuestPurchaseStatus,
max_retries: int,
max_age: datetime,
) -> None:
"""Find and mark exhausted purchases as FAILED, then send admin alerts."""
from app.database.crud.landing import update_purchase_status
from app.database.database import AsyncSessionLocal
result = await db.execute(
select(GuestPurchase.token, GuestPurchase.retry_count)
.where(
GuestPurchase.status == status.value,
GuestPurchase.retry_count >= max_retries,
or_(GuestPurchase.paid_at > max_age, GuestPurchase.paid_at.is_(None)),
)
.limit(10)
)
exhausted = result.all()
for token, retry_count in exhausted:
# Collect alert data before closing the session
alert_data: dict | None = None
try:
async with AsyncSessionLocal() as fail_db:
row = await fail_db.execute(select(GuestPurchase).where(GuestPurchase.token == token).with_for_update())
purchase = row.scalars().first()
if purchase and purchase.status not in (
GuestPurchaseStatus.DELIVERED.value,
GuestPurchaseStatus.FAILED.value,
):
# Capture alert data before commit expires attributes
alert_data = {
'id': purchase.id,
'token': purchase.token,
'amount_kopeks': purchase.amount_kopeks,
'payment_method': purchase.payment_method,
'payment_id': purchase.payment_id,
'contact_type': purchase.contact_type,
'contact_value': purchase.contact_value,
'created_at': purchase.created_at,
}
await update_purchase_status(fail_db, token, GuestPurchaseStatus.FAILED)
logger.error(
'Purchase exceeded max retries — marked FAILED',
token_prefix=token[:5],
retry_count=retry_count,
phase=status.value,
)
except Exception:
logger.exception('Failed to mark exhausted purchase as FAILED', token_prefix=token[:5])
# Send alert OUTSIDE the session (no row lock held)
if alert_data:
await _send_stuck_purchase_alert(alert_data, retry_count, status.value)
async def _send_stuck_purchase_alert(data: dict, retry_count: int, phase: str) -> None:
"""Send admin notification about a purchase that exhausted all retries.
Accepts a plain dict (not ORM object) so it can be called after the session is closed.
"""
if not getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) or not settings.BOT_TOKEN:
return
try:
import html as html_mod
from aiogram import Bot
from app.services.admin_notification_service import AdminNotificationService, NotificationCategory
amount_rub = data['amount_kopeks'] / 100
contact_value = html_mod.escape(str(data.get('contact_value', '?')))
contact_type = html_mod.escape(str(data.get('contact_type', '?')))
text = (
f'<b>STUCK PURCHASE — retries exhausted</b>\n\n'
f'Token: <code>{data["token"][:8]}...</code>\n'
f'Status: <code>{phase}</code> → <code>FAILED</code>\n'
f'Retries: <b>{retry_count}</b>\n'
f'Amount: <b>{amount_rub:.0f} ₽</b>\n'
f'Payment: <code>{html_mod.escape(str(data.get("payment_method") or "?"))}</code>\n'
f'Payment ID: <code>{html_mod.escape(str(data.get("payment_id") or "?"))}</code>\n'
f'Contact: {contact_type}: <code>{contact_value}</code>\n'
f'Created: {data["created_at"]:%Y-%m-%d %H:%M UTC}\n\n'
f'Requires manual investigation.'
)
async with Bot(token=settings.BOT_TOKEN) as bot:
service = AdminNotificationService(bot)
await service.send_admin_notification(text, category=NotificationCategory.ERRORS)
except Exception:
logger.warning('Failed to send stuck purchase admin alert', purchase_id=data.get('id'), exc_info=True)
async def _send_amount_mismatch_alert(
purchase: GuestPurchase,
provider_amount_kopeks: int,
provider_payment_id: str,
payment_method: str | None,
) -> None:
"""Send admin alert when recovery detects an amount mismatch (possible fraud or bug)."""
if not getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) or not settings.BOT_TOKEN:
return
try:
import html as html_mod
from aiogram import Bot
from app.services.admin_notification_service import AdminNotificationService, NotificationCategory
text = (
f'<b>AMOUNT MISMATCH — purchase marked FAILED</b>\n\n'
f'Token: <code>{purchase.token[:8]}...</code>\n'
f'Expected: <b>{purchase.amount_kopeks / 100:.0f} ₽</b>\n'
f'Provider: <b>{provider_amount_kopeks / 100:.0f} ₽</b>\n'
f'Payment: <code>{html_mod.escape(str(payment_method or "?"))}</code>\n'
f'Payment ID: <code>{html_mod.escape(str(provider_payment_id))}</code>\n'
f'Contact: {html_mod.escape(str(purchase.contact_type))}: '
f'<code>{html_mod.escape(str(purchase.contact_value))}</code>\n\n'
f'Requires manual investigation.'
)
async with Bot(token=settings.BOT_TOKEN) as bot:
service = AdminNotificationService(bot)
await service.send_admin_notification(text, category=NotificationCategory.ERRORS)
except Exception:
logger.warning('Failed to send amount mismatch alert', purchase_id=purchase.id, exc_info=True)
async def recover_stuck_pending_purchases(
db: AsyncSession,
stale_minutes: int = 10,
limit: int = 10,
max_age_hours: int = 24,
) -> int:
"""Recover purchases stuck in PENDING by checking provider payment status.
Queries all payment provider tables (YooKassa, Heleket, CryptoBot, etc.)
for succeeded payments matching the purchase_token. If a provider payment
is confirmed but the GuestPurchase is still PENDING (webhook was lost or
processing failed), marks the purchase as PAID so retry_stuck_paid_purchases
can fulfill it. Includes amount verification.
Returns the number of recovered purchases.
"""
from app.database.database import AsyncSessionLocal
cutoff = datetime.now(UTC) - timedelta(minutes=stale_minutes)
max_age = datetime.now(UTC) - timedelta(hours=max_age_hours)
# Find PENDING purchases older than stale_minutes but younger than max_age_hours
result = await db.execute(
select(GuestPurchase.token, GuestPurchase.payment_method)
.where(
GuestPurchase.status == GuestPurchaseStatus.PENDING.value,
GuestPurchase.created_at < cutoff,
GuestPurchase.created_at > max_age,
)
.order_by(GuestPurchase.created_at.asc())
.limit(limit)
)
pending_purchases = result.all()
if not pending_purchases:
return 0
recovered = 0
for token, payment_method in pending_purchases:
try:
async with AsyncSessionLocal() as recover_db:
paid = await _check_and_recover_pending_purchase(recover_db, token, payment_method)
if paid:
recovered += 1
except Exception:
logger.exception('Failed to recover pending purchase', token_prefix=token[:5])
return recovered
async def _find_succeeded_provider_payment(
db: AsyncSession,
base_method: str,
purchase_token: str,
) -> tuple[str, int | None] | None:
"""Query provider payment tables for a succeeded payment matching purchase_token.
Returns ``(provider_payment_id, amount_kopeks)`` or ``None``.
``amount_kopeks`` is ``None`` when the amount check should be skipped
(e.g., CryptoBot where USDRUB conversion introduces imprecision).
"""
from sqlalchemy import cast
from sqlalchemy.types import JSON as SA_JSON
from app.database.models import (
CloudPaymentsPayment,
CryptoBotPayment,
FreekassaPayment,
HeleketPayment,
KassaAiPayment,
MulenPayPayment,
Pal24Payment,
PlategaPayment,
RioPayPayment,
SeverPayPayment,
WataPayment,
YooKassaPayment,
)
# --- CryptoBot: special case — payload field (text JSON), skip amount check ---
if base_method == 'cryptobot':
result = await db.execute(
select(CryptoBotPayment).where(
CryptoBotPayment.status == 'paid',
cast(CryptoBotPayment.payload, SA_JSON)['purchase_token'].as_string() == purchase_token,
)
)
p = result.scalars().first()
return (p.invoice_id, None) if p else None
# --- All other providers: metadata_json['purchase_token'] + is_paid/status filters ---
model = None
payment_id_attr: str = ''
extra_conditions: list = []
if base_method.startswith('yookassa'):
model = YooKassaPayment
payment_id_attr = 'yookassa_payment_id'
extra_conditions = [YooKassaPayment.status == 'succeeded', YooKassaPayment.is_paid.is_(True)]
elif base_method == 'heleket':
model = HeleketPayment
payment_id_attr = 'uuid'
extra_conditions = [HeleketPayment.status.in_(['paid', 'paid_over'])]
elif base_method == 'mulenpay':
model = MulenPayPayment
payment_id_attr = 'uuid'
extra_conditions = [MulenPayPayment.is_paid.is_(True)]
elif base_method == 'pal24':
model = Pal24Payment
payment_id_attr = 'bill_id'
extra_conditions = [Pal24Payment.is_paid.is_(True)]
elif base_method == 'wata':
model = WataPayment
payment_id_attr = 'payment_link_id'
extra_conditions = [WataPayment.is_paid.is_(True)]
elif base_method == 'platega':
model = PlategaPayment
payment_id_attr = 'platega_transaction_id'
extra_conditions = [PlategaPayment.is_paid.is_(True)]
elif base_method == 'cloudpayments':
model = CloudPaymentsPayment
payment_id_attr = 'invoice_id'
extra_conditions = [CloudPaymentsPayment.status == 'completed', CloudPaymentsPayment.is_paid.is_(True)]
elif base_method == 'freekassa':
model = FreekassaPayment
payment_id_attr = 'order_id'
extra_conditions = [FreekassaPayment.status == 'success', FreekassaPayment.is_paid.is_(True)]
elif base_method == 'kassa_ai':
model = KassaAiPayment
payment_id_attr = 'order_id'
extra_conditions = [KassaAiPayment.status == 'success', KassaAiPayment.is_paid.is_(True)]
elif base_method == 'riopay':
model = RioPayPayment
payment_id_attr = 'order_id'
extra_conditions = [RioPayPayment.status == 'success', RioPayPayment.is_paid.is_(True)]
elif base_method == 'severpay':
model = SeverPayPayment
payment_id_attr = 'order_id'
extra_conditions = [SeverPayPayment.status == 'success', SeverPayPayment.is_paid.is_(True)]
if model is None:
return None
result = await db.execute(
select(model).where(
model.metadata_json['purchase_token'].as_string() == purchase_token,
*extra_conditions,
)
)
p = result.scalars().first()
if p is None:
return None
payment_id = str(getattr(p, payment_id_attr))
# amount_kopeks: Integer column for most providers, @property for Heleket
amount = getattr(p, 'amount_kopeks', None)
return (payment_id, amount)
async def _check_and_recover_pending_purchase(
db: AsyncSession,
purchase_token: str,
payment_method: str | None,
) -> bool:
"""Check if a PENDING purchase has a succeeded payment and transition to PAID.
Uses SELECT ... FOR UPDATE on the GuestPurchase row to prevent concurrent
webhook processing from racing with the recovery.
Verifies amount match between provider payment and guest purchase.
"""
from app.database.crud.landing import update_purchase_status
# Lock the row to prevent TOCTOU race with concurrent webhook processing
result = await db.execute(select(GuestPurchase).where(GuestPurchase.token == purchase_token).with_for_update())
purchase = result.scalars().first()
if purchase is None or purchase.status != GuestPurchaseStatus.PENDING.value:
return False
# Resolve base method: 'yookassa_sbp' → 'yookassa', 'kassa_ai' stays 'kassa_ai'
base_method = _resolve_base_payment_method(payment_method)
match = await _find_succeeded_provider_payment(db, base_method, purchase_token)
if match is None:
if base_method:
logger.debug(
'No succeeded provider payment found for PENDING purchase',
token_prefix=purchase_token[:5],
payment_method=payment_method,
)
return False
provider_payment_id, provider_amount_kopeks = match
# Amount verification (skip when provider_amount_kopeks is None, e.g., crypto)
if provider_amount_kopeks is not None and provider_amount_kopeks != purchase.amount_kopeks:
logger.error(
'Amount mismatch during PENDING recovery — skipping',
token_prefix=purchase_token[:5],
provider_amount=provider_amount_kopeks,
purchase_amount=purchase.amount_kopeks,
payment_method=payment_method,
)
# Mark FAILED to prevent repeated mismatch logs every cycle
from app.database.crud.landing import update_purchase_status as _update_status
await _update_status(db, purchase_token, GuestPurchaseStatus.FAILED)
await _send_amount_mismatch_alert(purchase, provider_amount_kopeks, provider_payment_id, payment_method)
return False
# Transition PENDING → PAID for retry_stuck_paid_purchases to handle
await update_purchase_status(
db,
purchase_token,
GuestPurchaseStatus.PAID,
payment_id=provider_payment_id,
paid_at=datetime.now(UTC),
)
logger.info(
'Recovered stuck PENDING purchase → PAID',
token_prefix=purchase_token[:5],
payment_method=payment_method,
provider_payment_id=provider_payment_id,
)
return True
+5 -3
View File
@@ -65,11 +65,11 @@ class MaintenanceService:
return False
try:
from app.services.admin_notification_service import AdminNotificationService
from app.services.admin_notification_service import AdminNotificationService, NotificationCategory
notification_service = AdminNotificationService(self._bot)
if not notification_service._is_enabled():
if not notification_service.is_enabled:
logger.debug('Уведомления администраторов отключены')
return False
@@ -79,7 +79,9 @@ class MaintenanceService:
timestamp = format_local_datetime(datetime.now(UTC), '%d.%m.%Y %H:%M:%S %Z')
formatted_message = f'{emoji} <b>ТЕХНИЧЕСКИЕ РАБОТЫ</b>\n\n{message}\n\n⏰ <i>{timestamp}</i>'
return await notification_service._send_message(formatted_message)
return await notification_service.send_admin_notification(
formatted_message, category=NotificationCategory.INFRASTRUCTURE
)
except Exception as e:
logger.error('Ошибка отправки уведомления через AdminNotificationService', error=e)
+21 -4
View File
@@ -1023,7 +1023,7 @@ class MonitoringService:
continue
days_before_expiry = (sub.end_date - current_time).days
if days_before_expiry <= min(sub.autopay_days_before, 3):
if days_before_expiry <= min(sub.autopay_days_before or 3, 3):
autopay_subscriptions.append(sub)
processed_count = 0
@@ -1742,18 +1742,35 @@ class MonitoringService:
)
async def _retry_stuck_guest_purchases(self, db: AsyncSession):
try:
from app.services.guest_purchase_service import retry_stuck_paid_purchases, retry_stuck_pending_activation
from app.services.guest_purchase_service import (
recover_stuck_pending_purchases,
retry_stuck_paid_purchases,
retry_stuck_pending_activation,
)
# Phase 1: Recover PENDING purchases where provider payment already succeeded
try:
recovered = await recover_stuck_pending_purchases(db, stale_minutes=10, limit=10)
if recovered:
logger.info('Recovered stuck PENDING purchases', recovered=recovered)
except Exception:
logger.error('Error recovering stuck PENDING guest purchases', exc_info=True)
# Phase 2: Retry fulfillment for purchases in PAID status
try:
retried = await retry_stuck_paid_purchases(db, stale_minutes=5, limit=10)
if retried:
logger.info('Retried stuck guest purchases', retried=retried)
except Exception:
logger.error('Error retrying stuck PAID guest purchases', exc_info=True)
# Phase 3: Retry activation for purchases in PENDING_ACTIVATION status
try:
retried_pa = await retry_stuck_pending_activation(db, stale_minutes=10, limit=10)
if retried_pa:
logger.info('Retried stuck pending_activation purchases', retried=retried_pa)
except Exception:
logger.error('Error retrying stuck guest purchases', exc_info=True)
logger.error('Error retrying stuck PENDING_ACTIVATION guest purchases', exc_info=True)
async def _cleanup_inactive_users(self, db: AsyncSession):
try:
+23 -11
View File
@@ -476,8 +476,7 @@ async def try_fulfill_guest_purchase(
introduces imprecision.
Returns:
``True`` -- guest purchase was detected and successfully fulfilled.
``False`` -- guest purchase was detected but fulfillment failed.
``True`` -- guest purchase was detected and consumed (fulfilled or queued for retry).
``None`` -- this is NOT a guest purchase (caller should proceed normally).
"""
purchase_token = _extract_guest_purchase_token(metadata)
@@ -485,7 +484,7 @@ async def try_fulfill_guest_purchase(
return None
from app.database.crud.landing import get_purchase_by_token, update_purchase_status
from app.database.models import GuestPurchaseStatus
from app.database.models import GuestPurchase, GuestPurchaseStatus
from app.services.guest_purchase_service import fulfill_purchase
try:
@@ -558,13 +557,26 @@ async def try_fulfill_guest_purchase(
provider=provider_name,
error=guest_error,
)
# Mark as FAILED so it doesn't get retried forever
# Mark as PAID (not FAILED) so retry_stuck_paid_purchases can pick it up.
# Use a fresh session to avoid tainted-session issues after rollback.
# The monitoring service retries PAID purchases every 5 minutes for up to 24 hours.
try:
await update_purchase_status(
db,
purchase_token,
GuestPurchaseStatus.FAILED,
)
from app.database.database import AsyncSessionLocal
async with AsyncSessionLocal() as recovery_db:
# Use FOR UPDATE to prevent TOCTOU race with concurrent webhook.
row = await recovery_db.execute(
select(GuestPurchase).where(GuestPurchase.token == purchase_token).with_for_update()
)
current = row.scalars().first()
if current and current.status in (
GuestPurchaseStatus.PENDING.value,
GuestPurchaseStatus.PAID.value,
):
current.status = GuestPurchaseStatus.PAID.value
current.payment_id = provider_payment_id
current.paid_at = datetime.now(UTC)
await recovery_db.commit()
except Exception:
logger.exception('Failed to mark guest purchase as FAILED')
return False
logger.exception('Failed to mark guest purchase as PAID for retry')
return True
+3 -6
View File
@@ -87,7 +87,6 @@ class RioPayPaymentMixin:
# Генерируем уникальный order_id с telegram_id для удобного поиска
order_id = f'rp{tg_id}_{uuid.uuid4().hex[:6]}'
amount_rubles = amount_kopeks / 100
currency = settings.RIOPAY_CURRENCY
# Срок действия платежа (1 час по умолчанию)
expires_at = datetime.now(UTC) + timedelta(hours=1)
@@ -105,11 +104,9 @@ class RioPayPaymentMixin:
# Используем API для создания заказа
result = await riopay_service.create_order(
amount=amount_rubles,
currency=currency,
external_id=order_id,
purpose=description,
success_url=success_url or settings.RIOPAY_SUCCESS_URL,
fail_url=fail_url or settings.RIOPAY_FAIL_URL,
)
payment_url = result.get('paymentLink')
@@ -129,7 +126,7 @@ class RioPayPaymentMixin:
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
currency=settings.RIOPAY_CURRENCY,
description=description,
payment_url=payment_url,
riopay_order_id=riopay_order_id,
@@ -143,7 +140,7 @@ class RioPayPaymentMixin:
order_id=order_id,
user_id=user_id,
amount_rubles=amount_rubles,
currency=currency,
currency=settings.RIOPAY_CURRENCY,
)
return {
@@ -151,7 +148,7 @@ class RioPayPaymentMixin:
'riopay_order_id': riopay_order_id,
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_rubles,
'currency': currency,
'currency': settings.RIOPAY_CURRENCY,
'payment_url': payment_url,
'expires_at': expires_at.isoformat(),
'local_payment_id': local_payment.id,
+1 -1
View File
@@ -721,7 +721,7 @@ class PaymentService(
payment_system_id=ps_id,
)
if result:
await _patch_guest_metadata(result['local_payment_id'], payment_method)
await _patch_guest_metadata(result['local_payment_id'], 'kassa_ai')
return {
'payment_url': result.get('payment_url'),
'payment_id': result.get('order_id'),
+40 -26
View File
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.transaction import get_user_total_spent_kopeks
from app.database.crud.user import lock_user_for_update
from app.database.models import PromoGroup, User
from app.services.admin_notification_service import AdminNotificationService
@@ -90,7 +91,9 @@ async def maybe_assign_promo_group_by_total_spent(
) -> PromoGroup | None:
from app.database.crud.user_promo_group import (
add_user_to_promo_group,
get_user_promo_groups,
has_user_promo_group,
remove_user_from_promo_group,
sync_user_primary_promo_group,
)
@@ -99,6 +102,9 @@ async def maybe_assign_promo_group_by_total_spent(
logger.debug('Не удалось найти пользователя для автовыдачи промогруппы', user_id=user_id)
return None
# Блокируем строку пользователя для предотвращения гонок при конкурентных вебхуках
user = await lock_user_for_update(db, user)
# Получаем текущую primary промогруппу
old_group = user.get_primary_promo_group()
@@ -108,60 +114,68 @@ async def maybe_assign_promo_group_by_total_spent(
previous_threshold = user.auto_promo_group_threshold_kopeks or 0
target_group = await _get_best_group_for_spending(
db,
total_spent,
min_threshold_kopeks=previous_threshold,
)
# Находим группу, соответствующую текущим тратам (без порогового фильтра,
# чтобы промокод-группы всегда очищались при покупке)
target_group = await _get_best_group_for_spending(db, total_spent)
if not target_group:
return None
try:
target_threshold = target_group.auto_assign_total_spent_kopeks or 0
if target_threshold <= previous_threshold:
logger.debug(
"Порог промогруппы '' не превышает ранее назначенный для пользователя",
target_group_name=target_group.name,
target_threshold=target_threshold,
previous_threshold=previous_threshold,
telegram_id=user.telegram_id,
)
return None
# Фаза 1: Удаляем старые auto/promocode группы, отличные от целевой
current_groups = await get_user_promo_groups(db, user_id)
removed_any = False
for upg in current_groups:
if upg.promo_group_id != target_group.id and upg.assigned_by in ('auto', 'promocode'):
await remove_user_from_promo_group(db, user_id, upg.promo_group_id, commit=False)
removed_any = True
logger.info(
'Удалена старая промогруппа перед автоназначением',
telegram_id=user.telegram_id,
old_group_name=upg.promo_group.name if upg.promo_group else upg.promo_group_id,
old_assigned_by=upg.assigned_by,
)
# Проверяем, есть ли уже эта группа у пользователя
if removed_any:
await db.flush()
await db.refresh(user)
# Проверяем, есть ли уже целевая группа у пользователя
already_has_group = await has_user_promo_group(db, user_id, target_group.id)
if user.auto_promo_group_assigned and already_has_group:
if user.auto_promo_group_assigned and already_has_group and not removed_any:
logger.debug(
"Пользователь уже имеет промогруппу '', повторная выдача не требуется",
'Пользователь уже имеет промогруппу, повторная выдача не требуется',
telegram_id=user.telegram_id,
target_group_name=target_group.name,
)
await sync_user_primary_promo_group(db, user_id)
if target_threshold > previous_threshold:
user.auto_promo_group_threshold_kopeks = target_threshold
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
await db.commit()
await db.refresh(user)
return target_group
user.auto_promo_group_assigned = True
user.auto_promo_group_threshold_kopeks = target_threshold
if target_threshold > previous_threshold:
user.auto_promo_group_threshold_kopeks = target_threshold
user.updated_at = datetime.now(UTC)
newly_added = False
if not already_has_group:
# Добавляем новую промогруппу к существующим
await add_user_to_promo_group(db, user_id, target_group.id, assigned_by='auto')
await add_user_to_promo_group(db, user_id, target_group.id, assigned_by='auto', commit=False)
newly_added = True
logger.info(
"🤖 Пользователю добавлена промогруппа '' за траты",
'Пользователю назначена промогруппа за траты',
telegram_id=user.telegram_id,
target_group_name=target_group.name,
total_spent=total_spent / 100,
)
else:
await sync_user_primary_promo_group(db, user_id)
logger.info(
"🤖 Пользователь уже имеет промогруппу '', отмечаем автоприсвоение",
'Пользователь уже имеет промогруппу, синхронизировано',
telegram_id=user.telegram_id,
target_group_name=target_group.name,
)
@@ -169,7 +183,7 @@ async def maybe_assign_promo_group_by_total_spent(
await db.commit()
await db.refresh(user)
if not already_has_group:
if newly_added:
await _notify_admins_about_auto_assignment(
db,
user,
+2 -2
View File
@@ -119,7 +119,7 @@ class PromoCodeService:
if promo_group:
# Add promo group to user
await add_user_to_promo_group(
db, user_id, promocode.promo_group_id, assigned_by='promocode'
db, user_id, promocode.promo_group_id, assigned_by='promocode', commit=False
)
logger.info(
@@ -393,7 +393,7 @@ class PromoCodeService:
has_group = await has_user_promo_group(db, user_id, promocode.promo_group_id)
if has_group:
await remove_user_from_promo_group(db, user_id, promocode.promo_group_id)
await remove_user_from_promo_group(db, user_id, promocode.promo_group_id, commit=False)
logger.info(
'Снята промогруппа ID у пользователя при деактивации промокода',
promo_group_id=promocode.promo_group_id,
+1 -1
View File
@@ -305,7 +305,7 @@ class ReferralContestService:
chat_id=chat_id,
text='\n'.join(lines),
disable_web_page_preview=True,
message_thread_id=settings.ADMIN_NOTIFICATIONS_TOPIC_ID,
message_thread_id=settings.ADMIN_NOTIFICATIONS_PROMO_TOPIC_ID or settings.ADMIN_NOTIFICATIONS_TOPIC_ID,
)
except Exception as exc:
logger.error('Не удалось отправить админскую сводку конкурса', exc=exc)
+15 -14
View File
@@ -654,13 +654,14 @@ class RemnaWaveWebhookService:
changed = True
# Sync subscription crypto link (for HAPP_CRYPT4_LINK)
subscription_crypto_link = data.get('subscriptionCryptoLink')
if (
subscription_crypto_link
and self._is_valid_link(subscription_crypto_link)
and subscription.subscription_crypto_link != subscription_crypto_link
):
subscription.subscription_crypto_link = subscription_crypto_link
subscription_crypto_link = data.get('subscriptionCryptoLink') or (data.get('happ') or {}).get('cryptoLink', '')
if subscription_crypto_link and self._is_valid_link(subscription_crypto_link):
if subscription.subscription_crypto_link != subscription_crypto_link:
subscription.subscription_crypto_link = subscription_crypto_link
changed = True
elif subscription_url and subscription.subscription_crypto_link:
# URL обновился, а крипто-ссылка не пришла — сбрасываем старую
subscription.subscription_crypto_link = None
changed = True
# Always stamp to protect from sync overwrite, even if no fields changed
@@ -743,18 +744,18 @@ class RemnaWaveWebhookService:
) -> None:
if subscription:
new_url = data.get('subscriptionUrl')
new_crypto_link = data.get('subscriptionCryptoLink')
new_crypto_link = data.get('subscriptionCryptoLink') or (data.get('happ') or {}).get('cryptoLink', '')
changed = False
if new_url and self._is_valid_url(new_url) and subscription.subscription_url != new_url:
subscription.subscription_url = new_url
changed = True
if (
new_crypto_link
and self._is_valid_link(new_crypto_link)
and subscription.subscription_crypto_link != new_crypto_link
):
subscription.subscription_crypto_link = new_crypto_link
if new_crypto_link and self._is_valid_link(new_crypto_link):
if subscription.subscription_crypto_link != new_crypto_link:
subscription.subscription_crypto_link = new_crypto_link
changed = True
elif new_url and subscription.subscription_crypto_link:
subscription.subscription_crypto_link = None
changed = True
# Always stamp to protect from sync overwrite
+1 -7
View File
@@ -45,7 +45,7 @@ class RioPayService:
def _get_headers(self) -> dict[str, str]:
"""Формирует заголовки для API запросов."""
return {
'x-api-token': self.api_token,
'X-Api-Token': self.api_token,
'Content-Type': 'application/json',
}
@@ -67,11 +67,9 @@ class RioPayService:
self,
*,
amount: float,
currency: str = 'RUB',
external_id: str,
purpose: str = 'Пополнение баланса',
success_url: str | None = None,
fail_url: str | None = None,
) -> dict[str, Any]:
"""
Создает заказ через API RioPay.
@@ -82,21 +80,17 @@ class RioPayService:
"""
payload: dict[str, Any] = {
'amount': str(amount),
'currency': currency,
'externalId': external_id,
'purpose': purpose,
}
if success_url:
payload['successUrl'] = success_url
if fail_url:
payload['failUrl'] = fail_url
logger.info(
'RioPay API create_order',
external_id=external_id,
amount=amount,
currency=currency,
)
try:
+8 -2
View File
@@ -65,7 +65,10 @@ class StartupNotificationService:
def __init__(self, bot: Bot) -> None:
self.bot = bot
self.chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None)
self.topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None)
# Стартовые/краш-уведомления → топик инфраструктуры, fallback на общий
self.topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_INFRASTRUCTURE_TOPIC_ID', None) or getattr(
settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None
)
self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
def _get_version(self) -> str:
@@ -381,7 +384,10 @@ async def send_crash_notification(bot: Bot, error: Exception, traceback_str: str
bool: True если уведомление отправлено успешно
"""
chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None)
topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None)
# Краш → топик ошибок, fallback на общий
topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_ERRORS_TOPIC_ID', None) or getattr(
settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None
)
enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
if not enabled or not chat_id:
+10 -2
View File
@@ -260,6 +260,15 @@ class BotConfigurationService:
'ADMIN_NOTIFICATIONS_CHAT_ID': 'ADMIN_NOTIFICATIONS',
'ADMIN_NOTIFICATIONS_TOPIC_ID': 'ADMIN_NOTIFICATIONS',
'ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID': 'ADMIN_NOTIFICATIONS',
'ADMIN_NOTIFICATIONS_PURCHASES_TOPIC_ID': 'ADMIN_NOTIFICATIONS',
'ADMIN_NOTIFICATIONS_RENEWALS_TOPIC_ID': 'ADMIN_NOTIFICATIONS',
'ADMIN_NOTIFICATIONS_TRIALS_TOPIC_ID': 'ADMIN_NOTIFICATIONS',
'ADMIN_NOTIFICATIONS_BALANCE_TOPIC_ID': 'ADMIN_NOTIFICATIONS',
'ADMIN_NOTIFICATIONS_ADDONS_TOPIC_ID': 'ADMIN_NOTIFICATIONS',
'ADMIN_NOTIFICATIONS_INFRASTRUCTURE_TOPIC_ID': 'ADMIN_NOTIFICATIONS',
'ADMIN_NOTIFICATIONS_ERRORS_TOPIC_ID': 'ADMIN_NOTIFICATIONS',
'ADMIN_NOTIFICATIONS_PROMO_TOPIC_ID': 'ADMIN_NOTIFICATIONS',
'ADMIN_NOTIFICATIONS_PARTNERS_TOPIC_ID': 'ADMIN_NOTIFICATIONS',
'ADMIN_REPORTS_ENABLED': 'ADMIN_REPORTS',
'ADMIN_REPORTS_CHAT_ID': 'ADMIN_REPORTS',
'ADMIN_REPORTS_TOPIC_ID': 'ADMIN_REPORTS',
@@ -275,7 +284,6 @@ class BotConfigurationService:
'SIMPLE_SUBSCRIPTION_DEVICE_LIMIT': 'SIMPLE_SUBSCRIPTION',
'SIMPLE_SUBSCRIPTION_TRAFFIC_GB': 'SIMPLE_SUBSCRIPTION',
'SIMPLE_SUBSCRIPTION_SQUAD_UUID': 'SIMPLE_SUBSCRIPTION',
'DISABLE_TOPUP_BUTTONS': 'PAYMENT',
'SUPPORT_TOPUP_ENABLED': 'PAYMENT',
'ENABLE_NOTIFICATIONS': 'NOTIFICATIONS',
'NOTIFICATION_RETRY_ATTEMPTS': 'NOTIFICATIONS',
@@ -572,7 +580,7 @@ class BotConfigurationService:
'format': 'Булево значение.',
'example': 'Включите после указания токена API и секрета вебхука.',
'warning': 'Пустой токен или неверный вебхук приведут к отказам платежей.',
'dependencies': 'CRYPTOBOT_API_TOKEN, CRYPTOBOT_WEBHOOK_SECRET',
'dependencies': 'CRYPTOBOT_API_TOKEN',
},
'PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED': {
'description': (
+9 -2
View File
@@ -400,9 +400,16 @@ return c
return fail_closed
@staticmethod
async def is_rate_limited(user_id: int, action: str, limit: int, window: int) -> bool:
async def is_rate_limited(
user_id: int,
action: str,
limit: int,
window: int,
*,
fail_closed: bool = False,
) -> bool:
key = cache_key('rate_limit', user_id, action)
return await RateLimitCache._atomic_rate_check(key, limit, window)
return await RateLimitCache._atomic_rate_check(key, limit, window, fail_closed=fail_closed)
@staticmethod
async def reset_rate_limit(user_id: int, action: str) -> bool:
+5 -1
View File
@@ -312,7 +312,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
)
signature = request.headers.get('Crypto-Pay-API-Signature')
secret = settings.CRYPTOBOT_WEBHOOK_SECRET
secret = settings.CRYPTOBOT_API_TOKEN
if secret:
if not signature:
return JSONResponse(
@@ -682,6 +682,10 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
async def platega_webhook(request: Request) -> JSONResponse:
merchant_id = request.headers.get('X-MerchantId', '')
secret = request.headers.get('X-Secret', '')
raw_body = await request.body()
if not merchant_id and not secret and not raw_body.strip():
logger.info('Platega webhook verification ping (no auth headers, empty body)')
return JSONResponse({'status': 'ok'})
if merchant_id != (settings.PLATEGA_MERCHANT_ID or '') or secret != (settings.PLATEGA_SECRET or ''):
return JSONResponse(
{'status': 'error', 'reason': 'unauthorized'},
+5 -1
View File
@@ -45,7 +45,11 @@ def run_migrations_offline() -> None:
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
context.configure(
connection=connection,
target_metadata=target_metadata,
transaction_per_migration=True,
)
with context.begin_transaction():
context.run_migrations()
@@ -0,0 +1,56 @@
"""add performance indexes for referral network queries
Revision ID: 0041
Revises: 0040
Create Date: 2026-03-20
"""
from typing import Sequence, Union
from alembic import op
revision: str = '0041'
down_revision: Union[str, None] = '0040'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# CREATE INDEX CONCURRENTLY cannot run inside a transaction.
# autocommit_block() temporarily disables the transaction wrapper.
#
# NOTE: If a concurrent index creation fails midway, PostgreSQL leaves behind
# an INVALID index. Check with:
# SELECT indexrelname FROM pg_stat_user_indexes
# JOIN pg_index ON pg_index.indexrelid = pg_stat_user_indexes.indexrelid
# WHERE NOT pg_index.indisvalid;
# Then drop the invalid index and re-run the migration.
with op.get_context().autocommit_block():
# Index on advertising_campaign_registrations(user_id, created_at)
# Fixes sequential scan in _fetch_campaign_registrations which filters by user_id
# and uses ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at)
op.create_index(
'ix_campaign_reg_user_created',
'advertising_campaign_registrations',
['user_id', 'created_at'],
if_not_exists=True,
postgresql_concurrently=True,
)
# Covering composite index on transactions(user_id, type, is_completed, amount_kopeks)
# Enables index-only scans for aggregation queries in referral network stats:
# _fetch_personal_spent, _fetch_branch_revenue, _fetch_campaign_stats
op.create_index(
'ix_transactions_user_type_completed_amount',
'transactions',
['user_id', 'type', 'is_completed', 'amount_kopeks'],
if_not_exists=True,
postgresql_concurrently=True,
)
def downgrade() -> None:
with op.get_context().autocommit_block():
op.execute('DROP INDEX CONCURRENTLY IF EXISTS ix_transactions_user_type_completed_amount')
op.execute('DROP INDEX CONCURRENTLY IF EXISTS ix_campaign_reg_user_created')
@@ -0,0 +1,88 @@
"""add retry_count to guest_purchases and expression indexes for payment recovery
Revision ID: 0042
Revises: 0041
Create Date: 2026-03-20
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0042'
down_revision: Union[str, None] = '0041'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Payment tables with metadata_json + is_paid column.
_TABLES_WITH_IS_PAID = [
'yookassa_payments',
'mulenpay_payments',
'pal24_payments',
'wata_payments',
'platega_payments',
'cloudpayments_payments',
'freekassa_payments',
'kassa_ai_payments',
'riopay_payments',
'severpay_payments',
]
# All tables that get a metadata purchase_token index (for downgrade)
_ALL_METADATA_TABLES = [*_TABLES_WITH_IS_PAID, 'heleket_payments']
def upgrade() -> None:
# 1. Add retry_count column to guest_purchases (safe: has server_default)
op.add_column(
'guest_purchases',
sa.Column('retry_count', sa.Integer(), nullable=False, server_default='0'),
)
# 2. Create expression indexes for payment recovery queries.
# These allow efficient lookup of succeeded payments by purchase_token
# stored inside the metadata_json column.
with op.get_context().autocommit_block():
# Tables with is_paid boolean column
for table in _TABLES_WITH_IS_PAID:
idx_name = f'ix_{table}_metadata_purchase_token'
op.execute(
sa.text(
f'CREATE INDEX CONCURRENTLY IF NOT EXISTS {idx_name} '
f"ON {table} ((metadata_json ->> 'purchase_token')) "
f'WHERE is_paid = TRUE'
)
)
# Heleket: no is_paid column (it's a Python @property), use status filter
op.execute(
sa.text(
'CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_heleket_payments_metadata_purchase_token '
"ON heleket_payments ((metadata_json ->> 'purchase_token')) "
"WHERE status IN ('paid', 'paid_over')"
)
)
# CryptoBot: payload (text) column with JSON inside, no metadata_json
op.execute(
sa.text(
'CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_cryptobot_payments_payload_purchase_token '
"ON cryptobot_payments ((CAST(payload AS json) ->> 'purchase_token')) "
"WHERE status = 'paid'"
)
)
def downgrade() -> None:
with op.get_context().autocommit_block():
for table in _ALL_METADATA_TABLES:
idx_name = f'ix_{table}_metadata_purchase_token'
op.execute(sa.text(f'DROP INDEX CONCURRENTLY IF EXISTS {idx_name}'))
op.execute(
sa.text('DROP INDEX CONCURRENTLY IF EXISTS ix_cryptobot_payments_payload_purchase_token')
)
op.drop_column('guest_purchases', 'retry_count')
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = 'remnawave-bedolaga-telegram-bot'
version = "3.34.0"
version = "3.36.0"
description = 'Telegram bot for RemnaWave VPN service'
readme = 'README.md'
license = { text = 'MIT' }
+3 -4
View File
@@ -27,7 +27,6 @@ def anyio_backend() -> str:
def _enable_token(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, 'CRYPTOBOT_API_TOKEN', 'token', raising=False)
monkeypatch.setattr(type(settings), 'get_cryptobot_base_url', lambda self: 'https://cryptobot.test', raising=False)
monkeypatch.setattr(settings, 'CRYPTOBOT_WEBHOOK_SECRET', 'secret', raising=False)
@pytest.mark.anyio('asyncio')
@@ -69,7 +68,7 @@ async def test_make_request_returns_none_without_token(monkeypatch: pytest.Monke
def test_verify_webhook_signature(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, 'CRYPTOBOT_WEBHOOK_SECRET', 'supersecret', raising=False)
monkeypatch.setattr(settings, 'CRYPTOBOT_API_TOKEN', 'supersecret', raising=False)
service = CryptoBotService()
body = '{"invoice_id":1}'
@@ -80,7 +79,7 @@ def test_verify_webhook_signature(monkeypatch: pytest.MonkeyPatch) -> None:
assert service.verify_webhook_signature(body, 'invalid') is False
def test_verify_webhook_signature_without_secret(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, 'CRYPTOBOT_WEBHOOK_SECRET', '', raising=False)
def test_verify_webhook_signature_without_token(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, 'CRYPTOBOT_API_TOKEN', '', raising=False)
service = CryptoBotService()
assert service.verify_webhook_signature('{}', 'anything') is True
Generated
+1 -1
View File
@@ -1115,7 +1115,7 @@ wheels = [
[[package]]
name = "remnawave-bedolaga-telegram-bot"
version = "3.32.4"
version = "3.34.1"
source = { virtual = "." }
dependencies = [
{ name = "aiogram" },