Compare commits

...

87 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
111 changed files with 2516 additions and 994 deletions
+9
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
# ===== УВЕДОМЛЕНИЯ ОТ ВЕБХУКОВ (что получают пользователи) =====
# Глобальный переключатель уведомлений пользователям от вебхуков
@@ -514,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)
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
# Эти настройки позволяют изменить описания платежей,
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.36.0"
".": "3.41.0"
}
+113
View File
@@ -1,5 +1,118 @@
# 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)
+1 -1
View File
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
FROM python:3.13-slim
ARG VERSION="v3.36.0" # x-release-please-version
ARG VERSION="v3.41.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+12 -7
View File
@@ -41,21 +41,21 @@ Bedolaga — полнофункциональная платформа для п
### 📦 Подписки и тарифы
- 🎯 Гибкие тарифные планы (от 14 дней до года)
- 📊 Трафик: безлимит, фиксированный лимит или пакеты
- 📱 Управление устройствами (1–20 на подписку)
- 🌍 Автовыбор сервера или ручной выбор
- 🆓 Пробный период с конвертацией в платный
- 🎯 Гибкие тарифные планы (от X дней до X дней)
- 📊 Трафик: безлимит, фиксированный лимит или пакеты с возможностью докупки
- 📱 Управление устройствами (1–20 на подписку) или отключение лимитов
- 🌍 Автовыбор сервера(Тарифы) или ручной выбор(Конфигуратор подписки - с возможностью докупки)
- 🆓 Пробный период(Возможен платный) с конвертацией в платный
- 🛒 Умная корзина — сохраняет выбор при недостатке баланса
- 🔄 Автопродление за 3 дня до окончания
- 🎁 Подарочные подписки
- 🎁 Подарочные подписки и конфигурируемые лендинги для быстрой продажи в вебе без авторизации
</td>
<td width="50%" valign="top">
### 💳 Платежи
- 🏦 **14 платёжных провайдеров** одновременно
- 🏦 **15 платёжных провайдеров** одновременно
- 💰 Единый баланс: пополнение любым способом → покупка с баланса
- ⚡ Автопокупка подписки после пополнения
- 💾 Рекуррентные платежи (сохранённые карты)
@@ -72,11 +72,13 @@ Bedolaga — полнофункциональная платформа для п
- 🏷 Промокоды (деньги, дни подписки, триалы)
- 👥 Реферальная программа с выводом средств
- 👥 Партнерская система
- 📨 Рассылки по сегментам пользователей
- 🌐 Кастомные лендинги с аналитикой
- 🎮 Конкурсы и ежедневные игры с призами
- 🎯 Персональные предложения и скидки
- 📈 Маркетинговые кампании с трекингом
- 🌐 Обязательная мультиподписка на каналы с возможностью автоотключения подписки - при отписки от канала
</td>
<td width="50%" valign="top">
@@ -91,6 +93,9 @@ Bedolaga — полнофункциональная платформа для п
- 📡 Мониторинг трафика и аномалий
- 🤝 Партнёрская программа
- 🔐 RBAC: роли и гранулярные права доступа
- 📈 Детальная отчетность с возможностью визуализации Реф сети
- 🔐 Блокировка юзеров из общего черного списка
И многое др...
</td>
</tr>
+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
+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)
+3 -1
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',
+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,
+2 -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,
@@ -550,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(
+103 -15
View File
@@ -2,6 +2,7 @@
import re
from collections import defaultdict
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -16,6 +17,7 @@ from app.database.models import (
PartnerStatus,
ReferralEarning,
Subscription,
SubscriptionStatus,
Tariff,
Transaction,
TransactionType,
@@ -78,6 +80,7 @@ class NetworkUserNode(BaseModel):
personal_spent_kopeks: int
subscription_name: str | None
subscription_end: str | None
subscription_status: str | None
registered_at: str | None
@@ -114,6 +117,7 @@ class NetworkGraphResponse(BaseModel):
total_referrers: int
total_campaigns: int
total_earnings_kopeks: int
total_subscription_revenue_kopeks: int
class NetworkUserDetail(BaseModel):
@@ -134,6 +138,7 @@ class NetworkUserDetail(BaseModel):
personal_spent_kopeks: int
subscription_name: str | None
subscription_end: str | None
subscription_status: str | None
registered_at: str | None
@@ -215,6 +220,7 @@ def _build_user_node(
campaign_id: int | None,
subscription_name: str | None,
subscription_end_str: str | None,
subscription_status: str | None,
) -> NetworkUserNode:
return NetworkUserNode(
id=user.id,
@@ -232,6 +238,7 @@ def _build_user_node(
personal_spent_kopeks=personal_spent,
subscription_name=subscription_name,
subscription_end=subscription_end_str,
subscription_status=subscription_status,
registered_at=_format_datetime(user.created_at),
)
@@ -312,7 +319,7 @@ async def _fetch_branch_revenue(db: AsyncSession, user_ids: set[int]) -> dict[in
stmt = (
select(
referred_user.c.referred_by_id,
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0),
)
.join(referred_user, Transaction.user_id == referred_user.c.id)
.where(
@@ -333,7 +340,7 @@ async def _fetch_personal_spent(db: AsyncSession, user_ids: set[int]) -> dict[in
return {}
stmt = (
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
select(Transaction.user_id, func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
.where(
and_(
Transaction.user_id.in_(user_ids),
@@ -376,18 +383,81 @@ async def _fetch_campaign_registrations(db: AsyncSession, user_ids: set[int] | N
return {row[0]: row[1] for row in result}
async def _fetch_subscription_info(db: AsyncSession, user_ids: set[int]) -> dict[int, tuple[str | None, str | None]]:
"""Return {user_id: (tariff_name, end_date_iso)} for given users."""
def _compute_subscription_status(
is_trial: bool | None,
db_status: str | None,
end_date: datetime | None,
now: datetime,
) -> str | None:
"""Map subscription fields to a frontend status label.
Returns one of: 'trial_active', 'trial_expired', 'paid_active', 'paid_expired', or None.
Statuses DISABLED, PENDING, EXPIRED, LIMITED are treated as inactive regardless of end_date.
ACTIVE and TRIAL fall through to a date-based check.
"""
if is_trial is None:
return None
if db_status in (
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.PENDING.value,
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.LIMITED.value,
):
return 'trial_expired' if is_trial else 'paid_expired'
if is_trial:
return 'trial_active' if (end_date and end_date > now) else 'trial_expired'
return 'paid_active' if (end_date and end_date > now) else 'paid_expired'
async def _fetch_subscription_info(
db: AsyncSession,
user_ids: set[int],
) -> dict[int, tuple[str | None, str | None, str | None]]:
"""Return {user_id: (tariff_name, end_date_iso, subscription_status)} for given users."""
if not user_ids:
return {}
stmt = (
select(Subscription.user_id, Tariff.name, Subscription.end_date)
row_num = (
func.row_number()
.over(
partition_by=Subscription.user_id,
order_by=Subscription.end_date.desc().nullslast(),
)
.label('rn')
)
inner = (
select(
Subscription.user_id,
Tariff.name,
Subscription.end_date,
Subscription.is_trial,
Subscription.status,
row_num,
)
.outerjoin(Tariff, Subscription.tariff_id == Tariff.id)
.where(Subscription.user_id.in_(user_ids))
)
subq = inner.subquery()
stmt = select(
subq.c.user_id,
subq.c.name,
subq.c.end_date,
subq.c.is_trial,
subq.c.status,
).where(subq.c.rn == 1)
result = await db.execute(stmt)
return {row[0]: (row[1], _format_datetime(row[2]) if row[2] else None) for row in result}
now = datetime.now(UTC)
out: dict[int, tuple[str | None, str | None, str | None]] = {}
for row in result:
user_id, tariff_name, end_date, is_trial, db_status = row
end_date_iso = _format_datetime(end_date) if end_date else None
sub_status = _compute_subscription_status(is_trial, db_status, end_date, now)
out[user_id] = (tariff_name, end_date_iso, sub_status)
return out
async def _fetch_campaign_stats(
@@ -428,7 +498,7 @@ async def _fetch_campaign_stats(
user_spent: dict[int, int] = {}
if all_campaign_users:
spent_stmt = (
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
select(Transaction.user_id, func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
.where(
and_(
Transaction.user_id.in_(all_campaign_users),
@@ -540,6 +610,7 @@ async def get_referral_network(
total_referrers=0,
total_campaigns=0,
total_earnings_kopeks=0,
total_subscription_revenue_kopeks=0,
)
# Cap to prevent excessive response sizes (deterministic: keep lowest IDs for stability)
@@ -567,7 +638,7 @@ async def get_referral_network(
# Build user nodes
user_nodes: list[NetworkUserNode] = []
for user in users:
sub = sub_info.get(user.id, (None, None))
sub = sub_info.get(user.id, (None, None, None))
user_nodes.append(
_build_user_node(
user,
@@ -578,6 +649,7 @@ async def get_referral_network(
campaign_id=campaign_regs.get(user.id),
subscription_name=sub[0],
subscription_end_str=sub[1],
subscription_status=sub[2],
)
)
@@ -629,6 +701,7 @@ async def get_referral_network(
total_referrers = len([u for u in user_nodes if u.direct_referrals > 0])
total_earnings = sum(personal_revenue.values())
total_subscription_revenue = sum(personal_spent.values())
return NetworkGraphResponse(
users=user_nodes,
@@ -638,6 +711,7 @@ async def get_referral_network(
total_referrers=total_referrers,
total_campaigns=len(campaign_nodes),
total_earnings_kopeks=total_earnings,
total_subscription_revenue_kopeks=total_subscription_revenue,
)
@@ -784,6 +858,7 @@ async def _build_scoped_graph(
total_referrers=0,
total_campaigns=len(campaign_nodes),
total_earnings_kopeks=0,
total_subscription_revenue_kopeks=0,
)
return NetworkGraphResponse(
users=[],
@@ -793,6 +868,7 @@ async def _build_scoped_graph(
total_referrers=0,
total_campaigns=0,
total_earnings_kopeks=0,
total_subscription_revenue_kopeks=0,
)
# Cap to prevent excessive response sizes
@@ -816,7 +892,7 @@ async def _build_scoped_graph(
user_nodes: list[NetworkUserNode] = []
for user in users:
sub = sub_info.get(user.id, (None, None))
sub = sub_info.get(user.id, (None, None, None))
user_nodes.append(
_build_user_node(
user,
@@ -827,6 +903,7 @@ async def _build_scoped_graph(
campaign_id=campaign_regs.get(user.id),
subscription_name=sub[0],
subscription_end_str=sub[1],
subscription_status=sub[2],
)
)
@@ -878,6 +955,7 @@ async def _build_scoped_graph(
total_referrers = len([u for u in user_nodes if u.direct_referrals > 0])
total_earnings = sum(personal_revenue.values())
total_subscription_revenue = sum(personal_spent.values())
return NetworkGraphResponse(
users=user_nodes,
@@ -887,6 +965,7 @@ async def _build_scoped_graph(
total_referrers=total_referrers,
total_campaigns=len(campaign_nodes),
total_earnings_kopeks=total_earnings,
total_subscription_revenue_kopeks=total_subscription_revenue,
)
@@ -1057,7 +1136,7 @@ async def get_network_user_detail(
branch_revenue = 0
# Personal spent
spent_stmt = select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
spent_stmt = select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
and_(
Transaction.user_id == user_id,
Transaction.type.in_(SPENT_TRANSACTION_TYPES),
@@ -1102,7 +1181,7 @@ async def get_network_user_detail(
# Branch revenue: total spent by all users in the branch
branch_user_ids_stmt = select(branch_cte.c.id)
branch_rev_stmt = select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
branch_rev_stmt = select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
and_(
Transaction.user_id.in_(branch_user_ids_stmt),
Transaction.type.in_(SPENT_TRANSACTION_TYPES),
@@ -1124,10 +1203,17 @@ async def get_network_user_detail(
# Subscription info
subscription_name: str | None = None
subscription_end: str | None = None
subscription_status: str | None = None
if user.subscription is not None:
if user.subscription.tariff is not None:
subscription_name = user.subscription.tariff.name
subscription_end = _format_datetime(user.subscription.end_date)
subscription_status = _compute_subscription_status(
user.subscription.is_trial,
user.subscription.status,
user.subscription.end_date,
datetime.now(UTC),
)
return NetworkUserDetail(
id=user.id,
@@ -1147,6 +1233,7 @@ async def get_network_user_detail(
personal_spent_kopeks=personal_spent,
subscription_name=subscription_name,
subscription_end=subscription_end,
subscription_status=subscription_status,
registered_at=_format_datetime(user.created_at),
)
@@ -1215,7 +1302,7 @@ async def get_network_campaign_detail(
total_spent = 0
if campaign_user_ids:
spent_stmt = (
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
select(Transaction.user_id, func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
.where(
and_(
Transaction.user_id.in_(campaign_user_ids),
@@ -1336,7 +1423,7 @@ async def search_referral_network(
sub_info = await _fetch_subscription_info(db, matched_ids)
for user in matched_users:
sub = sub_info.get(user.id, (None, None))
sub = sub_info.get(user.id, (None, None, None))
user_nodes.append(
_build_user_node(
user,
@@ -1347,6 +1434,7 @@ async def search_referral_network(
campaign_id=campaign_regs.get(user.id),
subscription_name=sub[0],
subscription_end_str=sub[1],
subscription_status=sub[2],
)
)
@@ -1400,7 +1488,7 @@ async def search_referral_network(
campaign_user_spent: dict[int, int] = {}
if all_campaign_user_ids:
spent_stmt = (
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
select(Transaction.user_id, func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
.where(
and_(
Transaction.user_id.in_(all_campaign_user_ids),
+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}
+12 -12
View File
@@ -112,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,
@@ -128,7 +128,7 @@ async def get_sales_summary(
# Manual top-ups by admins
manual_topup_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.is_completed == True,
@@ -246,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,
@@ -256,7 +256,7 @@ 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 + manual_topup,
@@ -1079,7 +1079,7 @@ async def get_deposits_stats(
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_(methods_with_manual),
Transaction.created_at >= period_start,
@@ -1089,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()
@@ -1101,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)
@@ -1116,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))
@@ -1137,7 +1137,7 @@ async def get_deposits_stats(
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),
+2 -7
View File
@@ -479,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 '',
+60 -45
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(
@@ -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
+67
View File
@@ -1373,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 подписка готова',
@@ -1382,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>
@@ -1389,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>
""",
@@ -1398,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>
""",
@@ -1407,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>
""",
@@ -1416,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>
""",
@@ -1425,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>
""",
+75 -18
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
@@ -118,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
@@ -375,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
@@ -802,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:
@@ -928,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.
@@ -1099,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:
@@ -1118,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]:
@@ -1439,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()
+6
View File
@@ -1442,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.
@@ -1475,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)
@@ -1497,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,
)
@@ -1526,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(
@@ -1538,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)
+16 -11
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,
@@ -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),
+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()
+2
View File
@@ -3288,6 +3288,8 @@ class GuestPurchase(Base):
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')
+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',
+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,
+69 -32
View File
@@ -1,5 +1,6 @@
import hashlib
import json
from html import escape as html_escape
from pathlib import Path
import qrcode
@@ -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)}
"""
+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)
+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 -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}页)",
+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,
+116 -19
View File
@@ -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
@@ -546,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,
@@ -656,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)
@@ -691,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,
@@ -1205,8 +1303,7 @@ async def _send_stuck_purchase_alert(data: dict, retry_count: int, phase: str) -
try:
import html as html_mod
from aiogram import Bot
from app.bot_factory import create_bot
from app.services.admin_notification_service import AdminNotificationService, NotificationCategory
amount_rub = data['amount_kopeks'] / 100
@@ -1225,7 +1322,7 @@ async def _send_stuck_purchase_alert(data: dict, retry_count: int, phase: str) -
f'Requires manual investigation.'
)
async with Bot(token=settings.BOT_TOKEN) as bot:
async with create_bot() as bot:
service = AdminNotificationService(bot)
await service.send_admin_notification(text, category=NotificationCategory.ERRORS)
except Exception:
@@ -1244,8 +1341,7 @@ async def _send_amount_mismatch_alert(
try:
import html as html_mod
from aiogram import Bot
from app.bot_factory import create_bot
from app.services.admin_notification_service import AdminNotificationService, NotificationCategory
text = (
@@ -1260,7 +1356,7 @@ async def _send_amount_mismatch_alert(
f'Requires manual investigation.'
)
async with Bot(token=settings.BOT_TOKEN) as bot:
async with create_bot() as bot:
service = AdminNotificationService(bot)
await service.send_admin_notification(text, category=NotificationCategory.ERRORS)
except Exception:
@@ -1351,6 +1447,7 @@ async def _find_succeeded_provider_payment(
result = await db.execute(
select(CryptoBotPayment).where(
CryptoBotPayment.status == 'paid',
CryptoBotPayment.payload.like('{%'),
cast(CryptoBotPayment.payload, SA_JSON)['purchase_token'].as_string() == purchase_token,
)
)
+4 -2
View File
@@ -377,8 +377,10 @@ class MonitoringService:
update_kwargs = dict(
uuid=user.remnawave_uuid,
status=RemnaWaveUserStatus.ACTIVE if is_active else RemnaWaveUserStatus.EXPIRED,
expire_at=subscription.end_date,
status=RemnaWaveUserStatus.ACTIVE if is_active else RemnaWaveUserStatus.DISABLED,
expire_at=subscription.end_date
if is_active
else max(subscription.end_date, current_time + timedelta(minutes=1)),
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
description=settings.format_remnawave_user_description(
+19 -11
View File
@@ -10,6 +10,7 @@ from app.config import settings
from app.lib.nalogo import Client
from app.lib.nalogo.dto.income import IncomeClient, IncomeType
from app.utils.cache import cache
from app.utils.proxy import mask_proxy_url, sanitize_proxy_error
logger = structlog.get_logger(__name__)
@@ -41,18 +42,25 @@ class NaloGoService:
try:
# Таймаут 30 секунд — nalog.ru иногда отвечает медленно
timeout = getattr(settings, 'NALOGO_TIMEOUT', 30.0)
proxy_url = settings.get_nalogo_proxy_url()
self.client = Client(
base_url='https://lknpd.nalog.ru/api',
storage_path=storage_path,
device_id=device_id or 'bot-device-123',
timeout=timeout,
proxy_url=proxy_url,
)
self.inn = inn
self.password = password
self.configured = True
logger.info('NaloGO клиент инициализирован для ИНН: ...', inn=inn[:5])
if proxy_url:
logger.info(
'NaloGO клиент инициализирован с прокси', inn=inn[:5], proxy_url=mask_proxy_url(proxy_url)
)
else:
logger.info('NaloGO клиент инициализирован для ИНН: ...', inn=inn[:5])
except Exception as error:
logger.error('Ошибка инициализации NaloGO клиента', error=error, exc_info=True)
logger.error('Ошибка инициализации NaloGO клиента', error=sanitize_proxy_error(error))
self.configured = False
@staticmethod
@@ -281,9 +289,9 @@ class NaloGoService:
return True
except Exception as error:
if self._is_service_unavailable(error):
logger.warning('NaloGO временно недоступен (техработы)', error=str(error)[:200])
logger.warning('NaloGO временно недоступен (техработы)', error=sanitize_proxy_error(error))
else:
logger.error('Ошибка аутентификации в NaloGO', error=error, exc_info=True)
logger.error('Ошибка аутентификации в NaloGO', error=sanitize_proxy_error(error))
return False
async def create_receipt(
@@ -355,7 +363,7 @@ class NaloGoService:
name, amount, quantity, client_info, payment_id, telegram_user_id, amount_kopeks
)
else:
logger.error('Ошибка аутентификации NaloGO', auth_error=auth_error, exc_info=True)
logger.error('Ошибка аутентификации NaloGO', auth_error=sanitize_proxy_error(auth_error))
return None
# ЭТАП 2: Создание чека
@@ -400,9 +408,9 @@ class NaloGoService:
# ВАЖНО: Аутентификация была успешной, запрос на создание чека УШЁЛ
# При таймауте чек МОГ быть создан на сервере — НЕ добавляем в очередь!
if self._is_service_unavailable(error):
error_msg = str(error)[:200]
error_msg = sanitize_proxy_error(error)[:200]
logger.error(
'⚠️ ТАЙМАУТ после успешной аутентификации! Чек МОГ быть создан! (payment_id=, сумма=₽). Сохраняем в очередь проверки. Проверьте lknpd.nalog.ru',
'ТАЙМАУТ после успешной аутентификации! Чек МОГ быть создан!',
payment_id=payment_id,
amount=amount,
)
@@ -418,7 +426,7 @@ class NaloGoService:
error_message=error_msg,
)
else:
logger.error('Ошибка создания чека в NaloGO', error=error, exc_info=True)
logger.error('Ошибка создания чека в NaloGO', error=sanitize_proxy_error(error))
return None
async def get_queue_length(self) -> int:
@@ -511,7 +519,7 @@ class NaloGoService:
return None
except Exception as error:
logger.warning('Ошибка проверки дубликата чека', error=error)
logger.warning('Ошибка проверки дубликата чека', error=sanitize_proxy_error(error))
return None
async def get_incomes(
@@ -555,7 +563,7 @@ class NaloGoService:
except Exception as error:
if self._is_service_unavailable(error):
logger.warning('NaloGO временно недоступен', error=error)
logger.warning('NaloGO временно недоступен', error=sanitize_proxy_error(error))
else:
logger.error('Ошибка получения списка доходов', error=error, exc_info=True)
logger.error('Ошибка получения списка доходов', error=sanitize_proxy_error(error))
return None # None = ошибка, [] = нет чеков
+23 -35
View File
@@ -452,18 +452,10 @@ class CloudPaymentsPaymentMixin:
transaction: Any,
) -> None:
"""Send success notification to user via Telegram."""
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from app.config import settings
from app.bot_factory import create_bot
from app.localization.texts import get_texts
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
# Skip email-only users (no telegram_id)
if not user.telegram_id:
logger.debug('Skipping CloudPayments notification for email-only user', user_id=user.id)
@@ -492,15 +484,18 @@ class CloudPaymentsPaymentMixin:
if referrer_info:
message += f'\n\n{referrer_info}'
try:
await bot.send_message(
chat_id=user.telegram_id,
text=message,
parse_mode='HTML',
reply_markup=keyboard,
)
except Exception as error:
logger.warning('Не удалось отправить уведомление пользователю', telegram_id=user.telegram_id, error=error)
async with create_bot() as bot:
try:
await bot.send_message(
chat_id=user.telegram_id,
text=message,
parse_mode='HTML',
reply_markup=keyboard,
)
except Exception as error:
logger.warning(
'Не удалось отправить уведомление пользователю', telegram_id=user.telegram_id, error=error
)
async def _send_cloudpayments_fail_notification(
self,
@@ -508,27 +503,20 @@ class CloudPaymentsPaymentMixin:
message: str,
) -> None:
"""Send failure notification to user via Telegram."""
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from app.config import settings
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
from app.bot_factory import create_bot
text = f'❌ <b>Оплата не прошла</b>\n\n{message}'
try:
await bot.send_message(
chat_id=telegram_id,
text=text,
parse_mode='HTML',
)
except Exception as error:
logger.warning('Не удалось отправить уведомление пользователю', telegram_id=telegram_id, error=error)
async with create_bot() as bot:
try:
await bot.send_message(
chat_id=telegram_id,
text=text,
parse_mode='HTML',
)
except Exception as error:
logger.warning('Не удалось отправить уведомление пользователю', telegram_id=telegram_id, error=error)
async def get_cloudpayments_payment_status(
self,
+36 -7
View File
@@ -483,12 +483,14 @@ async def try_fulfill_guest_purchase(
if purchase_token is None:
return None
from app.database.crud.landing import get_purchase_by_token, update_purchase_status
from app.database.crud.landing import update_purchase_status
from app.database.models import GuestPurchase, GuestPurchaseStatus
from app.services.guest_purchase_service import fulfill_purchase
try:
existing = await get_purchase_by_token(db, purchase_token)
# FOR UPDATE prevents concurrent webhooks from double-processing the same purchase
result = await db.execute(select(GuestPurchase).where(GuestPurchase.token == purchase_token).with_for_update())
existing = result.scalars().first()
# Verify amount (skip for providers with currency conversion imprecision)
if existing and not skip_amount_check and payment_amount_kopeks != existing.amount_kopeks:
@@ -502,11 +504,20 @@ async def try_fulfill_guest_purchase(
await update_purchase_status(db, purchase_token, GuestPurchaseStatus.FAILED)
return True # consumed, even though failed
# Idempotency: skip terminal states
if existing and existing.status in (
GuestPurchaseStatus.DELIVERED.value,
GuestPurchaseStatus.PENDING_ACTIVATION.value,
GuestPurchaseStatus.FAILED.value,
# Idempotency: skip terminal states (and code-only gifts already in PAID)
if (
existing
and existing.status
in (
GuestPurchaseStatus.DELIVERED.value,
GuestPurchaseStatus.PENDING_ACTIVATION.value,
GuestPurchaseStatus.FAILED.value,
)
) or (
existing
and existing.status == GuestPurchaseStatus.PAID.value
and existing.is_gift
and not existing.gift_recipient_type
):
logger.info(
'Guest purchase already in terminal state, skipping',
@@ -536,6 +547,24 @@ async def try_fulfill_guest_purchase(
purchase_token_prefix=purchase_token[:5],
provider=provider_name,
)
# NaloGO receipt: payment received, fulfillment deferred until code activation
try:
await db.refresh(existing)
if existing.buyer:
from app.services.guest_purchase_service import _create_nalogo_receipt_for_purchase
await _create_nalogo_receipt_for_purchase(db, existing, existing.buyer)
else:
logger.warning(
'Code-only gift has no buyer, skipping NaloGO receipt',
purchase_token_prefix=purchase_token[:5],
buyer_user_id=existing.buyer_user_id,
)
except Exception:
logger.exception(
'Failed to create NaloGO receipt for code-only gift',
purchase_token_prefix=purchase_token[:5],
)
return True
# Fulfill: create user, subscription, deliver (commits on success)
+16 -12
View File
@@ -151,6 +151,13 @@ class CryptoBotPaymentMixin:
)
return True
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
locked = await cryptobot_crud.get_cryptobot_payment_by_invoice_id_for_update(db, invoice_id)
if not locked:
logger.error('CryptoBot: не удалось заблокировать платёж', invoice_id=invoice_id)
return False
payment = locked
if payment.status == 'paid':
logger.info('CryptoBot платеж уже обработан', invoice_id=invoice_id)
return True
@@ -164,13 +171,14 @@ class CryptoBotPaymentMixin:
else:
paid_at = datetime.now(UTC)
updated_payment = await cryptobot_crud.update_cryptobot_payment_status(
db,
invoice_id,
status,
paid_at,
commit=False,
)
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
payment.status = status
payment.updated_at = datetime.now(UTC)
if status == 'paid' and paid_at:
payment.paid_at = paid_at
await db.flush()
updated_payment = payment
descriptor = decode_payment_payload(
getattr(updated_payment, 'payload', '') or '',
@@ -202,11 +210,7 @@ class CryptoBotPaymentMixin:
if renewal_handled:
return True
locked = await cryptobot_crud.get_cryptobot_payment_by_id_for_update(db, updated_payment.id)
if not locked:
logger.error('CryptoBot: не удалось заблокировать платёж', payment_id=updated_payment.id)
return False
updated_payment = locked
# FOR UPDATE lock already acquired above — no need to re-lock
# --- Guest purchase flow (landing page) ---
# CryptoBot stores guest metadata in the payload field (JSON string),
+53 -42
View File
@@ -198,7 +198,14 @@ class FreekassaPaymentMixin:
logger.warning('Freekassa webhook: платеж не найден order_id', order_id=order_id)
return False
# Проверка дублирования
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
locked = await freekassa_crud.get_freekassa_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Freekassa webhook: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
# Re-check is_paid from the locked row
if payment.is_paid:
logger.info('Freekassa webhook: платеж уже обработан order_id', order_id=order_id)
return True
@@ -213,7 +220,7 @@ class FreekassaPaymentMixin:
)
return False
# Обновляем статус платежа
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
callback_payload = {
'merchant_id': merchant_id,
'amount': amount,
@@ -221,16 +228,15 @@ class FreekassaPaymentMixin:
'intid': intid,
'cur_id': cur_id,
}
payment = await freekassa_crud.update_freekassa_payment_status(
db=db,
payment=payment,
status='success',
is_paid=True,
freekassa_order_id=intid,
payment_system_id=cur_id,
callback_payload=callback_payload,
)
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.callback_payload = callback_payload
payment.freekassa_order_id = intid
if cur_id is not None:
payment.payment_system_id = cur_id
payment.updated_at = datetime.now(UTC)
await db.flush()
# Финализируем платеж (начисляем баланс, создаем транзакцию)
return await self._finalize_freekassa_payment(db, payment, intid=intid, trigger='webhook')
@@ -250,13 +256,7 @@ class FreekassaPaymentMixin:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления."""
payment_module = import_module('app.services.payment_service')
freekassa_lock_crud = import_module('app.database.crud.freekassa')
locked = await freekassa_lock_crud.get_freekassa_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Freekassa: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
# FOR UPDATE lock already acquired by caller — just check idempotency
if payment.transaction_id:
logger.info(
'Freekassa платеж уже привязан к транзакции (trigger=)', order_id=payment.order_id, trigger=trigger
@@ -506,32 +506,43 @@ class FreekassaPaymentMixin:
if fk_status == 1:
logger.info('Freekassa payment confirmed via API', order_id=payment.order_id)
callback_payload = {
'check_source': 'api',
'fk_order_data': target_order,
}
# Lock payment row before finalization to prevent concurrent double-processing
locked = await freekassa_crud.get_freekassa_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Freekassa status check: не удалось заблокировать платёж', payment_id=payment.id)
elif locked.is_paid:
# Another concurrent handler already processed — skip
logger.info('Freekassa платеж уже оплачен после блокировки', order_id=locked.order_id)
payment = locked
else:
payment = locked
# ID заказа на стороне FK (fk_order_id или id)
fk_intid = str(target_order.get('fk_order_id') or target_order.get('id'))
callback_payload = {
'check_source': 'api',
'fk_order_data': target_order,
}
# Обновляем статус
payment = await freekassa_crud.update_freekassa_payment_status(
db=db,
payment=payment,
status='success',
is_paid=True,
freekassa_order_id=fk_intid,
payment_system_id=int(target_order.get('curID')) if target_order.get('curID') else None,
callback_payload=callback_payload,
)
# ID заказа на стороне FK (fk_order_id или id)
fk_intid = str(target_order.get('fk_order_id') or target_order.get('id'))
# Финализируем
await self._finalize_freekassa_payment(
db,
payment,
intid=fk_intid,
trigger='api_check',
)
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.callback_payload = callback_payload
payment.freekassa_order_id = fk_intid
if target_order.get('curID'):
payment.payment_system_id = int(target_order['curID'])
payment.updated_at = datetime.now(UTC)
await db.flush()
# Финализируем
await self._finalize_freekassa_payment(
db,
payment,
intid=fk_intid,
trigger='api_check',
)
except Exception as e:
logger.error('Error checking Freekassa payment status', e=e)
+53 -42
View File
@@ -191,7 +191,14 @@ class KassaAiPaymentMixin:
logger.warning('KassaAI webhook: платеж не найден order_id', order_id=order_id)
return False
# Проверка дублирования
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
locked = await kassa_ai_crud.get_kassa_ai_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('KassaAI webhook: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
# Re-check is_paid from the locked row
if payment.is_paid:
logger.info('KassaAI webhook: платеж уже обработан order_id', order_id=order_id)
return True
@@ -206,7 +213,7 @@ class KassaAiPaymentMixin:
)
return False
# Обновляем статус платежа
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
callback_payload = {
'merchant_id': merchant_id,
'amount': amount,
@@ -214,16 +221,15 @@ class KassaAiPaymentMixin:
'intid': intid,
'cur_id': cur_id,
}
payment = await kassa_ai_crud.update_kassa_ai_payment_status(
db=db,
payment=payment,
status='success',
is_paid=True,
kassa_ai_order_id=intid,
payment_system_id=cur_id,
callback_payload=callback_payload,
)
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.callback_payload = callback_payload
payment.kassa_ai_order_id = intid
if cur_id is not None:
payment.payment_system_id = cur_id
payment.updated_at = datetime.now(UTC)
await db.flush()
# Финализируем платеж (начисляем баланс, создаем транзакцию)
return await self._finalize_kassa_ai_payment(db, payment, intid=intid, trigger='webhook')
@@ -243,13 +249,7 @@ class KassaAiPaymentMixin:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления."""
payment_module = import_module('app.services.payment_service')
kassa_ai_lock_crud = import_module('app.database.crud.kassa_ai')
locked = await kassa_ai_lock_crud.get_kassa_ai_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('KassaAI: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
# FOR UPDATE lock already acquired by caller — just check idempotency
if payment.transaction_id:
logger.info(
'KassaAI платеж уже привязан к транзакции (trigger=)', order_id=payment.order_id, trigger=trigger
@@ -485,32 +485,43 @@ class KassaAiPaymentMixin:
if kai_status == 1:
logger.info('KassaAI payment confirmed via API', order_id=payment.order_id)
callback_payload = {
'check_source': 'api',
'kai_order_data': target_order,
}
# Lock payment row before finalization to prevent concurrent double-processing
locked = await kassa_ai_crud.get_kassa_ai_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('KassaAI status check: не удалось заблокировать платёж', payment_id=payment.id)
elif locked.is_paid:
# Another concurrent handler already processed — skip
logger.info('KassaAI платеж уже оплачен после блокировки', order_id=locked.order_id)
payment = locked
else:
payment = locked
# ID заказа на стороне KassaAI
kai_intid = str(target_order.get('fk_order_id') or target_order.get('id'))
callback_payload = {
'check_source': 'api',
'kai_order_data': target_order,
}
# Обновляем статус
payment = await kassa_ai_crud.update_kassa_ai_payment_status(
db=db,
payment=payment,
status='success',
is_paid=True,
kassa_ai_order_id=kai_intid,
payment_system_id=int(target_order.get('curID')) if target_order.get('curID') else None,
callback_payload=callback_payload,
)
# ID заказа на стороне KassaAI
kai_intid = str(target_order.get('fk_order_id') or target_order.get('id'))
# Финализируем (начисляем баланс)
await self._finalize_kassa_ai_payment(
db,
payment,
intid=kai_intid,
trigger='api_check',
)
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.callback_payload = callback_payload
payment.kassa_ai_order_id = kai_intid
if target_order.get('curID'):
payment.payment_system_id = int(target_order['curID'])
payment.updated_at = datetime.now(UTC)
await db.flush()
# Финализируем (начисляем баланс)
await self._finalize_kassa_ai_payment(
db,
payment,
intid=kai_intid,
trigger='api_check',
)
except Exception as e:
logger.error('Error checking KassaAI payment status', e=e)
+21 -20
View File
@@ -174,6 +174,14 @@ class MulenPayPaymentMixin:
)
return False
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
mulenpay_lock_crud = import_module('app.database.crud.mulenpay')
locked = await mulenpay_lock_crud.get_mulenpay_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('MulenPay: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
invoice_message = metadata.get('invoice_message') or {}
@@ -199,11 +207,9 @@ class MulenPayPaymentMixin:
if payment.is_paid:
if invoice_message_removed:
try:
await payment_module.update_mulenpay_payment_metadata(
db,
payment=payment,
metadata=metadata,
)
payment.metadata_json = metadata
payment.updated_at = datetime.now(UTC)
await db.commit()
except Exception as error: # pragma: no cover - diagnostics
logger.warning(
'Не удалось обновить метаданные после удаления счёта',
@@ -217,21 +223,16 @@ class MulenPayPaymentMixin:
return True
if payment_status == 'success':
await payment_module.update_mulenpay_payment_status(
db,
payment=payment,
status='success',
callback_payload=callback_data,
mulen_payment_id=mulen_payment_id_int,
metadata=metadata,
)
mulenpay_lock_crud = import_module('app.database.crud.mulenpay')
locked = await mulenpay_lock_crud.get_mulenpay_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('MulenPay: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.callback_payload = callback_data
if mulen_payment_id_int is not None and not payment.mulen_payment_id:
payment.mulen_payment_id = mulen_payment_id_int
payment.metadata_json = metadata
payment.updated_at = datetime.now(UTC)
await db.flush()
if payment.transaction_id:
logger.info('Для платежа уже создана транзакция', display_name=display_name, uuid=payment.uuid)
+50 -39
View File
@@ -240,6 +240,14 @@ class Pal24PaymentMixin:
logger.error('Pal24 платеж не найден: /', bill_id=bill_id, order_id=order_id)
return False
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
pal24_lock_crud = import_module('app.database.crud.pal24')
locked = await pal24_lock_crud.get_pal24_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Pal24: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
if payment.is_paid:
logger.info('Pal24 платеж уже обработан', bill_id=payment.bill_id)
return True
@@ -249,25 +257,32 @@ class Pal24PaymentMixin:
if not isinstance(metadata, dict):
metadata = {}
payment = await payment_module.update_pal24_payment_status(
db,
payment,
status=status,
is_paid=True,
paid_at=datetime.now(UTC),
callback_payload=callback,
payment_id=payment_id,
payment_status=callback.get('Status') or status,
payment_method=(
callback.get('payment_method')
or callback.get('PaymentMethod')
or metadata.get('selected_method')
or getattr(payment, 'payment_method', None)
),
balance_amount=callback.get('BalanceAmount') or callback.get('balance_amount'),
balance_currency=callback.get('BalanceCurrency') or callback.get('balance_currency'),
payer_account=callback.get('AccountNumber') or callback.get('account') or callback.get('Account'),
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
payment.status = status
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.callback_payload = callback
if payment_id is not None:
payment.payment_id = payment_id
payment.payment_status = callback.get('Status') or status
payment.payment_method = (
callback.get('payment_method')
or callback.get('PaymentMethod')
or metadata.get('selected_method')
or getattr(payment, 'payment_method', None)
)
balance_amount = callback.get('BalanceAmount') or callback.get('balance_amount')
if balance_amount is not None:
payment.balance_amount = balance_amount
balance_currency = callback.get('BalanceCurrency') or callback.get('balance_currency')
if balance_currency is not None:
payment.balance_currency = balance_currency
payer_account = callback.get('AccountNumber') or callback.get('account') or callback.get('Account')
if payer_account is not None:
payment.payer_account = payer_account
payment.last_status = status
payment.updated_at = datetime.now(UTC)
await db.flush()
return await self._finalize_pal24_payment(
db,
@@ -336,23 +351,13 @@ class Pal24PaymentMixin:
if invoice_message_removed:
try:
await payment_module.update_pal24_payment_status(
db,
payment,
status=payment.status,
metadata=metadata,
)
payment.metadata_json = metadata
payment.updated_at = datetime.now(UTC)
await db.flush()
except Exception as error: # pragma: no cover - diagnostics
logger.warning('Не удалось обновить метаданные PayPalych после удаления счёта', error=error)
pal24_lock_crud = import_module('app.database.crud.pal24')
locked = await pal24_lock_crud.get_pal24_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Pal24: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
# FOR UPDATE lock already acquired by caller — just check idempotency
if payment.transaction_id:
logger.info('Pal24 платеж уже привязан к транзакции (trigger=)', bill_id=payment.bill_id, trigger=trigger)
return True
@@ -643,14 +648,20 @@ class Pal24PaymentMixin:
if payment.is_paid and not payment.transaction_id:
try:
finalized = await self._finalize_pal24_payment(
db,
payment,
payment_id=getattr(payment, 'payment_id', None),
trigger='status_check',
)
if finalized:
payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id)
# Acquire FOR UPDATE lock before finalization (status_check path)
pal24_lock_crud = import_module('app.database.crud.pal24')
locked = await pal24_lock_crud.get_pal24_payment_by_id_for_update(db, payment.id)
if locked:
payment = locked
if not payment.transaction_id:
finalized = await self._finalize_pal24_payment(
db,
payment,
payment_id=getattr(payment, 'payment_id', None),
trigger='status_check',
)
if finalized:
payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id)
except Exception as error:
logger.error('Ошибка автоматического начисления по Pal24 статусу', error=error, exc_info=True)
+61 -49
View File
@@ -153,35 +153,38 @@ class PlategaPaymentMixin:
logger.warning('Platega webhook: платеж не найден (id=)', transaction_id=transaction_id)
return False
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
platega_crud = import_module('app.database.crud.platega')
locked = await platega_crud.get_platega_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Platega: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
status_raw = str(payload.get('status') or '').upper()
if not status_raw:
logger.warning('Platega webhook без статуса для платежа', payment_id=payment.id)
return False
update_kwargs = {
'status': status_raw,
'callback_payload': payload,
}
if transaction_id:
update_kwargs['platega_transaction_id'] = transaction_id
if status_raw in self._SUCCESS_STATUSES:
if payment.is_paid:
logger.info('Platega платеж уже помечен как оплачен', correlation_id=payment.correlation_id)
await payment_module.update_platega_payment(
db,
payment=payment,
**update_kwargs,
is_paid=True,
)
# Update callback payload without releasing the lock prematurely
payment.callback_payload = payload
if transaction_id and not payment.platega_transaction_id:
payment.platega_transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
await db.commit()
return True
payment = await payment_module.update_platega_payment(
db,
payment=payment,
**update_kwargs,
)
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
payment.status = status_raw
payment.callback_payload = payload
if transaction_id and not payment.platega_transaction_id:
payment.platega_transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
await db.flush()
result = await self._finalize_platega_payment(db, payment, payload)
if result is None:
logger.error('Platega webhook: финализация не удалась', payment_id=payment.id)
@@ -192,7 +195,9 @@ class PlategaPaymentMixin:
await payment_module.update_platega_payment(
db,
payment=payment,
**update_kwargs,
status=status_raw,
callback_payload=payload,
platega_transaction_id=transaction_id or None,
is_paid=False,
)
logger.info('Platega платеж перешёл в статус', correlation_id=payment.correlation_id, status_raw=status_raw)
@@ -201,7 +206,9 @@ class PlategaPaymentMixin:
await payment_module.update_platega_payment(
db,
payment=payment,
**update_kwargs,
status=status_raw,
callback_payload=payload,
platega_transaction_id=transaction_id or None,
)
return True
@@ -231,28 +238,40 @@ class PlategaPaymentMixin:
if remote_payload:
remote_status = str(remote_payload.get('status') or '').upper()
if remote_status and remote_status != payment.status:
await payment_module.update_platega_payment(
db,
payment=payment,
status=remote_status,
metadata={
**(getattr(payment, 'metadata_json', {}) or {}),
'remote_status': remote_payload,
},
)
payment = await payment_module.get_platega_payment_by_id(db, local_payment_id)
status_changed = remote_status and remote_status != payment.status
if remote_status in self._SUCCESS_STATUSES and not payment.is_paid:
payment = await payment_module.update_platega_payment(
db,
payment=payment,
status=remote_status,
callback_payload=remote_payload,
)
result = await self._finalize_platega_payment(db, payment, remote_payload)
if result is not None:
payment = result
# Lock payment row before finalization to prevent concurrent double-processing
platega_crud = import_module('app.database.crud.platega')
locked = await platega_crud.get_platega_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Platega status check: не удалось заблокировать платёж', payment_id=payment.id)
elif locked.is_paid:
# Another concurrent handler already processed — skip
logger.info('Platega платеж уже оплачен после блокировки', correlation_id=locked.correlation_id)
payment = locked
else:
payment = locked
payment.status = remote_status
payment.callback_payload = remote_payload
payment.metadata_json = {
**(getattr(payment, 'metadata_json', {}) or {}),
'remote_status': remote_payload,
}
payment.updated_at = datetime.now(UTC)
await db.flush()
result = await self._finalize_platega_payment(db, payment, remote_payload)
if result is not None:
payment = result
elif status_changed:
# Non-success status change — safe to persist without lock
payment.status = remote_status
payment.metadata_json = {
**(getattr(payment, 'metadata_json', {}) or {}),
'remote_status': remote_payload,
}
payment.updated_at = datetime.now(UTC)
await db.commit()
return {
'payment': payment,
@@ -279,14 +298,7 @@ class PlategaPaymentMixin:
except ValueError:
paid_at = None
# Lock FIRST, then read fresh state
platega_lock_crud = import_module('app.database.crud.platega')
locked = await platega_lock_crud.get_platega_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Platega: не удалось заблокировать платёж', payment_id=payment.id)
return None
payment = locked
# FOR UPDATE lock already acquired by caller — just check idempotency
if payment.transaction_id:
logger.info(
'Platega платеж уже связан с транзакцией',
+93 -42
View File
@@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.riopay import (
create_riopay_payment as crud_create_riopay_payment,
get_riopay_payment_by_id_for_update,
get_riopay_payment_by_order_id,
get_riopay_payment_by_riopay_order_id,
update_riopay_payment_status,
@@ -202,7 +203,14 @@ class RioPayPaymentMixin:
)
return False
# Проверка дублирования
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
locked = await get_riopay_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('RioPay: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
# Re-check is_paid from the locked row
if payment.is_paid:
logger.info('RioPay webhook: платеж уже обработан', order_id=payment.order_id)
return True
@@ -241,23 +249,33 @@ class RioPayPaymentMixin:
)
return False
# Обновляем статус платежа только после проверки суммы
payment = await update_riopay_payment_status(
db=db,
payment=payment,
status=internal_status,
is_paid=is_paid,
riopay_order_id=riopay_order_id,
payment_method=payload.get('paymentType'),
callback_payload=callback_payload,
)
# Финализируем платеж если оплачен
if is_paid:
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
payment.status = internal_status
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.updated_at = datetime.now(UTC)
if riopay_order_id:
payment.riopay_order_id = riopay_order_id
if payload.get('paymentType') is not None:
payment.payment_method = payload.get('paymentType')
payment.callback_payload = callback_payload
await db.flush()
return await self._finalize_riopay_payment(
db, payment, riopay_order_id=riopay_order_id, trigger='webhook'
)
# Non-success status — safe to use update with commit
await update_riopay_payment_status(
db=db,
payment=payment,
status=internal_status,
is_paid=False,
riopay_order_id=riopay_order_id,
payment_method=payload.get('paymentType'),
callback_payload=callback_payload,
)
return True
except Exception as e:
@@ -272,7 +290,10 @@ class RioPayPaymentMixin:
riopay_order_id: str | None,
trigger: str,
) -> bool:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления."""
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
FOR UPDATE lock already acquired by caller do NOT acquire again here.
"""
if payment.transaction_id:
logger.info('RioPay платеж уже привязан к транзакции', order_id=payment.order_id, trigger=trigger)
return True
@@ -302,7 +323,7 @@ class RioPayPaymentMixin:
)
return False
# Создаем транзакцию
# Создаем транзакцию (commit=False to keep FOR UPDATE lock intact)
transaction = await create_transaction(
db,
user_id=payment.user_id,
@@ -313,15 +334,13 @@ class RioPayPaymentMixin:
external_id=str(riopay_order_id) if riopay_order_id else payment.order_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
commit=False,
)
# Связываем платеж с транзакцией
await update_riopay_payment_status(
db=db,
payment=payment,
status=payment.status,
transaction_id=transaction.id,
)
# Связываем платеж с транзакцией (inline — no commit to preserve lock)
payment.transaction_id = transaction.id
payment.updated_at = datetime.now(UTC)
await db.flush()
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
@@ -344,6 +363,22 @@ class RioPayPaymentMixin:
await db.commit()
# Emit deferred side-effects after atomic commit (events, promo group checks)
try:
from app.database.crud.transaction import emit_transaction_side_effects
await emit_transaction_side_effects(
db,
transaction,
amount_kopeks=payment.amount_kopeks,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
payment_method=PaymentMethod.RIOPAY,
external_id=str(riopay_order_id) if riopay_order_id else payment.order_id,
)
except Exception as error:
logger.error('Ошибка emit_transaction_side_effects RioPay', error=error)
# Обработка реферального пополнения
try:
from app.services.referral_service import process_referral_topup
@@ -478,29 +513,45 @@ class RioPayPaymentMixin:
'is_paid': False,
}
logger.info('RioPay payment confirmed via API', order_id=payment.order_id)
# Lock payment row before finalization (TOCTOU race protection)
locked = await get_riopay_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error(
'RioPay status check: не удалось заблокировать платёж',
payment_id=payment.id,
)
elif locked.is_paid:
# Another concurrent handler already processed — skip
logger.info(
'RioPay платеж уже оплачен после блокировки',
order_id=locked.order_id,
)
payment = locked
else:
payment = locked
logger.info('RioPay payment confirmed via API', order_id=payment.order_id)
callback_payload = {
'check_source': 'api',
'riopay_order_data': order_data,
}
callback_payload = {
'check_source': 'api',
'riopay_order_data': order_data,
}
payment = await update_riopay_payment_status(
db=db,
payment=payment,
status='success',
is_paid=True,
riopay_order_id=payment.riopay_order_id,
payment_method=order_data.get('paymentType'),
callback_payload=callback_payload,
)
# Inline field updates — NO intermediate commit
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.updated_at = datetime.now(UTC)
if order_data.get('paymentType') is not None:
payment.payment_method = order_data.get('paymentType')
payment.callback_payload = callback_payload
await db.flush()
await self._finalize_riopay_payment(
db,
payment,
riopay_order_id=payment.riopay_order_id,
trigger='api_check',
)
await self._finalize_riopay_payment(
db,
payment,
riopay_order_id=payment.riopay_order_id,
trigger='api_check',
)
elif internal_status != payment.status:
# Обновляем статус если изменился
payment = await update_riopay_payment_status(
+40 -24
View File
@@ -217,7 +217,14 @@ class SeverPayPaymentMixin:
)
return False
# Проверка дублирования
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
locked = await severpay_crud.get_severpay_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('SeverPay: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
# Проверка дублирования (re-check from locked row)
if payment.is_paid:
logger.info('SeverPay webhook: платеж уже обработан', order_id=payment.order_id)
return True
@@ -259,9 +266,11 @@ class SeverPayPaymentMixin:
# Inline field assignments to keep FOR UPDATE lock intact
payment.status = internal_status
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.severpay_id = severpay_id or payment.severpay_id
payment.callback_payload = callback_payload
payment.updated_at = datetime.now(UTC)
await db.flush()
return await self._finalize_severpay_payment(db, payment, severpay_id=severpay_id, trigger='webhook')
# Для не-success статусов можно безопасно коммитить
@@ -290,18 +299,12 @@ class SeverPayPaymentMixin:
) -> bool:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
Использует FOR UPDATE lock для защиты от race condition.
FOR UPDATE lock must be acquired by the caller before invoking this method.
"""
payment_module = import_module('app.services.payment_service')
severpay_crud = import_module('app.database.crud.severpay')
# Lock FIRST, then read fresh state
locked = await severpay_crud.get_severpay_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('SeverPay: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
# FOR UPDATE lock already acquired by caller — just check idempotency
if payment.transaction_id:
logger.info(
'SeverPay платеж уже связан с транзакцией',
@@ -327,11 +330,12 @@ class SeverPayPaymentMixin:
if guest_result is not None:
return True
# Inline field assignments to keep FOR UPDATE lock
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.updated_at = datetime.now(UTC)
# Ensure paid fields are set (idempotent — caller may have already set them)
if not payment.is_paid:
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.updated_at = datetime.now(UTC)
balance_already_credited = bool(metadata.get('balance_credited'))
@@ -559,21 +563,33 @@ class SeverPayPaymentMixin:
'is_paid': False,
}
# Acquire FOR UPDATE lock before finalization
locked = await severpay_crud.get_severpay_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('SeverPay: не удалось заблокировать платёж', payment_id=payment.id)
return None
payment = locked
if payment.is_paid:
logger.info('SeverPay платеж уже обработан (api_check)', order_id=payment.order_id)
return {
'payment': payment,
'status': 'success',
'is_paid': True,
}
logger.info('SeverPay payment confirmed via API', order_id=payment.order_id)
callback_payload = {
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.callback_payload = {
'check_source': 'api',
'severpay_order_data': order_data,
}
payment = await severpay_crud.update_severpay_payment_status(
db=db,
payment=payment,
status='success',
is_paid=True,
severpay_id=payment.severpay_id,
callback_payload=callback_payload,
)
payment.updated_at = datetime.now(UTC)
await db.flush()
await self._finalize_severpay_payment(
db,
+63 -39
View File
@@ -223,6 +223,14 @@ class WataPaymentMixin:
)
return False
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
wata_crud = import_module('app.database.crud.wata')
locked = await wata_crud.get_wata_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('WATA: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
status_lower = transaction_status.lower()
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
metadata['last_webhook'] = payload
@@ -230,17 +238,38 @@ class WataPaymentMixin:
payload.get('terminalPublicId') or payload.get('terminal_public_id') or payload.get('terminalPublicID')
)
if status_lower == 'paid':
if payment.is_paid:
logger.info('WATA платеж уже помечен как оплачен', payment_link_id=payment.payment_link_id)
# Update callback payload without releasing the lock prematurely
payment.callback_payload = payload
payment.metadata_json = metadata
if terminal_public_id:
payment.terminal_public_id = terminal_public_id
await db.commit()
return True
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
payment.status = transaction_status
payment.last_status = transaction_status
payment.callback_payload = payload
payment.metadata_json = metadata
if terminal_public_id:
payment.terminal_public_id = terminal_public_id
await db.flush()
await self._finalize_wata_payment(db, payment, payload)
return True
# Non-success statuses: safe to use update_wata_payment_status (commits)
update_kwargs: dict[str, Any] = {
'metadata': metadata,
'callback_payload': payload,
'terminal_public_id': terminal_public_id,
'status': transaction_status,
'last_status': transaction_status,
}
if transaction_status:
update_kwargs['status'] = transaction_status
update_kwargs['last_status'] = transaction_status
if status_lower != 'paid' and not payment.is_paid:
if not payment.is_paid:
update_kwargs['is_paid'] = False
payment = await payment_module.update_wata_payment_status(
@@ -249,14 +278,6 @@ class WataPaymentMixin:
**update_kwargs,
)
if status_lower == 'paid':
if payment.is_paid:
logger.info('WATA платеж уже помечен как оплачен', payment_link_id=payment.payment_link_id)
return True
await self._finalize_wata_payment(db, payment, payload)
return True
if status_lower == 'declined':
logger.info('WATA платеж отклонён', payment_link_id=payment.payment_link_id)
@@ -364,7 +385,18 @@ class WataPaymentMixin:
if raw_status:
normalized_status = str(raw_status).lower()
if normalized_status == 'paid':
payment = await self._finalize_wata_payment(db, payment, transaction_payload)
# Lock payment row before finalization to prevent concurrent double-processing
wata_crud = import_module('app.database.crud.wata')
locked = await wata_crud.get_wata_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('WATA status check: не удалось заблокировать платёж', payment_id=payment.id)
elif locked.is_paid:
# Another concurrent handler already processed — skip
logger.info('WATA платеж уже оплачен после блокировки', payment_link_id=locked.payment_link_id)
payment = locked
else:
payment = locked
payment = await self._finalize_wata_payment(db, payment, transaction_payload)
else:
logger.debug(
'WATA транзакция в статусе , повторная обработка не требуется',
@@ -399,6 +431,15 @@ class WataPaymentMixin:
paid_status=paid_status,
)
# FOR UPDATE lock already acquired by caller — just check idempotency
if payment.transaction_id:
logger.info(
'WATA платеж уже привязан к транзакции',
payment_link_id=payment.payment_link_id,
transaction_id=payment.transaction_id,
)
return payment
paid_at = None
if isinstance(transaction_payload, dict):
paid_at = WataService._parse_datetime(transaction_payload.get('paymentTime'))
@@ -420,30 +461,13 @@ class WataPaymentMixin:
existing_metadata['transaction'] = transaction_payload
await payment_module.update_wata_payment_status(
db,
payment=payment,
status='Paid',
is_paid=True,
paid_at=paid_at,
callback_payload=transaction_payload,
metadata=existing_metadata,
)
wata_lock_crud = import_module('app.database.crud.wata')
locked = await wata_lock_crud.get_wata_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('WATA: не удалось заблокировать платёж', payment_id=payment.id)
return None
payment = locked
if payment.transaction_id:
logger.info(
'WATA платеж уже привязан к транзакции',
payment_link_id=payment.payment_link_id,
transaction_id=payment.transaction_id,
)
return payment
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
payment.status = 'Paid'
payment.is_paid = True
payment.paid_at = paid_at
payment.callback_payload = transaction_payload
payment.metadata_json = existing_metadata
await db.flush()
# --- Guest purchase flow (landing page) ---
wata_metadata = dict(getattr(payment, 'metadata_json', {}) or {})
+1 -4
View File
@@ -15,16 +15,13 @@ import structlog
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.rbac import AccessPolicyCRUD, AuditLogCRUD, UserRoleCRUD
from app.database.crud.rbac import SUPERADMIN_LEVEL, AccessPolicyCRUD, AuditLogCRUD, UserRoleCRUD
if TYPE_CHECKING:
from app.database.models import AccessPolicy, User
SUPERADMIN_LEVEL = 999
logger = structlog.get_logger(__name__)
+2 -3
View File
@@ -1,11 +1,10 @@
from datetime import UTC, datetime
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.bot_factory import create_bot
from app.config import settings
from app.database.crud.transaction import get_user_total_spent_kopeks
from app.database.crud.user import lock_user_for_update
@@ -31,7 +30,7 @@ async def _notify_admins_about_auto_assignment(
logger.debug('BOT_TOKEN не настроен — пропускаем уведомление о промогруппе')
return
bot = Bot(token=bot_token, default=DefaultBotProperties(parse_mode='HTML'))
bot = create_bot(token=bot_token)
try:
notification_service = AdminNotificationService(bot)
reason = (
+110 -17
View File
@@ -5,14 +5,15 @@ Auto-assigns the Superadmin role to users listed in ADMIN_IDS / ADMIN_EMAILS
config on bot startup. Runs once during the startup sequence.
"""
from datetime import UTC, datetime
from typing import Final
import structlog
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.crud.rbac import SUPERADMIN_LEVEL, UserRoleCRUD
from app.database.models import AdminRole, User, UserRole
@@ -129,15 +130,15 @@ async def _ensure_preset_roles(db: AsyncSession) -> AdminRole | None:
result = await db.execute(
select(AdminRole).where(AdminRole.is_system.is_(True), AdminRole.level == preset['level'])
)
existing = result.scalar_one_or_none()
existing = result.scalars().first()
# Fallback: поиск по имени (для ролей, созданных до этого фикса)
if existing is None:
result = await db.execute(select(AdminRole).where(AdminRole.name == preset['name']))
existing = result.scalar_one_or_none()
existing = result.scalars().first()
if existing is not None:
if existing.level == 999: # Superadmin level
if existing.level == SUPERADMIN_LEVEL:
superadmin_role = existing
# Добавить НОВЫЕ permissions из кода, не трогая существующие (админ мог кастомизировать)
if existing.is_system:
@@ -196,6 +197,8 @@ async def bootstrap_superadmins(db: AsyncSession) -> None:
if not admin_ids and not admin_emails:
logger.debug('No admin IDs or emails configured, skipping superadmin assignment')
await db.commit()
# Safety check even when no IDs configured — someone may have cleared them
await _warn_if_no_superadmins(db, admin_ids, admin_emails)
return
role_id: int = superadmin_role.id
@@ -213,23 +216,109 @@ async def bootstrap_superadmins(db: AsyncSession) -> None:
if assigned:
assigned_count += 1
# ── 4. Commit all changes ──────────────────────────────────────
# ── 4. Revoke superadmin from users NOT in env ───────────────
revoked_count = await _revoke_stale_superadmins(
db,
role_id=role_id,
admin_ids=admin_ids,
admin_emails=admin_emails,
)
# ── 5. Commit all changes ──────────────────────────────────────
await db.commit()
if assigned_count > 0:
if assigned_count > 0 or revoked_count > 0:
logger.info(
'Superadmin bootstrap completed',
assigned_count=assigned_count,
revoked_count=revoked_count,
role_id=role_id,
)
else:
logger.debug('Superadmin bootstrap: no new assignments needed')
logger.debug('Superadmin bootstrap: no changes needed')
# ── 6. Safety: warn if no active superadmins exist ────────────
await _warn_if_no_superadmins(db, admin_ids, admin_emails)
except Exception:
await db.rollback()
logger.exception('Failed to bootstrap superadmins, continuing startup')
async def _revoke_stale_superadmins(
db: AsyncSession,
*,
role_id: int,
admin_ids: list[int],
admin_emails: list[str],
) -> int:
"""Revoke superadmin from users who are no longer in env config.
Env config (ADMIN_IDS / ADMIN_EMAILS) is the single source of truth.
If a user was removed from env, their superadmin DB role is deactivated
on the next bot restart.
Returns the number of revoked assignments.
"""
result = await db.execute(
select(UserRole)
.options(selectinload(UserRole.user))
.where(
UserRole.role_id == role_id,
UserRole.is_active.is_(True),
)
)
active_assignments = result.scalars().all()
admin_ids_set = set(admin_ids)
admin_emails_set = {e.lower() for e in admin_emails}
revoked = 0
for assignment in active_assignments:
user = assignment.user
if user is None:
continue
# Check if user is still in env config.
# email_verified is required — symmetric with _ensure_role_by_email.
in_env_by_id = user.telegram_id is not None and user.telegram_id in admin_ids_set
in_env_by_email = user.email is not None and user.email_verified and user.email.lower() in admin_emails_set
if not in_env_by_id and not in_env_by_email:
assignment.is_active = False
await db.flush()
revoked += 1
logger.warning(
'Revoked Superadmin role: user removed from env config',
user_id=user.id,
telegram_id=user.telegram_id,
email=user.email,
user_role_id=assignment.id,
)
return revoked
async def _warn_if_no_superadmins(
db: AsyncSession,
admin_ids: list[int],
admin_emails: list[str],
) -> None:
"""Log critical/warning if no active superadmin RBAC roles exist in DB."""
active = await UserRoleCRUD.get_superadmin_count(db)
if active > 0:
return
if not admin_ids and not admin_emails:
logger.critical(
'No active superadmins exist and no ADMIN_IDS/ADMIN_EMAILS configured. '
'Cabinet admin access is not possible until this is resolved.',
)
else:
logger.warning(
'No active superadmin RBAC roles in DB. Legacy config admins (ADMIN_IDS/ADMIN_EMAILS) still have access.',
)
async def _ensure_role_by_telegram_id(
db: AsyncSession,
*,
@@ -256,13 +345,18 @@ async def _ensure_role_by_email(
email: str,
role_id: int,
) -> bool:
"""Assign Superadmin role to user found by email (case-insensitive). Returns True if assigned."""
result = await db.execute(select(User).where(func.lower(User.email) == email.lower()))
"""Assign Superadmin role to user found by verified email (case-insensitive). Returns True if assigned."""
result = await db.execute(
select(User).where(
func.lower(User.email) == email.lower(),
User.email_verified.is_(True),
)
)
user = result.scalar_one_or_none()
if user is None:
logger.debug(
'Admin user (email) not yet registered, skipping',
'Admin user (email) not yet registered or not verified, skipping',
email=email,
)
return False
@@ -279,12 +373,12 @@ async def _assign_if_missing(
) -> bool:
"""Create or reactivate a UserRole row for this user/role pair.
Handles the unique constraint on (user_id, role_id) by checking for
ANY existing assignment (active or inactive) and reactivating if needed.
Env config (ADMIN_IDS / ADMIN_EMAILS) is the source of truth for
Superadmin assignments. If a previously revoked assignment exists,
it is reactivated the env config always wins.
Returns True if a new assignment was created or an inactive one was reactivated.
"""
# Check for ANY existing assignment (active or not) to respect unique constraint
result = await db.execute(
select(UserRole).where(
UserRole.user_id == user_id,
@@ -301,14 +395,13 @@ async def _assign_if_missing(
identifier=identifier,
)
return False
# Reactivate previously revoked assignment
# Reactivate: env config is the source of truth
existing.is_active = True
existing.assigned_at = datetime.now(UTC)
await db.flush()
logger.info(
'Reactivated Superadmin role for user',
'Reactivated Superadmin role (user is in env config)',
user_id=user_id,
role_id=role_id,
identifier=identifier,
user_role_id=existing.id,
)
+140 -13
View File
@@ -114,10 +114,18 @@ _ADMIN_ERROR_EVENTS: dict[str, str] = {
'errors.bandwidth_usage_threshold_reached_max_notifications': '⚠️ Достигнут лимит уведомлений о трафике',
}
_ADMIN_NODE_CONNECTION_EVENTS = frozenset({'node.connection_lost', 'node.connection_restored'})
class RemnaWaveWebhookService:
"""Processes incoming webhooks from RemnaWave backend."""
# In-memory guard: tracks recent panel recreations per subscription_id.
# Prevents unbounded user.deleted → recreate → user.deleted loops.
# Key: subscription_id, Value: datetime of last recreation attempt.
_recent_recreations: dict[int, datetime] = {}
_RECREATION_GUARD_SECONDS: int = 120 # 2-minute cooldown
def __init__(self, bot: Bot) -> None:
self.bot = bot
self._admin_service = AdminNotificationService(bot)
@@ -216,6 +224,10 @@ class RemnaWaveWebhookService:
async def _process_admin_event(self, event_name: str, data: dict) -> bool:
"""Format and send admin notification for infrastructure events."""
if event_name in _ADMIN_NODE_CONNECTION_EVENTS and not settings.REMNAWAVE_WEBHOOK_NOTIFY_NODE_CONNECTION_STATUS:
logger.debug('RemnaWave node connection notifications disabled, skipping event', event_name=event_name)
return True
if not self._admin_service.is_enabled:
logger.debug('Admin notifications disabled, skipping event', event_name=event_name)
return True
@@ -681,6 +693,36 @@ class RemnaWaveWebhookService:
user_id = user.id
sub_id = subscription.id if subscription else None
# Evict stale entries from the recreation loop guard to prevent unbounded growth
if self._recent_recreations:
now = datetime.now(UTC)
expired_keys = [
k
for k, v in self._recent_recreations.items()
if (now - v).total_seconds() >= self._RECREATION_GUARD_SECONDS
]
for k in expired_keys:
del self._recent_recreations[k]
# Guard against webhook loop: if we recently attempted panel recreation for this
# subscription (from a previous user.deleted), skip to prevent unbounded
# recreate→delete→recreate cycles. Uses an in-memory guard (not the generic
# last_webhook_update_at stamp which fires on ANY webhook event).
if sub_id and sub_id in self._recent_recreations:
elapsed = (datetime.now(UTC) - self._recent_recreations[sub_id]).total_seconds()
if elapsed < self._RECREATION_GUARD_SECONDS:
logger.warning(
'Webhook user.deleted: skipping — panel recreation was attempted recently (recreation loop guard)',
sub_id=sub_id,
user_id=user_id,
elapsed=round(elapsed, 1),
)
return
# Stamp immediately (before any await) so concurrent coroutines see the guard
if sub_id:
self._recent_recreations[sub_id] = datetime.now(UTC)
if subscription:
self._stamp_webhook_update(subscription)
@@ -712,23 +754,46 @@ class RemnaWaveWebhookService:
logger.error('Webhook: user not found after rollback', user_id=user_id)
return
# Check if subscription has a future end_date — likely a spurious user.deleted
# (e.g., RemnaWave sends user.deleted during panel resync when modifying another user)
subscription_still_valid = (
subscription is not None and subscription.end_date is not None and subscription.end_date > datetime.now(UTC)
)
if subscription:
if subscription.status != SubscriptionStatus.EXPIRED.value:
subscription.status = SubscriptionStatus.EXPIRED.value
logger.info(
'Webhook: subscription marked expired (user deleted in panel) for user',
if subscription_still_valid:
# Subscription is still valid — don't mark as expired.
# Clear only panel linkage fields (URLs, UUID) but keep status and squads
# so that re-creation can restore VPN access.
logger.warning(
'Webhook user.deleted: subscription has future end_date, '
'keeping active status and attempting panel re-creation',
sub_id=sub_id,
user_id=user_id,
end_date=subscription.end_date,
status=subscription.status,
)
subscription.subscription_url = None
subscription.subscription_crypto_link = None
subscription.remnawave_short_uuid = None
# Keep connected_squads — needed for panel re-creation
subscription.updated_at = datetime.now(UTC)
else:
# Subscription expired or has no end_date — safe to mark as expired
if subscription.status != SubscriptionStatus.EXPIRED.value:
subscription.status = SubscriptionStatus.EXPIRED.value
logger.info(
'Webhook: subscription marked expired (user deleted in panel) for user',
sub_id=sub_id,
user_id=user_id,
)
subscription.subscription_url = None
subscription.subscription_crypto_link = None
subscription.remnawave_short_uuid = None
subscription.connected_squads = []
subscription.updated_at = datetime.now(UTC)
# Clear subscription data — panel user no longer exists
subscription.subscription_url = None
subscription.subscription_crypto_link = None
subscription.remnawave_short_uuid = None
subscription.connected_squads = []
subscription.updated_at = datetime.now(UTC)
# Remove SubscriptionServer link rows
# Remove SubscriptionServer link rows (panel user no longer exists)
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == sub_id))
# Clear remnawave linkage
@@ -737,7 +802,69 @@ class RemnaWaveWebhookService:
await db.commit()
await self._notify_user(user, 'WEBHOOK_SUB_DELETED', reply_markup=self._get_renew_keyboard(user))
if subscription_still_valid:
# Attempt to re-create user in panel to restore VPN access.
# If recreation fails, fall back to expiring the subscription
# so it doesn't stay in ACTIVE-but-no-panel limbo.
recreated = await self._attempt_panel_recreation(db, user, subscription)
if not recreated:
subscription.status = SubscriptionStatus.EXPIRED.value
subscription.connected_squads = []
subscription.updated_at = datetime.now(UTC)
await db.commit()
await self._notify_user(user, 'WEBHOOK_SUB_DELETED', reply_markup=self._get_renew_keyboard(user))
else:
await self._notify_user(user, 'WEBHOOK_SUB_DELETED', reply_markup=self._get_renew_keyboard(user))
async def _attempt_panel_recreation(self, db: AsyncSession, user: User, subscription: Subscription) -> bool:
"""Re-create user in RemnaWave panel after spurious user.deleted webhook.
Called when a user.deleted webhook arrives but the subscription still has a
future end_date, indicating the deletion was likely spurious (e.g., RemnaWave
resync when modifying another user). Attempts to restore VPN access by
creating/updating the user in the panel.
Returns True if recreation succeeded, False otherwise.
"""
# Update the recreation guard timestamp to the actual recreation start time
if subscription.id is not None:
self._recent_recreations[subscription.id] = datetime.now(UTC)
try:
from app.services.subscription_service import SubscriptionService
service = SubscriptionService()
if not service.is_configured:
logger.warning(
'RemnaWave not configured, cannot re-create panel user after user.deleted',
user_id=user.id,
)
return False
remnawave_user = await service.create_remnawave_user(db, subscription)
if remnawave_user:
logger.info(
'Webhook user.deleted: successfully re-created user in panel',
user_id=user.id,
subscription_id=subscription.id,
new_uuid=remnawave_user.uuid,
)
return True
logger.error(
'Webhook user.deleted: failed to re-create user in panel',
user_id=user.id,
subscription_id=subscription.id,
)
return False
except Exception as e:
logger.error(
'Webhook user.deleted: error re-creating user in panel',
user_id=user.id,
subscription_id=subscription.id,
error=e,
)
return False
async def _handle_user_revoked(
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
+1 -1
View File
@@ -476,7 +476,7 @@ class ReportingService:
"""
return select(
func.count(Transaction.id),
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0),
).where(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == true(),
@@ -22,7 +22,7 @@ from app.database.crud.subscription_conversion import (
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import ServerSquad, Subscription, SubscriptionStatus, TransactionType, User
from app.database.models import PaymentMethod, ServerSquad, Subscription, SubscriptionStatus, TransactionType, User
from app.localization.texts import get_texts
from app.services.subscription_service import SubscriptionService
from app.utils.pricing_utils import (
@@ -1109,6 +1109,7 @@ class MiniAppSubscriptionPurchaseService:
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=pricing.final_total,
description=f'Подписка на {pricing.selection.period.days} дней ({pricing.months} мес)',
payment_method=PaymentMethod.BALANCE,
)
await db.refresh(user)
+2 -1
View File
@@ -12,6 +12,7 @@ import structlog
from aiogram import Bot
from sqlalchemy.ext.asyncio import AsyncSession
from app.bot_factory import create_bot
from app.config import settings
from app.database.crud.subscription import (
add_subscription_servers,
@@ -337,7 +338,7 @@ async def with_admin_notification_service(
bot: Bot | None = None
try:
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
service = AdminNotificationService(bot)
await handler(service)
except Exception as error: # pragma: no cover - defensive logging
+18 -9
View File
@@ -1,7 +1,7 @@
import asyncio
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
import structlog
from sqlalchemy import select
@@ -348,13 +348,17 @@ class SubscriptionService:
# Определяем актуальный статус для отправки в RemnaWave
# НЕ меняем статус подписки здесь - это задача scheduled job
is_actually_active = (
subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date > current_time
subscription.status in (SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value)
and subscription.end_date > current_time
)
# Логируем если статус и end_date не согласованы (для отладки)
if subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date <= current_time:
if (
subscription.status in (SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value)
and subscription.end_date <= current_time
):
logger.warning(
'⚠️ update_remnawave_user: подписка имеет статус ACTIVE, но end_date <= now . Отправляем в RemnaWave как EXPIRED, но НЕ меняем статус в БД.',
'⚠️ update_remnawave_user: подписка имеет статус ACTIVE, но end_date <= now. Отправляем в RemnaWave как DISABLED, но НЕ меняем статус в БД.',
subscription_id=subscription.id,
end_date=subscription.end_date,
current_time=current_time,
@@ -370,8 +374,10 @@ class SubscriptionService:
update_kwargs = dict(
uuid=user.remnawave_uuid,
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
expire_at=subscription.end_date,
status=UserStatus.ACTIVE if is_actually_active else UserStatus.DISABLED,
expire_at=subscription.end_date
if is_actually_active
else max(subscription.end_date, current_time + timedelta(minutes=1)),
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff),
telegram_id=user.telegram_id,
@@ -843,7 +849,8 @@ class SubscriptionService:
current_time = datetime.now(UTC)
is_actually_active = (
sub.status == SubscriptionStatus.ACTIVE.value and sub.end_date > current_time
sub.status in (SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value)
and sub.end_date > current_time
)
user_tag = self._resolve_user_tag(sub)
@@ -852,8 +859,10 @@ class SubscriptionService:
update_kwargs = dict(
uuid=user.remnawave_uuid,
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
expire_at=sub.end_date,
status=UserStatus.ACTIVE if is_actually_active else UserStatus.DISABLED,
expire_at=sub.end_date
if is_actually_active
else max(sub.end_date, current_time + timedelta(minutes=1)),
traffic_limit_bytes=self._gb_to_bytes(sub.traffic_limit_gb),
traffic_limit_strategy=traffic_strategy,
telegram_id=user.telegram_id,
+13
View File
@@ -218,6 +218,7 @@ class BotConfigurationService:
'BOT_USERNAME': 'CORE',
'DEFAULT_LANGUAGE': 'LOCALIZATION',
'AVAILABLE_LANGUAGES': 'LOCALIZATION',
'REMNAWAVE_WEBHOOK_NOTIFY_NODE_CONNECTION_STATUS': 'ADMIN_NOTIFICATIONS',
'LANGUAGE_SELECTION_ENABLED': 'LOCALIZATION',
'DEFAULT_DEVICE_LIMIT': 'SUBSCRIPTIONS_CORE',
'DEFAULT_TRAFFIC_LIMIT_GB': 'SUBSCRIPTIONS_CORE',
@@ -843,6 +844,18 @@ class BotConfigurationService:
'example': '60',
'warning': 'Защита от спама уведомлениями по одному и тому же пользователю.',
},
'REMNAWAVE_WEBHOOK_NOTIFY_NODE_CONNECTION_STATUS': {
'description': (
'Уведомления администраторам о потере и восстановлении соединения с нодами из webhook-ов RemnaWave.'
),
'format': 'Булево значение.',
'example': 'false',
'warning': (
'Отключает только события node.connection_lost и node.connection_restored. '
'Остальные инфраструктурные уведомления продолжают отправляться.'
),
'dependencies': 'REMNAWAVE_WEBHOOK_ENABLED, ADMIN_NOTIFICATIONS_ENABLED',
},
'WEBHOOK_NOTIFY_USER_ENABLED': {
'description': (
'Глобальный переключатель уведомлений пользователям от вебхуков RemnaWave. '
+3
View File
@@ -293,6 +293,9 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
if 'MESSAGE_ID_INVALID' in str(error) or 'message to edit not found' in str(error).lower():
# Сообщение удалено или недоступно — просто игнорируем
return None
if 'message is not modified' in str(error).lower():
# Контент не изменился — безопасно игнорируем
return None
raise
+35
View File
@@ -0,0 +1,35 @@
"""Proxy URL utilities for safe logging and error handling."""
import re
from urllib.parse import urlparse
def mask_proxy_url(proxy_url: str) -> str:
"""Mask credentials in a proxy URL for safe logging.
Handles edge cases:
- No credentials: returns URL as-is
- Username + password: masks both with ***
- Password-only: masks as well
- No explicit port: omits :port part
"""
parsed = urlparse(proxy_url)
if not parsed.username and not parsed.password:
return proxy_url
host = parsed.hostname or 'unknown'
port_part = f':{parsed.port}' if parsed.port else ''
return f'{parsed.scheme}://***@{host}{port_part}'
_PROXY_CRED_RE = re.compile(r'(socks[45h]*://)([^@\s]+@)', re.IGNORECASE)
def sanitize_proxy_error(error: Exception) -> str:
"""Strip proxy credentials from exception messages.
httpx/socksio may include the full proxy URL (with credentials)
in connection error messages and tracebacks. This function removes
credentials from the error string while preserving the original scheme.
"""
msg = str(error)
return _PROXY_CRED_RE.sub(r'\1***@', msg)
+3 -11
View File
@@ -4,9 +4,6 @@ import mimetypes
from typing import Any
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,
@@ -20,6 +17,7 @@ from fastapi import (
status,
)
from app.bot_factory import create_bot
from app.config import settings
from ..dependencies import require_api_token
@@ -72,10 +70,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':
@@ -121,10 +116,7 @@ async def download_media(
file_id: str,
_: Any = Security(require_api_token),
) -> Response:
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
bot = create_bot()
try:
file = await bot.get_file(file_id)
+53 -27
View File
@@ -10,13 +10,13 @@ from typing import Any
from uuid import uuid4
import structlog
from aiogram import Bot
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import ValidationError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.bot_factory import create_bot
from app.config import settings
from app.database.crud.discount_offer import (
get_latest_claimed_offer_for_user,
@@ -928,7 +928,7 @@ async def create_payment_link(
detail='Failed to prepare Stars payment',
) from exc
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
invoice_payload = _build_balance_invoice_payload(user.id, amount_kopeks)
try:
payment_service = PaymentService(bot)
@@ -1399,7 +1399,7 @@ async def create_payment_link(
if not settings.BOT_TOKEN:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Bot token is not configured')
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
tribute_service = TributeService(bot)
payment_url = await tribute_service.create_payment_link(
@@ -2869,8 +2869,10 @@ async def _build_referral_info(
referral_settings = settings.get_referral_settings() or {}
referral_link = None
bot_referral_link = None
if referral_code:
referral_link = settings.get_referral_link(referral_code)
referral_link = settings.get_cabinet_referral_link(referral_code)
bot_referral_link = settings.get_bot_referral_link(referral_code)
minimum_topup_kopeks = int(referral_settings.get('minimum_topup_kopeks') or 0)
first_topup_bonus_kopeks = int(referral_settings.get('first_topup_bonus_kopeks') or 0)
@@ -2967,6 +2969,7 @@ async def _build_referral_info(
return MiniAppReferralInfo(
referral_code=referral_code,
referral_link=referral_link,
bot_referral_link=bot_referral_link,
terms=terms,
stats=stats,
recent_earnings=recent_earnings,
@@ -3773,14 +3776,13 @@ async def activate_subscription_trial_endpoint(
try:
from app.database.crud.tariff import get_tariff_by_id, 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_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
@@ -7142,7 +7144,16 @@ async def toggle_daily_subscription_pause_endpoint(
detail={'code': 'not_daily_tariff', 'message': 'Subscription is not on a daily tariff'},
)
# Определяем состояние
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)
subscription = user.subscription
# Определяем состояние из LOCKED экземпляра
from app.database.models import SubscriptionStatus
is_currently_paused = getattr(subscription, 'is_daily_paused', False)
@@ -7159,13 +7170,6 @@ async def toggle_daily_subscription_pause_endpoint(
new_paused_state = not is_currently_paused
subscription.is_daily_paused = new_paused_state
raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0)
# Lock user BEFORE price computation to prevent TOCTOU on promo discount
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 resume-after-topup)
from app.services.pricing_engine import PricingEngine
@@ -7175,6 +7179,8 @@ async def toggle_daily_subscription_pause_endpoint(
PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price
)
resume_transaction = None
# Если снимаем с паузы, проверяем баланс и списываем оплату
if not new_paused_state:
if daily_price > 0 and user.balance_kopeks < daily_price:
@@ -7199,6 +7205,7 @@ async def toggle_daily_subscription_pause_endpoint(
daily_price,
f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
mark_as_paid_subscription=True,
commit=False,
)
if not deducted:
raise HTTPException(
@@ -7214,32 +7221,51 @@ async def toggle_daily_subscription_pause_endpoint(
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 in miniapp', 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,
)
# Баланс списан — теперь активируем
now = datetime.now(UTC)
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.last_daily_charge_at = datetime.now(UTC)
subscription.end_date = datetime.now(UTC) + timedelta(days=1)
subscription.last_daily_charge_at = now
subscription.end_date = now + timedelta(days=1)
logger.info(
'Суточная подписка восстановлена в ACTIVE (miniapp)',
'Суточная подписка восстановлена в ACTIVE (miniapp)',
subscription_id=subscription.id,
previous_status='disabled/expired',
)
# Re-apply is_daily_paused on the current identity-mapped instance
# (subtract_user_balance with populate_existing=True may have reloaded it from DB)
subscription.is_daily_paused = new_paused_state
await db.commit()
await db.refresh(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 (miniapp)', error=exc)
# Синхронизация с RemnaWave только при возобновлении из DISABLED/EXPIRED
if not new_paused_state and was_disabled:
try:
+2 -7
View File
@@ -4,13 +4,11 @@ from datetime import UTC, datetime
from typing import Any, Optional
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
from app.services.pinned_message_service import (
broadcast_pinned_message,
@@ -52,10 +50,7 @@ def _serialize_pinned_message(msg: PinnedMessage) -> PinnedMessageResponse:
def _get_bot() -> Bot:
"""Создать экземпляр бота для API операций."""
return Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
return create_bot()
@router.get('', response_model=PinnedMessageListResponse)
+2 -8
View File
@@ -2,9 +2,6 @@ from __future__ import annotations
from typing import Any
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import (
APIRouter,
Depends,
@@ -328,12 +325,9 @@ async def send_poll(
total=0,
)
from app.config import settings
from app.bot_factory import create_bot
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
bot = create_bot()
try:
result = await send_poll_to_users(bot, db, poll, users)
+1 -1
View File
@@ -76,7 +76,7 @@ async def _get_overview(db: AsyncSession) -> dict[str, object]:
today = datetime.now(UTC).date()
today_transactions = (
await db.scalar(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
func.date(Transaction.created_at) == today,
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),

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