Compare commits

...

192 Commits

Author SHA1 Message Date
Egor 14e24a546e Merge pull request #2911 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.52.0
2026-04-24 17:12:13 +03:00
github-actions[bot] f60231e104 chore(main): release 3.52.0 2026-04-24 14:11:54 +00:00
Egor 920bab5b4f Merge pull request #2910 from BEDOLAGA-DEV/dev
Dev
2026-04-24 17:11:17 +03:00
Fringg 68d2350dfd fix: stop printing tracebacks for warning-level logs inside except blocks 2026-04-24 17:09:20 +03:00
Fringg 7d512d214a fix: integrate Yandex Metrika offline conv + S2S postback hooks
Restore integration hooks dropped in PR #2851 merge:
- PurchaseRequest accepts yandex_cid, referrer, subid from frontend
- Cache yandex_cid and subid in Redis at purchase creation (24h TTL)
- On fulfill_purchase: extract subid from cache, persist to DB
- Save Yandex CID from Redis to yandex_client_id_map
- Fire on_registration + S2S postback for new accounts
- Fire on_purchase + S2S postback for all paid purchases
- All hooks wrapped in try/except — failures never block delivery
2026-04-24 16:59:27 +03:00
Fringg 2cde38c63b fix: restore referrer field in admin landing purchases response 2026-04-24 16:53:47 +03:00
Fringg 24dc8d2a5e fix: restore HTTP Referer fallback for landing purchases 2026-04-24 16:47:22 +03:00
Fringg 1522d35f2d fix: gift purchases no longer inflate promo group level
Two bugs caused max promo group assignment on gift send/activate:

1. Buyer: GIFT_PAYMENT was counted in get_user_total_spent_kopeks
   alongside SUBSCRIPTION_PAYMENT. Now only SUBSCRIPTION_PAYMENT
   counts as personal spending for promo group auto-assignment.

2. Recipient: fulfill_purchase and activate_purchase created a
   SUBSCRIPTION_PAYMENT transaction for the recipient with the full
   gift price. Now skipped for gift recipients — they didn't pay.
2026-04-24 16:43:32 +03:00
Fringg 2b0d8a2a88 style: ruff format admin_bulk_actions.py 2026-04-24 16:34:43 +03:00
Fringg 9217f4116f fix: sanitize error messages in all bulk action catch-all handlers
Replace str(exc) with generic 'Action failed: internal error' in both
_execute_for_user and _execute_for_subscription catch-all blocks.
Prevents leaking internal paths, SQL details, or connection strings.
2026-04-24 16:24:45 +03:00
Fringg 2e45a93bd7 fix: bulk delete_user — pass real admin_id, sanitize error messages
- Thread admin_id through _execute_for_user to _do_delete_user for audit trail
- Replace raw exception str(e) with generic error message in client response
- Add subscriptions=[] to failure paths to prevent MissingGreenlet
2026-04-24 16:16:50 +03:00
Fringg d77fd81e16 feat: bulk actions — campaign/partner filters, delete_user action
- Add campaign_id and partner_id query params to GET /admin/users
- Filter users by advertising campaign via EXISTS subquery on registrations
- Filter users by partner via JOIN campaign registrations → campaigns
- Add DELETE_USER bulk action type with delete_from_panel param
- Handler calls UserService.delete_user_account for full bot+panel removal
- Permission check: users:delete required for delete_user action
- Add to _USER_LEVEL_ACTIONS (operates on user_ids, not subscription_ids)
2026-04-24 16:07:14 +03:00
Fringg bdb8cab1c9 feat: info page tab replacement — replaces_tab field + API
- Add replaces_tab column to InfoPage ('faq','rules','privacy','offer')
- Migration 0067: add nullable replaces_tab column
- CRUD: clear_replaces_tab ensures one page per tab, get_tab_replacements
  returns {tab: slug} mapping for active pages
- Admin routes: auto-clear old assignment on create/update
- Public route: GET /info-pages/tab-replacements (no auth)
- Schemas: replaces_tab with regex validation in all request/response models
2026-04-24 14:21:32 +03:00
Fringg d394565fe9 feat: FAQ support in info pages — page_type field + migration
- Add page_type column to InfoPage model ('page' or 'faq')
- Migration 0066: ALTER TABLE ADD COLUMN with server_default='page'
- Update schemas with page_type field and regex validation
- Update CRUD: page_type in create, filter in list
- Update admin/public routes with page_type query filter
- Backward compatible: existing pages default to type 'page'
2026-04-24 14:01:18 +03:00
Fringg 122d12db20 fix: /reorder route unreachable — move before /{page_id} path param 2026-04-24 13:50:38 +03:00
Fringg 2071a680d3 fix: info pages review — deduplicate slug index, type reorder items
- Remove triple-redundant slug index: keep only unique=True on column
  (PostgreSQL creates unique index automatically), remove __table_args__
  index and explicit create_index in migration
- Type ReorderRequest.items with ReorderItem(id: int, sort_order: int)
  instead of raw dict — prevents unvalidated input causing 500
- Migration downgrade: just drop_table (unique constraint drops with it)
2026-04-24 08:21:41 +03:00
Fringg e4b4a54797 feat: information pages — CRUD model, admin API, public API
- InfoPage model: slug, title (JSONB locale dict), content (JSONB),
  is_active, sort_order, icon, created_at/updated_at
- CRUD: create, get by id/slug, list, update, delete, reorder
- Admin routes: /admin/info-pages with full CRUD, toggle-active, reorder
  (permissions: settings:read/settings:edit)
- Public routes: /info-pages list active, /info-pages/{slug} get by slug
- Migration 0065: create info_pages table with unique slug index
- Custom pages support: admins can create any info page with any slug
2026-04-24 08:09:54 +03:00
Fringg 59c54c9b39 fix: privacy policy and offer text display HTML links as plain text
parse_mode='HTML' was missing from message sends during /start
registration. HTML tags like <a href="..."> were shown as literal
text instead of rendered links.

Fixed in 4 places:
- Privacy policy edit_text (line 1151)
- Privacy policy fallback answer (line 1158)
- Welcome/offer text in complete_registration_from_callback (line 1729)
- Welcome/offer text in complete_registration (line 2084)
2026-04-24 07:43:54 +03:00
Fringg 0d2b1dfdc9 feat: support multiple tariff_ids in user list filter
tariff_id query param now accepts comma-separated IDs (e.g.
tariff_id=1,3,5). CRUD functions updated to use IN() operator
for multi-tariff server-side filtering. Pagination works correctly
with multiple tariffs selected.
2026-04-24 07:16:33 +03:00
Fringg 605f202191 feat: bulk delete_subscription action — removes from bot DB + RemnaWave
Deactivates user in RemnaWave panel first, then deletes subscription
with related SubscriptionServer and TrafficPurchase records.
Subscription-level action (works with subscription_ids targeting).
2026-04-24 06:50:44 +03:00
Fringg be787a85bf feat: bulk set_devices action + device info in subscription list
- Add SET_DEVICES bulk action: sets device_limit on subscriptions,
  syncs to RemnaWave panel (subscription-level action)
- Add device_limit to SubscriptionListItem and BulkSubscriptionInfo
  schemas for frontend display
- Populate device_limit in _build_user_list_item and
  _build_subscription_info helpers
2026-04-24 06:35:29 +03:00
Fringg ff41ea9abb fix: suppress 'User already enabled' traceback in bulk add_traffic 2026-04-24 06:11:02 +03:00
Fringg cfbcc3082f fix: always return subscriptions list in user list API
subscriptions were only populated when is_multi_tariff_enabled()
was true. Users with multiple subscriptions in regular tariff mode
had empty subscriptions[] — bulk actions couldn't show or select them.
Now subscriptions are always populated regardless of tariff mode.
2026-04-24 06:08:23 +03:00
Fringg 2ad893badf fix: MissingGreenlet in subscription-ids bulk actions
_execute_for_subscription accessed user.subscriptions after commit,
triggering async lazy load → MissingGreenlet. Actions committed
successfully but reported as failed with cryptic error message.

Fix: use _build_subscription_info([sub]) with the already-loaded
targeted subscription instead of trying to lazy-load the full
user.subscriptions list.
2026-04-24 05:55:06 +03:00
Fringg e78177b2fc feat: multi-tariff bulk actions — subscription-level targeting
- Add subscription_ids to BulkExecuteRequest (mutually exclusive with
  user_ids via model_validator). Admins can now target specific
  subscriptions instead of auto-resolving the first active one.
- Add _execute_for_subscription dispatcher that loads subscription by
  ID, gets user, and passes sub_override to action handlers
- Add sub_override parameter to all 5 subscription-level handlers
  (extend, cancel, activate, change_tariff, add_traffic) — bypasses
  _resolve_subscription when a specific subscription is targeted
- Add SubscriptionListItem to UserListItem response — in multi-tariff
  mode, each user row includes all their subscriptions with tariff
  name, status, days remaining, traffic info
- User-level actions (add_balance, assign_promo_group, grant) reject
  subscription_ids with 400
- Add subscription_id field to BulkUserResult and SSE progress events
- Add _stream_bulk_execute_subscriptions SSE generator
- Backward compatible: existing user_ids requests work unchanged
2026-04-24 05:41:04 +03:00
Fringg daa472570c fix: add subscription/tariff/promo_group filters to admin user list API
The bulk actions page filters (subscription_status, tariff_id,
promo_group_id) were sent by the frontend but ignored by the
backend — the list_users endpoint had no such parameters.

- Add subscription_status, tariff_id, promo_group_id query params
  to GET /cabinet/admin/users
- Add subscription-level filtering via subquery in get_users_list
  and get_users_count CRUD functions
- Add tariff_id, tariff_name, traffic_used_gb, traffic_limit_gb,
  device_limit, days_remaining to UserListItem response schema
- Populate tariff info from subscription.tariff relationship in
  _build_user_list_item
2026-04-24 05:24:53 +03:00
Fringg db7b6734fd fix: bulk change_tariff not clearing squads when new tariff has none
When switching to a tariff with empty/null allowed_squads, the old
tariff's squads were preserved on the subscription. Now always sets
connected_squads from the new tariff (or empty list).
2026-04-24 05:10:47 +03:00
Fringg c0e0756b9a feat: bulk actions — SSE streaming progress, grant subscription, multi-tariff info
- Add SSE streaming mode (?stream=true): per-user progress events with
  real-time success/error counts, final summary event
- Add GRANT_SUBSCRIPTION action: creates new subscription with tariff,
  skips users who already have that tariff (multi-tariff aware),
  handles IntegrityError with graceful rollback
- Add BulkSubscriptionInfo: returns all user's subscriptions in each
  result for multi-tariff visibility
- Refactor: extract _validate_and_prepare and _execute_for_user helpers
  shared by streaming and non-streaming paths
2026-04-24 04:32:16 +03:00
Fringg 5b45d4354f fix: bulk actions review — rollback on error, multi-tariff constraint checks
- Add db.rollback() in per-user exception handler to prevent session
  poisoning (one failed commit would abort all subsequent users)
- Add multi-tariff duplicate subscription check in activate_subscription
  (prevents uq_subscriptions_user_tariff_active violation)
- Add multi-tariff duplicate subscription check in change_tariff
  (prevents switching to a tariff the user already holds)
2026-04-24 04:24:20 +03:00
Fringg fb2773fee4 feat: admin bulk actions API — mass operations on users
Add POST /cabinet/admin/bulk/execute endpoint for applying operations
to multiple users at once (up to 500 per request):

- extend_subscription / add_days: extend subscription by N days
- cancel_subscription: deactivate and expire subscriptions
- activate_subscription: reactivate expired subscriptions
- change_tariff: switch tariff without changing remaining days
- add_traffic: add extra GB to subscription traffic
- add_balance: credit balance with transaction record
- assign_promo_group: set or remove promo group for users

Features: dry_run preview mode, partial success handling (per-user
try/except), param validation before loop, auto-panel sync after
subscription mutations, multi-tariff mode support
2026-04-24 04:15:07 +03:00
Fringg ae7feeb726 fix: server squad sync fails on fresh DB without default promo group
On first startup with empty database, ensure_servers_synced() fetched
squads from RemnaWave but create_server_squad() failed with
ValueError('Server squad must be linked to at least one promo group')
because no default promo group existed yet.

Now _get_default_promo_group_id() auto-creates a default promo group
via _get_or_create_default_promo_group() when none exists, matching
the pattern used throughout the codebase for user registration.
2026-04-24 03:32:29 +03:00
Egor bcf5519880 Merge pull request #2904 from BEDOLAGA-DEV/dev
docs: add Overpay to README with partner block
2026-04-23 05:29:22 +03:00
Fringg 70568f82c5 docs: add Overpay to README with partner block 2026-04-23 05:25:49 +03:00
Egor 6fbd0bff28 Merge pull request #2903 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.51.0
2026-04-23 05:20:56 +03:00
github-actions[bot] dff3eb14a1 chore(main): release 3.51.0 2026-04-23 02:20:25 +00:00
Egor fab3d54f24 Merge pull request #2902 from BEDOLAGA-DEV/dev
Dev
2026-04-23 05:20:02 +03:00
Fringg 2c3ffc8c8a feat: integrate Overpay payment provider (pay.overpay.io)
Full integration across bot, cabinet, admin panel and landing pages:

- Config: 16 OVERPAY_* settings (API URL, credentials, P12 cert path,
  project ID, currency, amount limits, webhook path, payment methods)
- Service: overpay_service.py with httpx mTLS (P12 cert) + Basic Auth,
  create_payment, get_payment, refund_payment methods
- Payment mixin: OverpayPaymentMixin with create, webhook processing,
  finalize (balance credit + notifications), status check
- Model: OverpayPayment table + OVERPAY enum value
- CRUD: 9 standard operations (create, get_by_*, for_update, link)
- Bot handler: start_overpay_topup + process_overpay_payment_amount
- Handler registration: route_payment_by_method + callback registration
- Webhook: POST /overpay-webhook with DB-based anti-spoofing validation
- Cabinet: balance topup + guest payment dispatch
- Keyboard: payment method button in bot
- Admin: settings category + test payment button
- Infrastructure: payment_method_config_service, system_settings_service,
  payment_verification_service, payment_search_service
- Migration 0064: create overpay_payments table

Overpay API specifics: amount as string "100.00" (not kopeks),
success status "charged", HPP redirect via resultUrl
2026-04-23 04:36:56 +03:00
Fringg 6f87563789 fix: pad short RemnaWave usernames to meet 3-char minimum
RemnaWave API requires username >= 3 characters. Users with short
Telegram names (e.g. "Su") produced 2-char usernames that failed
validation, preventing subscription sync after payment.

Now format_remnawave_username pads short results with the user
identifier (telegram_id/email/user_id) to ensure minimum length.
2026-04-23 03:49:35 +03:00
Fringg 7005052156 fix: inactive user cleanup deletes users with paid subscriptions
The get_inactive_users query filtered only by last_activity (last bot
interaction), ignoring subscription end dates. Users who bought long
subscriptions (3-6-12 months) but didn't interact with the bot got
flagged as inactive and deleted+banned while their subscription was
still active or recently expired.

Fix: add SQL subquery excluding users who have ANY subscription with
end_date >= threshold_date. A user is now only deletable when BOTH
their last_activity AND their latest subscription end_date are older
than the configured inactivity period.
2026-04-23 03:40:45 +03:00
Fringg 29ae7089aa feat: respond to unknown media messages (photos, videos, documents)
Previously the bot only responded "Не понимаю эту команду" to text
messages. Photos, videos, documents and stickers sent outside of an
active FSM state were silently ignored, causing users to think their
media was received when it wasn't (e.g. support ticket screenshots
sent as separate messages).

Now the bot replies with the same "use menu buttons" message for any
unhandled media when no FSM state is active.
2026-04-23 03:31:21 +03:00
Egor 80e953a07b Merge pull request #2901 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.50.0
2026-04-22 06:18:07 +03:00
github-actions[bot] aebf58068f chore(main): release 3.50.0 2026-04-22 03:17:36 +00:00
Egor a491fe34bd Merge pull request #2900 from BEDOLAGA-DEV/dev
feat: v3.50.0 release
2026-04-22 06:17:09 +03:00
Egor 830e64afe0 Dev (#2899)
* fix: устранить MissingGreenlet в автоплатежах и починить traceback в логах

- subtract_user_balance: пишем promo_offer_log в отдельной сессии вместо rollback после commit, который экспайрил объекты основной сессии и ломал последующие обращения к subscription/user attrs
- monitoring_service._process_autopayments: перезагружаем subscription с eager-load user/tariff после списания, оборачиваем каждую итерацию в try/except + rollback, чтобы одна ошибка не валила весь батч
- logging_config: новый processor _auto_capture_exc_info автоматически подтягивает traceback из sys.exc_info() или error-kwarg → полный traceback в файле, консоли и Telegram без exc_info=True на каждом вызове
- logging_handler: дублирующая логика захвата exc_info в TelegramNotifierProcessor как резерв

* fix: устранить root cause MissingGreenlet в автоплатежах через refetch по id

Трейс показал: subscription.user падает на lazy-load → pool._checkout →
do_ping → await_ → MissingGreenlet. SQLAlchemy 2.0 async session не
поддерживает sync-lazy-load для relationships. Причина рассинхрона:
lock_user_for_pricing делает populate_existing=True + selectinload(
User.subscriptions).selectinload(Subscription.tariff), что разгружает
Subscription.user backref для сестринских подписок того же user.
Последующее обращение sub.user у другой подписки падает.

Фикс: захватываем (sub_id, user_id) пары ДО цикла, каждую итерацию
делаем fresh refetch через async select с eager load user+tariff+
promo_group. Никаких lazy access в горячем пути. В except используем
локально захваченные id вместо getattr(subscription, ...), чтобы
логирование не падало каскадом на expired объекте.

* fix: grant all available squads for unrestricted trials (#2897)

* feat: add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook (#2894)

* feat: add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook

* style: ruff format main.py

---------

Co-authored-by: Dmitry Lunin <br@slack.ru>

* fix: do not update first_name/last_name from OIDC claims (#2892)

Co-authored-by: Dmitry Lunin <br@slack.ru>

* fix: do not reset subscription_crypto_link when cryptoLink absent in webhook (#2891)

Co-authored-by: Dmitry Lunin <br@slack.ru>

* fix: FSM state loss on balance topup, PayPear confirmation_url, hidden trial tariff in renewal

- balance/platega: re-set FSM state after min/max validation errors,
  set state before pending_amount path, use balance_topup callback for back button
- balance/main: set FSM state and payment_method in handle_topup_amount_callback
  for all providers before routing, use balance_topup callback in validation errors
- payment/paypear: fix confirmation_url key (was 'url'), add fallback,
  store charged amount with commission for correct webhook amount comparison
- tariff_purchase: redirect to active tariff list when current tariff is
  inactive (hidden trial after promo code activation)
- cabinet/renewal: check tariff.is_active in both GET and POST endpoints
  to prevent hidden trial tariff periods from appearing

* fix: tariff switch pricing showing free for upgrades, admin duplicate subscription guard

- pricing_engine: use shortest period for daily rate comparison instead
  of period closest to remaining_days — fixes incorrect free/zero cost
  for upgrades when tariffs have different period sets
- pricing_engine: remove unused target_days parameter from
  get_tariff_daily_rate_fraction
- admin_users: add duplicate subscription check before create,
  change_tariff and activate actions to prevent UniqueViolationError
  on uq_subscriptions_user_tariff_active constraint
- admin_users: add IntegrityError fallback on create as TOCTOU safety net

* feat: tariff switch direction control, fix device pricing within tariff limit

Tariff switch direction:
- Add TARIFF_SWITCH_UPGRADE_ENABLED and TARIFF_SWITCH_DOWNGRADE_ENABLED
  settings to control allowed switch directions
- Guard all 10 entry points: instant switch (list, preview, confirm),
  legacy switch (list, select, confirm, daily confirm), cabinet (preview,
  execute), purchase-options API
- Filter tariff lists by allowed direction, show "unavailable" when
  both directions disabled
- Expose settings in cabinet purchase-options response for frontend

Device pricing fix:
- Devices within tariff.device_limit are now free when restoring
  (was charging for all devices regardless of tariff inclusion)
- Fix max(100, price) minimum enforcing 1 RUB even when
  chargeable_devices is 0
- Apply fix across all endpoints: bot handlers (confirm_change,
  execute_change, confirm_add), cabinet API (legacy purchase,
  modern purchase, get-price, save-cart), inline keyboard display

* fix: classic mode renewal resets device_limit to 1 via cart key mismatch

- Fix cart key mismatch: extend cart saved 'device_limit' but
  confirm_purchase read 'devices' key, falling back to DEFAULT=1.
  Now both keys are saved in both cart-save paths
- Fix confirm_purchase device resolution: use explicit is None checks
  instead of or-chain to avoid falsy-zero trap
- Fix return_to_saved_cart display: fall back to 'device_limit' and
  'traffic_limit_gb' keys when 'devices'/'traffic_gb' are absent
- Fix second cart-save path in _extend_existing_subscription with
  same dual-key pattern
- Fix RemnaWaveService import path in renewal service
- Add RESET_DEVICES_ON_RENEWAL setting: resets all connected devices
  (hwid) via RemnaWave API on each subscription renewal

* fix: menu layout schema icon limit, traffic_topup_enabled condition, shadowing imports

- Increase icon max_length from 10 to 100 in all three schemas
  (MenuButtonConfig, ButtonUpdateRequest, AddCustomButtonRequest)
  to support Telegram Custom Emoji IDs
- Add traffic_topup_enabled condition to ButtonConditions schema
- Remove shadowing local imports of MenuLayoutService in
  routes/menu_layout.py (top-level import already provides access)

* feat(tickets): multi-media message gallery (media_items JSONB)

- Add media_items JSONB column to TicketMessage model for multi-media
  gallery support (photos/videos/documents in one bubble)
- Add TicketMediaItem schema with type validation and shared
  _validate_media_bundle helper (max 10 items, legacy field compat)
- Update admin and user ticket handlers to store media_items and
  back-fill legacy media_type/media_file_id/media_caption from first
  item for backward compatibility
- Update _message_to_response in both admin and user routes to include
  media_items in API responses
- Allow empty message text when media is attached (message field now
  defaults to empty string with model validator ensuring text or media)
- Add migration 0061 with idempotent column check

Based on PR #2869 by @smediainfo — CI/CD workflow changes excluded
(hardcoded version strings would regress dynamic manifest reading)

* fix: ticket media_items review fixes

- Add if has_media else None guards in user-side ticket handlers
  (create_ticket, add_message) matching admin handler pattern
- Fix Telegram notification using resolved primary_file_id/primary_type
  instead of raw request fields for gallery messages
- Narrow except Exception to (TypeError, KeyError, ValueError) in
  _message_to_response with warning log for debugging
- Add media_items parameter to TicketCRUD.create_ticket and
  TicketCRUD.add_message for CRUD layer parity
- Add TicketMediaItemResponse and media_items field to webapi
  TicketMessageResponse to prevent data loss on read

* feat: landing page analytics goals and sticky pay button

- Add sticky_pay_button, analytics_view_enabled, analytics_view_goal,
  analytics_click_enabled, analytics_click_goal columns to LandingPage
- Add fields to CRUD updatable fields, admin create/update/detail
  schemas, create_landing() kwargs, _landing_to_detail() response
- Expose sticky_pay_button and analytics fields in public landing
  config response for frontend Yandex Metrika integration
- Add migration 0062 with idempotent column checks

Based on PR #2852 by @smediainfo — CI/CD workflow changes excluded
(hardcoded version strings would regress dynamic manifest reading)

* fix: validate analytics goal is set when analytics is enabled on landing

Prevent enabling analytics_view/click without providing the
corresponding goal identifier, which would result in empty
Yandex Metrika calls on the frontend.

* feat: Yandex Metrika offline conversions + S2S postbacks

- Add YandexClientIdMap model for user → yandex_cid mapping with
  upsert-safe CRUD (ON CONFLICT DO UPDATE)
- Add yandex_cid, subid, referrer columns to GuestPurchase
- Add yandex_offline_conv_service: Measurement Protocol integration
  with mc.yandex.ru/collect (registration, trial, purchase events),
  background task management, CID parsing from /start params
- Add s2s_postback_service: server-to-server affiliate postbacks
  with URL template placeholders and URL-safe encoding
- Add analytics offline conversion info to branding API (masked secret)
- Add POST /analytics/yandex-cid endpoint for cabinet CID capture
- Add 11 config settings (YANDEX_OFFLINE_CONV_*, S2S_POSTBACK_*)
- Add migration 0063 (yandex_client_id_map table + guest_purchases cols)
- Fix: mask measurement secret aggressively (show only last 4 chars)
- Fix: always replace {user_id} placeholder in S2S postback URLs
- Fix: use structlog kwargs instead of f-strings with LOG_PREFIX

Based on PR #2851 by @smediainfo — CI/CD workflow changes excluded

---------

Co-authored-by: c0mrade <killmy666@gmail.com>
Co-authored-by: Danila Yudin <danyayudin2012@gmail.com>
Co-authored-by: Dmitry V. Lunin <49199230+BlackRaincoat@users.noreply.github.com>
Co-authored-by: Dmitry Lunin <br@slack.ru>
2026-04-22 06:08:26 +03:00
Fringg 1068c1387a feat: Yandex Metrika offline conversions + S2S postbacks
- Add YandexClientIdMap model for user → yandex_cid mapping with
  upsert-safe CRUD (ON CONFLICT DO UPDATE)
- Add yandex_cid, subid, referrer columns to GuestPurchase
- Add yandex_offline_conv_service: Measurement Protocol integration
  with mc.yandex.ru/collect (registration, trial, purchase events),
  background task management, CID parsing from /start params
- Add s2s_postback_service: server-to-server affiliate postbacks
  with URL template placeholders and URL-safe encoding
- Add analytics offline conversion info to branding API (masked secret)
- Add POST /analytics/yandex-cid endpoint for cabinet CID capture
- Add 11 config settings (YANDEX_OFFLINE_CONV_*, S2S_POSTBACK_*)
- Add migration 0063 (yandex_client_id_map table + guest_purchases cols)
- Fix: mask measurement secret aggressively (show only last 4 chars)
- Fix: always replace {user_id} placeholder in S2S postback URLs
- Fix: use structlog kwargs instead of f-strings with LOG_PREFIX

Based on PR #2851 by @smediainfo — CI/CD workflow changes excluded
2026-04-22 05:57:46 +03:00
Fringg d31632534b fix: validate analytics goal is set when analytics is enabled on landing
Prevent enabling analytics_view/click without providing the
corresponding goal identifier, which would result in empty
Yandex Metrika calls on the frontend.
2026-04-22 05:43:54 +03:00
Fringg 3272b4bb05 feat: landing page analytics goals and sticky pay button
- Add sticky_pay_button, analytics_view_enabled, analytics_view_goal,
  analytics_click_enabled, analytics_click_goal columns to LandingPage
- Add fields to CRUD updatable fields, admin create/update/detail
  schemas, create_landing() kwargs, _landing_to_detail() response
- Expose sticky_pay_button and analytics fields in public landing
  config response for frontend Yandex Metrika integration
- Add migration 0062 with idempotent column checks

Based on PR #2852 by @smediainfo — CI/CD workflow changes excluded
(hardcoded version strings would regress dynamic manifest reading)
2026-04-22 05:36:41 +03:00
Fringg dd177101f7 fix: ticket media_items review fixes
- Add if has_media else None guards in user-side ticket handlers
  (create_ticket, add_message) matching admin handler pattern
- Fix Telegram notification using resolved primary_file_id/primary_type
  instead of raw request fields for gallery messages
- Narrow except Exception to (TypeError, KeyError, ValueError) in
  _message_to_response with warning log for debugging
- Add media_items parameter to TicketCRUD.create_ticket and
  TicketCRUD.add_message for CRUD layer parity
- Add TicketMediaItemResponse and media_items field to webapi
  TicketMessageResponse to prevent data loss on read
2026-04-22 05:29:51 +03:00
Fringg 36571c4275 feat(tickets): multi-media message gallery (media_items JSONB)
- Add media_items JSONB column to TicketMessage model for multi-media
  gallery support (photos/videos/documents in one bubble)
- Add TicketMediaItem schema with type validation and shared
  _validate_media_bundle helper (max 10 items, legacy field compat)
- Update admin and user ticket handlers to store media_items and
  back-fill legacy media_type/media_file_id/media_caption from first
  item for backward compatibility
- Update _message_to_response in both admin and user routes to include
  media_items in API responses
- Allow empty message text when media is attached (message field now
  defaults to empty string with model validator ensuring text or media)
- Add migration 0061 with idempotent column check

Based on PR #2869 by @smediainfo — CI/CD workflow changes excluded
(hardcoded version strings would regress dynamic manifest reading)
2026-04-22 05:23:48 +03:00
Fringg 66f8577448 fix: menu layout schema icon limit, traffic_topup_enabled condition, shadowing imports
- Increase icon max_length from 10 to 100 in all three schemas
  (MenuButtonConfig, ButtonUpdateRequest, AddCustomButtonRequest)
  to support Telegram Custom Emoji IDs
- Add traffic_topup_enabled condition to ButtonConditions schema
- Remove shadowing local imports of MenuLayoutService in
  routes/menu_layout.py (top-level import already provides access)
2026-04-22 05:17:56 +03:00
Fringg 9ca3320a02 fix: classic mode renewal resets device_limit to 1 via cart key mismatch
- Fix cart key mismatch: extend cart saved 'device_limit' but
  confirm_purchase read 'devices' key, falling back to DEFAULT=1.
  Now both keys are saved in both cart-save paths
- Fix confirm_purchase device resolution: use explicit is None checks
  instead of or-chain to avoid falsy-zero trap
- Fix return_to_saved_cart display: fall back to 'device_limit' and
  'traffic_limit_gb' keys when 'devices'/'traffic_gb' are absent
- Fix second cart-save path in _extend_existing_subscription with
  same dual-key pattern
- Fix RemnaWaveService import path in renewal service
- Add RESET_DEVICES_ON_RENEWAL setting: resets all connected devices
  (hwid) via RemnaWave API on each subscription renewal
2026-04-22 05:12:27 +03:00
Fringg 9ed4f086b0 feat: tariff switch direction control, fix device pricing within tariff limit
Tariff switch direction:
- Add TARIFF_SWITCH_UPGRADE_ENABLED and TARIFF_SWITCH_DOWNGRADE_ENABLED
  settings to control allowed switch directions
- Guard all 10 entry points: instant switch (list, preview, confirm),
  legacy switch (list, select, confirm, daily confirm), cabinet (preview,
  execute), purchase-options API
- Filter tariff lists by allowed direction, show "unavailable" when
  both directions disabled
- Expose settings in cabinet purchase-options response for frontend

Device pricing fix:
- Devices within tariff.device_limit are now free when restoring
  (was charging for all devices regardless of tariff inclusion)
- Fix max(100, price) minimum enforcing 1 RUB even when
  chargeable_devices is 0
- Apply fix across all endpoints: bot handlers (confirm_change,
  execute_change, confirm_add), cabinet API (legacy purchase,
  modern purchase, get-price, save-cart), inline keyboard display
2026-04-22 04:54:17 +03:00
Fringg da855a7c89 fix: tariff switch pricing showing free for upgrades, admin duplicate subscription guard
- pricing_engine: use shortest period for daily rate comparison instead
  of period closest to remaining_days — fixes incorrect free/zero cost
  for upgrades when tariffs have different period sets
- pricing_engine: remove unused target_days parameter from
  get_tariff_daily_rate_fraction
- admin_users: add duplicate subscription check before create,
  change_tariff and activate actions to prevent UniqueViolationError
  on uq_subscriptions_user_tariff_active constraint
- admin_users: add IntegrityError fallback on create as TOCTOU safety net
2026-04-22 04:25:42 +03:00
Fringg 7be404b918 fix: FSM state loss on balance topup, PayPear confirmation_url, hidden trial tariff in renewal
- balance/platega: re-set FSM state after min/max validation errors,
  set state before pending_amount path, use balance_topup callback for back button
- balance/main: set FSM state and payment_method in handle_topup_amount_callback
  for all providers before routing, use balance_topup callback in validation errors
- payment/paypear: fix confirmation_url key (was 'url'), add fallback,
  store charged amount with commission for correct webhook amount comparison
- tariff_purchase: redirect to active tariff list when current tariff is
  inactive (hidden trial after promo code activation)
- cabinet/renewal: check tariff.is_active in both GET and POST endpoints
  to prevent hidden trial tariff periods from appearing
2026-04-22 04:05:46 +03:00
Dmitry V. Lunin b71e58c8d2 fix: do not reset subscription_crypto_link when cryptoLink absent in webhook (#2891)
Co-authored-by: Dmitry Lunin <br@slack.ru>
2026-04-21 08:05:11 +03:00
Dmitry V. Lunin 1696e6f884 fix: do not update first_name/last_name from OIDC claims (#2892)
Co-authored-by: Dmitry Lunin <br@slack.ru>
2026-04-21 08:04:00 +03:00
Dmitry V. Lunin 7093d368d3 feat: add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook (#2894)
* feat: add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook

* style: ruff format main.py

---------

Co-authored-by: Dmitry Lunin <br@slack.ru>
2026-04-21 08:02:53 +03:00
Danila Yudin 905cea68b4 fix: grant all available squads for unrestricted trials (#2897) 2026-04-21 08:01:28 +03:00
Egor dc5442223d Merge pull request #2898 from BEDOLAGA-DEV/main
w
2026-04-21 07:53:33 +03:00
c0mrade 3b03c253cc fix: устранить root cause MissingGreenlet в автоплатежах через refetch по id
Трейс показал: subscription.user падает на lazy-load → pool._checkout →
do_ping → await_ → MissingGreenlet. SQLAlchemy 2.0 async session не
поддерживает sync-lazy-load для relationships. Причина рассинхрона:
lock_user_for_pricing делает populate_existing=True + selectinload(
User.subscriptions).selectinload(Subscription.tariff), что разгружает
Subscription.user backref для сестринских подписок того же user.
Последующее обращение sub.user у другой подписки падает.

Фикс: захватываем (sub_id, user_id) пары ДО цикла, каждую итерацию
делаем fresh refetch через async select с eager load user+tariff+
promo_group. Никаких lazy access в горячем пути. В except используем
локально захваченные id вместо getattr(subscription, ...), чтобы
логирование не падало каскадом на expired объекте.
2026-04-19 12:08:34 +03:00
c0mrade db79cc9eb0 fix: устранить MissingGreenlet в автоплатежах и починить traceback в логах
- subtract_user_balance: пишем promo_offer_log в отдельной сессии вместо rollback после commit, который экспайрил объекты основной сессии и ломал последующие обращения к subscription/user attrs
- monitoring_service._process_autopayments: перезагружаем subscription с eager-load user/tariff после списания, оборачиваем каждую итерацию в try/except + rollback, чтобы одна ошибка не валила весь батч
- logging_config: новый processor _auto_capture_exc_info автоматически подтягивает traceback из sys.exc_info() или error-kwarg → полный traceback в файле, консоли и Telegram без exc_info=True на каждом вызове
- logging_handler: дублирующая логика захвата exc_info в TelegramNotifierProcessor как резерв
2026-04-19 11:50:40 +03:00
Egor 1b94d9e700 Merge pull request #2890 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.49.0
2026-04-18 04:00:56 +03:00
github-actions[bot] 39a7c92cd4 chore(main): release 3.49.0 2026-04-18 00:58:34 +00:00
Egor 81ebec676c Merge pull request #2889 from BEDOLAGA-DEV/dev
Dev
2026-04-18 03:58:10 +03:00
Fringg 25ea5c60fd docs: add AuraPay to README with partner block 2026-04-18 03:56:48 +03:00
Fringg 29877fc93b fix: handle edge case when all tariffs are daily in legacy renewal 2026-04-18 01:09:53 +03:00
Fringg 5986c00fab fix: redirect legacy users without tariff to tariff selection on renewal
Users created before tariffs were introduced (tariff_id=NULL) got
"Тариф не найден" when pressing "Продлить подписку". Now they see
a tariff selection list instead, allowing them to pick a tariff
and renew with proper parameters (traffic, devices, etc).
2026-04-18 01:05:48 +03:00
Fringg ecc4a6147d fix: rate-limit daily subscription insufficient balance notifications to 6 hours
Users with daily subscriptions and low balance were getting
"Подписка приостановлена" notification every 30 minutes (on each
charge cycle). Now rate-limited via Redis cache to max 1 notification
per 6 hours per subscription.
2026-04-18 00:59:09 +03:00
Fringg 16bc1d4198 fix: align campaign top registrations revenue with period comparison
Top registrations list was summing DEPOSIT + SUBSCRIPTION_PAYMENT
transactions (total user spending), while period comparison revenue
only counted DEPOSIT with real payment methods (actual money paid in).

Now both use the same calculation: only DEPOSIT transactions with
real payment methods. This fixes the discrepancy where a user showed
500₽ in the list but total revenue was 250₽.
2026-04-18 00:50:23 +03:00
Fringg 0f814be1b7 fix: add missing RollyPay CRUD wrappers and guest payment flow
RollyPay was missing:
- 7 CRUD wrapper functions in payment_service.py (create, get, update, link)
- Guest payment block in create_guest_payment()

Both were present for PayPear and AuraPay but omitted for RollyPay.
2026-04-18 00:23:38 +03:00
Fringg 97179360c0 feat: integrate AuraPay payment provider
Full integration following PayPear/RollyPay patterns:
- API client with X-ApiKey + X-ShopId auth, HMAC-SHA256 webhook verification
  (sorted keys, concatenated values, secret key #2)
- AuraPayPaymentMixin with create, webhook, finalize (all side effects), status check
- CRUD, model, migration (0060), PaymentMethod.AURAPAY enum
- Webhook endpoint, cabinet topup, bot handler with sub-options (card, sbp)
- Admin panel: AURAPAY category in bot_configuration + system_settings_service
- Config: AURAPAY_ENABLED, AURAPAY_API_KEY, AURAPAY_SHOP_ID, AURAPAY_SECRET_KEY
2026-04-18 00:02:49 +03:00
Fringg 2aa5927433 fix: register PayPear and RollyPay in admin panel settings
- bot_configuration.py: added PAYPEAR/ROLLYPAY to payment categories
  and test payment buttons
- system_settings_service.py: added category titles, descriptions,
  and prefix mappings for PAYPEAR_* and ROLLYPAY_* settings
2026-04-17 23:44:35 +03:00
Egor 1c696c69e3 Merge pull request #2887 from BEDOLAGA-DEV/dev
docs: add PayPear and RollyPay to README with partner blocks
2026-04-16 06:26:19 +03:00
Fringg b531959982 docs: add PayPear and RollyPay to README with partner blocks 2026-04-16 06:25:33 +03:00
Egor ead0fc99d3 Merge pull request #2886 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.48.0
2026-04-16 06:11:21 +03:00
github-actions[bot] c09ae7436c chore(main): release 3.48.0 2026-04-16 03:10:42 +00:00
Egor 1ea76575ce Merge pull request #2885 from BEDOLAGA-DEV/dev
Dev
2026-04-16 06:10:19 +03:00
Fringg 25447edc9e docs: add SEVERPAY, PAYPEAR, ROLLYPAY to .env.example 2026-04-16 06:08:10 +03:00
Fringg a59858227f fix: support payment_method selection for RollyPay (sbp/card/crypto)
- Mixin accepts payment_method_type parameter (None = show all on form)
- API service sends payment_method only when specified
- Cabinet route passes payment_option to mixin
- Config service has sub_options: sbp, card, crypto
- Keyboard label no longer hardcodes "СБП"
2026-04-16 06:03:33 +03:00
Fringg ccc2f4efec feat: integrate RollyPay payment provider (SBP via USDT)
Full integration following PayPear/SeverPay patterns:
- API client with X-API-Key + X-Nonce auth, HMAC-SHA256 webhook verification
- RollyPayPaymentMixin with create, webhook, finalize (all side effects), status check
- CRUD, model, migration (0059), PaymentMethod.ROLLYPAY enum
- Webhook endpoint, cabinet topup, bot handler
- Statuses: created, processing, paid, expired, canceled, chargeback
- Config: ROLLYPAY_ENABLED, ROLLYPAY_API_KEY, ROLLYPAY_SIGNING_SECRET, etc.
2026-04-16 05:52:25 +03:00
Fringg a18f6caa9b feat: integrate PayPear payment provider
Full integration following SeverPay patterns:
- API client with Basic Auth, idempotency, HMAC webhook verification
- PayPearPaymentMixin with create, webhook, finalize (all side effects), status check
- CRUD, model, migration (0058), PaymentMethod.PAYPEAR enum
- Webhook endpoint, cabinet topup, bot handler, payment verification
- Sub-options: bank_card, sbp, sberpay, tpay
- Config: PAYPEAR_ENABLED, PAYPEAR_SHOP_ID, PAYPEAR_SECRET_KEY, etc.
2026-04-16 05:36:48 +03:00
Fringg 92eaf45311 fix: increase nalogo receipt queue retry window to 12 hours
Changed defaults from 10 attempts × 5min (50min total) to
72 attempts × 10min (12 hours total). When nalog.ru is temporarily
down, receipts now retry for 12 hours before being dropped,
giving the service enough time to recover.
2026-04-16 04:58:54 +03:00
Fringg 61cf495fc5 fix: show menu buttons for limited subscriptions in back-to-menu paths
The previous fix only covered /start command paths. The more common
show_main_menu and handle_back_to_menu in menu.py used their own
is_active check which returned False for limited status.

Now both paths treat limited subscriptions as active for UI, matching
the _calculate_subscription_flags fix in start.py.
2026-04-16 04:54:57 +03:00
Fringg 0c545490b6 fix: show menu buttons for limited (traffic exhausted) subscriptions
When Remnawave webhook set subscription status to 'limited' (traffic
exhausted), the main menu hid ALL buttons (connect, subscription,
buy traffic) because is_active returns False for non-'active' status.

Now 'limited' is treated as active for UI purposes — the subscription
is not expired, just traffic-exhausted. Users can see the "Buy traffic"
button precisely when they need it most.
2026-04-16 04:50:13 +03:00
Fringg 2d5afe5d75 fix: low balance alerts disabled by default, add quiet hours, expiry filter, top-up button
- Default balance_low_enabled changed to False (opt-in via cabinet)
- Quiet hours: alerts skipped between 22:00-09:00 UTC
- Only alerts when subscription expires within LOW_BALANCE_ALERT_EXPIRY_DAYS (default 3)
- Added inline "Top up" button linking to cabinet miniapp
- Synced defaults across notification_prefs, cabinet notifications route
2026-04-16 04:42:36 +03:00
Egor 50dc5a0fd1 Merge pull request #2882 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.47.0
2026-04-15 14:12:24 +03:00
github-actions[bot] 4dc8b4c091 chore(main): release 3.47.0 2026-04-15 11:10:30 +00:00
Egor 4db9e85062 feat: multi-tariff sync fix, daily discount fix, campaign links, TELEGRAM_API_URL
* fix: update subscription_crypto_link when syncing user from panel (#2867)

* fix: update subscription_crypto_link when syncing user from panel

* fix: update subscription_crypto_link when syncing user from panel

* fix: use PROXY_URL for Telegram OIDC JWKS requests (#2866)

* feat: add TELEGRAM_API_URL for custom Telegram Bot API server

Support custom Telegram Bot API server URL via TELEGRAM_API_URL env var.
Enables bot operation in regions where api.telegram.org is blocked
(Cloudflare Worker, self-hosted telegram-bot-api, nginx reverse proxy).
Uses native aiogram TelegramAPIServer.from_base(), works with PROXY_URL.

* fix(ci): read Docker image version from release-please manifest instead of hardcoding (#2859)

The x-release-please-version markers in workflow files were stuck at v3.7.0
since commit 5070bb34 removed them from extra-files (GitHub Actions returns
403 when release-please tries to modify .github/workflows/ via GITHUB_TOKEN).

Instead of hardcoding the version, read it from .release-please-manifest.json
at build time. This file is always kept in sync by release-please and does
not require workflow file write permissions.

---

Маркеры x-release-please-version в workflow-файлах застряли на v3.7.0
после коммита 5070bb34, который удалил их из extra-files (GitHub Actions
возвращает 403 при попытке release-please изменить .github/workflows/
через GITHUB_TOKEN).

Вместо хардкода версии теперь читаем её из .release-please-manifest.json
во время сборки. Этот файл всегда синхронизируется release-please и не
требует прав на запись в workflow-файлы.

* fix: format telegram_auth.py to use single quotes (ruff)

* fix: remove daily tariff fallback to smallest period discount

Daily tariffs (period_days=1) incorrectly inherited the discount
of the smallest configured period (e.g. 90 days -> 5%). This caused
daily prices to show discounts that were never intended for them.

Now daily tariffs only get a discount if explicitly configured for
period_days=1 in the promo group's period_discounts.

* fix: use CABINET_URL for campaign web links instead of MINIAPP_CUSTOM_URL

Campaign web links were generated from MINIAPP_CUSTOM_URL which is often
empty, causing get_campaign_web_link() to return None. Admins and partners
could only share bot links for campaigns, not cabinet links.

Now prefers CABINET_URL (where the auth flow captures ?campaign= param),
falling back to MINIAPP_CUSTOM_URL for backwards compatibility. This is
consistent with how referral web links already use CABINET_URL.

* feat: add DISPLAY_NAME_RESTRICTION_ENABLED toggle

Allows disabling the display name restriction middleware via .env.
Users with special characters in their Telegram name (e.g. "@")
were blocked from using the bot entirely. Default: true (enabled).

Set DISPLAY_NAME_RESTRICTION_ENABLED=false to disable.

* fix: allow clearing all period discounts from promo groups

Empty period_discounts dict was normalized to None by the schema,
making it indistinguishable from "field absent" (don't update).
Now empty dict passes through to CRUD which correctly sets
period_discounts=None in DB, clearing all discounts.

* fix: create panel user instead of update for new subscriptions in multi-tariff mode

In multi-tariff mode, new subscriptions have remnawave_uuid=None.
The old logic fell back to user.remnawave_uuid (from a previous
subscription) and called update_remnawave_user(), which refused
to work because the NEW subscription had no UUID.

Now correctly: in multi-tariff mode, always CREATE if subscription
has no remnawave_uuid. In single-tariff mode, use user-level UUID.

Fixes: "subscription has no remnawave_uuid, cannot update panel"

* fix: apply same create-vs-update fix to renewal and purchase flows

Same bug as the tariff purchase fix: in multi-tariff mode, new
subscriptions without remnawave_uuid incorrectly fell back to
user.remnawave_uuid and called update instead of create.

Fixed in subscription_renewal_service.py and purchase.py to use
the same _should_create pattern based on mode.

* fix: apply create-vs-update fix to all remaining tariff_purchase flows

Fixed 6 more locations in tariff_purchase.py that had the same broken
pattern (custom purchase, daily purchase, trial conversion, tariff
switch, daily switch, instant switch). All now use _should_create
based on multi-tariff mode instead of falling back to user UUID.

* fix: apply create-vs-update fix to cabinet traffic/devices and monitoring

Same multi-tariff create-vs-update bug in 5 more locations:
- cabinet/subscription_modules/traffic.py (2 instances)
- cabinet/subscription_modules/devices.py (2 instances)
- services/monitoring_service.py (1 instance)

All now use _should_create pattern based on subscription.remnawave_uuid
in multi-tariff mode instead of falling back to user.remnawave_uuid.

* fix: ruff format traffic.py and monitoring_service.py

---------

Co-authored-by: Dmitry V. Lunin <49199230+BlackRaincoat@users.noreply.github.com>
Co-authored-by: Gary Jarrel <gary@jarrel.com.au>
2026-04-15 14:10:05 +03:00
Egor fca8d6da97 Dev (#2880)
* fix: update subscription_crypto_link when syncing user from panel (#2867)

* fix: update subscription_crypto_link when syncing user from panel

* fix: update subscription_crypto_link when syncing user from panel

* fix: use PROXY_URL for Telegram OIDC JWKS requests (#2866)

* feat: add TELEGRAM_API_URL for custom Telegram Bot API server

Support custom Telegram Bot API server URL via TELEGRAM_API_URL env var.
Enables bot operation in regions where api.telegram.org is blocked
(Cloudflare Worker, self-hosted telegram-bot-api, nginx reverse proxy).
Uses native aiogram TelegramAPIServer.from_base(), works with PROXY_URL.

* fix(ci): read Docker image version from release-please manifest instead of hardcoding (#2859)

The x-release-please-version markers in workflow files were stuck at v3.7.0
since commit 5070bb34 removed them from extra-files (GitHub Actions returns
403 when release-please tries to modify .github/workflows/ via GITHUB_TOKEN).

Instead of hardcoding the version, read it from .release-please-manifest.json
at build time. This file is always kept in sync by release-please and does
not require workflow file write permissions.

---

Маркеры x-release-please-version в workflow-файлах застряли на v3.7.0
после коммита 5070bb34, который удалил их из extra-files (GitHub Actions
возвращает 403 при попытке release-please изменить .github/workflows/
через GITHUB_TOKEN).

Вместо хардкода версии теперь читаем её из .release-please-manifest.json
во время сборки. Этот файл всегда синхронизируется release-please и не
требует прав на запись в workflow-файлы.

* fix: format telegram_auth.py to use single quotes (ruff)

* fix: remove daily tariff fallback to smallest period discount

Daily tariffs (period_days=1) incorrectly inherited the discount
of the smallest configured period (e.g. 90 days -> 5%). This caused
daily prices to show discounts that were never intended for them.

Now daily tariffs only get a discount if explicitly configured for
period_days=1 in the promo group's period_discounts.

* fix: use CABINET_URL for campaign web links instead of MINIAPP_CUSTOM_URL

Campaign web links were generated from MINIAPP_CUSTOM_URL which is often
empty, causing get_campaign_web_link() to return None. Admins and partners
could only share bot links for campaigns, not cabinet links.

Now prefers CABINET_URL (where the auth flow captures ?campaign= param),
falling back to MINIAPP_CUSTOM_URL for backwards compatibility. This is
consistent with how referral web links already use CABINET_URL.

* feat: add DISPLAY_NAME_RESTRICTION_ENABLED toggle

Allows disabling the display name restriction middleware via .env.
Users with special characters in their Telegram name (e.g. "@")
were blocked from using the bot entirely. Default: true (enabled).

Set DISPLAY_NAME_RESTRICTION_ENABLED=false to disable.

* fix: allow clearing all period discounts from promo groups

Empty period_discounts dict was normalized to None by the schema,
making it indistinguishable from "field absent" (don't update).
Now empty dict passes through to CRUD which correctly sets
period_discounts=None in DB, clearing all discounts.

* fix: create panel user instead of update for new subscriptions in multi-tariff mode

In multi-tariff mode, new subscriptions have remnawave_uuid=None.
The old logic fell back to user.remnawave_uuid (from a previous
subscription) and called update_remnawave_user(), which refused
to work because the NEW subscription had no UUID.

Now correctly: in multi-tariff mode, always CREATE if subscription
has no remnawave_uuid. In single-tariff mode, use user-level UUID.

Fixes: "subscription has no remnawave_uuid, cannot update panel"

* fix: apply same create-vs-update fix to renewal and purchase flows

Same bug as the tariff purchase fix: in multi-tariff mode, new
subscriptions without remnawave_uuid incorrectly fell back to
user.remnawave_uuid and called update instead of create.

Fixed in subscription_renewal_service.py and purchase.py to use
the same _should_create pattern based on mode.

* fix: apply create-vs-update fix to all remaining tariff_purchase flows

Fixed 6 more locations in tariff_purchase.py that had the same broken
pattern (custom purchase, daily purchase, trial conversion, tariff
switch, daily switch, instant switch). All now use _should_create
based on multi-tariff mode instead of falling back to user UUID.

* fix: apply create-vs-update fix to cabinet traffic/devices and monitoring

Same multi-tariff create-vs-update bug in 5 more locations:
- cabinet/subscription_modules/traffic.py (2 instances)
- cabinet/subscription_modules/devices.py (2 instances)
- services/monitoring_service.py (1 instance)

All now use _should_create pattern based on subscription.remnawave_uuid
in multi-tariff mode instead of falling back to user.remnawave_uuid.

* fix: ruff format traffic.py and monitoring_service.py

---------

Co-authored-by: Dmitry V. Lunin <49199230+BlackRaincoat@users.noreply.github.com>
Co-authored-by: Gary Jarrel <gary@jarrel.com.au>
2026-04-15 14:04:16 +03:00
Fringg 4fe67a9c74 fix: ruff format traffic.py and monitoring_service.py 2026-04-15 13:57:15 +03:00
Fringg 30a1a31978 fix: apply create-vs-update fix to cabinet traffic/devices and monitoring
Same multi-tariff create-vs-update bug in 5 more locations:
- cabinet/subscription_modules/traffic.py (2 instances)
- cabinet/subscription_modules/devices.py (2 instances)
- services/monitoring_service.py (1 instance)

All now use _should_create pattern based on subscription.remnawave_uuid
in multi-tariff mode instead of falling back to user.remnawave_uuid.
2026-04-15 13:28:06 +03:00
Fringg c2b68e1afa fix: apply create-vs-update fix to all remaining tariff_purchase flows
Fixed 6 more locations in tariff_purchase.py that had the same broken
pattern (custom purchase, daily purchase, trial conversion, tariff
switch, daily switch, instant switch). All now use _should_create
based on multi-tariff mode instead of falling back to user UUID.
2026-04-15 13:24:07 +03:00
Fringg cea7260a85 fix: apply same create-vs-update fix to renewal and purchase flows
Same bug as the tariff purchase fix: in multi-tariff mode, new
subscriptions without remnawave_uuid incorrectly fell back to
user.remnawave_uuid and called update instead of create.

Fixed in subscription_renewal_service.py and purchase.py to use
the same _should_create pattern based on mode.
2026-04-15 13:17:35 +03:00
Fringg e3c0caabcf fix: create panel user instead of update for new subscriptions in multi-tariff mode
In multi-tariff mode, new subscriptions have remnawave_uuid=None.
The old logic fell back to user.remnawave_uuid (from a previous
subscription) and called update_remnawave_user(), which refused
to work because the NEW subscription had no UUID.

Now correctly: in multi-tariff mode, always CREATE if subscription
has no remnawave_uuid. In single-tariff mode, use user-level UUID.

Fixes: "subscription has no remnawave_uuid, cannot update panel"
2026-04-15 13:14:10 +03:00
Fringg aeaa4f8e0d fix: allow clearing all period discounts from promo groups
Empty period_discounts dict was normalized to None by the schema,
making it indistinguishable from "field absent" (don't update).
Now empty dict passes through to CRUD which correctly sets
period_discounts=None in DB, clearing all discounts.
2026-04-15 04:13:43 +03:00
Fringg e226ac8637 feat: add DISPLAY_NAME_RESTRICTION_ENABLED toggle
Allows disabling the display name restriction middleware via .env.
Users with special characters in their Telegram name (e.g. "@")
were blocked from using the bot entirely. Default: true (enabled).

Set DISPLAY_NAME_RESTRICTION_ENABLED=false to disable.
2026-04-15 04:03:18 +03:00
Fringg 85403da528 fix: use CABINET_URL for campaign web links instead of MINIAPP_CUSTOM_URL
Campaign web links were generated from MINIAPP_CUSTOM_URL which is often
empty, causing get_campaign_web_link() to return None. Admins and partners
could only share bot links for campaigns, not cabinet links.

Now prefers CABINET_URL (where the auth flow captures ?campaign= param),
falling back to MINIAPP_CUSTOM_URL for backwards compatibility. This is
consistent with how referral web links already use CABINET_URL.
2026-04-15 03:56:34 +03:00
Fringg bdd873382c fix: remove daily tariff fallback to smallest period discount
Daily tariffs (period_days=1) incorrectly inherited the discount
of the smallest configured period (e.g. 90 days -> 5%). This caused
daily prices to show discounts that were never intended for them.

Now daily tariffs only get a discount if explicitly configured for
period_days=1 in the promo group's period_discounts.
2026-04-15 03:39:52 +03:00
Fringg 9d750d9eb0 fix: format telegram_auth.py to use single quotes (ruff) 2026-04-15 03:34:49 +03:00
Gary Jarrel 87b83f59c6 fix(ci): read Docker image version from release-please manifest instead of hardcoding (#2859)
The x-release-please-version markers in workflow files were stuck at v3.7.0
since commit 5070bb34 removed them from extra-files (GitHub Actions returns
403 when release-please tries to modify .github/workflows/ via GITHUB_TOKEN).

Instead of hardcoding the version, read it from .release-please-manifest.json
at build time. This file is always kept in sync by release-please and does
not require workflow file write permissions.

---

Маркеры x-release-please-version в workflow-файлах застряли на v3.7.0
после коммита 5070bb34, который удалил их из extra-files (GitHub Actions
возвращает 403 при попытке release-please изменить .github/workflows/
через GITHUB_TOKEN).

Вместо хардкода версии теперь читаем её из .release-please-manifest.json
во время сборки. Этот файл всегда синхронизируется release-please и не
требует прав на запись в workflow-файлы.
2026-04-15 03:15:35 +03:00
Fringg 8ff6f99229 feat: add TELEGRAM_API_URL for custom Telegram Bot API server
Support custom Telegram Bot API server URL via TELEGRAM_API_URL env var.
Enables bot operation in regions where api.telegram.org is blocked
(Cloudflare Worker, self-hosted telegram-bot-api, nginx reverse proxy).
Uses native aiogram TelegramAPIServer.from_base(), works with PROXY_URL.
2026-04-15 03:12:59 +03:00
Dmitry V. Lunin 94da3c35b0 fix: use PROXY_URL for Telegram OIDC JWKS requests (#2866) 2026-04-15 03:08:06 +03:00
Dmitry V. Lunin d35a8bb74c fix: update subscription_crypto_link when syncing user from panel (#2867)
* fix: update subscription_crypto_link when syncing user from panel

* fix: update subscription_crypto_link when syncing user from panel
2026-04-15 03:07:07 +03:00
Egor c635d88764 Merge pull request #2878 from BEDOLAGA-DEV/main
w
2026-04-15 02:53:04 +03:00
c0mrade 5009703676 Merge pull request #2875 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.46.1
2026-04-13 22:09:58 +03:00
github-actions[bot] e88a5989b6 chore(main): release 3.46.1 2026-04-13 19:07:27 +00:00
c0mrade 02747381dc Merge pull request #2874 from BEDOLAGA-DEV/dev
fix: cabinet_refresh_tokens migration + notification_settings jsonb
2026-04-13 22:06:53 +03:00
c0mrade e74fda954c fix: change notification_settings from json to jsonb for DISTINCT compatibility 2026-04-13 21:56:04 +03:00
c0mrade 8587f03f67 fix: add checkfirst guards to cabinet_refresh_tokens migration 2026-04-13 21:47:16 +03:00
c0mrade 4707cdf60c fix: add missing migration for cabinet_refresh_tokens table 2026-04-13 21:43:46 +03:00
c0mrade 0879b8b218 Merge pull request #2873 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.46.0
2026-04-13 19:47:36 +03:00
github-actions[bot] 1d91382b8e chore(main): release 3.46.0 2026-04-13 16:37:54 +00:00
c0mrade 3768b18a39 Merge pull request #2872 from BEDOLAGA-DEV/dev
Bugfixes: campaign, tickets, NaloGO, devices, broadcasts, menu editor
2026-04-13 19:37:16 +03:00
c0mrade 1eeeb39779 fix: exclude users with active subscriptions from expired broadcast
In multi-subscription mode, a user with an expired trial AND an active
paid subscription was incorrectly included in expired broadcast targets.

Fixed in 3 places:
- get_target_users_count (SQL): added NOT EXISTS active sub subquery
- get_target_users 'expired' (Python): skip if has_active
- get_target_users 'expired_subscribers' (Python): same check
2026-04-13 19:21:52 +03:00
c0mrade 570af82dfd fix: raise MAX_BUTTONS_PER_ROW to 8 and allow tg:// deep links in menu editor
MAX_BUTTONS_PER_ROW was 3, causing Pydantic 422 when adding multiple
custom buttons to a row. Telegram allows up to 8 buttons per row.

Also added tg:// to URL_PATTERN so admins can use Telegram deep links
(tg://resolve, tg://user, etc.) in custom menu buttons. webapp mode
still requires https:// as enforced by existing validation.
2026-04-13 17:07:19 +03:00
c0mrade bc3893b934 fix: use MAX_DEVICES_LIMIT instead of hardcoded 10 for device buttons
Admin user device editor had hardcoded limit of 10 in text, validation,
and inline buttons. Now uses settings.MAX_DEVICES_LIMIT dynamically,
generating buttons 1..N in rows of 4. Falls back to text-only input
if limit exceeds Telegram's 100-button cap.
2026-04-13 14:42:16 +03:00
c0mrade 16d91638bc fix: enforce max_attempts limit in NaloGO receipt queue
The _max_attempts property existed but was never checked as a limit.
Receipts were retried indefinitely (55+ attempts observed in logs).
Now receipts exceeding max_attempts are removed from the queue.
2026-04-13 14:42:06 +03:00
c0mrade eb18b3a0f9 fix: handle TelegramBadRequest when deleting old ticket notifications
Messages older than 48 hours cannot be deleted via Telegram API.
Now falls back to editing the message text instead of crashing.
2026-04-13 14:41:59 +03:00
c0mrade a8e2b62f4b feat: save campaign_slug during standalone email registration
Campaign slug was lost during email registration flow — it was only
sent at verification time from localStorage, which is empty if the
user opens the verification link in a different browser/webview.

Now campaign_slug is accepted in the registration request, saved to
user.pending_campaign_slug, and used as fallback during email
verification. Also processed immediately for auto-verified test emails.
2026-04-13 14:41:49 +03:00
c0mrade fb8d2b3ee4 fix: upsert refresh tokens (ON CONFLICT) + periodic cleanup of expired/revoked tokens 2026-04-10 18:08:27 +03:00
c0mrade 2321667ecb fix: add TRAFFIC_WARNING_ALERT and LOW_BALANCE_ALERT localization keys to all locales 2026-04-10 17:58:04 +03:00
c0mrade 113304b212 style: remove unused import (ruff fix) 2026-04-10 16:47:41 +03:00
c0mrade 0300044b00 feat: add category field to broadcast API schemas and routes 2026-04-10 16:10:13 +03:00
c0mrade 931abfe7a5 feat: add broadcast category (system/news/promo) + filter recipients by user prefs 2026-04-10 16:05:43 +03:00
c0mrade 1d96f80f60 feat: add traffic % warning check using user's threshold preference 2026-04-10 15:59:32 +03:00
c0mrade 4e50419171 feat: implement low balance alert + respect user notification preferences
- Add _check_low_balance_alerts to monitoring service
- Notify users with autopay when balance drops below their threshold
- Uses notification_prefs helper for per-user settings
2026-04-10 15:43:32 +03:00
c0mrade 7208a52c94 feat: respect user traffic_warning notification preference in webhook handler 2026-04-10 15:37:33 +03:00
c0mrade 63fdfe4a42 feat: respect user subscription_expiry notification preferences 2026-04-10 15:37:00 +03:00
c0mrade e0e2edf816 feat: add user notification preferences helper utility 2026-04-10 15:35:30 +03:00
c0mrade 522a8779d6 style: fix ruff format for all sync-related changes 2026-04-10 15:13:59 +03:00
c0mrade be32010d63 fix: trial activation fallback to trial-eligible servers when tariff has no squads (BUG-12) + fix misleading button text 2026-04-10 15:04:38 +03:00
c0mrade 7e920fa30f fix: add retry queue to all remaining RemnaWave error handlers 2026-04-10 14:22:10 +03:00
c0mrade 1b376baeca fix: add retry queue to cabinet subscription operation RemnaWave errors 2026-04-10 11:56:48 +03:00
c0mrade 91a756a33e fix: add retry queue to payment webhook and renewal service RemnaWave errors 2026-04-10 11:56:24 +03:00
c0mrade 970dc549df fix: add retry queue to classic mode bot purchase handler 2026-04-10 11:40:20 +03:00
c0mrade 65120f0bad fix: add retry queue to daily subscription service RemnaWave errors 2026-04-10 11:39:39 +03:00
c0mrade 9cb559ff39 fix: enqueue retry on RemnaWave API failure in all purchase flows (BUG-2, BUG-10)
Add remnawave_retry_queue.enqueue() calls in all 10 purchase error handlers
where RemnaWave API failure was caught and swallowed without scheduling a retry:
- cabinet purchase.py: purchase_tariff() and activate_trial() (2 places)
- subscription_purchase_service.py: miniapp purchase flow (1 place)
- tariff_purchase.py: custom, standard, daily, renewal, switch, daily-switch,
  and instant-switch flows (7 places)
2026-04-10 11:04:02 +03:00
c0mrade 8f1882f24c feat: start RemnaWave retry queue on app startup 2026-04-10 10:48:14 +03:00
c0mrade 8542a39305 fix: always sync squads in auto-purchase renewal (BUG-4) 2026-04-10 10:48:01 +03:00
c0mrade 646ac4cfa1 fix: match tariff_id when creating subscriptions from panel sync (BUG-11) 2026-04-10 10:41:50 +03:00
c0mrade abdf296767 feat: add RemnaWave retry queue for failed API calls (BUG-2, BUG-10) 2026-04-10 10:41:49 +03:00
c0mrade a1b6d9bb61 fix: use update_remnawave_user when UUID exists in tariff_purchase (BUG-3) 2026-04-10 10:38:00 +03:00
c0mrade cf19e4e1f7 fix: protect OAuth users with remnawave_uuid from sync deactivation (BUG-6) 2026-04-10 10:36:16 +03:00
c0mrade 35412e9f21 fix: sync connected_squads from panel during sync (BUG-5) 2026-04-10 10:36:02 +03:00
c0mrade 6aed7d355b fix: default sync_squads=True in update_remnawave_user (BUG-4) 2026-04-10 10:34:56 +03:00
c0mrade 9c08ce6948 fix: resync RemnaWave after account merge (BUG-7) 2026-04-10 10:33:45 +03:00
c0mrade 862352139e fix: use 'is not None' for telegram_id in create_user API (BUG-9) 2026-04-10 10:33:25 +03:00
c0mrade d465ccb3ac fix: resync RemnaWave after Telegram account linking (BUG-1) 2026-04-10 10:32:39 +03:00
c0mrade b57f185258 feat: add remnawave_resync_service for identity-change sync
Introduces resync_user_subscriptions_with_panel(), a standalone async
helper that re-pushes all active subscriptions to the RemnaWave panel
after any identity change (TG linking, account merge, email verification).
Handles multi-tariff vs. single-tariff mode, create vs. update branching,
tariff eager-loading, and returns a synced/failed/total stats dict.
2026-04-10 10:31:12 +03:00
c0mrade ffbb3fb8be Merge pull request #2861 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.45.2
2026-04-08 18:46:58 +03:00
github-actions[bot] f01dbff000 chore(main): release 3.45.2 2026-04-08 15:44:09 +00:00
c0mrade 31adcfded4 Merge pull request #2860 from BEDOLAGA-DEV/dev
fix: batch bug fixes from user complaints
2026-04-08 18:42:47 +03:00
c0mrade 78f963bf5e fix: batch bug fixes from user complaints
- 100% discount: daily tariff fallback to smallest configured period discount
- 100% discount: purchase blocked by safety guard (base_price → original_total in 6 guards)
- Gift subscription reset existing days (replace → extend for active/trial subs)
- Cabinet broadcast: target alias active_subscribers not mapped to active
- Promo code: error always "expired" — split into inactive/not_yet_valid/expired
- Multi-tariff: add delete subscription button in admin bot
- Multi-tariff → single: select subscription with most remaining time (end_date DESC)
- Gift purchases not counted in total spent (added GIFT_PAYMENT type)
- Remnawave API: retry on 502/503/504 (was only 429)
- Heleket: add from_referral_code to invoice payload
- Whitespace fix in blacklist_service
2026-04-08 18:33:17 +03:00
Egor 357d94d1b0 Merge pull request #2855 from andreycoast/fix/blacklist-parsing-logic
fix: исправление парсинга черного списка (поддержка '#' и извлечение username)
2026-04-07 15:49:54 +03:00
Egor 0fb4a2c235 Merge pull request #2856 from BEDOLAGA-DEV/main
w
2026-04-07 15:49:20 +03:00
andreycoast 2f7184627a fix: исправление парсинга черного списка (поддержка '#' и извлечение username) 2026-04-07 15:38:32 +03:00
c0mrade d55e9db62a Merge pull request #2849 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.45.1
2026-04-03 20:58:07 +03:00
github-actions[bot] 57adfaf4f3 chore(main): release 3.45.1 2026-04-03 17:57:12 +00:00
c0mrade 4165eaea7a Merge pull request #2848 from BEDOLAGA-DEV/dev
fix: add missing WEBHOOK_TORRENT_DETECTED mapping + dedup before uniq…
2026-04-03 20:56:51 +03:00
c0mrade 3b5d5a18a1 fix: add missing WEBHOOK_TORRENT_DETECTED mapping + dedup before unique index in migration 0053 2026-04-03 20:50:13 +03:00
c0mrade eef41c4bca Merge pull request #2846 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.45.0
2026-04-03 19:13:53 +03:00
github-actions[bot] 987c3c93c2 chore(main): release 3.45.0 2026-04-03 16:12:28 +00:00
c0mrade 7d24e8d704 Merge pull request #2845 from BEDOLAGA-DEV/dev
fix: subscription system bugfixes + torrent notifications + user deletion cleanup
2026-04-03 19:12:06 +03:00
c0mrade 819f09a68e fix: restore missing import + rewrite user.deleted webhook to properly deactivate all subscriptions
- Fix NameError in admin_users.py: re-add get_traffic_reset_strategy import that ruff auto-removed
- user.deleted: remove auto-recreation logic — deleted means deleted, no more recreating users back in panel
- user.deleted: deactivate primary subscription unconditionally (expire + clear all linkage)
- user.deleted: sweep sibling subscriptions — verify each via panel API, deactivate only those whose panel user is gone (safe for multi-tariff where only one of N panel users may be deleted)
- Works across multi-tariff, single-tariff, and classic modes
2026-04-03 18:56:14 +03:00
c0mrade 2f9d00343b feat: send torrent blocker notification to user (not just admin)
- torrent_blocker.report is now a dual event: admin notification + user message
- New _handle_torrent_detected user handler sends WEBHOOK_TORRENT_DETECTED
- process_event handles events registered in both admin and user handlers
- Webhook router passes DB session for dual events (needs_db_session check)
- Add WEBHOOK_NOTIFY_TORRENT_DETECTED setting (default: true)
- Add WEBHOOK_TORRENT_DETECTED locale texts (ru/en/ua/zh/fa)
2026-04-03 18:19:45 +03:00
c0mrade 9b7ac47f16 fix: resolve multiple subscription bugs — LIMITED status, trial tariff blocking, traffic reset strategy, classic mode pricing, 100% discount support
- Include LIMITED status in subscription lookups (get_active_subscriptions_by_user_id, get_subscription_by_user_and_tariff) — fixes duplicate subscriptions when traffic exhausted
- Migration 0053: update partial unique index to include LIMITED
- Trial subscriptions no longer block tariff purchase — excluded from purchased_tariff_ids, handle_extend_subscription routes trial+tariff to tariff extend flow
- Replace hardcoded TrafficLimitStrategy.MONTH with get_traffic_reset_strategy() across all sync/create paths (remnawave_service, monitoring_service, admin_users)
- Subscriptions with tariff_id always use tariff pricing flow regardless of global sales mode — fixes 0₽ renewal in classic mode
- Support 100% promo group discount across all purchase/renewal flows — balance checks skip when price=0, validation allows final_total=0 when base_price>0
2026-04-03 17:22:42 +03:00
Egor 0d5638f778 Merge pull request #2838 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.44.0
2026-04-02 07:24:58 +03:00
github-actions[bot] 7836720db3 chore(main): release 3.44.0 2026-04-02 04:24:32 +00:00
Egor dcb90d6139 Merge pull request #2837 from BEDOLAGA-DEV/dev
Dev
2026-04-02 07:24:07 +03:00
Fringg 96c420e917 style: fix ruff format for severpay.py 2026-04-02 07:17:36 +03:00
Fringg 9d63635502 feat: add SberPay as KassaAI sub-method (payment_system_id=43)
Adds SberPay alongside existing SBP (44) and Card (36) sub-methods.

Changes across all 8 required files:
- config.py: KASSA_AI_SBERPAY_ENABLED, display name, helper methods
- kassa_ai_service.py: payment_system_id=43 in KASSA_AI_SUB_METHODS
- kassa_ai.py handler: method config + start_kassa_ai_sberpay_topup
- main.py: route tuple, import, handler registration
- payment_service.py: guest purchase flow method check
- cabinet/balance.py: KASSA_AI_OPTION_MAP sberpay=43
- payment_method_config_service.py: sub_option for admin panel
- inline.py: keyboard button + fallback condition guard

Env vars: KASSA_AI_SBERPAY_ENABLED, KASSA_AI_SBERPAY_DISPLAY_NAME
2026-04-02 06:47:57 +03:00
Egor c4c5d330be Merge pull request #2836 from BEDOLAGA-DEV/main
w
2026-04-02 06:39:28 +03:00
Fringg 977950b97f fix: address review issues in PR #2829 webhook intentional deletion guard
5 issues found by review agents and fixed:

1. Intentional deletion guard was at top of process_event, skipping ALL
   cleanup (subscription URLs, server counts, expired marking). Moved
   check into _handle_user_deleted so cleanup still runs but re-creation
   is suppressed via subscription_still_valid=False.

2. Variable shadowing: telegram_id loop var in any() generator shadowed
   the outer telegram_id local. Renamed to tid/uid.

3. No hard cap on in-memory dicts: added _MAX_INTENTIONAL_ENTRIES=10000
   with early return in mark_intentional_panel_deletion.

4. mark_intentional_panel_deletion called inside for-loop with single
   UUID — race window if webhook from first delete arrives before
   second UUID is marked. Moved to before the loop with all UUIDs.

5. Tests: @pytest.mark.anyio → @pytest.mark.anyio('asyncio') for
   consistency. Replaced process_event(db=None) test with direct
   mark+detect unit tests + hard cap test.
2026-04-02 06:34:35 +03:00
Fringg 6f6b9fa039 Merge branch 'yazhog/main' into dev 2026-04-02 06:30:45 +03:00
Fringg 6713921887 fix: Pal24 card/sbp option not passed to API in cabinet balance topup
The payment_option (card/sbp) was parsed from the request but never
passed to create_pal24_payment as payment_method. Without it,
_normalize_payment_method(None) defaults to 'sbp', so both Card and
SBP buttons always created SBP payments.
2026-04-02 06:20:21 +03:00
Fringg 2d42152f54 fix: NameError in SeverPay guest payment flow
user variable was unbound when user_id=None (guest purchase path).
Added user=None in the else branch to prevent NameError when
constructing the fallback email.
2026-04-02 06:18:37 +03:00
Fringg 08ca947b2b fix: send telegram_id@telegram.org as email to SeverPay
SeverPay requires a non-empty client_email field. When user has no
email, fallback to {telegram_id}@telegram.org instead of empty string.
2026-04-02 06:16:17 +03:00
Fringg b04157c913 fix: notification sent for non-deactivated subs + webhook race condition
Two fixes for channel subscription enforcement:

1. Middleware notification guard: the deactivation notification was sent
   even when deactivated_subs was empty (no subs actually deactivated).
   Also fixed len(active_subs) -> len(deactivated_subs) for multi-tariff
   notification text selection.

2. Webhook echo race condition: when a user quickly leaves and rejoins
   a channel, the delayed user.disabled webhook from RemnaWave could
   re-deactivate a subscription that was already reactivated.
   Fix: stamp last_webhook_update_at on reactivation (both channel_member
   handler and middleware), then guard _handle_user_disabled against
   re-deactivating recently-reactivated ACTIVE subscriptions using the
   existing is_recently_updated_by_webhook (60s window).
2026-04-02 06:14:45 +03:00
Fringg f284351c51 fix: middleware disables panel VPN for all subs ignoring per-channel settings
In _deactivate_subscription_on_unsubscribe, the loop calling
disable_remnawave_user iterated over all active_subs instead of only
the subscriptions that passed the should_disable_subscription check.

This caused paid subscriptions to be disabled at the RemnaWave panel
level even when disable_paid_on_leave=False, while the DB record
stayed ACTIVE. On rejoin, reactivation found no DISABLED subs in DB
so enable_remnawave_user was never called — VPN stayed off permanently.

Fixed by collecting actually-deactivated subs into a separate list
and using that for both panel disable calls and notifications.
2026-04-02 06:09:39 +03:00
Fringg b607993854 fix: prevent nested state saves and None state loss in promo handler
Two bugs found during review of the previous FSM state restore fix:

1. Re-entering promo flow created nested _prev_data (unbounded growth).
   Now skips save if already in PromoCodeStates.waiting_for_code and
   strips _prev_ keys from saved data to prevent nesting.

2. _restore_previous_state used `if prev_state:` which treated saved
   None state (user at menu) same as "no saved state". Now uses a
   sentinel to distinguish the two cases, correctly restoring None
   state with its data instead of calling state.clear().
2026-04-02 05:59:28 +03:00
Fringg 246659032d fix: promo code activation destroys balance input FSM state
When a user was in BalanceStates.waiting_for_amount and activated a
promo code, process_promocode called state.clear() on all exit paths,
wiping the balance state. Typing the amount then hit the fallback
"Не понимаю эту команду" handler.

Now show_promocode_menu saves the previous FSM state/data before
entering promo flow, and _restore_previous_state restores it after
promo code processing completes.
2026-04-02 05:55:29 +03:00
Fringg 3dc72b00e7 fix: send telegram_id@telegram.org as email to Kassa AI
Kassa AI requires email in format {telegram_id}@telegram.org but we
were sending user_{order_id}@telegram.org as fallback when email was
not provided. Now the fallback uses user.telegram_id directly.
2026-04-02 05:49:01 +03:00
Fringg 033d0da5e0 fix: remove non-existent Platega method code 10, rename 11 to Карты (RUB)
Platega API defines: 2=СБП, 11=Карточный эквайринг, 12=Международная,
13=Крипто. Code 10 does not exist in their API but was defined in our
config as "Банковские карты (RUB)", while real code 11 was mislabeled
as "Банковские карты". This caused two card options to appear in the
admin panel, one of which didn't work.

- Removed code 10 from definitions, defaults, allowed set, .env.example
- Renamed code 11: "Банковские карты" → "Карты (RUB)"
- Removed redundant filter in handlers/balance/platega.py
- Updated tests to match
2026-04-02 05:44:24 +03:00
Fringg 991f0b43e1 fix: autopay failure notifications ignoring 6h cooldown
Two bugs caused users to receive autopay error notifications every
monitoring cycle (hourly) instead of respecting the 6-hour cooldown:

1. subtract_user_balance failure path had no cooldown check at all
2. cache.exists() silently returns False when Redis is disconnected,
   bypassing the try/except cooldown guard

Extracted shared _check_autopay_fail_cooldown / _set_autopay_fail_cooldown
methods with in-memory fallback dict that works even without Redis.
Added cleanup of expired in-memory entries in _cleanup_notification_cache.
2026-04-02 05:44:13 +03:00
c0mrade c9524cb703 Merge pull request #2832 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.43.1
2026-03-31 20:24:44 +03:00
github-actions[bot] 76d4a2124c chore(main): release 3.43.1 2026-03-31 17:20:24 +00:00
c0mrade 4fa230c07f Merge pull request #2831 from BEDOLAGA-DEV/dev
Release: dev → main
2026-03-31 20:19:53 +03:00
c0mrade d580a78403 Merge remote-tracking branch 'origin/main' into dev 2026-03-31 15:13:46 +03:00
c0mrade 312cc728a9 docs: add Platega partnership to README, highlight partner payment providers 2026-03-31 13:04:40 +03:00
yazhog b1820c651d Fix RemnaWave webhook deletion race 2026-03-30 15:56:45 +03:00
c0mrade 0c284b9e99 fix: use subscription-level remnawave_uuid in multi-tariff mode for sync and detail pages
In multi-tariff mode, remnawave_uuid lives on the subscription object,
not the user. The sync status and user detail endpoints were always
returning user.remnawave_uuid, causing some users to see no UUID.
2026-03-30 14:41:58 +03:00
c0mrade 72170b35f5 fix: prevent MissingGreenlet on subscription.tariff lazy load in webhook handlers
Replace unsafe getattr(subscription, 'tariff', None) with sa_inspect().dict.get()
to avoid triggering lazy loads after db.commit()/refresh() in async context.
2026-03-29 17:31:38 +03:00
171 changed files with 18932 additions and 5889 deletions
+85 -2
View File
@@ -13,11 +13,15 @@ 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
# Альтернативный URL сервера Telegram Bot API (для регионов где api.telegram.org заблокирован)
# Примеры: Cloudflare Worker, self-hosted telegram-bot-api (tdlib), любой совместимый прокси
# TELEGRAM_API_URL=https://your-telegram-proxy.workers.dev
# ===== СИСТЕМА ПОДДЕРЖКИ =====
# Включить меню поддержки в интерфейсе
SUPPORT_MENU_ENABLED=true
@@ -250,6 +254,18 @@ WEBHOOK_NOTIFY_DEVICES=true
# - Подходит для продажи готовых пакетов услуг
SALES_MODE=tariffs
# Управление сменой тарифа (для SALES_MODE=tariffs)
# UPGRADE / DOWNGRADE:
# true / true = все направления разрешены
# true / false = только повышение (на более дорогой тариф)
# false / true = только понижение (на более дешёвый тариф)
# false / false = смена тарифа полностью отключена
TARIFF_SWITCH_UPGRADE_ENABLED=true
TARIFF_SWITCH_DOWNGRADE_ENABLED=true
# Сброс привязанных устройств при продлении подписки (однократно при каждом продлении)
RESET_DEVICES_ON_RENEWAL=false
# ===== ТРИАЛ ПОДПИСКА =====
TRIAL_DURATION_DAYS=3
TRIAL_TRAFFIC_LIMIT_GB=10
@@ -614,7 +630,7 @@ PLATEGA_RETURN_URL=
PLATEGA_FAILED_URL=
PLATEGA_CURRENCY=RUB
# Список ID активных методов из кабинета Platega (через запятую)
PLATEGA_ACTIVE_METHODS=2,10,11,12,13
PLATEGA_ACTIVE_METHODS=2,11,12,13
PLATEGA_MIN_AMOUNT_KOPEKS=100
PLATEGA_MAX_AMOUNT_KOPEKS=100000000
PLATEGA_WEBHOOK_PATH=/platega-webhook
@@ -680,6 +696,70 @@ RIOPAY_WEBHOOK_PATH=/riopay-webhook
RIOPAY_SUCCESS_URL=
RIOPAY_FAIL_URL=
# ===== SEVERPAY (severpay.io) =====
SEVERPAY_ENABLED=false
# Merchant ID
SEVERPAY_MID=
# Секретный токен для HMAC-SHA256
SEVERPAY_TOKEN=
SEVERPAY_DISPLAY_NAME=SeverPay
SEVERPAY_CURRENCY=RUB
SEVERPAY_MIN_AMOUNT_KOPEKS=10000
SEVERPAY_MAX_AMOUNT_KOPEKS=10000000
SEVERPAY_WEBHOOK_PATH=/severpay-webhook
# URL возврата после оплаты
# SEVERPAY_RETURN_URL=
# Время жизни платежа в минутах (30-4320)
SEVERPAY_LIFETIME=1440
# ===== PAYPEAR (api.paypear.ru) =====
PAYPEAR_ENABLED=false
# Shop ID для HTTP Basic Auth
PAYPEAR_SHOP_ID=
# Secret Key для HTTP Basic Auth
PAYPEAR_SECRET_KEY=
PAYPEAR_DISPLAY_NAME=PayPear
PAYPEAR_CURRENCY=RUB
PAYPEAR_MIN_AMOUNT_KOPEKS=10000
PAYPEAR_MAX_AMOUNT_KOPEKS=10000000
PAYPEAR_WEBHOOK_PATH=/paypear-webhook
# URL возврата после оплаты
# PAYPEAR_RETURN_URL=
# Время жизни платежа в минутах
PAYPEAR_PAYMENT_LIFETIME_MINUTES=60
# ===== ROLLYPAY (rollypay.io) =====
ROLLYPAY_ENABLED=false
# API ключ (X-API-Key header)
ROLLYPAY_API_KEY=
# Секрет для HMAC-SHA256 верификации вебхуков
ROLLYPAY_SIGNING_SECRET=
ROLLYPAY_DISPLAY_NAME=RollyPay
ROLLYPAY_CURRENCY=RUB
ROLLYPAY_MIN_AMOUNT_KOPEKS=10000
ROLLYPAY_MAX_AMOUNT_KOPEKS=10000000
ROLLYPAY_WEBHOOK_PATH=/rollypay-webhook
# URL возврата после оплаты
# ROLLYPAY_RETURN_URL=
# ===== AURAPAY (aurapay.tech) =====
AURAPAY_ENABLED=false
# API ключ (X-ApiKey header)
AURAPAY_API_KEY=
# UUID магазина (X-ShopId header)
AURAPAY_SHOP_ID=
# Секретный ключ #2 для HMAC-SHA256 верификации вебхуков
AURAPAY_SECRET_KEY=
AURAPAY_DISPLAY_NAME=AuraPay
AURAPAY_CURRENCY=RUB
AURAPAY_MIN_AMOUNT_KOPEKS=10000
AURAPAY_MAX_AMOUNT_KOPEKS=10000000
AURAPAY_WEBHOOK_PATH=/aurapay-webhook
# URL возврата после оплаты
# AURAPAY_RETURN_URL=
# Время жизни инвойса в минутах
AURAPAY_PAYMENT_LIFETIME_MINUTES=60
# ===== WATA =====
WATA_ENABLED=false
WATA_BASE_URL=https://api.wata.pro
@@ -906,6 +986,9 @@ DEBUG=false
WEBHOOK_URL=
WEBHOOK_PATH=/webhook
WEBHOOK_SECRET_TOKEN=
# IP адрес сервера для setWebhook — Telegram будет использовать его напрямую без DNS резолва домена
# Необходимо в регионах где Telegram не может резолвить домены (РФ и др.)
# WEBHOOK_IP=
WEBHOOK_DROP_PENDING_UPDATES=true
WEBHOOK_MAX_QUEUE_SIZE=1024
WEBHOOK_WORKERS=4
Binary file not shown.

After

Width:  |  Height:  |  Size: 822 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

+13 -11
View File
@@ -26,36 +26,38 @@ jobs:
- name: Get version info
id: version
run: |
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
SHORT_SHA=$(git rev-parse --short HEAD)
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
# Определяем версию и теги
# Read base version from release-please manifest (single source of truth)
BASE_VERSION=$(jq -r '."."' .release-please-manifest.json)
if [[ $GITHUB_REF == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v3.7.0-$(git rev-parse --short HEAD)" # x-release-please-version
VERSION="v${BASE_VERSION}-${SHORT_SHA}"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
VERSION="v${BASE_VERSION}-dev-${SHORT_SHA}"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v3.7.0-pr-$(git rev-parse --short HEAD)" # x-release-please-version
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
VERSION="v${BASE_VERSION}-pr-${SHORT_SHA}"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-${SHORT_SHA}"
echo "🔀 Собираем PR версию: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tags=$TAGS" >> $GITHUB_OUTPUT
echo "should_push=${{ github.event_name != 'pull_request' }}" >> $GITHUB_OUTPUT
echo "=== Информация о сборке ==="
echo "Версия: $VERSION"
echo "Коммит: $(git rev-parse --short HEAD)"
echo "Коммит: $SHORT_SHA"
echo "Теги: $TAGS"
echo "Push: ${{ github.event_name != 'pull_request' }}"
echo "==========================="
+10 -7
View File
@@ -42,25 +42,28 @@ jobs:
- name: Get version info
id: version
run: |
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
SHORT_SHA=$(git rev-parse --short HEAD)
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
# Read base version from release-please manifest (single source of truth)
BASE_VERSION=$(jq -r '."."' .release-please-manifest.json)
if [[ $GITHUB_REF == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v3.7.0-$(git rev-parse --short HEAD)" # x-release-please-version
VERSION="v${BASE_VERSION}-${SHORT_SHA}"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
VERSION="v${BASE_VERSION}-dev-${SHORT_SHA}"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v3.7.0-pr-$(git rev-parse --short HEAD)" # x-release-please-version
VERSION="v${BASE_VERSION}-pr-${SHORT_SHA}"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
# Определяем, нужно ли пушить образ
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "should_push=false" >> $GITHUB_OUTPUT
echo "⚠️ PR - only build without push"
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.43.0"
".": "3.52.0"
}
+258
View File
@@ -1,5 +1,263 @@
# Changelog
## [3.52.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.51.0...v3.52.0) (2026-04-24)
### New Features
* admin bulk actions API — mass operations on users ([fb2773f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fb2773fee4f36fc517624d96243b731c94a3508d))
* bulk actions — campaign/partner filters, delete_user action ([d77fd81](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d77fd81e161a65ba848133ba4798a6a335e35c1c))
* bulk actions — SSE streaming progress, grant subscription, multi-tariff info ([c0e0756](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c0e0756b9ac15cd926708be3f2f9c93e571ad411))
* bulk delete_subscription action — removes from bot DB + RemnaWave ([605f202](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/605f202191d0203fdc7f5ea3bb0695ece47c9cd6))
* bulk set_devices action + device info in subscription list ([be787a8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/be787a85bfd981b70bd347c943f5cef8ec0610fc))
* FAQ support in info pages — page_type field + migration ([d394565](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d394565fe940549d1211a1ddab37f4d002d43dd5))
* info page tab replacement — replaces_tab field + API ([bdb8cab](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bdb8cab1c97328ff05218a9a68612418c69ccacf))
* information pages — CRUD model, admin API, public API ([e4b4a54](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e4b4a54797875b697146f8b804b3bbdfff4eb78b))
* multi-tariff bulk actions — subscription-level targeting ([e78177b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e78177b2fc34c895ab2b74705516af2a3fcbfb70))
* support multiple tariff_ids in user list filter ([0d2b1df](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0d2b1dfdc97d2f3058ef793c89beaa8da5709a4a))
### Bug Fixes
* /reorder route unreachable — move before /{page_id} path param ([122d12d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/122d12db20537cf0ad55cea5328f03aa3464b540))
* add subscription/tariff/promo_group filters to admin user list API ([daa4725](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/daa472570ccf6ef50441e0e173ddd1b87fe31e5e))
* always return subscriptions list in user list API ([cfbcc30](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cfbcc3082fca3a6101604aea197e7844974ed15d))
* bulk actions review — rollback on error, multi-tariff constraint checks ([5b45d43](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5b45d4354f6e58bd609e69a45ef100acfed3dbf4))
* bulk change_tariff not clearing squads when new tariff has none ([db7b673](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/db7b6734fdcae52397a25eb94e1b24e908fcc41c))
* bulk delete_user — pass real admin_id, sanitize error messages ([2e45a93](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2e45a93bd72c7e930fad60f7be2292596271c0dc))
* gift purchases no longer inflate promo group level ([1522d35](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1522d35f2dec0e35153c452818380f9b41b35f1d))
* info pages review — deduplicate slug index, type reorder items ([2071a68](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2071a680d3d569f67c447921370463f13c928c55))
* integrate Yandex Metrika offline conv + S2S postback hooks ([7d512d2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7d512d214a1ca184ba6601bf1ee5a39727ed41e5))
* MissingGreenlet in subscription-ids bulk actions ([2ad893b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2ad893badfec6f782ed89746b1cf2d3d7b07aa3a))
* privacy policy and offer text display HTML links as plain text ([59c54c9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/59c54c9b39d9cb26ed518cc74c93e1da43ce2020))
* restore HTTP Referer fallback for landing purchases ([24dc8d2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/24dc8d2a5e1be5239036acf5888acfe1e8e96444))
* restore referrer field in admin landing purchases response ([2cde38c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2cde38c63b35e52a552bf58c003a8281525a0be7))
* sanitize error messages in all bulk action catch-all handlers ([9217f41](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9217f4116f74d15133531a711cb2bc083f30dd49))
* server squad sync fails on fresh DB without default promo group ([ae7feeb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ae7feeb726ab0a9b3074c9ed11fd45e2cc412ea0))
* stop printing tracebacks for warning-level logs inside except blocks ([68d2350](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/68d2350dfdf2f1f6964dd744a447b558a1d06267))
* suppress 'User already enabled' traceback in bulk add_traffic ([ff41ea9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff41ea9abbdbeb6746e23cde48a5f962b289fc48))
### Documentation
* add Overpay to README with partner block ([bcf5519](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bcf5519880471d113833a5031cae54594e658a7b))
* add Overpay to README with partner block ([70568f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/70568f82c5c6c2c1041865c7109907fa9e2b8c6a))
## [3.51.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.50.0...v3.51.0) (2026-04-23)
### New Features
* integrate Overpay payment provider (pay.overpay.io) ([2c3ffc8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2c3ffc8c8a9e6591d2356320ed0a71ab5db6cbec))
* respond to unknown media messages (photos, videos, documents) ([29ae708](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/29ae7089aa0a8a5c7ddd73622a492df6b5e67e03))
### Bug Fixes
* inactive user cleanup deletes users with paid subscriptions ([7005052](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/70050521566ab86b75fb4f64ed3b61bced5d1612))
* pad short RemnaWave usernames to meet 3-char minimum ([6f87563](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6f875637899544d95daf8d87a30042b5347f6b41))
## [3.50.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.49.0...v3.50.0) (2026-04-22)
### New Features
* add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook ([#2894](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/issues/2894)) ([7093d36](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7093d368d3356c692bff3eddda68996579bfd036))
* landing page analytics goals and sticky pay button ([3272b4b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3272b4bb053c76c3995cd3904b085a28f741e815))
* tariff switch direction control, fix device pricing within tariff limit ([9ed4f08](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ed4f086b0102c20d7861e03f4d212ff57e28245))
* **tickets:** multi-media message gallery (media_items JSONB) ([36571c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/36571c4275b76fa0bdb490ffccacc0a0e32e9bd6))
* v3.50.0 release ([a491fe3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a491fe34bda6da86d72891fe4d05b6abd169cf2f))
* Yandex Metrika offline conversions + S2S postbacks ([1068c13](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1068c1387a03bf7c94c7b29f9bb61acc3e3e782a))
### Bug Fixes
* classic mode renewal resets device_limit to 1 via cart key mismatch ([9ca3320](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ca3320a0204346aac2c76601f221b0bc70226a7))
* do not reset subscription_crypto_link when cryptoLink absent in webhook ([#2891](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/issues/2891)) ([b71e58c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b71e58c8d25a5c94c94a401a59359cab4870e3c0))
* do not update first_name/last_name from OIDC claims ([#2892](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/issues/2892)) ([1696e6f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1696e6f8843ceaa1a3e9a6d536e0d99968e579b1))
* FSM state loss on balance topup, PayPear confirmation_url, hidden trial tariff in renewal ([7be404b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7be404b918640dcccdcda82802bb02a342b730d6))
* grant all available squads for unrestricted trials ([#2897](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/issues/2897)) ([905cea6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/905cea68b48b596412d83c42de0d1dce77dcaee0))
* menu layout schema icon limit, traffic_topup_enabled condition, shadowing imports ([66f8577](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/66f8577448712786d851b1802c4a183e7acc1779))
* tariff switch pricing showing free for upgrades, admin duplicate subscription guard ([da855a7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da855a7c8955dcbf3543dbbaedc00e16b9c35193))
* ticket media_items review fixes ([dd17710](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dd177101f7314c5d25120a69e04c66d74f37b18f))
* validate analytics goal is set when analytics is enabled on landing ([d316325](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d31632534b3e1b2268a8f8b2a441214f377f23eb))
* устранить MissingGreenlet в автоплатежах и починить traceback в логах ([db79cc9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/db79cc9eb0d7dc7a4a1ae9190f7a23c6c9e6e317))
* устранить root cause MissingGreenlet в автоплатежах через refetch по id ([3b03c25](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3b03c253cc1603fe54fddcbc8bc374ec2c71bdfc))
## [3.49.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.48.0...v3.49.0) (2026-04-18)
### New Features
* integrate AuraPay payment provider ([9717936](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97179360c0288940b1fa2f6c21a6e1431a27536f))
### Bug Fixes
* add missing RollyPay CRUD wrappers and guest payment flow ([0f814be](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0f814be1b7dfaec84dde9acc402b2c1790417611))
* align campaign top registrations revenue with period comparison ([16bc1d4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/16bc1d41989d66e105724ed846fb40ccf03322fd))
* handle edge case when all tariffs are daily in legacy renewal ([29877fc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/29877fc93bc612ee199ccb2438c90e57a3c1e9e0))
* rate-limit daily subscription insufficient balance notifications to 6 hours ([ecc4a61](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ecc4a6147dad0c8886acd48f38e567a6c7fc8916))
* redirect legacy users without tariff to tariff selection on renewal ([5986c00](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5986c00fab8c5fe2060d296afca72d28038ff7bd))
* register PayPear and RollyPay in admin panel settings ([2aa5927](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2aa59274331610eec3cd84f90ddb49ee59da22ef))
### Documentation
* add AuraPay to README with partner block ([25ea5c6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25ea5c60fdaf3cac9294b6df74142c176b1d4d04))
* add PayPear and RollyPay to README with partner blocks ([1c696c6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1c696c69e34e668ff90c915249b7c3dc37bfd89b))
* add PayPear and RollyPay to README with partner blocks ([b531959](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b53195998231d34b6e1c7165193e87fee6e5c293))
## [3.48.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.47.0...v3.48.0) (2026-04-16)
### New Features
* integrate PayPear payment provider ([a18f6ca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a18f6caa9bd9c08511c464e6141fb8aa614135b0))
* integrate RollyPay payment provider (SBP via USDT) ([ccc2f4e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ccc2f4efecf0a3c1a943971c81a9a7d1985ae14a))
### Bug Fixes
* increase nalogo receipt queue retry window to 12 hours ([92eaf45](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/92eaf4531162e5625b938bafc43eb98abe2632e2))
* low balance alerts disabled by default, add quiet hours, expiry filter, top-up button ([2d5afe5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2d5afe5d75ff65f4d167a853e078943d518a881f))
* show menu buttons for limited (traffic exhausted) subscriptions ([0c54549](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0c545490b61baec930f10adc86652a7e5cf5d378))
* show menu buttons for limited subscriptions in back-to-menu paths ([61cf495](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/61cf495fc5919e699aba7231f49222722da044b4))
* support payment_method selection for RollyPay (sbp/card/crypto) ([a598582](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a59858227f31bd53d0ac54d693288ad52e447687))
### Documentation
* add SEVERPAY, PAYPEAR, ROLLYPAY to .env.example ([25447ed](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25447edc9eab7c3a76ed94be52c1b341917a40c2))
## [3.47.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.46.1...v3.47.0) (2026-04-15)
### New Features
* multi-tariff sync fix, daily discount fix, campaign links, TELEGRAM_API_URL ([4db9e85](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4db9e850629f25cbcb11bb5ba0e0de5c580ca115))
## [3.46.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.46.0...v3.46.1) (2026-04-13)
### Bug Fixes
* add checkfirst guards to cabinet_refresh_tokens migration ([8587f03](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8587f03f67d7a451b07a4d9c450bc4524f4ac0e7))
* add missing migration for cabinet_refresh_tokens table ([4707cdf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4707cdf60c9d163c1191719b3b1fc4a17ae993d2))
* cabinet_refresh_tokens migration + notification_settings jsonb ([0274738](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02747381dce2b96af7f97b5f41d6acff5d9d8fd3))
* change notification_settings from json to jsonb for DISTINCT compatibility ([e74fda9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e74fda954ccc63e2ac7a30933d9f9ac26772b25d))
## [3.46.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.45.2...v3.46.0) (2026-04-13)
### New Features
* add broadcast category (system/news/promo) + filter recipients by user prefs ([931abfe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/931abfe7a5a7fb70e9638fbf6b566fa8d1a837e4))
* add category field to broadcast API schemas and routes ([0300044](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0300044b009f3e4b3aa3928652dfaf261a387dbc))
* add RemnaWave retry queue for failed API calls (BUG-2, BUG-10) ([abdf296](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/abdf2967675975e90f0c4d834f129281c1c28e7b))
* add remnawave_resync_service for identity-change sync ([b57f185](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b57f185258be050d945cec5989ad6dc710980a6a))
* add traffic % warning check using user's threshold preference ([1d96f80](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1d96f80f60ca445eb5108e7bc54e00d022a4cc9e))
* add user notification preferences helper utility ([e0e2edf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e0e2edf81659fbeea361046d1bb2718c2149d884))
* implement low balance alert + respect user notification preferences ([4e50419](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4e50419171176ee452371ff095bdfead3879e554))
* respect user subscription_expiry notification preferences ([63fdfe4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/63fdfe4a421942b26caca35d8bd9b1d65f1fe7e2))
* respect user traffic_warning notification preference in webhook handler ([7208a52](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7208a52c9424d39757187fc86eec2c3460a2cdbb))
* save campaign_slug during standalone email registration ([a8e2b62](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a8e2b62f4bb0833ca32446b34ad4e0c5615fcd2a))
* start RemnaWave retry queue on app startup ([8f1882f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8f1882f24c7d066e2d0fc756f38ce23bf082687e))
### Bug Fixes
* add retry queue to all remaining RemnaWave error handlers ([7e920fa](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7e920fa30fc8e61ed2dd31e7a09151d57b7361ca))
* add retry queue to cabinet subscription operation RemnaWave errors ([1b376ba](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b376baeca120970b1ffcd406b1bbf7d9d43cee0))
* add retry queue to classic mode bot purchase handler ([970dc54](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/970dc549dfa06ba9945d5bd861374058acdca86b))
* add retry queue to daily subscription service RemnaWave errors ([65120f0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/65120f0badc9a4581ba0a127acdae8a0b23e8501))
* add retry queue to payment webhook and renewal service RemnaWave errors ([91a756a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/91a756a33ed4ce685bdf485cdb4e91c3e08799dd))
* add TRAFFIC_WARNING_ALERT and LOW_BALANCE_ALERT localization keys to all locales ([2321667](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2321667ecbe76bfe8dd37213e0bb4e45104e0fc5))
* always sync squads in auto-purchase renewal (BUG-4) ([8542a39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8542a393055a93d320d5c8c6d3aa7cc291cf8def))
* default sync_squads=True in update_remnawave_user (BUG-4) ([6aed7d3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6aed7d355bc47c4dbd2aa761d78a5e5421c32edf))
* enforce max_attempts limit in NaloGO receipt queue ([16d9163](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/16d91638bc149c5eee8f4cdd266cbd195c411030))
* enqueue retry on RemnaWave API failure in all purchase flows (BUG-2, BUG-10) ([9cb559f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9cb559ff3994c0f1c6ba48a4ec09dec9b391e48b))
* exclude users with active subscriptions from expired broadcast ([1eeeb39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1eeeb39779982ebb2700cb7523760631229407da))
* handle TelegramBadRequest when deleting old ticket notifications ([eb18b3a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eb18b3a0f9ac3a617a1b21d3293e1619980a2a71))
* match tariff_id when creating subscriptions from panel sync (BUG-11) ([646ac4c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/646ac4cfa18f738040fbc6498c5d86c1546e2b9a))
* protect OAuth users with remnawave_uuid from sync deactivation (BUG-6) ([cf19e4e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cf19e4e1f7148b21d9a8072f7a0b4ae97fd04e8a))
* raise MAX_BUTTONS_PER_ROW to 8 and allow tg:// deep links in menu editor ([570af82](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/570af82dfdec980f42be96c8f816e1677f896f81))
* resync RemnaWave after account merge (BUG-7) ([9c08ce6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9c08ce69485b78f8fe818500e05ab6995115166a))
* resync RemnaWave after Telegram account linking (BUG-1) ([d465ccb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d465ccb3ac3a86add6313d6bb61d53d0fe143d5e))
* sync connected_squads from panel during sync (BUG-5) ([35412e9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/35412e9f215680c0fdf1c55b5bf23496f662935c))
* trial activation fallback to trial-eligible servers when tariff has no squads (BUG-12) + fix misleading button text ([be32010](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/be32010d63966498bc6216823a75d328346d9a37))
* upsert refresh tokens (ON CONFLICT) + periodic cleanup of expired/revoked tokens ([fb8d2b3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fb8d2b3ee4566823840100b96fe2f3bc7d41edb7))
* use 'is not None' for telegram_id in create_user API (BUG-9) ([8623521](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/862352139e8a3545e144b0618fed5011470a9a67))
* use MAX_DEVICES_LIMIT instead of hardcoded 10 for device buttons ([bc3893b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bc3893b934f0d4e5059eedbd03cdfbc628852ac1))
* use update_remnawave_user when UUID exists in tariff_purchase (BUG-3) ([a1b6d9b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a1b6d9bb619ec3de038647fe6f2e5d38979298a8))
## [3.45.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.45.1...v3.45.2) (2026-04-08)
### Bug Fixes
* batch bug fixes from user complaints ([31adcfd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/31adcfded4b161bf515d4d6b25b4395e543208f4))
* batch bug fixes from user complaints ([78f963b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/78f963bf5e7b3439d7614584c4041f88be5beb4a))
* исправление парсинга черного списка (поддержка '#' и извлечение username) ([357d94d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/357d94d1b0d7fc8036b00cbb6b75175c29821751))
* исправление парсинга черного списка (поддержка '#' и извлечение username) ([2f71846](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f7184627a0fb598a8c0208905cdecf0e4bb04a7))
## [3.45.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.45.0...v3.45.1) (2026-04-03)
### Bug Fixes
* add missing WEBHOOK_TORRENT_DETECTED mapping + dedup before uniq… ([4165eae](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4165eaea7adfdaf683b1ece16c8c93a9c4ed216d))
* add missing WEBHOOK_TORRENT_DETECTED mapping + dedup before unique index in migration 0053 ([3b5d5a1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3b5d5a18a1122ef50868fd038a09109d17795a74))
## [3.45.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.44.0...v3.45.0) (2026-04-03)
### New Features
* send torrent blocker notification to user (not just admin) ([2f9d003](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f9d00343bee2980cc89bd24361259073b97127a))
### Bug Fixes
* resolve multiple subscription bugs — LIMITED status, trial tariff blocking, traffic reset strategy, classic mode pricing, 100% discount support ([9b7ac47](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9b7ac47f16076e546da62062ff7ce18d7c308988))
* restore missing import + rewrite user.deleted webhook to properly deactivate all subscriptions ([819f09a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/819f09a68ec95237294bae97f31c644044a3623f))
* subscription system bugfixes + torrent notifications + user deletion cleanup ([7d24e8d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7d24e8d7047c7a3a1c417e655a6fbccbe5ae577d))
## [3.44.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.43.1...v3.44.0) (2026-04-02)
### New Features
* add SberPay as KassaAI sub-method (payment_system_id=43) ([9d63635](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d636355026ad1e50d045e78ffa21e76cfef0774))
### Bug Fixes
* address review issues in PR [#2829](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/issues/2829) webhook intentional deletion guard ([977950b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/977950b97f07eecf089152d3f4e678fda373e1e6))
* autopay failure notifications ignoring 6h cooldown ([991f0b4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/991f0b43e1e73446690a4fbec7c5c5642ac8c406))
* middleware disables panel VPN for all subs ignoring per-channel settings ([f284351](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f284351c51a6843db0771a92338ec770d5f0d8d2))
* NameError in SeverPay guest payment flow ([2d42152](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2d42152f5491b14cc45388e0ffccf8a61848a2f6))
* notification sent for non-deactivated subs + webhook race condition ([b04157c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b04157c91327d9031e9f603a6ad33c708e27d753))
* Pal24 card/sbp option not passed to API in cabinet balance topup ([6713921](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/67139218878dca3e75974eb5b5a2ce91d5b1438e))
* prevent nested state saves and None state loss in promo handler ([b607993](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b607993854d1374e7d7c2afbb7fe5cc8824732f5))
* promo code activation destroys balance input FSM state ([2466590](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/246659032de812f1d4502029ab139f3104237d5c))
* remove non-existent Platega method code 10, rename 11 to Карты (RUB) ([033d0da](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/033d0da5e0033a2310431586a291b529e3ccb89a))
* send telegram_id@telegram.org as email to Kassa AI ([3dc72b0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3dc72b00e751a69966d2d5830492c82e055b72e6))
* send telegram_id@telegram.org as email to SeverPay ([08ca947](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/08ca947b2b2bb29782c86e7b5d6bea71e2811751))
## [3.43.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.43.0...v3.43.1) (2026-03-31)
### Bug Fixes
* prevent MissingGreenlet on subscription.tariff lazy load in webhook handlers ([72170b3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/72170b35f5d2af56aa7dcb579a70ecf6af2da3f6))
* use subscription-level remnawave_uuid in multi-tariff mode for sync and detail pages ([0c284b9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0c284b9e9941b516170fc68a3f63551096cb5a7b))
### Documentation
* add Platega partnership to README, highlight partner payment providers ([312cc72](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/312cc728a9321fe9ac90cf1f5201e38465f67f16))
## [3.43.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.42.0...v3.43.0) (2026-03-29)
+1 -1
View File
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
FROM python:3.13-slim
ARG VERSION="v3.43.0" # x-release-please-version
ARG VERSION="v3.52.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+71 -4
View File
@@ -55,7 +55,7 @@ Bedolaga — полнофункциональная платформа для п
### 💳 Платежи
- 🏦 **15 платёжных провайдеров** одновременно
- 🏦 **18 платёжных провайдеров** одновременно
- 💰 Единый баланс: пополнение любым способом → покупка с баланса
- ⚡ Автопокупка подписки после пополнения
- 💾 Рекуррентные платежи (сохранённые карты)
@@ -118,15 +118,21 @@ Bedolaga — полнофункциональная платформа для п
| 💳 | **Freekassa** | NSPK СБП, карты | RUB |
| 💳 | **Kassa AI** | СБП, карты, SberPay | RUB |
| 💳 | **PayPalych (Pal24)** | Карты, СБП | RUB |
| 💳 | **Platega** | Карты, СБП, крипто | RUB |
| 💳 | **WATA** | СБП, Карты | RUB |
| 🤝 | **[Platega](https://t.me/ArstanPlatega)** 🔸 | Карты, СБП, крипто | RUB |
| 🤝 | **[WATA](https://t.me/wyrz_wata)** 🔸 | СБП, Карты | RUB |
| 💳 | **MulenPay** | Карты | RUB |
| 💳 | **RioPay** | Карты | RUB |
| 💳 | **SeverPay** | СБП, карты | RUB |
| 🤝 | **[PayPear](https://t.me/Paymen1_Manager)** 🔸 | Карты, СБП, SberPay, T-Pay | RUB |
| 🤝 | **[RollyPay](https://rollypay.io/?utm_source=bedolaga&utm_medium=community&utm_campaign=integration)** 🔸 | СБП, карты, крипто | RUB → USDT |
| 🤝 | **[AuraPay](https://aurapay.tech/)** 🔸 | Карты, СБП | RUB |
| 🤝 | **[Overpay](https://overpay.pro/)** 🔸 | Карты, СБП | RUB |
| 📲 | **Tribute** | Telegram-платежи | RUB |
</div>
> 🔸 — официальный партнёр Bedolaga (особые условия по кодовому слову **`bedolaga`**)
>
> Все провайдеры работают параллельно через единый веб-сервер на порту 8080. Подробная настройка — в [документации](https://docs.bedolagam.ru/bot/payments).
<div align="center">
@@ -134,6 +140,18 @@ Bedolaga — полнофункциональная платформа для п
<tr>
<td align="center">
<img src=".github/assets/platega-logo.jpg" alt="Platega" width="60" />
**🤝 Официальный партнёр Platega**
Bedolaga — официальный партнёр платёжной системы **Platega**.<br>
Пользователи бота получают **особые условия** при подключении по кодовому слову **`bedolaga`**
📩 По вопросам: [@ArstanPlatega](https://t.me/ArstanPlatega)
</td>
<td align="center">
<img src=".github/assets/wata-logo.jpg" alt="WATA" width="60" />
**🤝 Официальный партнёр WATA**
@@ -143,6 +161,55 @@ Bedolaga — официальный партнёр платёжной систе
📩 По вопросам: [@wyrz_wata](https://t.me/wyrz_wata)
</td>
</tr>
<tr>
<td align="center">
**🤝 Официальный партнёр PayPear**
Bedolaga — официальный партнёр платёжной системы **[PayPear](https://paypear.ru)**.<br>
Банковские карты, СБП, SberPay и T-Pay — всё через единый API.<br>
Подключение по **спец. условиям** через кодовое слово **`БЕДОЛАГА`**
📩 Менеджер: [@Paymen1_Manager](https://t.me/Paymen1_Manager)
</td>
<td align="center">
**🤝 Официальный партнёр RollyPay**
Bedolaga — официальный партнёр платёжного шлюза **[RollyPay](https://rollypay.io/?utm_source=bedolaga&utm_medium=community&utm_campaign=integration)**.<br>
СБП (от 5%), банковские карты РФ, крипто, вывод в USDT.<br>
Универсальная форма оплаты, высокая проходимость, стабильная работа в каскаде.<br>
Подключение по кодовому слову **`БЕДОЛАГА`** — **спец. условия**
📩 Менеджер: [@rollypay_manager](https://t.me/rollypay_manager) | 🌐 [rollypay.io](https://rollypay.io/?utm_source=bedolaga&utm_medium=community&utm_campaign=integration)
</td>
</tr>
<tr>
<td align="center">
**🤝 Официальный партнёр AuraPay**
Bedolaga — официальный партнёр платёжной системы **[AuraPay](https://aurapay.tech/)**.<br>
Банковские карты и СБП через единый API с быстрой интеграцией.<br>
Подключение по кодовому слову **`БЕДОЛАГА`** — **спец. условия**
📩 Менеджер: [@kickdownm](https://t.me/kickdownm) | 🌐 [aurapay.tech](https://aurapay.tech/)
</td>
<td align="center">
**🤝 Официальный партнёр Overpay**
Bedolaga — официальный партнёр платёжного шлюза **[Overpay](https://overpay.pro/)**.<br>
Банковские карты и СБП, mTLS-авторизация, HPP-интеграция.<br>
Подключение по кодовому слову **`БЕДОЛАГА`** — **спец. условия**
📩 Менеджер: [@A_OverPay](https://t.me/A_OverPay) | 🌐 [overpay.pro](https://overpay.pro/)
</td>
</tr>
</table>
@@ -208,7 +275,7 @@ docker compose up -d
| | Раздел | Описание |
|:---:|:---|:---|
| 🚀 | [Быстрый старт](https://docs.bedolagam.ru/getting-started/quickstart) | Развёртывание за 5 минут |
| 💳 | [Настройка платежей](https://docs.bedolagam.ru/bot/payments) | 14 провайдеров, webhook, фискализация |
| 💳 | [Настройка платежей](https://docs.bedolagam.ru/bot/payments) | 18 провайдеров, webhook, фискализация |
| 📦 | [Подписки и тарифы](https://docs.bedolagam.ru/bot/subscriptions) | Конфигурация планов и трафика |
| 👥 | [Реферальная программа](https://docs.bedolagam.ru/bot/referral-program) | Партнёрка и вывод средств |
| 🖥 | [Cabinet](https://docs.bedolagam.ru/cabinet/overview) | Настройка веб-кабинета |
+16
View File
@@ -280,12 +280,28 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
except Exception as e:
logger.warning('Failed to load menu layout cache', error=e)
try:
from app.services.remnawave_retry_queue import remnawave_retry_queue
await remnawave_retry_queue.start()
logger.info('RemnaWave retry queue запущен')
except Exception as e:
logger.error('Ошибка запуска RemnaWave retry queue', error=e)
logger.info('Бот успешно настроен')
return bot, dp
async def shutdown_bot():
try:
from app.services.remnawave_retry_queue import remnawave_retry_queue
await remnawave_retry_queue.stop()
logger.info('RemnaWave retry queue остановлен')
except Exception as e:
logger.error('Ошибка остановки RemnaWave retry queue', error=e)
try:
await maintenance_service.stop_monitoring()
logger.info('Мониторинг техработ остановлен')
+12 -4
View File
@@ -1,4 +1,4 @@
"""Factory for creating Bot instances with proxy support."""
"""Factory for creating Bot instances with proxy and custom API server support."""
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
@@ -8,13 +8,21 @@ 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."""
"""Create a Bot instance with SOCKS5 proxy and/or custom Telegram API server."""
proxy_url = settings.get_proxy_url()
telegram_api_url = settings.get_telegram_api_url()
session = None
if proxy_url:
if proxy_url or telegram_api_url:
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.client.telegram import TelegramAPIServer
session = AiohttpSession(proxy=proxy_url)
session_kwargs: dict = {}
if proxy_url:
session_kwargs['proxy'] = proxy_url
if telegram_api_url:
session_kwargs['api'] = TelegramAPIServer.from_base(telegram_api_url)
session = AiohttpSession(**session_kwargs)
kwargs.setdefault('default', DefaultBotProperties(parse_mode=ParseMode.HTML))
return Bot(token=token or settings.BOT_TOKEN, session=session, **kwargs)
+2 -1
View File
@@ -195,7 +195,8 @@ async def _get_jwks(force: bool = False) -> dict[str, Any]:
if not force and _jwks_cache and _jwks_cache_expiry and now < _jwks_cache_expiry:
return _jwks_cache
async with httpx.AsyncClient(timeout=10) as client:
proxy = settings.PROXY_URL if hasattr(settings, 'PROXY_URL') and settings.PROXY_URL else None
async with httpx.AsyncClient(timeout=10, proxy=proxy) as client:
response = await client.get(_JWKS_URL)
response.raise_for_status()
_jwks_cache = response.json()
+6
View File
@@ -7,10 +7,12 @@ from .admin_apps import router as admin_apps_router
from .admin_audit_log import router as admin_audit_log_router
from .admin_ban_system import router as admin_ban_system_router
from .admin_broadcasts import router as admin_broadcasts_router
from .admin_bulk_actions import router as admin_bulk_actions_router
from .admin_button_styles import router as admin_button_styles_router
from .admin_campaigns import router as admin_campaigns_router
from .admin_channels import router as admin_channels_router
from .admin_email_templates import router as admin_email_templates_router
from .admin_info_pages import router as admin_info_pages_router
from .admin_landings import router as admin_landings_router
from .admin_menu_layout import router as admin_menu_layout_router
from .admin_news import router as admin_news_router
@@ -44,6 +46,7 @@ from .branding import router as branding_router
from .contests import router as contests_router
from .gift import router as gift_router
from .info import router as info_router
from .info_pages import router as info_pages_router
from .landing import router as landing_router
from .media import router as media_router
from .news import router as news_router
@@ -93,6 +96,7 @@ router.include_router(branding_router)
router.include_router(landing_router)
router.include_router(media_router)
router.include_router(news_router)
router.include_router(info_pages_router)
# Wheel routes
router.include_router(wheel_router)
@@ -118,6 +122,7 @@ router.include_router(admin_campaigns_router)
router.include_router(admin_partners_router)
router.include_router(admin_withdrawals_router)
router.include_router(admin_users_router)
router.include_router(admin_bulk_actions_router)
router.include_router(admin_payment_methods_router)
router.include_router(admin_landings_router)
router.include_router(admin_payments_router)
@@ -140,6 +145,7 @@ router.include_router(admin_news_categories_router)
router.include_router(admin_news_tags_router)
router.include_router(admin_news_media_router)
router.include_router(admin_news_router)
router.include_router(admin_info_pages_router)
# WebSocket route
router.include_router(websocket_router)
+37
View File
@@ -622,6 +622,24 @@ async def link_telegram(
telegram_id=telegram_id,
user_id=user.id,
)
# BUG-1 fix: Sync all subscriptions with RemnaWave panel so it knows the new telegram_id
try:
from app.services.remnawave_resync_service import resync_user_subscriptions_with_panel
resync_result = await resync_user_subscriptions_with_panel(db, user)
logger.info(
'Post-TG-link resync completed',
user_id=user.id,
telegram_id=telegram_id,
synced=resync_result['synced'],
failed=resync_result['failed'],
)
except Exception as resync_error:
logger.error(
'Post-TG-link resync failed (non-fatal)',
user_id=user.id,
error=resync_error,
)
return LinkCallbackResponse(success=True, message='linked')
@@ -867,6 +885,25 @@ async def execute_merge_endpoint(
detail='Failed to load merged user',
)
# BUG-7 fix: Resync merged user's subscriptions with RemnaWave panel
try:
from app.services.remnawave_resync_service import resync_user_subscriptions_with_panel
resync_result = await resync_user_subscriptions_with_panel(db, merged_user)
logger.info(
'Post-merge resync completed',
primary_user_id=primary_user_id,
secondary_user_id=secondary_user_id,
synced=resync_result['synced'],
failed=resync_result['failed'],
)
except Exception as resync_error:
logger.error(
'Post-merge resync failed (non-fatal)',
primary_user_id=primary_user_id,
error=resync_error,
)
# 5. Create auth tokens for the merged user
try:
auth_response = await _create_auth_response(merged_user, db)
+5
View File
@@ -141,6 +141,7 @@ def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
created_at=broadcast.created_at,
completed_at=broadcast.completed_at,
progress_percent=progress,
category=getattr(broadcast, 'category', 'system') or 'system',
channel=getattr(broadcast, 'channel', 'telegram') or 'telegram',
email_subject=getattr(broadcast, 'email_subject', None),
email_html_content=getattr(broadcast, 'email_html_content', None),
@@ -432,6 +433,7 @@ async def create_broadcast(
status='queued',
admin_id=admin.id,
admin_name=admin.username or f'Admin #{admin.id}',
category=request.category,
)
db.add(broadcast)
await db.commit()
@@ -454,6 +456,7 @@ async def create_broadcast(
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,
category=request.category,
)
# Start broadcast
@@ -626,6 +629,7 @@ async def create_combined_broadcast(
status='queued',
admin_id=admin.id,
admin_name=admin_name,
category=request.category,
channel=request.channel,
email_subject=request.email_subject.strip() if request.email_subject else None,
email_html_content=request.email_html_content.strip() if request.email_html_content else None,
@@ -653,6 +657,7 @@ async def create_combined_broadcast(
media=media_config,
initiator_name=admin_name,
custom_buttons=[btn.model_dump() for btn in request.custom_buttons] if request.custom_buttons else None,
category=request.category,
)
await broadcast_service.start_broadcast(broadcast.id, telegram_config)
File diff suppressed because it is too large Load Diff
+219
View File
@@ -0,0 +1,219 @@
"""Admin routes for managing info pages in cabinet."""
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.info_pages import (
clear_replaces_tab,
create_info_page,
delete_info_page,
get_all_info_pages,
get_info_page_by_id,
reorder_info_pages,
update_info_page,
)
from app.database.models import User
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.info_pages import (
InfoPageCreateRequest,
InfoPageListItem,
InfoPageResponse,
InfoPageUpdateRequest,
ReorderRequest,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/info-pages', tags=['Cabinet Admin Info Pages'])
@router.get('', response_model=list[InfoPageListItem])
async def list_all_info_pages(
page_type: str | None = Query(None, pattern=r'^(page|faq)$'),
admin: User = Depends(require_permission('settings:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> list[InfoPageListItem]:
"""Get all info pages (admin view, includes inactive)."""
try:
pages = await get_all_info_pages(db, include_inactive=True, page_type=page_type)
return [InfoPageListItem.model_validate(p) for p in pages]
except HTTPException:
raise
except Exception:
logger.exception('Failed to list info pages')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load info pages',
)
@router.get('/{page_id}', response_model=InfoPageResponse)
async def get_info_page_detail(
page_id: int,
admin: User = Depends(require_permission('settings:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Get a single info page by ID (admin view)."""
page = await get_info_page_by_id(db, page_id)
if not page:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Info page not found',
)
return InfoPageResponse.model_validate(page)
@router.post('', response_model=InfoPageResponse, status_code=status.HTTP_201_CREATED)
async def create_page(
request: InfoPageCreateRequest,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Create a new info page."""
try:
if request.replaces_tab:
await clear_replaces_tab(db, request.replaces_tab)
page = await create_info_page(
db,
slug=request.slug,
title=request.title,
content=request.content,
page_type=request.page_type,
is_active=request.is_active,
sort_order=request.sort_order,
icon=request.icon,
replaces_tab=request.replaces_tab,
)
except IntegrityError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='An info page with this slug already exists',
)
except Exception:
logger.exception('Failed to create info page')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create info page',
)
return InfoPageResponse.model_validate(page)
@router.put('/{page_id}', response_model=InfoPageResponse)
async def update_page(
page_id: int,
request: InfoPageUpdateRequest,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Update an existing info page."""
existing = await get_info_page_by_id(db, page_id)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Info page not found',
)
try:
update_data = request.model_dump(exclude_unset=True)
replaces_tab = update_data.get('replaces_tab')
if replaces_tab is not None:
await clear_replaces_tab(db, replaces_tab, exclude_page_id=page_id)
page = await update_info_page(db, page_id, **update_data)
except IntegrityError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='An info page with this slug already exists',
)
except Exception:
logger.exception('Failed to update info page', page_id=page_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to update info page',
)
if not page:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Info page not found after update',
)
return InfoPageResponse.model_validate(page)
@router.delete('/{page_id}', status_code=status.HTTP_204_NO_CONTENT)
async def remove_page(
page_id: int,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> None:
"""Delete an info page."""
existing = await get_info_page_by_id(db, page_id)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Info page not found',
)
try:
await delete_info_page(db, page_id)
except Exception:
logger.exception('Failed to delete info page', page_id=page_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to delete info page',
)
@router.post('/reorder', status_code=status.HTTP_204_NO_CONTENT)
async def reorder_pages(
request: ReorderRequest,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> None:
"""Bulk update sort_order for info pages."""
try:
await reorder_info_pages(db, request.items)
except Exception:
logger.exception('Failed to reorder info pages')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to reorder info pages',
)
@router.post('/{page_id}/toggle-active', response_model=InfoPageResponse)
async def toggle_active(
page_id: int,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Toggle the active status of an info page."""
existing = await get_info_page_by_id(db, page_id)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Info page not found',
)
try:
page = await update_info_page(db, page_id, is_active=not existing.is_active)
except Exception:
logger.exception('Failed to toggle info page active status', page_id=page_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to toggle active status',
)
if not page:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Info page not found after toggle',
)
return InfoPageResponse.model_validate(page)
+36
View File
@@ -205,6 +205,19 @@ class LandingCreateRequest(BaseModel):
discount_ends_at: datetime | None = None
discount_badge_text: dict[str, str] | None = None
background_config: dict | None = None
sticky_pay_button: bool = False
analytics_view_enabled: bool = False
analytics_view_goal: str | None = Field(default=None, max_length=64)
analytics_click_enabled: bool = False
analytics_click_goal: str | None = Field(default=None, max_length=64)
@model_validator(mode='after')
def validate_analytics_goals(self) -> 'LandingCreateRequest':
if self.analytics_view_enabled and not self.analytics_view_goal:
raise ValueError('analytics_view_goal is required when analytics_view_enabled is True')
if self.analytics_click_enabled and not self.analytics_click_goal:
raise ValueError('analytics_click_goal is required when analytics_click_enabled is True')
return self
@field_validator('background_config')
@classmethod
@@ -314,6 +327,11 @@ class LandingUpdateRequest(BaseModel):
discount_ends_at: datetime | None = None
discount_badge_text: dict[str, str] | None = None
background_config: dict | None = None
sticky_pay_button: bool | None = None
analytics_view_enabled: bool | None = None
analytics_view_goal: str | None = Field(default=None, max_length=64)
analytics_click_enabled: bool | None = None
analytics_click_goal: str | None = Field(default=None, max_length=64)
@field_validator('background_config')
@classmethod
@@ -461,6 +479,11 @@ class LandingDetailResponse(BaseModel):
discount_ends_at: datetime | None = None
discount_badge_text: dict[str, str] | None = None
background_config: dict | None = None
sticky_pay_button: bool = False
analytics_view_enabled: bool = False
analytics_view_goal: str | None = None
analytics_click_enabled: bool = False
analytics_click_goal: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
@@ -527,6 +550,7 @@ class LandingPurchaseItem(BaseModel):
currency: str
payment_method: str | None = None
status: str
referrer: str | None = None
created_at: datetime | None = None
paid_at: datetime | None = None
@@ -638,6 +662,11 @@ async def create_landing_page(
discount_ends_at=request.discount_ends_at,
discount_badge_text=request.discount_badge_text,
background_config=request.background_config,
sticky_pay_button=request.sticky_pay_button,
analytics_view_enabled=request.analytics_view_enabled,
analytics_view_goal=request.analytics_view_goal,
analytics_click_enabled=request.analytics_click_enabled,
analytics_click_goal=request.analytics_click_goal,
)
logger.info('Admin created landing page', admin_id=admin.id, slug=landing.slug, landing_id=landing.id)
@@ -979,6 +1008,7 @@ async def get_landing_purchases(
GuestPurchase.currency,
GuestPurchase.payment_method,
GuestPurchase.status,
GuestPurchase.referrer,
GuestPurchase.created_at,
GuestPurchase.paid_at,
)
@@ -1004,6 +1034,7 @@ async def get_landing_purchases(
currency=row.currency,
payment_method=row.payment_method,
status=row.status,
referrer=row.referrer,
created_at=row.created_at,
paid_at=row.paid_at,
)
@@ -1068,6 +1099,11 @@ def _landing_to_detail(landing: LandingPage) -> LandingDetailResponse:
discount_ends_at=landing.discount_ends_at,
discount_badge_text=landing.discount_badge_text,
background_config=landing.background_config,
sticky_pay_button=landing.sticky_pay_button,
analytics_view_enabled=landing.analytics_view_enabled,
analytics_view_goal=landing.analytics_view_goal,
analytics_click_enabled=landing.analytics_click_enabled,
analytics_click_goal=landing.analytics_click_goal,
created_at=landing.created_at,
updated_at=landing.updated_at,
)
+3 -3
View File
@@ -42,9 +42,9 @@ router = APIRouter(prefix='/admin/menu-layout', tags=['Admin Menu Layout'])
# ---- Constants ---------------------------------------------------------------
MAX_ROWS = 20
MAX_BUTTONS_PER_ROW = 3
MAX_BUTTONS_PER_ROW = 8 # Telegram inline keyboard limit
MAX_LABEL_LENGTH = 100
URL_PATTERN = re.compile(r'^https?://')
URL_PATTERN = re.compile(r'^(https?://|tg://)')
# ---- Schemas -----------------------------------------------------------------
@@ -275,7 +275,7 @@ def _validate_update_payload(rows: list[RowConfig]) -> None:
if not btn.url or not URL_PATTERN.match(btn.url):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Custom button "{btn.id}" must have a URL starting with http:// or https://.',
detail=f'Custom button "{btn.id}" must have a URL starting with http://, https://, or tg://.',
)
if btn.open_in == 'webapp' and not btn.url.startswith('https://'):
raise HTTPException(
+16 -2
View File
@@ -4,14 +4,14 @@ from __future__ import annotations
import asyncio
from datetime import datetime
from typing import Any
from typing import Any, ClassVar
import structlog
from aiogram import Bot
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 pydantic import BaseModel, Field, validator
from sqlalchemy.ext.asyncio import AsyncSession
from app.bot_factory import create_bot
@@ -128,6 +128,20 @@ class PromoOfferBroadcastRequest(BaseModel):
message_text: str | None = Field(None, description='Custom message text (HTML)')
button_text: str | None = Field(None, description='Button text')
_TARGET_ALIASES: ClassVar[dict[str, str]] = {
'no_sub': 'no',
'all_users': 'all',
'active_subscribers': 'active',
'trial_users': 'trial',
}
@validator('target')
def normalize_target(cls, value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip().lower()
return cls._TARGET_ALIASES.get(normalized, normalized)
class PromoOfferBroadcastResponse(BaseModel):
created_offers: int
+35 -14
View File
@@ -17,7 +17,7 @@ from app.database.crud.ticket_notification import TicketNotificationCRUD
from app.database.models import Ticket, TicketMessage, User
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.tickets import TicketMessageResponse
from ..schemas.tickets import TicketMediaItem, TicketMessageResponse, _validate_media_bundle
logger = structlog.get_logger(__name__)
@@ -89,19 +89,19 @@ class AdminTicketListResponse(BaseModel):
class AdminReplyRequest(BaseModel):
"""Admin reply to ticket."""
message: str = Field(..., min_length=1, max_length=4000, description='Reply message')
message: str = Field(default='', max_length=4000, description='Reply message')
media_type: str | None = Field(None, description='Media type: photo, video, or document')
media_file_id: str | None = Field(None, max_length=255, description='Telegram file_id from media upload')
media_caption: str | None = Field(None, max_length=1000, description='Caption for media')
media_items: list[TicketMediaItem] | None = Field(None, description='Multi-media gallery attachments')
@model_validator(mode='after')
def validate_media_fields(self) -> 'AdminReplyRequest':
if self.media_file_id and not self.media_type:
raise ValueError('media_type is required when media_file_id is provided')
if self.media_type and not self.media_file_id:
raise ValueError('media_file_id is required when media_type is provided')
if self.media_type and self.media_type not in {'photo', 'video', 'document'}:
raise ValueError('media_type must be one of: photo, video, document')
_validate_media_bundle(self.media_type, self.media_file_id, self.media_items)
has_text = bool(self.message.strip())
has_media = bool(self.media_file_id) or bool(self.media_items)
if not has_text and not has_media:
raise ValueError('message or media is required')
return self
@@ -157,14 +157,23 @@ class TicketSettingsUpdateRequest(BaseModel):
def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
"""Convert TicketMessage to response."""
raw_items = getattr(message, 'media_items', None) or None
items = None
if raw_items:
try:
items = [TicketMediaItem(**it) for it in raw_items]
except (TypeError, KeyError, ValueError) as exc:
logger.warning('Failed to parse media_items', message_id=message.id, error=str(exc))
items = None
return TicketMessageResponse(
id=message.id,
message_text=message.message_text or '',
is_from_admin=message.is_from_admin,
has_media=bool(message.media_file_id),
has_media=bool(message.media_file_id) or bool(items),
media_type=message.media_type,
media_file_id=message.media_file_id,
media_caption=message.media_caption,
media_items=items,
created_at=message.created_at,
)
@@ -455,17 +464,29 @@ async def reply_to_ticket(
detail='Ticket not found',
)
# Create admin message
has_media = bool(request.media_file_id)
# Resolve media payload: prefer media_items, fall back to legacy single-media fields
items_payload = None
primary_type = request.media_type
primary_file_id = request.media_file_id
primary_caption = request.media_caption
if request.media_items:
items_payload = [it.model_dump() for it in request.media_items]
first = request.media_items[0]
primary_type = first.type
primary_file_id = first.file_id
primary_caption = primary_caption or first.caption
has_media = bool(primary_file_id)
message = TicketMessage(
ticket_id=ticket.id,
user_id=ticket.user_id,
message_text=request.message,
is_from_admin=True,
has_media=has_media,
media_type=request.media_type if has_media else None,
media_file_id=request.media_file_id if has_media else None,
media_caption=request.media_caption if has_media else None,
media_type=primary_type if has_media else None,
media_file_id=primary_file_id if has_media else None,
media_caption=primary_caption if has_media else None,
media_items=items_payload,
created_at=datetime.now(UTC),
)
db.add(message)
+155 -28
View File
@@ -70,6 +70,7 @@ from ..schemas.users import (
ResetTrialRequest,
ResetTrialResponse,
SortByEnum,
SubscriptionListItem,
SyncFromPanelRequest,
SyncFromPanelResponse,
SyncToPanelRequest,
@@ -119,6 +120,12 @@ def _build_user_list_item(user: User, spending_stats: dict = None) -> UserListIt
subscription_is_trial = False
subscription_end_date = None
has_subscription = False
tariff_id = None
tariff_name = None
traffic_used_gb = 0.0
traffic_limit_gb = 0
device_limit = 0
days_remaining = 0
subs = getattr(user, 'subscriptions', None) or []
subscription = next((s for s in subs if s.is_active), subs[0] if subs else None)
@@ -127,6 +134,36 @@ def _build_user_list_item(user: User, spending_stats: dict = None) -> UserListIt
subscription_status = subscription.status
subscription_is_trial = subscription.is_trial
subscription_end_date = subscription.end_date
tariff_id = subscription.tariff_id
tariff_name = subscription.tariff.name if subscription.tariff else None
traffic_used_gb = subscription.traffic_used_gb or 0.0
traffic_limit_gb = subscription.traffic_limit_gb or 0
device_limit = subscription.device_limit or 0
if subscription.end_date:
delta = subscription.end_date - datetime.now(UTC)
days_remaining = max(0, delta.days)
# Build per-subscription list (always — bulk actions need it for any mode)
sub_list: list[SubscriptionListItem] = []
if subs:
for s in subs:
s_days = 0
if s.end_date:
s_delta = s.end_date - datetime.now(UTC)
s_days = max(0, s_delta.days)
sub_list.append(
SubscriptionListItem(
id=s.id,
tariff_id=s.tariff_id,
tariff_name=s.tariff.name if s.tariff else None,
status=s.status,
end_date=s.end_date,
days_remaining=s_days,
traffic_used_gb=s.traffic_used_gb or 0.0,
traffic_limit_gb=s.traffic_limit_gb or 0,
device_limit=s.device_limit or 0,
)
)
return UserListItem(
id=user.id,
@@ -144,6 +181,13 @@ def _build_user_list_item(user: User, spending_stats: dict = None) -> UserListIt
subscription_status=subscription_status,
subscription_is_trial=subscription_is_trial,
subscription_end_date=subscription_end_date,
tariff_id=tariff_id,
tariff_name=tariff_name,
traffic_used_gb=traffic_used_gb,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
days_remaining=days_remaining,
subscriptions=sub_list,
promo_group_id=user.promo_group_id,
promo_group_name=user.promo_group.name if user.promo_group else None,
total_spent_kopeks=user_stats.get('total_spent', 0),
@@ -236,8 +280,9 @@ async def _sync_subscription_to_panel(
"""
try:
from app.config import settings
from app.external.remnawave_api import TrafficLimitStrategy, UserStatus as PanelUserStatus
from app.external.remnawave_api import UserStatus as PanelUserStatus
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_service import get_traffic_reset_strategy
from app.utils.subscription_utils import resolve_hwid_device_limit_for_payload
service = RemnaWaveService()
@@ -323,7 +368,7 @@ async def _sync_subscription_to_panel(
'uuid': panel_uuid,
'status': panel_status,
'traffic_limit_bytes': traffic_limit_bytes,
'traffic_limit_strategy': TrafficLimitStrategy.MONTH,
'traffic_limit_strategy': get_traffic_reset_strategy(subscription.tariff),
'description': description,
}
if expire_at:
@@ -358,7 +403,7 @@ async def _sync_subscription_to_panel(
'expire_at': expire_at or (datetime.now(UTC) + timedelta(days=30)),
'status': panel_status,
'traffic_limit_bytes': traffic_limit_bytes,
'traffic_limit_strategy': TrafficLimitStrategy.MONTH,
'traffic_limit_strategy': get_traffic_reset_strategy(subscription.tariff),
'telegram_id': user.telegram_id,
'email': user.email,
'description': description,
@@ -416,6 +461,11 @@ async def list_users(
search: str | None = Query(None, max_length=255),
email: str | None = Query(None, max_length=255),
status: UserStatusEnum | None = Query(None),
subscription_status: str | None = Query(None, max_length=20),
tariff_id: str | None = Query(None, max_length=255),
promo_group_id: int | None = Query(None),
campaign_id: int | None = Query(None),
partner_id: int | None = Query(None),
sort_by: SortByEnum = Query(SortByEnum.CREATED_AT),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
@@ -442,6 +492,14 @@ async def list_users(
order_by_total_spent = sort_by == SortByEnum.TOTAL_SPENT
order_by_purchase_count = sort_by == SortByEnum.PURCHASE_COUNT
# Parse comma-separated tariff_ids
tariff_ids: list[int] | None = None
if tariff_id:
try:
tariff_ids = [int(x.strip()) for x in tariff_id.split(',') if x.strip()]
except ValueError:
tariff_ids = None
users = await get_users_list(
db=db,
offset=offset,
@@ -449,6 +507,11 @@ async def list_users(
search=search,
email=email,
status=user_status,
subscription_status=subscription_status,
tariff_ids=tariff_ids,
promo_group_id=promo_group_id,
campaign_id=campaign_id,
partner_id=partner_id,
order_by_balance=order_by_balance,
order_by_traffic=order_by_traffic,
order_by_last_activity=order_by_last_activity,
@@ -456,7 +519,17 @@ async def list_users(
order_by_purchase_count=order_by_purchase_count,
)
total = await get_users_count(db=db, status=user_status, search=search, email=email)
total = await get_users_count(
db=db,
status=user_status,
search=search,
email=email,
subscription_status=subscription_status,
tariff_ids=tariff_ids,
promo_group_id=promo_group_id,
campaign_id=campaign_id,
partner_id=partner_id,
)
# Get spending stats for all users
user_ids = [u.id for u in users]
@@ -709,7 +782,11 @@ async def get_user_detail(
promo_offer_discount_source=user.promo_offer_discount_source,
promo_offer_discount_expires_at=user.promo_offer_discount_expires_at,
recent_transactions=recent_transactions,
remnawave_uuid=user.remnawave_uuid,
remnawave_uuid=(
primary_sub.remnawave_uuid
if settings.is_multi_tariff_enabled() and primary_sub and primary_sub.remnawave_uuid
else user.remnawave_uuid
),
)
@@ -1045,6 +1122,17 @@ async def update_user_subscription(
detail='User already has a subscription. Enable multi-tariff mode to add more.',
)
# Проверка: нельзя создать вторую активную подписку с тем же тарифом
if is_multi_tariff and request.tariff_id:
from app.database.crud.subscription import get_subscription_by_user_and_tariff
existing = await get_subscription_by_user_and_tariff(db, user.id, request.tariff_id)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='User already has an active subscription for this tariff. Extend it instead.',
)
from app.database.crud.subscription import create_paid_subscription
days = request.days or 30
@@ -1064,16 +1152,25 @@ async def update_user_subscription(
if tariff.allowed_squads:
connected_squads = tariff.allowed_squads
new_sub = await create_paid_subscription(
db=db,
user_id=user.id,
duration_days=days,
traffic_limit_gb=traffic_limit,
device_limit=device_limit,
is_trial=is_trial,
tariff_id=request.tariff_id,
connected_squads=connected_squads,
)
from sqlalchemy.exc import IntegrityError
try:
new_sub = await create_paid_subscription(
db=db,
user_id=user.id,
duration_days=days,
traffic_limit_gb=traffic_limit,
device_limit=device_limit,
is_trial=is_trial,
tariff_id=request.tariff_id,
connected_squads=connected_squads,
)
except IntegrityError:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='User already has an active subscription for this tariff. Extend it instead.',
)
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, new_sub)
@@ -1186,6 +1283,18 @@ async def update_user_subscription(
detail='Tariff not found',
)
# Проверка: нельзя сменить тариф, если у пользователя уже есть
# другая активная подписка с целевым тарифом
if is_multi_tariff and request.tariff_id != subscription.tariff_id:
from app.database.crud.subscription import get_subscription_by_user_and_tariff
existing = await get_subscription_by_user_and_tariff(db, user.id, request.tariff_id)
if existing and existing.id != subscription.id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='User already has an active subscription for the target tariff',
)
# Preserve extra purchased devices above the old tariff's base limit
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
@@ -1317,6 +1426,18 @@ async def update_user_subscription(
)
if request.action == 'activate':
# Проверка: нельзя активировать, если у пользователя уже есть
# другая активная подписка с тем же тарифом
if is_multi_tariff and subscription.tariff_id:
from app.database.crud.subscription import get_subscription_by_user_and_tariff
existing = await get_subscription_by_user_and_tariff(db, user.id, subscription.tariff_id)
if existing and existing.id != subscription.id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='Cannot activate: user already has an active subscription for this tariff',
)
subscription.status = SubscriptionStatus.ACTIVE.value
if subscription.end_date and subscription.end_date <= datetime.now(UTC):
# Extend by 30 days if expired
@@ -2681,6 +2802,13 @@ async def get_user_sync_status(
bot_device_limit = active_sub.device_limit or 0
bot_squads = active_sub.connected_squads or []
# In multi-tariff mode, UUID lives on subscription, not user
effective_uuid = (
active_sub.remnawave_uuid
if settings.is_multi_tariff_enabled() and active_sub and active_sub.remnawave_uuid
else user.remnawave_uuid
)
# Panel data
panel_found = False
panel_status = None
@@ -2699,16 +2827,9 @@ async def get_user_sync_status(
async with service.get_api_client() as api:
panel_user = None
# In multi-tariff mode, UUID lives on subscription, not user
sync_uuid = (
active_sub.remnawave_uuid
if settings.is_multi_tariff_enabled() and active_sub and active_sub.remnawave_uuid
else user.remnawave_uuid
)
# Try by UUID first (works for all users including OAuth)
if sync_uuid:
panel_user = await api.get_user_by_uuid(sync_uuid)
if effective_uuid:
panel_user = await api.get_user_by_uuid(effective_uuid)
# Fallback: search by telegram_id
if not panel_user and user.telegram_id:
@@ -2800,7 +2921,7 @@ async def get_user_sync_status(
return PanelSyncStatusResponse(
user_id=user.id,
telegram_id=user.telegram_id,
remnawave_uuid=user.remnawave_uuid,
remnawave_uuid=effective_uuid,
last_sync=user.last_remnawave_sync,
subscription_id=active_sub.id if active_sub else None,
subscription_tariff_name=sub_tariff_name,
@@ -3018,6 +3139,11 @@ async def sync_user_from_panel(
changes['remnawave_short_uuid'] = {'old': sub.remnawave_short_uuid, 'new': panel_user.short_uuid}
sub.remnawave_short_uuid = panel_user.short_uuid
# Update crypto link
if panel_user.happ_crypto_link and sub.subscription_crypto_link != panel_user.happ_crypto_link:
changes['subscription_crypto_link'] = {'old': sub.subscription_crypto_link, 'new': '***'}
sub.subscription_crypto_link = panel_user.happ_crypto_link
# Update traffic usage if requested
if request.update_traffic and sync_sub:
panel_traffic_used = panel_user.used_traffic_bytes / (1024**3) if panel_user.used_traffic_bytes else 0
@@ -3114,8 +3240,9 @@ async def sync_user_to_panel(
try:
from app.config import settings
from app.external.remnawave_api import TrafficLimitStrategy, UserStatus as PanelUserStatus
from app.external.remnawave_api import UserStatus as PanelUserStatus
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_service import get_traffic_reset_strategy
from app.utils.subscription_utils import resolve_hwid_device_limit_for_payload
service = RemnaWaveService()
@@ -3214,7 +3341,7 @@ async def sync_user_to_panel(
if request.update_traffic_limit:
update_kwargs['traffic_limit_bytes'] = traffic_limit_bytes
update_kwargs['traffic_limit_strategy'] = TrafficLimitStrategy.MONTH
update_kwargs['traffic_limit_strategy'] = get_traffic_reset_strategy(sub.tariff)
changes['traffic_limit_gb'] = sub.traffic_limit_gb
if request.update_squads and sub.connected_squads:
@@ -3248,7 +3375,7 @@ async def sync_user_to_panel(
'expire_at': expire_at or (datetime.now(UTC) + timedelta(days=30)),
'status': panel_status,
'traffic_limit_bytes': traffic_limit_bytes,
'traffic_limit_strategy': TrafficLimitStrategy.MONTH,
'traffic_limit_strategy': get_traffic_reset_strategy(sub.tariff),
'telegram_id': user.telegram_id,
'email': user.email,
'description': description,
+32 -14
View File
@@ -144,22 +144,28 @@ async def _store_refresh_token(
refresh_token: str,
device_info: str | None = None,
) -> None:
"""Store refresh token hash in database."""
"""Store refresh token hash in database using upsert to avoid duplicate key errors."""
from sqlalchemy.dialects.postgresql import insert as pg_insert
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
expires_at = get_refresh_token_expires_at()
token_record = CabinetRefreshToken(
stmt = pg_insert(CabinetRefreshToken).values(
user_id=user_id,
token_hash=token_hash,
device_info=device_info,
expires_at=expires_at,
)
db.add(token_record)
try:
await db.commit()
except IntegrityError:
await db.rollback()
logger.debug('Refresh token already exists (duplicate)', user_id=user_id)
stmt = stmt.on_conflict_do_update(
index_elements=['token_hash'],
set_={
'expires_at': expires_at,
'device_info': device_info,
'revoked_at': None,
},
)
await db.execute(stmt)
await db.commit()
async def _process_campaign_bonus(
@@ -809,10 +815,9 @@ async def auth_telegram_oidc(
# Update user info from OIDC claims
if username and username != user.username:
user.username = username
if first_name and first_name != user.first_name:
user.first_name = first_name
if last_name is not None and last_name != user.last_name:
user.last_name = last_name
# NOTE: не обновляем first_name/last_name из OIDC
# Telegram OIDC возвращает только поле name как полное имя без разделения на first/last
# Имя правильно заполняется через middleware при обычном использовании бота
user.cabinet_last_login = datetime.now(UTC)
await db.commit()
@@ -1052,6 +1057,10 @@ async def register_email_standalone(
referred_by_id=referrer.id if referrer else None,
)
# Сохранить campaign_slug для обработки при верификации email
if request.campaign_slug:
user.pending_campaign_slug = request.campaign_slug
# Для тестового email или отключённой верификации - автоматически верифицировать
if is_test_email or not settings.is_cabinet_email_verification_enabled():
user.email_verified = True
@@ -1063,6 +1072,11 @@ async def register_email_standalone(
await _sync_subscription_from_panel_by_email(db, user)
except Exception:
logger.warning('Failed to sync panel subscription after auto-verify', user_id=user.id, exc_info=True)
# Process campaign bonus immediately for auto-verified users
if request.campaign_slug:
await _process_campaign_bonus(db, user, request.campaign_slug)
user.pending_campaign_slug = None
await db.commit()
else:
# Сгенерировать токен верификации
verification_token = generate_verification_token()
@@ -1173,8 +1187,12 @@ async def verify_email(
response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, response.refresh_token)
# Process campaign bonus
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
# Process campaign bonus (prefer request param, fallback to saved slug from registration)
effective_campaign_slug = request.campaign_slug or user.pending_campaign_slug
response.campaign_bonus = await _process_campaign_bonus(db, user, effective_campaign_slug)
if user.pending_campaign_slug:
user.pending_campaign_slug = None
await db.commit()
if response.campaign_bonus:
response.user = _user_to_response(user)
+122 -1
View File
@@ -578,6 +578,7 @@ async def create_topup(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_method=option,
)
if result:
@@ -698,7 +699,7 @@ async def create_topup(
)
# Use payment_option to select sbp or card
KASSA_AI_OPTION_MAP = {'sbp': 44, 'card': 36}
KASSA_AI_OPTION_MAP = {'sbp': 44, 'card': 36, 'sberpay': 43}
option = (request.payment_option or '').strip().lower()
ps_id = KASSA_AI_OPTION_MAP.get(option) # None = use env default
@@ -793,6 +794,126 @@ async def create_topup(
detail='Failed to create SeverPay payment',
)
elif request.payment_method == 'paypear':
if not settings.is_paypear_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='PayPear payment method is unavailable',
)
payment_service = PaymentService()
result = await payment_service.create_paypear_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
email=getattr(user, 'email', None),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
return_url=cabinet_success_url,
)
if result and result.get('payment_url'):
payment_url = result.get('payment_url')
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create PayPear payment',
)
elif request.payment_method == 'rollypay':
if not settings.is_rollypay_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='RollyPay payment method is unavailable',
)
payment_service = PaymentService()
payment_method_type = request.payment_option or None
result = await payment_service.create_rollypay_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
email=getattr(user, 'email', None),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_method_type=payment_method_type,
return_url=cabinet_success_url,
)
if result and result.get('payment_url'):
payment_url = result.get('payment_url')
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create RollyPay payment',
)
elif request.payment_method == 'overpay':
if not settings.is_overpay_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Overpay payment method is unavailable',
)
payment_service = PaymentService()
result = await payment_service.create_overpay_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
email=getattr(user, 'email', None),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
return_url=cabinet_success_url,
)
if result and result.get('payment_url'):
payment_url = result.get('payment_url')
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create Overpay payment',
)
elif request.payment_method == 'aurapay':
if not settings.is_aurapay_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='AuraPay payment method is unavailable',
)
payment_service = PaymentService()
payment_method_type = request.payment_option or None
result = await payment_service.create_aurapay_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
email=getattr(user, 'email', None),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_method_type=payment_method_type,
return_url=cabinet_success_url,
)
if result and result.get('payment_url'):
payment_url = result.get('payment_url')
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create AuraPay payment',
)
else:
# For other payment methods, redirect to bot
raise HTTPException(
+73 -1
View File
@@ -17,7 +17,7 @@ from app.config import settings
from app.database.crud.system_setting import get_setting_value
from app.database.models import SystemSetting, User
from ..dependencies import get_cabinet_db, require_permission
from ..dependencies import get_cabinet_db, get_current_cabinet_user, require_permission
logger = structlog.get_logger(__name__)
@@ -291,12 +291,24 @@ class GiftEnabledUpdate(BaseModel):
enabled: bool
class OfflineConvGoal(BaseModel):
"""Yandex Metrika offline conversion goal descriptor."""
name: str
event_id: str
dedup: str
class AnalyticsCountersResponse(BaseModel):
"""Analytics counter settings."""
yandex_metrika_id: str = ''
google_ads_id: str = ''
google_ads_label: str = ''
offline_conv_enabled: bool = False
offline_conv_counter_id: str = ''
offline_conv_measurement_secret_masked: str = ''
offline_conv_goals: list[OfflineConvGoal] = []
class AnalyticsCountersUpdate(BaseModel):
@@ -924,10 +936,27 @@ async def get_analytics_counters(
google_id = await get_setting_value(db, GOOGLE_ADS_ID_KEY) or ''
google_label = await get_setting_value(db, GOOGLE_ADS_LABEL_KEY) or ''
# Yandex Metrika offline conversions snapshot from Settings
oc_enabled = bool(getattr(settings, 'YANDEX_OFFLINE_CONV_ENABLED', False))
oc_counter = str(getattr(settings, 'YANDEX_OFFLINE_CONV_COUNTER_ID', '') or '')
oc_secret = str(getattr(settings, 'YANDEX_OFFLINE_CONV_MEASUREMENT_SECRET', '') or '')
oc_secret_masked = ('*' * 8 + oc_secret[-4:]) if len(oc_secret) > 4 else ('***' if oc_secret else '')
oc_goals: list[OfflineConvGoal] = []
if oc_enabled:
oc_goals = [
OfflineConvGoal(name='Registration', event_id='registration', dedup='user_id'),
OfflineConvGoal(name='Trial', event_id='trial-add', dedup='user_id'),
OfflineConvGoal(name='Purchase', event_id='purchase', dedup='order_id'),
]
return AnalyticsCountersResponse(
yandex_metrika_id=yandex_id,
google_ads_id=google_id,
google_ads_label=google_label,
offline_conv_enabled=oc_enabled,
offline_conv_counter_id=oc_counter,
offline_conv_measurement_secret_masked=oc_secret_masked,
offline_conv_goals=oc_goals,
)
@@ -966,13 +995,56 @@ async def update_analytics_counters(
google_id = await get_setting_value(db, GOOGLE_ADS_ID_KEY) or ''
google_label = await get_setting_value(db, GOOGLE_ADS_LABEL_KEY) or ''
oc_enabled = bool(getattr(settings, 'YANDEX_OFFLINE_CONV_ENABLED', False))
oc_counter = str(getattr(settings, 'YANDEX_OFFLINE_CONV_COUNTER_ID', '') or '')
oc_secret = str(getattr(settings, 'YANDEX_OFFLINE_CONV_MEASUREMENT_SECRET', '') or '')
oc_secret_masked = ('*' * 8 + oc_secret[-4:]) if len(oc_secret) > 4 else ('***' if oc_secret else '')
oc_goals: list[OfflineConvGoal] = []
if oc_enabled:
oc_goals = [
OfflineConvGoal(name='Registration', event_id='registration', dedup='user_id'),
OfflineConvGoal(name='Trial', event_id='trial-add', dedup='user_id'),
OfflineConvGoal(name='Purchase', event_id='purchase', dedup='order_id'),
]
return AnalyticsCountersResponse(
yandex_metrika_id=yandex_id,
google_ads_id=google_id,
google_ads_label=google_label,
offline_conv_enabled=oc_enabled,
offline_conv_counter_id=oc_counter,
offline_conv_measurement_secret_masked=oc_secret_masked,
offline_conv_goals=oc_goals,
)
# ============ Yandex CID Sync ============
class YandexCidRequest(BaseModel):
cid: str = Field(max_length=128, pattern=r'^[A-Za-z0-9._:-]{4,128}$')
@router.post('/analytics/yandex-cid', status_code=204)
async def store_yandex_cid(
body: YandexCidRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Store Yandex Metrika ClientID for the authenticated cabinet user."""
try:
from app.services import yandex_offline_conv_service as yandex_conv
await yandex_conv.store_cid(db, user.id, body.cid, source='cabinet')
await db.commit()
except Exception as exc:
logger.warning('Failed to store yandex_cid', user_id=user.id, exc=str(exc))
try:
await db.rollback()
except Exception:
pass
# ============ Lite Mode Routes ============
+2 -2
View File
@@ -425,8 +425,8 @@ async def create_gift_purchase(
warning=recipient_warning,
)
# Balance mode
if user.balance_kopeks < price_kopeks:
# Balance mode (skip for 100% discount)
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Insufficient balance',
+68
View File
@@ -0,0 +1,68 @@
"""Public info page routes for cabinet."""
import structlog
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.info_pages import get_all_info_pages, get_info_page_by_slug, get_tab_replacements
from ..dependencies import get_cabinet_db
from ..schemas.info_pages import InfoPageListItem, InfoPageResponse
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/info-pages', tags=['Cabinet Info Pages'])
@router.get('', response_model=list[InfoPageListItem])
async def list_active_info_pages(
page_type: str | None = Query(None, pattern=r'^(page|faq)$'),
db: AsyncSession = Depends(get_cabinet_db),
) -> list[InfoPageListItem]:
"""Get all active info pages (public, no auth required)."""
try:
pages = await get_all_info_pages(db, include_inactive=False, page_type=page_type)
return [InfoPageListItem.model_validate(p) for p in pages]
except Exception:
logger.exception('Failed to list active info pages')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load info pages',
)
@router.get('/tab-replacements')
async def get_info_page_tab_replacements(
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, str | None]:
"""Get tab replacement mapping (public, no auth required).
Returns a dict mapping each replaceable tab to the info page slug that replaces it,
or null if no replacement is set: ``{faq: slug_or_null, ...}``.
"""
try:
return await get_tab_replacements(db)
except Exception:
logger.exception('Failed to get tab replacements')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load tab replacements',
)
@router.get('/{slug}', response_model=InfoPageResponse)
async def get_info_page_by_slug_public(
slug: str = Path(..., max_length=200, pattern=r'^[a-z0-9\-]+$'),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Get a single info page by slug (public, no auth required)."""
page = await get_info_page_by_slug(db, slug)
if not page or not page.is_active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Info page not found',
)
return InfoPageResponse.model_validate(page)
+36 -1
View File
@@ -23,7 +23,7 @@ from app.services.guest_purchase_service import (
)
from app.services.payment_method_config_service import _get_method_defaults
from app.services.payment_service import PaymentService
from app.utils.cache import RateLimitCache
from app.utils.cache import RateLimitCache, cache
logger = structlog.get_logger(__name__)
@@ -99,6 +99,11 @@ class LandingConfigResponse(BaseModel):
meta_description: str | None = None
discount: LandingDiscountInfo | None = None # null if no active discount
background_config: dict | None = None
sticky_pay_button: bool = False
analytics_view_enabled: bool = False
analytics_view_goal: str | None = None
analytics_click_enabled: bool = False
analytics_click_goal: str | None = None
_EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$')
@@ -123,6 +128,9 @@ class PurchaseRequest(BaseModel):
gift_recipient_type: str | None = Field(default=None, pattern=r'^(email|telegram)$')
gift_recipient_value: str | None = Field(default=None, max_length=255)
gift_message: str | None = Field(default=None, max_length=1000)
yandex_cid: str | None = Field(default=None, max_length=128, pattern=r'^[A-Za-z0-9._:-]{4,128}$')
referrer: str | None = Field(default=None, max_length=500)
subid: str | None = Field(default=None, max_length=255)
@model_validator(mode='after')
def validate_contacts(self) -> 'PurchaseRequest':
@@ -535,6 +543,11 @@ async def get_landing_config(
meta_description=resolve_locale_text(landing.meta_description, lang) or None,
discount=discount,
background_config=landing.background_config,
sticky_pay_button=landing.sticky_pay_button,
analytics_view_enabled=landing.analytics_view_enabled,
analytics_view_goal=landing.analytics_view_goal,
analytics_click_enabled=landing.analytics_click_enabled,
analytics_click_goal=landing.analytics_click_goal,
)
@@ -644,9 +657,17 @@ async def create_landing_purchase(
gift_recipient_type=body.gift_recipient_type,
gift_recipient_value=body.gift_recipient_value,
gift_message=body.gift_message,
subid=body.subid,
referrer=body.referrer,
commit=False,
)
# Fallback to HTTP Referer header if body did not supply one
if not purchase.referrer:
http_referrer = raw_request.headers.get('referer') or raw_request.headers.get('referrer')
if http_referrer and len(http_referrer) <= 500:
purchase.referrer = http_referrer
# Determine return URL: per-method override → default cabinet URL
cabinet_base = (settings.CABINET_URL or '').rstrip('/')
default_return_url = f'{cabinet_base}/buy/success/{purchase.token}'
@@ -690,6 +711,20 @@ async def create_landing_purchase(
await db.commit()
await db.refresh(purchase)
# Persist Yandex CID in cache so fulfill_purchase can link it to the user later
if body.yandex_cid and settings.YANDEX_OFFLINE_CONV_ENABLED:
try:
await cache.set(f'yacid:purchase:{purchase.token}', body.yandex_cid, expire=86400)
except Exception:
pass
# Persist subid in cache for S2S postback
if body.subid:
try:
await cache.set(f'subid:purchase:{purchase.token}', body.subid, expire=86400)
except Exception:
pass
return PurchaseResponse(
purchase_token=purchase.token,
payment_url=payment_url,
+2 -2
View File
@@ -28,7 +28,7 @@ class NotificationSettingsResponse(BaseModel):
subscription_expiry_days: int = 3
traffic_warning_enabled: bool = True
traffic_warning_percent: int = 80
balance_low_enabled: bool = True
balance_low_enabled: bool = False
balance_low_threshold: int = 100 # kopeks
news_enabled: bool = True
promo_offers_enabled: bool = True
@@ -60,7 +60,7 @@ def _get_notification_settings(user: User) -> dict[str, Any]:
'subscription_expiry_days': settings_data.get('subscription_expiry_days', 3),
'traffic_warning_enabled': settings_data.get('traffic_warning_enabled', True),
'traffic_warning_percent': settings_data.get('traffic_warning_percent', 80),
'balance_low_enabled': settings_data.get('balance_low_enabled', True),
'balance_low_enabled': settings_data.get('balance_low_enabled', False),
'balance_low_threshold': settings_data.get('balance_low_threshold', 100),
'news_enabled': settings_data.get('news_enabled', True),
'promo_offers_enabled': settings_data.get('promo_offers_enabled', True),
+2
View File
@@ -79,6 +79,8 @@ async def activate_promocode(
error_messages = {
'not_found': 'Promo code not found',
'expired': 'Promo code has expired',
'inactive': 'Promo code is deactivated',
'not_yet_valid': 'Promo code is not yet active',
'used': 'Promo code has been fully used',
'already_used_by_user': 'You have already used this promo code',
'active_discount_exists': 'You already have an active discount. Deactivate it first via /deactivate-discount',
@@ -168,6 +168,13 @@ async def toggle_subscription_pause(
)
except Exception as e:
logger.error('Error syncing RemnaWave user on resume', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='create',
)
if new_paused_state:
message = 'Daily subscription paused'
@@ -108,7 +108,24 @@ async def purchase_devices_legacy(
detail='Докупка устройств недоступна',
)
base_total_price = device_price * request.devices
# Устройства в пределах тарифного лимита — бесплатные
current_devices = subscription.device_limit or 1
if tariff:
tariff_included = tariff.device_limit or 0
if current_devices < tariff_included:
free_devices = tariff_included - current_devices
chargeable_devices = max(0, request.devices - free_devices)
else:
chargeable_devices = request.devices
else:
free_baseline = settings.DEFAULT_DEVICE_LIMIT
if current_devices < free_baseline:
free_devices = free_baseline - current_devices
chargeable_devices = max(0, request.devices - free_devices)
else:
chargeable_devices = request.devices
base_total_price = device_price * chargeable_devices
# Lock user row to prevent TOCTOU on promo-offer state
from app.database.crud.user import lock_user_for_pricing
@@ -134,8 +151,8 @@ async def purchase_devices_legacy(
detail=f'Максимальное количество устройств: {max_device_limit}',
)
# Check balance
if user.balance_kopeks < total_price:
# Check balance (skip for 100% discount)
if total_price > 0 and user.balance_kopeks < total_price:
missing = total_price - user.balance_kopeks
# Сохраняем корзину для автопокупки после пополнения
@@ -228,12 +245,24 @@ async def purchase_devices_legacy(
# Sync with RemnaWave
try:
service = SubscriptionService()
if _resolve_panel_uuid(subscription, user):
await service.update_remnawave_user(db, subscription)
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(user, 'remnawave_uuid', None)
if _should_create:
await service.create_remnawave_user(db, subscription)
else:
await service.update_remnawave_user(db, subscription)
except Exception as e:
logger.error('Failed to sync devices with RemnaWave (legacy endpoint)', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='create' if _should_create else 'update',
)
# Отправляем уведомление админам
try:
@@ -354,10 +383,27 @@ async def purchase_devices(
days_left = max(1, (end_date - now).days)
total_days = 30 # Base period for device price calculation
# Устройства в пределах тарифного лимита — бесплатные
if tariff:
tariff_included = tariff.device_limit or 0
if current_devices < tariff_included:
free_devices = tariff_included - current_devices
chargeable_devices = max(0, request.devices - free_devices)
else:
chargeable_devices = request.devices
else:
free_baseline = settings.DEFAULT_DEVICE_LIMIT
if current_devices < free_baseline:
free_devices = free_baseline - current_devices
chargeable_devices = max(0, request.devices - free_devices)
else:
chargeable_devices = request.devices
# Calculate base price before discount
base_price_per_month = device_price * request.devices
base_price_per_month = device_price * chargeable_devices
base_price_prorated = int(base_price_per_month * days_left / total_days)
base_price_prorated = max(100, base_price_prorated) # Minimum 1 ruble
if chargeable_devices > 0:
base_price_prorated = max(100, base_price_prorated) # Minimum 1 ruble
# Lock user BEFORE discount computation to prevent TOCTOU on promo group
from app.database.crud.user import lock_user_for_pricing
@@ -375,8 +421,8 @@ async def purchase_devices(
if devices_discount_percent < 100:
price_kopeks = max(100, price_kopeks)
# Check balance
if user.balance_kopeks < price_kopeks:
# Check balance (skip for 100% discount)
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
missing = price_kopeks - user.balance_kopeks
# Сохраняем корзину для автопокупки после пополнения
@@ -469,12 +515,24 @@ async def purchase_devices(
# Sync with RemnaWave
service = SubscriptionService()
try:
if _resolve_panel_uuid(subscription, user):
await service.update_remnawave_user(db, subscription)
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(user, 'remnawave_uuid', None)
if _should_create:
await service.create_remnawave_user(db, subscription)
else:
await service.update_remnawave_user(db, subscription)
except Exception as e:
logger.error('Failed to sync devices with RemnaWave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='create' if _should_create else 'update',
)
await db.refresh(user)
@@ -603,8 +661,25 @@ async def save_devices_cart(
days_left = max(1, (end_date - now).days)
total_days = 30
base_total_price = int(device_price * request.devices * days_left / total_days)
base_total_price = max(100, base_total_price) # Minimum 1 ruble
# Устройства в пределах тарифного лимита — бесплатные
if tariff:
tariff_included = tariff.device_limit or 0
if current_devices < tariff_included:
free_devices = tariff_included - current_devices
chargeable_devices = max(0, request.devices - free_devices)
else:
chargeable_devices = request.devices
else:
free_baseline = settings.DEFAULT_DEVICE_LIMIT
if current_devices < free_baseline:
free_devices = free_baseline - current_devices
chargeable_devices = max(0, request.devices - free_devices)
else:
chargeable_devices = request.devices
base_total_price = int(device_price * chargeable_devices * days_left / total_days)
if chargeable_devices > 0:
base_total_price = max(100, base_total_price) # Minimum 1 ruble
# Apply discount from promo group
period_hint_days = days_left
@@ -700,9 +775,26 @@ async def get_device_price(
days_left = max(1, (end_date - now).days)
total_days = 30
# Устройства в пределах тарифного лимита — бесплатные
if tariff:
tariff_included = tariff.device_limit or 0
if current_devices < tariff_included:
free_devices = tariff_included - current_devices
chargeable_devices = max(0, devices - free_devices)
else:
chargeable_devices = devices
else:
free_baseline = settings.DEFAULT_DEVICE_LIMIT
if current_devices < free_baseline:
free_devices = free_baseline - current_devices
chargeable_devices = max(0, devices - free_devices)
else:
chargeable_devices = devices
# Calculate base price before discount (total first, then floor)
base_total_price = int(device_price * devices * days_left / total_days)
base_total_price = max(100, base_total_price)
base_total_price = int(device_price * chargeable_devices * days_left / total_days)
if chargeable_devices > 0:
base_total_price = max(100, base_total_price)
# Apply discount from promo group
period_hint_days = days_left
@@ -304,9 +304,7 @@ async def get_purchase_options(
from app.database.crud.subscription import get_active_subscriptions_by_user_id
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
purchased_tariff_ids = {
s.tariff_id for s in active_subs if s.tariff_id and s.status in ('active', 'trial')
}
purchased_tariff_ids = {s.tariff_id for s in active_subs if s.tariff_id and not s.is_trial}
if subscription_id:
from app.database.crud.subscription import get_subscription_by_id_for_user
@@ -355,6 +353,9 @@ async def get_purchase_options(
'all_tariffs_purchased': len(purchased_tariff_ids) >= len(tariffs)
if settings.is_multi_tariff_enabled()
else False,
# Направления смены тарифа
'tariff_switch_upgrade_enabled': settings.TARIFF_SWITCH_UPGRADE_ENABLED,
'tariff_switch_downgrade_enabled': settings.TARIFF_SWITCH_DOWNGRADE_ENABLED,
}
# Classic mode - return periods
@@ -678,15 +679,17 @@ async def purchase_tariff(
promo_offer_discount_value = result.promo_offer_discount
price_before_promo_offer = price_kopeks + promo_offer_discount_value
# Safety guard: reject zero-price purchases for non-daily tariffs (defense in depth)
if price_kopeks <= 0 and result.base_price <= 0 and not is_daily_tariff:
# Safety guard: reject zero-price purchases for non-daily tariffs (defense in depth).
# Use original_total (pre-discount price) — base_price is already discounted,
# so a 100% group discount legitimately makes it 0.
if price_kopeks <= 0 and result.original_total <= 0 and not is_daily_tariff:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid tariff period or pricing configuration',
)
# Check balance
if user.balance_kopeks < price_kopeks:
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
missing = price_kopeks - user.balance_kopeks
# Save cart for auto-purchase after balance top-up
@@ -911,6 +914,13 @@ async def purchase_tariff(
)
except Exception as remnawave_error:
logger.error('Failed to sync subscription with RemnaWave', remnawave_error=remnawave_error)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='create' if not subscription.remnawave_uuid else 'update',
)
# Save cart for auto-renewal (not for daily tariffs - they have their own charging)
if not is_daily_tariff:
@@ -1158,7 +1168,7 @@ async def activate_trial(
from app.database.crud.user import subtract_user_balance
price_kopeks = settings.TRIAL_ACTIVATION_PRICE
if user.balance_kopeks < price_kopeks:
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Insufficient balance. Need {price_kopeks / 100:.2f} RUB',
@@ -1212,9 +1222,11 @@ async def activate_trial(
trial_tariff = None
if trial_tariff:
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
trial_traffic_limit = trial_tariff.traffic_limit_gb
trial_device_limit = trial_tariff.device_limit
trial_squads = trial_tariff.allowed_squads or []
trial_squads = await get_effective_tariff_squad_uuids(db, trial_tariff.allowed_squads)
tariff_id_for_trial = trial_tariff.id
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
if tariff_trial_days:
@@ -1228,6 +1240,13 @@ async def activate_trial(
except Exception as e:
logger.error('Error getting trial tariff', error=e)
# No trial tariff configured, use the legacy random trial squad fallback.
if not trial_squads:
from app.database.crud.server_squad import get_random_trial_squad_uuid
trial_squad_uuid = await get_random_trial_squad_uuid(db)
trial_squads = [trial_squad_uuid] if trial_squad_uuid else []
# Create trial subscription
subscription = await create_trial_subscription(
db=db,
@@ -1249,6 +1268,13 @@ async def activate_trial(
await db.refresh(subscription)
except Exception as e:
logger.error('Failed to create RemnaWave user for trial', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='create',
)
# Send admin notification about trial activation
try:
@@ -57,7 +57,14 @@ async def get_renewal_options(
return []
# Determine available periods
if subscription.tariff_id and subscription.tariff and subscription.tariff.period_prices:
# Скрытый/неактивный тариф (например, триальный после промокода) —
# не показываем его периоды, используем стандартные
if (
subscription.tariff_id
and subscription.tariff
and subscription.tariff.is_active
and subscription.tariff.period_prices
):
periods = sorted(int(k) for k in subscription.tariff.period_prices.keys())
else:
periods = settings.get_available_renewal_periods()
@@ -67,7 +74,7 @@ async def get_renewal_options(
for period in periods:
pricing = await pricing_engine.calculate_renewal_price(db, subscription, period, user=user)
if pricing.final_total <= 0 and pricing.base_price <= 0:
if pricing.final_total <= 0 and pricing.original_total <= 0:
continue
original_price = pricing.original_total
@@ -128,7 +135,12 @@ async def renew_subscription(
detail=f'Cannot renew subscription with status: {_actual_status}',
)
if subscription.tariff_id and subscription.tariff and subscription.tariff.period_prices:
if (
subscription.tariff_id
and subscription.tariff
and subscription.tariff.is_active
and subscription.tariff.period_prices
):
available_periods = [int(p) for p in subscription.tariff.period_prices.keys()]
else:
available_periods = settings.get_available_renewal_periods()
@@ -155,7 +167,7 @@ async def renew_subscription(
promo_offer_discount_value = pricing.promo_offer_discount
promo_offer_discount_percent = pricing.breakdown.get('offer_discount_pct', 0)
if price_kopeks <= 0 and pricing.base_price <= 0:
if price_kopeks <= 0 and pricing.original_total <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid renewal period',
@@ -168,8 +180,8 @@ async def renew_subscription(
tariff = subscription.tariff if subscription.tariff_id else None
# Check balance
if user.balance_kopeks < price_kopeks:
# Check balance (skip for 100% discount)
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
missing = price_kopeks - user.balance_kopeks
# Get tariff info for cart
@@ -249,6 +249,13 @@ async def update_countries(
await subscription_service.create_remnawave_user(db, subscription)
except Exception as e:
logger.error('Failed to sync countries with RemnaWave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='update' if _has_panel else 'create',
)
await db.refresh(subscription)
@@ -119,6 +119,18 @@ async def preview_tariff_switch(
)
upgrade_cost = switch_result.upgrade_cost
is_upgrade = switch_result.is_upgrade
# Проверяем разрешение на смену в данном направлении
if is_upgrade and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Повышение тарифа недоступно',
)
if not is_upgrade and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Понижение тарифа недоступно',
)
base_upgrade_cost = switch_result.raw_cost
discount_value = switch_result.discount_value
period_discount_percent = switch_result.effective_discount_pct
@@ -263,11 +275,24 @@ async def switch_tariff(
user=user,
)
upgrade_cost = switch_result.upgrade_cost
is_upgrade = switch_result.is_upgrade
base_upgrade_cost = switch_result.raw_cost
discount_value = switch_result.discount_value
period_discount_percent = switch_result.effective_discount_pct
new_period_days = switch_result.new_period_days
# Проверяем разрешение на смену в данном направлении
if is_upgrade and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Повышение тарифа недоступно',
)
if not is_upgrade and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Понижение тарифа недоступно',
)
# Validate daily price for switching TO daily
new_is_daily = getattr(new_tariff, 'is_daily', False)
current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False
@@ -428,6 +453,13 @@ async def switch_tariff(
)
except Exception as e:
logger.error('Failed to sync tariff switch with RemnaWave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='update' if _has_panel else 'create',
)
# Reset all devices on tariff switch
devices_reset = False
@@ -254,7 +254,7 @@ async def purchase_traffic(
final_price = max(100, final_price)
# Проверяем баланс
if user.balance_kopeks < final_price:
if final_price > 0 and user.balance_kopeks < final_price:
missing = final_price - user.balance_kopeks
# Save cart for auto-purchase after balance top-up
@@ -316,19 +316,32 @@ async def purchase_traffic(
# Синхронизируем с RemnaWave
try:
subscription_service = SubscriptionService()
_panel_uuid = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
else getattr(user, 'remnawave_uuid', None)
)
if _panel_uuid:
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(user, 'remnawave_uuid', None)
if _should_create:
await subscription_service.create_remnawave_user(db, subscription)
else:
await subscription_service.update_remnawave_user(db, subscription)
if subscription.status == 'active':
await subscription_service.enable_remnawave_user(_panel_uuid)
else:
await subscription_service.create_remnawave_user(db, subscription)
_enable_uuid = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled()
else getattr(user, 'remnawave_uuid', None)
)
if _enable_uuid:
await subscription_service.enable_remnawave_user(_enable_uuid)
except Exception as e:
logger.error('Failed to sync traffic with RemnaWave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='create' if _should_create else 'update',
)
# Создаём транзакцию
await create_transaction(
@@ -560,7 +573,7 @@ async def switch_traffic_package(
# Prorated calculation
final_price, days_charged = calculate_prorated_price(price_diff, subscription.end_date)
if user.balance_kopeks < final_price:
if final_price > 0 and user.balance_kopeks < final_price:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail=f'Insufficient balance. Need {final_price / 100:.2f} RUB',
@@ -604,17 +617,25 @@ async def switch_traffic_package(
# Sync with RemnaWave
try:
subscription_service = SubscriptionService()
_panel_uuid2 = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
else getattr(user, 'remnawave_uuid', None)
)
if _panel_uuid2:
await subscription_service.update_remnawave_user(db, subscription)
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(user, 'remnawave_uuid', None)
if _should_create:
await subscription_service.create_remnawave_user(db, subscription)
else:
await subscription_service.update_remnawave_user(db, subscription)
except Exception as e:
logger.error('Failed to sync traffic switch with RemnaWave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='create' if _should_create else 'update',
)
await db.refresh(user)
await db.refresh(subscription)
+49 -9
View File
@@ -20,6 +20,7 @@ from ..schemas.tickets import (
TicketCreateRequest,
TicketDetailResponse,
TicketListResponse,
TicketMediaItem,
TicketMessageCreateRequest,
TicketMessageResponse,
TicketResponse,
@@ -33,14 +34,23 @@ router = APIRouter(prefix='/tickets', tags=['Cabinet Tickets'])
def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
"""Convert TicketMessage to response."""
raw_items = getattr(message, 'media_items', None) or None
items = None
if raw_items:
try:
items = [TicketMediaItem(**it) for it in raw_items]
except (TypeError, KeyError, ValueError) as exc:
logger.warning('Failed to parse media_items', message_id=message.id, error=str(exc))
items = None
return TicketMessageResponse(
id=message.id,
message_text=message.message_text or '',
is_from_admin=message.is_from_admin,
has_media=bool(message.media_file_id),
has_media=bool(message.media_file_id) or bool(items),
media_type=message.media_type,
media_file_id=message.media_file_id,
media_caption=message.media_caption,
media_items=items,
created_at=message.created_at,
)
@@ -143,15 +153,30 @@ async def create_ticket(
db.add(ticket)
await db.flush()
# Resolve media payload
items_payload = None
primary_type = request.media_type
primary_file_id = request.media_file_id
primary_caption = request.media_caption
if getattr(request, 'media_items', None):
items_payload = [it.model_dump() for it in request.media_items]
first = request.media_items[0]
primary_type = first.type
primary_file_id = first.file_id
primary_caption = primary_caption or first.caption
# Create initial message with optional media
has_media = bool(primary_file_id)
message = TicketMessage(
ticket_id=ticket.id,
user_id=user.id,
message_text=request.message,
is_from_admin=False,
media_type=request.media_type,
media_file_id=request.media_file_id,
media_caption=request.media_caption,
has_media=has_media,
media_type=primary_type if has_media else None,
media_file_id=primary_file_id if has_media else None,
media_caption=primary_caption if has_media else None,
media_items=items_payload,
created_at=datetime.now(UTC),
)
db.add(message)
@@ -259,15 +284,30 @@ async def add_ticket_message(
detail='Replies to this ticket are blocked',
)
# Resolve media payload
items_payload = None
primary_type = request.media_type
primary_file_id = request.media_file_id
primary_caption = request.media_caption
if getattr(request, 'media_items', None):
items_payload = [it.model_dump() for it in request.media_items]
first = request.media_items[0]
primary_type = first.type
primary_file_id = first.file_id
primary_caption = primary_caption or first.caption
# Create message with optional media
has_media = bool(primary_file_id)
message = TicketMessage(
ticket_id=ticket.id,
user_id=user.id,
message_text=request.message,
is_from_admin=False,
media_type=request.media_type,
media_file_id=request.media_file_id,
media_caption=request.media_caption,
has_media=has_media,
media_type=primary_type if has_media else None,
media_file_id=primary_file_id if has_media else None,
media_caption=primary_caption if has_media else None,
media_items=items_payload,
created_at=datetime.now(UTC),
)
db.add(message)
@@ -286,8 +326,8 @@ async def add_ticket_message(
ticket,
request.message,
db,
media_file_id=request.media_file_id,
media_type=request.media_type,
media_file_id=primary_file_id,
media_type=primary_type,
)
except Exception as e:
logger.error('Error notifying admins about ticket reply from cabinet', error=e)
+3
View File
@@ -138,6 +138,9 @@ class EmailRegisterStandaloneRequest(BaseModel):
referral_code: str | None = Field(
None, max_length=32, pattern=r'^[a-zA-Z0-9_-]+$', description='Referral code of inviter'
)
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
class CampaignBonusInfo(BaseModel):
+7
View File
@@ -118,6 +118,7 @@ class BroadcastCreateRequest(BaseModel):
selected_buttons: list[str] = Field(default_factory=lambda: ['home'])
custom_buttons: list[CustomBroadcastButton] = Field(default_factory=list, max_length=10)
media: BroadcastMediaRequest | None = None
category: str = Field(default='system', pattern='^(system|news|promo)$')
# ============ Response ============
@@ -144,6 +145,9 @@ class BroadcastResponse(BaseModel):
completed_at: datetime | None = None
progress_percent: float = 0.0
# Category for user notification preference filtering
category: str = 'system' # system|news|promo
# Email/channel fields
channel: str = 'telegram' # telegram|email|both
email_subject: str | None = None
@@ -212,6 +216,9 @@ class CombinedBroadcastCreateRequest(BaseModel):
custom_buttons: list[CustomBroadcastButton] = Field(default_factory=list, max_length=10)
media: BroadcastMediaRequest | None = None
# Broadcast category for user notification preference filtering
category: str = Field(default='system', pattern='^(system|news|promo)$')
# Email-specific fields
email_subject: str | None = Field(default=None, max_length=255)
email_html_content: str | None = Field(default=None, max_length=100000)
+77
View File
@@ -0,0 +1,77 @@
"""Schemas for admin bulk actions."""
from enum import StrEnum
from pydantic import BaseModel, Field, model_validator
class BulkActionType(StrEnum):
EXTEND_SUBSCRIPTION = 'extend_subscription'
CANCEL_SUBSCRIPTION = 'cancel_subscription'
ACTIVATE_SUBSCRIPTION = 'activate_subscription'
CHANGE_TARIFF = 'change_tariff'
ADD_DAYS = 'add_days'
ADD_TRAFFIC = 'add_traffic'
ADD_BALANCE = 'add_balance'
ASSIGN_PROMO_GROUP = 'assign_promo_group'
GRANT_SUBSCRIPTION = 'grant_subscription'
SET_DEVICES = 'set_devices'
DELETE_SUBSCRIPTION = 'delete_subscription'
DELETE_USER = 'delete_user'
class BulkActionParams(BaseModel):
days: int | None = Field(None, ge=1, le=3650)
tariff_id: int | None = Field(None, gt=0)
traffic_gb: int | None = Field(None, ge=1, le=10000)
amount_kopeks: int | None = Field(None, ge=1, le=2_000_000_000)
balance_description: str = Field(default='Массовое начисление баланса', max_length=500)
promo_group_id: int | None = None
device_limit: int | None = Field(None, ge=1, le=50)
delete_from_panel: bool = Field(default=True)
class BulkSubscriptionInfo(BaseModel):
id: int
tariff_id: int | None = None
tariff_name: str | None = None
status: str
days_remaining: int
traffic_used_gb: float = 0
traffic_limit_gb: int = 0
device_limit: int = 0
class BulkExecuteRequest(BaseModel):
action: BulkActionType
user_ids: list[int] | None = Field(None, min_length=1, max_length=500)
subscription_ids: list[int] | None = Field(None, min_length=1, max_length=2000)
params: BulkActionParams = Field(default_factory=BulkActionParams)
dry_run: bool = Field(default=False, description='Preview only, no mutations')
@model_validator(mode='after')
def _exactly_one_target(self):
has_users = self.user_ids is not None
has_subs = self.subscription_ids is not None
if has_users == has_subs:
raise ValueError('Exactly one of user_ids or subscription_ids must be provided')
return self
class BulkUserResult(BaseModel):
user_id: int
subscription_id: int | None = None
success: bool
message: str
username: str | None = None
subscriptions: list[BulkSubscriptionInfo] | None = None
class BulkExecuteResponse(BaseModel):
action: str
total: int
success_count: int
error_count: int
skipped_count: int
dry_run: bool
results: list[BulkUserResult]
+78
View File
@@ -0,0 +1,78 @@
"""Schemas for info pages in cabinet."""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class InfoPageResponse(BaseModel):
"""Full info page response."""
id: int
slug: str
title: dict[str, str]
content: dict[str, str]
page_type: str = 'page'
is_active: bool
sort_order: int
icon: str | None = None
replaces_tab: str | None = None
created_at: datetime
updated_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
class InfoPageListItem(BaseModel):
"""Compact info page for list views."""
id: int
slug: str
title: dict[str, str]
page_type: str = 'page'
is_active: bool
sort_order: int
icon: str | None = None
replaces_tab: str | None = None
updated_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
class InfoPageCreateRequest(BaseModel):
"""Request to create an info page."""
slug: str = Field(min_length=1, max_length=200, pattern=r'^[a-z0-9\-]+$')
title: dict[str, str] = Field(default_factory=dict)
content: dict[str, str] = Field(default_factory=dict)
page_type: str = Field(default='page', pattern=r'^(page|faq)$')
is_active: bool = True
sort_order: int = 0
icon: str | None = Field(None, max_length=50)
replaces_tab: str | None = Field(None, pattern=r'^(faq|rules|privacy|offer)$')
class InfoPageUpdateRequest(BaseModel):
"""Request to update an info page."""
slug: str | None = Field(None, min_length=1, max_length=200, pattern=r'^[a-z0-9\-]+$')
title: dict[str, str] | None = None
content: dict[str, str] | None = None
page_type: str | None = Field(None, pattern=r'^(page|faq)$')
is_active: bool | None = None
sort_order: int | None = None
icon: str | None = Field(None, max_length=50)
replaces_tab: str | None = Field(None, pattern=r'^(faq|rules|privacy|offer)$')
class ReorderItem(BaseModel):
"""Single item in a reorder request."""
id: int
sort_order: int = Field(ge=0)
class ReorderRequest(BaseModel):
"""Request to bulk-reorder info pages."""
items: list[ReorderItem] = Field(..., min_length=1)
+67 -3
View File
@@ -2,7 +2,50 @@
from datetime import datetime
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
ALLOWED_MEDIA_TYPES = {'photo', 'video', 'document'}
MAX_MEDIA_ITEMS = 10
class TicketMediaItem(BaseModel):
"""Single media attachment in a ticket message."""
type: str = Field(..., description='Media type: photo, video, or document')
file_id: str = Field(..., max_length=255, description='Telegram file_id')
caption: str | None = Field(None, max_length=1000, description='Optional caption')
@model_validator(mode='after')
def validate_type(self) -> 'TicketMediaItem':
if self.type not in ALLOWED_MEDIA_TYPES:
raise ValueError(f'type must be one of: {sorted(ALLOWED_MEDIA_TYPES)}')
return self
def _validate_media_bundle(
media_type: str | None,
media_file_id: str | None,
media_items: list[TicketMediaItem] | None,
) -> None:
"""Shared validator for media-attached request bodies."""
if media_items is not None:
if len(media_items) == 0:
raise ValueError('media_items must not be empty (send null instead)')
if len(media_items) > MAX_MEDIA_ITEMS:
raise ValueError(f'media_items cannot exceed {MAX_MEDIA_ITEMS} entries')
if media_file_id and media_file_id != media_items[0].file_id:
raise ValueError('legacy media_file_id must match media_items[0].file_id')
if media_type and media_type != media_items[0].type:
raise ValueError('legacy media_type must match media_items[0].type')
return
if media_file_id and not media_type:
raise ValueError('media_type is required when media_file_id is provided')
if media_type and not media_file_id:
raise ValueError('media_file_id is required when media_type is provided')
if media_type and media_type not in ALLOWED_MEDIA_TYPES:
raise ValueError(f'media_type must be one of: {sorted(ALLOWED_MEDIA_TYPES)}')
class TicketMessageResponse(BaseModel):
@@ -15,6 +58,7 @@ class TicketMessageResponse(BaseModel):
media_type: str | None = None
media_file_id: str | None = None
media_caption: str | None = None
media_items: list[TicketMediaItem] | None = None
created_at: datetime
class Config:
@@ -69,16 +113,36 @@ class TicketCreateRequest(BaseModel):
"""Request to create a new ticket."""
title: str = Field(..., min_length=3, max_length=255, description='Ticket title')
message: str = Field(..., min_length=10, max_length=4000, description='Initial message')
message: str = Field(default='', max_length=4000, description='Initial message')
media_type: str | None = Field(None, description='Media type: photo, video, document')
media_file_id: str | None = Field(None, description='Telegram file_id of uploaded media')
media_caption: str | None = Field(None, max_length=1000, description='Media caption')
media_items: list[TicketMediaItem] | None = Field(None, description='Multi-media attachments')
@model_validator(mode='after')
def validate_has_content(self) -> 'TicketCreateRequest':
_validate_media_bundle(self.media_type, self.media_file_id, self.media_items)
has_text = bool(self.message.strip())
has_media = bool(self.media_file_id) or bool(self.media_items)
if not has_text and not has_media:
raise ValueError('message or media is required')
return self
class TicketMessageCreateRequest(BaseModel):
"""Request to add message to ticket."""
message: str = Field(..., min_length=1, max_length=4000, description='Message text')
message: str = Field(default='', max_length=4000, description='Message text')
media_type: str | None = Field(None, description='Media type: photo, video, document')
media_file_id: str | None = Field(None, description='Telegram file_id of uploaded media')
media_caption: str | None = Field(None, max_length=1000, description='Media caption')
media_items: list[TicketMediaItem] | None = Field(None, description='Multi-media attachments')
@model_validator(mode='after')
def validate_has_content(self) -> 'TicketMessageCreateRequest':
_validate_media_bundle(self.media_type, self.media_file_id, self.media_items)
has_text = bool(self.message.strip())
has_media = bool(self.media_file_id) or bool(self.media_items)
if not has_text and not has_media:
raise ValueError('message or media is required')
return self
+23
View File
@@ -82,6 +82,20 @@ class UserPromoGroupInfo(BaseModel):
# === User List ===
class SubscriptionListItem(BaseModel):
"""Compact subscription info for user list (multi-tariff mode)."""
id: int
tariff_id: int | None = None
tariff_name: str | None = None
status: str
end_date: datetime | None = None
days_remaining: int = 0
traffic_used_gb: float = 0
traffic_limit_gb: int = 0
device_limit: int = 0
class UserListItem(BaseModel):
"""User item in list."""
@@ -102,6 +116,15 @@ class UserListItem(BaseModel):
subscription_status: str | None = None
subscription_is_trial: bool = False
subscription_end_date: datetime | None = None
tariff_id: int | None = None
tariff_name: str | None = None
traffic_used_gb: float = 0
traffic_limit_gb: int = 0
device_limit: int = 0
days_remaining: int = 0
# All subscriptions (multi-tariff)
subscriptions: list[SubscriptionListItem] = []
# Promo group
promo_group_id: int | None = None
+10 -1
View File
@@ -12,7 +12,16 @@ def get_campaign_deep_link(start_parameter: str) -> str:
def get_campaign_web_link(start_parameter: str) -> str | None:
"""Generate a web app link for a campaign."""
"""Generate a web app link for a campaign.
Prefers CABINET_URL (where the auth flow captures ?campaign= param),
falls back to MINIAPP_CUSTOM_URL for backwards compatibility.
"""
cabinet_url = settings._normalized_cabinet_url()
if cabinet_url:
sep = '&' if '?' in cabinet_url else '?'
return f'{cabinet_url}{sep}campaign={start_parameter}'
base_url = (settings.MINIAPP_CUSTOM_URL or '').rstrip('/')
if base_url:
return f'{base_url}/?campaign={start_parameter}'
+157 -7
View File
@@ -68,9 +68,9 @@ class Settings(BaseSettings):
ADMIN_NOTIFICATIONS_PARTNERS_TOPIC_ID: int | None = None # Партнёрки, выводы, админ-действия
# Настройки очереди чеков NaloGO
NALOGO_QUEUE_CHECK_INTERVAL: int = 300 # Интервал проверки очереди (секунды)
NALOGO_QUEUE_CHECK_INTERVAL: int = 600 # Интервал проверки очереди (секунды, 10 мин)
NALOGO_QUEUE_RECEIPT_DELAY: int = 3 # Задержка между отправкой чеков (секунды)
NALOGO_QUEUE_MAX_ATTEMPTS: int = 10 # Максимум попыток отправки чека
NALOGO_QUEUE_MAX_ATTEMPTS: int = 72 # Максимум попыток отправки чека (72 × 10мин = 12 часов)
ADMIN_REPORTS_ENABLED: bool = False
ADMIN_REPORTS_CHAT_ID: str | None = None
@@ -133,6 +133,7 @@ class Settings(BaseSettings):
WEBHOOK_NOTIFY_NOT_CONNECTED: bool = True
WEBHOOK_NOTIFY_BANDWIDTH_THRESHOLD: bool = True
WEBHOOK_NOTIFY_DEVICES: bool = True
WEBHOOK_NOTIFY_TORRENT_DETECTED: bool = True
TRIAL_DURATION_DAYS: int = 3
TRIAL_TRAFFIC_LIMIT_GB: int = 10
@@ -147,6 +148,9 @@ class Settings(BaseSettings):
DEFAULT_TRAFFIC_RESET_STRATEGY: str = 'MONTH'
RESET_TRAFFIC_ON_PAYMENT: bool = False
RESET_TRAFFIC_ON_TARIFF_SWITCH: bool = True
RESET_DEVICES_ON_RENEWAL: bool = False
TARIFF_SWITCH_UPGRADE_ENABLED: bool = True
TARIFF_SWITCH_DOWNGRADE_ENABLED: bool = True
MAX_DEVICES_LIMIT: int = 20
TRIAL_WARNING_HOURS: int = 2
@@ -323,6 +327,7 @@ class Settings(BaseSettings):
SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS: int = 20000
MONITORING_INTERVAL: int = 60
LOW_BALANCE_ALERT_EXPIRY_DAYS: int = 3 # Only alert when subscription expires within N days
INACTIVE_USER_DELETE_MONTHS: int = 3
MAINTENANCE_MODE: bool = False
@@ -435,6 +440,7 @@ class Settings(BaseSettings):
MULENPAY_LANGUAGE: str = 'ru'
MULENPAY_VAT_CODE: int = 0
DISPLAY_NAME_RESTRICTION_ENABLED: bool = True
DISPLAY_NAME_BANNED_KEYWORDS: str = '\n'.join(DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS)
MULENPAY_PAYMENT_SUBJECT: int = 4
MULENPAY_PAYMENT_MODE: int = 4
@@ -467,7 +473,7 @@ class Settings(BaseSettings):
PLATEGA_RETURN_URL: str | None = None
PLATEGA_FAILED_URL: str | None = None
PLATEGA_CURRENCY: str = 'RUB'
PLATEGA_ACTIVE_METHODS: str = '2,10,11,12,13'
PLATEGA_ACTIVE_METHODS: str = '2,11,12,13'
PLATEGA_INLINE_METHODS: bool = True
PLATEGA_MIN_AMOUNT_KOPEKS: int = 10000
PLATEGA_MAX_AMOUNT_KOPEKS: int = 100000000
@@ -558,6 +564,23 @@ class Settings(BaseSettings):
KASSA_AI_SBP_DISPLAY_NAME: str = 'СБП (KassaAI)'
KASSA_AI_CARD_ENABLED: bool = False # Карты РФ — payment_system_id=36
KASSA_AI_CARD_DISPLAY_NAME: str = 'Карта (KassaAI)'
KASSA_AI_SBERPAY_ENABLED: bool = False # SberPay — payment_system_id=43
KASSA_AI_SBERPAY_DISPLAY_NAME: str = 'SberPay (KassaAI)'
# ── Yandex Metrika offline conversions (server → mc.yandex.ru/collect) ──
YANDEX_OFFLINE_CONV_ENABLED: bool = False
YANDEX_OFFLINE_CONV_COUNTER_ID: str = ''
YANDEX_OFFLINE_CONV_MEASUREMENT_SECRET: str = ''
YANDEX_OFFLINE_CONV_START_PREFIX: str = 'utm_ya_'
YANDEX_OFFLINE_CONV_DL: str = ''
YANDEX_OFFLINE_CONV_DT: str = ''
YANDEX_OFFLINE_CONV_CURRENCY: str = 'RUB'
# ── S2S Postback (server-to-server affiliate notifications) ──
S2S_POSTBACK_ENABLED: bool = False
S2S_POSTBACK_REGISTRATION_URL: str = ''
S2S_POSTBACK_TRIAL_URL: str = ''
S2S_POSTBACK_PURCHASE_URL: str = ''
# RioPay (api.riopay.online) v2.0.1
RIOPAY_ENABLED: bool = False
@@ -583,6 +606,59 @@ class Settings(BaseSettings):
SEVERPAY_RETURN_URL: str | None = None
SEVERPAY_LIFETIME: int = 1440 # minutes, 30-4320
# PayPear (paypear.ru)
PAYPEAR_ENABLED: bool = False
PAYPEAR_SHOP_ID: str | None = None
PAYPEAR_SECRET_KEY: str | None = None
PAYPEAR_DISPLAY_NAME: str = 'PayPear'
PAYPEAR_CURRENCY: str = 'RUB'
PAYPEAR_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
PAYPEAR_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
PAYPEAR_WEBHOOK_PATH: str = '/paypear-webhook'
PAYPEAR_RETURN_URL: str | None = None
PAYPEAR_PAYMENT_METHOD: str = 'sbp' # bank_card, sbp, sberpay, tpay
# RollyPay (rollypay.io)
ROLLYPAY_ENABLED: bool = False
ROLLYPAY_API_KEY: str | None = None # X-API-Key header
ROLLYPAY_SIGNING_SECRET: str | None = None # HMAC webhook verification
ROLLYPAY_DISPLAY_NAME: str = 'RollyPay'
ROLLYPAY_CURRENCY: str = 'RUB'
ROLLYPAY_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
ROLLYPAY_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
ROLLYPAY_WEBHOOK_PATH: str = '/rollypay-webhook'
ROLLYPAY_RETURN_URL: str | None = None
# Overpay (pay.overpay.io)
OVERPAY_ENABLED: bool = False
OVERPAY_API_URL: str = 'https://api.overpay.io'
OVERPAY_USERNAME: str | None = None
OVERPAY_PASSWORD: str | None = None
OVERPAY_PROJECT_ID: str | None = None
OVERPAY_P12_PATH: str | None = None
OVERPAY_P12_PASSPHRASE: str | None = None
OVERPAY_DISPLAY_NAME: str = 'Overpay'
OVERPAY_CURRENCY: str = 'RUB'
OVERPAY_MIN_AMOUNT_KOPEKS: int = 10000
OVERPAY_MAX_AMOUNT_KOPEKS: int = 10000000
OVERPAY_WEBHOOK_PATH: str = '/overpay-webhook'
OVERPAY_RETURN_URL: str | None = None
OVERPAY_LIFETIME_MINUTES: int = 1440
OVERPAY_PAYMENT_METHODS: str = 'card,fps'
# AuraPay (aurapay.tech)
AURAPAY_ENABLED: bool = False
AURAPAY_API_KEY: str | None = None # X-ApiKey header
AURAPAY_SHOP_ID: str | None = None # X-ShopId header (UUID)
AURAPAY_SECRET_KEY: str | None = None # Secret key #2 for webhook HMAC
AURAPAY_DISPLAY_NAME: str = 'AuraPay'
AURAPAY_CURRENCY: str = 'RUB'
AURAPAY_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
AURAPAY_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
AURAPAY_WEBHOOK_PATH: str = '/aurapay-webhook'
AURAPAY_RETURN_URL: str | None = None
AURAPAY_PAYMENT_LIFETIME_MINUTES: int = 60
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
CABINET_BUTTON_STYLE: str = ''
@@ -727,6 +803,7 @@ class Settings(BaseSettings):
WEBHOOK_URL: str | None = None
WEBHOOK_PATH: str = '/webhook'
WEBHOOK_SECRET_TOKEN: str | None = None
WEBHOOK_IP: str | None = None # IP адрес для setWebhook, чтобы Telegram не резолвил домен
WEBHOOK_DROP_PENDING_UPDATES: bool = True
WEBHOOK_MAX_QUEUE_SIZE: int = 1024
WEBHOOK_WORKERS: int = 4
@@ -822,6 +899,10 @@ class Settings(BaseSettings):
# Format: socks5://user:password@host:port or socks5://host:port
PROXY_URL: str | None = None
# Custom Telegram Bot API server URL (for regions where api.telegram.org is blocked)
# Examples: Cloudflare Worker proxy, self-hosted telegram-bot-api (tdlib), nginx reverse proxy
TELEGRAM_API_URL: str | None = None
@field_validator('PROXY_URL', 'NALOGO_PROXY_URL', mode='before')
@classmethod
def validate_proxy_url(cls, value: str | None) -> str | None:
@@ -969,6 +1050,10 @@ class Settings(BaseSettings):
"""Return SOCKS5 proxy URL or None."""
return self.PROXY_URL if self.PROXY_URL else None
def get_telegram_api_url(self) -> str | None:
"""Return custom Telegram Bot API server URL or None."""
return self.TELEGRAM_API_URL if self.TELEGRAM_API_URL else None
def get_nalogo_proxy_url(self) -> str | None:
"""Return SOCKS proxy URL for nalogo or None.
@@ -1182,7 +1267,13 @@ class Settings(BaseSettings):
if not sanitized_username:
sanitized_username = _sanitize(f'user_{identifier}')
return sanitized_username[:36].strip('_-') or 'user'
result = sanitized_username[:36].strip('_-') or 'user'
# RemnaWave требует username минимум 3 символа
if len(result) < 3:
result = f'{result}_{identifier}'[:36].strip('_-')
return result or 'user'
@staticmethod
def parse_daily_time_list(raw_value: str | None) -> list[time]:
@@ -1839,7 +1930,7 @@ class Settings(BaseSettings):
except ValueError:
logger.warning('Некорректный код метода Platega', part=part)
continue
if method_code in {2, 10, 11, 12, 13} and method_code not in seen:
if method_code in {2, 11, 12, 13} and method_code not in seen:
methods.append(method_code)
seen.add(method_code)
@@ -1852,8 +1943,7 @@ class Settings(BaseSettings):
def get_platega_method_definitions() -> dict[int, dict[str, str]]:
return {
2: {'name': 'СБП (QR)', 'title': '🏦 СБП (QR)'},
10: {'name': 'Банковские карты (RUB)', 'title': '💳 Карты (RUB)'},
11: {'name': 'Банковские карты', 'title': '💳 Банковские карты'},
11: {'name': 'Карты (RUB)', 'title': '💳 Карты (RUB)'},
12: {'name': 'Международные карты', 'title': '🌍 Международные карты'},
13: {'name': 'Криптовалюта', 'title': '🪙 Криптовалюта'},
}
@@ -1961,6 +2051,56 @@ class Settings(BaseSettings):
def get_severpay_display_name_html(self) -> str:
return html.escape(self.get_severpay_display_name())
def is_paypear_enabled(self) -> bool:
return self.PAYPEAR_ENABLED and self.PAYPEAR_SHOP_ID is not None and self.PAYPEAR_SECRET_KEY is not None
def get_paypear_display_name(self) -> str:
name = (self.PAYPEAR_DISPLAY_NAME or '').strip()
return name if name else 'PayPear'
def get_paypear_display_name_html(self) -> str:
return html.escape(self.get_paypear_display_name())
def is_rollypay_enabled(self) -> bool:
return self.ROLLYPAY_ENABLED and self.ROLLYPAY_API_KEY is not None and self.ROLLYPAY_SIGNING_SECRET is not None
def get_rollypay_display_name(self) -> str:
name = (self.ROLLYPAY_DISPLAY_NAME or '').strip()
return name if name else 'RollyPay'
def get_rollypay_display_name_html(self) -> str:
return html.escape(self.get_rollypay_display_name())
def is_overpay_enabled(self) -> bool:
return (
self.OVERPAY_ENABLED
and self.OVERPAY_USERNAME is not None
and self.OVERPAY_PASSWORD is not None
and self.OVERPAY_PROJECT_ID is not None
)
def get_overpay_display_name(self) -> str:
name = (self.OVERPAY_DISPLAY_NAME or '').strip()
return name if name else 'Overpay'
def get_overpay_display_name_html(self) -> str:
return html.escape(self.get_overpay_display_name())
def is_aurapay_enabled(self) -> bool:
return (
self.AURAPAY_ENABLED
and self.AURAPAY_API_KEY is not None
and self.AURAPAY_SHOP_ID is not None
and self.AURAPAY_SECRET_KEY is not None
)
def get_aurapay_display_name(self) -> str:
name = (self.AURAPAY_DISPLAY_NAME or '').strip()
return name if name else 'AuraPay'
def get_aurapay_display_name_html(self) -> str:
return html.escape(self.get_aurapay_display_name())
def is_kassa_ai_sbp_enabled(self) -> bool:
return self.KASSA_AI_SBP_ENABLED and self.is_kassa_ai_enabled()
@@ -1981,6 +2121,16 @@ class Settings(BaseSettings):
def get_kassa_ai_card_display_name_html(self) -> str:
return html.escape(self.get_kassa_ai_card_display_name())
def is_kassa_ai_sberpay_enabled(self) -> bool:
return self.KASSA_AI_SBERPAY_ENABLED and self.is_kassa_ai_enabled()
def get_kassa_ai_sberpay_display_name(self) -> str:
name = (self.KASSA_AI_SBERPAY_DISPLAY_NAME or '').strip()
return name if name else 'SberPay (KassaAI)'
def get_kassa_ai_sberpay_display_name_html(self) -> str:
return html.escape(self.get_kassa_ai_sberpay_display_name())
def is_payment_verification_auto_check_enabled(self) -> bool:
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
+157
View File
@@ -0,0 +1,157 @@
"""CRUD операции для платежей AuraPay."""
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import AuraPayPayment
logger = structlog.get_logger(__name__)
async def create_aurapay_payment(
db: AsyncSession,
*,
user_id: int | None,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
payment_method: str | None = None,
aurapay_invoice_id: str | None = None,
expires_at: datetime | None = None,
metadata_json: dict | None = None,
) -> AuraPayPayment:
"""Создает запись о платеже AuraPay."""
payment = AuraPayPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method,
aurapay_invoice_id=aurapay_invoice_id,
expires_at=expires_at,
metadata_json=metadata_json,
status='pending',
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info('Создан платеж AuraPay', order_id=order_id, user_id=user_id)
return payment
async def get_aurapay_payment_by_order_id(db: AsyncSession, order_id: str) -> AuraPayPayment | None:
"""Получает платеж по order_id (internal)."""
result = await db.execute(select(AuraPayPayment).where(AuraPayPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_aurapay_payment_by_invoice_id(db: AsyncSession, aurapay_invoice_id: str) -> AuraPayPayment | None:
"""Получает платеж по UUID от AuraPay."""
result = await db.execute(select(AuraPayPayment).where(AuraPayPayment.aurapay_invoice_id == aurapay_invoice_id))
return result.scalar_one_or_none()
async def get_aurapay_payment_by_id(db: AsyncSession, payment_id: int) -> AuraPayPayment | None:
"""Получает платеж по ID."""
result = await db.execute(select(AuraPayPayment).where(AuraPayPayment.id == payment_id))
return result.scalar_one_or_none()
async def get_aurapay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> AuraPayPayment | None:
"""Получает платеж по ID с блокировкой FOR UPDATE."""
result = await db.execute(
select(AuraPayPayment)
.where(AuraPayPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
async def update_aurapay_payment_status(
db: AsyncSession,
payment: AuraPayPayment,
*,
status: str,
is_paid: bool | None = None,
aurapay_invoice_id: str | None = None,
payment_method: str | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> AuraPayPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.updated_at = datetime.now(UTC)
if is_paid is not None:
payment.is_paid = is_paid
if is_paid:
payment.paid_at = datetime.now(UTC)
if aurapay_invoice_id is not None:
payment.aurapay_invoice_id = aurapay_invoice_id
if payment_method is not None:
payment.payment_method = payment_method
if callback_payload is not None:
payment.callback_payload = callback_payload
if transaction_id is not None:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(
'Обновлен статус платежа AuraPay',
order_id=payment.order_id,
status=status,
is_paid=payment.is_paid,
)
return payment
async def get_pending_aurapay_payments(db: AsyncSession, user_id: int) -> list[AuraPayPayment]:
"""Получает незавершенные платежи пользователя."""
result = await db.execute(
select(AuraPayPayment).where(
AuraPayPayment.user_id == user_id,
AuraPayPayment.status == 'pending',
AuraPayPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_expired_pending_aurapay_payments(
db: AsyncSession,
) -> list[AuraPayPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.now(UTC)
result = await db.execute(
select(AuraPayPayment).where(
AuraPayPayment.status == 'pending',
AuraPayPayment.is_paid == False,
AuraPayPayment.expires_at < now,
)
)
return list(result.scalars().all())
async def link_aurapay_payment_to_transaction(
db: AsyncSession,
*,
payment: AuraPayPayment,
transaction_id: int,
) -> AuraPayPayment:
"""Связывает платеж с транзакцией."""
payment.transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(payment)
return payment
+214
View File
@@ -0,0 +1,214 @@
"""CRUD operations for info pages."""
from datetime import UTC, datetime
from typing import Any
import structlog
from sqlalchemy import delete, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import InfoPage
logger = structlog.get_logger(__name__)
# Fields that can be set via update_info_page
_ALLOWED_UPDATE_FIELDS: frozenset[str] = frozenset(
{
'slug',
'title',
'content',
'page_type',
'is_active',
'sort_order',
'icon',
'replaces_tab',
}
)
# Fields that can be explicitly set to None
_NULLABLE_UPDATE_FIELDS: frozenset[str] = frozenset(
{
'icon',
'replaces_tab',
}
)
async def create_info_page(
db: AsyncSession,
*,
slug: str,
title: dict[str, str],
content: dict[str, str],
page_type: str = 'page',
is_active: bool = True,
sort_order: int = 0,
icon: str | None = None,
replaces_tab: str | None = None,
) -> InfoPage:
"""Create a new info page.
Raises:
IntegrityError: if slug is not unique (caller must handle).
"""
page = InfoPage(
slug=slug,
title=title,
content=content,
page_type=page_type,
is_active=is_active,
sort_order=sort_order,
icon=icon,
replaces_tab=replaces_tab,
)
db.add(page)
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise
await db.refresh(page)
logger.info('Created info page', page_id=page.id, slug=page.slug)
return page
async def get_info_page_by_id(db: AsyncSession, page_id: int) -> InfoPage | None:
"""Get an info page by ID."""
result = await db.execute(select(InfoPage).where(InfoPage.id == page_id))
return result.scalar_one_or_none()
async def get_info_page_by_slug(db: AsyncSession, slug: str) -> InfoPage | None:
"""Get an info page by slug."""
result = await db.execute(select(InfoPage).where(InfoPage.slug == slug))
return result.scalar_one_or_none()
async def get_all_info_pages(
db: AsyncSession,
*,
include_inactive: bool = False,
page_type: str | None = None,
) -> list[InfoPage]:
"""Get all info pages, ordered by sort_order ascending."""
stmt = select(InfoPage)
if not include_inactive:
stmt = stmt.where(InfoPage.is_active.is_(True))
if page_type is not None:
stmt = stmt.where(InfoPage.page_type == page_type)
stmt = stmt.order_by(InfoPage.sort_order.asc(), InfoPage.id.asc())
result = await db.execute(stmt)
return list(result.scalars().all())
async def update_info_page(
db: AsyncSession,
page_id: int,
**kwargs: Any,
) -> InfoPage | None:
"""Update an info page. Only whitelisted fields are applied.
Raises:
IntegrityError: if slug conflicts with another page (caller must handle).
"""
update_data: dict[str, Any] = {}
for key, value in kwargs.items():
if key not in _ALLOWED_UPDATE_FIELDS:
continue
if value is None and key not in _NULLABLE_UPDATE_FIELDS:
continue
update_data[key] = value
if not update_data:
return await get_info_page_by_id(db, page_id)
update_data['updated_at'] = datetime.now(UTC)
await db.execute(update(InfoPage).where(InfoPage.id == page_id).values(**update_data))
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise
page = await get_info_page_by_id(db, page_id)
if page:
logger.info(
'Updated info page',
page_id=page_id,
updated_fields=list(update_data.keys()),
)
return page
async def delete_info_page(db: AsyncSession, page_id: int) -> None:
"""Delete an info page."""
await db.execute(delete(InfoPage).where(InfoPage.id == page_id))
await db.commit()
logger.info('Deleted info page', page_id=page_id)
async def get_tab_replacements(db: AsyncSession) -> dict[str, str | None]:
"""Return a mapping of tab name to info page slug for active pages with replaces_tab set.
Returns dict like ``{'faq': 'my-custom-faq', 'rules': None, 'privacy': None, 'offer': None}``.
"""
result_map: dict[str, str | None] = {
'faq': None,
'rules': None,
'privacy': None,
'offer': None,
}
stmt = select(InfoPage).where(
InfoPage.is_active.is_(True),
InfoPage.replaces_tab.isnot(None),
)
result = await db.execute(stmt)
for page in result.scalars().all():
if page.replaces_tab in result_map:
result_map[page.replaces_tab] = page.slug
return result_map
async def clear_replaces_tab(db: AsyncSession, tab: str, *, exclude_page_id: int | None = None) -> None:
"""Clear replaces_tab for all pages that currently replace the given tab.
Optionally exclude a specific page (the one being saved).
"""
stmt = (
update(InfoPage)
.where(
InfoPage.replaces_tab == tab,
)
.values(replaces_tab=None, updated_at=datetime.now(UTC))
)
if exclude_page_id is not None:
stmt = stmt.where(InfoPage.id != exclude_page_id)
await db.execute(stmt)
async def reorder_info_pages(db: AsyncSession, items: list[dict]) -> None:
"""Bulk update sort_order for info pages.
Each item must have ``id`` and ``sort_order`` attributes.
"""
for item in items:
page_id = item.id if hasattr(item, 'id') else item.get('id')
sort_order = item.sort_order if hasattr(item, 'sort_order') else item.get('sort_order')
if page_id is None or sort_order is None:
continue
await db.execute(
update(InfoPage).where(InfoPage.id == page_id).values(sort_order=sort_order, updated_at=datetime.now(UTC))
)
await db.commit()
logger.info('Reordered info pages', count=len(items))
+5
View File
@@ -76,6 +76,11 @@ _LANDING_UPDATABLE_FIELDS = frozenset(
'discount_ends_at',
'discount_badge_text',
'background_config',
'sticky_pay_button',
'analytics_view_enabled',
'analytics_view_goal',
'analytics_click_enabled',
'analytics_click_goal',
}
)
+157
View File
@@ -0,0 +1,157 @@
"""CRUD операции для платежей Overpay."""
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import OverpayPayment
logger = structlog.get_logger(__name__)
async def create_overpay_payment(
db: AsyncSession,
*,
user_id: int | None,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
payment_method: str | None = None,
overpay_payment_id: str | None = None,
expires_at: datetime | None = None,
metadata_json: dict | None = None,
) -> OverpayPayment:
"""Создает запись о платеже Overpay."""
payment = OverpayPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method,
overpay_payment_id=overpay_payment_id,
expires_at=expires_at,
metadata_json=metadata_json,
status='pending',
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info('Создан платеж Overpay', order_id=order_id, user_id=user_id)
return payment
async def get_overpay_payment_by_order_id(db: AsyncSession, order_id: str) -> OverpayPayment | None:
"""Получает платеж по order_id (internal)."""
result = await db.execute(select(OverpayPayment).where(OverpayPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_overpay_payment_by_overpay_id(db: AsyncSession, overpay_payment_id: str) -> OverpayPayment | None:
"""Получает платеж по ID от Overpay."""
result = await db.execute(select(OverpayPayment).where(OverpayPayment.overpay_payment_id == overpay_payment_id))
return result.scalar_one_or_none()
async def get_overpay_payment_by_id(db: AsyncSession, payment_id: int) -> OverpayPayment | None:
"""Получает платеж по ID."""
result = await db.execute(select(OverpayPayment).where(OverpayPayment.id == payment_id))
return result.scalar_one_or_none()
async def get_overpay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> OverpayPayment | None:
"""Получает платеж по ID с блокировкой FOR UPDATE."""
result = await db.execute(
select(OverpayPayment)
.where(OverpayPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
async def update_overpay_payment_status(
db: AsyncSession,
payment: OverpayPayment,
*,
status: str,
is_paid: bool | None = None,
overpay_payment_id: str | None = None,
payment_method: str | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> OverpayPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.updated_at = datetime.now(UTC)
if is_paid is not None:
payment.is_paid = is_paid
if is_paid:
payment.paid_at = datetime.now(UTC)
if overpay_payment_id is not None:
payment.overpay_payment_id = overpay_payment_id
if payment_method is not None:
payment.payment_method = payment_method
if callback_payload is not None:
payment.callback_payload = callback_payload
if transaction_id is not None:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(
'Обновлен статус платежа Overpay',
order_id=payment.order_id,
status=status,
is_paid=payment.is_paid,
)
return payment
async def get_pending_overpay_payments(db: AsyncSession, user_id: int) -> list[OverpayPayment]:
"""Получает незавершенные платежи пользователя."""
result = await db.execute(
select(OverpayPayment).where(
OverpayPayment.user_id == user_id,
OverpayPayment.status == 'pending',
OverpayPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_expired_pending_overpay_payments(
db: AsyncSession,
) -> list[OverpayPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.now(UTC)
result = await db.execute(
select(OverpayPayment).where(
OverpayPayment.status == 'pending',
OverpayPayment.is_paid == False,
OverpayPayment.expires_at < now,
)
)
return list(result.scalars().all())
async def link_overpay_payment_to_transaction(
db: AsyncSession,
*,
payment: OverpayPayment,
transaction_id: int,
) -> OverpayPayment:
"""Связывает платеж с транзакцией."""
payment.transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(payment)
return payment
+157
View File
@@ -0,0 +1,157 @@
"""CRUD операции для платежей PayPear."""
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PayPearPayment
logger = structlog.get_logger(__name__)
async def create_paypear_payment(
db: AsyncSession,
*,
user_id: int | None,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
payment_method: str | None = None,
paypear_id: str | None = None,
expires_at: datetime | None = None,
metadata_json: dict | None = None,
) -> PayPearPayment:
"""Создает запись о платеже PayPear."""
payment = PayPearPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method,
paypear_id=paypear_id,
expires_at=expires_at,
metadata_json=metadata_json,
status='pending',
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info('Создан платеж PayPear', order_id=order_id, user_id=user_id)
return payment
async def get_paypear_payment_by_order_id(db: AsyncSession, order_id: str) -> PayPearPayment | None:
"""Получает платеж по order_id (internal)."""
result = await db.execute(select(PayPearPayment).where(PayPearPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_paypear_payment_by_paypear_id(db: AsyncSession, paypear_id: str) -> PayPearPayment | None:
"""Получает платеж по ID от PayPear."""
result = await db.execute(select(PayPearPayment).where(PayPearPayment.paypear_id == paypear_id))
return result.scalar_one_or_none()
async def get_paypear_payment_by_id(db: AsyncSession, payment_id: int) -> PayPearPayment | None:
"""Получает платеж по ID."""
result = await db.execute(select(PayPearPayment).where(PayPearPayment.id == payment_id))
return result.scalar_one_or_none()
async def get_paypear_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> PayPearPayment | None:
"""Получает платеж по ID с блокировкой FOR UPDATE."""
result = await db.execute(
select(PayPearPayment)
.where(PayPearPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
async def update_paypear_payment_status(
db: AsyncSession,
payment: PayPearPayment,
*,
status: str,
is_paid: bool | None = None,
paypear_id: str | None = None,
payment_method: str | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> PayPearPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.updated_at = datetime.now(UTC)
if is_paid is not None:
payment.is_paid = is_paid
if is_paid:
payment.paid_at = datetime.now(UTC)
if paypear_id is not None:
payment.paypear_id = paypear_id
if payment_method is not None:
payment.payment_method = payment_method
if callback_payload is not None:
payment.callback_payload = callback_payload
if transaction_id is not None:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(
'Обновлен статус платежа PayPear',
order_id=payment.order_id,
status=status,
is_paid=payment.is_paid,
)
return payment
async def get_pending_paypear_payments(db: AsyncSession, user_id: int) -> list[PayPearPayment]:
"""Получает незавершенные платежи пользователя."""
result = await db.execute(
select(PayPearPayment).where(
PayPearPayment.user_id == user_id,
PayPearPayment.status == 'pending',
PayPearPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_expired_pending_paypear_payments(
db: AsyncSession,
) -> list[PayPearPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.now(UTC)
result = await db.execute(
select(PayPearPayment).where(
PayPearPayment.status == 'pending',
PayPearPayment.is_paid == False,
PayPearPayment.expires_at < now,
)
)
return list(result.scalars().all())
async def link_paypear_payment_to_transaction(
db: AsyncSession,
*,
payment: PayPearPayment,
transaction_id: int,
) -> PayPearPayment:
"""Связывает платеж с транзакцией."""
payment.transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(payment)
return payment
+1 -1
View File
@@ -166,7 +166,7 @@ async def update_promo_group(
group.device_discount_percent = max(0, min(100, device_discount_percent))
if period_discounts is not None:
normalized_period_discounts = _normalize_period_discounts(period_discounts)
group.period_discounts = normalized_period_discounts or None
group.period_discounts = normalized_period_discounts if normalized_period_discounts else None
if auto_assign_total_spent_kopeks is not None:
value = max(0, auto_assign_total_spent_kopeks)
group.auto_assign_total_spent_kopeks = value if value > 0 else None
+157
View File
@@ -0,0 +1,157 @@
"""CRUD операции для платежей RollyPay."""
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import RollyPayPayment
logger = structlog.get_logger(__name__)
async def create_rollypay_payment(
db: AsyncSession,
*,
user_id: int | None,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
payment_method: str | None = None,
rollypay_payment_id: str | None = None,
expires_at: datetime | None = None,
metadata_json: dict | None = None,
) -> RollyPayPayment:
"""Создает запись о платеже RollyPay."""
payment = RollyPayPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method,
rollypay_payment_id=rollypay_payment_id,
expires_at=expires_at,
metadata_json=metadata_json,
status='pending',
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info('Создан платеж RollyPay', order_id=order_id, user_id=user_id)
return payment
async def get_rollypay_payment_by_order_id(db: AsyncSession, order_id: str) -> RollyPayPayment | None:
"""Получает платеж по order_id (internal)."""
result = await db.execute(select(RollyPayPayment).where(RollyPayPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_rollypay_payment_by_rollypay_id(db: AsyncSession, rollypay_payment_id: str) -> RollyPayPayment | None:
"""Получает платеж по ID от RollyPay."""
result = await db.execute(select(RollyPayPayment).where(RollyPayPayment.rollypay_payment_id == rollypay_payment_id))
return result.scalar_one_or_none()
async def get_rollypay_payment_by_id(db: AsyncSession, payment_id: int) -> RollyPayPayment | None:
"""Получает платеж по ID."""
result = await db.execute(select(RollyPayPayment).where(RollyPayPayment.id == payment_id))
return result.scalar_one_or_none()
async def get_rollypay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> RollyPayPayment | None:
"""Получает платеж по ID с блокировкой FOR UPDATE."""
result = await db.execute(
select(RollyPayPayment)
.where(RollyPayPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
async def update_rollypay_payment_status(
db: AsyncSession,
payment: RollyPayPayment,
*,
status: str,
is_paid: bool | None = None,
rollypay_payment_id: str | None = None,
payment_method: str | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> RollyPayPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.updated_at = datetime.now(UTC)
if is_paid is not None:
payment.is_paid = is_paid
if is_paid:
payment.paid_at = datetime.now(UTC)
if rollypay_payment_id is not None:
payment.rollypay_payment_id = rollypay_payment_id
if payment_method is not None:
payment.payment_method = payment_method
if callback_payload is not None:
payment.callback_payload = callback_payload
if transaction_id is not None:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(
'Обновлен статус платежа RollyPay',
order_id=payment.order_id,
status=status,
is_paid=payment.is_paid,
)
return payment
async def get_pending_rollypay_payments(db: AsyncSession, user_id: int) -> list[RollyPayPayment]:
"""Получает незавершенные платежи пользователя."""
result = await db.execute(
select(RollyPayPayment).where(
RollyPayPayment.user_id == user_id,
RollyPayPayment.status == 'pending',
RollyPayPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_expired_pending_rollypay_payments(
db: AsyncSession,
) -> list[RollyPayPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.now(UTC)
result = await db.execute(
select(RollyPayPayment).where(
RollyPayPayment.status == 'pending',
RollyPayPayment.is_paid == False,
RollyPayPayment.expires_at < now,
)
)
return list(result.scalars().all())
async def link_rollypay_payment_to_transaction(
db: AsyncSession,
*,
payment: RollyPayPayment,
transaction_id: int,
) -> RollyPayPayment:
"""Связывает платеж с транзакцией."""
payment.transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(payment)
return payment
+23 -1
View File
@@ -33,7 +33,15 @@ logger = structlog.get_logger(__name__)
async def _get_default_promo_group_id(db: AsyncSession) -> int | None:
result = await db.execute(select(PromoGroup.id).where(PromoGroup.is_default.is_(True)).limit(1))
return result.scalar_one_or_none()
default_id = result.scalar_one_or_none()
if default_id is not None:
return default_id
# На пустой БД дефолтной промогруппы нет — создаём автоматически
from app.database.crud.user import _get_or_create_default_promo_group
default_group = await _get_or_create_default_promo_group(db)
return default_group.id
async def create_server_squad(
@@ -155,6 +163,20 @@ async def get_available_server_squads(
return result.scalars().unique().all()
async def get_effective_tariff_squad_uuids(
db: AsyncSession,
allowed_squads: Sequence[str] | None,
) -> list[str]:
"""Resolve tariff squads, treating an empty list as "all available squads"."""
normalized = [str(squad_uuid) for squad_uuid in (allowed_squads or []) if squad_uuid]
if normalized:
return list(dict.fromkeys(normalized))
available = await get_available_server_squads(db)
return [squad.squad_uuid for squad in available if squad.squad_uuid]
async def get_active_server_squads(db: AsyncSession) -> list[ServerSquad]:
"""Возвращает список активных серверов, доступных для подключения."""
+35 -12
View File
@@ -96,12 +96,13 @@ async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Subscri
)
.where(Subscription.user_id == user_id)
.order_by(
# Active/trial subscriptions first, then by creation date
# Active/trial subscriptions first, then by end_date (most remaining time)
case(
(Subscription.status == SubscriptionStatus.ACTIVE.value, 0),
(Subscription.status == SubscriptionStatus.TRIAL.value, 1),
else_=2,
),
Subscription.end_date.desc().nulls_last(),
Subscription.created_at.desc(),
)
.limit(1)
@@ -141,8 +142,8 @@ async def create_trial_subscription(
if device_limit is None:
device_limit = settings.TRIAL_DEVICE_LIMIT
# Если переданы connected_squads, используем их
# Иначе используем squad_uuid или получаем случайный
# Если переданы connected_squads, используем их.
# Иначе используем squad_uuid или все доступные сквады по умолчанию.
final_squads = []
if connected_squads:
final_squads = connected_squads
@@ -150,13 +151,14 @@ async def create_trial_subscription(
final_squads = [squad_uuid]
else:
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
random_squad = await get_random_trial_squad_uuid(db)
if random_squad:
final_squads = [random_squad]
final_squads = await get_effective_tariff_squad_uuids(db, None)
if final_squads:
logger.debug(
'Выбран сквад для триальной подписки пользователя', random_squad=random_squad, user_id=user_id
'Выбраны дефолтные сквады для триальной подписки пользователя',
final_squads=final_squads,
user_id=user_id,
)
except Exception as error:
logger.error('Не удалось получить сквад для триальной подписки пользователя', user_id=user_id, error=error)
@@ -2075,7 +2077,12 @@ async def toggle_daily_subscription_pause(
async def get_active_subscriptions_by_user_id(db: AsyncSession, user_id: int) -> list[Subscription]:
"""Get all active/trial subscriptions for a user."""
"""Get all active/trial/limited subscriptions for a user.
Includes LIMITED status because those subscriptions still have time remaining
(just ran out of traffic) and should be treated as "alive" for renewal,
duplicate prevention, and display purposes.
"""
result = await db.execute(
select(Subscription)
.options(
@@ -2084,7 +2091,13 @@ async def get_active_subscriptions_by_user_id(db: AsyncSession, user_id: int) ->
)
.where(
Subscription.user_id == user_id,
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
Subscription.status.in_(
[
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.TRIAL.value,
SubscriptionStatus.LIMITED.value,
]
),
)
.order_by(Subscription.created_at.desc())
)
@@ -2121,7 +2134,11 @@ async def get_subscription_by_id(db: AsyncSession, subscription_id: int) -> Subs
async def get_subscription_by_user_and_tariff(db: AsyncSession, user_id: int, tariff_id: int) -> Subscription | None:
"""Get active/trial subscription for a specific user+tariff combination."""
"""Get active/trial/limited subscription for a specific user+tariff combination.
Includes LIMITED status because those subscriptions still have time remaining
(just ran out of traffic) and should be extended rather than duplicated.
"""
result = await db.execute(
select(Subscription)
.options(
@@ -2131,7 +2148,13 @@ async def get_subscription_by_user_and_tariff(db: AsyncSession, user_id: int, ta
.where(
Subscription.user_id == user_id,
Subscription.tariff_id == tariff_id,
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
Subscription.status.in_(
[
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.TRIAL.value,
SubscriptionStatus.LIMITED.value,
]
),
)
.order_by(Subscription.created_at.desc())
.limit(1)
+6
View File
@@ -122,6 +122,12 @@ async def clear_trial_tariff(db: AsyncSession) -> None:
await db.commit()
async def get_all_active_tariffs(db: AsyncSession) -> list[Tariff]:
"""Get all active tariffs."""
result = await db.execute(select(Tariff).where(Tariff.is_active.is_(True)).order_by(Tariff.tier_level))
return list(result.scalars().all())
async def get_tariffs_for_user(
db: AsyncSession,
promo_group_id: int | None = None,
+6 -2
View File
@@ -25,6 +25,7 @@ class TicketCRUD:
media_type: str | None = None,
media_file_id: str | None = None,
media_caption: str | None = None,
media_items: list[dict] | None = None,
) -> Ticket:
"""Создать новый тикет с первым сообщением"""
ticket = Ticket(user_id=user_id, title=title, status=TicketStatus.OPEN.value, priority=priority)
@@ -37,10 +38,11 @@ class TicketCRUD:
user_id=user_id,
message_text=message_text,
is_from_admin=False,
has_media=bool(media_type and media_file_id),
has_media=bool(media_type and media_file_id) or bool(media_items),
media_type=media_type,
media_file_id=media_file_id,
media_caption=media_caption,
media_items=media_items,
)
db.add(message)
@@ -381,6 +383,7 @@ class TicketMessageCRUD:
media_type: str | None = None,
media_file_id: str | None = None,
media_caption: str | None = None,
media_items: list[dict] | None = None,
) -> TicketMessage:
"""Добавить сообщение в тикет"""
message = TicketMessage(
@@ -388,10 +391,11 @@ class TicketMessageCRUD:
user_id=user_id,
message_text=message_text,
is_from_admin=is_from_admin,
has_media=bool(media_type and media_file_id),
has_media=bool(media_type and media_file_id) or bool(media_items),
media_type=media_type,
media_file_id=media_file_id,
media_caption=media_caption,
media_items=media_items,
)
db.add(message)
+5
View File
@@ -230,6 +230,11 @@ async def get_user_transactions_count(
async def get_user_total_spent_kopeks(db: AsyncSession, user_id: int) -> int:
"""Sum of personal spending for promo group auto-assignment.
Only counts SUBSCRIPTION_PAYMENT (user's own subscriptions).
GIFT_PAYMENT is excluded buying a gift for someone else is not personal spending.
"""
result = await db.execute(
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
and_(
+144 -26
View File
@@ -3,7 +3,7 @@ import string
from datetime import UTC, datetime, timedelta
import structlog
from sqlalchemy import and_, case, func, nullslast, or_, select, text
from sqlalchemy import and_, case, exists, func, nullslast, or_, select, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -13,6 +13,8 @@ from app.database.crud.discount_offer import get_latest_claimed_offer_for_user
from app.database.crud.promo_group import get_default_promo_group
from app.database.crud.promo_offer_log import log_promo_offer_action
from app.database.models import (
AdvertisingCampaign,
AdvertisingCampaignRegistration,
PaymentMethod,
PromoGroup,
Subscription,
@@ -710,30 +712,52 @@ async def subtract_user_balance(
await db.refresh(user)
if consume_promo_offer and log_context:
try:
await log_promo_offer_action(
db,
user_id=user.id,
offer_id=log_context.get('offer_id'),
action='consumed',
source=log_context.get('source'),
percent=log_context.get('percent'),
effect_type=log_context.get('effect_type'),
details=log_context.get('details'),
commit=commit,
)
except Exception as log_error: # pragma: no cover - defensive logging
logger.warning(
'Failed to record promo offer consumption log for user', user_id=user.id, log_error=log_error
)
if commit:
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
'Failed to rollback session after promo offer consumption log failure',
rollback_error=rollback_error,
# Пишем лог в ОТДЕЛЬНОЙ сессии, чтобы его commit/rollback не касался
# основной сессии caller'а. Иначе rollback в случае фейла логирования
# экспайрит объекты сессии и следующее обращение к subscription/user
# attrs у caller'а падает с MissingGreenlet.
if commit:
try:
from app.database.database import AsyncSessionLocal
async with AsyncSessionLocal() as log_db:
await log_promo_offer_action(
log_db,
user_id=user.id,
offer_id=log_context.get('offer_id'),
action='consumed',
source=log_context.get('source'),
percent=log_context.get('percent'),
effect_type=log_context.get('effect_type'),
details=log_context.get('details'),
commit=True,
)
except Exception as log_error: # pragma: no cover - defensive logging
logger.warning(
'Failed to record promo offer consumption log for user',
user_id=user.id,
log_error=log_error,
)
else:
# Caller управляет транзакцией — пишем в его сессию без commit.
try:
await log_promo_offer_action(
db,
user_id=user.id,
offer_id=log_context.get('offer_id'),
action='consumed',
source=log_context.get('source'),
percent=log_context.get('percent'),
effect_type=log_context.get('effect_type'),
details=log_context.get('details'),
commit=False,
)
except Exception as log_error: # pragma: no cover - defensive logging
logger.warning(
'Failed to record promo offer consumption log for user',
user_id=user.id,
log_error=log_error,
)
logger.info('✅ Средства списаны: →', old_balance=old_balance, balance_kopeks=user.balance_kopeks)
return True
@@ -839,6 +863,11 @@ async def get_users_list(
search: str | None = None,
email: str | None = None,
status: UserStatus | None = None,
subscription_status: str | None = None,
tariff_ids: list[int] | None = None,
promo_group_id: int | None = None,
campaign_id: int | None = None,
partner_id: int | None = None,
order_by_balance: bool = False,
order_by_traffic: bool = False,
order_by_last_activity: bool = False,
@@ -854,6 +883,41 @@ async def get_users_list(
if status:
query = query.where(User.status == status.value)
# Subscription-level filters via subquery
if subscription_status or tariff_ids:
sub_conditions = []
if subscription_status:
sub_conditions.append(Subscription.status == subscription_status)
if tariff_ids:
sub_conditions.append(Subscription.tariff_id.in_(tariff_ids))
sub_query = select(Subscription.user_id).where(and_(*sub_conditions)).distinct().scalar_subquery()
query = query.where(User.id.in_(sub_query))
if promo_group_id:
query = query.where(User.promo_group_id == promo_group_id)
if campaign_id:
query = query.where(
exists(
select(AdvertisingCampaignRegistration.id).where(
AdvertisingCampaignRegistration.user_id == User.id,
AdvertisingCampaignRegistration.campaign_id == campaign_id,
)
)
)
if partner_id:
query = query.where(
exists(
select(AdvertisingCampaignRegistration.id)
.join(AdvertisingCampaign, AdvertisingCampaign.id == AdvertisingCampaignRegistration.campaign_id)
.where(
AdvertisingCampaignRegistration.user_id == User.id,
AdvertisingCampaign.partner_user_id == partner_id,
)
)
)
if search:
search_term = f'%{search}%'
conditions = [
@@ -933,13 +997,55 @@ async def get_users_list(
async def get_users_count(
db: AsyncSession, status: UserStatus | None = None, search: str | None = None, email: str | None = None
db: AsyncSession,
status: UserStatus | None = None,
search: str | None = None,
email: str | None = None,
subscription_status: str | None = None,
tariff_ids: list[int] | None = None,
promo_group_id: int | None = None,
campaign_id: int | None = None,
partner_id: int | None = None,
) -> int:
query = select(func.count(User.id))
if status:
query = query.where(User.status == status.value)
if subscription_status or tariff_ids:
sub_conditions = []
if subscription_status:
sub_conditions.append(Subscription.status == subscription_status)
if tariff_ids:
sub_conditions.append(Subscription.tariff_id.in_(tariff_ids))
sub_query = select(Subscription.user_id).where(and_(*sub_conditions)).distinct().scalar_subquery()
query = query.where(User.id.in_(sub_query))
if promo_group_id:
query = query.where(User.promo_group_id == promo_group_id)
if campaign_id:
query = query.where(
exists(
select(AdvertisingCampaignRegistration.id).where(
AdvertisingCampaignRegistration.user_id == User.id,
AdvertisingCampaignRegistration.campaign_id == campaign_id,
)
)
)
if partner_id:
query = query.where(
exists(
select(AdvertisingCampaignRegistration.id)
.join(AdvertisingCampaign, AdvertisingCampaign.id == AdvertisingCampaignRegistration.campaign_id)
.where(
AdvertisingCampaignRegistration.user_id == User.id,
AdvertisingCampaign.partner_user_id == partner_id,
)
)
)
if search:
search_term = f'%{search}%'
conditions = [
@@ -1090,6 +1196,12 @@ async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> list[Us
async def get_inactive_users(db: AsyncSession, months: int = 3) -> list[User]:
threshold_date = datetime.now(UTC) - timedelta(days=months * 30)
# Подзапрос: пользователи, у которых есть подписка с end_date >= threshold
# (активная или недавно истёкшая) — таких удалять нельзя
users_with_recent_subs = (
select(Subscription.user_id).where(Subscription.end_date >= threshold_date).distinct().scalar_subquery()
)
result = await db.execute(
select(User)
.options(
@@ -1098,7 +1210,13 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> list[User]:
selectinload(User.referrer),
selectinload(User.promo_group),
)
.where(and_(User.last_activity < threshold_date, User.status == UserStatus.ACTIVE.value))
.where(
and_(
User.last_activity < threshold_date,
User.status == UserStatus.ACTIVE.value,
User.id.not_in(users_with_recent_subs),
)
)
)
users = result.scalars().all()
+108
View File
@@ -0,0 +1,108 @@
"""CRUD operations for yandex_client_id_map table."""
from __future__ import annotations
from datetime import UTC, datetime
import structlog
from sqlalchemy import select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import YandexClientIdMap
logger = structlog.get_logger(__name__)
async def upsert_cid(
db: AsyncSession,
user_id: int,
cid: str,
source: str = 'web',
counter_id: str | None = None,
subid: str | None = None,
) -> YandexClientIdMap:
"""Insert or update Yandex ClientID for a user (race-safe via ON CONFLICT)."""
now = datetime.now(UTC)
values = {
'yandex_cid': cid,
'source': source,
'updated_at': now,
}
if counter_id:
values['counter_id'] = counter_id
if subid:
values['subid'] = subid
stmt = (
pg_insert(YandexClientIdMap)
.values(user_id=user_id, yandex_cid=cid, source=source, counter_id=counter_id, subid=subid)
.on_conflict_do_update(index_elements=['user_id'], set_=values)
.returning(YandexClientIdMap)
)
result = await db.execute(stmt)
await db.flush()
return result.scalar_one()
async def get_cid(db: AsyncSession, user_id: int) -> YandexClientIdMap | None:
"""Get Yandex ClientID mapping for a user."""
result = await db.execute(select(YandexClientIdMap).where(YandexClientIdMap.user_id == user_id))
return result.scalar_one_or_none()
async def mark_registration_sent(db: AsyncSession, user_id: int) -> None:
"""Mark registration event as sent for a user."""
await db.execute(
update(YandexClientIdMap)
.where(YandexClientIdMap.user_id == user_id)
.values(registration_sent=True, updated_at=datetime.now(UTC))
)
await db.flush()
async def mark_trial_sent(db: AsyncSession, user_id: int) -> None:
"""Mark trial event as sent for a user."""
await db.execute(
update(YandexClientIdMap)
.where(YandexClientIdMap.user_id == user_id)
.values(trial_sent=True, updated_at=datetime.now(UTC))
)
await db.flush()
async def upsert_subid(
db: AsyncSession,
user_id: int,
subid: str,
source: str = 'web',
) -> None:
"""Save subid for a user. Updates existing record or creates with placeholder CID."""
if not subid or len(subid) > 255:
return
now = datetime.now(UTC)
# Try update first (don't create empty CID records)
result = await db.execute(
update(YandexClientIdMap).where(YandexClientIdMap.user_id == user_id).values(subid=subid, updated_at=now)
)
if result.rowcount == 0:
# No existing record — create with placeholder
stmt = (
pg_insert(YandexClientIdMap)
.values(user_id=user_id, yandex_cid='_subid_only', source=source, subid=subid)
.on_conflict_do_update(
index_elements=['user_id'],
set_={'subid': subid, 'updated_at': now},
)
)
await db.execute(stmt)
await db.flush()
logger.info('Subid saved', user_id=user_id, subid=subid, source=source)
async def get_subid(db: AsyncSession, user_id: int) -> str | None:
"""Get subid for a user."""
result = await db.execute(select(YandexClientIdMap.subid).where(YandexClientIdMap.user_id == user_id))
return result.scalar_one_or_none()
+312 -2
View File
@@ -162,6 +162,10 @@ class PaymentMethod(Enum):
KASSA_AI = 'kassa_ai'
RIOPAY = 'riopay'
SEVERPAY = 'severpay'
PAYPEAR = 'paypear'
ROLLYPAY = 'rollypay'
OVERPAY = 'overpay'
AURAPAY = 'aurapay'
MANUAL = 'manual'
BALANCE = 'balance'
@@ -878,6 +882,254 @@ class SeverPayPayment(Base):
return f'<SeverPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class PayPearPayment(Base):
"""Платежи через PayPear (paypear.ru)."""
__tablename__ = 'paypear_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
# Идентификаторы
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
paypear_id = Column(String(64), unique=True, nullable=True, index=True) # ID от PayPear
# Суммы
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default='RUB')
description = Column(Text, nullable=True)
# Статусы
status = Column(String(32), nullable=False, default='pending')
is_paid = Column(Boolean, default=False)
# Данные платежа
payment_url = Column(Text, nullable=True)
payment_method = Column(String(32), nullable=True)
# Метаданные
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
# Временные метки
paid_at = Column(AwareDateTime(), nullable=True)
expires_at = Column(AwareDateTime(), nullable=True)
created_at = Column(AwareDateTime(), default=func.now())
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
# Связь с транзакцией
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
# Relationships
user = relationship('User', backref='paypear_payments')
transaction = relationship('Transaction', backref='paypear_payment')
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
@property
def is_pending(self) -> bool:
return self.status == 'pending'
@property
def is_success(self) -> bool:
return self.status == 'success' and self.is_paid
@property
def is_failed(self) -> bool:
return self.status in ['failed', 'expired', 'canceled', 'amount_mismatch']
def __repr__(self) -> str: # pragma: no cover - debug helper
return f'<PayPearPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class RollyPayPayment(Base):
"""Платежи через RollyPay (rollypay.io)."""
__tablename__ = 'rollypay_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
# Идентификаторы
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
rollypay_payment_id = Column(String(128), unique=True, nullable=True, index=True) # pay_uuid от RollyPay
# Суммы
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default='RUB')
description = Column(Text, nullable=True)
# Статусы
status = Column(String(32), nullable=False, default='pending')
is_paid = Column(Boolean, default=False)
# Данные платежа
payment_url = Column(Text, nullable=True)
payment_method = Column(String(32), nullable=True)
# Метаданные
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
# Временные метки
paid_at = Column(AwareDateTime(), nullable=True)
expires_at = Column(AwareDateTime(), nullable=True)
created_at = Column(AwareDateTime(), default=func.now())
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
# Связь с транзакцией
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
# Relationships
user = relationship('User', backref='rollypay_payments')
transaction = relationship('Transaction', backref='rollypay_payment')
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
@property
def is_pending(self) -> bool:
return self.status == 'pending'
@property
def is_success(self) -> bool:
return self.status == 'success' and self.is_paid
@property
def is_failed(self) -> bool:
return self.status in ['failed', 'expired', 'canceled', 'chargeback', 'amount_mismatch']
def __repr__(self) -> str: # pragma: no cover - debug helper
return f'<RollyPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class OverpayPayment(Base):
"""Платежи через Overpay (pay.overpay.io)."""
__tablename__ = 'overpay_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
# Идентификаторы
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
overpay_payment_id = Column(String(128), unique=True, nullable=True, index=True) # ID от Overpay
# Суммы
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default='RUB')
description = Column(Text, nullable=True)
# Статусы
status = Column(String(32), nullable=False, default='pending')
is_paid = Column(Boolean, default=False)
# Данные платежа
payment_url = Column(Text, nullable=True)
payment_method = Column(String(32), nullable=True)
# Метаданные
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
# Временные метки
paid_at = Column(AwareDateTime(), nullable=True)
expires_at = Column(AwareDateTime(), nullable=True)
created_at = Column(AwareDateTime(), default=func.now())
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
# Связь с транзакцией
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
# Relationships
user = relationship('User', backref='overpay_payments')
transaction = relationship('Transaction', backref='overpay_payment')
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
@property
def is_pending(self) -> bool:
return self.status == 'pending'
@property
def is_success(self) -> bool:
return self.status == 'success' and self.is_paid
@property
def is_failed(self) -> bool:
return self.status in ['failed', 'expired', 'canceled', 'chargeback', 'amount_mismatch']
def __repr__(self) -> str: # pragma: no cover - debug helper
return f'<OverpayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class AuraPayPayment(Base):
"""Платежи через AuraPay (aurapay.tech)."""
__tablename__ = 'aurapay_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
# Идентификаторы
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
aurapay_invoice_id = Column(String(128), unique=True, nullable=True, index=True) # UUID от AuraPay
# Суммы
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default='RUB')
description = Column(Text, nullable=True)
# Статусы
status = Column(String(32), nullable=False, default='pending')
is_paid = Column(Boolean, default=False)
# Данные платежа
payment_url = Column(Text, nullable=True)
payment_method = Column(String(32), nullable=True)
# Метаданные
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
# Временные метки
paid_at = Column(AwareDateTime(), nullable=True)
expires_at = Column(AwareDateTime(), nullable=True)
created_at = Column(AwareDateTime(), default=func.now())
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
# Связь с транзакцией
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
# Relationships
user = relationship('User', backref='aurapay_payments')
transaction = relationship('Transaction', backref='aurapay_payment')
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
@property
def is_pending(self) -> bool:
return self.status == 'pending'
@property
def is_success(self) -> bool:
return self.status == 'success' and self.is_paid
@property
def is_failed(self) -> bool:
return self.status in ['failed', 'expired', 'canceled', 'amount_mismatch']
def __repr__(self) -> str: # pragma: no cover - debug helper
return f'<AuraPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class PromoGroup(Base):
__tablename__ = 'promo_groups'
@@ -1204,6 +1456,8 @@ class User(Base):
password_reset_token = Column(String(255), nullable=True)
password_reset_expires = Column(AwareDateTime(), nullable=True)
cabinet_last_login = Column(AwareDateTime(), nullable=True)
# Campaign slug saved at registration, consumed at email verification
pending_campaign_slug = Column(String(64), nullable=True)
# Email change fields
email_change_new = Column(String(255), nullable=True) # New email pending verification
email_change_code = Column(String(6), nullable=True) # 6-digit verification code
@@ -1255,7 +1509,7 @@ class User(Base):
user_promo_groups = relationship('UserPromoGroup', back_populates='user', cascade='all, delete-orphan')
poll_responses = relationship('PollResponse', back_populates='user')
admin_roles_rel = relationship('UserRole', foreign_keys='[UserRole.user_id]', back_populates='user')
notification_settings = Column(JSON, nullable=True, default=dict)
notification_settings = Column(JSONB, nullable=True, default=dict)
last_pinned_message_id = Column(Integer, nullable=True)
# Ограничения пользователя
@@ -1357,7 +1611,7 @@ class Subscription(Base):
'user_id',
'tariff_id',
unique=True,
postgresql_where=text("tariff_id IS NOT NULL AND status IN ('active', 'trial')"),
postgresql_where=text("tariff_id IS NOT NULL AND status IN ('active', 'trial', 'limited')"),
),
)
@@ -2238,6 +2492,9 @@ class BroadcastHistory(Base):
created_at = Column(AwareDateTime(), server_default=func.now())
completed_at = Column(AwareDateTime(), nullable=True)
# Broadcast category for user notification preferences filtering
category = Column(String(20), default='system', nullable=False) # system|news|promo
# Email broadcast fields
channel = Column(String(20), default='telegram', nullable=False) # telegram|email|both
email_subject = Column(String(255), nullable=True)
@@ -2642,6 +2899,8 @@ class TicketMessage(Base):
media_type = Column(String(20), nullable=True) # photo, video, document, voice, etc.
media_file_id = Column(String(255), nullable=True)
media_caption = Column(Text, nullable=True)
# Multi-media gallery (photos/videos/documents bundled in one bubble)
media_items = Column(JSONB, nullable=True)
created_at = Column(AwareDateTime(), default=func.now())
@@ -3263,6 +3522,13 @@ class LandingPage(Base):
background_config = Column(
JSON, nullable=True
) # AnimationConfig: {enabled, type, settings, opacity, blur, reducedOnMobile}
# Sticky pay button on mobile (full-width fixed bottom)
sticky_pay_button = Column(Boolean, nullable=False, default=False, server_default=text('false'))
# Yandex Metrika landing-level conversion goals
analytics_view_enabled = Column(Boolean, nullable=False, default=False, server_default=text('false'))
analytics_view_goal = Column(String(64), nullable=True)
analytics_click_enabled = Column(Boolean, nullable=False, default=False, server_default=text('false'))
analytics_click_goal = Column(String(64), nullable=True)
created_at = Column(AwareDateTime(), server_default=func.now())
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
@@ -3325,6 +3591,10 @@ class GuestPurchase(Base):
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)
# Yandex Metrika offline conversions: client identifier + traffic source tags
yandex_cid = Column(String(128), nullable=True)
subid = Column(String(255), nullable=True)
referrer = Column(String(500), nullable=True)
landing = relationship('LandingPage', back_populates='guest_purchases', lazy='selectin')
tariff = relationship('Tariff', lazy='selectin')
@@ -3406,3 +3676,43 @@ class NewsTag(Base):
def __repr__(self) -> str:
return f"<NewsTag id={self.id} name='{self.name}'>"
class YandexClientIdMap(Base):
"""Yandex Metrika client identifier captured per user.
Stores the mapping user_id -> yandex_cid so we can fire offline
conversion events to mc.yandex.ru with the right CID even after
the user leaves the landing/web flow. The ``subid`` column carries
a pass-through traffic-source identifier for S2S postbacks.
"""
__tablename__ = 'yandex_client_id_map'
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), unique=True, nullable=False)
yandex_cid = Column(String(128), nullable=False)
source = Column(String(20), nullable=False, default='web', server_default='web')
counter_id = Column(String(32), nullable=True)
registration_sent = Column(Boolean, default=False, server_default=text('false'), nullable=False)
trial_sent = Column(Boolean, default=False, server_default=text('false'), nullable=False)
subid = Column(String(255), nullable=True)
created_at = Column(AwareDateTime(), server_default=func.now())
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
class InfoPage(Base):
"""Static informational page with multilingual title/content (JSONB)."""
__tablename__ = 'info_pages'
id = Column(Integer, primary_key=True, index=True)
slug = Column(String(200), unique=True, nullable=False)
title = Column(JSONB, nullable=False, server_default='{}')
content = Column(JSONB, nullable=False, server_default='{}')
page_type = Column(String(20), nullable=False, default='page', server_default='page')
is_active = Column(Boolean, nullable=False, default=True, server_default='true')
sort_order = Column(Integer, nullable=False, default=0, server_default='0')
icon = Column(String(50), nullable=True)
replaces_tab = Column(String(20), nullable=True) # 'faq', 'rules', 'privacy', 'offer', or null
created_at = Column(AwareDateTime(), server_default=func.now())
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
+9 -8
View File
@@ -376,15 +376,16 @@ class RemnaWaveAPI:
except json.JSONDecodeError:
response_data = {'raw_response': response_text}
if response.status == 429 and attempt < max_retries:
if response.status in (429, 502, 503, 504) and attempt < max_retries:
retry_after = float(response.headers.get('Retry-After', base_delay * (2**attempt)))
logger.warning(
'Rate limited (429) on , retry / after s',
method=method,
endpoint=endpoint,
attempt=attempt + 1,
max_retries=max_retries,
retry_after=retry_after,
'Retryable %s on %s %s, retry %s/%s after %ss',
response.status,
method,
endpoint,
attempt + 1,
max_retries,
retry_after,
)
await asyncio.sleep(retry_after)
continue
@@ -445,7 +446,7 @@ class RemnaWaveAPI:
'trafficLimitStrategy': traffic_limit_strategy.value,
}
if telegram_id:
if telegram_id is not None:
data['telegramId'] = telegram_id
if email:
data['email'] = email
+17 -1
View File
@@ -63,7 +63,7 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
},
'payments': {
'title': '💳 Платежные системы',
'description': 'YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute, Kassa AI, RioPay, SeverPay и Telegram Stars.',
'description': 'YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute, Kassa AI, RioPay, SeverPay, PayPear, RollyPay и Telegram Stars.',
'icon': '💳',
'categories': (
'PAYMENT',
@@ -76,6 +76,10 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
'KASSA_AI',
'RIOPAY',
'SEVERPAY',
'PAYPEAR',
'ROLLYPAY',
'OVERPAY',
'AURAPAY',
'MULENPAY',
'PAL24',
'WATA',
@@ -1260,6 +1264,18 @@ def _build_settings_keyboard(
elif category_key == 'SEVERPAY':
label = texts.t('PAYMENT_SEVERPAY', f'💳 {settings.get_severpay_display_name()}')
test_payment_buttons.append([_test_button(f'{label} · тест', 'severpay')])
elif category_key == 'PAYPEAR':
label = texts.t('PAYMENT_PAYPEAR', f'💳 {settings.get_paypear_display_name()}')
test_payment_buttons.append([_test_button(f'{label} · тест', 'paypear')])
elif category_key == 'ROLLYPAY':
label = texts.t('PAYMENT_ROLLYPAY', f'💳 {settings.get_rollypay_display_name()}')
test_payment_buttons.append([_test_button(f'{label} · тест', 'rollypay')])
elif category_key == 'OVERPAY':
label = texts.t('PAYMENT_OVERPAY', f'💳 {settings.get_overpay_display_name()}')
test_payment_buttons.append([_test_button(f'{label} · тест', 'overpay')])
elif category_key == 'AURAPAY':
label = texts.t('PAYMENT_AURAPAY', f'💳 {settings.get_aurapay_display_name()}')
test_payment_buttons.append([_test_button(f'{label} · тест', 'aurapay')])
if test_payment_buttons:
rows.extend(test_payment_buttons)
+14 -22
View File
@@ -1599,42 +1599,28 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
result = await db.execute(query)
return result.scalar() or 0
if target == 'expired':
# Истекшие подписки
if target in ('expired', 'expired_subscribers'):
# Истекшие подписки — исключаем юзеров с хотя бы одной активной
now = datetime.now(UTC)
expired_statuses = [
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.LIMITED.value,
]
query = (
select(sql_func.count(distinct(User.id)))
.outerjoin(Subscription, User.id == Subscription.user_id)
has_active_sub = (
select(Subscription.id)
.where(
base_filter,
or_(
Subscription.status.in_(expired_statuses),
and_(Subscription.end_date <= now, Subscription.status != SubscriptionStatus.ACTIVE.value),
and_(Subscription.id == None, User.has_had_paid_subscription == True),
),
Subscription.user_id == User.id,
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
.exists()
)
result = await db.execute(query)
return result.scalar() or 0
if target == 'expired_subscribers':
# То же что и expired
now = datetime.now(UTC)
expired_statuses = [
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.LIMITED.value,
]
query = (
select(sql_func.count(distinct(User.id)))
.outerjoin(Subscription, User.id == Subscription.user_id)
.where(
base_filter,
~has_active_sub,
or_(
Subscription.status.in_(expired_statuses),
and_(Subscription.end_date <= now, Subscription.status != SubscriptionStatus.ACTIVE.value),
@@ -1785,6 +1771,9 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
for user in users:
subs = getattr(user, 'subscriptions', None) or []
if subs:
has_active = any(s.is_active for s in subs)
if has_active:
continue # Skip users who have at least one active subscription
has_expired = any(s.status in expired_statuses or (s.end_date <= now and not s.is_active) for s in subs)
if has_expired:
expired_users.append(user)
@@ -1833,6 +1822,9 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
for user in users:
subs = getattr(user, 'subscriptions', None) or []
if subs:
has_active = any(s.is_active for s in subs)
if has_active:
continue # Skip users who have at least one active subscription
has_expired = any(s.status in expired_statuses or (s.end_date <= now and not s.is_active) for s in subs)
if has_expired:
expired_users.append(user)
+1 -1
View File
@@ -77,7 +77,7 @@ def _build_server_edit_view(server):
],
[
types.InlineKeyboardButton(
text='🎁 Выдавать сквад' if not server.is_trial_eligible else '🚫 Не выдавать сквад',
text='🎁 Выдавать в триал' if not server.is_trial_eligible else '🚫 Не выдавать в триал',
callback_data=f'admin_server_trial_{server.id}',
),
],
+124 -35
View File
@@ -990,13 +990,14 @@ async def _render_user_subscription_overview(
]
)
else:
keyboard.append(
[
types.InlineKeyboardButton(
text='✅ Активировать', callback_data=f'admin_sub_activate_{user_id}{_sid}'
)
]
)
row = [
types.InlineKeyboardButton(text='✅ Активировать', callback_data=f'admin_sub_activate_{user_id}{_sid}'),
]
if settings.is_multi_tariff_enabled() and subscription_id:
row.append(
types.InlineKeyboardButton(text='🗑 Удалить', callback_data=f'admin_sub_delete_{user_id}{_sid}')
)
keyboard.append(row)
else:
text += '❌ <b>Подписка отсутствует</b>\n\n'
text += 'Пользователь еще не активировал подписку.'
@@ -3302,6 +3303,76 @@ async def confirm_subscription_deactivation(callback: types.CallbackQuery, db_us
await callback.answer()
@admin_required
@error_handler
async def delete_user_subscription(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
"""Show confirmation for deleting a subscription (multi-tariff only)."""
user_id, subscription_id = _extract_admin_sub_context(callback.data)
if not subscription_id or not settings.is_multi_tariff_enabled():
await callback.answer('Удаление доступно только в мультитарифном режиме', show_alert=True)
return
back_cb = f'admin_user_sub_select_{user_id}_{subscription_id}'
_sid = f'_s{subscription_id}'
await callback.message.edit_text(
'🗑 <b>Удаление подписки</b>\n\n⚠️ Подписка будет полностью удалена из системы.\nЭто действие необратимо!',
reply_markup=get_confirmation_keyboard(f'admin_sub_delete_confirm_{user_id}{_sid}', back_cb, db_user.language),
)
await callback.answer()
@admin_required
@error_handler
async def confirm_subscription_deletion(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
"""Delete a subscription permanently (multi-tariff only)."""
user_id, subscription_id = _extract_admin_sub_context(callback.data)
if not subscription_id or not settings.is_multi_tariff_enabled():
await callback.answer('Удаление доступно только в мультитарифном режиме', show_alert=True)
return
from app.database.crud.subscription import get_subscription_by_id_for_user
subscription = await get_subscription_by_id_for_user(db, subscription_id, user_id)
if not subscription:
await callback.answer('Подписка не найдена', show_alert=True)
return
# Disable on Remnawave side first
_uuid = getattr(subscription, 'remnawave_uuid', None)
if _uuid:
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(_uuid)
# Delete traffic purchases
from sqlalchemy import delete as sql_delete
from app.database.models import TrafficPurchase
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
await db.delete(subscription)
await db.commit()
logger.info(
'Админ удалил подписку пользователя',
admin_id=db_user.id,
user_id=user_id,
subscription_id=subscription_id,
)
back_cb = f'admin_user_subscription_{user_id}'
await callback.message.edit_text(
'✅ Подписка удалена',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text='📱 К подпискам', callback_data=back_cb)]]
),
)
await callback.answer()
@admin_required
@error_handler
async def activate_user_subscription(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
@@ -3750,26 +3821,38 @@ async def start_devices_edit(callback: types.CallbackQuery, db_user: User, state
else f'admin_user_subscription_{user_id}'
)
await callback.message.edit_text(
'📱 <b>Изменение количества устройств</b>\n\n'
'Введите новое количество устройств (от 1 до 10):\n'
'• Текущее значение будет заменено\n'
'• Примеры: 1, 2, 5, 10\n\n'
'Или нажмите /cancel для отмены',
reply_markup=types.InlineKeyboardMarkup(
max_dev = settings.MAX_DEVICES_LIMIT
# Build device buttons dynamically: rows of 4, respect Telegram 100 button limit (99 + cancel)
if max_dev <= 99:
device_buttons: list[list[types.InlineKeyboardButton]] = []
row: list[types.InlineKeyboardButton] = []
for i in range(1, max_dev + 1):
row.append(
types.InlineKeyboardButton(
text=str(i),
callback_data=f'admin_user_devices_set_{user_id}{_sid}_{i}',
)
)
if len(row) == 4:
device_buttons.append(row)
row = []
if row:
device_buttons.append(row)
device_buttons.append([types.InlineKeyboardButton(text='❌ Отмена', callback_data=back_cb)])
markup = types.InlineKeyboardMarkup(inline_keyboard=device_buttons)
else:
markup = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(text='1', callback_data=f'admin_user_devices_set_{user_id}{_sid}_1'),
types.InlineKeyboardButton(text='2', callback_data=f'admin_user_devices_set_{user_id}{_sid}_2'),
types.InlineKeyboardButton(text='3', callback_data=f'admin_user_devices_set_{user_id}{_sid}_3'),
],
[
types.InlineKeyboardButton(text='5', callback_data=f'admin_user_devices_set_{user_id}{_sid}_5'),
types.InlineKeyboardButton(text='10', callback_data=f'admin_user_devices_set_{user_id}{_sid}_10'),
],
[types.InlineKeyboardButton(text='❌ Отмена', callback_data=back_cb)],
]
),
)
await callback.message.edit_text(
'📱 <b>Изменение количества устройств</b>\n\n'
f'Введите новое количество устройств (от 1 до {max_dev}):\n'
'• Текущее значение будет заменено\n\n'
'Или нажмите /cancel для отмены',
reply_markup=markup,
)
await state.set_state(AdminStates.editing_user_devices)
@@ -3839,8 +3922,8 @@ async def process_devices_edit_text(message: types.Message, db_user: User, state
try:
devices = int(message.text.strip())
if devices <= 0 or devices > 10:
await message.answer('❌ Количество устройств должно быть от 1 до 10')
if devices <= 0 or devices > settings.MAX_DEVICES_LIMIT:
await message.answer(f'❌ Количество устройств должно быть от 1 до {settings.MAX_DEVICES_LIMIT}')
return
success = await _update_user_devices(db, user_id, devices, db_user.id, subscription_id=subscription_id)
@@ -4162,14 +4245,16 @@ async def _update_user_traffic(
) or getattr(user, 'remnawave_uuid', None)
if _uuid:
try:
from app.external.remnawave_api import TrafficLimitStrategy
from app.services.subscription_service import get_traffic_reset_strategy
remnawave_service = RemnaWaveService()
async with remnawave_service.get_api_client() as api:
await api.update_user(
uuid=_uuid,
traffic_limit_bytes=traffic_gb * (1024**3) if traffic_gb > 0 else 0,
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
traffic_limit_strategy=get_traffic_reset_strategy(
subscription.tariff if subscription else None
),
description=settings.format_remnawave_user_description(
full_name=user.full_name, username=user.username, telegram_id=user.telegram_id
),
@@ -4433,11 +4518,9 @@ async def _grant_paid_subscription(
trial_squads: list[str] = []
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
trial_uuid = await get_random_trial_squad_uuid(db)
if trial_uuid:
trial_squads = [trial_uuid]
trial_squads = await get_effective_tariff_squad_uuids(db, None)
except Exception as error:
logger.error('Не удалось подобрать сквад при выдаче подписки админом', admin_id=admin_id, error=error)
@@ -4877,8 +4960,9 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
)
try:
from app.external.remnawave_api import TrafficLimitStrategy, UserStatus
from app.external.remnawave_api import UserStatus
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_service import get_traffic_reset_strategy
remnawave_service = RemnaWaveService()
@@ -4903,7 +4987,7 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3)
if subscription.traffic_limit_gb > 0
else 0,
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff),
description=settings.format_remnawave_user_description(
full_name=target_user.full_name,
username=target_user.username,
@@ -4939,7 +5023,7 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3)
if subscription.traffic_limit_gb > 0
else 0,
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff),
telegram_id=target_user.telegram_id,
email=target_user.email,
description=settings.format_remnawave_user_description(
@@ -5939,6 +6023,11 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(activate_user_subscription, F.data.startswith('admin_sub_activate_'))
dp.callback_query.register(
delete_user_subscription, F.data.startswith('admin_sub_delete_') & ~F.data.contains('confirm')
)
dp.callback_query.register(confirm_subscription_deletion, F.data.startswith('admin_sub_delete_confirm_'))
dp.callback_query.register(grant_trial_subscription, F.data.startswith('admin_sub_grant_trial_'))
dp.callback_query.register(
+247
View File
@@ -0,0 +1,247 @@
"""Handler for AuraPay balance top-up."""
import html
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = structlog.get_logger(__name__)
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
if not getattr(db_user, 'restriction_topup', False):
return None
keyboard = []
support_url = settings.get_support_contact_url()
if support_url:
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
async def _create_aurapay_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
):
"""
Common logic for creating AuraPay payment and sending response.
"""
texts = get_texts(db_user.language)
amount_rub = amount_kopeks / 100
# Create payment
payment_service = PaymentService()
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
service_name=settings.PAYMENT_SERVICE_NAME,
description='Пополнение баланса',
)
result = await payment_service.create_aurapay_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=description,
email=getattr(db_user, 'email', None),
language=db_user.language,
)
if not result:
error_text = texts.t(
'PAYMENT_CREATE_ERROR',
'Не удалось создать платёж. Попробуйте позже.',
)
if edit_message:
await message_or_callback.edit_text(
error_text,
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
else:
await message_or_callback.answer(
error_text,
parse_mode='HTML',
)
return
payment_url = result.get('payment_url')
display_name = settings.get_aurapay_display_name()
# Create keyboard with payment button
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t(
'PAY_BUTTON',
'\U0001f4b3 Оплатить {amount}\u20bd',
).format(amount=f'{amount_rub:.0f}'),
url=payment_url,
)
],
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
callback_data='menu_balance',
)
],
]
)
response_text = texts.t(
'AURAPAY_PAYMENT_CREATED',
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
'Сумма: <b>{amount}\u20bd</b>\n\n'
'Нажмите кнопку ниже для оплаты.\n'
'После успешной оплаты баланс будет пополнен автоматически.',
).format(name=display_name, amount=f'{amount_rub:.2f}')
if edit_message:
await message_or_callback.edit_text(
response_text,
reply_markup=keyboard,
parse_mode='HTML',
)
else:
await message_or_callback.answer(
response_text,
reply_markup=keyboard,
parse_mode='HTML',
)
logger.info('AuraPay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
@error_handler
async def process_aurapay_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
"""
Process payment amount directly.
"""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await message.answer(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
await state.clear()
return
# Validate amount
min_amount = settings.AURAPAY_MIN_AMOUNT_KOPEKS
max_amount = settings.AURAPAY_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount}\u20bd',
).format(min_amount=min_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
if amount_kopeks > max_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount}\u20bd',
).format(max_amount=max_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
await state.clear()
await _create_aurapay_payment_and_respond(
message_or_callback=message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=False,
)
@error_handler
async def start_aurapay_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Start AuraPay top-up process - ask for amount.
"""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await callback.message.edit_text(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
return
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method='aurapay')
min_amount = settings.AURAPAY_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.AURAPAY_MAX_AMOUNT_KOPEKS // 100
display_name = settings.get_aurapay_display_name()
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
callback_data='menu_balance',
)
]
]
)
await callback.message.edit_text(
texts.t(
'AURAPAY_ENTER_AMOUNT',
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
'Введите сумму пополнения в рублях.\n\n'
'Минимум: {min_amount}\u20bd\n'
'Максимум: {max_amount}\u20bd',
).format(
name=display_name,
min_amount=min_amount,
max_amount=f'{max_amount:,}'.replace(',', ' '),
),
parse_mode='HTML',
reply_markup=keyboard,
)
+16
View File
@@ -39,6 +39,11 @@ _KASSA_AI_METHOD_CONFIG = {
'display_name': settings.get_kassa_ai_card_display_name,
'unavailable_text': 'KassaAI Карта временно недоступна',
},
'kassa_ai_sberpay': {
'is_enabled': settings.is_kassa_ai_sberpay_enabled,
'display_name': settings.get_kassa_ai_sberpay_display_name,
'unavailable_text': 'KassaAI SberPay временно недоступен',
},
}
@@ -350,3 +355,14 @@ async def start_kassa_ai_card_topup(
):
"""Start KassaAI Card top-up process."""
await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai_card')
@error_handler
async def start_kassa_ai_sberpay_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Start KassaAI SberPay top-up process."""
await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai_sberpay')
+63 -8
View File
@@ -133,7 +133,7 @@ async def route_payment_by_method(
)
return True
if payment_method in ('kassa_ai', 'kassa_ai_sbp', 'kassa_ai_card'):
if payment_method in ('kassa_ai', 'kassa_ai_sbp', 'kassa_ai_card', 'kassa_ai_sberpay'):
from .kassa_ai import process_kassa_ai_payment_amount
async with AsyncSessionLocal() as db:
@@ -149,6 +149,34 @@ async def route_payment_by_method(
await process_severpay_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == 'paypear':
from .paypear import process_paypear_payment_amount
async with AsyncSessionLocal() as db:
await process_paypear_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == 'rollypay':
from .rollypay import process_rollypay_payment_amount
async with AsyncSessionLocal() as db:
await process_rollypay_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == 'overpay':
from .overpay import process_overpay_payment_amount
async with AsyncSessionLocal() as db:
await process_overpay_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == 'aurapay':
from .aurapay import process_aurapay_payment_amount
async with AsyncSessionLocal() as db:
await process_aurapay_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == 'riopay':
from .riopay import process_riopay_payment_amount
@@ -470,12 +498,16 @@ async def process_topup_amount(message: types.Message, db_user: User, state: FSM
amount_rubles = float(amount_text.replace(',', '.'))
if amount_rubles < 1:
await message.answer('Минимальная сумма пополнения: 1 ₽', reply_markup=get_back_keyboard(db_user.language))
await message.answer(
'Минимальная сумма пополнения: 1 ₽',
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
)
return
if amount_rubles > 50000:
await message.answer(
'Максимальная сумма пополнения: 50,000 ₽', reply_markup=get_back_keyboard(db_user.language)
'Максимальная сумма пополнения: 50,000 ₽',
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
)
return
@@ -488,7 +520,7 @@ async def process_topup_amount(message: types.Message, db_user: User, state: FSM
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
await message.answer(
f'❌ Минимальная сумма для оплаты через YooKassa: {min_rubles:.0f}',
reply_markup=get_back_keyboard(db_user.language),
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
)
return
@@ -496,7 +528,7 @@ async def process_topup_amount(message: types.Message, db_user: User, state: FSM
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
await message.answer(
f'❌ Максимальная сумма для оплаты через YooKassa: {max_rubles:,.0f}'.replace(',', ' '),
reply_markup=get_back_keyboard(db_user.language),
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
)
return
@@ -572,6 +604,7 @@ async def handle_topup_amount_callback(
platega_method_code = int(method[len('platega_m') :])
await state.update_data(payment_method='platega', platega_method=platega_method_code)
await state.set_state(BalanceStates.waiting_for_amount)
async with AsyncSessionLocal() as db:
await process_platega_payment_amount(callback.message, db_user, db, amount_kopeks, state)
elif method == 'platega':
@@ -583,6 +616,7 @@ async def handle_topup_amount_callback(
method_code = int(data.get('platega_method', 0)) if data else 0
if method_code > 0:
await state.set_state(BalanceStates.waiting_for_amount)
async with AsyncSessionLocal() as db:
await process_platega_payment_amount(callback.message, db_user, db, amount_kopeks, state)
else:
@@ -594,9 +628,12 @@ async def handle_topup_amount_callback(
await start_tribute_payment(callback, db_user)
return
# Стандартные методы через роутер
elif not await route_payment_by_method(callback.message, db_user, amount_kopeks, state, method):
await callback.answer('❌ Неизвестный способ оплаты', show_alert=True)
return
else:
await state.update_data(payment_method=method)
await state.set_state(BalanceStates.waiting_for_amount)
if not await route_payment_by_method(callback.message, db_user, amount_kopeks, state, method):
await callback.answer('❌ Неизвестный способ оплаты', show_alert=True)
return
await callback.answer()
@@ -701,6 +738,7 @@ def register_balance_handlers(dp: Dispatcher):
from .kassa_ai import (
start_kassa_ai_card_topup,
start_kassa_ai_sberpay_topup,
start_kassa_ai_sbp_topup,
start_kassa_ai_topup,
)
@@ -708,6 +746,7 @@ def register_balance_handlers(dp: Dispatcher):
dp.callback_query.register(start_kassa_ai_topup, F.data == 'topup_kassa_ai')
dp.callback_query.register(start_kassa_ai_sbp_topup, F.data == 'topup_kassa_ai_sbp')
dp.callback_query.register(start_kassa_ai_card_topup, F.data == 'topup_kassa_ai_card')
dp.callback_query.register(start_kassa_ai_sberpay_topup, F.data == 'topup_kassa_ai_sberpay')
from .riopay import start_riopay_topup
@@ -717,6 +756,22 @@ def register_balance_handlers(dp: Dispatcher):
dp.callback_query.register(start_severpay_topup, F.data == 'topup_severpay')
from .paypear import start_paypear_topup
dp.callback_query.register(start_paypear_topup, F.data == 'topup_paypear')
from .rollypay import start_rollypay_topup
dp.callback_query.register(start_rollypay_topup, F.data == 'topup_rollypay')
from .overpay import start_overpay_topup
dp.callback_query.register(start_overpay_topup, F.data == 'topup_overpay')
from .aurapay import start_aurapay_topup
dp.callback_query.register(start_aurapay_topup, F.data == 'topup_aurapay')
from .mulenpay import check_mulenpay_payment_status
dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_'))
+247
View File
@@ -0,0 +1,247 @@
"""Handler for Overpay balance top-up."""
import html
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = structlog.get_logger(__name__)
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
if not getattr(db_user, 'restriction_topup', False):
return None
keyboard = []
support_url = settings.get_support_contact_url()
if support_url:
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
async def _create_overpay_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
):
"""
Common logic for creating Overpay payment and sending response.
"""
texts = get_texts(db_user.language)
amount_rub = amount_kopeks / 100
# Create payment
payment_service = PaymentService()
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
service_name=settings.PAYMENT_SERVICE_NAME,
description='Пополнение баланса',
)
result = await payment_service.create_overpay_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=description,
email=getattr(db_user, 'email', None),
language=db_user.language,
)
if not result:
error_text = texts.t(
'PAYMENT_CREATE_ERROR',
'Не удалось создать платёж. Попробуйте позже.',
)
if edit_message:
await message_or_callback.edit_text(
error_text,
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
else:
await message_or_callback.answer(
error_text,
parse_mode='HTML',
)
return
payment_url = result.get('payment_url')
display_name = settings.get_overpay_display_name()
# Create keyboard with payment button
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t(
'PAY_BUTTON',
'\U0001f4b3 Оплатить {amount}\u20bd',
).format(amount=f'{amount_rub:.0f}'),
url=payment_url,
)
],
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
callback_data='menu_balance',
)
],
]
)
response_text = texts.t(
'OVERPAY_PAYMENT_CREATED',
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
'Сумма: <b>{amount}\u20bd</b>\n\n'
'Нажмите кнопку ниже для оплаты.\n'
'После успешной оплаты баланс будет пополнен автоматически.',
).format(name=display_name, amount=f'{amount_rub:.2f}')
if edit_message:
await message_or_callback.edit_text(
response_text,
reply_markup=keyboard,
parse_mode='HTML',
)
else:
await message_or_callback.answer(
response_text,
reply_markup=keyboard,
parse_mode='HTML',
)
logger.info('Overpay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
@error_handler
async def process_overpay_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
"""
Process payment amount directly.
"""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await message.answer(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
await state.clear()
return
# Validate amount
min_amount = settings.OVERPAY_MIN_AMOUNT_KOPEKS
max_amount = settings.OVERPAY_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount}\u20bd',
).format(min_amount=min_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
if amount_kopeks > max_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount}\u20bd',
).format(max_amount=max_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
await state.clear()
await _create_overpay_payment_and_respond(
message_or_callback=message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=False,
)
@error_handler
async def start_overpay_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Start Overpay top-up process - ask for amount.
"""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await callback.message.edit_text(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
return
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method='overpay')
min_amount = settings.OVERPAY_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.OVERPAY_MAX_AMOUNT_KOPEKS // 100
display_name = settings.get_overpay_display_name()
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
callback_data='menu_balance',
)
]
]
)
await callback.message.edit_text(
texts.t(
'OVERPAY_ENTER_AMOUNT',
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
'Введите сумму пополнения в рублях.\n\n'
'Минимум: {min_amount}\u20bd\n'
'Максимум: {max_amount}\u20bd',
).format(
name=display_name,
min_amount=min_amount,
max_amount=f'{max_amount:,}'.replace(',', ' '),
),
parse_mode='HTML',
reply_markup=keyboard,
)
+247
View File
@@ -0,0 +1,247 @@
"""Handler for PayPear balance top-up."""
import html
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = structlog.get_logger(__name__)
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
if not getattr(db_user, 'restriction_topup', False):
return None
keyboard = []
support_url = settings.get_support_contact_url()
if support_url:
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
async def _create_paypear_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
):
"""
Common logic for creating PayPear payment and sending response.
"""
texts = get_texts(db_user.language)
amount_rub = amount_kopeks / 100
# Create payment
payment_service = PaymentService()
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
service_name=settings.PAYMENT_SERVICE_NAME,
description='Пополнение баланса',
)
result = await payment_service.create_paypear_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=description,
email=getattr(db_user, 'email', None),
language=db_user.language,
)
if not result:
error_text = texts.t(
'PAYMENT_CREATE_ERROR',
'Не удалось создать платёж. Попробуйте позже.',
)
if edit_message:
await message_or_callback.edit_text(
error_text,
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
else:
await message_or_callback.answer(
error_text,
parse_mode='HTML',
)
return
payment_url = result.get('payment_url')
display_name = settings.get_paypear_display_name()
# Create keyboard with payment button
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t(
'PAY_BUTTON',
'\U0001f4b3 Оплатить {amount}\u20bd',
).format(amount=f'{amount_rub:.0f}'),
url=payment_url,
)
],
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
callback_data='menu_balance',
)
],
]
)
response_text = texts.t(
'PAYPEAR_PAYMENT_CREATED',
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
'Сумма: <b>{amount}\u20bd</b>\n\n'
'Нажмите кнопку ниже для оплаты.\n'
'После успешной оплаты баланс будет пополнен автоматически.',
).format(name=display_name, amount=f'{amount_rub:.2f}')
if edit_message:
await message_or_callback.edit_text(
response_text,
reply_markup=keyboard,
parse_mode='HTML',
)
else:
await message_or_callback.answer(
response_text,
reply_markup=keyboard,
parse_mode='HTML',
)
logger.info('PayPear payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
@error_handler
async def process_paypear_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
"""
Process payment amount directly.
"""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await message.answer(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
await state.clear()
return
# Validate amount
min_amount = settings.PAYPEAR_MIN_AMOUNT_KOPEKS
max_amount = settings.PAYPEAR_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount}\u20bd',
).format(min_amount=min_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
if amount_kopeks > max_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount}\u20bd',
).format(max_amount=max_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
await state.clear()
await _create_paypear_payment_and_respond(
message_or_callback=message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=False,
)
@error_handler
async def start_paypear_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Start PayPear top-up process - ask for amount.
"""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await callback.message.edit_text(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
return
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method='paypear')
min_amount = settings.PAYPEAR_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.PAYPEAR_MAX_AMOUNT_KOPEKS // 100
display_name = settings.get_paypear_display_name()
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
callback_data='menu_balance',
)
]
]
)
await callback.message.edit_text(
texts.t(
'PAYPEAR_ENTER_AMOUNT',
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
'Введите сумму пополнения в рублях.\n\n'
'Минимум: {min_amount}\u20bd\n'
'Максимум: {max_amount}\u20bd',
).format(
name=display_name,
min_amount=min_amount,
max_amount=f'{max_amount:,}'.replace(',', ' '),
),
parse_mode='HTML',
reply_markup=keyboard,
)
+6 -4
View File
@@ -20,8 +20,7 @@ logger = structlog.get_logger(__name__)
def _get_active_methods() -> list[int]:
methods = settings.get_platega_active_methods()
return [code for code in methods if code in {2, 10, 11, 12, 13}]
return settings.get_platega_active_methods()
async def _prompt_amount(
@@ -43,6 +42,7 @@ async def _prompt_amount(
# Если сумма уже известна (например, после быстрого выбора),
# сразу создаём платеж и сбрасываем временное значение.
await state.update_data(platega_pending_amount=None)
await state.set_state(BalanceStates.waiting_for_amount)
from app.database.database import AsyncSessionLocal
@@ -295,8 +295,9 @@ async def process_platega_payment_amount(
'PLATEGA_AMOUNT_TOO_LOW',
'Минимальная сумма для оплаты через Platega: {amount}',
).format(amount=settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS)),
reply_markup=get_back_keyboard(db_user.language),
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
)
await state.set_state(BalanceStates.waiting_for_amount)
return
if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS:
@@ -305,8 +306,9 @@ async def process_platega_payment_amount(
'PLATEGA_AMOUNT_TOO_HIGH',
'Максимальная сумма для оплаты через Platega: {amount}',
).format(amount=settings.format_price(settings.PLATEGA_MAX_AMOUNT_KOPEKS)),
reply_markup=get_back_keyboard(db_user.language),
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
)
await state.set_state(BalanceStates.waiting_for_amount)
return
try:
+247
View File
@@ -0,0 +1,247 @@
"""Handler for RollyPay balance top-up."""
import html
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = structlog.get_logger(__name__)
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
if not getattr(db_user, 'restriction_topup', False):
return None
keyboard = []
support_url = settings.get_support_contact_url()
if support_url:
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
async def _create_rollypay_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
):
"""
Common logic for creating RollyPay payment and sending response.
"""
texts = get_texts(db_user.language)
amount_rub = amount_kopeks / 100
# Create payment
payment_service = PaymentService()
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
service_name=settings.PAYMENT_SERVICE_NAME,
description='Пополнение баланса',
)
result = await payment_service.create_rollypay_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=description,
email=getattr(db_user, 'email', None),
language=db_user.language,
)
if not result:
error_text = texts.t(
'PAYMENT_CREATE_ERROR',
'Не удалось создать платёж. Попробуйте позже.',
)
if edit_message:
await message_or_callback.edit_text(
error_text,
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
else:
await message_or_callback.answer(
error_text,
parse_mode='HTML',
)
return
payment_url = result.get('payment_url')
display_name = settings.get_rollypay_display_name()
# Create keyboard with payment button
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t(
'PAY_BUTTON',
'\U0001f4b3 Оплатить {amount}\u20bd',
).format(amount=f'{amount_rub:.0f}'),
url=payment_url,
)
],
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
callback_data='menu_balance',
)
],
]
)
response_text = texts.t(
'ROLLYPAY_PAYMENT_CREATED',
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
'Сумма: <b>{amount}\u20bd</b>\n\n'
'Нажмите кнопку ниже для оплаты.\n'
'После успешной оплаты баланс будет пополнен автоматически.',
).format(name=display_name, amount=f'{amount_rub:.2f}')
if edit_message:
await message_or_callback.edit_text(
response_text,
reply_markup=keyboard,
parse_mode='HTML',
)
else:
await message_or_callback.answer(
response_text,
reply_markup=keyboard,
parse_mode='HTML',
)
logger.info('RollyPay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
@error_handler
async def process_rollypay_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
"""
Process payment amount directly.
"""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await message.answer(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
await state.clear()
return
# Validate amount
min_amount = settings.ROLLYPAY_MIN_AMOUNT_KOPEKS
max_amount = settings.ROLLYPAY_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount}\u20bd',
).format(min_amount=min_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
if amount_kopeks > max_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount}\u20bd',
).format(max_amount=max_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
await state.clear()
await _create_rollypay_payment_and_respond(
message_or_callback=message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=False,
)
@error_handler
async def start_rollypay_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Start RollyPay top-up process - ask for amount.
"""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await callback.message.edit_text(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
return
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method='rollypay')
min_amount = settings.ROLLYPAY_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.ROLLYPAY_MAX_AMOUNT_KOPEKS // 100
display_name = settings.get_rollypay_display_name()
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
callback_data='menu_balance',
)
]
]
)
await callback.message.edit_text(
texts.t(
'ROLLYPAY_ENTER_AMOUNT',
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
'Введите сумму пополнения в рублях.\n\n'
'Минимум: {min_amount}\u20bd\n'
'Максимум: {max_amount}\u20bd',
).format(
name=display_name,
min_amount=min_amount,
max_amount=f'{max_amount:,}'.replace(',', ' '),
),
parse_mode='HTML',
reply_markup=keyboard,
)
+3
View File
@@ -78,6 +78,9 @@ async def on_user_joined_channel(event: ChatMemberUpdated, bot: Bot) -> None:
for subscription in disabled_subs:
await reactivate_subscription(db, subscription)
# Ставим штамп чтобы webhook user.disabled (echo от нашего disable)
# не переотключил подписку при быстрой реподписке
subscription.last_webhook_update_at = datetime.now(UTC)
logger.info(
'Subscriptions reactivated via channel event',
telegram_id=user.id,
+9
View File
@@ -127,3 +127,12 @@ def register_handlers(dp: Dispatcher):
F.text.is_not(None),
~F.text.startswith('/'),
)
# Ловим медиа-сообщения (фото, видео, документы, стикеры и т.д.)
# без активного состояния — чтобы пользователь знал, что бот не принял медиа
dp.message.register(
handle_unknown_message,
StateFilter(None),
F.successful_payment.is_(None),
F.text.is_(None),
)
+6 -2
View File
@@ -169,7 +169,9 @@ async def show_main_menu(
await db.commit()
# Multi-tariff aware: check if user has ANY active subscription
has_active_subscription = any(sub.is_active for sub in (getattr(db_user, 'subscriptions', None) or []))
# 'limited' (traffic exhausted) subscriptions are still active for UI purposes
_subs = getattr(db_user, 'subscriptions', None) or []
has_active_subscription = any(sub.is_active or getattr(sub, 'actual_status', None) == 'limited' for sub in _subs)
subscription_is_active = has_active_subscription
menu_text = await get_main_menu_text(db_user, texts, db)
@@ -1013,7 +1015,9 @@ async def handle_back_to_menu(callback: types.CallbackQuery, state: FSMContext,
texts = get_texts(db_user.language)
# Multi-tariff aware: check if user has ANY active subscription
has_active_subscription = any(sub.is_active for sub in (getattr(db_user, 'subscriptions', None) or []))
# 'limited' (traffic exhausted) subscriptions are still active for UI purposes
_subs = getattr(db_user, 'subscriptions', None) or []
has_active_subscription = any(sub.is_active or getattr(sub, 'actual_status', None) == 'limited' for sub in _subs)
subscription_is_active = has_active_subscription
menu_text = await get_main_menu_text(db_user, texts, db)
+38 -5
View File
@@ -34,7 +34,21 @@ async def show_promocode_menu(callback: types.CallbackQuery, db_user: User, stat
else:
raise
# Сохраняем предыдущее состояние, чтобы восстановить после промокода
previous_state = await state.get_state()
previous_data = await state.get_data()
# Не перезаписываем сохранённое состояние при повторном входе в промо-флоу
if previous_state == PromoCodeStates.waiting_for_code.state:
await callback.answer()
return
# Убираем мета-ключи чтобы не создавать вложенность
previous_data.pop('_prev_state', None)
previous_data.pop('_prev_data', None)
await state.set_state(PromoCodeStates.waiting_for_code)
await state.update_data(_prev_state=previous_state, _prev_data=previous_data)
await callback.answer()
@@ -75,6 +89,23 @@ async def activate_promocode_for_registration(
return result
_NO_SAVED_STATE = object()
async def _restore_previous_state(state: FSMContext) -> None:
"""Восстанавливает FSM-состояние, которое было до входа в промокод-флоу."""
data = await state.get_data()
prev_state = data.get('_prev_state', _NO_SAVED_STATE)
prev_data = data.get('_prev_data') or {}
if prev_state is _NO_SAVED_STATE:
# Не было сохранённого состояния — defensive clear
await state.clear()
else:
# Восстанавливаем предыдущее состояние (включая None = меню без FSM)
await state.set_state(prev_state)
await state.set_data(prev_data)
@error_handler
async def process_promocode(message: types.Message, db_user: User, state: FSMContext, db: AsyncSession):
texts = get_texts(db_user.language)
@@ -108,7 +139,7 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
).format(cooldown=cooldown),
reply_markup=get_back_keyboard(db_user.language),
)
await state.clear()
await _restore_previous_state(state)
return
# Лимит на стакинг (макс активаций в день)
@@ -120,7 +151,7 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
),
reply_markup=get_back_keyboard(db_user.language),
)
await state.clear()
await _restore_previous_state(state)
return
result = await activate_promocode_for_registration(db, db_user.id, code, message.bot)
@@ -131,7 +162,7 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
texts.PROMOCODE_SUCCESS.format(description=result['description']),
reply_markup=get_back_keyboard(db_user.language),
)
await state.clear()
await _restore_previous_state(state)
elif result.get('error') == 'select_subscription':
# Multi-tariff: user needs to choose which subscription to apply days to
eligible = result.get('eligible_subscriptions', [])
@@ -156,7 +187,7 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
)
await state.clear()
await _restore_previous_state(state)
else:
# Записываем неудачную попытку только для not_found (перебор)
if result['error'] == 'not_found':
@@ -166,6 +197,8 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
error_messages = {
'not_found': texts.PROMOCODE_INVALID,
'expired': texts.PROMOCODE_EXPIRED,
'inactive': texts.t('PROMOCODE_INACTIVE', '❌ Промокод деактивирован'),
'not_yet_valid': texts.t('PROMOCODE_NOT_YET_VALID', '❌ Промокод ещё не начал действовать'),
'used': texts.PROMOCODE_USED,
'already_used_by_user': texts.PROMOCODE_USED,
'not_first_purchase': texts.t(
@@ -188,7 +221,7 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
error_text = error_messages.get(result['error'], texts.PROMOCODE_INVALID)
await message.answer(error_text, reply_markup=get_back_keyboard(db_user.language))
await state.clear()
await _restore_previous_state(state)
async def handle_promo_subscription_select(
+18 -2
View File
@@ -441,7 +441,7 @@ async def handle_simple_subscription_pay_with_balance(
# Проверяем баланс пользователя
user_balance_kopeks = getattr(db_user, 'balance_kopeks', 0)
if user_balance_kopeks < total_required:
if total_required > 0 and user_balance_kopeks < total_required:
await callback.answer('❌ Недостаточно средств на балансе для оплаты подписки', show_alert=True)
return
@@ -549,6 +549,14 @@ async def handle_simple_subscription_pay_with_balance(
sync_error=sync_error,
exc_info=True,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='create',
)
# Отправляем уведомление об успешной покупке
server_label = _get_simple_subscription_server_label(
@@ -2181,7 +2189,7 @@ async def confirm_simple_subscription_purchase(
# Проверяем баланс пользователя
user_balance_kopeks = getattr(db_user, 'balance_kopeks', 0)
if user_balance_kopeks < total_required:
if total_required > 0 and user_balance_kopeks < total_required:
await callback.answer('❌ Недостаточно средств на балансе для оплаты подписки', show_alert=True)
return
@@ -2289,6 +2297,14 @@ async def confirm_simple_subscription_purchase(
sync_error=sync_error,
exc_info=True,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='create',
)
# Отправляем уведомление об успешной покупке
server_label = _get_simple_subscription_server_label(
+8
View File
@@ -253,6 +253,14 @@ async def _handle_trial_payment(
except Exception as rw_error:
logger.error('Ошибка создания пользователя RemnaWave для триала', rw_error=rw_error)
# Не откатываем подписку, просто логируем - RemnaWave может быть временно недоступен
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='create',
)
await db.commit()
await db.refresh(user)
+31 -4
View File
@@ -234,6 +234,14 @@ async def _claim_phantom_user(
subscription_id=phantom_sub.id,
error=str(exc),
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(phantom_sub, 'id') and hasattr(phantom_sub, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=phantom_sub.id,
user_id=phantom_sub.user_id,
action='update',
)
return True, phantom
@@ -336,8 +344,9 @@ def _calculate_subscription_flags(subscription):
return False, False
actual_status = getattr(subscription, 'actual_status', None)
has_active_subscription = actual_status in {'active', 'trial'}
subscription_is_active = bool(getattr(subscription, 'is_active', False))
# 'limited' subscriptions are still active (traffic exhausted, but subscription not expired)
has_active_subscription = actual_status in {'active', 'trial', 'limited'}
subscription_is_active = bool(getattr(subscription, 'is_active', False)) or actual_status == 'limited'
return has_active_subscription, subscription_is_active
@@ -1139,14 +1148,18 @@ async def _show_privacy_policy_after_rules(
logger.info('🔒 Используется политика конфиденциальности из БД для языка', language=language)
try:
await callback.message.edit_text(privacy_policy_text, reply_markup=get_privacy_policy_keyboard(language))
await callback.message.edit_text(
privacy_policy_text, reply_markup=get_privacy_policy_keyboard(language), parse_mode='HTML'
)
await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept)
logger.info('🔒 Политика конфиденциальности отправлена пользователю', from_user_id=callback.from_user.id)
return True
except Exception as e:
logger.error('Ошибка при показе политики конфиденциальности', error=e, exc_info=True)
try:
await callback.message.answer(privacy_policy_text, reply_markup=get_privacy_policy_keyboard(language))
await callback.message.answer(
privacy_policy_text, reply_markup=get_privacy_policy_keyboard(language), parse_mode='HTML'
)
await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept)
logger.info(
'🔒 Политика конфиденциальности отправлена новым сообщением пользователю',
@@ -1716,6 +1729,7 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
await callback.message.answer(
offer_text,
reply_markup=get_post_registration_keyboard(user.language),
parse_mode='HTML',
)
logger.info('✅ Приветственное сообщение отправлено пользователю', telegram_id=user.telegram_id)
if pinned_message and not pinned_message.send_before_menu:
@@ -2070,6 +2084,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
await message.answer(
offer_text,
reply_markup=keyboard,
parse_mode='HTML',
)
logger.info('✅ Приветственное сообщение отправлено пользователю', telegram_id=user.telegram_id)
if pinned_message and not pinned_message.send_before_menu:
@@ -2443,6 +2458,18 @@ async def required_sub_channel_check(
telegram_id=user.telegram_id if user else query.from_user.id,
api_error=api_error,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
for sub in _subs:
if sub.is_trial and sub.status == SubscriptionStatus.ACTIVE.value:
if hasattr(sub, 'id') and hasattr(sub, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=sub.id,
user_id=sub.user_id,
action='update'
if (getattr(sub, 'remnawave_uuid', None) or user.remnawave_uuid)
else 'create',
)
await query.answer(
texts.t('CHANNEL_SUBSCRIBE_THANKS', '✅ Спасибо за подписку'),
+13 -2
View File
@@ -412,7 +412,18 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User,
await db.commit()
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription, sync_squads=True)
try:
await subscription_service.update_remnawave_user(db, subscription, sync_squads=True)
except Exception as rw_err:
logger.error('Ошибка синхронизации с RemnaWave при смене стран', error=rw_err)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='update',
)
await db.refresh(subscription)
@@ -861,7 +872,7 @@ async def confirm_add_countries_to_subscription(
if country['uuid'] in removed_countries:
removed_countries_names.append(html.escape(country['name']))
if new_countries and db_user.balance_kopeks < total_price:
if new_countries and total_price > 0 and db_user.balance_kopeks < total_price:
missing_kopeks = total_price - db_user.balance_kopeks
message_text = texts.t(
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
+38 -9
View File
@@ -325,9 +325,14 @@ async def confirm_change_devices(
if devices_difference > 0:
additional_devices = devices_difference
# Для тарифов - все устройства платные (нет бесплатного лимита)
# Устройства в пределах тарифного лимита — бесплатные
if tariff:
chargeable_devices = additional_devices
tariff_included = tariff.device_limit or 0
if current_devices < tariff_included:
free_devices = tariff_included - current_devices
chargeable_devices = max(0, additional_devices - free_devices)
else:
chargeable_devices = additional_devices
elif current_devices < settings.DEFAULT_DEVICE_LIMIT:
free_devices = settings.DEFAULT_DEVICE_LIMIT - current_devices
chargeable_devices = max(0, additional_devices - free_devices)
@@ -352,7 +357,8 @@ async def confirm_change_devices(
)
# Цена = месячная_цена * days_left / 30
price = int(discounted_per_month * days_left / 30)
price = max(100, price) # Минимум 1 рубль
if chargeable_devices > 0:
price = max(100, price) # Минимум 1 рубль (только для платных устройств)
total_discount = int(discount_per_month * days_left / 30)
period_label = f'{days_left} дн.' if days_left > 1 else '1 день'
@@ -553,7 +559,12 @@ async def execute_change_devices(
devices_difference = new_devices_count - current_devices
if devices_difference > 0:
if tariff:
chargeable_devices = devices_difference
tariff_included = tariff.device_limit or 0
if current_devices < tariff_included:
free_devices = tariff_included - current_devices
chargeable_devices = max(0, devices_difference - free_devices)
else:
chargeable_devices = devices_difference
elif current_devices < settings.DEFAULT_DEVICE_LIMIT:
free_devices = settings.DEFAULT_DEVICE_LIMIT - current_devices
chargeable_devices = max(0, devices_difference - free_devices)
@@ -572,7 +583,8 @@ async def execute_change_devices(
devices_discount_percent,
)
price = int(discounted_per_month * days_left / 30)
price = max(100, price)
if chargeable_devices > 0:
price = max(100, price)
else:
price = 0
@@ -1215,7 +1227,22 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
)
return
devices_price_per_month = devices_count * price_per_device
# Устройства в пределах тарифного лимита — бесплатные
current_devices = subscription.device_limit or 1
if tariff:
tariff_included = tariff.device_limit or 0
if current_devices < tariff_included:
free_devices = tariff_included - current_devices
chargeable_devices = max(0, devices_count - free_devices)
else:
chargeable_devices = devices_count
elif current_devices < settings.DEFAULT_DEVICE_LIMIT:
free_devices = settings.DEFAULT_DEVICE_LIMIT - current_devices
chargeable_devices = max(0, devices_count - free_devices)
else:
chargeable_devices = devices_count
devices_price_per_month = chargeable_devices * price_per_device
# TOCTOU: lock user row before reading promo/discount state
db_user = await lock_user_for_pricing(db, db_user.id)
@@ -1240,7 +1267,8 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
)
# Цена = месячная_цена * days_left / 30
price = int(discounted_per_month * days_left / 30)
price = max(100, price) # Минимум 1 рубль
if chargeable_devices > 0:
price = max(100, price) # Минимум 1 рубль (только для платных устройств)
total_discount = int(discount_per_month * days_left / 30)
period_label = f'{days_left} дн.' if days_left > 1 else '1 день'
else:
@@ -1260,7 +1288,8 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
)
# Цена = месячная_цена * days_left / 30
price = int(discounted_per_month * days_left / 30)
price = max(100, price) # Минимум 1 рубль
if chargeable_devices > 0:
price = max(100, price) # Минимум 1 рубль (только для платных устройств)
total_discount = int(discount_per_month * days_left / 30)
period_label = f'{days_left} дн.' if days_left > 1 else '1 день'
@@ -1273,7 +1302,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
total_discount=total_discount / 100,
)
if db_user.balance_kopeks < price:
if price > 0 and db_user.balance_kopeks < price:
missing_kopeks = price - db_user.balance_kopeks
required_text = f'{texts.format_price(price)} (за {period_label})'
message_text = texts.t(
+136 -47
View File
@@ -922,9 +922,11 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
trial_tariff = await get_tariff_by_id(db, trial_tariff_id)
if trial_tariff:
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
trial_traffic_limit = trial_tariff.traffic_limit_gb
trial_device_limit = trial_tariff.device_limit
trial_squads = trial_tariff.allowed_squads or []
trial_squads = await get_effective_tariff_squad_uuids(db, trial_tariff.allowed_squads)
tariff_id_for_trial = trial_tariff.id
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
if tariff_trial_days:
@@ -937,6 +939,13 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
except Exception as e:
logger.error('Ошибка получения триального тарифа', error=e)
# No trial tariff configured, use the legacy random trial squad fallback.
if not trial_squads:
from app.database.crud.server_squad import get_random_trial_squad_uuid
trial_squad_uuid = await get_random_trial_squad_uuid(db)
trial_squads = [trial_squad_uuid] if trial_squad_uuid else []
subscription = await create_trial_subscription(
db,
db_user.id,
@@ -1537,7 +1546,7 @@ async def return_to_saved_cart(callback: types.CallbackQuery, state: FSMContext,
total_price = prepared_cart_data.get('total_price', 0)
if db_user.balance_kopeks < total_price:
if total_price > 0 and db_user.balance_kopeks < total_price:
missing_amount = total_price - db_user.balance_kopeks
insufficient_keyboard = get_insufficient_balance_keyboard_with_cart(
db_user.language,
@@ -1572,11 +1581,15 @@ async def return_to_saved_cart(callback: types.CallbackQuery, state: FSMContext,
if settings.is_traffic_fixed():
traffic_value = prepared_cart_data.get('traffic_gb')
if traffic_value is None:
traffic_value = prepared_cart_data.get('traffic_limit_gb')
if traffic_value is None:
traffic_value = settings.get_fixed_traffic_limit()
traffic_display = 'Безлимитный' if traffic_value == 0 else f'{traffic_value} ГБ'
else:
traffic_value = prepared_cart_data.get('traffic_gb', 0) or 0
traffic_value = prepared_cart_data.get('traffic_gb')
if traffic_value is None:
traffic_value = prepared_cart_data.get('traffic_limit_gb', 0)
traffic_display = 'Безлимитный' if traffic_value == 0 else f'{traffic_value} ГБ'
summary_lines = [
@@ -1589,6 +1602,8 @@ async def return_to_saved_cart(callback: types.CallbackQuery, state: FSMContext,
if settings.is_devices_selection_enabled():
devices_value = prepared_cart_data.get('devices')
if devices_value is None:
devices_value = prepared_cart_data.get('device_limit')
if devices_value is not None:
summary_lines.append(f'📱 Устройства: {devices_value}')
@@ -1635,7 +1650,7 @@ async def handle_extend_subscription(
else:
subscription = db_user.subscription
if not subscription or subscription.is_trial:
if not subscription:
await callback.message.edit_text(
'🎯 <b>Пробный период заканчивается</b>\n\nЧтобы продолжить пользоваться VPN, выберите подходящий тариф.',
reply_markup=types.InlineKeyboardMarkup(
@@ -1654,24 +1669,53 @@ async def handle_extend_subscription(
await callback.answer()
return
# В режиме тарифов проверяем наличие tariff_id
if settings.is_tariffs_mode():
if subscription.tariff_id:
# Проверяем, суточный ли тариф — у суточных нет period_prices, продление через resume
from app.database.crud.tariff import get_tariff_by_id
# Триальная подписка с тарифом — направляем на покупку этого тарифа
if subscription.is_trial:
if subscription.tariff_id and settings.is_tariffs_mode():
from .tariff_purchase import show_tariff_extend
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff and getattr(tariff, 'is_daily', False):
# Суточный тариф: перенаправляем на страницу подписки (там кнопка «Возобновить»)
await show_subscription_info(callback, db_user, db)
return
await show_tariff_extend(callback, db_user, db)
return
# Триал без тарифа предлагаем выбрать
await callback.message.edit_text(
'🎯 <b>Пробный период заканчивается</b>\n\nЧтобы продолжить пользоваться VPN, выберите подходящий тариф.',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data='menu_buy')],
[
types.InlineKeyboardButton(
text=texts.t('WEBHOOK_CLOSE_BUTTON', '✖️ Закрыть'),
callback_data='webhook:close',
)
],
]
),
parse_mode='HTML',
)
await callback.answer()
return
# Подписка с тарифом — всегда используем тарифный flow,
# даже если бот в классическом режиме (подписка могла быть куплена через кабинет)
if subscription.tariff_id:
# Проверяем, суточный ли тариф — у суточных нет period_prices, продление через resume
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff and getattr(tariff, 'is_daily', False):
# Суточный тариф: перенаправляем на страницу подписки (там кнопка «Возобновить»)
await show_subscription_info(callback, db_user, db)
return
if tariff:
# У подписки есть тариф - перенаправляем на продление по тарифу
from .tariff_purchase import show_tariff_extend
await show_tariff_extend(callback, db_user, db)
return
# У подписки нет тарифа - предлагаем выбрать тариф
if settings.is_tariffs_mode():
# У подписки нет тарифа, но режим тарифов включён - предлагаем выбрать тариф
await callback.message.edit_text(
'📦 <b>Выберите тариф для продления</b>\n\n'
'Ваша текущая подписка была создана до введения тарифов.\n'
@@ -1706,6 +1750,10 @@ async def handle_extend_subscription(
# original = price before ALL discounts, final = price with all discounts
total_original_price = pricing.original_total
# Пропускаем периоды с нулевой ценой (если оригинальная цена тоже 0 — не настроен)
if pricing.final_total <= 0 and pricing.original_total <= 0:
continue
renewal_prices[days] = {
'final': pricing.final_total,
'original': total_original_price,
@@ -1899,7 +1947,7 @@ async def confirm_extend_subscription(
await callback.answer('⚠ Ошибка расчета стоимости', show_alert=True)
return
if db_user.balance_kopeks < price:
if price > 0 and db_user.balance_kopeks < price:
missing_kopeks = price - db_user.balance_kopeks
required_text = texts.format_price(price)
message_text = texts.t(
@@ -1930,7 +1978,10 @@ async def confirm_extend_subscription(
'description': f'Продление подписки на {days} дней',
'consume_promo_offer': bool(promo_offer_discount > 0),
'device_limit': device_limit,
'devices': device_limit,
'traffic_limit_gb': renewal_traffic_gb,
'traffic_gb': renewal_traffic_gb,
'countries': list(subscription.connected_squads or []),
}
await user_cart_service.save_user_cart(db_user.id, cart_data)
@@ -2185,7 +2236,12 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
devices_selection_enabled = settings.is_devices_selection_enabled()
forced_disabled_limit: int | None = None
if devices_selection_enabled:
devices_selected = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
# Для extend-корзины ключ может быть 'device_limit' вместо 'devices'
devices_selected = data.get('devices')
if devices_selected is None:
devices_selected = data.get('device_limit')
if devices_selected is None:
devices_selected = settings.DEFAULT_DEVICE_LIMIT
else:
forced_disabled_limit = settings.get_disabled_mode_device_limit()
if forced_disabled_limit is None:
@@ -2307,7 +2363,7 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
)
logger.info('ИТОГО: ₽', final_price=final_price / 100)
if db_user.balance_kopeks < final_price:
if final_price > 0 and db_user.balance_kopeks < final_price:
missing_kopeks = final_price - db_user.balance_kopeks
message_text = texts.t(
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
@@ -2546,17 +2602,19 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
subscription_service = SubscriptionService()
# При покупке подписки ВСЕГДА сбрасываем трафик в панели
_purchase_uuid = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
else db_user.remnawave_uuid
)
if settings.is_multi_tariff_enabled() and not getattr(subscription, 'remnawave_uuid', None):
logger.warning(
'Multi-tariff: subscription missing remnawave_uuid, using user fallback',
subscription_id=getattr(subscription, 'id', None),
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(db_user, 'remnawave_uuid', None)
if _should_create:
remnawave_user = await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка подписки',
)
if _purchase_uuid:
else:
remnawave_user = await subscription_service.update_remnawave_user(
db,
subscription,
@@ -2564,22 +2622,25 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
reset_reason='покупка подписки',
sync_squads=True,
)
else:
remnawave_user = await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка подписки',
)
if not remnawave_user:
logger.error('Не удалось создать/обновить RemnaWave пользователя для', telegram_id=db_user.telegram_id)
remnawave_user = await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка подписки (повторная попытка)',
)
logger.error('Не удалось создать/обновить RemnaWave пользователя', telegram_id=db_user.telegram_id)
try:
remnawave_user = await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка подписки (повторная попытка)',
)
except Exception as retry_error:
logger.error('Повторная попытка создания RemnaWave пользователя не удалась', error=retry_error)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=db_user.id,
action='create',
)
transaction = await create_transaction(
db=db,
@@ -3129,6 +3190,13 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
)
except Exception as e:
logger.error('Ошибка синхронизации с Remnawave при возобновлении', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=db_user.id,
action='update',
)
# Отправляем уведомление администраторам о возобновлении суточной подписки
if resume_transaction is not None:
@@ -3255,9 +3323,11 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user:
if trial_tariff_id > 0:
trial_tariff = await _get_tariff(db, trial_tariff_id)
if trial_tariff:
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
trial_traffic_limit = trial_tariff.traffic_limit_gb
trial_device_limit = trial_tariff.device_limit
trial_squads = trial_tariff.allowed_squads or []
trial_squads = await get_effective_tariff_squad_uuids(db, trial_tariff.allowed_squads)
tariff_id_for_trial = trial_tariff.id
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
if tariff_trial_days:
@@ -3270,6 +3340,13 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user:
except Exception as e:
logger.error('Ошибка получения триального тарифа для платного триала', error=e)
# No trial tariff configured, use the legacy random trial squad fallback.
if not trial_squads:
from app.database.crud.server_squad import get_random_trial_squad_uuid
trial_squad_uuid = await get_random_trial_squad_uuid(db)
trial_squads = [trial_squad_uuid] if trial_squad_uuid else []
subscription = await create_trial_subscription(
db,
db_user.id,
@@ -3614,9 +3691,11 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
if trial_tariff_id > 0:
trial_tariff = await _get_tariff(db, trial_tariff_id)
if trial_tariff:
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
trial_traffic = trial_tariff.traffic_limit_gb
trial_devices = trial_tariff.device_limit
trial_squads_list = trial_tariff.allowed_squads or []
trial_squads_list = await get_effective_tariff_squad_uuids(db, trial_tariff.allowed_squads)
tariff_id_for_trial = trial_tariff.id
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
if tariff_trial_days:
@@ -3629,7 +3708,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
except Exception as e:
logger.error('Ошибка получения триального тарифа для платного триала', error=e)
# Если тариф не задал серверы, получаем случайный сквад
# Если триальный тариф не найден, используем legacy fallback со случайным сквадом.
if not trial_squads_list:
from app.database.crud.server_squad import get_random_trial_squad_uuid
@@ -4415,8 +4494,8 @@ async def _extend_existing_subscription(
device_limit=device_limit,
)
# Проверяем баланс пользователя
if db_user.balance_kopeks < price_kopeks:
# Проверяем баланс пользователя (при 100% скидке — пропускаем)
if price_kopeks > 0 and db_user.balance_kopeks < price_kopeks:
missing_kopeks = price_kopeks - db_user.balance_kopeks
message_text = texts.t(
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
@@ -4447,8 +4526,11 @@ async def _extend_existing_subscription(
'return_to_cart': True,
'description': f'Продление подписки на {period_days} дней',
'device_limit': device_limit,
'devices': device_limit,
'traffic_limit_gb': traffic_limit_gb,
'traffic_gb': traffic_limit_gb,
'squad_uuid': squad_uuid,
'countries': [squad_uuid] if squad_uuid else [],
'consume_promo_offer': consume_promo,
}
@@ -4561,6 +4643,13 @@ async def _extend_existing_subscription(
logger.error('⚠ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE')
except Exception as e:
logger.error('⚠ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=current_subscription.id,
user_id=db_user.id,
action='update',
)
# Создаём транзакцию
transaction = await create_transaction(
+388 -64
View File
@@ -576,7 +576,7 @@ async def show_tariffs_list(
from app.database.crud.subscription import get_active_subscriptions_by_user_id
active_subs = await get_active_subscriptions_by_user_id(db, db_user.id)
purchased_tariff_ids = {s.tariff_id for s in active_subs if s.tariff_id and s.status in ('active', 'trial')}
purchased_tariff_ids = {s.tariff_id for s in active_subs if s.tariff_id and not s.is_trial}
# Проверяем есть ли у пользователя скидки по периодам
promo_group = db_user.get_primary_promo_group() if hasattr(db_user, 'get_primary_promo_group') else None
@@ -619,7 +619,7 @@ async def select_tariff(
from app.database.crud.subscription import get_active_subscriptions_by_user_id
_active = await get_active_subscriptions_by_user_id(db, db_user.id)
_existing = next((s for s in _active if s.tariff_id == tariff_id and s.status in ('active', 'trial')), None)
_existing = next((s for s in _active if s.tariff_id == tariff_id and not s.is_trial), None)
if _existing:
days_left = max(0, (_existing.end_date - datetime.now(UTC)).days) if _existing.end_date else 0
await callback.answer(
@@ -928,14 +928,14 @@ async def handle_custom_confirm(
)
total_price = result.final_total
# Проверяем, что цена за период валидна
if result.base_price == 0 and not tariff.can_purchase_custom_days():
# Проверяем, что цена за период валидна (original_total — цена до скидок)
if result.original_total == 0 and not tariff.can_purchase_custom_days():
await callback.answer('Выбранный период недоступен для этого тарифа', show_alert=True)
return
# Проверяем баланс (user already locked, balance is fresh)
# Проверяем баланс (при 100% скидке — пропускаем)
user_balance = db_user.balance_kopeks or 0
if user_balance < total_price:
if total_price > 0 and user_balance < total_price:
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
@@ -1057,14 +1057,34 @@ async def handle_custom_confirm(
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка тарифа',
)
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(db_user, 'remnawave_uuid', None)
if _should_create:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка тарифа',
)
else:
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка тарифа',
)
except Exception as e:
logger.error('Ошибка обновления Remnawave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=db_user.id,
action='create',
)
# Создаем транзакцию
await create_transaction(
@@ -1353,7 +1373,7 @@ async def confirm_tariff_purchase(
# Проверяем баланс (user already locked, balance is fresh)
user_balance = db_user.balance_kopeks or 0
if user_balance < final_price:
if final_price > 0 and user_balance < final_price:
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
@@ -1568,14 +1588,37 @@ async def confirm_tariff_purchase(
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка тарифа',
)
# In multi-tariff mode, each subscription has its own panel user.
# A new subscription has no remnawave_uuid yet, so always CREATE.
# In single-tariff mode, reuse the user-level UUID if available.
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(db_user, 'remnawave_uuid', None)
if _should_create:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка тарифа',
)
else:
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка тарифа',
)
except Exception as e:
logger.error('Ошибка обновления Remnawave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=db_user.id,
action='create',
)
# Создаем транзакцию
try:
@@ -1694,7 +1737,7 @@ async def confirm_daily_tariff_purchase(
# Проверяем баланс (user already locked, balance is fresh)
user_balance = db_user.balance_kopeks or 0
if user_balance < final_daily_price:
if final_daily_price > 0 and user_balance < final_daily_price:
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
@@ -1828,14 +1871,34 @@ async def confirm_daily_tariff_purchase(
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка суточного тарифа',
)
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(db_user, 'remnawave_uuid', None)
if _should_create:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка суточного тарифа',
)
else:
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка суточного тарифа',
)
except Exception as e:
logger.error('Ошибка обновления Remnawave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=db_user.id,
action='create',
)
# Создаем транзакцию
await create_transaction(
@@ -2013,8 +2076,6 @@ async def show_tariff_extend(
# Show subscription picker for extending
keyboard = []
for sub in sorted(active_subs, key=lambda s: s.id):
if sub.is_trial:
continue
tariff_name = ''
if sub.tariff_id:
_t = await get_tariff_by_id(db, sub.tariff_id)
@@ -2044,8 +2105,36 @@ async def show_tariff_extend(
subscription = None
else:
subscription = await get_subscription_by_user_id(db, db_user.id)
if not subscription or not subscription.tariff_id:
await callback.answer('Тариф не найден', show_alert=True)
if not subscription:
await callback.answer('Подписка не найдена', show_alert=True)
return
if not subscription.tariff_id:
# Legacy user without tariff — show tariff selection for upgrade
promo_group_id = getattr(db_user, 'promo_group_id', None)
tariffs = await get_tariffs_for_user(db, promo_group_id)
if not tariffs:
await callback.answer('Нет доступных тарифов', show_alert=True)
return
keyboard = []
for t in tariffs:
if t.is_daily:
continue
keyboard.append([InlineKeyboardButton(text=f'📦 {t.name}', callback_data=f'tariff_select:{t.id}')])
if not keyboard:
await callback.answer('Нет доступных тарифов для продления', show_alert=True)
return
keyboard.append([InlineKeyboardButton(text='◀️ Назад', callback_data='back_to_menu')])
await callback.message.edit_text(
'🔄 <b>Выберите тариф для продления</b>\n\n'
'Для продления подписки необходимо выбрать тариф.\n'
'Подписка будет обновлена с параметрами выбранного тарифа.',
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode='HTML',
)
await callback.answer()
return
tariff = await get_tariff_by_id(db, subscription.tariff_id)
@@ -2053,6 +2142,31 @@ async def show_tariff_extend(
await callback.answer('Тариф не найден', show_alert=True)
return
# Скрытый/неактивный тариф (например, триальный после промокода) —
# показываем список доступных тарифов вместо продления скрытого
if not tariff.is_active:
promo_group_id = getattr(db_user, 'promo_group_id', None)
tariffs = await get_tariffs_for_user(db, promo_group_id)
active_tariffs = [t for t in tariffs if not t.is_daily]
if not active_tariffs:
await callback.answer('Нет доступных тарифов для продления', show_alert=True)
return
keyboard = []
for t in active_tariffs:
keyboard.append([InlineKeyboardButton(text=f'📦 {t.name}', callback_data=f'tariff_select:{t.id}')])
keyboard.append([InlineKeyboardButton(text='◀️ Назад', callback_data='back_to_menu')])
await callback.message.edit_text(
'🔄 <b>Выберите тариф для продления</b>\n\n'
'Для продления подписки необходимо выбрать тариф.\n'
'Подписка будет обновлена с параметрами выбранного тарифа.',
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode='HTML',
)
await callback.answer()
return
traffic = format_traffic(tariff.traffic_limit_gb)
# Проверяем есть ли у пользователя скидки по периодам
@@ -2246,7 +2360,7 @@ async def confirm_tariff_extend(
# Проверяем баланс
user_balance = db_user.balance_kopeks or 0
if user_balance < final_price:
if final_price > 0 and user_balance < final_price:
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
@@ -2266,24 +2380,50 @@ async def confirm_tariff_extend(
await callback.answer('Ошибка списания баланса', show_alert=True)
return
# Продлеваем подписку (параметры тарифа не меняются, только добавляется время)
# Запоминаем, был ли триал ДО продления
was_trial = subscription.is_trial
# Продлеваем подписку; для триала передаём tariff_id чтобы сбросить is_trial
subscription = await extend_subscription(
db,
subscription,
days=period,
tariff_id=tariff.id if was_trial else None,
traffic_limit_gb=tariff.traffic_limit_gb if was_trial else None,
device_limit=actual_device_limit if was_trial else None,
)
# Обновляем пользователя в Remnawave
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason='продление тарифа',
)
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(db_user, 'remnawave_uuid', None)
if _should_create:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT or was_trial,
reset_reason='конвертация триала' if was_trial else 'продление тарифа',
)
else:
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT or was_trial,
reset_reason='конвертация триала' if was_trial else 'продление тарифа',
)
except Exception as e:
logger.error('Ошибка обновления Remnawave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=db_user.id,
action='create',
)
# Создаем транзакцию
await create_transaction(
@@ -2303,7 +2443,7 @@ async def confirm_tariff_extend(
subscription,
None, # Транзакция отсутствует, оплата с баланса
period,
was_trial_conversion=False,
was_trial_conversion=was_trial,
amount_kopeks=final_price,
purchase_type='renewal',
)
@@ -2526,6 +2666,18 @@ async def show_tariff_switch_list(
current_tariff_id = subscription.tariff_id
# Проверяем, разрешена ли смена тарифа хотя бы в одном направлении
if not settings.TARIFF_SWITCH_UPGRADE_ENABLED and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
await callback.message.edit_text(
'🚫 <b>Смена тарифа недоступна</b>\n\nАдминистратор отключил возможность смены тарифа.',
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')]]
),
parse_mode='HTML',
)
await callback.answer()
return
# Получаем доступные тарифы
promo_group_id = getattr(db_user, 'promo_group_id', None)
tariffs = await get_tariffs_for_user(db, promo_group_id)
@@ -2538,6 +2690,14 @@ async def show_tariff_switch_list(
else:
available_tariffs = [t for t in tariffs if t.id != current_tariff_id]
# Фильтруем по разрешённым направлениям (upgrade/downgrade)
current_tariff = await get_tariff_by_id(db, current_tariff_id) if current_tariff_id else None
if current_tariff:
remaining_days = max(0, (subscription.end_date - datetime.now(UTC)).days) if subscription.end_date else 0
available_tariffs = _filter_tariffs_by_switch_direction(
available_tariffs, current_tariff, remaining_days, db_user
)
if not available_tariffs:
await callback.message.edit_text(
'😔 <b>Нет доступных тарифов для переключения</b>\n\nВы уже используете единственный доступный тариф.',
@@ -2599,6 +2759,24 @@ async def select_tariff_switch(
await callback.answer('Тариф недоступен', show_alert=True)
return
# Проверяем разрешение на смену в данном направлении
current_subscription_sw, _sw_sub_id_check = await _resolve_subscription(callback, db_user, db, state)
if current_subscription_sw and current_subscription_sw.tariff_id:
cur_tariff_sw = await get_tariff_by_id(db, current_subscription_sw.tariff_id)
if cur_tariff_sw:
rem_days = (
max(0, (current_subscription_sw.end_date - datetime.now(UTC)).days)
if current_subscription_sw.end_date
else 0
)
_, is_up = _calculate_instant_switch_cost(cur_tariff_sw, tariff, rem_days, db_user)
if is_up and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
await callback.answer('Повышение тарифа недоступно', show_alert=True)
return
if not is_up and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
await callback.answer('Понижение тарифа недоступно', show_alert=True)
return
traffic = format_traffic(tariff.traffic_limit_gb)
# Проверяем, суточный ли это тариф
@@ -2820,6 +2998,19 @@ async def confirm_tariff_switch(
await callback.answer('У вас нет активной подписки', show_alert=True)
return
# Проверяем разрешение на смену в данном направлении
if subscription.tariff_id and subscription.tariff_id != tariff_id:
cur_tariff_obj = await get_tariff_by_id(db, subscription.tariff_id)
if cur_tariff_obj:
rem_days = max(0, (subscription.end_date - datetime.now(UTC)).days) if subscription.end_date else 0
_, is_up = _calculate_instant_switch_cost(cur_tariff_obj, tariff, rem_days, db_user)
if is_up and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
await callback.answer('Повышение тарифа недоступно', show_alert=True)
return
if not is_up and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
await callback.answer('Понижение тарифа недоступно', show_alert=True)
return
# Calculate price via PricingEngine (handles per-category discounts + extra devices)
from app.services.pricing_engine import pricing_engine
@@ -2836,7 +3027,7 @@ async def confirm_tariff_switch(
# Проверяем баланс
user_balance = db_user.balance_kopeks or 0
if user_balance < final_price:
if final_price > 0 and user_balance < final_price:
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
@@ -2889,14 +3080,34 @@ async def confirm_tariff_switch(
# Обновляем пользователя в Remnawave
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='переключение тарифа',
)
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(db_user, 'remnawave_uuid', None)
if _should_create:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='переключение тарифа',
)
else:
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='переключение тарифа',
)
except Exception as e:
logger.error('Ошибка обновления Remnawave при переключении тарифа', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=db_user.id,
action='create',
)
# Гарантированный сброс устройств при смене тарифа
await db.refresh(db_user)
@@ -3042,7 +3253,7 @@ async def confirm_daily_tariff_switch(
# Проверяем баланс (user already locked, balance is fresh)
user_balance = db_user.balance_kopeks or 0
if user_balance < final_daily_price:
if final_daily_price > 0 and user_balance < final_daily_price:
await callback.answer('Недостаточно средств на балансе', show_alert=True)
return
@@ -3052,6 +3263,19 @@ async def confirm_daily_tariff_switch(
await callback.answer('У вас нет активной подписки', show_alert=True)
return
# Проверяем разрешение на смену в данном направлении
if subscription.tariff_id and subscription.tariff_id != tariff_id:
cur_tariff_daily = await get_tariff_by_id(db, subscription.tariff_id)
if cur_tariff_daily:
rem_days = max(0, (subscription.end_date - datetime.now(UTC)).days) if subscription.end_date else 0
_, is_up = _calculate_instant_switch_cost(cur_tariff_daily, tariff, rem_days, db_user)
if is_up and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
await callback.answer('Повышение тарифа недоступно', show_alert=True)
return
if not is_up and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
await callback.answer('Понижение тарифа недоступно', show_alert=True)
return
texts = get_texts(db_user.language)
try:
@@ -3117,14 +3341,34 @@ async def confirm_daily_tariff_switch(
# Обновляем пользователя в Remnawave (сброс трафика по админ-настройке)
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='смена на суточный тариф',
)
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(db_user, 'remnawave_uuid', None)
if _should_create:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='смена на суточный тариф',
)
else:
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='смена на суточный тариф',
)
except Exception as e:
logger.error('Ошибка обновления Remnawave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=db_user.id,
action='create',
)
# Гарантированный сброс устройств при смене тарифа
await db.refresh(db_user)
@@ -3263,6 +3507,30 @@ def _calculate_instant_switch_cost(
return result.upgrade_cost, result.is_upgrade
def _filter_tariffs_by_switch_direction(
tariffs: list[Tariff],
current_tariff: Tariff,
remaining_days: int,
db_user: User | None = None,
) -> list[Tariff]:
"""Фильтрует тарифы по разрешённым направлениям смены (upgrade/downgrade)."""
upgrade_ok = settings.TARIFF_SWITCH_UPGRADE_ENABLED
downgrade_ok = settings.TARIFF_SWITCH_DOWNGRADE_ENABLED
if upgrade_ok and downgrade_ok:
return tariffs
filtered = []
for tariff in tariffs:
if tariff.id == current_tariff.id:
filtered.append(tariff)
continue
_, is_upgrade = _calculate_instant_switch_cost(current_tariff, tariff, remaining_days, db_user)
if (is_upgrade and upgrade_ok) or (not is_upgrade and downgrade_ok):
filtered.append(tariff)
return filtered
def format_instant_switch_list_text(
tariffs: list[Tariff],
current_tariff: Tariff,
@@ -3270,16 +3538,21 @@ def format_instant_switch_list_text(
db_user: User | None = None,
) -> str:
"""Форматирует текст со списком тарифов для мгновенного переключения."""
upgrade_ok = settings.TARIFF_SWITCH_UPGRADE_ENABLED
downgrade_ok = settings.TARIFF_SWITCH_DOWNGRADE_ENABLED
lines = [
'📦 <b>Мгновенная смена тарифа</b>',
f'📌 Текущий: <b>{html.escape(current_tariff.name)}</b>',
f'⏰ Осталось: <b>{remaining_days} дн.</b>',
'',
'💡 При переключении остаток дней сохраняется.',
'⬆️ Повышение тарифа = доплата за разницу',
'⬇️ Понижение = бесплатно',
'',
]
if upgrade_ok:
lines.append('⬆️ Повышение тарифа = доплата за разницу')
if downgrade_ok:
lines.append('⬇️ Понижение = бесплатно')
lines.append('')
for tariff in tariffs:
if tariff.id == current_tariff.id:
@@ -3411,6 +3684,18 @@ async def show_instant_switch_list(
await callback.answer()
return
# Проверяем, разрешена ли смена тарифа хотя бы в одном направлении
if not settings.TARIFF_SWITCH_UPGRADE_ENABLED and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
await callback.message.edit_text(
'🚫 <b>Смена тарифа недоступна</b>\n\nАдминистратор отключил возможность смены тарифа.',
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')]]
),
parse_mode='HTML',
)
await callback.answer()
return
# Получаем доступные тарифы
promo_group_id = getattr(db_user, 'promo_group_id', None)
tariffs = await get_tariffs_for_user(db, promo_group_id)
@@ -3423,6 +3708,9 @@ async def show_instant_switch_list(
else:
available_tariffs = [t for t in tariffs if t.id != current_tariff.id]
# Фильтруем по разрешённым направлениям (upgrade/downgrade)
available_tariffs = _filter_tariffs_by_switch_direction(available_tariffs, current_tariff, remaining_days, db_user)
if not available_tariffs:
await callback.message.edit_text(
'😔 <b>Нет доступных тарифов для переключения</b>\n\nВы уже используете единственный доступный тариф.',
@@ -3492,6 +3780,14 @@ async def preview_instant_switch(
# Рассчитываем стоимость переключения
upgrade_cost, is_upgrade = _calculate_instant_switch_cost(current_tariff, new_tariff, remaining_days, db_user)
# Проверяем разрешение на смену в данном направлении
if is_upgrade and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
await callback.answer('Повышение тарифа недоступно', show_alert=True)
return
if not is_upgrade and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
await callback.answer('Понижение тарифа недоступно', show_alert=True)
return
# Проверяем баланс
user_balance = db_user.balance_kopeks or 0
@@ -3666,6 +3962,14 @@ async def confirm_instant_switch(
is_upgrade = switch_result.is_upgrade
consume_promo = switch_result.offer_discount_pct > 0
# Проверяем разрешение на смену в данном направлении
if is_upgrade and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
await callback.answer('Повышение тарифа недоступно', show_alert=True)
return
if not is_upgrade and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
await callback.answer('Понижение тарифа недоступно', show_alert=True)
return
# Проверяем баланс если это upgrade (use locked user's fresh balance)
user_balance = db_user.balance_kopeks or 0
if is_upgrade and user_balance < upgrade_cost:
@@ -3791,14 +4095,34 @@ async def confirm_instant_switch(
# Обновляем пользователя в Remnawave (сброс трафика по админ-настройке)
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='мгновенное переключение тарифа',
)
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(db_user, 'remnawave_uuid', None)
if _should_create:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='мгновенное переключение тарифа',
)
else:
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='мгновенное переключение тарифа',
)
except Exception as e:
logger.error('Ошибка обновления Remnawave при мгновенном переключении', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=db_user.id,
action='create',
)
# Гарантированный сброс устройств при смене тарифа
await db.refresh(db_user)
@@ -3946,8 +4270,8 @@ async def return_to_saved_tariff_cart(
user_balance = db_user.balance_kopeks or 0
traffic = format_traffic(tariff.traffic_limit_gb)
# Проверяем баланс
if user_balance < total_price:
# Проверяем баланс (при 100% скидке — пропускаем)
if total_price > 0 and user_balance < total_price:
missing = total_price - user_balance
if cart_mode == 'daily_tariff_purchase':
+3 -3
View File
@@ -332,7 +332,7 @@ async def confirm_reset_traffic(
reset_price = _calculate_traffic_reset_price(subscription)
if db_user.balance_kopeks < reset_price:
if reset_price > 0 and db_user.balance_kopeks < reset_price:
missing_kopeks = reset_price - db_user.balance_kopeks
message_text = texts.t(
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
@@ -574,7 +574,7 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
total_discount_value = int(discount_per_month * charged_days / 30)
if db_user.balance_kopeks < price:
if price > 0 and db_user.balance_kopeks < price:
missing_kopeks = price - db_user.balance_kopeks
# Save cart for auto-purchase after balance top-up
@@ -830,7 +830,7 @@ async def confirm_switch_traffic(
total_price_difference = int(price_difference_per_month * days_remaining / 30)
total_price_difference = max(100, total_price_difference)
if db_user.balance_kopeks < total_price_difference:
if total_price_difference > 0 and db_user.balance_kopeks < total_price_difference:
missing_kopeks = total_price_difference - db_user.balance_kopeks
message_text = texts.t(
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
+8 -1
View File
@@ -983,7 +983,14 @@ async def close_ticket_notification(callback: types.CallbackQuery, db_user: User
await callback.answer()
return
await callback.message.delete()
try:
await callback.message.delete()
except TelegramBadRequest:
# Message is too old to delete (>48h) — edit it instead
try:
await callback.message.edit_text(texts.t('NOTIFICATION_CLOSED', 'Уведомление закрыто.'))
except TelegramBadRequest:
pass
await callback.answer(texts.t('NOTIFICATION_CLOSED', 'Уведомление закрыто.'))
+63 -2
View File
@@ -1755,10 +1755,23 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
has_direct_payment_methods = True
if settings.is_kassa_ai_sberpay_enabled():
sberpay_name = settings.get_kassa_ai_sberpay_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_KASSA_AI_SBERPAY', f'💳 {sberpay_name}'),
callback_data=_build_callback('kassa_ai_sberpay'),
)
]
)
has_direct_payment_methods = True
if (
settings.is_kassa_ai_enabled()
and not settings.is_kassa_ai_sbp_enabled()
and not settings.is_kassa_ai_card_enabled()
and not settings.is_kassa_ai_sberpay_enabled()
):
kassa_ai_name = settings.get_kassa_ai_display_name()
keyboard.append(
@@ -1794,6 +1807,54 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
has_direct_payment_methods = True
if settings.is_paypear_enabled():
paypear_name = settings.get_paypear_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_PAYPEAR', f'💳 Оплата ({paypear_name})'),
callback_data=_build_callback('paypear'),
)
]
)
has_direct_payment_methods = True
if settings.is_rollypay_enabled():
rollypay_name = settings.get_rollypay_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_ROLLYPAY', f'💳 {rollypay_name}'),
callback_data=_build_callback('rollypay'),
)
]
)
has_direct_payment_methods = True
if settings.is_overpay_enabled():
overpay_name = settings.get_overpay_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_OVERPAY', f'💳 {overpay_name}'),
callback_data=_build_callback('overpay'),
)
]
)
has_direct_payment_methods = True
if settings.is_aurapay_enabled():
aurapay_name = settings.get_aurapay_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_AURAPAY', f'💳 {aurapay_name}'),
callback_data=_build_callback('aurapay'),
)
]
)
has_direct_payment_methods = True
if settings.is_support_topup_enabled():
keyboard.append(
[
@@ -2261,8 +2322,8 @@ def get_change_devices_keyboard(
tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None
if tariff and tariff_device_price:
device_price_per_month = tariff_device_price
# Для тарифов все устройства платные (нет бесплатного лимита)
default_device_limit = 0
# Устройства в пределах тарифного лимита — бесплатные
default_device_limit = tariff.device_limit if tariff else 0
else:
device_price_per_month = settings.PRICE_PER_DEVICE
default_device_limit = settings.DEFAULT_DEVICE_LIMIT
+4 -1
View File
@@ -1755,5 +1755,8 @@
"WEBHOOK_USER_NOT_CONNECTED": "📡 <b>Not connected yet</b>\n\nYour subscription{tariff_label} is active but no VPN connection has been made. Connect to start using the service.",
"WEBHOOK_DEVICE_ADDED": "📱 <b>New device</b>\n\nA new device has been added to your subscription{tariff_label}: <code>{device}</code>",
"WEBHOOK_DEVICE_DELETED": "📱 <b>Device removed</b>\n\nA device has been removed from your subscription{tariff_label}: <code>{device}</code>",
"WEBHOOK_CLOSE_BUTTON": "✖️ Close"
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Torrent detected</b>\n\nTorrent traffic was detected on your connection{tariff_label}. Using torrents may result in subscription restrictions.",
"WEBHOOK_CLOSE_BUTTON": "✖️ Close",
"TRAFFIC_WARNING_ALERT": "⚠️ <b>Traffic Warning</b>\n\nUsed: {used:.1f} / {limit} GB ({percent:.0f}%)\n\nYour traffic limit is almost reached.",
"LOW_BALANCE_ALERT": "⚠️ <b>Low Balance</b>\n\nYour balance: {balance} ₽\nNotification threshold: {threshold} ₽\n\nTop up your balance to ensure automatic subscription renewal."
}
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -1749,19 +1749,16 @@
"MODEM_PRICE_WITH_DISCOUNT": "Стоимость: <s>{base_price}</s> <b>{final_price}</b> (за {months} мес)\n🎁 Скидка {discount}%: -{discount_amount}",
"MODEM_PRICE_NO_DISCOUNT": "Стоимость: {price} (за {months} мес)",
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>Подтверждение подключения модема</b>\n\n{price_text}\n\nПри подключении модема:\n• К подписке добавится дополнительное устройство\n• Ежемесячная плата увеличится на {monthly_price}\n\nПодтвердить подключение?",
"ADMIN_USER_RESTRICTIONS": "⚠️ Ограничить",
"USER_RESTRICTION_TOPUP_BLOCKED": "🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\nЕсли вы считаете это ошибкой, вы можете обжаловать решение.",
"USER_RESTRICTION_SUBSCRIPTION_BLOCKED": "🚫 <b>Покупка/продление подписки ограничено</b>\n\n{reason}\n\nЕсли вы считаете это ошибкой, вы можете обжаловать решение.",
"USER_RESTRICTION_APPEAL_BUTTON": "🆘 Обжаловать",
"PAUSE_DAILY_BUTTON": "⏸️ Приостановить подписку",
"RESUME_DAILY_BUTTON": "▶️ Возобновить подписку",
"DAILY_SWITCH_WARNING": "⚠️ <b>Внимание!</b> У вас осталось {days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!",
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Подписка приостановлена",
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Подписка возобновлена!",
"DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ <b>Подписка возобновлена!</b>\n\nВаш суточный тариф «{tariff_name}» возобновлён после пополнения баланса.\n\n💳 Списано: {amount}\n💰 Остаток: {balance}",
"WEBHOOK_SUB_EXPIRED": "❌ <b>Подписка{tariff_label} истекла</b>\n\nВаша подписка завершена. Продлите подписку, чтобы восстановить доступ к VPN.",
"WEBHOOK_SUB_DISABLED": "🚫 <b>Подписка{tariff_label} отключена</b>\n\nВаша подписка была отключена администратором.",
"WEBHOOK_SUB_ENABLED": "✅ <b>Подписка{tariff_label} активирована</b>\n\nВаша подписка снова активна. Приятного использования!",
@@ -1779,5 +1776,8 @@
"WEBHOOK_USER_NOT_CONNECTED": "📡 <b>Вы ещё не подключились</b>\n\nВаша подписка{tariff_label} активна, но VPN-соединение не было установлено. Подключитесь, чтобы начать пользоваться.",
"WEBHOOK_DEVICE_ADDED": "📱 <b>Новое устройство</b>\n\nК подписке{tariff_label} подключено новое устройство: <code>{device}</code>",
"WEBHOOK_DEVICE_DELETED": "📱 <b>Устройство удалено</b>\n\nУстройство отключено от подписки{tariff_label}: <code>{device}</code>",
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрыть"
}
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Обнаружен торрент</b>\n\nВ вашем подключении{tariff_label} обнаружен торрент-трафик. Использование торрентов может привести к ограничению подписки.",
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрыть",
"TRAFFIC_WARNING_ALERT": "⚠️ <b>Предупреждение о трафике</b>\n\nИспользовано: {used:.1f} / {limit} ГБ ({percent:.0f}%)\n\nВаш лимит трафика почти исчерпан.",
"LOW_BALANCE_ALERT": "⚠️ <b>Низкий баланс</b>\n\nВаш баланс: {balance} ₽\nПорог уведомления: {threshold} ₽\n\nПополните баланс, чтобы автопродление подписки прошло успешно."
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+46
View File
@@ -15,6 +15,7 @@ Usage::
from __future__ import annotations
import logging
import sys
from typing import Any
import structlog
@@ -56,6 +57,46 @@ def _prefix_logger_name(logger: Any, method_name: str, event_dict: dict[str, Any
return event_dict
def _auto_capture_exc_info(logger: Any, method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]:
"""Auto-populate event_dict['exc_info'] so tracebacks render in files/console.
Without this, callers must pass ``exc_info=True`` at every ``logger.error``
site. Instead, we try:
1. exc_info=True replace with sys.exc_info() (standard structlog behaviour)
2. no exc_info but we're inside an active except block → use sys.exc_info()
3. error/exc/exception/e/err kwarg is a BaseException with __traceback__
synthesize an exc_info tuple from it
Result: ``logger.error('msg', error=e)`` inside any ``except`` block now
renders the full traceback to files, console, and Telegram automatically.
"""
exc_info = event_dict.get('exc_info')
if exc_info is True:
current = sys.exc_info()
if current[1] is not None:
event_dict['exc_info'] = current
return event_dict
if exc_info:
return event_dict
# Only auto-capture from sys.exc_info() for error/critical levels.
# For warning/info inside except blocks, callers must pass exc_info=True explicitly.
if method_name in ('error', 'critical', 'exception'):
current = sys.exc_info()
if current[1] is not None:
event_dict['exc_info'] = current
return event_dict
for key in ('error', 'exc', 'exception', 'e', 'err'):
candidate = event_dict.get(key)
if isinstance(candidate, BaseException) and candidate.__traceback__ is not None:
event_dict['exc_info'] = (type(candidate), candidate, candidate.__traceback__)
return event_dict
return event_dict
def setup_logging() -> tuple[logging.Formatter, logging.Formatter, Any]:
"""Configure structlog and return formatters + notifier.
@@ -82,6 +123,11 @@ def setup_logging() -> tuple[logging.Formatter, logging.Formatter, Any]:
structlog.stdlib.PositionalArgumentsFormatter(),
timestamper,
structlog.processors.StackInfoRenderer(),
# Auto-capture traceback from sys.exc_info()/error-kwarg BEFORE any
# consumer looks at event_dict. Runs for ALL log levels so files,
# console, and Telegram all see the same traceback without requiring
# every caller to pass exc_info=True.
_auto_capture_exc_info,
# TelegramNotifierProcessor MUST run while exc_info is still a raw
# tuple so it can extract the traceback for Telegram notifications.
# ConsoleRenderer handles exc_info formatting downstream (with Rich
+16 -1
View File
@@ -129,13 +129,28 @@ class TelegramNotifierProcessor:
if any(logger_name.startswith(prefix) for prefix in IGNORED_LOGGER_PREFIXES):
return event_dict
# 4. Resolve exc_info=True to actual tuple while still in except block.
# 4. Resolve exc_info into actual tuple while still in except block.
# logger.exception() sets exc_info=True (bool); we need the tuple for
# traceback extraction. sys.exc_info() works because the processor runs
# synchronously inside the except clause.
#
# If exc_info is not passed at all, auto-capture traceback from:
# (a) sys.exc_info() — works when logger.error is called inside except
# (b) error/exc/exception kwargs if they carry __traceback__
# This avoids having to pass exc_info=True at every logger.error site.
exc_info = event_dict.get('exc_info')
if exc_info is True:
event_dict['exc_info'] = sys.exc_info()
elif not exc_info:
current = sys.exc_info()
if current[1] is not None:
event_dict['exc_info'] = current
else:
for key in ('error', 'exc', 'exception', 'e', 'err'):
candidate = event_dict.get(key)
if isinstance(candidate, BaseException) and candidate.__traceback__ is not None:
event_dict['exc_info'] = (type(candidate), candidate, candidate.__traceback__)
break
# 5. Bot not initialized yet — skip
bot = self._bot
+28 -23
View File
@@ -370,6 +370,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
# Per-channel settings: check if any unsubscribed channel requires deactivation
unsubscribed = [ch for ch in channels if not ch.get('is_subscribed', False)]
deactivated_subs = []
for subscription in active_subs:
should_disable = any(
channel_subscription_service.should_disable_subscription(ch, subscription.is_trial)
@@ -379,6 +380,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
continue
await deactivate_subscription(db, subscription)
deactivated_subs.append(subscription)
sub_type = 'trial' if subscription.is_trial else 'paid'
logger.info(
'Subscription deactivated after channel unsubscribe',
@@ -387,7 +389,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
)
service = SubscriptionService()
for subscription in active_subs:
for subscription in deactivated_subs:
panel_uuid = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
@@ -404,29 +406,30 @@ class ChannelCheckerMiddleware(BaseMiddleware):
)
# Notify user about deactivation
try:
normalized = _normalize_channels(channels)
texts = get_texts(user.language or DEFAULT_LANGUAGE)
if settings.is_multi_tariff_enabled() and len(active_subs) > 1:
notification_text = texts.t(
'SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE_MULTI',
'🚫 Ваши подписки приостановлены, так как вы отписались от обязательного канала.\n\n'
'Подпишитесь на все каналы для восстановления доступа к VPN.',
if deactivated_subs:
try:
normalized = _normalize_channels(channels)
texts = get_texts(user.language or DEFAULT_LANGUAGE)
if settings.is_multi_tariff_enabled() and len(deactivated_subs) > 1:
notification_text = texts.t(
'SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE_MULTI',
'🚫 Ваши подписки приостановлены, так как вы отписались от обязательного канала.\n\n'
'Подпишитесь на все каналы для восстановления доступа к VPN.',
)
else:
notification_text = texts.t(
'SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE',
'🚫 Ваша подписка приостановлена, так как вы отписались от канала.\n\n'
'Подпишитесь на канал снова, чтобы восстановить доступ к VPN.',
)
channel_kb = get_channel_sub_keyboard(normalized, language=user.language)
await bot.send_message(telegram_id, notification_text, reply_markup=channel_kb)
except Exception as notify_error:
logger.error(
'Failed to send deactivation notification to user',
telegram_id=telegram_id,
notify_error=notify_error,
)
else:
notification_text = texts.t(
'SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE',
'🚫 Ваша подписка приостановлена, так как вы отписались от канала.\n\n'
'Подпишитесь на канал снова, чтобы восстановить доступ к VPN.',
)
channel_kb = get_channel_sub_keyboard(normalized, language=user.language)
await bot.send_message(telegram_id, notification_text, reply_markup=channel_kb)
except Exception as notify_error:
logger.error(
'Failed to send deactivation notification to user',
telegram_id=telegram_id,
notify_error=notify_error,
)
await db.commit()
except Exception as db_error:
logger.error(
@@ -463,6 +466,8 @@ class ChannelCheckerMiddleware(BaseMiddleware):
for subscription in disabled_subs:
await reactivate_subscription(db, subscription)
# Штамп для защиты от echo-webhook user.disabled
subscription.last_webhook_update_at = datetime.now(UTC)
sub_type = 'trial' if subscription.is_trial else 'paid'
logger.info(
'Subscription reactivated after channel subscribe',
@@ -85,6 +85,9 @@ class DisplayNameRestrictionMiddleware(BaseMiddleware):
if not user or user.is_bot:
return await handler(event, data)
if not settings.DISPLAY_NAME_RESTRICTION_ENABLED:
return await handler(event, data)
display_name = self._build_display_name(user)
username = user.username or ''
+216
View File
@@ -0,0 +1,216 @@
"""Сервис для работы с API AuraPay (aurapay.tech)."""
import hashlib
import hmac
from typing import Any
import aiohttp
import structlog
from app.config import settings
logger = structlog.get_logger(__name__)
API_BASE_URL = 'https://app.aurapay.tech'
class AuraPayAPIError(Exception):
"""Ошибка API AuraPay."""
def __init__(self, status_code: int, message: str):
self.status_code = status_code
self.message = message
super().__init__(f'AuraPay API error ({status_code}): {message}')
class AuraPayService:
"""Сервис для работы с API AuraPay."""
def __init__(self):
self._session: aiohttp.ClientSession | None = None
@property
def api_key(self) -> str:
return settings.AURAPAY_API_KEY or ''
@property
def shop_id(self) -> str:
return settings.AURAPAY_SHOP_ID or ''
@property
def secret_key(self) -> str:
return settings.AURAPAY_SECRET_KEY or ''
async def _get_session(self) -> aiohttp.ClientSession:
"""Возвращает переиспользуемую HTTP-сессию."""
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30),
)
return self._session
async def close(self) -> None:
"""Закрывает HTTP-сессию."""
if self._session and not self._session.closed:
await self._session.close()
self._session = None
def _build_headers(self) -> dict[str, str]:
"""Строит заголовки запроса с X-ApiKey и X-ShopId."""
return {
'Content-Type': 'application/json',
'X-ApiKey': self.api_key,
'X-ShopId': self.shop_id,
}
async def create_invoice(
self,
*,
amount: float,
order_id: str,
comment: str = '',
service: str | None = None,
success_url: str | None = None,
fail_url: str | None = None,
callback_url: str | None = None,
custom_fields: str | None = None,
lifetime: int | None = None,
) -> dict[str, Any]:
"""
Создает инвойс через API AuraPay.
POST /invoice/create
"""
payload: dict[str, Any] = {
'amount': amount,
'order_id': order_id,
}
if comment:
payload['comment'] = comment
if service:
payload['service'] = service
if success_url:
payload['success_url'] = success_url
if fail_url:
payload['fail_url'] = fail_url
if callback_url:
payload['callback_url'] = callback_url
if custom_fields:
payload['custom_fields'] = custom_fields
if lifetime is not None:
payload['lifetime'] = lifetime
logger.info(
'AuraPay API create_invoice',
order_id=order_id,
amount=amount,
service=service,
)
try:
session = await self._get_session()
async with session.post(
f'{API_BASE_URL}/invoice/create',
json=payload,
headers=self._build_headers(),
) as response:
data = await response.json(content_type=None)
if response.status == 200:
logger.info(
'AuraPay API invoice created',
order_id=order_id,
invoice_id=data.get('id'),
status=data.get('status'),
)
return data
error_msg = data.get('message') or data.get('error') or str(data)
logger.error(
'AuraPay create_invoice error',
status_code=response.status,
error_msg=error_msg,
response_data=data,
)
raise AuraPayAPIError(response.status, error_msg)
except aiohttp.ClientError as e:
logger.exception('AuraPay API connection error', error=e)
raise
async def get_invoice_status(
self,
*,
order_id: str | None = None,
invoice_id: str | None = None,
) -> dict[str, Any]:
"""
Получает статус инвойса.
POST /invoice/status
"""
if not order_id and not invoice_id:
raise ValueError('Either order_id or invoice_id must be provided')
payload: dict[str, str] = {}
if order_id:
payload['order_id'] = order_id
if invoice_id:
payload['id'] = invoice_id
logger.info('AuraPay get_invoice_status', order_id=order_id, invoice_id=invoice_id)
try:
session = await self._get_session()
async with session.post(
f'{API_BASE_URL}/invoice/status',
json=payload,
headers=self._build_headers(),
) as response:
data = await response.json(content_type=None)
if response.status == 200:
return data
error_msg = data.get('message') or data.get('error') or str(data)
logger.error(
'AuraPay get_invoice_status error',
status_code=response.status,
error_msg=error_msg,
)
raise AuraPayAPIError(response.status, error_msg)
except aiohttp.ClientError as e:
logger.exception('AuraPay API connection error', error=e)
raise
def verify_webhook_signature(self, payload: dict[str, Any], received_signature: str) -> bool:
"""Верификация подписи webhook AuraPay через HMAC-SHA256.
Algorithm: sort JSON keys alphabetically, concatenate all VALUES
(converted to str) into one string, HMAC-SHA256 with secret key #2.
Header: X-SIGNATURE
"""
try:
if not received_signature:
logger.warning('AuraPay webhook: отсутствует X-SIGNATURE')
return False
# Сортируем ключи по алфавиту и конкатенируем значения
sorted_keys = sorted(payload.keys())
concatenated_values = ''.join(str(payload[key]) for key in sorted_keys)
expected = hmac.new(
self.secret_key.encode('utf-8'),
concatenated_values.encode('utf-8'),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, received_signature)
except Exception as e:
logger.error('AuraPay webhook verify error', error=e)
return False
# Singleton instance
aurapay_service = AuraPayService()
+25 -20
View File
@@ -86,33 +86,38 @@ class BlacklistService:
if not line or line.startswith('#'):
continue # Пропускаем пустые строки и комментарии
# В формате '7021477105 #@MAMYT_PAXAL2016, перепродажа подписок'
# В формате '7021477105 # @MAMYT_PAXAL2016, перепродажа подписок'
# только первая часть до пробела - это Telegram ID, всё остальное комментарий
parts = line.split()
if not parts:
continue
try:
telegram_id = int(parts[0]) # Первое число - это Telegram ID
# Всё остальное - просто комментарий, не используем его для логики
# Но можем использовать первую часть после ID как username для отображения
username = ''
if len(parts) > 1:
# Берем вторую часть как username (если начинается с @)
if parts[1].startswith('@'):
username = parts[1]
# 1. Разделяем строку на ID и всё остальное по символу '#'
if '#' in line:
id_part, content_part = line.split('#', 1)
telegram_id = int(id_part.strip())
content = content_part.strip()
else:
# Если решётки нет, пробуем просто взять первое число
parts = line.split(maxsplit=1)
telegram_id = int(parts[0])
content = parts[1].strip() if len(parts) > 1 else ''
# По умолчанию используем "Занесен в черный список", если нет другой информации
# 2. Обрабатываем контент: вычленяем username, если он есть в начале
username = ''
reason = 'Занесен в черный список'
# Если есть запятая в строке, можем использовать часть после нее как причину
full_line_after_id = line[len(str(telegram_id)) :].strip()
if ',' in full_line_after_id:
# Извлекаем причину после запятой
after_comma = full_line_after_id.split(',', 1)[1].strip()
reason = after_comma
if content:
if content.startswith('@'):
# Разбиваем контент только по первому пробелу
# content_parts[0] будет юзернеймом, content_parts[1] — причиной
content_parts = content.split(maxsplit=1)
username = content_parts[0]
if len(content_parts) > 1:
reason = content_parts[1].strip()
else:
# Если собачки нет, значит весь контент — это причина
reason = content
blacklist_data.append((telegram_id, username, reason))
except ValueError:
# Если не удается преобразовать в число, это не ID
logger.warning(
+20 -3
View File
@@ -62,6 +62,7 @@ class BroadcastConfig:
media: BroadcastMediaConfig | None = None
initiator_name: str | None = None
custom_buttons: list[dict] | None = None
category: str = 'system' # system|news|promo
@dataclass
@@ -160,7 +161,7 @@ class BroadcastService:
await session.commit()
# _fetch_recipients теперь возвращает list[int] (telegram_id), а не ORM-объекты
recipient_ids: list[int] = await self._fetch_recipients(config.target)
recipient_ids: list[int] = await self._fetch_recipients(config.target, config.category)
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
@@ -226,8 +227,13 @@ class BroadcastService:
logger.exception('Критическая ошибка при выполнении рассылки', broadcast_id=broadcast_id, exc=exc)
await self._mark_failed(broadcast_id, sent_count, failed_count, blocked_count)
async def _fetch_recipients(self, target: str) -> list[int]:
"""Загружает получателей и возвращает список telegram_id (скаляры, не ORM-объекты)."""
async def _fetch_recipients(self, target: str, category: str = 'system') -> list[int]:
"""Загружает получателей и возвращает список telegram_id (скаляры, не ORM-объекты).
Filters out users who disabled the given broadcast category in their
notification preferences (news_enabled, promo_offers_enabled).
Category 'system' is never filtered system notifications reach everyone.
"""
async with AsyncSessionLocal() as session:
if target.startswith('custom_'):
criteria = target[len('custom_') :]
@@ -235,6 +241,17 @@ class BroadcastService:
else:
users_orm = await get_target_users(session, target)
# Filter by user notification preferences based on broadcast category
if category == 'news':
from app.utils.notification_prefs import is_news_enabled
users_orm = [u for u in users_orm if is_news_enabled(u)]
elif category == 'promo':
from app.utils.notification_prefs import is_promo_offers_enabled
users_orm = [u for u in users_orm if is_promo_offers_enabled(u)]
# category == 'system' → no filtering, sent to everyone
# Извлекаем telegram_id сразу, пока сессия жива.
# После выхода из блока ORM-объекты станут detached.
return [u.telegram_id for u in users_orm if u.telegram_id is not None]
+12 -20
View File
@@ -159,17 +159,13 @@ class AdvertisingCampaignService:
device_limit = campaign.subscription_device_limit
if device_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
squads = list(campaign.subscription_squads or [])
try:
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
if not squads:
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
trial_uuid = await get_random_trial_squad_uuid(db)
if trial_uuid:
squads = [trial_uuid]
except Exception as error:
logger.error('Не удалось подобрать сквад для кампании', campaign_id=campaign.id, error=error)
squads = await get_effective_tariff_squad_uuids(db, campaign.subscription_squads)
except Exception as error:
logger.error('Не удалось подобрать сквады для кампании', campaign_id=campaign.id, error=error)
squads = list(campaign.subscription_squads or [])
if existing_subscription:
# Multi-tariff: extend the best existing subscription
@@ -305,17 +301,13 @@ class AdvertisingCampaignService:
traffic_limit = tariff.traffic_limit_gb
device_limit = tariff.device_limit
squads = list(tariff.allowed_squads or [])
try:
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
if not squads:
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
trial_uuid = await get_random_trial_squad_uuid(db)
if trial_uuid:
squads = [trial_uuid]
except Exception as error:
logger.error('Не удалось подобрать сквад для тарифа кампании', campaign_id=campaign.id, error=error)
squads = await get_effective_tariff_squad_uuids(db, tariff.allowed_squads)
except Exception as error:
logger.error('Не удалось подобрать сквады для тарифа кампании', campaign_id=campaign.id, error=error)
squads = list(tariff.allowed_squads or [])
if existing_subscription:
# Multi-tariff: extend the existing subscription for this tariff

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