Compare commits

...

148 Commits

Author SHA1 Message Date
Egor df9985802b Merge pull request #2801 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.41.0
2026-03-22 10:56:39 +03:00
github-actions[bot] c66849db10 chore(main): release 3.41.0 2026-03-22 07:56:12 +00:00
Egor 8a1da85f3e Merge pull request #2800 from BEDOLAGA-DEV/dev
Dev
2026-03-22 10:55:46 +03:00
Fringg 0335f40b47 chore: ruff format rbac_bootstrap_service.py 2026-03-22 10:51:26 +03:00
Fringg 8ac1183670 chore: ruff format admin_referral_network.py 2026-03-22 10:47:01 +03:00
Fringg bcc761f9d3 fix: add missing total_subscription_revenue_kopeks in scoped graph early return
Prevents Pydantic ValidationError (500) when scoped_user_ids is empty
but campaign_ids is present.
2026-03-22 10:46:08 +03:00
Fringg 1eb4e18c17 fix: add abs() to all remaining subscription payment sum queries
Apply func.abs() consistently to all 5 remaining locations that sum
SUBSCRIPTION_PAYMENT amounts: branch revenue, campaign stats,
user detail branch revenue, campaign detail, and search results.
2026-03-22 10:39:20 +03:00
Fringg 056c13bc23 fix: use abs() for subscription payment amounts in referral network
SUBSCRIPTION_PAYMENT transactions are stored as negative values,
causing negative totals in stats panel and user detail card.
2026-03-22 10:36:13 +03:00
Fringg 2bdb7643f8 feat: add total subscription revenue to referral network stats
Expose total_subscription_revenue_kopeks in NetworkGraphResponse,
computed from the existing personal_spent data (sum of all
SUBSCRIPTION_PAYMENT transactions by scoped users).
2026-03-22 10:31:18 +03:00
Fringg 8b8f1b91f3 refactor: extract _compute_subscription_status shared helper
Eliminates duplicated status mapping logic between _fetch_subscription_info
and get_network_user_detail. Single source of truth for mapping
subscription fields to frontend status labels.
2026-03-22 10:08:50 +03:00
Fringg 5ed2f0c958 fix: treat expired and limited subscription statuses as inactive in referral network graph
Previously only disabled and pending statuses were forced to show as
expired in the network graph. Subscriptions with status='expired' or
status='limited' but with end_date > now would incorrectly display as
active. Now all four non-active statuses are treated as expired.
2026-03-22 09:54:59 +03:00
Fringg 454dc9321b fix: consider subscription status field in network graph
The subscription_status computation now checks the Subscription.status
field. Disabled and pending subscriptions are treated as expired
regardless of end_date, preventing incorrect "active" display.
Also added SubscriptionStatus import.
2026-03-22 09:51:59 +03:00
Fringg de91d3282f feat: add subscription status to referral network graph nodes
Add subscription_status field (trial_active, paid_active, trial_expired,
paid_expired) to NetworkUserNode and NetworkUserDetail schemas. Backend
computes status from Subscription.is_trial and end_date using window
function to pick latest subscription per user.
2026-03-22 09:07:29 +03:00
Fringg e0bedc8e78 fix: superadmin role managed exclusively via env config
Superadmin (level 999) assignments are now the sole domain of
ADMIN_IDS/ADMIN_EMAILS environment variables. On startup, bootstrap
reactivates env-listed users and revokes superadmin from users removed
from env. API assign/revoke endpoints return 403 for superadmin-level
roles. _ensure_role_by_email now requires email_verified (symmetric
with revocation check).
2026-03-22 08:41:36 +03:00
Egor 4a48818bc3 Merge pull request #2798 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.40.0
2026-03-22 07:36:14 +03:00
github-actions[bot] ff7388766c chore(main): release 3.40.0 2026-03-22 04:35:51 +00:00
Egor 6b36dc4df1 Merge pull request #2797 from BEDOLAGA-DEV/dev
Dev
2026-03-22 07:35:24 +03:00
Fringg fe847e35f0 chore: ruff format oauth, auth schemas, webhook service 2026-03-22 07:32:04 +03:00
Fringg 4c2cb63cf9 fix: accept stale Telegram initData to prevent MiniApp auth failures
Telegram Desktop/iOS cache initData with stale auth_date (tdesktop#28303).
Increase max_age_seconds from 24h to 30 days for all cabinet login and
account linking endpoints. HMAC signature still validates authenticity,
JWT tokens handle session expiration. Add structured logging for stale
initData acceptance monitoring.
2026-03-22 07:24:27 +03:00
Fringg d3c994083e fix: daily subscription pause not persisting in cabinet and miniapp
lock_user_for_pricing with populate_existing=True was overwriting the
pending is_daily_paused mutation before db.commit(), silently discarding
the pause toggle. Fix moves lock before state reads, uses commit=False
for subtract_user_balance and create_transaction to ensure single atomic
commit, and re-applies is_daily_paused after any populate_existing reload.
2026-03-22 06:54:39 +03:00
Fringg cce3b0c13b feat: allow inactive tariffs for trial subscription activation
Inactive tariffs with is_trial_available=True can now be used for trial
activation across bot, miniapp, and cabinet. This enables dedicated trial
tariffs with custom limits (traffic, devices, servers) without exposing
them in the regular purchase flow. Paid trial paths now properly resolve
trial tariff parameters instead of using global settings defaults.
2026-03-22 06:01:34 +03:00
Fringg 6c208581d9 fix: sanitize email dots in RemnaWave username generation
Email addresses with dots (e.g., john.doe@gmail.com) caused RemnaWave
API validation failure. Now sanitizes email prefix early in the
identifier construction, not just in the final result. Also sanitizes
the fallback username path for defense in depth.
2026-03-22 04:59:30 +03:00
Fringg c307278231 fix: prevent MESSAGE_TOO_LONG in promo groups list
With 20+ promo groups, the full details per group exceeded Telegram's
4096 char limit. Simplified list to one compact line per group with
name and member count. Full details remain in the group detail view.
2026-03-22 04:17:32 +03:00
Fringg 9eab802000 fix: handle spurious user.deleted webhooks — preserve active subscriptions and prevent orphaned panel users
- Smart end_date check: subscriptions with future end_date are preserved (not expired) and panel user is re-created automatically
- Recreation loop guard: in-memory 120s cooldown prevents unbounded recreate→delete→recreate cycles with stale entry eviction
- Race condition protection: guard timestamp stamped before any await point so concurrent coroutines are serialized
- Admin deletion: force_panel_delete=True ensures panel user is always removed, preventing orphaned subscriptions
2026-03-22 03:20:50 +03:00
Fringg 13ea3768b5 feat: custom broadcast buttons and fix home button to use bot menu
- Add custom buttons support: admins can add up to 10 custom buttons
  with callback_data or URL action types to broadcast messages
- CustomBroadcastButton Pydantic model with validation:
  callback_data checked in UTF-8 bytes (Telegram 64-byte limit),
  URLs restricted to https:// and tg:// schemes only
- Fix home button: removed from CABINET_MINIAPP_BUTTON_KEYS so it
  uses back_to_menu callback instead of opening cabinet WebApp
- Both legacy and combined broadcast endpoints pass custom_buttons
2026-03-22 01:54:05 +03:00
Fringg ed5a92ab96 fix: referral system — self-referral protection, race condition fix, deleted user re-registration
- Add telegram_id-based self-referral protection in all 3 Telegram auth endpoints
  (user doesn't exist yet at referral resolution, so telegram_id is used instead of user.id)
- Add SELECT FOR UPDATE + db.refresh in _process_referral_code to prevent TOCTOU race
  on concurrent referral assignment (matches _process_campaign_bonus pattern)
- Fix _process_referral_code to handle two cases: referred_by_id already set by
  create_user() → fire registration event; not set → resolve code, set, fire event
- Fix deleted user re-registration losing referral: keep status=DELETED in preparation
  block so complete_registration enters the DELETED branch (not "already active")
- Remove unused referral_code from DeepLinkPollRequest (deep link = existing users only)
- Fix OIDC exception handling inconsistency (ValueError/LookupError → Exception)
- Fix bare except clauses in start.py → except Exception
- Pass is_new_user to _finalize_oauth_login (only new user path passes True)
2026-03-21 15:06:10 +03:00
Fringg 48265f1cd4 chore: remove redundant comments from DISABLED status fix 2026-03-21 09:07:43 +03:00
Fringg 3b9568fcc1 style: format long lines in monitoring and subscription services 2026-03-21 09:06:01 +03:00
Fringg 79cfcbcece fix: send DISABLED instead of EXPIRED status to RemnaWave API
RemnaWave API only accepts ACTIVE/DISABLED for user status updates —
EXPIRED and LIMITED are managed internally. The bot was sending EXPIRED
status and past expireAt dates, causing 400 validation errors.

- Change UserStatus.EXPIRED → UserStatus.DISABLED in all 5 call sites
- Add 1-minute buffer to expire_at for inactive subscriptions to avoid
  "expiration date in the past" rejections (matches _safe_expire_at_for_panel)
- Include TRIAL status in is_actually_active checks (consistent with
  remnawave_service.py sync_users_to_panel)
2026-03-21 09:00:59 +03:00
Egor 6d5aceb4ca Merge pull request #2793 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.39.0
2026-03-21 07:41:49 +03:00
github-actions[bot] 905dbcc779 chore(main): release 3.39.0 2026-03-21 04:40:13 +00:00
Egor f33dfdf031 Merge pull request #2792 from BEDOLAGA-DEV/dev
Dev
2026-03-21 07:39:51 +03:00
Egor 59cd74d307 Merge pull request #2791 from BEDOLAGA-DEV/main
w
2026-03-21 07:38:22 +03:00
Fringg 90209ebef1 feat: add NaloGO fiscal receipts for code-only gift purchases
- Create NaloGO receipt when code-only gifts (no recipient) are paid via
  any gateway provider, not just directed gifts
- Add receipt_uuid and receipt_created_at columns to guest_purchases for
  persistent DB-level dedup (covers PENDING_ACTIVATION and code-only paths
  where no Transaction exists at receipt time)
- Use SELECT ... FOR UPDATE in try_fulfill_guest_purchase to prevent
  concurrent webhook double-processing race condition
- Expand idempotency guard to include code-only gifts already in PAID status
- Add db.refresh after PENDING_ACTIVATION nalogo call to guard against
  inner rollback expiring the ORM object
2026-03-21 07:37:03 +03:00
Fringg ab43e74ab7 fix: manual admin top-ups missing from sales statistics
Cabinet API and WebAPI created admin balance transactions with
payment_method=NULL instead of 'manual', making them invisible
to sales statistics filters.

Changes:
- Add payment_method=PaymentMethod.MANUAL to Cabinet and WebAPI
  balance update endpoints
- Add func.abs() to all transaction amount aggregations missing it
  across sales stats, dashboard stats, and reporting queries
- Remove redundant Python abs() on addon_revenue (SQL func.abs
  already applied)
- Add data migration 0044 to fix historical NULL payment_method
  records for admin top-ups
2026-03-21 07:01:22 +03:00
Fringg 4244962337 fix: add NaloGO fiscal receipt creation for landing page purchases
Landing page (guest) payments were completely skipping nalogo receipt
generation because the guest purchase flow returned early in payment
webhook handlers before reaching the nalogo code.

Added _create_nalogo_receipt_for_purchase() helper with:
- payment_id null-check (Redis dedup requires it)
- amount validation (skip zero/negative)
- transaction.receipt_uuid duplicate guard
- inner try/except with db.rollback() for receipt_uuid persistence
- sanitize_proxy_error for credential-safe error logging
- privacy: no telegram_user_id in receipt description sent to tax authority

Called in both DELIVERED and PENDING_ACTIVATION paths.
Added db.refresh(purchase) after nalogo call to handle potential
session expiry from rollback inside the helper.
2026-03-21 06:36:21 +03:00
Fringg ba79d03e38 fix: skip non-JSON payload rows in cryptobot payment index and query
payload column in cryptobot_payments contains plain strings like
"balance_2_10000" alongside JSON objects. CAST(payload AS json) fails
on these rows during CREATE INDEX CONCURRENTLY.

- Add AND payload LIKE '{%' to partial index WHERE clause in migration 0042
- Add .payload.like('{%') filter to guest_purchase_service query
2026-03-21 05:43:22 +03:00
Egor 8e4e2ddd1a Update README.md 2026-03-21 05:09:00 +03:00
Egor 38853cdd5a Merge pull request #2790 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.38.0
2026-03-21 04:32:38 +03:00
github-actions[bot] f837c0c244 chore(main): release 3.38.0 2026-03-21 01:32:11 +00:00
Egor 8a7b9cc651 Merge pull request #2789 from BEDOLAGA-DEV/dev
Dev
2026-03-21 04:31:51 +03:00
Fringg 3bf31055e7 fix: sanitize proxy credentials in all nalogo error paths
- Apply sanitize_proxy_error() to all 8 error handlers in nalogo_service
- Remove exc_info=True from error paths that could expose proxy creds
- Fix regex backreference to preserve original SOCKS scheme
- Consolidate proxy utility imports to module level
- Add source indicator (NALOGO_PROXY_URL vs fallback) to startup log
2026-03-21 04:27:15 +03:00
Fringg 3c5bf4fa22 feat: add SOCKS proxy support for nalogo (tax service) module
Route all nalog.ru API traffic through SOCKS proxy. Uses NALOGO_PROXY_URL
env var (falls back to PROXY_URL if not set). Adds httpx[socks] dependency.

- Thread proxy_url through Client → AuthProviderImpl + AsyncHTTPClient
- Extract mask_proxy_url() and sanitize_proxy_error() utilities
- Add socks5h:// scheme support for remote DNS resolution
- Sanitize proxy credentials in error messages
- Log masked proxy URL at startup and service init
2026-03-21 04:21:11 +03:00
Fringg 4990ddf9e4 fix: add diagnostic payload logging in create_user error path
Consistent with update_user — log full payload before re-raising
non-A039 errors to aid debugging.
2026-03-21 04:08:18 +03:00
Fringg de00612965 fix: retry Remnawave API calls without externalSquadUuid on A039 FK violation
When a tariff has a stale external_squad_uuid that no longer exists in
the Remnawave panel, PATCH/POST /api/users fails with A039 (P2003 FK
constraint violation). This caused subscriptions to not sync with the
panel even though balance was already charged.

Now both update_user() and create_user() catch A039 errors and
automatically retry without externalSquadUuid, logging a warning about
the stale UUID. The subscription sync succeeds without the external
squad assignment rather than failing entirely.
2026-03-21 03:58:21 +03:00
Egor 43f5629c8c Merge pull request #2788 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.37.0
2026-03-21 03:18:30 +03:00
github-actions[bot] 448799a3ed chore(main): release 3.37.0 2026-03-21 00:18:10 +00:00
Egor b2b5f104b5 Merge pull request #2787 from BEDOLAGA-DEV/dev
Dev
2026-03-21 03:17:44 +03:00
Egor 5f71eaa926 Merge pull request #2778 from smediainfo/fix/dashboard-guest-revenue
fix: include SUBSCRIPTION_PAYMENT in dashboard revenue
2026-03-21 03:15:20 +03:00
Fringg 54155b5649 style: ruff format cloudpayments.py 2026-03-21 03:14:15 +03:00
Egor 8dc778654d Merge pull request #2785 from BEDOLAGA-DEV/revert-2783-revert-2779-fix/dashboard-revenue-complete
Revert 2783 revert 2779 fix/dashboard revenue complete
2026-03-21 03:11:03 +03:00
Egor 5175cccab6 Revert "Revert "fix: include landing page revenue in dashboard statistics"" 2026-03-21 03:07:19 +03:00
Egor 39ae095d9b Merge pull request #2786 from BEDOLAGA-DEV/main
w
2026-03-21 03:04:27 +03:00
Egor db3ac254ba Revert "Revert "fix: include landing page revenue in dashboard statistics"" 2026-03-21 03:03:06 +03:00
Egor bff9ebf078 Merge pull request #2783 from BEDOLAGA-DEV/revert-2779-fix/dashboard-revenue-complete
Revert "fix: include landing page revenue in dashboard statistics"
2026-03-21 03:02:57 +03:00
Egor 42ddadec5b Revert "fix: include landing page revenue in dashboard statistics" 2026-03-21 03:02:36 +03:00
Egor c6c1599e14 Merge pull request #2779 from smediainfo/fix/dashboard-revenue-complete
fix: include landing page revenue in dashboard statistics
2026-03-21 03:02:09 +03:00
Egor 77b2d645c5 Merge pull request #2782 from SayonaraQ/pr/remnawave-node-webhook-toggle-dev
add toggle for Remnawave node connection webhook alerts
2026-03-21 03:00:13 +03:00
Fringg 3875335cd7 fix: narrow exception handling and fix session leak in gift.py
- Replace broad `except Exception` with `except TelegramAPIError` in
  balance.py and wheel.py Stars invoice creation (prevents masking
  programming errors)
- Fix session leak in gift.py telegram_stars path: wrap PaymentService
  usage in try/finally to ensure bot.session.close() is called
2026-03-21 02:55:48 +03:00
Fringg 0a53b85b8a refactor: centralize Bot instantiation via create_bot() factory
Replace all ~45 direct Bot() calls across the codebase with a centralized
create_bot() factory function that automatically configures SOCKS5 proxy
session when PROXY_URL is set. This ensures proxy support applies uniformly
to all Telegram API traffic.

Key changes:
- Add app/bot_factory.py with create_bot() factory
- Replace direct Bot() instantiation in 33 files
- Fix session leaks in cloudpayments.py and auth.py (async with)
- Replace 2 direct httpx calls to api.telegram.org with
  bot.create_invoice_link() (balance.py, wheel.py)
- Remove now-unused imports (Bot, DefaultBotProperties, ParseMode, httpx)
2026-03-21 02:49:37 +03:00
Fringg 82b6a8bf70 feat: add SOCKS5 proxy support for Telegram API traffic
Route bot traffic through SOCKS5 proxy when PROXY_URL env var is set.
Validates scheme to reject HTTP proxies (would expose bot token).
Credentials are masked in logs.
2026-03-21 02:32:35 +03:00
Fringg 2a72deadd6 fix: resolve EmailService stale SMTP config causing NoneType crash on from_email
EmailService singleton cached SMTP settings in __init__ at import time.
is_configured() read live from settings, but self.from_email stayed None
when SMTP was unconfigured at startup → AttributeError on .split('@').

Replace cached attributes with @property accessors, snapshot from_email
once per send_email call with validation guard.
2026-03-21 02:21:25 +03:00
Fringg afefcc9c07 fix: resolve remaining TOCTOU issues in RioPay, SeverPay and restore paid_at
- RioPay: use create_transaction(commit=False) to keep FOR UPDATE lock,
  replace update_riopay_payment_status with inline assignment + flush,
  add emit_transaction_side_effects after commit
- SeverPay: add db.flush() before _finalize, remove self-assignment,
  add paid_at to both webhook and status-check paths
- Freekassa/KassaAI: add is_paid and paid_at to webhook and status-check
  inline sections (regression from CRUD→inline migration)
- MulenPay: add is_paid and paid_at to webhook inline section
2026-03-21 02:10:41 +03:00
Fringg 82c79c1306 fix: prevent double-payment TOCTOU race in all payment providers
Apply the same FOR UPDATE locking pattern across 8 providers:
- RioPay: added FOR UPDATE lock (had none at all)
- CryptoBot: moved lock before status check, removed redundant lock
- WATA: moved lock before is_paid commit, removed redundant lock
- Freekassa: moved lock before is_paid commit, removed redundant lock
- KassaAI: moved lock before is_paid commit, removed redundant lock
- MulenPay: moved lock before is_paid commit, removed redundant lock
- Pal24: moved lock before is_paid commit, removed redundant lock
- SeverPay: moved lock before is_paid check, removed redundant lock

Pattern applied to all: acquire FOR UPDATE with populate_existing=True
immediately after finding the payment, replace intermediate commits with
inline assignments + flush(), re-check is_paid from locked row.
2026-03-21 01:52:56 +03:00
Fringg 0e1296e0ea fix: prevent double balance credit on concurrent Platega webhooks
- acquire FOR UPDATE lock immediately after payment lookup, before is_paid check
- use populate_existing=True to prevent SQLAlchemy identity map stale reads
- replace intermediate update_platega_payment(commit) with inline assignments + flush
- re-check locked.is_paid after lock in get_platega_payment_status
- guard _finalize_platega_payment: only called when lock held and is_paid=False
- suppress "message is not modified" TelegramBadRequest in message_patch
2026-03-21 01:46:44 +03:00
Fringg 9dd6b54c6e fix: prevent bootstrap from reactivating revoked superadmin roles
- bootstrap _assign_if_missing no longer reactivates revoked UserRole rows
- revoke_role uses SELECT FOR UPDATE + pg_advisory_xact_lock to prevent TOCTOU race on last-superadmin check
- block self-revocation of superadmin role
- block is_active/level changes on system roles
- block expires_at on superadmin role assignments
- single SUPERADMIN_LEVEL constant in crud/rbac.py, imported everywhere
- get_superadmin_count excludes expired assignments
- removed dead UserRoleCRUD.revoke_role method
- warn when revoking RBAC role from a legacy ADMIN_IDS user
- added migration 0043: indexes on user_roles.role_id, access_policies.role_id, lower(users.email)
2026-03-21 01:09:13 +03:00
SayonaraQ 8a5710aff3 style: align webhook service with ruff format 2026-03-21 00:59:18 +03:00
SayonaraQ ce36fba54f style: format remnawave webhook service 2026-03-21 00:54:54 +03:00
SayonaraQ f601bedb48 add toggle for Remnawave node connection webhook alerts 2026-03-21 00:30:49 +03:00
sMedia.tech 801921ff74 fix: increase landing purchase rate limit from 5 to 30 req/min
Users hitting 429 Too Many Requests when trying to purchase on landing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 23:48:02 +03:00
Fringg 67da390371 feat: show both bot and cabinet referral links everywhere
Previously the bot showed only one referral link (cabinet when CABINET_URL
is set, bot otherwise). Users who received the cabinet link were confused —
they opened a web registration form instead of being directed to the bot.

Now the bot, cabinet API, and miniapp API all return both links:
- Bot link (t.me deep link) — always shown
- Cabinet link (web registration) — shown when CABINET_URL is configured

Changes:
- Add get_bot_referral_link() and get_cabinet_referral_link() to config
- Refactor config methods to eliminate code duplication
- Update bot referral handler to display both links
- Fix switch_inline_query 256-char limit with auto-truncation
- Add html_escape() to all user-controlled strings in HTML messages
- Add translations for 5 new keys in all 5 locale files (ru/en/ua/zh/fa)
- Simplify cabinet route to use new methods instead of inline URL construction
- Add bot_referral_link to MiniApp API schema and response
2026-03-20 23:12:22 +03:00
sMedia.tech d400cd7b49 feat: broadcast caption validation + landing daily created stats
- Validate message length for media broadcasts (1024 char Telegram limit)
- Add created count per day to landing stats API (separate from successful)
- Fix total_purchases to show total_created instead of total_successful

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 17:14:27 +03:00
sMedia.tech bf0ba22790 style: ruff format email_templates.py
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:30:47 +03:00
sMedia.tech f82a713110 feat: expose cabinet_email/password vars in subscription delivered template admin UI
Add cabinet_email and cabinet_password to context_vars and sample_contexts
for guest_subscription_delivered template type so they appear in the admin
email template editor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:28:18 +03:00
sMedia.tech fedcf2569a feat: include cabinet credentials in subscription delivered email
Add login/password block to the "subscription ready" email template
so users receive their cabinet credentials in the first email.
The credentials block is only shown when cabinet_password is present
(new accounts). All 5 locales updated (ru, en, zh, ua, fa).

The separate credentials email (GUEST_CABINET_CREDENTIALS) is still
sent as before — this provides redundancy in case one email doesn't
arrive (e.g. due to SMTP quota limits).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:22:22 +03:00
sMedia.tech 1882909b3e fix: derive income_today from revenue_chart to ensure consistency
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:25:39 +03:00
sMedia.tech 226d3f2766 fix: default payment_method to BALANCE for bot subscription payments
Prevents double-counting in revenue: bot users create DEPOSIT (real
money) + SUBSCRIPTION_PAYMENT (balance debit). Without explicit
payment_method, subscription_payment had NULL which was patched to
kassa_ai, causing both to count as real revenue.

Now create_transaction defaults to BALANCE for SUBSCRIPTION_PAYMENT
when no payment_method is specified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:19:55 +03:00
sMedia.tech 6982d27378 fix: include SUBSCRIPTION_PAYMENT in recent payments today/week totals
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:19:55 +03:00
sMedia.tech 27ef75214e fix: include SUBSCRIPTION_PAYMENT in sales summary and deposits stats
Same fix as transaction.py — sales dashboard summary and deposits
breakdown were only counting DEPOSIT transactions, missing all
landing page purchases (SUBSCRIPTION_PAYMENT).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:19:55 +03:00
sMedia.tech 13dba5a303 fix: include SUBSCRIPTION_PAYMENT in dashboard revenue calculations
Guest/landing purchases create SUBSCRIPTION_PAYMENT transactions (not
DEPOSIT), so they were excluded from income_today, total_income,
revenue_by_period, and payment_methods breakdown.

Also use func.abs() for SUBSCRIPTION_PAYMENT amounts since they are
stored as negative values.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:19:55 +03:00
sMedia.tech d7f91c8358 fix: include SUBSCRIPTION_PAYMENT in dashboard revenue calculations
Guest/landing purchases create SUBSCRIPTION_PAYMENT transactions (not
DEPOSIT), so they were excluded from income_today, total_income,
revenue_by_period, and payment_methods breakdown.

Also use func.abs() for SUBSCRIPTION_PAYMENT amounts since they are
stored as negative values.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 10:10:56 +03:00
Egor ee9f0b7382 Merge pull request #2777 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.36.1
2026-03-20 08:48:30 +03:00
github-actions[bot] e16eba10d9 chore(main): release 3.36.1 2026-03-20 05:48:16 +00:00
Egor 1771cc4d13 Merge pull request #2776 from BEDOLAGA-DEV/dev
Dev
2026-03-20 08:47:53 +03:00
Egor 3bda0a2001 Merge pull request #2775 from BEDOLAGA-DEV/main
w
2026-03-20 08:47:02 +03:00
Fringg 877b1cde11 fix: handle duplicate admin roles in RBAC bootstrap
Use scalars().first() instead of scalar_one_or_none() to tolerate
duplicate rows in admin_roles table by (is_system, level).
2026-03-20 08:45:23 +03:00
Fringg 5faf7015ac fix: make migration 0042 idempotent for retry_count column 2026-03-20 08:44:19 +03:00
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
151 changed files with 5258 additions and 4098 deletions
+9 -5
View File
@@ -13,6 +13,11 @@ SUPPORT_USERNAME=@support
# Имя пользователя бота (опционально, автоопределяется)
# BOT_USERNAME=
# ===== SOCKS5 ПРОКСИ =====
# URL SOCKS5 прокси-сервера для маршрутизации трафика бота к Telegram API
# Формат: socks5://user:password@host:port или socks5://host:port
# PROXY_URL=socks5://127.0.0.1:1080
# ===== СИСТЕМА ПОДДЕРЖКИ =====
# Включить меню поддержки в интерфейсе
SUPPORT_MENU_ENABLED=true
@@ -194,6 +199,9 @@ REMNAWAVE_WEBHOOK_PATH=/remnawave-webhook
# Сгенерируйте: openssl rand -hex 32
# ВАЖНО: этот же секрет указывается в панели Remnawave при создании вебхука
REMNAWAVE_WEBHOOK_SECRET=
# Уведомления администраторам о потере/восстановлении связи с нодами
# false = не отправлять события node.connection_lost / node.connection_restored
REMNAWAVE_WEBHOOK_NOTIFY_NODE_CONNECTION_STATUS=true
# ===== УВЕДОМЛЕНИЯ ОТ ВЕБХУКОВ (что получают пользователи) =====
# Глобальный переключатель уведомлений пользователям от вебхуков
@@ -491,16 +499,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
@@ -519,6 +522,7 @@ NALOGO_STORAGE_PATH=./nalogo_tokens.json # Путь к файлу с токен
NALOGO_QUEUE_CHECK_INTERVAL=300 # Интервал проверки очереди чеков (секунды)
NALOGO_QUEUE_RECEIPT_DELAY=3 # Задержка между отправкой чеков (секунды)
NALOGO_QUEUE_MAX_ATTEMPTS=10 # Максимум попыток отправки одного чека
# NALOGO_PROXY_URL=socks5://127.0.0.1:1080 # SOCKS прокси для nalog.ru (если не задан — используется PROXY_URL)
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
# Эти настройки позволяют изменить описания платежей,
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.41.0"
}
+193
View File
@@ -1,5 +1,198 @@
# Changelog
## [3.41.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.40.0...v3.41.0) (2026-03-22)
### New Features
* add subscription status to referral network graph nodes ([de91d32](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/de91d3282ffa15c0cec60c0d62871d39e7ee4c05))
* add total subscription revenue to referral network stats ([2bdb764](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2bdb7643f8fd142e99caee0fe989348161377348))
### Bug Fixes
* add abs() to all remaining subscription payment sum queries ([1eb4e18](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1eb4e18c1776b2265a48e0b923a0ca4ee057d912))
* add missing total_subscription_revenue_kopeks in scoped graph early return ([bcc761f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bcc761f9d3f673bd2b404adf817762058d8e0df4))
* consider subscription status field in network graph ([454dc93](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/454dc9321bb9405c5ff0ff559ae4ced15533f3af))
* superadmin role managed exclusively via env config ([e0bedc8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e0bedc8e780a2f91509517110639773e90bb6125))
* treat expired and limited subscription statuses as inactive in referral network graph ([5ed2f0c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5ed2f0c95842a43ab57220dc05ca346748bd6adb))
* use abs() for subscription payment amounts in referral network ([056c13b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/056c13bc23e6737f44bbcb802a66b643349f75a9))
### Refactoring
* extract _compute_subscription_status shared helper ([8b8f1b9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8b8f1b91f37f829528f785a40e3a9cb98c85e043))
## [3.40.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.39.0...v3.40.0) (2026-03-22)
### New Features
* allow inactive tariffs for trial subscription activation ([cce3b0c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cce3b0c13bcbf0b567bd4dcf2670973382e7cab0))
* custom broadcast buttons and fix home button to use bot menu ([13ea376](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/13ea3768b516337c4e0320120bc60a9acb27a16b))
### Bug Fixes
* accept stale Telegram initData to prevent MiniApp auth failures ([4c2cb63](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c2cb63cf9f71fb392c3723a99e88ca3d02b127d))
* daily subscription pause not persisting in cabinet and miniapp ([d3c9940](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d3c994083e3b054d02d4911172968c914724d051))
* handle spurious user.deleted webhooks — preserve active subscriptions and prevent orphaned panel users ([9eab802](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9eab80200006e576967204b52f90bf9866875917))
* prevent MESSAGE_TOO_LONG in promo groups list ([c307278](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c30727823169159b4b6b61f54897b209ced8dfd2))
* referral system — self-referral protection, race condition fix, deleted user re-registration ([ed5a92a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ed5a92ab966dac54c15217050eae87f4b05eed62))
* sanitize email dots in RemnaWave username generation ([6c20858](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6c208581d936f5ab7d6b978baafd50881b8ce9f1))
* send DISABLED instead of EXPIRED status to RemnaWave API ([79cfcbc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79cfcbcece3938f2daa83206f96ec1bffd0857e0))
## [3.39.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.38.0...v3.39.0) (2026-03-21)
### New Features
* add NaloGO fiscal receipts for code-only gift purchases ([90209eb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/90209ebef1a872665e622124a1898d52eff398e7))
### Bug Fixes
* add NaloGO fiscal receipt creation for landing page purchases ([4244962](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/424496233773b4cee4e389a1172e95208b3afeaf))
* manual admin top-ups missing from sales statistics ([ab43e74](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ab43e74ab7484f8d3517f91e366ea395e1944b99))
* skip non-JSON payload rows in cryptobot payment index and query ([ba79d03](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba79d03e389afed972296fe2bc05104aa6b883f3))
## [3.38.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.37.0...v3.38.0) (2026-03-21)
### New Features
* add SOCKS proxy support for nalogo (tax service) module ([3c5bf4f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3c5bf4fa22d1cdf144269f4e6ab32a4523c8f1f3))
### Bug Fixes
* add diagnostic payload logging in create_user error path ([4990ddf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4990ddf9e46495b65fc3638ea8d6bed0cbe6b857))
* retry Remnawave API calls without externalSquadUuid on A039 FK violation ([de00612](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/de006129657ce3dac2b1f2fc0ab1b91e23e44241))
* sanitize proxy credentials in all nalogo error paths ([3bf3105](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3bf31055e71ff64e6a6d94486bb7f7775ac7dc91))
## [3.37.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.36.1...v3.37.0) (2026-03-21)
### New Features
* add SOCKS5 proxy support for Telegram API traffic ([82b6a8b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/82b6a8bf707736541b58637b0fc9a84b0c403a6c))
* broadcast caption validation + landing daily created stats ([d400cd7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d400cd7b49cb18edf8545a5af54009561610218a))
* expose cabinet_email/password vars in subscription delivered template admin UI ([f82a713](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f82a713110c90494e41f2de9910f5dc60a06962e))
* include cabinet credentials in subscription delivered email ([fedcf25](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fedcf2569a153ac40699dd5b402208ac86db0fc3))
* show both bot and cabinet referral links everywhere ([67da390](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/67da3903715e97c0d84d0018efdd74b085ee8720))
### Bug Fixes
* default payment_method to BALANCE for bot subscription payments ([226d3f2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/226d3f2766bb0842e5a4aefb1458a9e40389108e))
* derive income_today from revenue_chart to ensure consistency ([1882909](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1882909b3e60f4959921ae085e909bc91c0756b3))
* include landing page revenue in dashboard statistics ([c6c1599](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c6c1599e14a8c7033dfa399ff3f2c3ee6d156598))
* include SUBSCRIPTION_PAYMENT in dashboard revenue ([5f71eaa](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f71eaa92604faa2c6928994bd32a9c6c8ae7ae5))
* include SUBSCRIPTION_PAYMENT in dashboard revenue calculations ([13dba5a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/13dba5a303604f8d39e85d1382a4d36225f957ac))
* include SUBSCRIPTION_PAYMENT in dashboard revenue calculations ([d7f91c8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d7f91c83584384ef5d0fc90a28debc722b2db79f))
* include SUBSCRIPTION_PAYMENT in recent payments today/week totals ([6982d27](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6982d27378d0dbe5c7b37d1491e6c9d0461dffc8))
* include SUBSCRIPTION_PAYMENT in sales summary and deposits stats ([27ef752](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/27ef75214e73211a68d541f5375ca14275ea86e9))
* increase landing purchase rate limit from 5 to 30 req/min ([801921f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/801921ff74107daccc17e49a0335c7b133268280))
* narrow exception handling and fix session leak in gift.py ([3875335](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3875335cd7083115043d22f8726f0d33a225cd10))
* prevent bootstrap from reactivating revoked superadmin roles ([9dd6b54](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9dd6b54c6e963d2a842d4ac48c9a463014306ed7))
* prevent double balance credit on concurrent Platega webhooks ([0e1296e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0e1296e0ea291ee661aa82508d5eb8049ce23cac))
* prevent double-payment TOCTOU race in all payment providers ([82c79c1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/82c79c130601736cd149350f761dcceacfb7c2db))
* resolve EmailService stale SMTP config causing NoneType crash on from_email ([2a72dea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2a72deadd6ac3baea15a8d4ee414d763c16690b0))
* resolve remaining TOCTOU issues in RioPay, SeverPay and restore paid_at ([afefcc9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/afefcc9c07a80ecf65871bc2d0fbcfe13ffc24fd))
### Refactoring
* centralize Bot instantiation via create_bot() factory ([0a53b85](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0a53b85b8a3193f67e148c0f2bde246cc92f010c))
## [3.36.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.36.0...v3.36.1) (2026-03-20)
### Bug Fixes
* handle duplicate admin roles in RBAC bootstrap ([877b1cd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/877b1cde11c462c0cf5692119ca09944476a2fb6))
* make migration 0042 idempotent for retry_count column ([5faf701](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5faf7015ac043f44366fd2a5e00be19eab76b945))
## [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.41.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+196 -2149
View File
File diff suppressed because it is too large Load Diff
+14 -3
View File
@@ -96,10 +96,21 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
except Exception as e:
logger.warning('Кеш не инициализирован', error=e)
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from app.bot_factory import create_bot
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
bot = create_bot()
proxy_url = settings.get_proxy_url()
nalogo_proxy_url = settings.get_nalogo_proxy_url()
if proxy_url or nalogo_proxy_url:
from app.utils.proxy import mask_proxy_url
if proxy_url:
logger.info('Proxy configured', proxy_url=mask_proxy_url(proxy_url))
if nalogo_proxy_url:
source = 'NALOGO_PROXY_URL' if settings.NALOGO_PROXY_URL else 'PROXY_URL (fallback)'
logger.info('Nalogo proxy configured', proxy_url=mask_proxy_url(nalogo_proxy_url), source=source)
maintenance_service.set_bot(bot)
logger.info('Бот установлен в maintenance_service')
+20
View File
@@ -0,0 +1,20 @@
"""Factory for creating Bot instances with proxy support."""
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from app.config import settings
def create_bot(token: str | None = None, **kwargs) -> Bot:
"""Create a Bot instance with SOCKS5 proxy session if PROXY_URL is configured."""
proxy_url = settings.get_proxy_url()
session = None
if proxy_url:
from aiogram.client.session.aiohttp import AiohttpSession
session = AiohttpSession(proxy=proxy_url)
kwargs.setdefault('default', DefaultBotProperties(parse_mode=ParseMode.HTML))
return Bot(token=token or settings.BOT_TOKEN, session=session, **kwargs)
+20
View File
@@ -49,7 +49,17 @@ def validate_telegram_login_widget(data: dict[str, Any], max_age_seconds: int =
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
age = (datetime.now(UTC) - auth_time).total_seconds()
if age > max_age_seconds or age < -_MAX_CLOCK_SKEW_SECONDS:
logger.warning(
'Telegram widget auth rejected: too old',
age_hours=round(age / 3600, 1),
max_age_hours=round(max_age_seconds / 3600, 1),
)
return False
if age > 86400:
logger.info(
'Telegram widget auth accepted with stale auth_date',
age_hours=round(age / 3600, 1),
)
except (ValueError, TypeError, OSError):
return False
@@ -96,7 +106,17 @@ def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) ->
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
age = (datetime.now(UTC) - auth_time).total_seconds()
if age > max_age_seconds or age < -_MAX_CLOCK_SKEW_SECONDS:
logger.warning(
'Telegram initData rejected: too old',
age_hours=round(age / 3600, 1),
max_age_hours=round(max_age_seconds / 3600, 1),
)
return None
if age > 86400:
logger.info(
'Telegram initData accepted with stale auth_date (Telegram caching bug)',
age_hours=round(age / 3600, 1),
)
except (ValueError, TypeError, OSError):
return None
+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)
+4 -2
View File
@@ -487,7 +487,8 @@ async def link_telegram(
if request.init_data:
# Mini App flow: validate initData
user_data = validate_telegram_init_data(request.init_data)
# Generous max_age: Telegram Desktop/iOS cache initData with stale auth_date
user_data = validate_telegram_init_data(request.init_data, max_age_seconds=86400 * 30)
if not user_data or not user_data.get('id'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -560,7 +561,8 @@ async def link_telegram(
if request.photo_url is not None:
widget_data['photo_url'] = request.photo_url
if not validate_telegram_login_widget(widget_data):
# Generous max_age: Telegram caches auth data with stale auth_date
if not validate_telegram_login_widget(widget_data, max_age_seconds=86400 * 30):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid or expired Telegram Login Widget data',
+9
View File
@@ -411,6 +411,13 @@ async def create_broadcast(
media_payload = request.media
# Validate caption length for media messages (Telegram limit: 1024 chars)
if media_payload and len(message_text) > 1024:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Текст слишком длинный для сообщения с медиа. Максимум 1024 символов, сейчас {len(message_text)}. Сократите текст или уберите медиафайл.',
)
# Create broadcast record
broadcast = BroadcastHistory(
target_type=request.target,
@@ -446,6 +453,7 @@ async def create_broadcast(
selected_buttons=request.selected_buttons,
media=media_config,
initiator_name=admin.username or f'Admin #{admin.id}',
custom_buttons=[btn.model_dump() for btn in request.custom_buttons] if request.custom_buttons else None,
)
# Start broadcast
@@ -644,6 +652,7 @@ async def create_combined_broadcast(
selected_buttons=request.selected_buttons,
media=media_config,
initiator_name=admin_name,
custom_buttons=[btn.model_dump() for btn in request.custom_buttons] if request.custom_buttons else None,
)
await broadcast_service.start_broadcast(broadcast.id, telegram_config)
+5 -3
View File
@@ -312,7 +312,7 @@ TEMPLATE_TYPES = [
'zh': '通过落地页成功付款后发送给买家的邮件',
'ua': 'Лист покупцю після успішної оплати через лендінг',
},
'context_vars': ['tariff_name', 'period_days', 'cabinet_url'],
'context_vars': ['tariff_name', 'period_days', 'cabinet_url', 'cabinet_email', 'cabinet_password'],
},
{
'type': 'guest_activation_required',
@@ -425,6 +425,8 @@ SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = {
'tariff_name': 'Premium',
'period_days': 30,
'cabinet_url': 'https://example.com/cabinet',
'cabinet_email': 'user@example.com',
'cabinet_password': 'SecurePass123',
},
'guest_activation_required': {
'tariff_name': 'Premium',
@@ -670,8 +672,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
+21 -1
View File
@@ -483,6 +483,7 @@ class OrderRequest(BaseModel):
class LandingDailyStat(BaseModel):
date: str # YYYY-MM-DD
created: int = 0
purchases: int
revenue_kopeks: int
gifts: int
@@ -844,17 +845,35 @@ async def get_landing_stats(
)
daily_rows = {str(r.day): r for r in daily_result.all()}
# Created per day (all statuses, by created_at)
day_created_utc = func.date(func.timezone('UTC', GuestPurchase.created_at))
created_result = await db.execute(
select(
day_created_utc.label('day'),
func.count(GuestPurchase.id).label('created'),
)
.where(
GuestPurchase.landing_id == landing_id,
GuestPurchase.created_at >= cutoff,
)
.group_by(day_created_utc)
.order_by(day_created_utc)
)
created_rows = {str(r.day): r.created for r in created_result.all()}
# Fill missing days with zeros
today = now.date()
daily_stats: list[LandingDailyStat] = []
for i in range(_STATS_PERIOD_DAYS, -1, -1):
day = today - timedelta(days=i)
day_str = day.isoformat()
day_created = created_rows.get(day_str, 0)
if day_str in daily_rows:
r = daily_rows[day_str]
daily_stats.append(
LandingDailyStat(
date=day_str,
created=day_created,
purchases=r.purchases,
revenue_kopeks=r.revenue_kopeks,
gifts=r.gifts,
@@ -864,6 +883,7 @@ async def get_landing_stats(
daily_stats.append(
LandingDailyStat(
date=day_str,
created=day_created,
purchases=0,
revenue_kopeks=0,
gifts=0,
@@ -897,7 +917,7 @@ async def get_landing_stats(
]
return LandingStatsResponse(
total_purchases=total_successful,
total_purchases=total_created,
total_revenue_kopeks=total_revenue_kopeks,
total_gifts=total_gifts,
total_regular=total_regular,
+4 -6
View File
@@ -227,8 +227,7 @@ async def approve_application(
# Notify user about approval
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.config import settings
from app.services.notification_delivery_service import notification_delivery_service
@@ -240,7 +239,7 @@ async def approve_application(
tg_message = (
f'✅ Ваша заявка на партнёрство одобрена!\nКомиссия: {request.commission_percent}%{comment_text}'
)
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
await notification_delivery_service.notify_partner_approved(
user=user,
@@ -280,8 +279,7 @@ async def reject_application(
# Notify user about rejection
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.config import settings
from app.services.notification_delivery_service import notification_delivery_service
@@ -291,7 +289,7 @@ async def reject_application(
if user:
comment_text = f'\nПричина: {request.comment}' if request.comment else ''
tg_message = f'❌ Ваша заявка на партнёрство отклонена.{comment_text}'
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
await notification_delivery_service.notify_partner_rejected(
user=user,
+4 -5
View File
@@ -4,14 +4,11 @@ import math
from datetime import UTC, datetime, timedelta
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.bot_factory import create_bot
from app.database.models import PaymentMethod, User
from app.services.payment_search_service import (
MAX_ALL_TIME_DAYS,
@@ -62,6 +59,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 +283,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,
)
@@ -548,7 +547,7 @@ async def check_payment_status(
old_is_paid = record.is_paid
# Run manual check
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
bot = create_bot()
try:
payment_service = PaymentService(bot=bot)
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
+2 -7
View File
@@ -5,13 +5,11 @@ from datetime import UTC, datetime
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.bot_factory import create_bot
from app.database.models import PinnedMessage, User
from app.services.pinned_message_service import (
broadcast_pinned_message,
@@ -77,10 +75,7 @@ _cached_bot: Bot | None = None
def _get_bot() -> Bot:
global _cached_bot
if _cached_bot is None:
_cached_bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
_cached_bot = create_bot()
return _cached_bot
+2 -7
View File
@@ -8,15 +8,13 @@ from typing import Any
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.bot_factory import create_bot
from app.database.crud.discount_offer import (
count_discount_offers,
list_discount_offers,
@@ -369,10 +367,7 @@ async def list_offers(
def _get_bot() -> Bot:
"""Create bot instance for sending notifications."""
return Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
return create_bot()
def _build_default_promo_message(
File diff suppressed because it is too large Load Diff
+43 -27
View File
@@ -4,12 +4,13 @@ from __future__ import annotations
from datetime import datetime
import sqlalchemy as sa
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.rbac import AdminRoleCRUD, UserRoleCRUD
from app.database.crud.rbac import SUPERADMIN_LEVEL, AdminRoleCRUD, UserRoleCRUD
from app.database.models import User
from app.services.permission_service import PERMISSION_REGISTRY, get_all_permissions
@@ -129,21 +130,26 @@ async def _role_to_response(db: AsyncSession, role) -> RoleResponse:
async def _get_admin_level(db: AsyncSession, admin: User) -> int:
"""Get the maximum role level of the current admin.
"""Get the effective management level of the current admin.
Legacy config-based admins (ADMIN_IDS) get superadmin level (999+1=1000)
so they can manage all roles including level 999.
Superadmin-tier users (DB level 999 or legacy ADMIN_IDS) are promoted to
level 1000 so they can manage peer Superadmins. Without this, the ``>=``
hierarchy guard would block 999-vs-999 operations.
"""
from app.config import settings
_perms, _names, max_level = await UserRoleCRUD.get_user_permissions(db, admin.id)
# DB-assigned Superadmins can manage peers
if max_level >= SUPERADMIN_LEVEL:
max_level = SUPERADMIN_LEVEL + 1
# Legacy config-based admins always get the highest level
if settings.is_admin(
telegram_id=admin.telegram_id,
email=admin.email if admin.email_verified else None,
):
max_level = max(max_level, 1000)
max_level = max(max_level, SUPERADMIN_LEVEL + 1)
return max_level
@@ -339,6 +345,15 @@ async def update_role(
update_data = payload.model_dump(exclude_unset=True)
# System roles: only permissions can be extended, block is_active/level changes
if role.is_system:
blocked = {'is_active', 'level'} & update_data.keys()
if blocked:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f'Cannot change {", ".join(sorted(blocked))} on a system role',
)
# Validate level change
if 'level' in update_data and update_data['level'] >= admin_level:
raise HTTPException(
@@ -426,6 +441,14 @@ async def assign_role(
detail='Role not found',
)
# Superadmin role is managed exclusively via ADMIN_IDS/ADMIN_EMAILS env config
if role.level >= SUPERADMIN_LEVEL:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Superadmin role is managed via ADMIN_IDS/ADMIN_EMAILS environment variables. '
'Add the user there and restart the bot.',
)
admin_level = await _get_admin_level(db, admin)
# Cannot assign a role with level >= own level
@@ -483,13 +506,11 @@ async def revoke_role(
admin: User = Depends(require_permission('roles:assign')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Revoke a role assignment. Cannot remove the last superadmin."""
from sqlalchemy import select as sa_select
"""Revoke a role assignment. Superadmin roles are managed via env config."""
from app.database.models import UserRole
# Load the assignment to check hierarchy
result = await db.execute(sa_select(UserRole).where(UserRole.id == assignment_id))
# Lock the assignment row (FOR UPDATE held until commit)
result = await db.execute(sa.select(UserRole).where(UserRole.id == assignment_id).with_for_update())
user_role = result.scalar_one_or_none()
if not user_role:
raise HTTPException(
@@ -504,6 +525,14 @@ async def revoke_role(
detail='Associated role not found',
)
# Superadmin role is managed exclusively via env config
if role.level >= SUPERADMIN_LEVEL:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Superadmin role is managed via ADMIN_IDS/ADMIN_EMAILS environment variables. '
'Remove the user from env and restart the bot.',
)
admin_level = await _get_admin_level(db, admin)
# Cannot revoke a role at or above own level
@@ -513,23 +542,9 @@ async def revoke_role(
detail='Cannot revoke a role at or above your own level',
)
# Protect last superadmin (level 999)
superadmin_level = 999
if role.level == superadmin_level:
superadmin_count = await UserRoleCRUD.get_superadmin_count(db)
if superadmin_count <= 1:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot remove the last superadmin',
)
revoked = await UserRoleCRUD.revoke_role(db, assignment_id)
if not revoked:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to revoke role',
)
# Revoke directly on the locked object (avoid CRUD re-fetch without FOR UPDATE)
user_role.is_active = False
await db.flush()
await db.commit()
logger.info(
@@ -539,4 +554,5 @@ async def revoke_role(
target_user_id=user_role.user_id,
role_name=role.name,
)
return {'message': 'Role revoked', 'assignment_id': assignment_id}
+32 -14
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
@@ -110,11 +112,11 @@ async def get_sales_summary(
try:
period_start, period_end = _parse_period(days, start_date, end_date)
# Total revenue (deposits with real payment methods)
# Total revenue (deposits + direct subscription payments with real payment methods)
revenue_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
Transaction.is_completed == True,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
Transaction.created_at >= period_start,
@@ -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(func.abs(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(
@@ -230,7 +246,7 @@ async def get_sales_summary(
# Add-on revenue
addon_revenue_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
and_(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.is_completed == True,
@@ -240,10 +256,11 @@ async def get_sales_summary(
)
)
)
addon_revenue = abs(addon_revenue_result.scalar() or 0)
addon_revenue = 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.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.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,
)
@@ -1071,7 +1089,7 @@ async def get_deposits_stats(
totals_result = await db.execute(
select(
func.count(Transaction.id).label('count'),
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
).where(base_filter)
)
totals = totals_result.one()
@@ -1083,11 +1101,11 @@ async def get_deposits_stats(
select(
Transaction.payment_method.label('method'),
func.count(Transaction.id).label('count'),
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
)
.where(base_filter)
.group_by(Transaction.payment_method)
.order_by(func.sum(Transaction.amount_kopeks).desc())
.order_by(func.sum(func.abs(Transaction.amount_kopeks)).desc())
)
by_method = [
DepositByMethodItem(method=row.method or 'unknown', count=row.count, amount_kopeks=row.amount)
@@ -1098,7 +1116,7 @@ async def get_deposits_stats(
select(
func.date(Transaction.created_at).label('date'),
func.count(Transaction.id).label('count'),
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
)
.where(base_filter)
.group_by(func.date(Transaction.created_at))
@@ -1114,12 +1132,12 @@ 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'),
Transaction.payment_method.label('method'),
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
)
.where(base_filter)
.group_by(func.date(Transaction.created_at), Transaction.payment_method)
+14 -6
View File
@@ -275,6 +275,14 @@ async def get_dashboard_stats(
# Get tariff statistics
tariff_stats = await _get_tariff_stats(db)
# Derive income_today from revenue_chart to ensure consistency with chart
today_str = now.date().isoformat()
income_today_from_chart = sum(
item.get('amount_kopeks', 0) for item in revenue_data if str(item.get('date', '')) == today_str
)
# Use chart-derived value if available, otherwise fall back to trans_stats
income_today_kopeks = income_today_from_chart or trans_stats.get('today', {}).get('income_kopeks', 0)
# Build response
return DashboardStats(
nodes=nodes_data,
@@ -290,8 +298,8 @@ async def get_dashboard_stats(
trial_to_paid_conversion=sub_stats.get('trial_to_paid_conversion', 0.0),
),
financial=FinancialStats(
income_today_kopeks=trans_stats.get('today', {}).get('income_kopeks', 0),
income_today_rubles=trans_stats.get('today', {}).get('income_kopeks', 0) / 100,
income_today_kopeks=income_today_kopeks,
income_today_rubles=income_today_kopeks / 100,
income_month_kopeks=trans_stats.get('totals', {}).get('income_kopeks', 0),
income_month_rubles=trans_stats.get('totals', {}).get('income_kopeks', 0) / 100,
income_total_kopeks=all_time_stats.get('totals', {}).get('income_kopeks', 0),
@@ -926,9 +934,9 @@ async def get_recent_payments(
total_count = total_count_result.scalar() or 0
today_total_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
Transaction.is_completed == True,
Transaction.created_at >= today_start,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
@@ -938,9 +946,9 @@ async def get_recent_payments(
total_today = today_total_result.scalar() or 0
week_total_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
Transaction.is_completed == True,
Transaction.created_at >= week_ago,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
+21 -8
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)
@@ -461,14 +479,9 @@ async def reply_to_ticket(
# Try to notify user via Telegram
try:
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from app.bot_factory import create_bot
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
bot = create_bot()
try:
from app.handlers.admin.tickets import notify_user_about_ticket_reply
+2 -8
View File
@@ -7,16 +7,13 @@ import time
from datetime import UTC, datetime, timedelta
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.types import BufferedInputFile
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.bot_factory import create_bot
from app.database.models import Subscription, Transaction, TransactionType, User
from app.services.remnawave_service import RemnaWaveService
@@ -680,10 +677,7 @@ async def export_traffic_csv(
filename = f'traffic_usage_{period_label}_{timestamp}.csv'
try:
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
bot = create_bot()
async with bot:
await bot.send_document(
chat_id=admin.telegram_id,
+3
View File
@@ -29,6 +29,7 @@ from app.database.crud.user import (
from app.database.crud.user_promo_group import sync_user_primary_promo_group
from app.database.models import (
GuestPurchase,
PaymentMethod,
PromoGroup,
ReferralEarning,
Subscription,
@@ -897,6 +898,7 @@ async def update_user_balance(
description=request.description,
create_transaction=request.create_transaction,
transaction_type=TransactionType.DEPOSIT,
payment_method=PaymentMethod.MANUAL,
)
else:
# Subtract balance
@@ -912,6 +914,7 @@ async def update_user_balance(
amount_kopeks=amount_to_subtract,
description=request.description,
create_transaction=request.create_transaction,
payment_method=PaymentMethod.MANUAL,
)
if not success:
+4 -6
View File
@@ -199,8 +199,7 @@ async def approve_withdrawal(
# Notify user about approval
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.config import settings
from app.services.notification_delivery_service import notification_delivery_service
@@ -211,7 +210,7 @@ async def approve_withdrawal(
formatted_amount = settings.format_price(withdrawal.amount_kopeks)
comment_text = f'\n{request.comment}' if request.comment else ''
tg_message = f'✅ Ваш запрос на вывод {formatted_amount} одобрен.{comment_text}'
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
await notification_delivery_service.notify_withdrawal_approved(
user=user,
@@ -251,8 +250,7 @@ async def reject_withdrawal(
# Notify user about rejection
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.config import settings
from app.services.notification_delivery_service import notification_delivery_service
@@ -263,7 +261,7 @@ async def reject_withdrawal(
formatted_amount = settings.format_price(withdrawal.amount_kopeks)
comment_text = f'\nПричина: {request.comment}' if request.comment else ''
tg_message = f'❌ Ваш запрос на вывод {formatted_amount} отклонён.{comment_text}'
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
await notification_delivery_service.notify_withdrawal_rejected(
user=user,
+91 -29
View File
@@ -196,12 +196,10 @@ async def _process_campaign_bonus(
user.referred_by_id = campaign.partner_user_id
await db.flush()
try:
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from app.bot_factory import create_bot
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot)
async with create_bot() as bot:
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot)
logger.info(
'Referral set from campaign partner',
user_id=user.id,
@@ -241,11 +239,39 @@ async def _process_referral_code(
db: AsyncSession,
user: User,
referral_code: str | None,
*,
is_new_user: bool = False,
) -> None:
"""Set referred_by_id for user if referral_code is valid. Never raises."""
if not referral_code or user.referred_by_id:
"""Process referral for a newly created user. Never raises.
Only applies to new users (is_new_user=True). Existing users cannot be
assigned a referrer same logic as the bot /start handler.
Handles two cases:
- referred_by_id already set by create_user() fire registration event
- referred_by_id not set (resolution failed earlier) resolve, set, fire
"""
if not referral_code or not is_new_user:
return
try:
from app.bot_factory import create_bot
# Lock user row to prevent concurrent referral application (TOCTOU race)
await db.execute(select(User).where(User.id == user.id).with_for_update())
await db.refresh(user)
# Case 1: referred_by_id already set by create_user() — just fire the event
if user.referred_by_id:
async with create_bot() as bot:
await process_referral_registration(db, user.id, user.referred_by_id, bot=bot)
logger.info(
'Referral registration processed for pre-set referrer',
user_id=user.id,
referrer_id=user.referred_by_id,
)
return
# Case 2: referred_by_id not set — resolve referral code and set it
referrer = await get_user_by_referral_code(db, referral_code)
if not referrer:
return
@@ -255,12 +281,9 @@ async def _process_referral_code(
return
user.referred_by_id = referrer.id
await db.flush()
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
await process_referral_registration(db, user.id, referrer.id, bot=bot)
async with create_bot() as bot:
await process_referral_registration(db, user.id, referrer.id, bot=bot)
logger.info('Referral applied from code', user_id=user.id, referrer_id=referrer.id, referral_code=referral_code)
except Exception as e:
logger.error('Failed to process referral code', error=e, referral_code=referral_code)
@@ -408,7 +431,11 @@ async def auth_telegram(
detail='Too many requests',
headers={'Retry-After': '60'},
)
user_data = validate_telegram_init_data(request.init_data)
# Telegram Desktop/iOS cache initData with stale auth_date (known Telegram bug:
# https://github.com/telegramdesktop/tdesktop/issues/28303).
# Use generous max_age: HMAC signature proves authenticity,
# JWT tokens handle actual session expiration after login.
user_data = validate_telegram_init_data(request.init_data, max_age_seconds=86400 * 30)
if not user_data:
raise HTTPException(
@@ -437,10 +464,19 @@ async def auth_telegram(
try:
referrer = await get_user_by_referral_code(db, request.referral_code)
if referrer:
referrer_id = referrer.id
# Self-referral protection by telegram_id (user doesn't exist yet, can't compare user.id)
if referrer.telegram_id and referrer.telegram_id == telegram_id:
logger.warning(
'Self-referral attempt blocked via telegram_id',
telegram_id=telegram_id,
referral_code=request.referral_code,
)
else:
referrer_id = referrer.id
except Exception as e:
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
is_new_user = not user
if not user:
# Create new user from Telegram initData
logger.info('Creating new user from cabinet (initData): telegram_id', telegram_id=telegram_id)
@@ -484,8 +520,8 @@ async def auth_telegram(
# Store refresh token
await _store_refresh_token(db, user.id, response.refresh_token)
# Process referral code (before campaign bonus, which may also set referrer)
await _process_referral_code(db, user, request.referral_code)
# Process referral code (only for new users — existing users cannot be assigned a referrer)
await _process_referral_code(db, user, request.referral_code, is_new_user=is_new_user)
# Process campaign bonus
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
@@ -518,7 +554,8 @@ async def auth_telegram_widget(
widget_data = request.model_dump(exclude={'campaign_slug', 'referral_code'})
if not validate_telegram_login_widget(widget_data):
# Generous max_age: Telegram caches auth data with stale auth_date
if not validate_telegram_login_widget(widget_data, max_age_seconds=86400 * 30):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid or expired Telegram authentication data',
@@ -532,10 +569,19 @@ async def auth_telegram_widget(
try:
referrer = await get_user_by_referral_code(db, request.referral_code)
if referrer:
referrer_id = referrer.id
# Self-referral protection by telegram_id (user doesn't exist yet, can't compare user.id)
if referrer.telegram_id and referrer.telegram_id == request.id:
logger.warning(
'Self-referral attempt blocked via telegram_id',
telegram_id=request.id,
referral_code=request.referral_code,
)
else:
referrer_id = referrer.id
except Exception as e:
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
is_new_user = not user
if not user:
# Create new user from Telegram data
logger.info(
@@ -572,8 +618,8 @@ async def auth_telegram_widget(
response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, response.refresh_token)
# Process referral code (before campaign bonus, which may also set referrer)
await _process_referral_code(db, user, request.referral_code)
# Process referral code (only for new users — existing users cannot be assigned a referrer)
await _process_referral_code(db, user, request.referral_code, is_new_user=is_new_user)
# Process campaign bonus
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
@@ -664,10 +710,19 @@ async def auth_telegram_oidc(
try:
referrer = await get_user_by_referral_code(db, request.referral_code)
if referrer:
referrer_id = referrer.id
except (ValueError, LookupError) as e:
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=str(e))
# Self-referral protection by telegram_id (user doesn't exist yet, can't compare user.id)
if referrer.telegram_id and referrer.telegram_id == telegram_id:
logger.warning(
'Self-referral attempt blocked via telegram_id',
telegram_id=telegram_id,
referral_code=request.referral_code,
)
else:
referrer_id = referrer.id
except Exception as e:
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
is_new_user = not user
if not user:
logger.info('Creating new user from cabinet OIDC', telegram_id=telegram_id, username=username)
user = await create_user(
@@ -701,7 +756,8 @@ async def auth_telegram_oidc(
response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, response.refresh_token)
await _process_referral_code(db, user, request.referral_code)
# Process referral code (only for new users — existing users cannot be assigned a referrer)
await _process_referral_code(db, user, request.referral_code, is_new_user=is_new_user)
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
if response.campaign_bonus:
@@ -937,12 +993,10 @@ async def register_email_standalone(
# Обработать реферальную регистрацию (если есть реферер)
if referrer:
try:
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from app.bot_factory import create_bot
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
await process_referral_registration(db, user.id, referrer.id, bot=bot)
async with create_bot() as bot:
await process_referral_registration(db, user.id, referrer.id, bot=bot)
logger.info(
'Processed referral registration: user_id=, referrer_id', user_id=user.id, referrer_id=referrer.id
)
@@ -1798,6 +1852,14 @@ async def poll_deep_link_token(
response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, response.refresh_token, device_info='deep_link')
# Deep link auth is always for existing users — referral code not applicable
# (kept for campaign bonus processing only)
# Process campaign bonus
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
if response.campaign_bonus:
response.user = _user_to_response(user)
logger.info('Deep link auth successful', user_id=user.id, telegram_id=user.telegram_id)
return response
+26 -42
View File
@@ -4,15 +4,12 @@ import math
import time
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
import httpx
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.bot_factory import create_bot
from app.config import settings
from app.database.crud.saved_payment_method import (
deactivate_payment_method,
@@ -272,50 +269,37 @@ async def create_stars_invoice(
# Create invoice through Telegram Bot API
try:
bot_token = settings.BOT_TOKEN
api_url = f'https://api.telegram.org/bot{bot_token}/createInvoiceLink'
from aiogram.exceptions import TelegramAPIError
from aiogram.types import LabeledPrice
async with httpx.AsyncClient() as client:
response = await client.post(
api_url,
json={
'title': 'Пополнение баланса VPN',
'description': f'Пополнение баланса на {normalized_kopeks / 100:.2f} ₽ ({stars_amount} ⭐)',
'payload': payload,
'provider_token': '', # Empty for Stars
'currency': 'XTR',
'prices': [{'label': 'Пополнение баланса', 'amount': stars_amount}],
},
async with create_bot() as bot:
invoice_url = await bot.create_invoice_link(
title='Пополнение баланса VPN',
description=f'Пополнение баланса на {normalized_kopeks / 100:.2f} ₽ ({stars_amount} ⭐)',
payload=payload,
provider_token='',
currency='XTR',
prices=[LabeledPrice(label='Пополнение баланса', amount=stars_amount)],
)
result = response.json()
logger.info(
'Created Stars invoice for balance top-up: user=, amount= kopeks, stars',
user_id=user.id,
amount_kopeks=request.amount_kopeks,
stars_amount=stars_amount,
)
if not result.get('ok'):
logger.error('Telegram API error', result=result)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create Stars invoice',
)
return StarsInvoiceResponse(
invoice_url=invoice_url,
stars_amount=stars_amount,
amount_kopeks=normalized_kopeks,
)
invoice_url = result['result']
logger.info(
'Created Stars invoice for balance top-up: user=, amount= kopeks, stars',
user_id=user.id,
amount_kopeks=request.amount_kopeks,
stars_amount=stars_amount,
)
return StarsInvoiceResponse(
invoice_url=invoice_url,
stars_amount=stars_amount,
amount_kopeks=normalized_kopeks,
)
except httpx.HTTPError as e:
logger.error('HTTP error creating Stars invoice', error=e)
except TelegramAPIError as e:
logger.error('Error creating Stars invoice', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to connect to Telegram API',
detail='Failed to create Stars invoice',
)
@@ -1202,7 +1186,7 @@ async def check_payment_status(
old_is_paid = record.is_paid
# Run manual check
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
bot = create_bot()
try:
payment_service = PaymentService(bot=bot)
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
+17 -13
View File
@@ -306,9 +306,9 @@ async def create_gift_purchase(
else:
# 2) Fall back to Bot API (works for public usernames the bot has seen)
try:
from aiogram import Bot
from app.bot_factory import create_bot
async with Bot(token=settings.BOT_TOKEN) as bot:
async with create_bot() as bot:
chat = await asyncio.wait_for(bot.get_chat(chat_id=f'@{tg_username}'), timeout=5.0)
pre_resolved_telegram_id = chat.id
except Exception:
@@ -371,19 +371,23 @@ async def create_gift_purchase(
# Stars payments need a Bot instance to create invoice links
bot = None
if body.payment_method == 'telegram_stars':
from aiogram import Bot
from app.bot_factory import create_bot
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
payment_service = PaymentService(bot=bot)
payment_result = await payment_service.create_guest_payment(
db=db,
amount_kopeks=price_kopeks,
payment_method=body.payment_method,
description=f'Gift: {tariff.name} ({body.period_days}d)',
purchase_token=purchase.token,
return_url=return_url,
)
try:
payment_service = PaymentService(bot=bot)
payment_result = await payment_service.create_guest_payment(
db=db,
amount_kopeks=price_kopeks,
payment_method=body.payment_method,
description=f'Gift: {tariff.name} ({body.period_days}d)',
purchase_token=purchase.token,
return_url=return_url,
)
finally:
if bot:
await bot.session.close()
if payment_result is None:
await db.rollback()
+1 -1
View File
@@ -550,7 +550,7 @@ async def create_landing_purchase(
No authentication required.
"""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'landing_purchase', limit=5, window=60, fail_closed=True):
if await RateLimitCache.is_ip_rate_limited(client_ip, 'landing_purchase', limit=30, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many purchase attempts, please try again later',
+3 -11
View File
@@ -3,13 +3,11 @@
import mimetypes
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.types import BufferedInputFile
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile, status
from pydantic import BaseModel
from app.bot_factory import create_bot
from app.config import settings
from app.database.models import User
@@ -98,10 +96,7 @@ async def upload_media(
target_chat_id = _resolve_target_chat_id()
upload = BufferedInputFile(file_bytes, filename=file.filename or 'upload')
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
bot = create_bot()
try:
if media_type_normalized == 'photo':
@@ -158,10 +153,7 @@ async def download_media(
Download media file by file_id.
Used to display images/documents in ticket messages.
"""
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
bot = create_bot()
try:
file = await bot.get_file(file_id)
+7 -3
View File
@@ -40,6 +40,8 @@ async def _finalize_oauth_login(
provider: str,
campaign_slug: str | None = None,
referral_code: str | None = None,
*,
is_new_user: bool = False,
) -> AuthResponse:
"""Update last login, create tokens, store refresh token."""
user.cabinet_last_login = datetime.now(UTC)
@@ -47,10 +49,10 @@ async def _finalize_oauth_login(
auth_response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}')
# Process referral code (before campaign bonus, which may also set referrer)
# Process referral code (only for new users — existing users cannot be assigned a referrer)
from .auth import _process_referral_code, _user_to_response
await _process_referral_code(db, user, referral_code)
await _process_referral_code(db, user, referral_code, is_new_user=is_new_user)
auth_response.campaign_bonus = await _process_campaign_bonus(db, user, campaign_slug)
if auth_response.campaign_bonus:
@@ -232,4 +234,6 @@ async def oauth_callback(
referred_by_id=referrer_id,
)
logger.info('New OAuth user created', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider, request.campaign_slug, request.referral_code)
return await _finalize_oauth_login(
db, user, provider, request.campaign_slug, request.referral_code, is_new_user=True
)
+2 -3
View File
@@ -178,12 +178,11 @@ async def apply_for_partner(
# Уведомляем админов о новой заявке
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_partner_application_notification(
+2 -8
View File
@@ -92,14 +92,8 @@ async def get_referral_info(
available_balance = min(user.balance_kopeks, referral_entitlement)
# Build referral links
referral_link = settings.get_referral_link(user.referral_code) if user.referral_code else ''
bot_username = settings.get_bot_username()
bot_referral_link = ''
if user.referral_code and bot_username:
from urllib.parse import quote
safe_code = quote(user.referral_code, safe='')
bot_referral_link = f'https://t.me/{bot_username}?start={safe_code}'
referral_link = (settings.get_cabinet_referral_link(user.referral_code) or '') if user.referral_code else ''
bot_referral_link = settings.get_bot_referral_link(user.referral_code) if user.referral_code else ''
return ReferralInfoResponse(
referral_code=user.referral_code or '',
+61 -46
View File
@@ -815,12 +815,11 @@ async def purchase_traffic(
# Отправляем уведомление админам
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
notification_service = AdminNotificationService(bot)
old_traffic = subscription.traffic_limit_gb - request.gb
@@ -1043,12 +1042,11 @@ async def purchase_devices_legacy(
# Отправляем уведомление админам
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_subscription_update_notification(
@@ -1147,6 +1145,7 @@ async def get_trial_info(
price_kopeks = settings.TRIAL_ACTIVATION_PRICE if requires_payment else 0
# Get trial parameters from tariff if configured (same logic as activate_trial)
# Триальный тариф может быть неактивным — используется для отдельных лимитов
try:
from app.database.crud.tariff import get_tariff_by_id, get_trial_tariff
@@ -1156,8 +1155,6 @@ async def get_trial_info(
trial_tariff_id = settings.get_trial_tariff_id()
if trial_tariff_id > 0:
trial_tariff = await get_tariff_by_id(db, trial_tariff_id)
if trial_tariff and not trial_tariff.is_active:
trial_tariff = None
if trial_tariff:
traffic_limit_gb = trial_tariff.traffic_limit_gb
@@ -1290,6 +1287,7 @@ async def activate_trial(
# First check for tariff with is_trial_available flag in DB (set via admin panel)
# Then fallback to TRIAL_TARIFF_ID from settings
# Триальный тариф может быть неактивным — используется для отдельных лимитов
trial_tariff = None
try:
from app.database.crud.tariff import get_tariff_by_id, get_trial_tariff
@@ -1300,8 +1298,6 @@ async def activate_trial(
trial_tariff_id = settings.get_trial_tariff_id()
if trial_tariff_id > 0:
trial_tariff = await get_tariff_by_id(db, trial_tariff_id)
if trial_tariff and not trial_tariff.is_active:
trial_tariff = None
if trial_tariff:
trial_traffic_limit = trial_tariff.traffic_limit_gb
@@ -1344,12 +1340,11 @@ async def activate_trial(
# Send admin notification about trial activation
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
notification_service = AdminNotificationService(bot)
charged_amount = settings.TRIAL_ACTIVATION_PRICE if requires_payment else None
@@ -1763,12 +1758,11 @@ async def submit_purchase(
# Отправляем уведомление админам о покупке подписки
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
notification_service = AdminNotificationService(bot)
is_new_subscription = result.get('was_trial_conversion') or not context.subscription
@@ -2161,12 +2155,11 @@ async def purchase_tariff(
# Отправляем уведомление админам о покупке/продлении тарифа
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
notification_service = AdminNotificationService(bot)
# Определяем тип покупки: новая подписка или продление
@@ -2418,12 +2411,11 @@ async def purchase_devices(
# Отправляем уведомление админам
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_subscription_update_notification(
@@ -4107,7 +4099,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
@@ -4204,12 +4196,11 @@ async def switch_tariff(
# Отправляем уведомление админам о смене тарифа
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_subscription_purchase_notification(
@@ -4262,6 +4253,7 @@ async def toggle_subscription_pause(
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Toggle pause/resume for daily subscription."""
logger.debug('toggle_subscription_pause called', user_id=user.id)
await db.refresh(user, ['subscription'])
if not user.subscription:
@@ -4284,7 +4276,15 @@ async def toggle_subscription_pause(
detail='Pause is only available for daily tariffs',
)
# Determine current state
raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0)
# Lock user BEFORE reading state and mutating to prevent TOCTOU on promo group
# and to ensure is_daily_paused mutation is not overwritten by populate_existing
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
# Determine current state from the LOCKED instance
from app.database.models import SubscriptionStatus
is_currently_paused = getattr(user.subscription, 'is_daily_paused', False)
@@ -4302,13 +4302,6 @@ async def toggle_subscription_pause(
new_paused_state = not is_currently_paused
user.subscription.is_daily_paused = new_paused_state
raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0)
# Lock user BEFORE discount computation to prevent TOCTOU on promo group
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
# Apply group discount to daily price (consistent with DailySubscriptionService and miniapp resume)
from app.services.pricing_engine import PricingEngine
@@ -4318,6 +4311,8 @@ async def toggle_subscription_pause(
PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price
)
resume_transaction = None
# If resuming, check balance and charge
if not new_paused_state:
if daily_price > 0 and user.balance_kopeks < daily_price:
@@ -4342,6 +4337,7 @@ async def toggle_subscription_pause(
daily_price,
f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
mark_as_paid_subscription=True,
commit=False,
)
if not deducted:
raise HTTPException(
@@ -4357,26 +4353,45 @@ async def toggle_subscription_pause(
from app.database.crud.transaction import create_transaction
from app.database.models import TransactionType
try:
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=daily_price,
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
)
except Exception as exc:
logger.warning('Failed to create resume transaction', error=exc)
resume_transaction = await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=daily_price,
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
commit=False,
)
# Balance deducted successfully — now activate
now = datetime.now(UTC)
user.subscription.status = SubscriptionStatus.ACTIVE.value
user.subscription.last_daily_charge_at = datetime.now(UTC)
user.subscription.end_date = datetime.now(UTC) + timedelta(days=1)
user.subscription.last_daily_charge_at = now
user.subscription.end_date = now + timedelta(days=1)
# Re-apply is_daily_paused on the current identity-mapped instance
# (subtract_user_balance with populate_existing=True may have reloaded it from DB)
user.subscription.is_daily_paused = new_paused_state
await db.commit()
await db.refresh(user.subscription)
await db.refresh(user)
# Emit deferred transaction side effects after commit
if not new_paused_state and was_disabled and daily_price > 0 and resume_transaction is not None:
try:
from app.database.crud.transaction import emit_transaction_side_effects
await emit_transaction_side_effects(
db=db,
transaction=resume_transaction,
amount_kopeks=daily_price,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
)
except Exception as exc:
logger.warning('Failed to emit resume transaction side effects', error=exc)
# Sync with RemnaWave only when resuming from DISABLED state
if not new_paused_state and was_disabled:
try:
+20 -35
View File
@@ -5,7 +5,6 @@ API роуты колеса удачи для пользователей.
import math
import time
import httpx
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
@@ -21,7 +20,6 @@ from app.cabinet.schemas.wheel import (
WheelConfigResponse,
WheelPrizeDisplay,
)
from app.config import settings
from app.database.crud.wheel import (
get_or_create_wheel_config,
get_user_spin_history,
@@ -251,44 +249,31 @@ async def create_stars_invoice(
# Создаем invoice через Telegram Bot API
try:
bot_token = settings.BOT_TOKEN
api_url = f'https://api.telegram.org/bot{bot_token}/createInvoiceLink'
from aiogram.exceptions import TelegramAPIError
from aiogram.types import LabeledPrice
async with httpx.AsyncClient() as client:
response = await client.post(
api_url,
json={
'title': 'Колесо удачи',
'description': f'Спин колеса удачи ({stars_amount} ⭐)',
'payload': payload,
'provider_token': '', # Пустой для Stars
'currency': 'XTR',
'prices': [{'label': 'Спин колеса', 'amount': stars_amount}],
},
from app.bot_factory import create_bot
async with create_bot() as bot:
invoice_url = await bot.create_invoice_link(
title='Колесо удачи',
description=f'Спин колеса удачи ({stars_amount} ⭐)',
payload=payload,
provider_token='',
currency='XTR',
prices=[LabeledPrice(label='Спин колеса', amount=stars_amount)],
)
result = response.json()
logger.info('Created Stars invoice for wheel spin: user=, stars', user_id=user.id, stars_amount=stars_amount)
if not result.get('ok'):
logger.error('Telegram API error', result=result)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Ошибка создания инвойса',
)
return StarsInvoiceResponse(
invoice_url=invoice_url,
stars_amount=stars_amount,
)
invoice_url = result['result']
logger.info(
'Created Stars invoice for wheel spin: user=, stars', user_id=user.id, stars_amount=stars_amount
)
return StarsInvoiceResponse(
invoice_url=invoice_url,
stars_amount=stars_amount,
)
except httpx.HTTPError as e:
logger.error('HTTP error creating invoice', error=e)
except TelegramAPIError as e:
logger.error('Error creating invoice', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Ошибка соединения с Telegram',
detail='Ошибка создания инвойса',
)
+2 -3
View File
@@ -70,12 +70,11 @@ async def create_withdrawal(
# Уведомляем админов о запросе на вывод
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_withdrawal_request_notification(
+12 -1
View File
@@ -198,6 +198,17 @@ class DeepLinkTokenResponse(BaseModel):
class DeepLinkPollRequest(BaseModel):
"""Request to poll deep link auth status."""
"""Request to poll deep link auth status.
Deep link auth is always for existing bot users referral codes are not applicable here.
Only campaign_slug is supported (campaign bonus can apply to existing users).
"""
token: str = Field(..., min_length=16, max_length=128, description='Deep link auth token')
campaign_slug: str | None = Field(
None,
min_length=1,
max_length=64,
pattern=r'^[a-zA-Z0-9_-]+$',
description='Campaign slug captured from cabinet URL',
)
+24 -1
View File
@@ -3,7 +3,7 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
# ============ Channel Types ============
@@ -75,6 +75,27 @@ class BroadcastButtonsResponse(BaseModel):
buttons: list[BroadcastButton]
class CustomBroadcastButton(BaseModel):
"""Custom button for broadcast message."""
label: str = Field(..., min_length=1, max_length=64)
action_type: Literal['callback', 'url'] = 'callback'
action_value: str = Field(..., min_length=1, max_length=256)
@field_validator('action_value')
@classmethod
def validate_action_value(cls, v: str, info) -> str:
action_type = info.data.get('action_type', 'callback')
if action_type == 'url':
if not v.startswith(('https://', 'tg://')):
raise ValueError('URL must start with https:// or tg://')
elif action_type == 'callback':
# Telegram API limits callback_data to 64 bytes
if len(v.encode('utf-8')) > 64:
raise ValueError('Callback data must be at most 64 bytes')
return v
# ============ Media ============
@@ -95,6 +116,7 @@ class BroadcastCreateRequest(BaseModel):
target: str
message_text: str = Field(..., min_length=1, max_length=4000)
selected_buttons: list[str] = Field(default_factory=lambda: ['home'])
custom_buttons: list[CustomBroadcastButton] = Field(default_factory=list, max_length=10)
media: BroadcastMediaRequest | None = None
@@ -187,6 +209,7 @@ class CombinedBroadcastCreateRequest(BaseModel):
# Telegram-specific fields
message_text: str | None = Field(default=None, max_length=4000)
selected_buttons: list[str] = Field(default_factory=lambda: ['home'])
custom_buttons: list[CustomBroadcastButton] = Field(default_factory=list, max_length=10)
media: BroadcastMediaRequest | None = None
# Email-specific fields
+35 -11
View File
@@ -17,14 +17,33 @@ logger = structlog.get_logger(__name__)
class EmailService:
"""Service for sending emails via SMTP."""
def __init__(self):
self.host = settings.SMTP_HOST
self.port = settings.SMTP_PORT
self.user = settings.SMTP_USER
self.password = settings.SMTP_PASSWORD
self.from_email = settings.get_smtp_from_email()
self.from_name = settings.SMTP_FROM_NAME
self.use_tls = settings.SMTP_USE_TLS
@property
def host(self) -> str | None:
return settings.SMTP_HOST
@property
def port(self) -> int:
return settings.SMTP_PORT
@property
def user(self) -> str | None:
return settings.SMTP_USER
@property
def password(self) -> str | None:
return settings.SMTP_PASSWORD
@property
def from_email(self) -> str | None:
return settings.get_smtp_from_email()
@property
def from_name(self) -> str:
return settings.SMTP_FROM_NAME
@property
def use_tls(self) -> bool:
return settings.SMTP_USE_TLS
def is_configured(self) -> bool:
"""Check if SMTP is properly configured."""
@@ -71,6 +90,11 @@ class EmailService:
logger.warning('SMTP is not configured, cannot send email')
return False
sender_email = self.from_email
if not sender_email or '@' not in sender_email:
logger.error('Invalid or missing SMTP from_email, cannot send email', from_email=sender_email)
return False
# Defensive: strip newlines to prevent header injection
to_email = to_email.strip().replace('\n', '').replace('\r', '')
subject = subject.replace('\n', '').replace('\r', '')
@@ -79,11 +103,11 @@ class EmailService:
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
safe_from_name = self.from_name.replace('\n', '').replace('\r', '') if self.from_name else ''
safe_from_email = self.from_email.replace('\n', '').replace('\r', '') if self.from_email else ''
safe_from_email = sender_email.replace('\n', '').replace('\r', '')
msg['From'] = f'{safe_from_name} <{safe_from_email}>'
msg['To'] = to_email
msg['Date'] = formatdate(localtime=False)
msg['Message-ID'] = make_msgid(domain=self.from_email.split('@')[-1])
msg['Message-ID'] = make_msgid(domain=safe_from_email.split('@')[-1])
# Plain text version
if body_text is None:
@@ -103,7 +127,7 @@ class EmailService:
msg.attach(part2)
with self._get_smtp_connection() as smtp:
smtp.sendmail(self.from_email, to_email, msg.as_string())
smtp.sendmail(safe_from_email, to_email, msg.as_string())
logger.info('Email sent successfully to', to_email=to_email)
return True
@@ -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
+100
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 = {
@@ -1340,6 +1373,8 @@ class EmailNotificationTemplates:
tariff_name = html.escape(context.get('tariff_name', ''))
period_days = context.get('period_days', 0)
cabinet_url = html.escape(context.get('cabinet_url', ''))
cabinet_email = html.escape(context.get('cabinet_email', ''))
cabinet_password = context.get('cabinet_password', '')
subjects = {
'ru': 'Ваша VPN подписка готова',
@@ -1349,6 +1384,66 @@ class EmailNotificationTemplates:
'fa': 'اشتراک VPN شما آماده است',
}
creds_block_ru = (
f"""
<div class="highlight">
<p><strong>Данные для входа в личный кабинет:</strong></p>
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
<p><strong>Пароль:</strong> <code>{cabinet_password}</code></p>
</div>
"""
if cabinet_password
else ''
)
creds_block_en = (
f"""
<div class="highlight">
<p><strong>Your cabinet login credentials:</strong></p>
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
<p><strong>Password:</strong> <code>{cabinet_password}</code></p>
</div>
"""
if cabinet_password
else ''
)
creds_block_zh = (
f"""
<div class="highlight">
<p><strong>个人中心登录信息</strong></p>
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
<p><strong>密码:</strong> <code>{cabinet_password}</code></p>
</div>
"""
if cabinet_password
else ''
)
creds_block_ua = (
f"""
<div class="highlight">
<p><strong>Дані для входу в особистий кабінет:</strong></p>
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
<p><strong>Пароль:</strong> <code>{cabinet_password}</code></p>
</div>
"""
if cabinet_password
else ''
)
creds_block_fa = (
f"""
<div class="highlight">
<p><strong>اطلاعات ورود به پنل کاربری:</strong></p>
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
<p><strong>رمز عبور:</strong> <code>{cabinet_password}</code></p>
</div>
"""
if cabinet_password
else ''
)
bodies = {
'ru': f"""
<h2>Ваша VPN подписка готова!</h2>
@@ -1356,6 +1451,7 @@ class EmailNotificationTemplates:
<p>Тариф: <strong>{tariff_name}</strong></p>
<p>Период: <strong>{period_days} дней</strong></p>
</div>
{creds_block_ru}
<p>Подписка активирована в вашем личном кабинете.</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти в личный кабинет</a></p>
""",
@@ -1365,6 +1461,7 @@ class EmailNotificationTemplates:
<p>Plan: <strong>{tariff_name}</strong></p>
<p>Period: <strong>{period_days} days</strong></p>
</div>
{creds_block_en}
<p>Your subscription has been activated in your cabinet.</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Go to Cabinet</a></p>
""",
@@ -1374,6 +1471,7 @@ class EmailNotificationTemplates:
<p>套餐: <strong>{tariff_name}</strong></p>
<p>期限: <strong>{period_days} </strong></p>
</div>
{creds_block_zh}
<p>订阅已在您的个人中心激活</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">前往个人中心</a></p>
""",
@@ -1383,6 +1481,7 @@ class EmailNotificationTemplates:
<p>Тариф: <strong>{tariff_name}</strong></p>
<p>Період: <strong>{period_days} днів</strong></p>
</div>
{creds_block_ua}
<p>Підписка активована у вашому особистому кабінеті.</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти до кабінету</a></p>
""",
@@ -1392,6 +1491,7 @@ class EmailNotificationTemplates:
<p>طرح: <strong>{tariff_name}</strong></p>
<p>مدت: <strong>{period_days} روز</strong></p>
</div>
{creds_block_fa}
<p>اشتراک شما در پنل کاربری فعال شده است.</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">رفتن به پنل کاربری</a></p>
""",
+86 -24
View File
@@ -7,7 +7,7 @@ from collections import defaultdict
from datetime import time
from pathlib import Path
from typing import Literal
from urllib.parse import urlparse
from urllib.parse import quote as _url_quote, urlparse
from zoneinfo import ZoneInfo
import structlog
@@ -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 # Задержка между отправкой чеков (секунды)
@@ -107,6 +118,7 @@ class Settings(BaseSettings):
REMNAWAVE_WEBHOOK_ENABLED: bool = False
REMNAWAVE_WEBHOOK_PATH: str = '/remnawave-webhook'
REMNAWAVE_WEBHOOK_SECRET: str | None = None # HMAC-SHA256 shared secret (min 32 chars)
REMNAWAVE_WEBHOOK_NOTIFY_NODE_CONNECTION_STATUS: bool = True
# Webhook user notification toggles (what Telegram messages users receive from webhook events)
WEBHOOK_NOTIFY_USER_ENABLED: bool = True
@@ -353,10 +365,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
@@ -366,6 +376,7 @@ class Settings(BaseSettings):
NALOGO_PASSWORD: str | None = None
NALOGO_DEVICE_ID: str | None = None
NALOGO_STORAGE_PATH: str = './nalogo_tokens.json'
NALOGO_PROXY_URL: str | None = None # SOCKS proxy for nalog.ru; falls back to PROXY_URL if not set
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
@@ -793,6 +804,27 @@ class Settings(BaseSettings):
BAN_SYSTEM_API_TOKEN: str | None = None
BAN_SYSTEM_REQUEST_TIMEOUT: int = 30
# SOCKS5 proxy for routing bot traffic to Telegram API
# Format: socks5://user:password@host:port or socks5://host:port
PROXY_URL: str | None = None
@field_validator('PROXY_URL', 'NALOGO_PROXY_URL', mode='before')
@classmethod
def validate_proxy_url(cls, value: str | None) -> str | None:
if not value:
return None
from urllib.parse import urlparse
parsed = urlparse(value)
if parsed.scheme not in ('socks5', 'socks5h', 'socks4'):
raise ValueError(
f'Proxy URL must use socks5://, socks5h://, or socks4:// scheme, got: {parsed.scheme!r}. '
'HTTP proxies are not supported for security reasons.'
)
if not parsed.hostname:
raise ValueError('Proxy URL must contain a hostname')
return value
@field_validator('MAIN_MENU_MODE', mode='before')
@classmethod
def normalize_main_menu_mode(cls, value: str | None) -> str:
@@ -919,6 +951,17 @@ class Settings(BaseSettings):
"""Проверяет, используется ли SQLite"""
return 'sqlite' in self.get_database_url()
def get_proxy_url(self) -> str | None:
"""Return SOCKS5 proxy URL or None."""
return self.PROXY_URL if self.PROXY_URL else None
def get_nalogo_proxy_url(self) -> str | None:
"""Return SOCKS proxy URL for nalogo or None.
Uses NALOGO_PROXY_URL if set, otherwise falls back to PROXY_URL.
"""
return self.NALOGO_PROXY_URL or self.PROXY_URL
def is_admin(self, telegram_id: int | None = None, email: str | None = None) -> bool:
"""
Check if user is admin by telegram_id or email.
@@ -1090,12 +1133,17 @@ class Settings(BaseSettings):
username_clean = (username or '').lstrip('@')
full_name_value = full_name or ''
# Remnawave разрешает только буквы, цифры, подчёркивания и дефисы
def _sanitize(value: str) -> str:
result = re.sub(r'[^0-9A-Za-z_-]+', '_', value)
return re.sub(r'_+', '_', result).strip('_-')
# Для email-пользователей формируем уникальный identifier
if telegram_id:
identifier = str(telegram_id)
elif email:
email_prefix = email.split('@')[0][:10]
identifier = f'email_{email_prefix}_{user_id}' if user_id else f'email_{email_prefix}'
email_prefix = _sanitize(email.split('@')[0][:10])
identifier = _sanitize(f'email_{email_prefix}_{user_id}' if user_id else f'email_{email_prefix}')
elif user_id:
identifier = f'id_{user_id}'
else:
@@ -1109,20 +1157,18 @@ class Settings(BaseSettings):
'username_clean': username_clean,
'telegram_id': str(telegram_id) if telegram_id else identifier,
'identifier': identifier,
'email': email.split('@')[0] if email else '',
'email': _sanitize(email.split('@')[0]) if email else '',
'user_id': str(user_id) if user_id else '',
},
)
raw_username = template.format_map(values).strip()
# Remnawave разрешает только буквы, цифры, подчёркивания и дефисы
sanitized_username = re.sub(r'[^0-9A-Za-z_-]+', '_', raw_username)
sanitized_username = re.sub(r'_+', '_', sanitized_username).strip('_-')
sanitized_username = _sanitize(raw_username)
if not sanitized_username:
sanitized_username = f'user_{identifier}'
sanitized_username = _sanitize(f'user_{identifier}')
return sanitized_username[:36]
return sanitized_username[:36].strip('_-') or 'user'
@staticmethod
def parse_daily_time_list(raw_value: str | None) -> list[time]:
@@ -1254,10 +1300,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']
@@ -1434,24 +1476,44 @@ class Settings(BaseSettings):
_CABINET_URL_DEFAULT = 'https://example.com/cabinet'
def _encode_referral_code(self, referral_code: str) -> str:
"""Validate and URL-encode a referral code."""
if not referral_code:
raise ValueError('referral_code must not be empty or None')
return _url_quote(referral_code, safe='')
def _normalized_cabinet_url(self) -> str | None:
"""Return normalized cabinet URL, or None if not configured."""
cabinet_url = (self.CABINET_URL or '').strip().rstrip('/')
if not cabinet_url or cabinet_url == self._CABINET_URL_DEFAULT:
return None
return cabinet_url
def get_referral_link(self, referral_code: str, bot_username: str | None = None) -> str:
"""Build a referral link pointing to the web cabinet.
Falls back to a Telegram bot deep link when CABINET_URL is not configured.
"""
from urllib.parse import quote
cabinet_link = self.get_cabinet_referral_link(referral_code)
if cabinet_link:
return cabinet_link
return self.get_bot_referral_link(referral_code, bot_username)
if not referral_code:
raise ValueError('referral_code must not be empty or None')
safe_code = quote(referral_code, safe='')
cabinet_url = (self.CABINET_URL or '').strip().rstrip('/')
if cabinet_url and cabinet_url != self._CABINET_URL_DEFAULT:
sep = '&' if '?' in cabinet_url else '?'
return f'{cabinet_url}{sep}ref={safe_code}'
def get_bot_referral_link(self, referral_code: str, bot_username: str | None = None) -> str:
"""Always return the Telegram bot deep link for a referral code."""
safe_code = self._encode_referral_code(referral_code)
username = bot_username or self.get_bot_username() or 'bot'
return f'https://t.me/{username}?start={safe_code}'
def get_cabinet_referral_link(self, referral_code: str) -> str | None:
"""Return the cabinet referral link, or None if cabinet is not configured."""
cabinet_url = self._normalized_cabinet_url()
if not cabinet_url:
return None
safe_code = self._encode_referral_code(referral_code)
sep = '&' if '?' in cabinet_url else '?'
return f'{cabinet_url}{sep}ref={safe_code}'
def is_deep_links_enabled(self) -> bool:
return self.ENABLE_DEEP_LINKS
+17 -1
View File
@@ -67,8 +67,24 @@ async def get_cryptobot_payment_by_id(db: AsyncSession, payment_id: int) -> Cryp
return result.scalar_one_or_none()
async def get_cryptobot_payment_by_invoice_id_for_update(db: AsyncSession, invoice_id: str) -> CryptoBotPayment | None:
result = await db.execute(
select(CryptoBotPayment)
.options(selectinload(CryptoBotPayment.user))
.where(CryptoBotPayment.invoice_id == invoice_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
async def get_cryptobot_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> CryptoBotPayment | None:
result = await db.execute(select(CryptoBotPayment).where(CryptoBotPayment.id == payment_id).with_for_update())
result = await db.execute(
select(CryptoBotPayment)
.where(CryptoBotPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
+6 -1
View File
@@ -63,7 +63,12 @@ async def get_freekassa_payment_by_id(db: AsyncSession, payment_id: int) -> Free
async def get_freekassa_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> FreekassaPayment | None:
result = await db.execute(select(FreekassaPayment).where(FreekassaPayment.id == payment_id).with_for_update())
result = await db.execute(
select(FreekassaPayment)
.where(FreekassaPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
+6 -1
View File
@@ -65,7 +65,12 @@ async def get_kassa_ai_payment_by_id(db: AsyncSession, payment_id: int) -> Kassa
async def get_kassa_ai_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> KassaAiPayment | None:
result = await db.execute(select(KassaAiPayment).where(KassaAiPayment.id == payment_id).with_for_update())
result = await db.execute(
select(KassaAiPayment)
.where(KassaAiPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
+6 -1
View File
@@ -58,7 +58,12 @@ async def get_mulenpay_payment_by_local_id(db: AsyncSession, payment_id: int) ->
async def get_mulenpay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> MulenPayPayment | None:
result = await db.execute(select(MulenPayPayment).where(MulenPayPayment.id == payment_id).with_for_update())
result = await db.execute(
select(MulenPayPayment)
.where(MulenPayPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
+6 -1
View File
@@ -67,7 +67,12 @@ async def get_pal24_payment_by_id(db: AsyncSession, payment_id: int) -> Pal24Pay
async def get_pal24_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> Pal24Payment | None:
result = await db.execute(select(Pal24Payment).where(Pal24Payment.id == payment_id).with_for_update())
result = await db.execute(
select(Pal24Payment)
.where(Pal24Payment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
+6 -1
View File
@@ -71,7 +71,12 @@ async def get_platega_payment_by_id(db: AsyncSession, payment_id: int) -> Plateg
async def get_platega_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> PlategaPayment | None:
result = await db.execute(select(PlategaPayment).where(PlategaPayment.id == payment_id).with_for_update())
result = await db.execute(
select(PlategaPayment)
.where(PlategaPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
+6 -19
View File
@@ -38,8 +38,8 @@ _POLICY_UPDATABLE_FIELDS = frozenset(
}
)
# Superadmin level constant
_SUPERADMIN_LEVEL = 999
# Superadmin level constant — single source of truth, imported by admin_roles and bootstrap
SUPERADMIN_LEVEL = 999
class AdminRoleCRUD:
@@ -240,21 +240,6 @@ class UserRoleCRUD:
logger.info('Assigned role to user', user_role_id=user_role.id, user_id=user_id, role_id=role_id)
return user_role
@staticmethod
async def revoke_role(db: AsyncSession, user_role_id: int) -> bool:
"""Soft-revoke: set is_active=False. Returns False if not found."""
result = await db.execute(select(UserRole).where(UserRole.id == user_role_id))
user_role = result.scalar_one_or_none()
if not user_role:
return False
user_role.is_active = False
await db.flush()
logger.info(
'Revoked user role', user_role_id=user_role_id, user_id=user_role.user_id, role_id=user_role.role_id
)
return True
@staticmethod
async def get_all_admins(
db: AsyncSession,
@@ -296,14 +281,16 @@ class UserRoleCRUD:
@staticmethod
async def get_superadmin_count(db: AsyncSession) -> int:
"""Count users with an active role at superadmin level (999)."""
"""Count users with an active, non-expired role at superadmin level (999)."""
now = datetime.now(UTC)
result = await db.execute(
select(func.count(func.distinct(UserRole.user_id)))
.join(AdminRole, UserRole.role_id == AdminRole.id)
.where(
UserRole.is_active.is_(True),
AdminRole.is_active.is_(True),
AdminRole.level == _SUPERADMIN_LEVEL,
AdminRole.level == SUPERADMIN_LEVEL,
or_(UserRole.expires_at.is_(None), UserRole.expires_at > now),
)
)
return result.scalar() or 0
+11
View File
@@ -66,6 +66,17 @@ async def get_riopay_payment_by_id(db: AsyncSession, payment_id: int) -> RioPayP
return result.scalar_one_or_none()
async def get_riopay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> RioPayPayment | None:
"""Получает платеж по ID с блокировкой FOR UPDATE (для защиты от TOCTOU race)."""
result = await db.execute(
select(RioPayPayment)
.where(RioPayPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
async def update_riopay_payment_status(
db: AsyncSession,
payment: RioPayPayment,
+6 -1
View File
@@ -70,7 +70,12 @@ async def get_severpay_payment_by_id(db: AsyncSession, payment_id: int) -> Sever
async def get_severpay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> SeverPayPayment | None:
"""Получает платеж по ID с блокировкой FOR UPDATE."""
result = await db.execute(select(SeverPayPayment).where(SeverPayPayment.id == payment_id).with_for_update())
result = await db.execute(
select(SeverPayPayment)
.where(SeverPayPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
+12 -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:
@@ -1447,6 +1442,7 @@ async def create_pending_subscription(
payment_method: str = 'pending',
total_price_kopeks: int = 0,
is_trial: bool = False,
tariff_id: int | None = None,
) -> Subscription:
"""Creates a pending subscription that will be activated after payment.
@@ -1480,6 +1476,8 @@ async def create_pending_subscription(
existing_subscription.connected_squads = connected_squads or []
existing_subscription.traffic_used_gb = 0.0
existing_subscription.updated_at = current_time
if tariff_id is not None:
existing_subscription.tariff_id = tariff_id
await db.commit()
await db.refresh(existing_subscription)
@@ -1502,6 +1500,7 @@ async def create_pending_subscription(
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=connected_squads or [],
tariff_id=tariff_id,
autopay_enabled=settings.is_autopay_enabled_by_default(),
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
)
@@ -1531,6 +1530,7 @@ async def create_pending_trial_subscription(
connected_squads: list[str] = None,
payment_method: str = 'pending',
total_price_kopeks: int = 0,
tariff_id: int | None = None,
) -> Subscription:
"""Creates a pending trial subscription. Wrapper for create_pending_subscription with is_trial=True."""
return await create_pending_subscription(
@@ -1543,6 +1543,7 @@ async def create_pending_trial_subscription(
payment_method=payment_method,
total_price_kopeks=total_price_kopeks,
is_trial=True,
tariff_id=tariff_id,
)
+4 -1
View File
@@ -83,13 +83,16 @@ async def count_tariffs(db: AsyncSession, *, include_inactive: bool = False) ->
async def get_trial_tariff(db: AsyncSession) -> Tariff | None:
"""Получает тариф, доступный для триала (is_trial_available=True).
Триальный тариф может быть неактивным это сделано специально,
чтобы он не отображался в списке покупки, но использовался для триала
со своими лимитами (трафик, устройства, серверы).
Сортируется по updated_at DESC, чтобы вернуть последний установленный
триальный тариф (на случай если их несколько).
"""
query = (
select(Tariff)
.where(Tariff.is_trial_available.is_(True))
.where(Tariff.is_active.is_(True))
.options(selectinload(Tariff.allowed_promo_groups))
.order_by(Tariff.updated_at.desc().nullslast(), Tariff.id.desc())
.limit(1)
+19 -14
View File
@@ -51,6 +51,11 @@ async def create_transaction(
else amount_kopeks
)
# Default payment_method to BALANCE for subscription/gift payments from bot (not landing)
# to avoid double-counting with DEPOSIT in revenue calculations
if payment_method is None and type in (TransactionType.SUBSCRIPTION_PAYMENT, TransactionType.GIFT_PAYMENT):
payment_method = PaymentMethod.BALANCE
transaction = Transaction(
user_id=user_id,
type=type.value,
@@ -108,7 +113,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 +173,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 +258,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
)
@@ -278,11 +283,11 @@ async def get_transactions_statistics(
if not end_date:
end_date = datetime.now(UTC)
# Доход считаем только по реальным платежам (исключаем колесо, промокоды, админские пополнения)
# Доход считаем по реальным платежам + прямые покупки подписок (лендинги)
income_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
Transaction.is_completed == True,
Transaction.created_at >= start_date,
Transaction.created_at <= end_date,
@@ -339,11 +344,11 @@ async def get_transactions_statistics(
select(
Transaction.payment_method,
func.count(Transaction.id).label('count'),
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('total_amount'),
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('total_amount'),
)
.where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
Transaction.is_completed == True,
Transaction.created_at >= start_date,
Transaction.created_at <= end_date,
@@ -363,11 +368,11 @@ async def get_transactions_statistics(
)
transactions_today = today_result.scalar()
# Доход за сегодня - только реальные платежи
# Доход за сегодня реальные платежи + прямые покупки подписок (лендинги)
today_income_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
Transaction.is_completed == True,
Transaction.created_at >= today,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
@@ -391,17 +396,17 @@ async def get_transactions_statistics(
async def get_revenue_by_period(db: AsyncSession, days: int = 30) -> list[dict]:
"""Доход по дням - только реальные платежи."""
"""Доход по дням реальные платежи + прямые покупки подписок (лендинги)."""
start_date = datetime.now(UTC) - timedelta(days=days)
result = await db.execute(
select(
func.date(Transaction.created_at).label('date'),
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
)
.where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
Transaction.is_completed == True,
Transaction.created_at >= start_date,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
+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
@@ -72,7 +72,12 @@ async def get_wata_payment_by_id(
async def get_wata_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> WataPayment | None:
result = await db.execute(select(WataPayment).where(WataPayment.id == payment_id).with_for_update())
result = await db.execute(
select(WataPayment)
.where(WataPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
+8 -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,9 @@ 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')
receipt_uuid = Column(String(255), nullable=True, index=True)
receipt_created_at = Column(AwareDateTime(), nullable=True)
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)
+30 -5
View File
@@ -469,7 +469,22 @@ class RemnaWaveAPI:
hwidDeviceLimit=data.get('hwidDeviceLimit'),
status=data.get('status'),
)
response = await self._make_request('POST', '/api/users', data)
try:
response = await self._make_request('POST', '/api/users', data)
except RemnaWaveAPIError as e:
# A039 = FK violation on externalSquadUuid — retry without it
error_code = (e.response_data or {}).get('errorCode', '')
if error_code == 'A039' and 'externalSquadUuid' in data:
stale_uuid = data.pop('externalSquadUuid')
logger.warning(
'A039 FK violation on externalSquadUuid, retrying without it',
stale_uuid=stale_uuid,
username=data.get('username'),
)
response = await self._make_request('POST', '/api/users', data)
else:
logger.error('POST /api/users FAILED — full payload', payload=data)
raise
user = self._parse_user(response['response'])
logger.info(
'POST /api/users response',
@@ -570,10 +585,20 @@ class RemnaWaveAPI:
try:
response = await self._make_request('PATCH', '/api/users', data)
except Exception:
# Логируем полный payload при ошибке для диагностики A039
logger.error('PATCH /api/users FAILED — full payload', payload=data)
raise
except RemnaWaveAPIError as e:
# A039 = FK violation on externalSquadUuid — retry without it
error_code = (e.response_data or {}).get('errorCode', '')
if error_code == 'A039' and 'externalSquadUuid' in data:
stale_uuid = data.pop('externalSquadUuid')
logger.warning(
'A039 FK violation on externalSquadUuid, retrying without it',
stale_uuid=stale_uuid,
uuid=uuid,
)
response = await self._make_request('PATCH', '/api/users', data)
else:
logger.error('PATCH /api/users FAILED — full payload', payload=data)
raise
user = self._parse_user(response['response'])
logger.info(
'PATCH /api/users response',
+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()
+19 -2
View File
@@ -83,7 +83,6 @@ CABINET_MINIAPP_BUTTON_KEYS = {
'connect',
'subscription',
'support',
'home',
}
@@ -97,7 +96,11 @@ def get_updated_message_buttons_selector_keyboard(
return get_updated_message_buttons_selector_keyboard_with_media(selected_buttons, False, language)
def create_broadcast_keyboard(selected_buttons: list, language: str = 'ru') -> types.InlineKeyboardMarkup | None:
def create_broadcast_keyboard(
selected_buttons: list,
language: str = 'ru',
custom_buttons: list[dict] | None = None,
) -> types.InlineKeyboardMarkup | None:
selected_buttons = selected_buttons or []
keyboard: list[list[types.InlineKeyboardButton]] = []
button_config_map = get_broadcast_button_config(language)
@@ -123,6 +126,20 @@ def create_broadcast_keyboard(selected_buttons: list, language: str = 'ru') -> t
if row_buttons:
keyboard.append(row_buttons)
# Append custom buttons (each on its own row)
if custom_buttons:
for btn in custom_buttons:
label = btn.get('label', '')
action_type = btn.get('action_type', 'callback')
action_value = btn.get('action_value', '')
if not label or not action_value:
continue
if action_type == 'url':
keyboard.append([types.InlineKeyboardButton(text=label, url=action_value)])
else:
# callback type
keyboard.append([types.InlineKeyboardButton(text=label, callback_data=action_value)])
if not keyboard:
return None
+7 -18
View File
@@ -462,28 +462,17 @@ async def show_promo_groups_menu(
keyboard_rows = []
for group, member_count in groups:
icon = '' if group.is_default else '🎯'
default_suffix = texts.t('ADMIN_PROMO_GROUPS_DEFAULT_LABEL', ' (базовая)') if group.is_default else ''
group_lines = [
f'{"" if group.is_default else "🎯"} <b>{group.name}</b>{default_suffix}',
]
group_lines.extend(_format_discount_lines(texts, group))
group_lines.append(_format_auto_assign_line(texts, group))
group_lines.append(
texts.t(
'ADMIN_PROMO_GROUPS_MEMBERS_COUNT',
'Участников: {count}',
).format(count=member_count)
)
period_lines = _format_period_discounts_lines(texts, group, db_user.language)
group_lines.extend(period_lines)
group_lines.append('')
lines.extend(group_lines)
members_label = texts.t(
'ADMIN_PROMO_GROUPS_MEMBERS_COUNT',
'Участников: {count}',
).format(count=member_count)
lines.append(f'{icon} <b>{group.name}</b>{default_suffix}{members_label}')
keyboard_rows.append(
[
types.InlineKeyboardButton(
text=f'{"" if group.is_default else "🎯"} {group.name}',
text=f'{icon} {group.name}',
callback_data=f'promo_group_manage_{group.id}',
)
]
+3 -3
View File
@@ -1019,7 +1019,7 @@ async def delete_user_account(callback: types.CallbackQuery, db_user: User, db:
user_id = int(callback.data.split('_')[-1])
user_service = UserService()
delete_result = await user_service.delete_user_account(db, user_id, db_user.id)
delete_result = await user_service.delete_user_account(db, user_id, db_user.id, force_panel_delete=True)
if delete_result.bot_deleted:
await callback.message.edit_text(
@@ -4571,7 +4571,7 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
async with remnawave_service.get_api_client() as api:
update_kwargs = dict(
uuid=target_user.remnawave_uuid,
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.DISABLED,
expire_at=subscription.end_date,
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3)
if subscription.traffic_limit_gb > 0
@@ -4608,7 +4608,7 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
create_kwargs = dict(
username=username,
expire_at=subscription.end_date,
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.DISABLED,
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3)
if subscription.traffic_limit_gb > 0
else 0,
+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)
+73 -34
View File
@@ -1,5 +1,6 @@
import hashlib
import json
from html import escape as html_escape
from pathlib import Path
import qrcode
@@ -14,7 +15,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
@@ -45,7 +46,8 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
summary = await get_user_referral_summary(db, db_user.id)
bot_username = (await callback.bot.get_me()).username
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
bot_referral_link = settings.get_bot_referral_link(db_user.referral_code, bot_username)
cabinet_referral_link = settings.get_cabinet_referral_link(db_user.referral_code)
referral_text = (
texts.t('REFERRAL_PROGRAM_TITLE', '👥 <b>Реферальная программа</b>')
@@ -114,13 +116,27 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
'• Комиссия с каждого пополнения реферала: <b>{percent}%</b>',
).format(percent=get_effective_referral_commission_percent(db_user))
referral_text += '\n' + commission_line + '\n\n'
# Show bot link
referral_text += (
texts.t('REFERRAL_BOT_LINK_TITLE', '🤖 <b>Ссылка на бота:</b>')
+ f'\n<code>{html_escape(bot_referral_link)}</code>\n'
)
# Show cabinet link if configured
if cabinet_referral_link:
referral_text += (
'\n'
+ texts.t('REFERRAL_CABINET_LINK_TITLE', '🌐 <b>Ссылка на кабинет:</b>')
+ f'\n<code>{html_escape(cabinet_referral_link)}</code>\n'
)
referral_text += (
'\n'
+ commission_line
+ '\n\n'
+ texts.t('REFERRAL_LINK_TITLE', '🔗 <b>Ваша реферальная ссылка:</b>')
+ f'\n<code>{referral_link}</code>\n\n'
+ texts.t('REFERRAL_CODE_TITLE', '🆔 <b>Ваш код:</b> <code>{code}</code>').format(code=db_user.referral_code)
+ texts.t('REFERRAL_CODE_TITLE', '🆔 <b>Ваш код:</b> <code>{code}</code>').format(
code=html_escape(str(db_user.referral_code or ''))
)
+ '\n\n'
)
@@ -158,7 +174,7 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
).format(
reason=reason_text,
amount=texts.format_price(earning['amount_kopeks']),
referral_name=earning['referral_name'],
referral_name=html_escape(str(earning['referral_name'] or '')),
)
+ '\n'
)
@@ -243,15 +259,15 @@ async def show_referral_qr(
await callback.answer()
bot_username = (await callback.bot.get_me()).username
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
bot_referral_link = settings.get_bot_referral_link(db_user.referral_code, bot_username)
qr_dir = Path('data') / 'referral_qr'
qr_dir.mkdir(parents=True, exist_ok=True)
link_hash = hashlib.md5(referral_link.encode()).hexdigest()[:8]
link_hash = hashlib.md5(bot_referral_link.encode()).hexdigest()[:8]
file_path = qr_dir / f'{db_user.id}_{link_hash}.png'
if not file_path.exists():
img = qrcode.make(referral_link)
img = qrcode.make(bot_referral_link)
img.save(file_path)
photo = FSInputFile(file_path)
@@ -259,25 +275,28 @@ async def show_referral_qr(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_referrals')]]
)
caption = texts.t(
'REFERRAL_QR_BOT_LINK',
'🤖 Ссылка на бота:\n{link}',
).format(link=bot_referral_link)
cabinet_referral_link = settings.get_cabinet_referral_link(db_user.referral_code)
if cabinet_referral_link:
caption += '\n\n' + texts.t(
'REFERRAL_QR_CABINET_LINK',
'🌐 Ссылка на кабинет:\n{link}',
).format(link=cabinet_referral_link)
try:
await callback.message.edit_media(
types.InputMediaPhoto(
media=photo,
caption=texts.t(
'REFERRAL_LINK_CAPTION',
'🔗 Ваша реферальная ссылка:\n{link}',
).format(link=referral_link),
),
types.InputMediaPhoto(media=photo, caption=caption),
reply_markup=keyboard,
)
except TelegramBadRequest:
await callback.message.delete()
await callback.message.answer_photo(
photo,
caption=texts.t(
'REFERRAL_LINK_CAPTION',
'🔗 Ваша реферальная ссылка:\n{link}',
).format(link=referral_link),
caption=caption,
reply_markup=keyboard,
)
@@ -322,7 +341,7 @@ async def show_detailed_referral_list(callback: types.CallbackQuery, db_user: Us
texts.t(
'REFERRAL_LIST_ITEM_HEADER',
'{index}. {status} <b>{name}</b>',
).format(index=i, status=status_emoji, name=referral['full_name'])
).format(index=i, status=status_emoji, name=html_escape(str(referral['full_name'] or '')))
+ '\n'
)
text += (
@@ -454,7 +473,7 @@ async def show_referral_analytics(callback: types.CallbackQuery, db_user: User,
'{index}. {name}: {amount} ({count} начислений)',
).format(
index=i,
name=ref['referral_name'],
name=html_escape(str(ref['referral_name'] or '')),
amount=texts.format_price(ref['total_earned_kopeks']),
count=ref['earnings_count'],
)
@@ -485,7 +504,8 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User):
return
bot_username = (await callback.bot.get_me()).username
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
bot_referral_link = settings.get_bot_referral_link(db_user.referral_code, bot_username)
cabinet_referral_link = settings.get_cabinet_referral_link(db_user.referral_code)
invite_text = texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!')
@@ -507,14 +527,29 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User):
+ texts.t('REFERRAL_INVITE_FEATURE_SECURE', '🔒 Надежная защита')
+ '\n\n'
+ texts.t('REFERRAL_INVITE_LINK_PROMPT', '👇 Переходи по ссылке:')
+ f'\n{referral_link}'
+ f'\n{bot_referral_link}'
)
if cabinet_referral_link:
invite_text += (
'\n\n'
+ texts.t('REFERRAL_INVITE_CABINET_LINK', '🌐 Или через личный кабинет:')
+ f'\n{cabinet_referral_link}'
)
# Compact share text for switch_inline_query (256-char limit)
share_text = invite_text
if len(share_text) > 256:
share_text = texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!') + f'\n\n👇 {bot_referral_link}'
if cabinet_referral_link and len(share_text) + len(cabinet_referral_link) + 5 <= 256:
share_text += f'\n🌐 {cabinet_referral_link}'
share_text = share_text[:256]
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t('REFERRAL_SHARE_BUTTON', '📤 Поделиться'), switch_inline_query=invite_text
text=texts.t('REFERRAL_SHARE_BUTTON', '📤 Поделиться'), switch_inline_query=share_text
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_referrals')],
@@ -531,7 +566,7 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User):
'Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:',
)
+ '\n\n'
f'<code>{invite_text}</code>'
f'<code>{html_escape(invite_text)}</code>'
),
keyboard,
)
@@ -584,7 +619,7 @@ async def show_withdrawal_info(callback: types.CallbackQuery, db_user: User, db:
]
)
else:
text += f'{reason}\n'
text += f'{html_escape(str(reason))}\n'
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_referrals')])
@@ -746,7 +781,7 @@ async def process_payment_details(message: types.Message, db_user: User, db: Asy
)
text += (
texts.t('REFERRAL_WITHDRAWAL_CONFIRM_DETAILS', '💳 Реквизиты:\n<code>{details}</code>').format(
details=payment_details
details=html_escape(payment_details)
)
+ '\n\n'
)
@@ -792,16 +827,18 @@ async def confirm_withdrawal_request(callback: types.CallbackQuery, db_user: Use
# Отправляем уведомление админам
analysis = json.loads(request.risk_analysis) if request.risk_analysis else {}
user_id_display = db_user.telegram_id or db_user.email or f'#{db_user.id}'
user_id_display = html_escape(str(db_user.telegram_id or db_user.email or f'#{db_user.id}'))
safe_name = html_escape(db_user.full_name or 'Без имени')
safe_details = html_escape(payment_details)
admin_text = f"""
🔔 <b>Новая заявка на вывод #{request.id}</b>
👤 Пользователь: {db_user.full_name or 'Без имени'}
👤 Пользователь: {safe_name}
🆔 ID: <code>{user_id_display}</code>
💰 Сумма: <b>{amount_kopeks / 100:.0f}</b>
💳 Реквизиты:
<code>{payment_details}</code>
<code>{safe_details}</code>
{referral_withdrawal_service.format_analysis_for_admin(analysis)}
"""
@@ -825,7 +862,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('❌ Ошибка обработки платежа. Обратитесь в поддержку.')
+5 -4
View File
@@ -888,7 +888,8 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
balance_kopeks=user.balance_kopeks,
)
user.status = UserStatus.ACTIVE.value
# Keep status=DELETED so complete_registration properly handles
# referral assignment and status change (not the "already active" branch)
user.balance_kopeks = 0
user.remnawave_uuid = None
user.has_had_paid_subscription = False
@@ -1191,7 +1192,7 @@ async def process_rules_accept(callback: types.CallbackQuery, state: FSMContext,
reply_markup=get_rules_keyboard(language),
)
await state.set_state(RegistrationStates.waiting_for_rules_accept)
except:
except Exception:
pass
@@ -1302,7 +1303,7 @@ async def process_privacy_policy_accept(callback: types.CallbackQuery, state: FS
reply_markup=get_privacy_policy_keyboard(language),
)
await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept)
except:
except Exception:
pass
@@ -1392,7 +1393,7 @@ async def process_referral_code_skip(callback: types.CallbackQuery, state: FSMCo
await callback.message.edit_text(
texts.t('REGISTRATION_COMPLETING', '✅ Завершаем регистрацию...'), reply_markup=None
)
except:
except Exception:
pass
await complete_registration_from_callback(callback, state, db)
+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
# Проверяем тариф подписки
+118 -27
View File
@@ -619,8 +619,6 @@ async def show_trial_offer(callback: types.CallbackQuery, db_user: User, db: Asy
trial_tariff_id = settings.get_trial_tariff_id()
if trial_tariff_id > 0:
trial_tariff = await get_tariff(db, trial_tariff_id)
if trial_tariff and not trial_tariff.is_active:
trial_tariff = None
if trial_tariff:
trial_traffic = trial_tariff.traffic_limit_gb
@@ -811,14 +809,36 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
user_balance_kopeks = getattr(db_user, 'balance_kopeks', 0) or 0
can_pay_from_balance = user_balance_kopeks >= trial_price_kopeks
traffic_label = 'Безлимит' if settings.TRIAL_TRAFFIC_LIMIT_GB == 0 else f'{settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ'
# Берём параметры из триального тарифа если доступен
paid_trial_days = settings.TRIAL_DURATION_DAYS
paid_trial_traffic = settings.TRIAL_TRAFFIC_LIMIT_GB
paid_trial_devices = settings.TRIAL_DEVICE_LIMIT
if settings.is_tariffs_mode():
try:
from app.database.crud.tariff import get_tariff_by_id as get_tariff, get_trial_tariff
paid_trial_tariff = await get_trial_tariff(db)
if not paid_trial_tariff:
trial_tariff_id = settings.get_trial_tariff_id()
if trial_tariff_id > 0:
paid_trial_tariff = await get_tariff(db, trial_tariff_id)
if paid_trial_tariff:
paid_trial_traffic = paid_trial_tariff.traffic_limit_gb
paid_trial_devices = paid_trial_tariff.device_limit
tariff_trial_days = getattr(paid_trial_tariff, 'trial_duration_days', None)
if tariff_trial_days:
paid_trial_days = tariff_trial_days
except Exception as e:
logger.error('Ошибка получения триального тарифа для платного триала', error=e)
traffic_label = 'Безлимит' if paid_trial_traffic == 0 else f'{paid_trial_traffic} ГБ'
message_lines = [
texts.t('PAID_TRIAL_HEADER', '⚡ <b>Пробная подписка</b>'),
'',
f'📅 {texts.t("PERIOD", "Период")}: {settings.TRIAL_DURATION_DAYS} {texts.t("DAYS", "дней")}',
f'📅 {texts.t("PERIOD", "Период")}: {paid_trial_days} {texts.t("DAYS", "дней")}',
f'📊 {texts.t("TRAFFIC", "Трафик")}: {traffic_label}',
f'📱 {texts.t("DEVICES", "Устройства")}: {settings.TRIAL_DEVICE_LIMIT}',
f'📱 {texts.t("DEVICES", "Устройства")}: {paid_trial_devices}',
'',
f'💰 {texts.t("PRICE", "Стоимость")}: {settings.format_price(trial_price_kopeks)}',
f'💳 {texts.t("YOUR_BALANCE", "Ваш баланс")}: {settings.format_price(user_balance_kopeks)}',
@@ -865,6 +885,7 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
from app.database.crud.tariff import get_tariff_by_id, get_trial_tariff
# Сначала проверяем тариф из БД с флагом is_trial_available
# Триальный тариф может быть неактивным — используется для отдельных лимитов
trial_tariff = await get_trial_tariff(db)
# Если не найден в БД, проверяем настройку TRIAL_TARIFF_ID
@@ -872,8 +893,6 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
trial_tariff_id = settings.get_trial_tariff_id()
if trial_tariff_id > 0:
trial_tariff = await get_tariff_by_id(db, trial_tariff_id)
if trial_tariff and not trial_tariff.is_active:
trial_tariff = None
if trial_tariff:
trial_traffic_limit = trial_tariff.traffic_limit_gb
@@ -3044,10 +3063,47 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user:
if not settings.is_devices_selection_enabled():
forced_devices = settings.get_disabled_mode_device_limit()
# Получаем параметры из триального тарифа (аналогично бесплатному триалу)
trial_tariff = None
trial_traffic_limit = None
trial_device_limit = forced_devices
trial_squads = None
tariff_id_for_trial = None
trial_duration = None
if settings.is_tariffs_mode():
try:
from app.database.crud.tariff import get_tariff_by_id as _get_tariff, get_trial_tariff
trial_tariff = await get_trial_tariff(db)
if not trial_tariff:
trial_tariff_id = settings.get_trial_tariff_id()
if trial_tariff_id > 0:
trial_tariff = await _get_tariff(db, trial_tariff_id)
if trial_tariff:
trial_traffic_limit = trial_tariff.traffic_limit_gb
trial_device_limit = trial_tariff.device_limit
trial_squads = trial_tariff.allowed_squads or []
tariff_id_for_trial = trial_tariff.id
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
if tariff_trial_days:
trial_duration = tariff_trial_days
logger.info(
'Платный триал с баланса: используем тариф',
trial_tariff_name=trial_tariff.name,
trial_tariff_id=trial_tariff.id,
)
except Exception as e:
logger.error('Ошибка получения триального тарифа для платного триала', error=e)
subscription = await create_trial_subscription(
db,
db_user.id,
device_limit=forced_devices,
duration_days=trial_duration,
device_limit=trial_device_limit,
traffic_limit_gb=trial_traffic_limit,
connected_squads=trial_squads,
tariff_id=tariff_id_for_trial,
)
await db.refresh(db_user)
@@ -3365,28 +3421,63 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
try:
payment_service = PaymentService(callback.bot)
# Получаем случайный сквад для триала
from app.database.crud.server_squad import get_random_trial_squad_uuid
# Получаем параметры из триального тарифа
trial_duration = settings.TRIAL_DURATION_DAYS
trial_traffic = settings.TRIAL_TRAFFIC_LIMIT_GB
trial_devices = settings.TRIAL_DEVICE_LIMIT
trial_squads_list = []
tariff_id_for_trial = None
trial_squad_uuid = await get_random_trial_squad_uuid(db)
if settings.is_tariffs_mode():
try:
from app.database.crud.tariff import get_tariff_by_id as _get_tariff, get_trial_tariff
trial_tariff = await get_trial_tariff(db)
if not trial_tariff:
trial_tariff_id = settings.get_trial_tariff_id()
if trial_tariff_id > 0:
trial_tariff = await _get_tariff(db, trial_tariff_id)
if trial_tariff:
trial_traffic = trial_tariff.traffic_limit_gb
trial_devices = trial_tariff.device_limit
trial_squads_list = trial_tariff.allowed_squads or []
tariff_id_for_trial = trial_tariff.id
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
if tariff_trial_days:
trial_duration = tariff_trial_days
logger.info(
'Платный триал через платёжку: используем тариф',
trial_tariff_name=trial_tariff.name,
trial_tariff_id=trial_tariff.id,
)
except Exception as e:
logger.error('Ошибка получения триального тарифа для платного триала', error=e)
# Если тариф не задал серверы, получаем случайный сквад
if not trial_squads_list:
from app.database.crud.server_squad import get_random_trial_squad_uuid
trial_squad_uuid = await get_random_trial_squad_uuid(db)
trial_squads_list = [trial_squad_uuid] if trial_squad_uuid else []
# Создаем pending триальную подписку
pending_subscription = await create_pending_trial_subscription(
db=db,
user_id=db_user.id,
duration_days=settings.TRIAL_DURATION_DAYS,
traffic_limit_gb=settings.TRIAL_TRAFFIC_LIMIT_GB,
device_limit=settings.TRIAL_DEVICE_LIMIT,
connected_squads=[trial_squad_uuid] if trial_squad_uuid else [],
duration_days=trial_duration,
traffic_limit_gb=trial_traffic,
device_limit=trial_devices,
connected_squads=trial_squads_list,
payment_method=f'trial_{payment_method}',
total_price_kopeks=trial_price_kopeks,
tariff_id=tariff_id_for_trial,
)
if not pending_subscription:
await callback.answer('❌ Не удалось подготовить заказ. Попробуйте позже.', show_alert=True)
return
traffic_label = 'Безлимит' if settings.TRIAL_TRAFFIC_LIMIT_GB == 0 else f'{settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ'
traffic_label = 'Безлимит' if trial_traffic == 0 else f'{trial_traffic} ГБ'
if payment_method == 'stars':
# Оплата через Telegram Stars
@@ -3395,11 +3486,11 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
await callback.bot.send_invoice(
chat_id=callback.from_user.id,
title=texts.t('PAID_TRIAL_INVOICE_TITLE', 'Пробная подписка на {days} дней').format(
days=settings.TRIAL_DURATION_DAYS
days=trial_duration
),
description=(
f'{texts.t("PERIOD", "Период")}: {settings.TRIAL_DURATION_DAYS} {texts.t("DAYS", "дней")}\n'
f'{texts.t("DEVICES", "Устройства")}: {settings.TRIAL_DEVICE_LIMIT}\n'
f'{texts.t("PERIOD", "Период")}: {trial_duration} {texts.t("DAYS", "дней")}\n'
f'{texts.t("DEVICES", "Устройства")}: {trial_devices}\n'
f'{texts.t("TRAFFIC", "Трафик")}: {traffic_label}'
),
payload=f'trial_{pending_subscription.id}',
@@ -3426,7 +3517,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
db=db,
amount_kopeks=trial_price_kopeks,
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
days=settings.TRIAL_DURATION_DAYS
days=trial_duration
),
user_id=db_user.id,
metadata={
@@ -3465,7 +3556,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
user_id=db_user.id,
amount_kopeks=trial_price_kopeks,
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
days=settings.TRIAL_DURATION_DAYS
days=trial_duration
),
metadata={
'type': 'trial',
@@ -3514,7 +3605,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
amount_usd=amount_usd,
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
days=settings.TRIAL_DURATION_DAYS
days=trial_duration
),
payload=f'trial_{pending_subscription.id}_{db_user.id}',
)
@@ -3562,7 +3653,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
user_id=db_user.id,
amount_kopeks=trial_price_kopeks,
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
days=settings.TRIAL_DURATION_DAYS
days=trial_duration
),
language=db_user.language,
)
@@ -3600,7 +3691,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
user_id=db_user.id,
amount_kopeks=trial_price_kopeks,
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
days=settings.TRIAL_DURATION_DAYS
days=trial_duration
),
language=db_user.language,
)
@@ -3637,7 +3728,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
user_id=db_user.id,
amount_kopeks=trial_price_kopeks,
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
days=settings.TRIAL_DURATION_DAYS
days=trial_duration
),
language=db_user.language,
)
@@ -3675,7 +3766,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
user_id=db_user.id,
amount_kopeks=trial_price_kopeks,
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
days=settings.TRIAL_DURATION_DAYS
days=trial_duration
),
language=db_user.language,
)
@@ -3719,7 +3810,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
user_id=db_user.id,
amount_kopeks=trial_price_kopeks,
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
days=settings.TRIAL_DURATION_DAYS
days=trial_duration
),
language=db_user.language,
payment_method_code=method_code,
+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
+3 -1
View File
@@ -41,11 +41,13 @@ class AsyncHTTPClient:
auth_provider: AuthProvider,
default_headers: dict[str, str] | None = None,
timeout: float = 10.0,
proxy_url: str | None = None,
):
self.base_url = base_url
self.auth_provider = auth_provider
self.default_headers = default_headers or {}
self.timeout = timeout
self.proxy_url = proxy_url
self._refresh_lock = asyncio.Lock()
self.max_retries = 2 # Same as PHP AuthenticationPlugin::RETRY_LIMIT
@@ -124,7 +126,7 @@ class AsyncHTTPClient:
if json_data is not None:
request_kwargs['json'] = json_data
async with httpx.AsyncClient() as client:
async with httpx.AsyncClient(proxy=self.proxy_url) as client:
# Initial request
response = await client.request(**request_kwargs)
+6 -4
View File
@@ -40,6 +40,7 @@ class AuthProviderImpl(AuthProvider):
base_url: str = 'https://lknpd.nalog.ru/api',
storage_path: str | None = None,
device_id: str | None = None,
proxy_url: str | None = None,
):
self.base_url_v1 = f'{base_url}/v1'
self.base_url_v2 = f'{base_url}/v2'
@@ -47,6 +48,7 @@ class AuthProviderImpl(AuthProvider):
self.device_id = device_id or generate_device_id()
self.device_info = DeviceInfo(sourceDeviceId=self.device_id)
self._token_data: dict[str, Any] | None = None
self.proxy_url = proxy_url
# Default headers similar to PHP Authenticator
self.default_headers = {
@@ -130,7 +132,7 @@ class AuthProviderImpl(AuthProvider):
'deviceInfo': self.device_info.model_dump(),
}
async with httpx.AsyncClient() as client:
async with httpx.AsyncClient(proxy=self.proxy_url) as client:
response = await client.post(
f'{self.base_url_v1}/auth/lkfl',
json=request_data,
@@ -165,7 +167,7 @@ class AuthProviderImpl(AuthProvider):
'requireTpToBeActive': True,
}
async with httpx.AsyncClient() as client:
async with httpx.AsyncClient(proxy=self.proxy_url) as client:
response = await client.post(
f'{self.base_url_v2}/auth/challenge/sms/start',
json=request_data,
@@ -200,7 +202,7 @@ class AuthProviderImpl(AuthProvider):
'deviceInfo': self.device_info.model_dump(),
}
async with httpx.AsyncClient() as client:
async with httpx.AsyncClient(proxy=self.proxy_url) as client:
response = await client.post(
f'{self.base_url_v1}/auth/challenge/sms/verify',
json=request_data,
@@ -233,7 +235,7 @@ class AuthProviderImpl(AuthProvider):
}
try:
async with httpx.AsyncClient() as client:
async with httpx.AsyncClient(proxy=self.proxy_url) as client:
response = await client.post(
f'{self.base_url_v1}/auth/token',
json=request_data,
+4
View File
@@ -36,6 +36,7 @@ class Client:
storage_path: str | None = None,
device_id: str | None = None,
timeout: float = 10.0,
proxy_url: str | None = None,
):
"""
Initialize Moy Nalog API client.
@@ -45,6 +46,7 @@ class Client:
storage_path: Optional file path for token storage
device_id: Optional device ID (auto-generated if not provided)
timeout: HTTP request timeout in seconds
proxy_url: Optional SOCKS proxy URL for routing traffic
"""
self.base_url = base_url
self.timeout = timeout
@@ -54,6 +56,7 @@ class Client:
base_url=base_url,
storage_path=storage_path,
device_id=device_id,
proxy_url=proxy_url,
)
# Initialize HTTP client with auth middleware
@@ -67,6 +70,7 @@ class Client:
'Referrer': 'https://lknpd.nalog.ru/auth/login',
},
timeout=timeout,
proxy_url=proxy_url,
)
# User profile data (for receipt operations)
+5
View File
@@ -1317,6 +1317,11 @@
"REFERRAL_INVITE_TITLE": "🎉 Join the VPN service!",
"REFERRAL_LINK_CAPTION": "🔗 Your referral link:\n{link}",
"REFERRAL_LINK_TITLE": "🔗 <b>Your referral link:</b>",
"REFERRAL_BOT_LINK_TITLE": "🤖 <b>Bot link:</b>",
"REFERRAL_CABINET_LINK_TITLE": "🌐 <b>Cabinet link:</b>",
"REFERRAL_QR_BOT_LINK": "🤖 Bot link:\n{link}",
"REFERRAL_QR_CABINET_LINK": "🌐 Cabinet link:\n{link}",
"REFERRAL_INVITE_CABINET_LINK": "🌐 Or via personal cabinet:",
"REFERRAL_LIST_BUTTON": "👥 Referral list",
"REFERRAL_LIST_EMPTY": "📋 You have no referrals yet.\n\nShare your referral link to start earning!",
"REFERRAL_LIST_HEADER": "👥 <b>Your referrals</b> (page {current}/{total})",
+5
View File
@@ -1338,6 +1338,11 @@
"REFERRAL_INVITE_TITLE": "🎉 به سرویس VPN بپیوند!",
"REFERRAL_LINK_CAPTION": "🔗 لینک دعوت شما:\n{link}",
"REFERRAL_LINK_TITLE": "🔗 <b>لینک دعوت شما:</b>",
"REFERRAL_BOT_LINK_TITLE": "🤖 <b>لینک ربات:</b>",
"REFERRAL_CABINET_LINK_TITLE": "🌐 <b>لینک کابینت:</b>",
"REFERRAL_QR_BOT_LINK": "🤖 لینک ربات:\n{link}",
"REFERRAL_QR_CABINET_LINK": "🌐 لینک کابینت:\n{link}",
"REFERRAL_INVITE_CABINET_LINK": "🌐 یا از طریق کابینت شخصی:",
"REFERRAL_LIST_BUTTON": "👥 لیست دعوت‌شدگان",
"REFERRAL_LIST_EMPTY": "📋 هنوز دعوت‌شده‌ای ندارید.\n\nلینک دعوت خود را به اشتراک بگذارید!",
"REFERRAL_LIST_HEADER": "👥 <b>دعوت‌شدگان شما</b> (صفحه {current}/{total})",
+5
View File
@@ -1338,6 +1338,11 @@
"REFERRAL_INVITE_TITLE": "🎉 Присоединяйся к VPN сервису!",
"REFERRAL_LINK_CAPTION": "🔗 Ваша реферальная ссылка:\n{link}",
"REFERRAL_LINK_TITLE": "🔗 <b>Ваша реферальная ссылка:</b>",
"REFERRAL_BOT_LINK_TITLE": "🤖 <b>Ссылка на бота:</b>",
"REFERRAL_CABINET_LINK_TITLE": "🌐 <b>Ссылка на кабинет:</b>",
"REFERRAL_QR_BOT_LINK": "🤖 Ссылка на бота:\n{link}",
"REFERRAL_QR_CABINET_LINK": "🌐 Ссылка на кабинет:\n{link}",
"REFERRAL_INVITE_CABINET_LINK": "🌐 Или через личный кабинет:",
"REFERRAL_LIST_BUTTON": "👥 Список рефералов",
"REFERRAL_LIST_EMPTY": "📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!",
"REFERRAL_LIST_HEADER": "👥 <b>Ваши рефералы</b> (стр. {current}/{total})",
+5
View File
@@ -1254,6 +1254,11 @@
"REFERRAL_INVITE_TITLE": "🎉 Приєднуйся до VPN сервісу!",
"REFERRAL_LINK_CAPTION": "🔗 Ваше реферальне посилання:\n{link}",
"REFERRAL_LINK_TITLE": "🔗 <b>Ваше реферальне посилання:</b>",
"REFERRAL_BOT_LINK_TITLE": "🤖 <b>Посилання на бота:</b>",
"REFERRAL_CABINET_LINK_TITLE": "🌐 <b>Посилання на кабінет:</b>",
"REFERRAL_QR_BOT_LINK": "🤖 Посилання на бота:\n{link}",
"REFERRAL_QR_CABINET_LINK": "🌐 Посилання на кабінет:\n{link}",
"REFERRAL_INVITE_CABINET_LINK": "🌐 Або через особистий кабінет:",
"REFERRAL_LIST_BUTTON": "👥 Список рефералів",
"REFERRAL_LIST_EMPTY": "📋 У вас поки немає рефералів.\n\nПоділіться своїм реферальним посиланням, щоб почати заробляти!",
"REFERRAL_LIST_HEADER": "👥 <b>Ваші реферали</b> (стор. {current}/{total})",
+5
View File
@@ -1252,6 +1252,11 @@
"REFERRAL_INVITE_TITLE": "🎉加入VPN服务!",
"REFERRAL_LINK_CAPTION": "🔗您的推荐链接:\n{link}",
"REFERRAL_LINK_TITLE": "🔗<b>您的推荐链接:</b>",
"REFERRAL_BOT_LINK_TITLE": "🤖<b>机器人链接:</b>",
"REFERRAL_CABINET_LINK_TITLE": "🌐<b>控制面板链接:</b>",
"REFERRAL_QR_BOT_LINK": "🤖机器人链接:\n{link}",
"REFERRAL_QR_CABINET_LINK": "🌐控制面板链接:\n{link}",
"REFERRAL_INVITE_CABINET_LINK": "🌐或通过个人面板:",
"REFERRAL_LIST_BUTTON": "👥推荐列表",
"REFERRAL_LIST_EMPTY": "📋您目前没有推荐。\n\n分享您的推荐链接开始赚钱吧!",
"REFERRAL_LIST_HEADER": "👥<b>您的推荐</b>(第{current}/{total}页)",
+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)
+8 -3
View File
@@ -61,6 +61,7 @@ class BroadcastConfig:
selected_buttons: list[str]
media: BroadcastMediaConfig | None = None
initiator_name: str | None = None
custom_buttons: list[dict] | None = None
@dataclass
@@ -179,7 +180,7 @@ class BroadcastService:
await self._mark_finished(broadcast_id, sent_count, failed_count, blocked_count, cancelled=False)
return
keyboard = self._build_keyboard(config.selected_buttons)
keyboard = self._build_keyboard(config.selected_buttons, config.custom_buttons)
logger.info(
'Рассылка : начинаем отправку получателям (batch delay=s)',
@@ -358,10 +359,14 @@ class BroadcastService:
return sent_count, failed_count, blocked_count, False
def _build_keyboard(self, selected_buttons: list[str] | None) -> InlineKeyboardMarkup | None:
def _build_keyboard(
self,
selected_buttons: list[str] | None,
custom_buttons: list[dict] | None = None,
) -> InlineKeyboardMarkup | None:
if selected_buttons is None:
selected_buttons = []
return create_broadcast_keyboard(selected_buttons)
return create_broadcast_keyboard(selected_buttons, custom_buttons=custom_buttons)
async def _deliver_message(
self,
+525 -29
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
@@ -25,6 +25,7 @@ from app.database.models import (
LandingPage,
PaymentMethod,
Tariff,
Transaction,
TransactionType,
User,
)
@@ -46,11 +47,10 @@ async def _send_admin_notification(
if not getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) or not settings.BOT_TOKEN:
return
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.services.admin_notification_service import AdminNotificationService
async with Bot(token=settings.BOT_TOKEN) as bot:
async with create_bot() as bot:
service = AdminNotificationService(bot)
await service.send_guest_purchase_notification(
purchase,
@@ -177,6 +177,97 @@ async def create_purchase(
return purchase
async def _create_nalogo_receipt_for_purchase(
db: AsyncSession,
purchase: GuestPurchase,
user: User,
transaction: Transaction | None = None,
) -> None:
"""Create NaloGO fiscal receipt for a guest purchase (best-effort)."""
if not settings.is_nalogo_enabled():
return
# Без payment_id нет dedup-ключа в Redis — нельзя гарантировать идемпотентность
if not purchase.payment_id:
logger.warning(
'Cannot create NaloGO receipt: purchase has no payment_id',
purchase_id=purchase.id,
)
return
# Нулевые/отрицательные суммы не фискализируем
if purchase.amount_kopeks <= 0:
return
# Защита от дублей: если у транзакции или покупки уже есть чек — не создаём новый
if transaction and transaction.receipt_uuid:
logger.info(
'NaloGO receipt already exists for guest purchase (transaction)',
purchase_id=purchase.id,
receipt_uuid=transaction.receipt_uuid,
)
return
if purchase.receipt_uuid:
logger.info(
'NaloGO receipt already exists for guest purchase (purchase)',
purchase_id=purchase.id,
receipt_uuid=purchase.receipt_uuid,
)
return
try:
from app.services.nalogo_service import NaloGoService
nalogo_service = NaloGoService()
if not nalogo_service.configured:
return
amount_rubles = purchase.amount_kopeks / 100
# Не передаём telegram_user_id в описание чека — privacy (VPN-сервис)
receipt_name = settings.get_balance_payment_description(purchase.amount_kopeks)
receipt_uuid = await nalogo_service.create_receipt(
name=receipt_name,
amount=amount_rubles,
quantity=1,
payment_id=purchase.payment_id,
telegram_user_id=user.telegram_id,
amount_kopeks=purchase.amount_kopeks,
)
if receipt_uuid:
logger.info(
'NaloGO receipt created for guest purchase',
purchase_id=purchase.id,
receipt_uuid=receipt_uuid,
saved_to_transaction=transaction is not None,
)
# Всегда сохраняем receipt_uuid на purchase (persistent dedup)
try:
purchase.receipt_uuid = receipt_uuid
purchase.receipt_created_at = datetime.now(UTC)
if transaction:
transaction.receipt_uuid = receipt_uuid
transaction.receipt_created_at = datetime.now(UTC)
await db.commit()
except Exception:
await db.rollback()
logger.warning(
'Failed to save receipt_uuid to purchase/transaction',
purchase_id=purchase.id,
receipt_uuid=receipt_uuid,
)
except Exception as exc:
from app.utils.proxy import sanitize_proxy_error
logger.error(
'Failed to create nalogo receipt for guest purchase',
purchase_id=purchase.id,
error=sanitize_proxy_error(exc),
)
async def fulfill_purchase(
db: AsyncSession,
purchase_token: str,
@@ -272,6 +363,10 @@ async def fulfill_purchase(
await _send_admin_notification(purchase, notification_tariff_name, is_pending_activation=True)
# Создаем чек через NaloGO (деньги получены, чек нужен)
await _create_nalogo_receipt_for_purchase(db, purchase, user)
await db.refresh(purchase) # guard: inner rollback may expire the object
# Clear plaintext password after email delivery
if purchase.cabinet_password:
purchase.cabinet_password = None
@@ -336,9 +431,10 @@ async def fulfill_purchase(
await db.refresh(purchase, attribute_names=['landing', 'user'])
# Create transaction so promo group auto-assignment and contest tracking work
transaction = None
try:
payment_method_enum = _resolve_payment_method(purchase.payment_method)
await create_transaction(
transaction = await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
@@ -364,6 +460,12 @@ async def fulfill_purchase(
await _send_admin_notification(purchase, notification_tariff_name, is_pending_activation=False)
# Создаем чек через NaloGO
await _create_nalogo_receipt_for_purchase(db, purchase, user, transaction)
# Refresh purchase: если внутри nalogo helper был rollback, объект expired
await db.refresh(purchase)
# Clear plaintext password after email delivery — no longer needed in DB
if purchase.cabinet_password:
purchase.cabinet_password = None
@@ -392,24 +494,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:
@@ -529,9 +648,9 @@ async def _find_or_create_user(
resolved_telegram_id: int | None = pre_resolved_telegram_id
if resolved_telegram_id is None:
try:
from aiogram import Bot
from app.bot_factory import create_bot
async with Bot(token=settings.BOT_TOKEN) as bot:
async with create_bot() as bot:
chat = await asyncio.wait_for(
bot.get_chat(chat_id=f'@{username}'),
timeout=5.0,
@@ -639,11 +758,10 @@ async def _send_telegram_gift_notification(
try:
import html as html_mod
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from app.bot_factory import create_bot
gift_from = ''
if purchase.contact_value:
safe_name = html_mod.escape(purchase.contact_value)
@@ -674,10 +792,7 @@ async def _send_telegram_gift_notification(
]
)
async with Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
) as bot:
async with create_bot() as bot:
await bot.send_message(
chat_id=user.telegram_id,
text=text,
@@ -1002,14 +1117,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 +1135,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 +1151,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 +1161,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 +1176,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 +1195,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 +1205,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 +1215,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 +1223,368 @@ 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 app.bot_factory import create_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 create_bot() 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 app.bot_factory import create_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 create_bot() 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',
CryptoBotPayment.payload.like('{%'),
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

Some files were not shown because too many files have changed in this diff Show More