Compare commits

...

61 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
85 changed files with 6163 additions and 374 deletions
+15
View File
@@ -254,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
@@ -974,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
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.49.0"
".": "3.52.0"
}
+84
View File
@@ -1,5 +1,89 @@
# 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)
+1 -1
View File
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
FROM python:3.13-slim
ARG VERSION="v3.49.0" # x-release-please-version
ARG VERSION="v3.52.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+12
View File
@@ -126,6 +126,7 @@ Bedolaga — полнофункциональная платформа для п
| 🤝 | **[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>
@@ -198,6 +199,17 @@ Bedolaga — официальный партнёр платёжной систе
📩 Менеджер: [@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>
+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)
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,
)
+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)
+127 -11
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),
@@ -417,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),
@@ -443,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,
@@ -450,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,
@@ -457,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]
@@ -1050,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
@@ -1069,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)
@@ -1191,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
@@ -1322,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
+3 -4
View File
@@ -815,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()
+29
View File
@@ -854,6 +854,35 @@ async def create_topup(
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(
+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 ============
+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,
@@ -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
@@ -366,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
@@ -627,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
@@ -724,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
@@ -353,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
@@ -1219,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:
@@ -1235,7 +1240,7 @@ async def activate_trial(
except Exception as e:
logger.error('Error getting trial tariff', error=e)
# BUG-12 fix: If no squads from tariff, fallback to trial-eligible servers
# 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
@@ -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()
@@ -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()
@@ -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
+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)
+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
+58 -1
View File
@@ -148,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
@@ -564,6 +567,21 @@ class Settings(BaseSettings):
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
RIOPAY_API_TOKEN: str | None = None # x-api-token header
@@ -611,6 +629,23 @@ class Settings(BaseSettings):
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
@@ -768,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
@@ -1231,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]:
@@ -2029,6 +2071,21 @@ class Settings(BaseSettings):
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
+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
+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]:
"""Возвращает список активных серверов, доступных для подключения."""
+8 -7
View File
@@ -142,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
@@ -151,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)
+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)
+6 -6
View File
@@ -230,17 +230,17 @@ 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_(
Transaction.user_id == user_id,
Transaction.is_completed.is_(True),
Transaction.type.in_(
[
TransactionType.SUBSCRIPTION_PAYMENT.value,
TransactionType.GIFT_PAYMENT.value,
]
),
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
)
)
)
+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()
+116
View File
@@ -164,6 +164,7 @@ class PaymentMethod(Enum):
SEVERPAY = 'severpay'
PAYPEAR = 'paypear'
ROLLYPAY = 'rollypay'
OVERPAY = 'overpay'
AURAPAY = 'aurapay'
MANUAL = 'manual'
BALANCE = 'balance'
@@ -1005,6 +1006,68 @@ class RollyPayPayment(Base):
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)."""
@@ -2836,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())
@@ -3457,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())
@@ -3519,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')
@@ -3600,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())
+4
View File
@@ -78,6 +78,7 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
'SEVERPAY',
'PAYPEAR',
'ROLLYPAY',
'OVERPAY',
'AURAPAY',
'MULENPAY',
'PAL24',
@@ -1269,6 +1270,9 @@ def _build_settings_keyboard(
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')])
+2 -4
View File
@@ -4518,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)
+27 -7
View File
@@ -163,6 +163,13 @@ async def route_payment_by_method(
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
@@ -491,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
@@ -509,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
@@ -517,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
@@ -593,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':
@@ -604,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:
@@ -615,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()
@@ -748,6 +764,10 @@ def register_balance_handlers(dp: Dispatcher):
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')
+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,
)
+5 -2
View File
@@ -42,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
@@ -294,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:
@@ -304,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:
+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),
)
+8 -2
View File
@@ -1148,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(
'🔒 Политика конфиденциальности отправлена новым сообщением пользователю',
@@ -1725,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:
@@ -2079,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:
+37 -8
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 день'
+31 -8
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,7 +939,7 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
except Exception as e:
logger.error('Ошибка получения триального тарифа', error=e)
# BUG-12 fix: If no squads from tariff, fallback to trial-eligible servers
# 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
@@ -1579,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 = [
@@ -1596,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}')
@@ -1970,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)
@@ -2225,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:
@@ -3307,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:
@@ -3322,7 +3340,7 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user:
except Exception as e:
logger.error('Ошибка получения триального тарифа для платного триала', error=e)
# BUG-12 fix: If no squads from tariff, fallback to trial-eligible servers
# 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
@@ -3673,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:
@@ -3688,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
@@ -4506,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,
}
+152 -3
View File
@@ -2142,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)
# Проверяем есть ли у пользователя скидки по периодам
@@ -2641,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)
@@ -2653,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Вы уже используете единственный доступный тариф.',
@@ -2714,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)
# Проверяем, суточный ли это тариф
@@ -2935,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
@@ -3187,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:
@@ -3418,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,
@@ -3425,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:
@@ -3566,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)
@@ -3578,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Вы уже используете единственный доступный тариф.',
@@ -3647,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
@@ -3821,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:
+14 -2
View File
@@ -1831,6 +1831,18 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
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(
@@ -2310,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
+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
+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
+99 -28
View File
@@ -145,6 +145,8 @@ async def create_purchase(
gift_recipient_value: str | None = None,
gift_message: str | None = None,
source: str = 'landing',
subid: str | None = None,
referrer: str | None = None,
buyer_user_id: int | None = None,
commit: bool = True,
) -> GuestPurchase:
@@ -152,6 +154,8 @@ async def create_purchase(
purchase = await create_guest_purchase(
db,
commit=commit,
subid=subid,
referrer=referrer,
landing_id=landing.id if landing else None,
tariff_id=tariff.id,
period_days=period_days,
@@ -438,28 +442,93 @@ async def fulfill_purchase(
purchase.status = GuestPurchaseStatus.DELIVERED.value
purchase.user_id = user.id
purchase.delivered_at = datetime.now(UTC)
# Extract subid from Redis cache (saved at purchase creation)
try:
from app.utils.cache import cache
_cached_subid = await cache.get(f'subid:purchase:{purchase.token}')
if _cached_subid:
purchase.subid = _cached_subid if isinstance(_cached_subid, str) else _cached_subid.decode()
from app.database.crud.yandex_client_id import upsert_subid
await upsert_subid(db, user.id, purchase.subid, source='landing')
except Exception:
pass
if recipient_type == 'email' and not purchase.is_gift and is_new_account:
purchase.auto_login_token = create_auto_login_token(user.id)
await db.commit()
await db.refresh(purchase, attribute_names=['landing', 'user', 'buyer'])
# Create transaction so promo group auto-assignment and contest tracking work
# Create transaction so promo group auto-assignment and contest tracking work.
# Skip for gift recipients — they didn't pay, so their spending shouldn't be inflated.
transaction = None
if not purchase.is_gift:
try:
payment_method_enum = _resolve_payment_method(purchase.payment_method)
transaction = await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=purchase.amount_kopeks,
description=f'Покупка подписки через лендинг ({notification_tariff_name}, {purchase.period_days} дн.)',
payment_method=payment_method_enum,
external_id=purchase.payment_id,
is_completed=True,
)
except Exception:
logger.exception('Failed to create transaction for guest purchase', purchase_id=purchase.id)
# Save Yandex CID from Redis → DB (enables on_registration/on_purchase to use it)
try:
payment_method_enum = _resolve_payment_method(purchase.payment_method)
transaction = await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=purchase.amount_kopeks,
description=f'Покупка подписки через лендинг ({notification_tariff_name}, {purchase.period_days} дн.)',
payment_method=payment_method_enum,
external_id=purchase.payment_id,
is_completed=True,
)
from app.services import yandex_offline_conv_service as yandex_conv
from app.utils.cache import cache
_cached_cid = await cache.get(f'yacid:purchase:{purchase.token}')
if _cached_cid:
await yandex_conv.store_cid(db, user.id, _cached_cid, source='landing')
await db.commit()
except Exception:
logger.exception('Failed to create transaction for guest purchase', purchase_id=purchase.id)
logger.debug('Failed to save CID from Redis')
# Registration event (new accounts only) + S2S postback
if is_new_account:
try:
from app.services import yandex_offline_conv_service as yandex_conv
await yandex_conv.on_registration(db, user.id)
except Exception:
logger.debug('Yandex on_registration hook error')
try:
from app.database.crud.yandex_client_id import get_subid
from app.services.s2s_postback_service import send_postback
_subid = purchase.subid or await get_subid(db, user.id)
if _subid:
await send_postback('registration', _subid, user_id=user.id)
except Exception:
logger.debug('S2S postback registration hook error')
# Purchase event + S2S postback (always for paid purchases)
try:
from app.services import yandex_offline_conv_service as yandex_conv
await yandex_conv.on_purchase(db, user.id, purchase.amount_kopeks)
except Exception:
logger.debug('Yandex on_purchase hook error')
try:
from app.database.crud.yandex_client_id import get_subid
from app.services.s2s_postback_service import send_postback
_subid = purchase.subid or await get_subid(db, user.id)
if _subid:
await send_postback('purchase', _subid, amount=purchase.amount_kopeks / 100, user_id=user.id)
except Exception:
logger.debug('S2S postback purchase hook error')
try:
await send_guest_notification(
@@ -1147,21 +1216,23 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
await db.commit()
await db.refresh(purchase, attribute_names=['landing', 'user', 'buyer'])
# Create transaction so promo group auto-assignment and contest tracking work
try:
payment_method_enum = _resolve_payment_method(purchase.payment_method)
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=purchase.amount_kopeks,
description=f'Покупка подписки через лендинг ({notification_tariff_name}, {purchase.period_days} дн.)',
payment_method=payment_method_enum,
external_id=purchase.payment_id,
is_completed=True,
)
except Exception:
logger.exception('Failed to create transaction for activated purchase', purchase_id=purchase.id)
# Create transaction so promo group auto-assignment and contest tracking work.
# Skip for gift recipients — they didn't pay, so their spending shouldn't be inflated.
if not purchase.is_gift:
try:
payment_method_enum = _resolve_payment_method(purchase.payment_method)
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=purchase.amount_kopeks,
description=f'Покупка подписки через лендинг ({notification_tariff_name}, {purchase.period_days} дн.)',
payment_method=payment_method_enum,
external_id=purchase.payment_id,
is_completed=True,
)
except Exception:
logger.exception('Failed to create transaction for activated purchase', purchase_id=purchase.id)
if not skip_notification:
try:
+230 -156
View File
@@ -1202,160 +1202,229 @@ class MonitoringService:
processed_count = 0
failed_count = 0
for subscription in autopay_subscriptions:
from app.database.crud.subscription import is_recently_updated_by_webhook
if is_recently_updated_by_webhook(subscription):
logger.debug(
'Пропуск автоплатежа подписки : обновлена вебхуком недавно', subscription_id=subscription.id
)
continue
user = subscription.user
if not user:
continue
user_identifier = user.telegram_id or f'email:{user.id}'
# Определяем период продления: из тарифа (минимальный) или 30 дней по умолчанию
tariff = getattr(subscription, 'tariff', None)
if tariff:
autopay_period = tariff.get_shortest_period() or 30
else:
autopay_period = 30
# Захватываем (sub_id, user_id) ДО цикла, пока сессия ещё свежая.
# В цикле каждую итерацию делаем refetch subscription+user через
# async-запрос: это единственный безопасный способ избежать
# MissingGreenlet при sync-lazy-load, который SQLAlchemy 2.0 async
# session не поддерживает (напр. lock_user_for_pricing c
# populate_existing=True разгружает Subscription.user backref).
autopay_pairs: list[tuple[int, int]] = [(s.id, s.user_id) for s in autopay_subscriptions]
for sub_id_local, sub_user_id_local in autopay_pairs:
try:
from app.database.crud.user import lock_user_for_pricing
from app.services.pricing_engine import pricing_engine
user = await lock_user_for_pricing(db, user.id)
pricing = await pricing_engine.calculate_renewal_price(
db,
subscription,
autopay_period,
user=user,
# Refetch subscription с eager load user/tariff —
# никаких lazy access по ходу итерации.
refetch_result = await db.execute(
select(Subscription)
.options(
selectinload(Subscription.user).options(
selectinload(User.promo_group),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
),
selectinload(Subscription.tariff),
)
.where(Subscription.id == sub_id_local)
)
renewal_cost = pricing.final_total
except Exception as e:
logger.error(
'Ошибка расчёта стоимости автопродления, пропускаем',
subscription_id=subscription.id,
user_id=user.id,
error=str(e),
)
failed_count += 1
continue
subscription = refetch_result.scalar_one_or_none()
if subscription is None:
continue
if renewal_cost <= 0:
logger.warning(
'Нулевая стоимость автопродления, пропускаем',
subscription_id=subscription.id,
user_id=user.id,
renewal_cost=renewal_cost,
)
failed_count += 1
continue
from app.database.crud.subscription import is_recently_updated_by_webhook
# calculate_renewal_price уже включает promo_group + promo_offer скидки.
# Не применяем promo_offer повторно — только consume-им при успешной оплате.
charge_amount = renewal_cost
promo_discount_percent = get_user_active_promo_discount_percent(user)
if is_recently_updated_by_webhook(subscription):
logger.debug(
'Пропуск автоплатежа подписки : обновлена вебхуком недавно',
subscription_id=subscription.id,
)
continue
autopay_key = f'autopay_{user.id}_{subscription.id}'
if autopay_key in self._notified_users:
continue
user = subscription.user
if not user:
continue
if user.balance_kopeks >= charge_amount:
success = await subtract_user_balance(
db,
user,
charge_amount,
'Автопродление подписки',
consume_promo_offer=promo_discount_percent > 0,
mark_as_paid_subscription=True,
)
user_identifier = user.telegram_id or f'email:{user.id}'
if success:
# extend_subscription сам обработает EXPIRED→ACTIVE переход
# (проверяет status + end_date для определения was_expired)
if subscription.status == SubscriptionStatus.EXPIRED.value:
logger.info(
'🔄 Autopay: продление EXPIRED подписки (восстановление)',
subscription_id=subscription.id,
user_id=user.id,
)
old_end_date = subscription.end_date
await extend_subscription(db, subscription, autopay_period)
await self.subscription_service.update_remnawave_user(
# Определяем период продления: из тарифа (минимальный) или 30 дней по умолчанию
tariff = getattr(subscription, 'tariff', None)
if tariff:
autopay_period = tariff.get_shortest_period() or 30
else:
autopay_period = 30
try:
from app.database.crud.user import lock_user_for_pricing
from app.services.pricing_engine import pricing_engine
user = await lock_user_for_pricing(db, user.id)
pricing = await pricing_engine.calculate_renewal_price(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason='автопродление подписки',
autopay_period,
user=user,
)
renewal_cost = pricing.final_total
except Exception as e:
logger.error(
'Ошибка расчёта стоимости автопродления, пропускаем',
subscription_id=subscription.id,
user_id=user.id,
error=str(e),
)
failed_count += 1
continue
if renewal_cost <= 0:
logger.warning(
'Нулевая стоимость автопродления, пропускаем',
subscription_id=subscription.id,
user_id=user.id,
renewal_cost=renewal_cost,
)
failed_count += 1
continue
# calculate_renewal_price уже включает promo_group + promo_offer скидки.
# Не применяем promo_offer повторно — только consume-им при успешной оплате.
charge_amount = renewal_cost
promo_discount_percent = get_user_active_promo_discount_percent(user)
autopay_key = f'autopay_{user.id}_{subscription.id}'
if autopay_key in self._notified_users:
continue
if user.balance_kopeks >= charge_amount:
success = await subtract_user_balance(
db,
user,
charge_amount,
'Автопродление подписки',
consume_promo_offer=promo_discount_percent > 0,
mark_as_paid_subscription=True,
)
# Создаём транзакцию, чтобы автопродление было видно в статистике и карточке пользователя
try:
from app.database.crud.transaction import create_transaction
from app.database.models import PaymentMethod, TransactionType
transaction = await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=charge_amount,
description=f'Автопродление подписки на {autopay_period} дней',
payment_method=PaymentMethod.BALANCE,
)
except Exception as exc:
logger.warning('Не удалось создать транзакцию автопродления', user_id=user.id, exc=exc)
transaction = None
# Отправляем уведомление администраторам
try:
from app.services.subscription_renewal_service import with_admin_notification_service
if transaction:
await with_admin_notification_service(
lambda svc: svc.send_subscription_extension_notification(
db,
user,
subscription,
transaction,
autopay_period,
old_end_date,
new_end_date=subscription.end_date,
balance_after=user.balance_kopeks,
)
if success:
# subtract_user_balance мог оставить сессию в expired state
# (напр. rollback внутри log_promo_offer_action при consume_promo_offer).
# Перезагружаем subscription с eager-загрузкой user/tariff, чтобы
# избежать MissingGreenlet на последующих обращениях к subscription.*
refetch_result = await db.execute(
select(Subscription)
.options(
selectinload(Subscription.user),
selectinload(Subscription.tariff),
)
except Exception as exc:
.where(Subscription.id == subscription.id)
)
refreshed_subscription = refetch_result.scalar_one_or_none()
if refreshed_subscription is None:
logger.warning(
'Подписка пропала после списания — пропускаем шаги продления',
subscription_id=subscription.id,
user_id=user.id,
)
processed_count += 1
self._notified_users.add(autopay_key)
continue
subscription = refreshed_subscription
# extend_subscription сам обработает EXPIRED→ACTIVE переход
# (проверяет status + end_date для определения was_expired)
if subscription.status == SubscriptionStatus.EXPIRED.value:
logger.info(
'🔄 Autopay: продление EXPIRED подписки (восстановление)',
subscription_id=subscription.id,
user_id=user.id,
)
old_end_date = subscription.end_date
await extend_subscription(db, subscription, autopay_period)
await self.subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason='автопродление подписки',
)
# Создаём транзакцию, чтобы автопродление было видно в статистике и карточке пользователя
try:
from app.database.crud.transaction import create_transaction
from app.database.models import PaymentMethod, TransactionType
transaction = await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=charge_amount,
description=f'Автопродление подписки на {autopay_period} дней',
payment_method=PaymentMethod.BALANCE,
)
except Exception as exc:
logger.warning('Не удалось создать транзакцию автопродления', user_id=user.id, exc=exc)
transaction = None
# Отправляем уведомление администраторам
try:
from app.services.subscription_renewal_service import with_admin_notification_service
if transaction:
await with_admin_notification_service(
lambda svc: svc.send_subscription_extension_notification(
db,
user,
subscription,
transaction,
autopay_period,
old_end_date,
new_end_date=subscription.end_date,
balance_after=user.balance_kopeks,
)
)
except Exception as exc:
logger.warning(
'Не удалось отправить админ-уведомление об автопродлении', user_id=user.id, exc=exc
)
# Send notification via appropriate channel
if user.telegram_id and self.bot:
await self._send_autopay_success_notification(
user, charge_amount, autopay_period, subscription=subscription
)
elif not user.telegram_id:
# Email-only user - use notification delivery service
await notification_delivery_service.notify_autopay_success(
user=user,
amount_kopeks=charge_amount,
new_expires_at=subscription.end_date,
)
processed_count += 1
self._notified_users.add(autopay_key)
logger.info(
'💳 Автопродление подписки пользователя успешно (списано , скидка %)',
user_identifier=user_identifier,
charge_amount=charge_amount,
promo_discount_percent=promo_discount_percent,
)
else:
failed_count += 1
if await self._check_autopay_fail_cooldown(user.id, user_identifier):
if user.telegram_id and self.bot:
await self._send_autopay_failed_notification(
user, user.balance_kopeks, charge_amount, subscription=subscription
)
elif not user.telegram_id:
await notification_delivery_service.notify_autopay_failed(
user=user,
reason='Ошибка списания средств',
)
await self._set_autopay_fail_cooldown(user.id, user_identifier)
logger.warning(
'Не удалось отправить админ-уведомление об автопродлении', user_id=user.id, exc=exc
'💳 Ошибка списания средств для автопродления пользователя',
user_identifier=user_identifier,
)
# Send notification via appropriate channel
if user.telegram_id and self.bot:
await self._send_autopay_success_notification(
user, charge_amount, autopay_period, subscription=subscription
)
elif not user.telegram_id:
# Email-only user - use notification delivery service
await notification_delivery_service.notify_autopay_success(
user=user,
amount_kopeks=charge_amount,
new_expires_at=subscription.end_date,
)
processed_count += 1
self._notified_users.add(autopay_key)
logger.info(
'💳 Автопродление подписки пользователя успешно (списано , скидка %)',
user_identifier=user_identifier,
charge_amount=charge_amount,
promo_discount_percent=promo_discount_percent,
)
else:
failed_count += 1
if await self._check_autopay_fail_cooldown(user.id, user_identifier):
if user.telegram_id and self.bot:
await self._send_autopay_failed_notification(
@@ -1364,30 +1433,35 @@ class MonitoringService:
elif not user.telegram_id:
await notification_delivery_service.notify_autopay_failed(
user=user,
reason='Ошибка списания средств',
reason='Недостаточно средств на балансе',
)
await self._set_autopay_fail_cooldown(user.id, user_identifier)
logger.warning(
'💳 Ошибка списания средств для автопродления пользователя', user_identifier=user_identifier
'💳 Недостаточно средств для автопродления у пользователя',
user_identifier=user_identifier,
)
else:
except Exception as sub_error:
failed_count += 1
if await self._check_autopay_fail_cooldown(user.id, user_identifier):
if user.telegram_id and self.bot:
await self._send_autopay_failed_notification(
user, user.balance_kopeks, charge_amount, subscription=subscription
)
elif not user.telegram_id:
await notification_delivery_service.notify_autopay_failed(
user=user,
reason='Недостаточно средств на балансе',
)
await self._set_autopay_fail_cooldown(user.id, user_identifier)
logger.warning(
'💳 Недостаточно средств для автопродления у пользователя', user_identifier=user_identifier
# Используем локально захваченные id — subscription-объект
# может быть expired после чужого rollback'а.
logger.error(
'Ошибка автопродления отдельной подписки',
subscription_id=sub_id_local,
user_id=sub_user_id_local,
error=sub_error,
exc_info=True,
)
# Сессия могла остаться с aborted-транзакцией — откатываем,
# чтобы следующая итерация начала refetch на чистой сессии.
try:
await db.rollback()
except Exception as rollback_error:
logger.warning(
'Не удалось сделать rollback сессии после ошибки автопродления',
rollback_error=rollback_error,
)
continue
if processed_count > 0 or failed_count > 0:
await self._log_monitoring_event(
@@ -1398,7 +1472,7 @@ class MonitoringService:
)
except Exception as e:
logger.error('Ошибка обработки автоплатежей', error=e)
logger.error('Ошибка обработки автоплатежей', error=e, exc_info=True)
async def _send_subscription_expired_notification(
self, user: User, subscription: Subscription, *, tariff_name: str | None = None
+291
View File
@@ -0,0 +1,291 @@
"""Сервис для работы с API Overpay (pay.overpay.io)."""
import ssl
import tempfile
from typing import Any
import httpx
import structlog
from cryptography.hazmat.primitives.serialization import (
BestAvailableEncryption,
Encoding,
NoEncryption,
PrivateFormat,
pkcs12,
)
from app.config import settings
logger = structlog.get_logger(__name__)
class OverpayAPIError(Exception):
"""Ошибка API Overpay."""
def __init__(self, status_code: int, message: str):
self.status_code = status_code
self.message = message
super().__init__(f'Overpay API error ({status_code}): {message}')
class OverpayService:
"""Сервис для работы с API Overpay.
Overpay использует HTTP Basic Auth + mTLS (P12 сертификат).
"""
def __init__(self):
self._client: httpx.AsyncClient | None = None
self._ssl_context: ssl.SSLContext | None = None
self._temp_cert_file: str | None = None
self._temp_key_file: str | None = None
@property
def api_url(self) -> str:
return (settings.OVERPAY_API_URL or 'https://api.overpay.io').rstrip('/')
@property
def username(self) -> str:
return settings.OVERPAY_USERNAME or ''
@property
def password(self) -> str:
return settings.OVERPAY_PASSWORD or ''
@property
def project_id(self) -> str:
return settings.OVERPAY_PROJECT_ID or ''
def _build_ssl_context(self) -> ssl.SSLContext | None:
"""Создает SSL контекст с P12 сертификатом для mTLS."""
p12_path = settings.OVERPAY_P12_PATH
if not p12_path:
return None
if self._ssl_context is not None:
return self._ssl_context
try:
passphrase = settings.OVERPAY_P12_PASSPHRASE
passphrase_bytes = passphrase.encode('utf-8') if passphrase else None
with open(p12_path, 'rb') as f:
p12_data = f.read()
private_key, certificate, additional_certs = pkcs12.load_key_and_certificates(p12_data, passphrase_bytes)
# Write PEM files to temp files for ssl.SSLContext
cert_pem = certificate.public_bytes(Encoding.PEM)
if additional_certs:
for cert in additional_certs:
cert_pem += cert.public_bytes(Encoding.PEM)
if passphrase_bytes:
key_pem = private_key.private_bytes(
Encoding.PEM,
PrivateFormat.TraditionalOpenSSL,
BestAvailableEncryption(passphrase_bytes),
)
else:
key_pem = private_key.private_bytes(
Encoding.PEM,
PrivateFormat.TraditionalOpenSSL,
NoEncryption(),
)
# Write to temp files
with tempfile.NamedTemporaryFile(delete=False, suffix='.pem') as cert_file:
cert_file.write(cert_pem)
cert_file.flush()
self._temp_cert_file = cert_file.name
with tempfile.NamedTemporaryFile(delete=False, suffix='.pem') as key_file:
key_file.write(key_pem)
key_file.flush()
self._temp_key_file = key_file.name
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.load_cert_chain(
certfile=self._temp_cert_file,
keyfile=self._temp_key_file,
password=passphrase,
)
ctx.load_default_certs()
self._ssl_context = ctx
logger.info('Overpay: SSL контекст с P12 сертификатом создан')
return ctx
except Exception as e:
logger.exception('Overpay: ошибка загрузки P12 сертификата', error=e)
return None
async def _get_client(self) -> httpx.AsyncClient:
"""Возвращает переиспользуемый HTTP-клиент с mTLS."""
if self._client is not None and not self._client.is_closed:
return self._client
ssl_context = self._build_ssl_context()
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(30.0),
auth=httpx.BasicAuth(self.username, self.password),
verify=ssl_context if ssl_context else True,
)
return self._client
async def close(self) -> None:
"""Закрывает HTTP-клиент."""
if self._client and not self._client.is_closed:
await self._client.aclose()
self._client = None
# Clean up temp files
from pathlib import Path
for path in (self._temp_cert_file, self._temp_key_file):
if path:
try:
Path(path).unlink()
except OSError:
pass
self._temp_cert_file = None
self._temp_key_file = None
self._ssl_context = None
async def create_payment(
self,
*,
amount: str,
currency: str = 'RUB',
lifetime_minutes: int = 1440,
merchant_transaction_id: str,
description: str = '',
return_url: str | None = None,
payment_methods: list[str] | None = None,
) -> dict[str, Any]:
"""
Создает платеж через API Overpay.
POST {API_URL}/orders/
"""
payload: dict[str, Any] = {
'amount': amount,
'currency': currency,
'livetimeMinutes': lifetime_minutes,
'projectId': self.project_id,
'merchantTransactionId': merchant_transaction_id,
}
if description:
payload['description'] = description
if return_url:
payload['returnUrl'] = return_url
if payment_methods:
payload['paymentMethods'] = payment_methods
logger.info(
'Overpay API create_payment',
merchant_transaction_id=merchant_transaction_id,
amount=amount,
currency=currency,
)
try:
client = await self._get_client()
response = await client.post(
f'{self.api_url}/orders/',
json=payload,
headers={'Content-Type': 'application/json'},
)
data = response.json()
if response.status_code == 200 or response.status_code == 201:
logger.info(
'Overpay API payment created',
merchant_transaction_id=merchant_transaction_id,
overpay_id=data.get('id'),
result_url=data.get('resultUrl'),
)
return data
error_msg = data.get('message') or data.get('error') or str(data)
logger.error(
'Overpay create_payment error',
status_code=response.status_code,
error_msg=error_msg,
response_data=data,
)
raise OverpayAPIError(response.status_code, error_msg)
except httpx.HTTPError as e:
logger.exception('Overpay API connection error', error=e)
raise
async def get_payment(self, order_id: str) -> dict[str, Any]:
"""
Получает информацию о платеже по ID.
GET {API_URL}/orders/{id}
"""
logger.info('Overpay get_payment', order_id=order_id)
try:
client = await self._get_client()
response = await client.get(
f'{self.api_url}/orders/{order_id}',
headers={'Content-Type': 'application/json'},
)
data = response.json()
if response.status_code == 200:
return data
error_msg = data.get('message') or data.get('error') or str(data)
logger.error(
'Overpay get_payment error',
status_code=response.status_code,
error_msg=error_msg,
)
raise OverpayAPIError(response.status_code, error_msg)
except httpx.HTTPError as e:
logger.exception('Overpay API connection error', error=e)
raise
async def refund_payment(self, order_id: str, amount: str) -> dict[str, Any]:
"""
Возврат платежа.
PUT {API_URL}/orders/{id}/refund
"""
logger.info('Overpay refund_payment', order_id=order_id, amount=amount)
try:
client = await self._get_client()
response = await client.put(
f'{self.api_url}/orders/{order_id}/refund',
json={'amount': amount},
headers={'Content-Type': 'application/json'},
)
data = response.json()
if response.status_code == 200:
logger.info('Overpay refund successful', order_id=order_id, amount=amount)
return data
error_msg = data.get('message') or data.get('error') or str(data)
logger.error(
'Overpay refund error',
status_code=response.status_code,
error_msg=error_msg,
)
raise OverpayAPIError(response.status_code, error_msg)
except httpx.HTTPError as e:
logger.exception('Overpay API connection error', error=e)
raise
# Singleton instance
overpay_service = OverpayService()
+2
View File
@@ -12,6 +12,7 @@ from .freekassa import FreekassaPaymentMixin
from .heleket import HeleketPaymentMixin
from .kassa_ai import KassaAiPaymentMixin
from .mulenpay import MulenPayPaymentMixin
from .overpay import OverpayPaymentMixin
from .pal24 import Pal24PaymentMixin
from .paypear import PayPearPaymentMixin
from .platega import PlategaPaymentMixin
@@ -32,6 +33,7 @@ __all__ = [
'HeleketPaymentMixin',
'KassaAiPaymentMixin',
'MulenPayPaymentMixin',
'OverpayPaymentMixin',
'Pal24PaymentMixin',
'PayPearPaymentMixin',
'PaymentCommonMixin',
+567
View File
@@ -0,0 +1,567 @@
"""Mixin для интеграции с Overpay (pay.overpay.io)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from importlib import import_module
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.overpay_service import overpay_service
from app.utils.payment_logger import payment_logger as logger
from app.utils.user_utils import format_referrer_info
# Маппинг статусов Overpay -> internal
OVERPAY_STATUS_MAP: dict[str, tuple[str, bool]] = {
'charged': ('success', True),
'authorized': ('authorized', False),
'preflight': ('pending', False),
'new': ('pending', False),
'processing': ('processing', False),
'prepared': ('processing', False),
'rejected': ('rejected', False),
'declined': ('declined', False),
'reversed': ('reversed', False),
'refunded': ('refunded', False),
'chargeback': ('chargeback', False),
'error': ('error', False),
}
class OverpayPaymentMixin:
"""Mixin для работы с платежами Overpay."""
async def create_overpay_payment(
self,
db: AsyncSession,
*,
user_id: int | None,
amount_kopeks: int,
description: str = 'Пополнение баланса',
email: str | None = None,
language: str = 'ru',
return_url: str | None = None,
) -> dict[str, Any] | None:
"""
Создает платеж Overpay.
Returns:
Словарь с данными платежа или None при ошибке
"""
if not settings.is_overpay_enabled():
logger.error('Overpay не настроен')
return None
# Валидация лимитов
if amount_kopeks < settings.OVERPAY_MIN_AMOUNT_KOPEKS:
logger.warning(
'Overpay: сумма меньше минимальной',
amount_kopeks=amount_kopeks,
OVERPAY_MIN_AMOUNT_KOPEKS=settings.OVERPAY_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.OVERPAY_MAX_AMOUNT_KOPEKS:
logger.warning(
'Overpay: сумма больше максимальной',
amount_kopeks=amount_kopeks,
OVERPAY_MAX_AMOUNT_KOPEKS=settings.OVERPAY_MAX_AMOUNT_KOPEKS,
)
return None
# Получаем telegram_id пользователя для order_id
payment_module = import_module('app.services.payment_service')
if user_id is not None:
user = await payment_module.get_user_by_id(db, user_id)
tg_id = user.telegram_id if user else user_id
else:
user = None
tg_id = 'guest'
# Генерируем уникальный order_id с telegram_id для удобного поиска
order_id = f'op{tg_id}_{uuid.uuid4().hex[:6]}'
amount_rubles = amount_kopeks / 100
amount_value = f'{amount_rubles:.2f}'
currency = settings.OVERPAY_CURRENCY
# Метаданные
metadata = {
'user_id': user_id,
'amount_kopeks': amount_kopeks,
'description': description,
'language': language,
'type': 'balance_topup',
}
# Методы оплаты из настроек
payment_methods_str = settings.OVERPAY_PAYMENT_METHODS
payment_methods = (
[m.strip() for m in payment_methods_str.split(',') if m.strip()] if payment_methods_str else None
)
try:
# Используем API для создания платежа
result = await overpay_service.create_payment(
amount=amount_value,
currency=currency,
lifetime_minutes=settings.OVERPAY_LIFETIME_MINUTES,
merchant_transaction_id=order_id,
description=description,
return_url=return_url or settings.OVERPAY_RETURN_URL,
payment_methods=payment_methods,
)
payment_url = result.get('resultUrl')
overpay_payment_id = str(result.get('id', '')) if result.get('id') else None
if not payment_url:
logger.error('Overpay API не вернул URL платежа', result=result)
return None
logger.info(
'Overpay API: создан платеж',
order_id=order_id,
overpay_payment_id=overpay_payment_id,
payment_url=payment_url,
)
# Срок действия
expires_at = datetime.now(UTC) + timedelta(minutes=settings.OVERPAY_LIFETIME_MINUTES)
# Сохраняем в БД
overpay_crud = import_module('app.database.crud.overpay')
local_payment = await overpay_crud.create_overpay_payment(
db=db,
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
overpay_payment_id=overpay_payment_id,
expires_at=expires_at,
metadata_json=metadata,
)
logger.info(
'Overpay: создан платеж',
order_id=order_id,
user_id=user_id,
amount_rubles=amount_rubles,
currency=currency,
)
return {
'order_id': order_id,
'overpay_payment_id': overpay_payment_id,
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_rubles,
'currency': currency,
'payment_url': payment_url,
'expires_at': expires_at.isoformat(),
'local_payment_id': local_payment.id,
}
except Exception as e:
logger.exception('Overpay: ошибка создания платежа', error=e)
return None
async def process_overpay_webhook(
self,
db: AsyncSession,
payload: dict[str, Any],
) -> bool:
"""
Обрабатывает webhook от Overpay.
mTLS обеспечивает аутентификацию; дополнительно проверяем наличие платежа в БД.
Args:
db: Сессия БД
payload: JSON тело webhook
Returns:
True если платеж успешно обработан
"""
try:
overpay_payment_id = str(payload.get('id', '')) if payload.get('id') else None
merchant_transaction_id = payload.get('merchantTransactionId')
overpay_status = payload.get('status')
if not overpay_payment_id or not overpay_status:
logger.warning('Overpay webhook: отсутствуют обязательные поля', payload=payload)
return False
# Ищем платеж по order_id (наш merchantTransactionId) или overpay_payment_id
overpay_crud = import_module('app.database.crud.overpay')
payment = None
if merchant_transaction_id:
payment = await overpay_crud.get_overpay_payment_by_order_id(db, merchant_transaction_id)
if not payment and overpay_payment_id:
payment = await overpay_crud.get_overpay_payment_by_overpay_id(db, overpay_payment_id)
if not payment:
logger.warning(
'Overpay webhook: платеж не найден',
merchant_transaction_id=merchant_transaction_id,
overpay_payment_id=overpay_payment_id,
)
return False
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
locked = await overpay_crud.get_overpay_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Overpay: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
# Проверка дублирования (re-check from locked row)
if payment.is_paid:
logger.info('Overpay webhook: платеж уже обработан', order_id=payment.order_id)
return True
# Маппинг статуса
status_info = OVERPAY_STATUS_MAP.get(overpay_status, ('pending', False))
internal_status, is_paid = status_info
callback_payload = {
'overpay_payment_id': overpay_payment_id,
'merchant_transaction_id': merchant_transaction_id,
'status': overpay_status,
}
# Финализируем платеж если оплачен — без промежуточного commit
if is_paid:
# Inline field assignments to keep FOR UPDATE lock intact
payment.status = internal_status
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.overpay_payment_id = overpay_payment_id or payment.overpay_payment_id
payment.callback_payload = callback_payload
payment.updated_at = datetime.now(UTC)
await db.flush()
return await self._finalize_overpay_payment(
db, payment, overpay_payment_id=overpay_payment_id, trigger='webhook'
)
# Для не-success статусов можно безопасно коммитить
payment = await overpay_crud.update_overpay_payment_status(
db=db,
payment=payment,
status=internal_status,
is_paid=False,
overpay_payment_id=overpay_payment_id,
callback_payload=callback_payload,
)
return True
except Exception as e:
logger.exception('Overpay webhook: ошибка обработки', error=e)
return False
async def _finalize_overpay_payment(
self,
db: AsyncSession,
payment: Any,
*,
overpay_payment_id: str | None,
trigger: str,
) -> bool:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
FOR UPDATE lock must be acquired by the caller before invoking this method.
"""
payment_module = import_module('app.services.payment_service')
overpay_crud = import_module('app.database.crud.overpay')
# FOR UPDATE lock already acquired by caller — just check idempotency
if payment.transaction_id:
logger.info(
'Overpay платеж уже связан с транзакцией',
order_id=payment.order_id,
transaction_id=payment.transaction_id,
trigger=trigger,
)
return True
# Read fresh metadata AFTER lock to avoid stale data
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
# --- Guest purchase flow ---
from app.services.payment.common import try_fulfill_guest_purchase
guest_result = await try_fulfill_guest_purchase(
db,
metadata=metadata,
payment_amount_kopeks=payment.amount_kopeks,
provider_payment_id=str(overpay_payment_id) if overpay_payment_id else payment.order_id,
provider_name='overpay',
)
if guest_result is not None:
return True
# Ensure paid fields are set (idempotent — caller may have already set them)
if not payment.is_paid:
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.updated_at = datetime.now(UTC)
balance_already_credited = bool(metadata.get('balance_credited'))
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error('Пользователь не найден для Overpay', user_id=payment.user_id)
return False
# Загружаем промогруппы в асинхронном контексте
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
for user_promo_group in getattr(user, 'user_promo_groups', []):
await db.refresh(user_promo_group, attribute_names=['promo_group'])
promo_group = user.get_primary_promo_group()
subscription = getattr(user, 'subscription', None)
referrer_info = format_referrer_info(user)
transaction_external_id = str(overpay_payment_id) if overpay_payment_id else payment.order_id
# Проверяем дупликат транзакции
existing_transaction = None
if transaction_external_id:
existing_transaction = await payment_module.get_transaction_by_external_id(
db,
transaction_external_id,
PaymentMethod.OVERPAY,
)
display_name = settings.get_overpay_display_name()
description = f'Пополнение через {display_name}'
transaction = existing_transaction
created_transaction = False
if not transaction:
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=description,
payment_method=PaymentMethod.OVERPAY,
external_id=transaction_external_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
commit=False,
)
created_transaction = True
await overpay_crud.link_overpay_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
should_credit_balance = created_transaction or not balance_already_credited
if not should_credit_balance:
logger.info('Overpay платеж уже зачислил баланс ранее', order_id=payment.order_id)
return True
# Lock user row to prevent concurrent balance race conditions
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
# Emit deferred side-effects after atomic commit
from app.database.crud.transaction import emit_transaction_side_effects
await emit_transaction_side_effects(
db,
transaction,
amount_kopeks=payment.amount_kopeks,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
payment_method=PaymentMethod.OVERPAY,
external_id=transaction_external_id,
)
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
payment.amount_kopeks,
getattr(self, 'bot', None),
)
except Exception as error:
logger.error('Ошибка обработки реферального пополнения Overpay', error=error)
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
if getattr(self, 'bot', None):
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error('Ошибка отправки админ уведомления Overpay', error=error)
if getattr(self, 'bot', None) and user.telegram_id:
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
'\u2705 <b>Пополнение успешно!</b>\n\n'
f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
f'\U0001f4b3 Способ: {display_name}\n'
f'\U0001f194 Транзакция: {transaction.id}\n\n'
'Баланс пополнен автоматически!'
),
parse_mode='HTML',
reply_markup=keyboard,
)
except Exception as error:
logger.error('Ошибка отправки уведомления пользователю Overpay', error=error)
try:
from app.services.payment.common import send_cart_notification_after_topup
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
except Exception as error:
logger.error(
'Ошибка при работе с сохраненной корзиной для пользователя',
user_id=payment.user_id,
error=error,
exc_info=True,
)
metadata['balance_change'] = {
'old_balance': old_balance,
'new_balance': user.balance_kopeks,
'credited_at': datetime.now(UTC).isoformat(),
}
metadata['balance_credited'] = True
payment.metadata_json = metadata
await db.commit()
logger.info(
'Обработан Overpay платеж',
order_id=payment.order_id,
user_id=payment.user_id,
trigger=trigger,
)
return True
async def check_overpay_payment_status(
self,
db: AsyncSession,
order_id: str,
) -> dict[str, Any] | None:
"""Проверяет статус платежа через API."""
try:
overpay_crud = import_module('app.database.crud.overpay')
payment = await overpay_crud.get_overpay_payment_by_order_id(db, order_id)
if not payment:
logger.warning('Overpay payment not found', order_id=order_id)
return None
if payment.is_paid:
return {
'payment': payment,
'status': 'success',
'is_paid': True,
}
# Проверяем через API по overpay_payment_id
if payment.overpay_payment_id:
try:
order_data = await overpay_service.get_payment(payment.overpay_payment_id)
overpay_status = order_data.get('status')
if overpay_status:
status_info = OVERPAY_STATUS_MAP.get(overpay_status, ('pending', False))
internal_status, is_paid = status_info
if is_paid:
# Acquire FOR UPDATE lock before finalization
locked = await overpay_crud.get_overpay_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Overpay: не удалось заблокировать платёж', payment_id=payment.id)
return None
payment = locked
if payment.is_paid:
logger.info('Overpay платеж уже обработан (api_check)', order_id=payment.order_id)
return {
'payment': payment,
'status': 'success',
'is_paid': True,
}
logger.info('Overpay payment confirmed via API', order_id=payment.order_id)
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.callback_payload = {
'check_source': 'api',
'overpay_order_data': order_data,
}
payment.updated_at = datetime.now(UTC)
await db.flush()
await self._finalize_overpay_payment(
db,
payment,
overpay_payment_id=payment.overpay_payment_id,
trigger='api_check',
)
elif internal_status != payment.status:
# Обновляем статус если изменился
payment = await overpay_crud.update_overpay_payment_status(
db=db,
payment=payment,
status=internal_status,
)
except Exception as e:
logger.error('Error checking Overpay payment status via API', error=e)
return {
'payment': payment,
'status': payment.status or 'pending',
'is_paid': payment.is_paid,
}
except Exception as e:
logger.exception('Overpay: ошибка проверки статуса', error=e)
return None
+28 -7
View File
@@ -106,18 +106,30 @@ class PayPearPaymentMixin:
)
confirmation = result.get('confirmation', {})
payment_url = confirmation.get('url') if isinstance(confirmation, dict) else None
payment_url = (
(confirmation.get('confirmation_url') or confirmation.get('url'))
if isinstance(confirmation, dict)
else None
)
paypear_id = result.get('id')
if not payment_url:
logger.error('PayPear API не вернул URL платежа', result=result)
logger.error('PayPear API не вернул confirmation_url', result=result)
return None
# PayPear может добавить комиссию к сумме — сохраняем фактическую сумму
# для корректной проверки в webhook (amount включает комиссию)
api_amount = result.get('amount', {})
if isinstance(api_amount, dict) and api_amount.get('value') is not None:
charged_kopeks = round(float(api_amount['value']) * 100)
metadata['paypear_charged_kopeks'] = charged_kopeks
logger.info(
'PayPear API: создан платеж',
order_id=order_id,
paypear_id=paypear_id,
payment_url=payment_url,
charged_kopeks=metadata.get('paypear_charged_kopeks'),
)
# Срок действия — 30 минут по умолчанию
@@ -239,6 +251,8 @@ class PayPearPaymentMixin:
}
# Проверка суммы ДО обновления статуса
# PayPear добавляет комиссию к amount — сравниваем с сохранённой суммой
# (paypear_charged_kopeks), а не с исходной суммой пополнения
if is_paid:
amount_info = obj.get('amount', {})
if isinstance(amount_info, dict):
@@ -248,11 +262,14 @@ class PayPearPaymentMixin:
if amount_value is not None:
received_kopeks = round(float(amount_value) * 100)
if abs(received_kopeks - payment.amount_kopeks) > 1:
payment_metadata = dict(getattr(payment, 'metadata_json', {}) or {})
expected_kopeks = payment_metadata.get('paypear_charged_kopeks', payment.amount_kopeks)
if abs(received_kopeks - expected_kopeks) > 1:
logger.error(
'PayPear amount mismatch',
expected_kopeks=payment.amount_kopeks,
expected_kopeks=expected_kopeks,
received_kopeks=received_kopeks,
original_amount_kopeks=payment.amount_kopeks,
order_id=payment.order_id,
)
await paypear_crud.update_paypear_payment_status(
@@ -539,16 +556,20 @@ class PayPearPaymentMixin:
internal_status, is_paid = status_info
if is_paid:
# Проверка суммы
# Проверка суммы — сравниваем с paypear_charged_kopeks
# (amount включает комиссию PayPear)
amount_info = order_data.get('amount', {})
api_amount = amount_info.get('value') if isinstance(amount_info, dict) else amount_info
if api_amount is not None:
received_kopeks = round(float(api_amount) * 100)
if abs(received_kopeks - payment.amount_kopeks) > 1:
payment_metadata = dict(getattr(payment, 'metadata_json', {}) or {})
expected_kopeks = payment_metadata.get('paypear_charged_kopeks', payment.amount_kopeks)
if abs(received_kopeks - expected_kopeks) > 1:
logger.error(
'PayPear amount mismatch (API check)',
expected_kopeks=payment.amount_kopeks,
expected_kopeks=expected_kopeks,
received_kopeks=received_kopeks,
original_amount_kopeks=payment.amount_kopeks,
order_id=payment.order_id,
)
await paypear_crud.update_paypear_payment_status(
@@ -169,6 +169,16 @@ def _get_method_defaults() -> dict:
{'id': 'crypto', 'name': 'Криптовалюта'},
],
},
'overpay': {
'default_display_name': settings.get_overpay_display_name(),
'is_configured': settings.is_overpay_enabled(),
'default_min': settings.OVERPAY_MIN_AMOUNT_KOPEKS,
'default_max': settings.OVERPAY_MAX_AMOUNT_KOPEKS,
'available_sub_options': [
{'id': 'card', 'name': 'Карта'},
{'id': 'fps', 'name': 'СБП'},
],
},
'aurapay': {
'default_display_name': settings.get_aurapay_display_name(),
'is_configured': settings.is_aurapay_enabled(),
@@ -223,6 +233,7 @@ DEFAULT_METHOD_ORDER = [
'severpay',
'paypear',
'rollypay',
'overpay',
'aurapay',
]
+35
View File
@@ -21,6 +21,7 @@ from app.database.models import (
HeleketPayment,
KassaAiPayment,
MulenPayPayment,
OverpayPayment,
Pal24Payment,
PaymentMethod,
PlategaPayment,
@@ -649,6 +650,39 @@ async def _search_severpay(db: AsyncSession, params: SearchParams) -> list[Pendi
return records
async def _search_overpay(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = select(OverpayPayment).options(selectinload(OverpayPayment.user)).order_by(desc(OverpayPayment.created_at))
stmt = _apply_date_filter(stmt, OverpayPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
conditions = [
OverpayPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
OverpayPayment.overpay_payment_id.ilike(f'%{_escape_like(params.search)}%'),
]
stmt = stmt.where(or_(*conditions))
else:
stmt = _apply_user_join_filter(stmt, OverpayPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
record = _build_record(
PaymentMethod.OVERPAY,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _search_stars(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = (
select(Transaction)
@@ -702,6 +736,7 @@ _PROVIDER_SEARCH_MAP: dict[PaymentMethod, Any] = {
PaymentMethod.KASSA_AI: _search_kassa_ai,
PaymentMethod.RIOPAY: _search_riopay,
PaymentMethod.SEVERPAY: _search_severpay,
PaymentMethod.OVERPAY: _search_overpay,
PaymentMethod.TELEGRAM_STARS: _search_stars,
}
+62
View File
@@ -34,6 +34,7 @@ from app.services.payment.aurapay import AuraPayPaymentMixin
from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin
from app.services.payment.freekassa import FreekassaPaymentMixin
from app.services.payment.kassa_ai import KassaAiPaymentMixin
from app.services.payment.overpay import OverpayPaymentMixin
from app.services.payment.paypear import PayPearPaymentMixin
from app.services.payment.riopay import RioPayPaymentMixin
from app.services.payment.rollypay import RollyPayPaymentMixin
@@ -408,6 +409,44 @@ async def link_rollypay_payment_to_transaction(*args, **kwargs):
return await rollypay_crud.link_rollypay_payment_to_transaction(*args, **kwargs)
# --- Overpay CRUD wrappers ---
async def create_overpay_payment(*args, **kwargs):
overpay_crud = import_module('app.database.crud.overpay')
return await overpay_crud.create_overpay_payment(*args, **kwargs)
async def get_overpay_payment_by_order_id(*args, **kwargs):
overpay_crud = import_module('app.database.crud.overpay')
return await overpay_crud.get_overpay_payment_by_order_id(*args, **kwargs)
async def get_overpay_payment_by_overpay_id(*args, **kwargs):
overpay_crud = import_module('app.database.crud.overpay')
return await overpay_crud.get_overpay_payment_by_overpay_id(*args, **kwargs)
async def get_overpay_payment_by_id(*args, **kwargs):
overpay_crud = import_module('app.database.crud.overpay')
return await overpay_crud.get_overpay_payment_by_id(*args, **kwargs)
async def get_overpay_payment_by_id_for_update(*args, **kwargs):
overpay_crud = import_module('app.database.crud.overpay')
return await overpay_crud.get_overpay_payment_by_id_for_update(*args, **kwargs)
async def update_overpay_payment_status(*args, **kwargs):
overpay_crud = import_module('app.database.crud.overpay')
return await overpay_crud.update_overpay_payment_status(*args, **kwargs)
async def link_overpay_payment_to_transaction(*args, **kwargs):
overpay_crud = import_module('app.database.crud.overpay')
return await overpay_crud.link_overpay_payment_to_transaction(*args, **kwargs)
async def create_aurapay_payment(*args, **kwargs):
aurapay_crud = import_module('app.database.crud.aurapay')
return await aurapay_crud.create_aurapay_payment(*args, **kwargs)
@@ -468,6 +507,7 @@ class PaymentService(
SeverPayPaymentMixin,
PayPearPaymentMixin,
RollyPayPaymentMixin,
OverpayPaymentMixin,
AuraPayPaymentMixin,
):
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
@@ -932,6 +972,28 @@ class PaymentService(
}
return None
# --- Overpay ----------------------------------------------------------
if payment_method == 'overpay':
if not settings.is_overpay_enabled():
logger.warning('Overpay is not enabled, cannot create guest payment')
return None
result = await self.create_overpay_payment(
db=db,
user_id=None,
amount_kopeks=amount_kopeks,
description=description,
return_url=return_url,
)
if result:
await _patch_guest_metadata(result['local_payment_id'], 'overpay')
return {
'payment_url': result.get('payment_url'),
'payment_id': result.get('overpay_payment_id') or result.get('order_id'),
'provider': 'overpay',
}
return None
# --- AuraPay ----------------------------------------------------------
if payment_method == 'aurapay':
if not settings.is_aurapay_enabled():
@@ -76,6 +76,7 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
PaymentMethod.KASSA_AI,
PaymentMethod.RIOPAY,
PaymentMethod.SEVERPAY,
PaymentMethod.OVERPAY,
}
)
@@ -96,6 +97,7 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
PaymentMethod.KASSA_AI,
PaymentMethod.RIOPAY,
PaymentMethod.SEVERPAY,
PaymentMethod.OVERPAY,
}
)
@@ -125,6 +127,8 @@ def method_display_name(method: PaymentMethod) -> str:
return settings.get_riopay_display_name()
if method == PaymentMethod.SEVERPAY:
return settings.get_severpay_display_name()
if method == PaymentMethod.OVERPAY:
return settings.get_overpay_display_name()
if method == PaymentMethod.TELEGRAM_STARS:
return 'Telegram Stars'
return method.value
@@ -155,6 +159,8 @@ def _method_is_enabled(method: PaymentMethod) -> bool:
return settings.is_riopay_enabled()
if method == PaymentMethod.SEVERPAY:
return settings.is_severpay_enabled()
if method == PaymentMethod.OVERPAY:
return settings.is_overpay_enabled()
return False
+8 -4
View File
@@ -202,16 +202,20 @@ class PricingEngine:
# ------------------------------------------------------------------
@staticmethod
def get_tariff_daily_rate_fraction(tariff: Tariff, target_days: int) -> tuple[int, int]:
def get_tariff_daily_rate_fraction(tariff: Tariff) -> tuple[int, int]:
"""Дневная ставка тарифа как (price, period_days) для целочисленных вычислений.
Возвращает числитель и знаменатель дроби price/period_days,
чтобы избежать float-ошибок в финансовых расчётах.
Всегда использует кратчайший доступный период тарифа, чтобы
гарантировать корректное сравнение дневных ставок при смене
тарифов с разными наборами периодов.
"""
periods = tariff.get_available_periods()
if not periods:
return 0, 1
best_period = min(periods, key=lambda p: abs(p - target_days))
best_period = min(periods)
price = tariff.get_price_for_period(best_period)
if not price or best_period <= 0:
return 0, 1
@@ -270,8 +274,8 @@ class PricingEngine:
# raw_cost = (new_p/new_d - cur_p/cur_d) * remaining
# = (new_p * cur_d - cur_p * new_d) * remaining / (new_d * cur_d)
# Floor division (//) округляет дробные копейки вниз — в пользу пользователя.
cur_price, cur_period = self.get_tariff_daily_rate_fraction(current_tariff, remaining_days)
new_price, new_period = self.get_tariff_daily_rate_fraction(new_tariff, remaining_days)
cur_price, cur_period = self.get_tariff_daily_rate_fraction(current_tariff)
new_price, new_period = self.get_tariff_daily_rate_fraction(new_tariff)
numerator = (new_price * cur_period - cur_price * new_period) * remaining_days
denominator = new_period * cur_period
+3 -2
View File
@@ -378,11 +378,12 @@ class PromoCodeService:
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
tariff_id_for_trial = trial_tariff.id
if trial_tariff.allowed_squads:
trial_squads = trial_tariff.allowed_squads
trial_squads = await get_effective_tariff_squad_uuids(db, trial_tariff.allowed_squads)
except Exception as e:
logger.error('Ошибка получения тарифа для триального промокода', error=e)
+2 -4
View File
@@ -977,10 +977,8 @@ class RemnaWaveWebhookService:
if subscription.subscription_crypto_link != subscription_crypto_link:
subscription.subscription_crypto_link = subscription_crypto_link
changed = True
elif subscription_url and subscription.subscription_crypto_link:
# URL обновился, а крипто-ссылка не пришла — сбрасываем старую
subscription.subscription_crypto_link = None
changed = True
# NOTE: панель не включает cryptoLink в каждый webhook user.modified
# Отсутствие поля не означает что его нужно сбрасывать
# Always stamp to protect from sync overwrite, even if no fields changed
self._stamp_webhook_update(subscription)
+92
View File
@@ -0,0 +1,92 @@
"""S2S Postback Service — sends server-to-server postbacks on events."""
import structlog
from app.config import settings
logger = structlog.get_logger(__name__)
try:
import httpx
except ImportError:
httpx = None
def _is_enabled() -> bool:
return getattr(settings, 'S2S_POSTBACK_ENABLED', False) and httpx is not None
def _get_url(event: str) -> str | None:
"""Get postback URL template for event type."""
mapping = {
'registration': getattr(settings, 'S2S_POSTBACK_REGISTRATION_URL', ''),
'trial': getattr(settings, 'S2S_POSTBACK_TRIAL_URL', ''),
'purchase': getattr(settings, 'S2S_POSTBACK_PURCHASE_URL', ''),
}
url = mapping.get(event, '')
return url or None
async def send_postback(
event: str,
subid: str,
amount: float | None = None,
user_id: int | None = None,
) -> bool:
"""Send S2S postback for an event.
Args:
event: 'registration', 'trial', or 'purchase'
subid: tracking subid from URL
amount: purchase amount in rubles (for purchase event)
user_id: internal user ID for logging
Returns:
True if sent successfully
"""
if not _is_enabled():
return False
if not subid:
return False
url_template = _get_url(event)
if not url_template:
logger.debug('S2S postback URL not configured', event=event)
return False
# Replace placeholders (URL-encode subid to prevent injection)
from urllib.parse import quote
url = url_template.replace('{subid}', quote(subid, safe=''))
url = url.replace('{event}', event)
if amount is not None:
url = url.replace('{amount}', str(round(amount, 2)))
else:
url = url.replace('{amount}', '0')
url = url.replace('{user_id}', str(user_id) if user_id is not None else '0')
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(url)
logger.info(
'S2S postback sent',
event=event,
subid=subid,
amount=amount,
user_id=user_id,
status_code=response.status_code,
url=url[:100],
)
return response.status_code < 400
except Exception as e:
logger.error(
'S2S postback failed',
event=event,
subid=subid,
error=str(e),
url=url[:100],
)
return False
@@ -488,6 +488,7 @@ class SubscriptionRenewalService:
)
reset_traffic = was_expired and settings.RESET_TRAFFIC_ON_PAYMENT
reset_devices = settings.RESET_DEVICES_ON_RENEWAL
subscription_service = SubscriptionService()
try:
await db.refresh(user)
@@ -526,6 +527,28 @@ class SubscriptionRenewalService:
action='create' if not getattr(subscription_after, 'remnawave_uuid', None) else 'update',
)
# Сброс привязанных устройств при продлении (если включено)
if reset_devices:
try:
from app.services.remnawave_service import RemnaWaveService
rw_service = RemnaWaveService()
_uuid = (
getattr(subscription_after, 'remnawave_uuid', None)
if settings.is_multi_tariff_enabled()
else getattr(user, 'remnawave_uuid', None)
)
if _uuid:
async with rw_service.get_api_client() as api:
await api.reset_user_devices(_uuid)
logger.info(
'Devices reset on renewal',
subscription_id=subscription_after.id,
user_id=user.id,
)
except Exception as error:
logger.warning('Failed to reset devices on renewal', error=error, exc_info=True)
transaction: Transaction | None = None
try:
transaction = await create_transaction(
+3
View File
@@ -93,6 +93,7 @@ class BotConfigurationService:
'SEVERPAY': '💳 SeverPay',
'PAYPEAR': '💳 PayPear',
'ROLLYPAY': '💳 RollyPay',
'OVERPAY': '💳 Overpay',
'AURAPAY': '💳 AuraPay',
'YOOKASSA': '🟣 YooKassa',
'PLATEGA': '💳 {platega_name}',
@@ -157,6 +158,7 @@ class BotConfigurationService:
'RIOPAY': 'RioPay: платёжная система api.riopay.online с поддержкой карт и СБП.',
'PAYPEAR': 'PayPear: платёжная система api.paypear.ru с поддержкой карт, СБП, SberPay и T-Pay.',
'ROLLYPAY': 'RollyPay: платёжный шлюз rollypay.io с СБП, картами и криптовалютой.',
'OVERPAY': 'Overpay: платёжный шлюз pay.overpay.io с mTLS и поддержкой карт и СБП.',
'AURAPAY': 'AuraPay: платёжный шлюз aurapay.tech с поддержкой карт и СБП.',
'PLATEGA': '{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.',
'MULENPAY': 'Платежи {mulenpay_name} и параметры магазина.',
@@ -371,6 +373,7 @@ class BotConfigurationService:
'SEVERPAY_': 'SEVERPAY',
'PAYPEAR_': 'PAYPEAR',
'ROLLYPAY_': 'ROLLYPAY',
'OVERPAY_': 'OVERPAY',
'AURAPAY_': 'AURAPAY',
'PLATEGA_': 'PLATEGA',
'MULENPAY_': 'MULENPAY',
+352
View File
@@ -0,0 +1,352 @@
"""Yandex.Metrika offline conversions service.
Sends events (registration, trial-add, purchase) to mc.yandex.ru/collect
using the Measurement Protocol. No pageview needed user has active
Metrika session from the site. yclid is passed via landing page URL,
Metrika matches it automatically.
"""
from __future__ import annotations
import asyncio
import re
import time
import httpx
import structlog
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.yandex_client_id import (
get_cid,
mark_registration_sent,
mark_trial_sent,
upsert_cid,
)
from app.database.database import AsyncSessionLocal
logger = structlog.get_logger(__name__)
COLLECT_URL = 'https://mc.yandex.ru/collect'
TIMEOUT = 10.0
MAX_RETRIES = 3
RETRY_DELAY = 1.0
_CID_RE = re.compile(r'^[A-Za-z0-9._:-]{4,128}$')
_http_client: httpx.AsyncClient | None = None
def _get_client() -> httpx.AsyncClient:
global _http_client
if _http_client is None or _http_client.is_closed:
_http_client = httpx.AsyncClient(timeout=TIMEOUT)
return _http_client
def _is_enabled() -> bool:
return bool(
settings.YANDEX_OFFLINE_CONV_ENABLED
and settings.YANDEX_OFFLINE_CONV_COUNTER_ID
and settings.YANDEX_OFFLINE_CONV_MEASUREMENT_SECRET
)
def _normalize_cid(cid: str | None) -> str | None:
if not isinstance(cid, str):
return None
cid = cid.strip()
if not cid or not _CID_RE.match(cid):
return None
return cid
def _mask_cid(cid: str) -> str:
if len(cid) <= 4:
return '****'
return '*' * (len(cid) - 4) + cid[-4:]
def _base_payload(cid: str) -> dict[str, str]:
return {
'tid': settings.YANDEX_OFFLINE_CONV_COUNTER_ID,
'cid': cid,
'ms': settings.YANDEX_OFFLINE_CONV_MEASUREMENT_SECRET,
}
def _pageview_payload(cid: str) -> dict[str, str]:
payload = _base_payload(cid)
payload.update(
{
't': 'pageview',
'dl': settings.YANDEX_OFFLINE_CONV_DL or 'https://web.mtrxvps.ru',
'dt': settings.YANDEX_OFFLINE_CONV_DT or 'Matrixxx VPN',
}
)
return payload
def _event_payload(cid: str, event_action: str) -> dict[str, str]:
payload = _base_payload(cid)
payload.update(
{
't': 'event',
'ea': event_action,
}
)
return payload
def _ecommerce_purchase_payload(
cid: str,
amount_rubles: float,
order_id: str = '',
product_name: str = '',
product_category: str = '',
) -> dict[str, str]:
"""Build ecommerce:purchase payload for Metrika Measurement Protocol."""
service_name = (
getattr(settings, 'YANDEX_OFFLINE_CONV_DT', '')
or getattr(settings, 'PAYMENT_SERVICE_NAME', '')
or 'Subscription'
)
currency = getattr(settings, 'YANDEX_OFFLINE_CONV_CURRENCY', '') or 'RUB'
payload = _base_payload(cid)
payload.update(
{
't': 'event',
'ea': 'purchase',
'pa': 'purchase',
'ti': order_id or str(int(time.time())),
'tr': str(amount_rubles),
'cu': currency,
'ev': str(amount_rubles),
'pr1id': 'subscription',
'pr1nm': product_name or service_name,
'pr1ca': product_category or 'subscription',
'pr1pr': str(amount_rubles),
'pr1qt': '1',
}
)
return payload
async def _post_collect(payload: dict[str, str], kind: str, cid: str) -> bool:
"""POST to mc.yandex.ru/collect with retries. Returns True on success."""
masked = _mask_cid(cid)
for attempt in range(1, MAX_RETRIES + 1):
try:
client = _get_client()
resp = await client.post(COLLECT_URL, data=payload)
if 200 <= resp.status_code < 300:
logger.info('collect sent', kind=kind, cid=masked, status=resp.status_code)
return True
if 500 <= resp.status_code < 600 and attempt < MAX_RETRIES:
logger.warning(
'collect server error',
kind=kind,
attempt=attempt,
max=MAX_RETRIES,
cid=masked,
status=resp.status_code,
)
await asyncio.sleep(RETRY_DELAY)
continue
logger.error('collect rejected', kind=kind, cid=masked, status=resp.status_code, body=resp.text[:200])
return False
except Exception as exc:
logger.warning(
'collect request error', kind=kind, attempt=attempt, max=MAX_RETRIES, cid=masked, error=str(exc)
)
if attempt < MAX_RETRIES:
await asyncio.sleep(RETRY_DELAY)
continue
return False
return False
async def _send_event(cid: str, event_action: str) -> bool:
"""Send event directly — no pageview needed, user has active Metrika session."""
return await _post_collect(_event_payload(cid, event_action), event_action, cid)
# --- Background task helpers ---
_background_tasks: set[asyncio.Task] = set()
def _task_done(task):
"""Log errors from background conversion tasks."""
_background_tasks.discard(task)
if task.cancelled():
return
exc = task.exception()
if exc:
logger.error('YandexOfflineConv background task failed', error=str(exc))
def spawn_bg(coro) -> None:
"""Spawn a background Yandex conversion task with proper reference tracking.
Checks _is_enabled() early so callers don't need to.
"""
if not _is_enabled():
# Close the coroutine to avoid RuntimeWarning
coro.close()
return
task = asyncio.create_task(coro)
_background_tasks.add(task)
task.add_done_callback(_task_done)
async def _fire_bg(event_name: str, event_fn, user_id: int, **kwargs) -> None:
"""Generic background wrapper: opens a session, calls event_fn, logs errors."""
try:
async with AsyncSessionLocal() as db:
await event_fn(db, user_id, **kwargs)
except Exception as exc:
logger.warning('YandexOfflineConv background event failed', event=event_name, user_id=user_id, error=str(exc))
async def fire_registration_bg(user_id: int) -> None:
"""Fire registration event in background with its own DB session."""
await _fire_bg('registration', on_registration, user_id)
async def fire_trial_bg(user_id: int) -> None:
"""Fire trial event in background with its own DB session."""
await _fire_bg('trial', on_trial, user_id)
async def fire_purchase_bg(user_id: int, amount_kopeks: int) -> None:
"""Fire purchase event in background with its own DB session."""
await _fire_bg('purchase', on_purchase, user_id, amount_kopeks=amount_kopeks)
# --- Public API ---
async def store_cid(
db: AsyncSession,
user_id: int,
cid: str | None,
source: str = 'web',
) -> bool:
"""Store Yandex ClientID for a user. Returns True if stored."""
normalized = _normalize_cid(cid)
if not normalized:
return False
try:
await upsert_cid(db, user_id, normalized, source=source, counter_id=settings.YANDEX_OFFLINE_CONV_COUNTER_ID)
logger.info('stored CID', user_id=user_id, source=source)
return True
except Exception as exc:
logger.error('failed to store CID', user_id=user_id, error=str(exc))
return False
async def store_cid_and_fire_registration(
user_id: int,
cid: str | None,
*,
source: str = 'web',
) -> None:
"""Store Yandex CID and fire registration conversion in background (best-effort).
Opens its own DB session so it never interferes with the caller's transaction.
"""
if not cid:
return
try:
async with AsyncSessionLocal() as db:
stored = await store_cid(db, user_id, cid, source=source)
if stored:
await db.commit()
spawn_bg(fire_registration_bg(user_id))
except Exception as exc:
logger.warning('Failed to store CID and fire registration', user_id=user_id, error=str(exc))
async def on_registration(db: AsyncSession, user_id: int) -> None:
"""Fire registration event (once per user)."""
if not _is_enabled():
return
try:
row = await get_cid(db, user_id)
if not row or row.registration_sent:
return
if not row.yandex_cid or row.yandex_cid.startswith('_'):
return # placeholder row — real CID not yet received
success = await _send_event(row.yandex_cid, 'registration')
if success:
await mark_registration_sent(db, user_id)
await db.commit()
logger.info('registration event sent', user_id=user_id)
except Exception as exc:
logger.error('registration event failed', user_id=user_id, error=str(exc))
async def on_trial(db: AsyncSession, user_id: int) -> None:
"""Fire trial-add event (once per user)."""
if not _is_enabled():
return
try:
row = await get_cid(db, user_id)
if not row or row.trial_sent:
return
if not row.yandex_cid or row.yandex_cid.startswith('_'):
return # placeholder row — real CID not yet received
success = await _send_event(row.yandex_cid, 'trial-add')
if success:
await mark_trial_sent(db, user_id)
await db.commit()
logger.info('trial-add event sent', user_id=user_id)
except Exception as exc:
logger.error('trial-add event failed', user_id=user_id, error=str(exc))
async def on_purchase(db: AsyncSession, user_id: int, amount_kopeks: int) -> None:
"""Fire ecommerce purchase event (every payment)."""
if not _is_enabled():
return
try:
row = await get_cid(db, user_id)
if not row:
return
if not row.yandex_cid or row.yandex_cid.startswith('_'):
return # placeholder row — real CID not yet received
amount_rubles = amount_kopeks / 100
payload = _ecommerce_purchase_payload(row.yandex_cid, amount_rubles)
success = await _post_collect(payload, 'purchase', row.yandex_cid)
if success:
logger.info('purchase event sent', user_id=user_id, amount=amount_rubles)
except Exception as exc:
logger.error('purchase event failed', user_id=user_id, error=str(exc))
def parse_cid_from_start_param(param: str) -> tuple[str | None, str]:
"""Extract Yandex CID from bot start parameter.
If param starts with the configured prefix (e.g. 'utm_ya_'),
returns (cid, original_param). Otherwise returns (None, original_param).
Original param is always preserved for UTM tracking.
"""
prefix = settings.YANDEX_OFFLINE_CONV_START_PREFIX
if not prefix or not param.startswith(prefix):
return None, param
cid = param[len(prefix) :]
normalized = _normalize_cid(cid)
return normalized, param # Keep original param for UTM tracking
-4
View File
@@ -143,8 +143,6 @@ async def update_menu_layout(
btn_dict = btn.model_dump()
# Автоматически определяем наличие плейсхолдеров, если dynamic_text не установлен
if not btn_dict.get('dynamic_text', False):
from app.services.menu_layout.service import MenuLayoutService
btn_dict['dynamic_text'] = MenuLayoutService._text_has_placeholders(btn_dict.get('text', {}))
buttons_config[btn_id] = btn_dict
config['buttons'] = buttons_config
@@ -306,8 +304,6 @@ async def add_custom_button(
# Автоматически определяем наличие плейсхолдеров, если dynamic_text не установлен
dynamic_text = payload.dynamic_text
if not dynamic_text:
from app.services.menu_layout.service import MenuLayoutService
dynamic_text = MenuLayoutService._text_has_placeholders(payload.text)
button_config = {
+3 -1
View File
@@ -3834,9 +3834,11 @@ async def activate_subscription_trial_endpoint(
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:
+7 -7
View File
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.crud.server_squad import get_random_trial_squad_uuid
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
from app.database.crud.subscription import (
add_subscription_devices,
add_subscription_squad,
@@ -75,16 +75,16 @@ async def _choose_trial_squads(
return fallback_squads
try:
squad_uuid = await get_random_trial_squad_uuid(db)
default_squads = await get_effective_tariff_squad_uuids(db, None)
except Exception as error:
logger.error('Failed to select trial squad', error=error)
squad_uuid = None
logger.error('Failed to resolve default trial squads', error=error)
default_squads = []
if not squad_uuid:
if not default_squads:
return []
logger.debug('Selected trial squad for subscription replacement', squad_uuid=squad_uuid)
return [squad_uuid]
logger.debug('Selected default trial squads for subscription replacement', squad_uuids=default_squads)
return default_squads
async def _get_subscription(db: AsyncSession, subscription_id: int) -> Subscription:
+4 -3
View File
@@ -54,6 +54,7 @@ class ButtonConditions(BaseModel):
show_trial: bool | None = Field(default=None, description='Показать пробный период')
show_buy: bool | None = Field(default=None, description='Показать кнопку покупки')
has_saved_cart: bool | None = Field(default=None, description='Есть сохраненная корзина')
traffic_topup_enabled: bool | None = Field(default=None, description='Докупка трафика включена')
# Расширенные условия
min_balance_kopeks: int | None = Field(default=None, ge=0, description='Минимальный баланс в копейках')
@@ -84,7 +85,7 @@ class MenuButtonConfig(BaseModel):
type: ButtonType = Field(..., description='Тип кнопки')
builtin_id: str | None = Field(default=None, description='ID встроенной кнопки (для type=builtin)')
text: dict[str, str] = Field(..., description='Локализованные тексты кнопки: {lang_code: text}')
icon: str | None = Field(default=None, max_length=10, description='Эмодзи/иконка кнопки (отдельно от текста)')
icon: str | None = Field(default=None, max_length=100, description='Эмодзи/иконка кнопки (отдельно от текста)')
action: str = Field(..., description='callback_data или URL в зависимости от типа')
enabled: bool = Field(default=True, description='Кнопка активна')
visibility: ButtonVisibility = Field(default=ButtonVisibility.ALL, description='Видимость кнопки')
@@ -172,7 +173,7 @@ class ButtonUpdateRequest(BaseModel):
"""Запрос на обновление отдельной кнопки."""
text: dict[str, str] | None = Field(default=None, description='Новые локализованные тексты')
icon: str | None = Field(default=None, max_length=10, description='Эмодзи/иконка кнопки')
icon: str | None = Field(default=None, max_length=100, description='Эмодзи/иконка кнопки')
enabled: bool | None = Field(default=None, description='Включить/выключить')
visibility: ButtonVisibility | None = Field(default=None, description='Новая видимость')
conditions: ButtonConditions | None = Field(default=None, description='Новые условия показа')
@@ -212,7 +213,7 @@ class AddCustomButtonRequest(BaseModel):
id: str = Field(..., min_length=1, max_length=50, description='ID кнопки (уникальный)')
type: ButtonType = Field(..., description='Тип кнопки (url, mini_app или callback)')
text: dict[str, str] = Field(..., description='Локализованные тексты')
icon: str | None = Field(default=None, max_length=10, description='Эмодзи/иконка кнопки')
icon: str | None = Field(default=None, max_length=100, description='Эмодзи/иконка кнопки')
action: str = Field(..., min_length=1, description='URL или callback_data')
visibility: ButtonVisibility = Field(default=ButtonVisibility.ALL, description='Видимость')
conditions: ButtonConditions | None = Field(default=None, description='Условия показа')
+7
View File
@@ -5,6 +5,12 @@ from datetime import datetime
from pydantic import BaseModel, Field
class TicketMediaItemResponse(BaseModel):
type: str
file_id: str
caption: str | None = None
class TicketMessageResponse(BaseModel):
id: int
user_id: int
@@ -14,6 +20,7 @@ class TicketMessageResponse(BaseModel):
media_type: str | None = None
media_file_id: str | None = None
media_caption: str | None = None
media_items: list[TicketMediaItemResponse] | None = None
created_at: datetime
+76
View File
@@ -1340,6 +1340,81 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
routes_registered = True
# Overpay webhook
if settings.is_overpay_enabled():
@router.get(settings.OVERPAY_WEBHOOK_PATH)
async def overpay_health() -> JSONResponse:
return JSONResponse(
{
'status': 'ok',
'service': 'overpay_webhook',
'enabled': settings.is_overpay_enabled(),
}
)
@router.post(settings.OVERPAY_WEBHOOK_PATH)
async def overpay_webhook(request: Request) -> JSONResponse:
try:
raw_body = await request.body()
payload = json.loads(raw_body)
except Exception as parse_error:
logger.error('Overpay webhook: failed to parse JSON', parse_error=parse_error)
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
# Overpay uses mTLS for authentication — verify payment exists in DB
merchant_transaction_id = payload.get('merchantTransactionId')
if not merchant_transaction_id:
logger.warning('Overpay webhook: missing merchantTransactionId')
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
# Validate that the payment exists in our DB (basic anti-spoofing)
from app.database.crud.overpay import get_overpay_payment_by_order_id
db_generator = get_db()
try:
check_db = await db_generator.__anext__()
except StopAsyncIteration:
return JSONResponse({'status': False}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
try:
existing = await get_overpay_payment_by_order_id(check_db, merchant_transaction_id)
if not existing:
overpay_id = payload.get('id')
if overpay_id:
from app.database.crud.overpay import get_overpay_payment_by_overpay_id
existing = await get_overpay_payment_by_overpay_id(check_db, str(overpay_id))
if not existing:
logger.warning(
'Overpay webhook: payment not found in DB',
merchant_transaction_id=merchant_transaction_id,
)
return JSONResponse({'status': False}, status_code=status.HTTP_404_NOT_FOUND)
finally:
try:
await db_generator.__anext__()
except StopAsyncIteration:
pass
try:
success = await _process_payment_service_callback(
payment_service,
payload,
'process_overpay_webhook',
)
if not success:
logger.error(
'Overpay webhook processing failed',
data=payload.get('id'),
)
except Exception as e:
logger.exception('Overpay webhook processing error', error=e)
# Always return 200 — Overpay expects HTTP 200
return JSONResponse({'status': True}, status_code=status.HTTP_200_OK)
routes_registered = True
# AuraPay webhook
if settings.is_aurapay_enabled():
@@ -1411,6 +1486,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
'severpay_enabled': settings.is_severpay_enabled(),
'paypear_enabled': settings.is_paypear_enabled(),
'rollypay_enabled': settings.is_rollypay_enabled(),
'overpay_enabled': settings.is_overpay_enabled(),
'aurapay_enabled': settings.is_aurapay_enabled(),
}
)
+1
View File
@@ -607,6 +607,7 @@ async def main():
secret_token=settings.WEBHOOK_SECRET_TOKEN,
drop_pending_updates=False, # Обрабатываем накопившиеся обновления
allowed_updates=allowed_updates,
**({'ip_address': settings.WEBHOOK_IP} if settings.WEBHOOK_IP else {}),
)
stage.log(f'Webhook установлен: {webhook_url}')
stage.log(f'Allowed updates: {", ".join(sorted(allowed_updates)) if allowed_updates else "all"}')
@@ -0,0 +1,48 @@
"""add ticket_messages.media_items for multi-media bubbles
Revision ID: 0061
Revises: 0060
Create Date: 2026-04-22
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = '0061'
down_revision: Union[str, None] = '0060'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
result = conn.execute(
sa.text(
'SELECT EXISTS (SELECT 1 FROM information_schema.columns '
"WHERE table_name = 'ticket_messages' AND column_name = 'media_items')"
)
)
if not result.scalar():
op.add_column(
'ticket_messages',
sa.Column(
'media_items',
postgresql.JSONB(astext_type=sa.Text()),
nullable=True,
),
)
def downgrade() -> None:
conn = op.get_bind()
result = conn.execute(
sa.text(
'SELECT EXISTS (SELECT 1 FROM information_schema.columns '
"WHERE table_name = 'ticket_messages' AND column_name = 'media_items')"
)
)
if result.scalar():
op.drop_column('ticket_messages', 'media_items')
@@ -0,0 +1,62 @@
"""add landing_pages analytics columns + sticky_pay_button
Revision ID: 0062
Revises: 0061
Create Date: 2026-04-22
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0062'
down_revision: Union[str, None] = '0061'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
NEW_COLUMNS = (
(
'sticky_pay_button',
sa.Column('sticky_pay_button', sa.Boolean(), nullable=False, server_default=sa.text('false')),
),
(
'analytics_view_enabled',
sa.Column('analytics_view_enabled', sa.Boolean(), nullable=False, server_default=sa.text('false')),
),
('analytics_view_goal', sa.Column('analytics_view_goal', sa.String(64), nullable=True)),
(
'analytics_click_enabled',
sa.Column('analytics_click_enabled', sa.Boolean(), nullable=False, server_default=sa.text('false')),
),
('analytics_click_goal', sa.Column('analytics_click_goal', sa.String(64), nullable=True)),
)
def upgrade() -> None:
conn = op.get_bind()
for col_name, col_def in NEW_COLUMNS:
result = conn.execute(
sa.text(
'SELECT EXISTS (SELECT 1 FROM information_schema.columns '
"WHERE table_name = 'landing_pages' AND column_name = :col)"
),
{'col': col_name},
)
if not result.scalar():
op.add_column('landing_pages', col_def)
def downgrade() -> None:
conn = op.get_bind()
for col_name, _ in reversed(NEW_COLUMNS):
result = conn.execute(
sa.text(
'SELECT EXISTS (SELECT 1 FROM information_schema.columns '
"WHERE table_name = 'landing_pages' AND column_name = :col)"
),
{'col': col_name},
)
if result.scalar():
op.drop_column('landing_pages', col_name)
@@ -0,0 +1,99 @@
"""add yandex_client_id_map table + guest_purchases offline conv columns
Revision ID: 0063
Revises: 0062
Create Date: 2026-04-21
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0063'
down_revision: Union[str, None] = '0062'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
# 1) yandex_client_id_map — created idempotently
result = conn.execute(
sa.text("SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'yandex_client_id_map')")
)
if not result.scalar():
op.create_table(
'yandex_client_id_map',
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
sa.Column(
'user_id',
sa.Integer,
sa.ForeignKey('users.id', ondelete='CASCADE'),
unique=True,
nullable=False,
),
sa.Column('yandex_cid', sa.String(128), nullable=False),
sa.Column('source', sa.String(20), nullable=False, server_default='web'),
sa.Column('counter_id', sa.String(32), nullable=True),
sa.Column(
'registration_sent',
sa.Boolean,
nullable=False,
server_default=sa.text('false'),
),
sa.Column(
'trial_sent',
sa.Boolean,
nullable=False,
server_default=sa.text('false'),
),
sa.Column('subid', sa.String(255), nullable=True),
sa.Column(
'created_at',
sa.DateTime(timezone=True),
server_default=sa.func.now(),
),
sa.Column(
'updated_at',
sa.DateTime(timezone=True),
server_default=sa.func.now(),
),
)
# 2) guest_purchases — add yandex_cid / subid / referrer (idempotent)
for col_name, col_def in (
('yandex_cid', sa.Column('yandex_cid', sa.String(128), nullable=True)),
('subid', sa.Column('subid', sa.String(255), nullable=True)),
('referrer', sa.Column('referrer', sa.String(500), nullable=True)),
):
result = conn.execute(
sa.text(
'SELECT EXISTS (SELECT 1 FROM information_schema.columns '
"WHERE table_name = 'guest_purchases' AND column_name = :col)"
),
{'col': col_name},
)
if not result.scalar():
op.add_column('guest_purchases', col_def)
def downgrade() -> None:
conn = op.get_bind()
for col_name in ('referrer', 'subid', 'yandex_cid'):
result = conn.execute(
sa.text(
'SELECT EXISTS (SELECT 1 FROM information_schema.columns '
"WHERE table_name = 'guest_purchases' AND column_name = :col)"
),
{'col': col_name},
)
if result.scalar():
op.drop_column('guest_purchases', col_name)
result = conn.execute(
sa.text("SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'yandex_client_id_map')")
)
if result.scalar():
op.drop_table('yandex_client_id_map')
@@ -0,0 +1,45 @@
"""create overpay_payments table
Revision ID: 0064
Revises: 0063
Create Date: 2026-04-21
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0064'
down_revision: Union[str, None] = '0063'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'overpay_payments',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True),
sa.Column('order_id', sa.String(64), unique=True, nullable=False, index=True),
sa.Column('overpay_payment_id', sa.String(128), unique=True, nullable=True, index=True),
sa.Column('amount_kopeks', sa.Integer(), nullable=False),
sa.Column('currency', sa.String(10), nullable=False, server_default='RUB'),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('status', sa.String(32), nullable=False, server_default='pending'),
sa.Column('is_paid', sa.Boolean(), server_default=sa.text('false'), nullable=False),
sa.Column('payment_url', sa.Text(), nullable=True),
sa.Column('payment_method', sa.String(32), nullable=True),
sa.Column('metadata_json', sa.JSON(), nullable=True),
sa.Column('callback_payload', sa.JSON(), nullable=True),
sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('transaction_id', sa.Integer(), sa.ForeignKey('transactions.id'), nullable=True),
)
def downgrade() -> None:
op.drop_table('overpay_payments')
@@ -0,0 +1,47 @@
"""create info_pages table
Revision ID: 0065
Revises: 0064
Create Date: 2026-04-21
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0065'
down_revision: Union[str, None] = '0064'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
if not _table_exists('info_pages'):
op.create_table(
'info_pages',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('slug', sa.String(200), unique=True, nullable=False),
sa.Column('title', sa.dialects.postgresql.JSONB(), nullable=False, server_default='{}'),
sa.Column('content', sa.dialects.postgresql.JSONB(), nullable=False, server_default='{}'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'),
sa.Column('icon', sa.String(50), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table('info_pages')
def _table_exists(table_name: str) -> bool:
"""Check if a table already exists (idempotent migration guard)."""
bind = op.get_bind()
result = bind.execute(
sa.text('SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = :name)'),
{'name': table_name},
)
return result.scalar()
@@ -0,0 +1,28 @@
"""add page_type to info_pages
Revision ID: 0066
Revises: 0065
Create Date: 2026-04-21
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0066'
down_revision: Union[str, None] = '0065'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'info_pages',
sa.Column('page_type', sa.String(20), nullable=False, server_default='page'),
)
def downgrade() -> None:
op.drop_column('info_pages', 'page_type')
@@ -0,0 +1,28 @@
"""add replaces_tab to info_pages
Revision ID: 0067
Revises: 0066
Create Date: 2026-04-21
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0067'
down_revision: Union[str, None] = '0066'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'info_pages',
sa.Column('replaces_tab', sa.String(20), nullable=True),
)
def downgrade() -> None:
op.drop_column('info_pages', 'replaces_tab')
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = 'remnawave-bedolaga-telegram-bot'
version = "3.49.0"
version = "3.52.0"
description = 'Telegram bot for RemnaWave VPN service'
readme = 'README.md'
license = { text = 'MIT' }
+42
View File
@@ -0,0 +1,42 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from app.database.crud.subscription import create_trial_subscription
async def test_create_trial_subscription_uses_all_available_squads_by_default(monkeypatch):
db = Mock()
db.add = Mock()
db.commit = AsyncMock()
db.refresh = AsyncMock()
monkeypatch.setattr('app.database.crud.subscription.get_subscription_by_user_id', AsyncMock(return_value=None))
monkeypatch.setattr('app.database.crud.subscription.generate_unique_short_id', AsyncMock(return_value='abc123'))
monkeypatch.setattr(
'app.database.crud.server_squad.get_available_server_squads',
AsyncMock(
return_value=[
SimpleNamespace(squad_uuid='fi-uuid'),
SimpleNamespace(squad_uuid='ru-uuid'),
]
),
)
get_server_ids_mock = AsyncMock(return_value=[11, 12])
add_user_to_servers_mock = AsyncMock()
monkeypatch.setattr('app.database.crud.server_squad.get_server_ids_by_uuids', get_server_ids_mock)
monkeypatch.setattr('app.database.crud.server_squad.add_user_to_servers', add_user_to_servers_mock)
subscription = await create_trial_subscription(
db,
user_id=1,
duration_days=14,
traffic_limit_gb=100,
device_limit=5,
)
assert subscription.connected_squads == ['fi-uuid', 'ru-uuid']
db.add.assert_called_once_with(subscription)
db.commit.assert_awaited_once()
db.refresh.assert_awaited_once_with(subscription)
get_server_ids_mock.assert_awaited_once_with(db, ['fi-uuid', 'ru-uuid'])
add_user_to_servers_mock.assert_awaited_once_with(db, [11, 12])
+89
View File
@@ -2,8 +2,10 @@
Tests for PromoCodeService - focus on promo group integration
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock
from app.database.models import PromoCodeType
from app.services.promocode_service import PromoCodeService
@@ -382,3 +384,90 @@ async def test_promocode_data_includes_promo_group_id(
assert 'promocode' in result
assert 'promo_group_id' in result['promocode']
assert result['promocode']['promo_group_id'] == sample_promo_group.id
async def test_activate_trial_promocode_uses_all_available_squads_when_tariff_has_no_restrictions(
monkeypatch,
):
sample_user = SimpleNamespace(
id=1,
telegram_id=123456789,
username='testuser',
full_name='Test User',
balance_kopeks=0,
language='ru',
has_had_paid_subscription=False,
total_spent_kopeks=0,
)
mock_db_session = AsyncMock()
mock_db_session.commit = AsyncMock()
mock_db_session.rollback = AsyncMock()
mock_db_session.refresh = AsyncMock()
mock_db_session.delete = AsyncMock()
promocode = SimpleNamespace(
id=10,
code='KRTN14',
type=PromoCodeType.TRIAL_SUBSCRIPTION.value,
balance_bonus_kopeks=0,
subscription_days=14,
tariff_id=7,
promo_group_id=None,
promo_group=None,
first_purchase_only=False,
max_uses=20,
current_uses=0,
is_active=True,
is_valid=True,
valid_until=None,
)
trial_tariff = SimpleNamespace(
id=7,
name='Trial',
traffic_limit_gb=100,
device_limit=5,
allowed_squads=[],
trial_duration_days=14,
)
created_subscription = SimpleNamespace(id=99)
monkeypatch.setattr('app.services.promocode_service.RemnaWaveService', lambda: SimpleNamespace())
create_remnawave_user_mock = AsyncMock()
monkeypatch.setattr(
'app.services.promocode_service.SubscriptionService',
lambda: SimpleNamespace(create_remnawave_user=create_remnawave_user_mock),
)
monkeypatch.setattr('app.services.promocode_service.get_user_by_id', AsyncMock(return_value=sample_user))
monkeypatch.setattr('app.services.promocode_service.get_promocode_by_code', AsyncMock(return_value=promocode))
monkeypatch.setattr('app.services.promocode_service.check_user_promocode_usage', AsyncMock(return_value=False))
monkeypatch.setattr('app.database.crud.promocode.count_user_recent_activations', AsyncMock(return_value=0))
monkeypatch.setattr('app.services.promocode_service.get_subscription_by_user_id', AsyncMock(return_value=None))
monkeypatch.setattr('app.services.promocode_service.create_promocode_use', AsyncMock(return_value=object()))
monkeypatch.setattr('app.database.crud.tariff.get_tariff_by_id', AsyncMock(return_value=trial_tariff))
monkeypatch.setattr('app.database.crud.tariff.get_trial_tariff', AsyncMock(return_value=None))
monkeypatch.setattr(
'app.database.crud.server_squad.get_available_server_squads',
AsyncMock(
return_value=[
SimpleNamespace(squad_uuid='fi-uuid'),
SimpleNamespace(squad_uuid='ru-uuid'),
]
),
)
create_trial_subscription_mock = AsyncMock(return_value=created_subscription)
monkeypatch.setattr('app.database.crud.subscription.create_trial_subscription', create_trial_subscription_mock)
service = PromoCodeService()
result = await service.activate_promocode(mock_db_session, sample_user.id, promocode.code)
assert result['success'] is True
create_trial_subscription_mock.assert_awaited_once_with(
mock_db_session,
sample_user.id,
duration_days=14,
traffic_limit_gb=100,
device_limit=5,
connected_squads=['fi-uuid', 'ru-uuid'],
tariff_id=7,
)
create_remnawave_user_mock.assert_awaited_once_with(mock_db_session, created_subscription)
Generated
+1 -1
View File
@@ -1142,7 +1142,7 @@ wheels = [
[[package]]
name = "remnawave-bedolaga-telegram-bot"
version = "3.46.1"
version = "3.49.0"
source = { virtual = "." }
dependencies = [
{ name = "aiogram" },