Compare commits

...

40 Commits

Author SHA1 Message Date
Egor bcc35d6e22 Merge pull request #2706 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.28.0
2026-03-09 23:41:57 +03:00
github-actions[bot] b850e81897 chore(main): release 3.28.0 2026-03-09 20:41:35 +00:00
Egor 4d9e42c3f1 Merge pull request #2705 from BEDOLAGA-DEV/dev
Dev
2026-03-09 23:41:08 +03:00
Egor 834a0478ae Merge pull request #2704 from BEDOLAGA-DEV/main
w
2026-03-09 23:39:56 +03:00
Fringg 0e968987fb style: format guest_purchase_service.py with ruff 2026-03-09 23:39:24 +03:00
Fringg acd2cff9ca style: format inline.py with ruff 2026-03-09 23:38:34 +03:00
Fringg 69dbd6a2df fix: enforce HTTPS for webapp mode, deduplicate keyboard builder, fix long line 2026-03-09 23:37:35 +03:00
Fringg 497a8ee5b5 feat: add open_in setting for custom buttons (external browser / webapp) 2026-03-09 23:33:18 +03:00
Fringg dd8d7f6920 feat: add cabinet menu layout editor with row arrangement, custom URL buttons, and drag-and-drop reordering
- Add menu_layout_cache.py for CABINET_MENU_LAYOUT in-process cache
- Add admin_menu_layout.py routes (GET/PUT/POST reset) with merged view
- Rewrite _build_cabinet_main_menu_keyboard to use cached row layout
- Support custom URL buttons with style, emoji, labels, enabled toggle
- Atomic dual-key DB writes for layout + button styles
- Add language button to default layout and DEFAULT_BUTTON_STYLES
- Pydantic validation with Literal types, max_length, duplicate ID checks
- Register routes and cache loading in bot startup
2026-03-09 23:07:32 +03:00
Fringg b9089e693f fix: normalize threshold 0→NULL in create_promo_group for consistency 2026-03-09 22:16:30 +03:00
Fringg b815abf2b1 fix: loyalty tiers current status based on spending, not assigned group
- current_tier_name and is_current now determined by highest achieved
  tier threshold instead of user's assigned promo group
- Backend update_promo_group converts threshold 0 to NULL for clean state
2026-03-09 22:08:41 +03:00
Fringg 95a32e8574 fix: payment gateway issues — YooKassa polling, PAL24 card 500
- YooKassa: return local_payment_id instead of UUID for frontend polling
  (parseInt on UUID produced wrong ID → eternal spinner)
- PAL24: remove unsupported payment_method param from API call
  (cabinet and miniapp routes — URL selection is client-side)
2026-03-09 21:53:53 +03:00
Fringg cd04f3b622 feat: implement gateway payment for gifts, persist recipient warning
- Replace 501 stub with full gateway payment flow via PaymentService
- Move telegram username pre-check (DB-first) above gateway/balance branch
- Add recipient_warning column to GuestPurchase model + migration 0034
- Return warning in gift purchase status endpoint
- Add db.refresh(purchase) after commit in gateway branch
2026-03-09 21:25:49 +03:00
Fringg 6a4140e3e2 fix: harden gift subscription feature after multi-agent review
- Add self-gift prevention (telegram username + email)
- Unify 404 response on purchase status (eliminate token oracle)
- Add period_days upper bound (le=3650) in schema
- Handle NULL paid_at in retry query with or_()
- Capture purchase_token before fulfill_purchase (session safety)
- Upgrade Bot API pre-check logging to warning level
- Add exc_info=True for monitoring retry errors
- Add database indexes: (user_id, is_gift, status), (status, paid_at), buyer_user_id
- Use datetime instead of str for created_at in PendingGiftResponse
- Align GuestPurchase model __table_args__ with all migrations
2026-03-09 20:34:39 +03:00
Fringg f80b058380 fix: negate GIFT_PAYMENT amounts and remove dead code 2026-03-09 18:47:36 +03:00
Fringg 6a61b09575 feat: add cabinet gift subscription API routes and schemas
Create Pydantic schemas for gift config/purchase/status responses,
FastAPI routes for GET /gift/config, POST /gift/purchase, and
GET /gift/purchase/{token}, update GuestPurchaseService.create_purchase
to accept optional source and buyer_user_id params with nullable landing,
and register the gift router in the cabinet routes.
2026-03-09 18:44:38 +03:00
Fringg 759bfe1bdb feat: add CABINET_GIFT_ENABLED branding toggle 2026-03-09 18:41:07 +03:00
Fringg 0936d4a7f6 feat: add source and buyer_user_id fields to GuestPurchase model
- Add source column (landing/cabinet) to track purchase origin
- Add buyer_user_id FK to link cabinet gift purchases to authenticated users
- Add GIFT_PAYMENT to TransactionType enum for balance deductions
- Add foreign_keys disambiguation to existing user relationship
- Migration 0032: adds columns, index on source, FK constraint
2026-03-09 18:35:52 +03:00
Fringg 680c22c017 fix: support Telegram OIDC id_token in account linking endpoint
Email users couldn't link Telegram when OIDC was enabled because
the link_telegram endpoint only accepted init_data and Login Widget
data. Add id_token field to LinkTelegramRequest with JWKS validation,
replay protection, and rate limiting.
2026-03-09 06:23:02 +03:00
Egor 8c9efd5127 Merge pull request #2703 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.27.0
2026-03-09 05:08:42 +03:00
github-actions[bot] 4663097a24 chore(main): release 3.27.0 2026-03-09 02:07:55 +00:00
Egor dc51a55c98 Merge pull request #2702 from BEDOLAGA-DEV/dev
Dev
2026-03-09 05:07:31 +03:00
Fringg 275f249bbd fix: encode payment status in provider return URLs and wire failed_url
- Add &status=success/failed to cabinet return URLs for instant UX feedback
  without needing API auth in external browser
- Platega: pass cabinet_failed_url (was hardcoded to server URL)
- Heleket: add success_url param, pass cabinet_success_url for url_success
- WATA: add failed_url param, pass cabinet_failed_url to failRedirectUrl
- CloudPayments: add failed_url param, pass cabinet_failed_url
- Strip trailing slash from CABINET_URL for safety
2026-03-09 04:59:56 +03:00
Fringg 7a9264b173 fix: latest-payment endpoint returns all payments, not just pending
The /latest endpoint was using list_recent_pending_payments which only
returns unpaid payments. By the time the user returns from the payment
provider, the webhook has already marked the payment as paid, so the
endpoint returned 404. Now queries the payment table directly without
filtering by is_paid status.
2026-03-09 04:41:25 +03:00
Fringg 32d58b04b9 fix: add method query param to return_url and latest-payment endpoint
Payment providers redirect to external browser where sessionStorage is
unavailable. Now includes method in return_url query params and adds
GET /pending-payments/{method}/latest endpoint so TopUpResult can poll
payment status without sessionStorage data.
2026-03-09 04:34:14 +03:00
Fringg 7ca96195a7 fix: pass cabinet return_url to payment providers for top-up redirects
Payment providers were redirecting users back to the bot after completing
cabinet top-up payments. Now passes CABINET_URL/balance/top-up/result as
return_url to YooKassa, Platega, Heleket, WATA, and CloudPayments.
2026-03-09 04:16:10 +03:00
Fringg 5752b5e7c6 chore: apply ruff formatting to 4 files 2026-03-09 03:02:32 +03:00
Egor e6f577697b Merge pull request #2701 from BEDOLAGA-DEV/main
w
2026-03-09 03:01:02 +03:00
Fringg f4a776319e fix: add table existence guards to migrations for optional payment tables
Migrations 0019, 0022, 0031 crashed with UndefinedTableError when
payment provider tables (e.g. kassa_ai_payments) or contest_templates
did not exist. Added _table_exists() checks before ALTER/DROP operations.
2026-03-09 02:57:01 +03:00
Fringg 2649e12f64 fix: use parsed HTML length for Telegram caption limit checks
Replace hardcoded raw HTML length checks (len(text) <= 900/1000/1024)
with centralized caption_exceeds_telegram_limit() that strips HTML tags
and unescapes entities before measuring against the real 1024-char limit.
Fixes logo disappearing when promo discounts add HTML markup to captions.
2026-03-09 02:43:43 +03:00
Fringg 4a5cacda38 fix: resolve concurrent AsyncSession bug and sanitize error responses
- Fix critical concurrency issue in propagate_tariff_squads: preload
  users/tariffs before asyncio.gather, use single API client, no DB
  operations inside gather, single commit after all API calls
- Replace all str(e) leaks in admin_users.py with sanitized messages
- Fix double callback.answer by using callback.message.answer for
  failure alerts
- Move PropagateSquadsResult to module level, use field(default_factory)
- Compute traffic_strategy once before gather instead of N times
- Add warning logging on tariff refresh failures
- Reset synced counters on commit failure for accurate reporting
2026-03-09 02:26:38 +03:00
Fringg 79161eaae4 refactor: move squad propagation to service layer with parallel Remnawave sync
- Move _propagate_squads_to_subscriptions from handler to
  SubscriptionService.propagate_tariff_squads()
- Use asyncio.gather with semaphore (concurrency=5) for parallel
  Remnawave API calls instead of sequential O(N)
- Track failed subscription IDs for better observability
- Fix get_all_server_squads limit=50 default in admin handlers
  (now limit=10000 to prevent silent truncation)
- Add docstring to force_panel_delete parameter
- Return PropagateSquadsResult dataclass with total/synced/failed_ids
2026-03-09 01:58:23 +03:00
Fringg 289cbe966e fix: conditional log messages and sanitize panel_error in user deletion
- Log disable success/failure separately instead of unconditional success
- Sanitize panel_error to not leak internal exception details to API
- Make fallback disable log conditional on actual result
2026-03-09 01:52:37 +03:00
Fringg 7ccfb66690 fix: propagate tariff squad changes to existing subscriptions and fix user deletion from Remnawave
Squad toggle: when admin changes servers for a tariff, the changes now
propagate to all active/trial subscriptions and sync to Remnawave panel.
Previously only took effect on new purchases.

User deletion: full delete from Cabinet now actually deletes from Remnawave
panel. Previously lied about panel deletion status and skipped deletion
for users with active subscriptions.
2026-03-09 01:46:45 +03:00
Fringg 536525c9c0 fix: admin tariff server selection - 64-byte overflow and callback routing conflicts
1. Shortened squad toggle callback_data from admin_tariff_toggle_squad
   to trf_sq to stay within Telegram's 64-byte callback_data limit
   (was overflowing at tariff_id >= 10)

2. Fixed toggle_tariff handler capturing squad/promo/daily/traffic_topup
   toggle callbacks by adding exclusion filters

3. Fixed admin_tariff_edit_traffic capturing admin_tariff_edit_traffic_topup
   by registering traffic_topup handler before traffic handler

4. Fixed admin_tariff_delete capturing admin_tariff_delete_confirm
   by registering delete_confirm handler before delete handler
2026-03-09 01:23:03 +03:00
Fringg 4186159a61 fix: keep DB session alive in Tribute payment notification handler
The _send_success_notification method was closing the DB session (via
break) before calling send_cart_notification_after_topup, causing all
post-topup auto-renewal logic to silently fail for Tribute payments.

Moved break after all work is done so the session stays open during
auto-renewal operations. Added None guard for user lookup.
2026-03-09 01:07:47 +03:00
Fringg 6349b2f442 fix: align tariff pricing with calculate_renewal_price reference
- balance/main.py: single period discount on combined total (base + devices),
  add promo-offer discount, fix device_limit fallback to tariff_device_limit
- pricing.py: same combined discount + promo-offer, proper device_limit
  fallback matching reference (is not None check)
- admin/users.py: delegate to calculate_renewal_price() which handles both
  tariff and classic modes correctly, removing classic-only calculate_subscription_price
- menu.py: use renewal_service.calculate_pricing() for both price check and
  charge to ensure consistency, add try/except with user-facing error,
  show actual charged amount in success message
2026-03-09 00:39:16 +03:00
Fringg bfbefeb1e2 fix: renewal cost estimate double-counts servers and traffic in tariff mode
In tariff mode, period_prices already includes servers and traffic costs.
But show_payment_methods() and get_subscription_cost() were using the classic
additive formula, adding server and traffic prices on top of the tariff price.

Example: 49₽ tariff + 150₽ server + 150₽ traffic = 349₽ shown, should be 49₽.

Now both functions detect tariff mode and only add extra device costs beyond
the tariff's device_limit. Classic mode formula unchanged.
2026-03-08 23:14:57 +03:00
Fringg f9f07f360c fix: enforce tariff device_price and max_device_limit across all purchase paths
The miniapp, legacy cabinet endpoint, auto-purchase service, and Telegram bot
handlers were using only global settings (PRICE_PER_DEVICE, MAX_DEVICES_LIMIT)
for device purchases, completely ignoring tariff-level device_price_kopeks and
max_device_limit. This allowed users to buy devices when tariff price was 0
(should be blocked) and exceed the tariff's max device limit.

Fixed in all 4 code paths:
- miniapp _build_subscription_settings + update_subscription_devices_endpoint
- cabinet legacy POST /devices (+ added subscription status check, RemnaWave sync)
- subscription_auto_purchase_service._auto_add_devices
- telegram bot handlers confirm_change_devices, execute_change_devices, confirm_add_devices
2026-03-08 23:08:32 +03:00
Fringg 770b31d3d0 feat: auto-resume disabled daily subscriptions on balance topup
- Add try_resume_disabled_daily_after_topup() for instant resume when balance is topped up
- Fix all 5 resume paths to charge daily fee BEFORE activating subscription
- Remove unsafe inline auto-resume from add_user_balance() that bypassed fee charging
- Add NULL-safe is_daily_paused filter in subscription queries
- Use create_remnawave_user() instead of enable_remnawave_user() for full VPN panel sync
- Add DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP localization key (ru, en, fa, zh, ua)
2026-03-08 21:36:17 +03:00
58 changed files with 3410 additions and 475 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.26.0"
".": "3.28.0"
}
+53
View File
@@ -1,5 +1,58 @@
# Changelog
## [3.28.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.27.0...v3.28.0) (2026-03-09)
### New Features
* add cabinet gift subscription API routes and schemas ([6a61b09](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6a61b095755885ff8973eb9ac4422740d07e0306))
* add cabinet menu layout editor with row arrangement, custom URL buttons, and drag-and-drop reordering ([dd8d7f6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dd8d7f69203490553d15dcdad6dda28fab02d593))
* add CABINET_GIFT_ENABLED branding toggle ([759bfe1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/759bfe1bdb3a3d3f917334fd32d0ea2f5be5d1f0))
* add open_in setting for custom buttons (external browser / webapp) ([497a8ee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/497a8ee5b528cf80d7042a7eec62369b6a327339))
* add source and buyer_user_id fields to GuestPurchase model ([0936d4a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0936d4a7f651a1fcef8c2f86818320af3764b423))
* implement gateway payment for gifts, persist recipient warning ([cd04f3b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cd04f3b622444f45e2edf4a92da581f3d1f79b67))
### Bug Fixes
* enforce HTTPS for webapp mode, deduplicate keyboard builder, fix long line ([69dbd6a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/69dbd6a2df4cf5e0dd7156ca0f3beb53c4a061af))
* harden gift subscription feature after multi-agent review ([6a4140e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6a4140e3e203beb20cc56aa9c65dfed70f0a12d7))
* loyalty tiers current status based on spending, not assigned group ([b815abf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b815abf2b11e32eb658f9a8a63ae902bc0db46f4))
* negate GIFT_PAYMENT amounts and remove dead code ([f80b058](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f80b0583804f27c322a4eb27f0613163ca1f97e9))
* normalize threshold 0→NULL in create_promo_group for consistency ([b9089e6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b9089e693f823e3b8618d08329ccba559592dfa3))
* payment gateway issues — YooKassa polling, PAL24 card 500 ([95a32e8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/95a32e8574320eeba9276e44551a2f1207ae1e8b))
* support Telegram OIDC id_token in account linking endpoint ([680c22c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/680c22c0179253d24f7f89e115a283dac92f9a49))
## [3.27.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.26.0...v3.27.0) (2026-03-09)
### New Features
* auto-resume disabled daily subscriptions on balance topup ([770b31d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/770b31d3d05c22411b64ddbea3c304e34d879f5b))
### Bug Fixes
* add method query param to return_url and latest-payment endpoint ([32d58b0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/32d58b04b9a37473f43ae07cc32d4e18b161e3b9))
* add table existence guards to migrations for optional payment tables ([f4a7763](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f4a776319eaccbce108a1f22462da6cd592fe0f3))
* admin tariff server selection - 64-byte overflow and callback routing conflicts ([536525c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/536525c9c0a7701321bc3b83d6cef125c6f343ba))
* align tariff pricing with calculate_renewal_price reference ([6349b2f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6349b2f4426abd49e3bd63364f3d2b204a486282))
* conditional log messages and sanitize panel_error in user deletion ([289cbe9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/289cbe966e42afe74c8d1b936139941ff84e008b))
* encode payment status in provider return URLs and wire failed_url ([275f249](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/275f249bbdf28d065e1b856e4d8ec7e73af4e1aa))
* enforce tariff device_price and max_device_limit across all purchase paths ([f9f07f3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f9f07f360c36ce1eade8a27fa0fa5bf22808db93))
* keep DB session alive in Tribute payment notification handler ([4186159](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4186159a61a40003454afc9c0faf848582cfb037))
* latest-payment endpoint returns all payments, not just pending ([7a9264b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7a9264b1731cf8935c9e3985f41a1df919dfbf83))
* pass cabinet return_url to payment providers for top-up redirects ([7ca9619](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7ca96195a7240ce0c3bd613c20344d79e5219c74))
* propagate tariff squad changes to existing subscriptions and fix user deletion from Remnawave ([7ccfb66](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7ccfb66690c93df0c9c694935b16a280ca8ae812))
* renewal cost estimate double-counts servers and traffic in tariff mode ([bfbefeb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bfbefeb1e20a191f604bbcbc79b14d8c6e4cd5bd))
* resolve concurrent AsyncSession bug and sanitize error responses ([4a5cacd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4a5cacda386e7fa60ad7b6393aa3372384bee128))
* use parsed HTML length for Telegram caption limit checks ([2649e12](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2649e12f64b8f825a3db85b95da6a335b0f8eec6))
### Refactoring
* move squad propagation to service layer with parallel Remnawave sync ([79161ea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79161eaae4d67c82c45b6ea3654c0b15c8b785a4))
## [3.26.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.25.0...v3.26.0) (2026-03-08)
+1 -1
View File
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
FROM python:3.13-slim
ARG VERSION="v3.26.0" # x-release-please-version
ARG VERSION="v3.28.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+8 -1
View File
@@ -248,7 +248,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
elif settings.is_cabinet_mode():
logger.info('🏠 Режим Cabinet активен, базовый URL', MINIAPP_CUSTOM_URL=settings.MINIAPP_CUSTOM_URL)
# Load per-section button styles cache
# Load per-section button styles cache and menu layout cache
if settings.is_cabinet_mode():
try:
from app.utils.button_styles_cache import load_button_styles_cache
@@ -257,6 +257,13 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
except Exception as e:
logger.warning('Failed to load button styles cache', error=e)
try:
from app.utils.menu_layout_cache import load_menu_layout_cache
await load_menu_layout_cache()
except Exception as e:
logger.warning('Failed to load menu layout cache', error=e)
logger.info('Бот успешно настроен')
return bot, dp
+6
View File
@@ -12,6 +12,7 @@ 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_landings import router as admin_landings_router
from .admin_menu_layout import router as admin_menu_layout_router
from .admin_partners import router as admin_partners_router
from .admin_payment_methods import router as admin_payment_methods_router
from .admin_payments import router as admin_payments_router
@@ -36,6 +37,7 @@ from .auth import router as auth_router
from .balance import router as balance_router
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 .landing import router as landing_router
from .media import router as media_router
@@ -86,6 +88,9 @@ router.include_router(media_router)
# Wheel routes
router.include_router(wheel_router)
# Gift routes
router.include_router(gift_router)
# Admin routes (notifications router MUST be before tickets router to avoid route conflict)
router.include_router(admin_ticket_notifications_router)
router.include_router(admin_tickets_router)
@@ -113,6 +118,7 @@ router.include_router(admin_updates_router)
router.include_router(admin_traffic_router)
router.include_router(admin_pinned_messages_router)
router.include_router(admin_button_styles_router)
router.include_router(admin_menu_layout_router)
router.include_router(admin_channels_router)
router.include_router(admin_apps_router)
router.include_router(admin_roles_router)
+77 -11
View File
@@ -5,6 +5,7 @@ Router 1 (`router`): JWT-protected endpoints for linking/unlinking OAuth provide
Router 2 (`merge_router`): Public endpoints for merge preview and execution.
"""
import hashlib
from datetime import UTC, datetime
from typing import Literal, NotRequired, TypedDict
@@ -14,6 +15,8 @@ from pydantic import BaseModel, Field, model_validator
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.system_setting import get_setting_value
from app.database.crud.user import (
OAUTH_PROVIDER_COLUMNS,
clear_user_oauth_provider_id,
@@ -24,7 +27,7 @@ from app.database.crud.user import (
)
from app.database.models import User
from app.services.account_merge_service import compute_auth_methods, execute_merge, get_merge_preview
from app.utils.cache import RateLimitCache
from app.utils.cache import RateLimitCache, TokenReplayCache
from ..auth.merge_service import (
MERGE_TOKEN_TTL_SECONDS,
@@ -38,7 +41,11 @@ from ..auth.oauth_providers import (
get_provider,
validate_oauth_state,
)
from ..auth.telegram_auth import validate_telegram_init_data, validate_telegram_login_widget
from ..auth.telegram_auth import (
validate_telegram_init_data,
validate_telegram_login_widget,
validate_telegram_oidc_token,
)
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..ip_utils import get_client_ip
from ..schemas.auth import UserResponse
@@ -70,8 +77,6 @@ class OAuthStateData(TypedDict):
def _get_active_providers() -> list[str]:
"""Вернуть список активных провайдеров аутентификации (только включённые)."""
from app.config import settings
providers: list[str] = ['telegram']
if settings.is_cabinet_email_auth_enabled():
providers.append('email')
@@ -117,10 +122,12 @@ class UnlinkResponse(BaseModel):
class LinkTelegramRequest(BaseModel):
"""Request for linking Telegram account. Supply EITHER init_data OR widget fields."""
"""Request for linking Telegram account. Supply EITHER init_data, id_token, OR widget fields."""
# Mini App: Telegram WebApp initData
init_data: str | None = Field(None, max_length=4096, description='Telegram WebApp initData string')
# OIDC: id_token from Telegram Login popup
id_token: str | None = Field(None, max_length=4096, description='Telegram OIDC id_token (JWT)')
# Login Widget fields
id: int | None = Field(None, description='Telegram user ID from Login Widget')
first_name: str | None = Field(None, max_length=256, description="User's first name")
@@ -133,11 +140,13 @@ class LinkTelegramRequest(BaseModel):
@model_validator(mode='after')
def check_exclusive(self) -> 'LinkTelegramRequest':
has_init = self.init_data is not None
has_oidc = self.id_token is not None
has_widget = self.id is not None or self.hash is not None or self.auth_date is not None
if has_init and has_widget:
raise ValueError('Provide either init_data or Login Widget fields, not both')
if not has_init and not has_widget:
raise ValueError('Provide either init_data or Login Widget fields (id, auth_date, hash)')
modes = sum([has_init, has_oidc, has_widget])
if modes > 1:
raise ValueError('Provide exactly one of: init_data, id_token, or Login Widget fields')
if modes == 0:
raise ValueError('Provide one of: init_data, id_token, or Login Widget fields (id, auth_date, hash)')
if has_widget and not (self.id is not None and self.auth_date is not None and self.hash is not None):
raise ValueError('Login Widget mode requires id, auth_date, and hash fields')
return self
@@ -449,10 +458,20 @@ async def unlink_provider(
@router.post('/link/telegram', response_model=LinkCallbackResponse)
async def link_telegram(
request: LinkTelegramRequest,
raw_request: Request,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> LinkCallbackResponse:
"""Link Telegram account via WebApp initData or Login Widget."""
"""Link Telegram account via WebApp initData, OIDC id_token, or Login Widget."""
# Rate limit
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'link_telegram', limit=10, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
# 1. Already has Telegram linked?
if user.telegram_id:
raise HTTPException(
@@ -478,6 +497,53 @@ async def link_telegram(
telegram_username = user_data.get('username')
telegram_first_name = user_data.get('first_name')
telegram_last_name = user_data.get('last_name')
elif request.id_token:
# OIDC flow: validate id_token via JWKS
oidc_enabled_val = await get_setting_value(db, 'TELEGRAM_OIDC_ENABLED')
oidc_client_id_val = await get_setting_value(db, 'TELEGRAM_OIDC_CLIENT_ID')
oidc_client_id = oidc_client_id_val or settings.TELEGRAM_OIDC_CLIENT_ID
oidc_enabled = (
oidc_enabled_val.lower() == 'true' if oidc_enabled_val is not None else settings.TELEGRAM_OIDC_ENABLED
) and bool(oidc_client_id)
if not oidc_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Telegram OIDC is not configured',
)
claims = await validate_telegram_oidc_token(request.id_token, oidc_client_id)
if not claims:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid or expired Telegram OIDC token',
)
# Replay detection
token_hash = hashlib.sha256(request.id_token.encode()).hexdigest()
token_ttl = max(int(claims.get('exp', 0) - datetime.now(UTC).timestamp()), 60)
if await TokenReplayCache.is_token_replayed(token_hash, ttl=min(token_ttl, 600)):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid or expired Telegram OIDC token',
)
try:
telegram_id = int(claims.get('id', claims.get('sub', 0)))
except (ValueError, TypeError) as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid user ID in OIDC claims',
) from exc
if not telegram_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Missing user ID in OIDC claims',
)
telegram_username = claims.get('preferred_username')
telegram_first_name = claims.get('name', claims.get('given_name', ''))
telegram_last_name = claims.get('family_name')
elif request.id is not None and request.hash is not None and request.auth_date is not None:
# Login Widget flow: validate widget hash
widget_data = {
@@ -506,7 +572,7 @@ async def link_telegram(
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Provide either init_data (Mini App) or Login Widget fields (id, auth_date, hash)',
detail='Provide init_data (Mini App), id_token (OIDC), or Login Widget fields (id, auth_date, hash)',
)
# 3. Check if telegram_id is linked to ANOTHER user
+398
View File
@@ -0,0 +1,398 @@
"""Admin routes for cabinet menu layout configuration (rows + custom URL buttons).
Serves a MERGED view combining ``CABINET_MENU_LAYOUT`` (row arrangement, custom buttons)
and ``CABINET_BUTTON_STYLES`` (per-section style/emoji/enabled/labels) to the frontend.
On save, splits the payload back into two SystemSetting keys.
"""
import json
import re
from typing import Literal
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.utils.button_styles_cache import (
ALLOWED_STYLE_VALUES,
BOT_LOCALES,
BUTTON_STYLES_KEY,
DEFAULT_BUTTON_STYLES,
get_cached_button_styles,
load_button_styles_cache,
)
from app.utils.menu_layout_cache import (
BUILTIN_SECTIONS,
DEFAULT_MENU_LAYOUT,
MENU_LAYOUT_KEY,
VALID_CUSTOM_BUTTON_STYLES,
get_cached_menu_layout,
load_menu_layout_cache,
)
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/menu-layout', tags=['Admin Menu Layout'])
# ---- Constants ---------------------------------------------------------------
MAX_ROWS = 20
MAX_BUTTONS_PER_ROW = 3
MAX_LABEL_LENGTH = 100
URL_PATTERN = re.compile(r'^https?://')
# ---- Schemas -----------------------------------------------------------------
class ButtonConfig(BaseModel):
"""Configuration for a single button (built-in or custom URL)."""
id: str = Field(max_length=100)
type: Literal['builtin', 'custom']
style: str = Field(default='primary', max_length=20)
icon_custom_emoji_id: str = Field(default='', max_length=100)
enabled: bool = True
labels: dict[str, str] = Field(default_factory=dict, max_length=10)
url: str | None = Field(default=None, max_length=2048)
open_in: Literal['external', 'webapp'] = 'external'
class RowConfig(BaseModel):
"""Configuration for a single row of buttons."""
id: str = Field(max_length=100)
max_per_row: int = Field(default=2, ge=1, le=3)
buttons: list[ButtonConfig] = Field(default_factory=list, max_length=MAX_BUTTONS_PER_ROW)
class MenuConfigResponse(BaseModel):
"""Full merged menu configuration returned to the frontend."""
rows: list[RowConfig]
class MenuConfigUpdateRequest(BaseModel):
"""Full menu configuration submitted by the frontend."""
rows: list[RowConfig] = Field(max_length=MAX_ROWS)
# ---- Helpers -----------------------------------------------------------------
async def _get_setting_value(db: AsyncSession, key: str) -> str | None:
from sqlalchemy import select
from app.database.models import SystemSetting
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
return setting.value if setting else None
async def _upsert_setting(db: AsyncSession, key: str, value: str) -> None:
"""Insert or update a SystemSetting without committing."""
from sqlalchemy import select
from app.database.models import SystemSetting
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
if setting:
setting.value = value
else:
setting = SystemSetting(key=key, value=value)
db.add(setting)
def _build_merged_response(
layout: dict[str, object],
button_styles: dict[str, dict],
) -> MenuConfigResponse:
"""Merge layout rows with button_styles into a unified response.
Built-in buttons get style/emoji/enabled/labels from ``button_styles``.
Custom URL buttons get all config from layout's ``custom_buttons``.
"""
custom_buttons: dict[str, dict] = layout.get('custom_buttons', {})
# Collect row entries sorted numerically (row_1, row_2, ..., row_10, ...)
row_keys = sorted(
(k for k in layout if k.startswith('row_')),
key=lambda k: int(k.split('_', 1)[1]) if k.split('_', 1)[1].isdigit() else 0,
)
rows: list[RowConfig] = []
for row_key in row_keys:
row_data = layout[row_key]
if not isinstance(row_data, dict):
continue
raw_buttons: list[str] = row_data.get('buttons', [])
max_per_row: int = row_data.get('max_per_row', 2)
row_id: str = row_data.get('id', row_key)
merged_buttons: list[ButtonConfig] = []
for btn_id in raw_buttons:
if btn_id in BUILTIN_SECTIONS:
# Built-in: pull style data from button_styles cache
style_cfg = button_styles.get(btn_id, {})
merged_buttons.append(
ButtonConfig(
id=btn_id,
type='builtin',
style=style_cfg.get('style', 'primary'),
icon_custom_emoji_id=style_cfg.get('icon_custom_emoji_id', ''),
enabled=style_cfg.get('enabled', True),
labels=style_cfg.get('labels', {}),
),
)
elif btn_id.startswith('custom_') and btn_id in custom_buttons:
# Custom URL button: pull config from layout's custom_buttons
cb = custom_buttons[btn_id]
merged_buttons.append(
ButtonConfig(
id=btn_id,
type='custom',
style=cb.get('style', 'primary'),
icon_custom_emoji_id=cb.get('icon_custom_emoji_id', ''),
enabled=cb.get('enabled', True),
labels=cb.get('labels', {}),
url=cb.get('url'),
open_in=cb.get('open_in', 'external'),
),
)
rows.append(
RowConfig(
id=row_id,
max_per_row=max_per_row,
buttons=merged_buttons,
),
)
return MenuConfigResponse(rows=rows)
def _split_update(
rows: list[RowConfig],
) -> tuple[dict[str, object], dict[str, dict]]:
"""Split a flat list of RowConfig back into layout_data and button_styles_updates.
Returns:
(layout_data, button_styles_updates)
- layout_data: rows + custom_buttons for ``CABINET_MENU_LAYOUT``
- button_styles_updates: ``{section: {style, icon_custom_emoji_id, enabled, labels}}``
for built-in sections only
"""
layout_data: dict[str, object] = {}
custom_buttons: dict[str, dict] = {}
button_styles_updates: dict[str, dict] = {}
for idx, row in enumerate(rows, start=1):
row_key = f'row_{idx}'
button_ids: list[str] = []
for btn in row.buttons:
button_ids.append(btn.id)
if btn.type == 'builtin' and btn.id in BUILTIN_SECTIONS:
button_styles_updates[btn.id] = {
'style': btn.style,
'icon_custom_emoji_id': btn.icon_custom_emoji_id,
'enabled': btn.enabled,
'labels': btn.labels,
}
elif btn.type == 'custom' and btn.id.startswith('custom_'):
custom_buttons[btn.id] = {
'id': btn.id,
'url': btn.url or '',
'style': btn.style,
'icon_custom_emoji_id': btn.icon_custom_emoji_id,
'enabled': btn.enabled,
'labels': btn.labels,
'open_in': btn.open_in,
}
layout_data[row_key] = {
'id': row.id or row_key,
'buttons': button_ids,
'max_per_row': row.max_per_row,
}
layout_data['custom_buttons'] = custom_buttons
return layout_data, button_styles_updates
def _validate_update_payload(rows: list[RowConfig]) -> None:
"""Validate the full update payload. Raises HTTPException on failure."""
if len(rows) > MAX_ROWS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Too many rows: {len(rows)}. Maximum allowed: {MAX_ROWS}.',
)
# Check for duplicate button IDs across all rows
seen_ids: set[str] = set()
for row in rows:
for btn in row.buttons:
if btn.id in seen_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Duplicate button ID: "{btn.id}". Each button can only appear once.',
)
seen_ids.add(btn.id)
for row in rows:
if len(row.buttons) > MAX_BUTTONS_PER_ROW:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Row "{row.id}" has {len(row.buttons)} buttons. Maximum per row: {MAX_BUTTONS_PER_ROW}.',
)
for btn in row.buttons:
# Validate button type consistency
if btn.type == 'builtin' and btn.id not in BUILTIN_SECTIONS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Unknown built-in section: "{btn.id}".',
)
if btn.type == 'custom' and not btn.id.startswith('custom_'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Custom button id must start with "custom_": "{btn.id}".',
)
# Validate URL for custom buttons
if btn.type == 'custom':
if not btn.url or not URL_PATTERN.match(btn.url):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Custom button "{btn.id}" must have a URL starting with http:// or https://.',
)
if btn.open_in == 'webapp' and not btn.url.startswith('https://'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Custom button "{btn.id}" with webapp mode requires an https:// URL.',
)
# Validate style
all_allowed = ALLOWED_STYLE_VALUES | VALID_CUSTOM_BUTTON_STYLES
if btn.style not in all_allowed:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid style "{btn.style}" for button "{btn.id}". '
f'Allowed: {", ".join(sorted(all_allowed))}.',
)
# Validate labels
for locale_key, label_val in btn.labels.items():
if locale_key not in BOT_LOCALES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid locale "{locale_key}" for button "{btn.id}". '
f'Allowed: {", ".join(BOT_LOCALES)}.',
)
if not isinstance(label_val, str):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Label value for locale "{locale_key}" must be a string.',
)
if len(label_val.strip()) > MAX_LABEL_LENGTH:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Label for locale "{locale_key}" on button "{btn.id}" '
f'exceeds {MAX_LABEL_LENGTH} characters.',
)
# ---- Routes ------------------------------------------------------------------
@router.get('', response_model=MenuConfigResponse)
async def get_menu_layout(
_admin: User = Depends(require_permission('settings:read')),
):
"""Return merged menu layout config (rows + button styles). Admin only."""
layout = get_cached_menu_layout()
button_styles = get_cached_button_styles()
return _build_merged_response(layout, button_styles)
@router.put('', response_model=MenuConfigResponse)
async def update_menu_layout(
payload: MenuConfigUpdateRequest,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Save full menu layout config. Splits into layout + button styles. Admin only."""
_validate_update_payload(payload.rows)
layout_data, button_styles_updates = _split_update(payload.rows)
# Save layout to CABINET_MENU_LAYOUT (without committing)
await _upsert_setting(db, MENU_LAYOUT_KEY, json.dumps(layout_data))
# Merge button styles updates with existing styles (don't overwrite sections not in request)
if button_styles_updates:
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
current_styles: dict[str, dict] = {}
if raw:
try:
current_styles = json.loads(raw)
except (json.JSONDecodeError, TypeError):
current_styles = {}
for section, updates in button_styles_updates.items():
current_styles[section] = updates
await _upsert_setting(db, BUTTON_STYLES_KEY, json.dumps(current_styles))
# Single atomic commit for both settings
await db.commit()
# Refresh caches after commit
await load_button_styles_cache()
await load_menu_layout_cache()
logger.info(
'Admin updated menu layout',
telegram_id=admin.telegram_id,
rows_count=len(payload.rows),
custom_buttons_count=len(layout_data.get('custom_buttons', {})),
)
# Return merged response from fresh caches
layout = get_cached_menu_layout()
button_styles = get_cached_button_styles()
return _build_merged_response(layout, button_styles)
@router.post('/reset', response_model=MenuConfigResponse)
async def reset_menu_layout(
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset menu layout AND button styles to defaults. Admin only."""
await _upsert_setting(db, MENU_LAYOUT_KEY, json.dumps(DEFAULT_MENU_LAYOUT))
await _upsert_setting(db, BUTTON_STYLES_KEY, json.dumps(DEFAULT_BUTTON_STYLES))
# Single atomic commit for both settings
await db.commit()
# Refresh caches after commit
await load_button_styles_cache()
await load_menu_layout_cache()
logger.info('Admin reset menu layout and button styles to defaults', telegram_id=admin.telegram_id)
layout = get_cached_menu_layout()
button_styles = get_cached_button_styles()
return _build_merged_response(layout, button_styles)
+22 -18
View File
@@ -374,7 +374,7 @@ async def _sync_subscription_to_panel(
except Exception as e:
logger.error('Error syncing user to panel', user_id=user.id, error=e)
return {'error': str(e)}
return {'error': 'Ошибка синхронизации пользователя с панелью'}
# === List & Search ===
@@ -1718,7 +1718,7 @@ async def delete_user_device(
except Exception as e:
logger.error('Error deleting device for user', hwid=hwid, user_id=user_id, error=e)
return DeleteDeviceResponse(success=False, message=str(e))
return DeleteDeviceResponse(success=False, message='Ошибка удаления устройства')
@router.delete('/{user_id}/devices', response_model=ResetDevicesResponse)
@@ -1762,7 +1762,7 @@ async def reset_user_devices(
except Exception as e:
logger.error('Error resetting devices for user', user_id=user_id, error=e)
return ResetDevicesResponse(success=False, message=str(e))
return ResetDevicesResponse(success=False, message='Ошибка сброса устройств')
# === Delete User ===
@@ -1830,28 +1830,32 @@ async def full_delete_user(
detail='User not found',
)
panel_error: str | None = None
deleted_from_panel = False
# Pre-fetch admin.id to avoid MissingGreenlet after transaction rollback
admin_id_val = admin.id
# UserService.delete_user_account handles both bot DB and Remnawave panel
user_service = UserService()
success = await user_service.delete_user_account(db, user_id, admin_id_val)
if success:
deleted_from_panel = request.delete_from_panel and user.remnawave_uuid is not None
delete_result = await user_service.delete_user_account(
db, user_id, admin_id_val, force_panel_delete=request.delete_from_panel
)
reason_text = f' (reason: {request.reason})' if request.reason else ''
logger.info('Admin fully deleted user', admin_id=admin_id_val, user_id=user_id, reason_text=reason_text)
logger.info(
'Admin fully deleted user',
admin_id=admin_id_val,
user_id=user_id,
reason_text=reason_text,
bot_deleted=delete_result.bot_deleted,
panel_deleted=delete_result.panel_deleted,
panel_error=delete_result.panel_error,
)
return FullDeleteUserResponse(
success=success,
message='User fully deleted from bot and panel' if success else 'Failed to delete user',
deleted_from_bot=success,
deleted_from_panel=deleted_from_panel,
panel_error=panel_error,
success=delete_result.bot_deleted,
message='User fully deleted from bot and panel' if delete_result.bot_deleted else 'Failed to delete user',
deleted_from_bot=delete_result.bot_deleted,
deleted_from_panel=delete_result.panel_deleted,
panel_error=delete_result.panel_error,
)
@@ -1985,7 +1989,7 @@ async def reset_user_subscription(
if panel_deactivated:
logger.info('Disabled Remnawave user for subscription reset', remnawave_uuid=user.remnawave_uuid)
except Exception as e:
panel_error = str(e)
panel_error = 'Ошибка обработки пользователя в Remnawave'
logger.warning('Failed to disable Remnawave user during subscription reset', error=e)
# Delete subscription from database
@@ -2055,7 +2059,7 @@ async def disable_user(
if panel_deactivated:
logger.info('Disabled Remnawave user', remnawave_uuid=user.remnawave_uuid)
except Exception as e:
panel_error = str(e)
panel_error = 'Ошибка обработки пользователя в Remnawave'
logger.warning('Failed to disable Remnawave user', error=e)
# Deactivate subscription in bot database (skip if active paid subscription)
+99 -3
View File
@@ -349,6 +349,9 @@ async def create_topup(
amount_rubles = request.amount_kopeks / 100
payment_url = None
payment_id = None
cabinet_return_url = f'{settings.CABINET_URL.rstrip("/")}/balance/top-up/result?method={request.payment_method}'
cabinet_success_url = f'{cabinet_return_url}&status=success'
cabinet_failed_url = f'{cabinet_return_url}&status=failed'
try:
if request.payment_method == 'yookassa':
@@ -373,6 +376,7 @@ async def create_topup(
amount_kopeks=request.amount_kopeks,
description=description,
metadata=yookassa_metadata,
return_url=cabinet_return_url,
)
else:
result = await payment_service.create_yookassa_payment(
@@ -381,11 +385,12 @@ async def create_topup(
amount_kopeks=request.amount_kopeks,
description=description,
metadata=yookassa_metadata,
return_url=cabinet_return_url,
)
if result:
payment_url = result.get('confirmation_url')
payment_id = result.get('yookassa_payment_id')
payment_id = str(result.get('local_payment_id') or result.get('yookassa_payment_id') or 'pending')
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -490,6 +495,8 @@ async def create_topup(
),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_method_code=method_code,
return_url=cabinet_success_url,
failed_url=cabinet_failed_url,
)
if result and result.get('redirect_url'):
@@ -515,6 +522,8 @@ async def create_topup(
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
return_url=cabinet_return_url,
success_url=cabinet_success_url,
)
if result and result.get('payment_url'):
@@ -562,7 +571,6 @@ async def create_topup(
option = (request.payment_option or '').strip().lower()
if option not in {'card', 'sbp'}:
option = 'sbp'
provider_method = 'card' if option == 'card' else 'sbp'
payment_service = PaymentService()
result = await payment_service.create_pal24_payment(
@@ -571,7 +579,6 @@ async def create_topup(
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_method=provider_method,
)
if result:
@@ -612,6 +619,8 @@ async def create_topup(
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
return_url=cabinet_success_url,
failed_url=cabinet_failed_url,
)
if result and result.get('payment_url'):
@@ -638,6 +647,8 @@ async def create_topup(
description=settings.get_balance_payment_description(request.amount_kopeks),
telegram_id=user.telegram_id,
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
return_url=cabinet_success_url,
failed_url=cabinet_failed_url,
)
if result and result.get('payment_url'):
@@ -965,6 +976,91 @@ async def get_pending_payments(
)
@router.get('/pending-payments/{method}/latest', response_model=PendingPaymentResponse)
async def get_latest_payment_by_method(
method: str,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user's most recent payment for a given method (any status, not just pending)."""
try:
payment_method = PaymentMethod(method)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid payment method: {method}',
)
from datetime import UTC, datetime, timedelta
from sqlalchemy.orm import selectinload
from app.database.models import (
CloudPaymentsPayment,
CryptoBotPayment,
FreekassaPayment,
HeleketPayment,
KassaAiPayment,
MulenPayPayment,
Pal24Payment,
PlategaPayment,
WataPayment,
YooKassaPayment,
)
model_map: dict[PaymentMethod, type] = {
PaymentMethod.YOOKASSA: YooKassaPayment,
PaymentMethod.CRYPTOBOT: CryptoBotPayment,
PaymentMethod.HELEKET: HeleketPayment,
PaymentMethod.MULENPAY: MulenPayPayment,
PaymentMethod.PAL24: Pal24Payment,
PaymentMethod.WATA: WataPayment,
PaymentMethod.PLATEGA: PlategaPayment,
PaymentMethod.CLOUDPAYMENTS: CloudPaymentsPayment,
PaymentMethod.FREEKASSA: FreekassaPayment,
PaymentMethod.KASSA_AI: KassaAiPayment,
}
model = model_map.get(payment_method)
if not model:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Unsupported payment method: {method}',
)
cutoff = datetime.now(UTC) - timedelta(hours=1)
stmt = (
select(model)
.options(selectinload(model.user))
.where(model.user_id == user.id, model.created_at >= cutoff)
.order_by(desc(model.created_at))
.limit(1)
)
result = await db.execute(stmt)
payment = result.scalars().first()
if not payment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No recent payments found',
)
record = PendingPayment(
local_id=payment.id,
method=payment_method,
identifier=str(getattr(payment, 'correlation_id', None) or payment.id),
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
created_at=payment.created_at,
expires_at=getattr(payment, 'expires_at', None),
user=payment.user,
payment=payment,
)
return _record_to_response(record)
@router.get('/pending-payments/{method}/{payment_id}', response_model=PendingPaymentResponse)
async def get_pending_payment_details(
method: str,
+40
View File
@@ -39,6 +39,7 @@ YANDEX_METRIKA_ID_KEY = 'CABINET_YANDEX_METRIKA_ID' # Stores counter ID (numeri
GOOGLE_ADS_ID_KEY = 'CABINET_GOOGLE_ADS_ID' # Stores conversion ID (e.g. "AW-123456789")
GOOGLE_ADS_LABEL_KEY = 'CABINET_GOOGLE_ADS_LABEL' # Stores conversion label (alphanumeric)
LITE_MODE_ENABLED_KEY = 'CABINET_LITE_MODE_ENABLED' # Stores "true" or "false"
GIFT_ENABLED_KEY = 'CABINET_GIFT_ENABLED' # Stores "true" or "false"
ANIMATION_CONFIG_KEY = 'CABINET_ANIMATION_CONFIG' # Stores JSON with animation config
TELEGRAM_WIDGET_SIZE_KEY = 'TELEGRAM_WIDGET_SIZE'
TELEGRAM_WIDGET_RADIUS_KEY = 'TELEGRAM_WIDGET_RADIUS'
@@ -276,6 +277,18 @@ class LiteModeEnabledUpdate(BaseModel):
enabled: bool
class GiftEnabledResponse(BaseModel):
"""Gift feature enabled setting."""
enabled: bool = False
class GiftEnabledUpdate(BaseModel):
"""Request to update gift feature setting."""
enabled: bool
class AnalyticsCountersResponse(BaseModel):
"""Analytics counter settings."""
@@ -983,3 +996,30 @@ async def update_lite_mode_enabled(
logger.info('Admin set lite mode enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
return LiteModeEnabledResponse(enabled=payload.enabled)
# ============ Gift Feature Routes ============
@router.get('/gift-enabled', response_model=GiftEnabledResponse)
async def get_gift_enabled(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get gift feature enabled setting. Public endpoint."""
value = await get_setting_value(db, GIFT_ENABLED_KEY)
if value is not None:
enabled = value.lower() == 'true'
return GiftEnabledResponse(enabled=enabled)
return GiftEnabledResponse(enabled=False)
@router.patch('/gift-enabled', response_model=GiftEnabledResponse)
async def update_gift_enabled(
payload: GiftEnabledUpdate,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update gift feature enabled setting. Admin only."""
await set_setting_value(db, GIFT_ENABLED_KEY, str(payload.enabled).lower())
logger.info('Admin set gift enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
return GiftEnabledResponse(enabled=payload.enabled)
+495
View File
@@ -0,0 +1,495 @@
"""Gift subscription routes for cabinet."""
import asyncio
import re
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.landing import get_purchase_by_token
from app.database.crud.system_setting import get_setting_value
from app.database.crud.tariff import get_tariff_by_id
from app.database.crud.transaction import create_transaction, emit_transaction_side_effects
from app.database.crud.user import subtract_user_balance
from app.database.models import GuestPurchase, GuestPurchaseStatus, PaymentMethod, Tariff, TransactionType, User
from app.services.guest_purchase_service import (
GuestPurchaseError,
create_purchase,
fulfill_purchase,
)
from app.services.payment_method_config_service import get_enabled_methods_for_user
from app.utils.cache import RateLimitCache
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.gift import (
GiftConfigPaymentMethod,
GiftConfigResponse,
GiftConfigSubOption,
GiftConfigTariff,
GiftConfigTariffPeriod,
GiftPurchaseRequest,
GiftPurchaseResponse,
GiftPurchaseStatusResponse,
PendingGiftResponse,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/gift', tags=['Cabinet Gift'])
GIFT_ENABLED_KEY = 'CABINET_GIFT_ENABLED'
_EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$')
_TELEGRAM_RE = re.compile(r'^@?[a-zA-Z][a-zA-Z0-9_]{4,31}$')
async def _is_gift_enabled(db: AsyncSession) -> bool:
"""Check if the gift feature is enabled via system settings."""
value = await get_setting_value(db, GIFT_ENABLED_KEY)
if value is not None:
return value.lower() == 'true'
return False
@router.get('/config', response_model=GiftConfigResponse)
async def get_gift_config(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get gift subscription configuration: tariffs, payment methods, balance."""
enabled = await _is_gift_enabled(db)
if not enabled:
return GiftConfigResponse(
is_enabled=False,
balance_kopeks=user.balance_kopeks,
)
# Load active tariffs
result = await db.execute(
select(Tariff).where(Tariff.is_active.is_(True)).order_by(Tariff.display_order, Tariff.id)
)
tariffs_db = result.scalars().all()
tariffs: list[GiftConfigTariff] = []
for tariff in tariffs_db:
period_days_list = tariff.get_available_periods()
periods: list[GiftConfigTariffPeriod] = []
for days in period_days_list:
price = tariff.get_price_for_period(days)
if price is None:
continue
periods.append(
GiftConfigTariffPeriod(
days=days,
price_kopeks=price,
price_label=settings.format_price(price),
)
)
if not periods:
continue
tariffs.append(
GiftConfigTariff(
id=tariff.id,
name=tariff.name,
description=tariff.description,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
periods=periods,
)
)
# Load payment methods available for this user
enabled_methods = await get_enabled_methods_for_user(db, user=user)
payment_methods: list[GiftConfigPaymentMethod] = []
for method_data in enabled_methods:
sub_options = None
raw_options = method_data.get('options')
if raw_options:
sub_options = [GiftConfigSubOption(id=opt['id'], name=opt.get('name', opt['id'])) for opt in raw_options]
payment_methods.append(
GiftConfigPaymentMethod(
method_id=method_data['id'],
display_name=method_data['name'],
min_amount_kopeks=method_data.get('min_amount_kopeks'),
max_amount_kopeks=method_data.get('max_amount_kopeks'),
sub_options=sub_options,
)
)
return GiftConfigResponse(
is_enabled=True,
tariffs=tariffs,
payment_methods=payment_methods,
balance_kopeks=user.balance_kopeks,
currency_symbol=getattr(settings, 'CURRENCY_SYMBOL', '\u20bd'),
)
@router.post('/purchase', response_model=GiftPurchaseResponse)
async def create_gift_purchase(
body: GiftPurchaseRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a gift subscription purchase from the cabinet."""
enabled = await _is_gift_enabled(db)
if not enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Gift feature is not enabled',
)
# Rate limit: 5 gift purchases per 60 seconds per user
is_limited = await RateLimitCache.is_rate_limited(user.id, 'gift_purchase', limit=5, window=60)
if is_limited:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
# Check if user has purchase restrictions
if getattr(user, 'restriction_subscription', False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Purchases are restricted for this account',
)
# Validate recipient format
if body.recipient_type == 'email' and not _EMAIL_RE.match(body.recipient_value):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid email format',
)
if body.recipient_type == 'telegram' and not _TELEGRAM_RE.match(body.recipient_value):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid Telegram username format',
)
# Prevent self-gift
if body.recipient_type == 'telegram':
normalized_recipient = body.recipient_value.lstrip('@').lower()
if user.username and user.username.lower() == normalized_recipient:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot gift to yourself',
)
elif body.recipient_type == 'email':
if user.email and user.email.lower() == body.recipient_value.lower():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot gift to yourself',
)
# Find tariff and validate period
tariff = await get_tariff_by_id(db, body.tariff_id)
if tariff is None or not tariff.is_active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tariff not found or inactive',
)
price_kopeks = tariff.get_price_for_period(body.period_days)
if price_kopeks is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Price is not configured for this period',
)
# Determine buyer contact info
if user.email:
buyer_contact_type = 'email'
buyer_contact_value = user.email
elif user.username:
buyer_contact_type = 'telegram'
buyer_contact_value = f'@{user.username}'
else:
buyer_contact_type = 'telegram'
buyer_contact_value = f'id:{user.telegram_id or user.id}'
# Pre-check: try to resolve Telegram username — DB first, then Bot API.
# Placed after validation gates to prevent zero-cost enumeration.
# The resolved ID is passed to fulfill_purchase to avoid a duplicate API call.
recipient_warning: str | None = None
pre_resolved_telegram_id: int | None = None
if body.recipient_type == 'telegram':
tg_username = body.recipient_value.lstrip('@')
normalized_username = tg_username.lower()
# 1) Check local DB — user may already be registered in the bot
db_result = await db.execute(
select(User.telegram_id).where(
func.lower(User.username) == normalized_username,
User.telegram_id.isnot(None),
)
)
db_telegram_id = db_result.scalar_one_or_none()
if db_telegram_id is not None:
pre_resolved_telegram_id = db_telegram_id
else:
# 2) Fall back to Bot API (works for public usernames the bot has seen)
try:
from aiogram import Bot
async with Bot(token=settings.BOT_TOKEN) as bot:
chat = await asyncio.wait_for(bot.get_chat(chat_id=f'@{tg_username}'), timeout=5.0)
pre_resolved_telegram_id = chat.id
except Exception:
recipient_warning = 'telegram_unresolvable'
logger.warning(
'Telegram username not resolvable for gift',
username=tg_username,
buyer_id=user.id,
)
# Gateway mode: create payment via external provider
if body.payment_mode == 'gateway':
if not body.payment_method:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='payment_method is required for gateway mode',
)
try:
purchase = await create_purchase(
db,
landing=None,
tariff=tariff,
period_days=body.period_days,
amount_kopeks=price_kopeks,
contact_type=buyer_contact_type,
contact_value=buyer_contact_value,
payment_method=body.payment_method,
is_gift=True,
gift_recipient_type=body.recipient_type,
gift_recipient_value=body.recipient_value,
gift_message=body.gift_message,
source='cabinet',
buyer_user_id=user.id,
commit=False,
)
except GuestPurchaseError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
# Persist warning so it survives the gateway redirect
if recipient_warning:
purchase.recipient_warning = recipient_warning
# Build return URL for after payment
cabinet_base = (settings.CABINET_URL or '').rstrip('/')
return_url = f'{cabinet_base}/gift/result?token={purchase.token}'
from app.services.payment_service import PaymentService
payment_service = PaymentService()
payment_result = await payment_service.create_guest_payment(
db=db,
amount_kopeks=price_kopeks,
payment_method=body.payment_method,
description=f'Gift: {tariff.name} ({body.period_days}d)',
purchase_token=purchase.token,
return_url=return_url,
)
if payment_result is None:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail='Payment provider is unavailable, please try again later',
)
payment_url = payment_result.get('payment_url')
if not payment_url:
await db.rollback()
logger.error(
'Gift payment created but no payment_url returned',
purchase_token=purchase.token[:5],
provider=payment_result.get('provider'),
)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail='Payment provider returned an invalid response',
)
await db.commit()
await db.refresh(purchase)
return GiftPurchaseResponse(
status='created',
purchase_token=purchase.token,
payment_url=payment_url,
warning=recipient_warning,
)
# Balance mode
if user.balance_kopeks < price_kopeks:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Insufficient balance',
)
# Create purchase record
try:
purchase = await create_purchase(
db,
landing=None,
tariff=tariff,
period_days=body.period_days,
amount_kopeks=price_kopeks,
contact_type=buyer_contact_type,
contact_value=buyer_contact_value,
payment_method='balance',
is_gift=True,
gift_recipient_type=body.recipient_type,
gift_recipient_value=body.recipient_value,
gift_message=body.gift_message,
source='cabinet',
buyer_user_id=user.id,
commit=False,
)
except GuestPurchaseError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
# Persist warning on purchase record
if recipient_warning:
purchase.recipient_warning = recipient_warning
# Subtract balance
balance_ok = await subtract_user_balance(
db,
user,
price_kopeks,
description=f'Gift: {tariff.name} ({body.period_days}d)',
create_transaction=False,
)
if not balance_ok:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Insufficient balance',
)
# Create transaction record
transaction = await create_transaction(
db,
user_id=user.id,
type=TransactionType.GIFT_PAYMENT,
amount_kopeks=price_kopeks,
description=f'Gift: {tariff.name} ({body.period_days}d) -> {body.recipient_value}',
payment_method=PaymentMethod.BALANCE,
commit=False,
)
# Mark purchase as paid
purchase.status = GuestPurchaseStatus.PAID.value
purchase.paid_at = datetime.now(UTC)
await db.commit()
# Emit deferred side-effects after atomic commit
await emit_transaction_side_effects(
db,
transaction,
amount_kopeks=price_kopeks,
user_id=user.id,
type=TransactionType.GIFT_PAYMENT,
payment_method=PaymentMethod.BALANCE,
description=f'Gift: {tariff.name} ({body.period_days}d) -> {body.recipient_value}',
)
# Capture token before fulfill_purchase — session state may change after rollback inside fulfill
purchase_token = purchase.token
# Fulfill the purchase (find/create recipient user, create subscription, notify)
try:
await fulfill_purchase(db, purchase_token, pre_resolved_telegram_id=pre_resolved_telegram_id)
except Exception:
logger.exception(
'Gift purchase fulfillment failed (purchase is paid, will retry)',
purchase_id=purchase.id,
)
return GiftPurchaseResponse(
status='ok',
purchase_token=purchase_token,
warning=recipient_warning,
)
@router.get('/pending', response_model=list[PendingGiftResponse])
async def get_pending_gifts(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get pending gift purchases that the current user can activate."""
result = await db.execute(
select(GuestPurchase)
.where(
GuestPurchase.user_id == user.id,
GuestPurchase.is_gift.is_(True),
GuestPurchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value,
)
.order_by(GuestPurchase.created_at.desc())
)
purchases = result.scalars().all()
pending: list[PendingGiftResponse] = []
for p in purchases:
# Determine sender display name
sender_display = None
if p.contact_value:
sender_display = p.contact_value
pending.append(
PendingGiftResponse(
token=p.token,
tariff_name=p.tariff.name if p.tariff else None,
period_days=p.period_days,
gift_message=p.gift_message,
sender_display=sender_display,
created_at=p.created_at,
)
)
return pending
@router.get('/purchase/{token}', response_model=GiftPurchaseStatusResponse)
async def get_gift_purchase_status(
token: str,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get the status of a cabinet gift purchase."""
purchase = await get_purchase_by_token(db, token)
if purchase is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Purchase not found',
)
# Uniform 404 prevents token existence oracle
if purchase.buyer_user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Purchase not found',
)
tariff_name = purchase.tariff.name if purchase.tariff else None
recipient_contact_value = None
if purchase.gift_recipient_value:
recipient_contact_value = purchase.gift_recipient_value
return GiftPurchaseStatusResponse(
status=purchase.status,
is_gift=True,
recipient_contact_value=recipient_contact_value,
gift_message=purchase.gift_message,
tariff_name=tariff_name,
period_days=purchase.period_days,
warning=purchase.recipient_warning,
)
+16 -11
View File
@@ -204,15 +204,11 @@ async def get_loyalty_tiers(
total_spent_kopeks = await get_user_total_spent_kopeks(db, user.id)
total_spent_rubles = total_spent_kopeks / 100
# Get user's current promo group
await db.refresh(user, ['promo_group', 'user_promo_groups'])
current_promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
current_tier_name = current_promo_group.name if current_promo_group else None
# Get all auto-assign promo groups (sorted by threshold ascending)
auto_groups = await get_auto_assign_promo_groups(db)
tiers: list[LoyaltyTierInfo] = []
current_tier_name: str | None = None
next_tier_name: str | None = None
next_tier_threshold: float | None = None
@@ -220,7 +216,15 @@ async def get_loyalty_tiers(
threshold_kopeks = group.auto_assign_total_spent_kopeks or 0
threshold_rubles = threshold_kopeks / 100
is_achieved = total_spent_kopeks >= threshold_kopeks
is_current = current_promo_group and current_promo_group.id == group.id
# Track highest achieved tier as "current" (by spending, not by assignment)
if is_achieved:
current_tier_name = group.name
# Find next tier (first not achieved)
if not is_achieved and next_tier_name is None:
next_tier_name = group.name
next_tier_threshold = threshold_rubles
# Get period discounts
period_discounts = {}
@@ -241,15 +245,16 @@ async def get_loyalty_tiers(
traffic_discount_percent=group.traffic_discount_percent or 0,
device_discount_percent=group.device_discount_percent or 0,
period_discounts=period_discounts,
is_current=is_current,
is_current=False,
is_achieved=is_achieved,
)
)
# Find next tier (first not achieved)
if not is_achieved and next_tier_name is None:
next_tier_name = group.name
next_tier_threshold = threshold_rubles
# Mark only the highest achieved tier as "current"
for tier in reversed(tiers):
if tier.is_achieved:
tier.is_current = True
break
# Calculate progress to next tier
progress_percent = 0.0
+107 -23
View File
@@ -1008,9 +1008,10 @@ async def purchase_devices_legacy(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Purchase additional device slots (legacy endpoint without tariff support).
"""Purchase additional device slots (legacy endpoint).
DEPRECATED: Use /devices/purchase instead for full tariff and discount support.
Now uses tariff-aware pricing when subscription has a tariff_id.
"""
if getattr(user, 'restriction_subscription', False):
raise HTTPException(
@@ -1033,8 +1034,34 @@ async def purchase_devices_legacy(
detail='No subscription found',
)
price_per_device = settings.PRICE_PER_DEVICE
base_total_price = price_per_device * request.devices
if subscription.status not in ['active', 'trial']:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Ваша подписка неактивна',
)
# Get tariff for device price (if exists)
tariff = None
if subscription.tariff_id:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
# Determine device price and max limit from tariff or settings
if tariff and tariff.device_price_kopeks is not None:
device_price = tariff.device_price_kopeks
max_device_limit = tariff.max_device_limit
else:
device_price = settings.PRICE_PER_DEVICE
max_device_limit = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
if not device_price or device_price <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Докупка устройств недоступна',
)
base_total_price = device_price * request.devices
# Apply discount from promo group
discount_result = _apply_addon_discount(user, 'devices', base_total_price, 30)
@@ -1048,12 +1075,11 @@ async def purchase_devices_legacy(
# Check max devices limit (under row lock — prevents concurrent purchases exceeding limit)
current_devices = subscription.device_limit or 1
new_devices = current_devices + request.devices
max_devices = settings.MAX_DEVICES_LIMIT
if new_devices > max_devices:
if max_device_limit and new_devices > max_device_limit:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Maximum device limit is {max_devices}',
detail=f'Максимальное количество устройств: {max_device_limit}',
)
# Check balance
@@ -1127,7 +1153,7 @@ async def purchase_devices_legacy(
actual_current = subscription.device_limit or 1
actual_new = actual_current + request.devices
if max_devices > 0 and actual_new > max_devices:
if max_device_limit and actual_new > max_device_limit:
# Concurrent purchase already exceeded limit — refund balance
user_refund = await db.execute(
select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True)
@@ -1137,14 +1163,25 @@ async def purchase_devices_legacy(
await db.commit()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f'Maximum device limit is {max_devices}. Balance refunded.',
detail=f'Максимальное количество устройств: {max_device_limit}. Баланс возвращён.',
)
# Add devices (under lock)
subscription.device_limit = actual_new
await db.commit()
await db.refresh(subscription)
await db.refresh(user)
# Sync with RemnaWave
try:
service = SubscriptionService()
if getattr(user, 'remnawave_uuid', None):
await service.update_remnawave_user(db, subscription)
else:
await service.create_remnawave_user(db, subscription)
except Exception as e:
logger.error('Failed to sync devices with RemnaWave (legacy endpoint)', error=e)
# Отправляем уведомление админам
try:
from aiogram import Bot
@@ -4454,19 +4491,27 @@ async def toggle_subscription_pause(
detail='Pause is only available for daily tariffs',
)
# Toggle pause state
is_currently_paused = getattr(user.subscription, 'is_daily_paused', False)
new_paused_state = not is_currently_paused
user.subscription.is_daily_paused = new_paused_state
# Сохраняем статус ДО изменения для проверки RemnaWave
# Determine current state
from app.database.models import SubscriptionStatus
was_disabled = user.subscription.status == SubscriptionStatus.DISABLED.value
is_currently_paused = getattr(user.subscription, 'is_daily_paused', False)
was_disabled = user.subscription.status in (
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.EXPIRED.value,
)
# If resuming, check balance
# System-DISABLED subs (insufficient balance) should always be treated as needing resume,
# even if is_daily_paused is False (it's set by the system, not the user)
if was_disabled and not is_currently_paused:
new_paused_state = False # Force resume path
else:
new_paused_state = not is_currently_paused
user.subscription.is_daily_paused = new_paused_state
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
# If resuming, check balance and charge
if not new_paused_state:
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
if daily_price > 0 and user.balance_kopeks < daily_price:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
@@ -4478,8 +4523,44 @@ async def toggle_subscription_pause(
},
)
# Restore ACTIVE status if was DISABLED
# Charge daily fee FIRST, then restore ACTIVE status
if was_disabled:
if daily_price > 0:
from app.database.crud.user import subtract_user_balance
deducted = await subtract_user_balance(
db,
user,
daily_price,
f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
mark_as_paid_subscription=True,
)
if not deducted:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
'code': 'insufficient_balance',
'message': 'Balance deduction failed',
'required': daily_price,
'balance': user.balance_kopeks,
},
)
from app.database.crud.transaction import create_transaction
from app.database.models import TransactionType
try:
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=daily_price,
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
)
except Exception as exc:
logger.warning('Failed to create resume transaction', error=exc)
# Balance deducted successfully — now activate
user.subscription.status = SubscriptionStatus.ACTIVE.value
user.subscription.last_daily_charge_at = datetime.now(UTC)
user.subscription.end_date = datetime.now(UTC) + timedelta(days=1)
@@ -4489,14 +4570,17 @@ async def toggle_subscription_pause(
await db.refresh(user)
# Sync with RemnaWave only when resuming from DISABLED state
# При паузе НЕ отключаем - пользователь может пользоваться до конца оплаченного периода
# При возобновлении включаем только если подписка была отключена (DISABLED)
if not new_paused_state and user.remnawave_uuid and was_disabled:
if not new_paused_state and was_disabled:
try:
subscription_service = SubscriptionService()
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
await subscription_service.create_remnawave_user(
db,
user.subscription,
reset_traffic=False,
reset_reason=None,
)
except Exception as e:
logger.error('Error enabling RemnaWave user on resume', error=e)
logger.error('Error syncing RemnaWave user on resume', error=e)
if new_paused_state:
message = 'Daily subscription paused'
+89
View File
@@ -0,0 +1,89 @@
"""Schemas for cabinet gift subscription feature."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field, model_validator
class GiftConfigSubOption(BaseModel):
id: str
name: str
class GiftConfigTariffPeriod(BaseModel):
days: int
price_kopeks: int
price_label: str
original_price_kopeks: int | None = None
discount_percent: int | None = None
class GiftConfigTariff(BaseModel):
id: int
name: str
description: str | None = None
traffic_limit_gb: int
device_limit: int
periods: list[GiftConfigTariffPeriod]
class GiftConfigPaymentMethod(BaseModel):
method_id: str
display_name: str
description: str | None = None
icon_url: str | None = None
min_amount_kopeks: int | None = None
max_amount_kopeks: int | None = None
sub_options: list[GiftConfigSubOption] | None = None
class GiftConfigResponse(BaseModel):
is_enabled: bool
tariffs: list[GiftConfigTariff] = []
payment_methods: list[GiftConfigPaymentMethod] = []
balance_kopeks: int = 0
currency_symbol: str = '\u20bd'
class GiftPurchaseRequest(BaseModel):
tariff_id: int = Field(gt=0)
period_days: int = Field(gt=0, le=3650)
recipient_type: str = Field(pattern=r'^(email|telegram)$')
recipient_value: str = Field(min_length=1, max_length=255)
gift_message: str | None = Field(default=None, max_length=1000)
payment_mode: str = Field(pattern=r'^(balance|gateway)$')
payment_method: str | None = Field(default=None, max_length=50)
@model_validator(mode='after')
def validate_payment(self) -> GiftPurchaseRequest:
if self.payment_mode == 'gateway' and not self.payment_method:
raise ValueError('payment_method is required for gateway mode')
return self
class GiftPurchaseResponse(BaseModel):
status: str
purchase_token: str
payment_url: str | None = None
warning: str | None = None
class GiftPurchaseStatusResponse(BaseModel):
status: str
is_gift: bool = True
recipient_contact_value: str | None = None
gift_message: str | None = None
tariff_name: str | None = None
period_days: int | None = None
warning: str | None = None
class PendingGiftResponse(BaseModel):
token: str
tariff_name: str | None = None
period_days: int
gift_message: str | None = None
sender_display: str | None = None
created_at: datetime | None = None
+5 -4
View File
@@ -97,9 +97,9 @@ async def create_promo_group(
) -> PromoGroup:
normalized_period_discounts = _normalize_period_discounts(period_discounts)
auto_assign_total_spent_kopeks = (
max(0, auto_assign_total_spent_kopeks) if auto_assign_total_spent_kopeks is not None else None
)
if auto_assign_total_spent_kopeks is not None:
value = max(0, auto_assign_total_spent_kopeks)
auto_assign_total_spent_kopeks = value if value > 0 else None
existing_default = await get_default_promo_group(db)
should_be_default = existing_default is None or is_default
@@ -168,7 +168,8 @@ async def update_promo_group(
normalized_period_discounts = _normalize_period_discounts(period_discounts)
group.period_discounts = normalized_period_discounts or None
if auto_assign_total_spent_kopeks is not None:
group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks)
value = max(0, auto_assign_total_spent_kopeks)
group.auto_assign_total_spent_kopeks = value if value > 0 else None
if apply_discounts_to_addons is not None:
group.apply_discounts_to_addons = bool(apply_discounts_to_addons)
+5 -1
View File
@@ -2094,6 +2094,9 @@ async def get_disabled_daily_subscriptions_for_resume(
Subscription.status == SubscriptionStatus.DISABLED.value,
User.status == UserStatus.ACTIVE.value,
Subscription.is_trial.is_(False),
# Не возобновляем подписки, приостановленные пользователем вручную
# is_(False) не ловит NULL, поэтому добавляем OR is_(None)
(Subscription.is_daily_paused.is_(False) | Subscription.is_daily_paused.is_(None)),
# Баланс пользователя >= суточной цены тарифа
User.balance_kopeks >= Tariff.daily_price_kopeks,
)
@@ -2135,7 +2138,8 @@ async def get_expired_daily_subscriptions_for_recovery(db: AsyncSession) -> list
Tariff.is_active.is_(True),
Subscription.status == SubscriptionStatus.EXPIRED.value,
User.status == UserStatus.ACTIVE.value,
Subscription.is_daily_paused.is_(False),
# is_(False) не ловит NULL, поэтому добавляем OR is_(None)
(Subscription.is_daily_paused.is_(False) | Subscription.is_daily_paused.is_(None)),
Subscription.is_trial.is_(False),
# Только недавно экспайренные
Subscription.updated_at >= recovery_threshold,
+4 -2
View File
@@ -41,10 +41,12 @@ async def create_transaction(
*,
commit: bool = True,
) -> Transaction:
# SUBSCRIPTION_PAYMENT — always store as negative (debit from user balance)
# SUBSCRIPTION_PAYMENT / GIFT_PAYMENT — always store as negative (debit from user balance)
# Keep original for downstream consumers (events, contests)
stored_amount = (
-amount_kopeks if type == TransactionType.SUBSCRIPTION_PAYMENT and amount_kopeks > 0 else amount_kopeks
-amount_kopeks
if type in (TransactionType.SUBSCRIPTION_PAYMENT, TransactionType.GIFT_PAYMENT) and amount_kopeks > 0
else amount_kopeks
)
transaction = Transaction(
+4 -34
View File
@@ -449,40 +449,10 @@ async def add_user_balance(
amount_kopeks=amount_kopeks,
)
# Автоматическое возобновление приостановленной суточной подписки
try:
from app.database.crud.subscription import get_subscription_by_user_id, resume_daily_subscription
from app.database.crud.tariff import get_tariff_by_id
from app.database.models import SubscriptionStatus
# Загружаем подписку явно, чтобы избежать lazy loading
subscription = await get_subscription_by_user_id(db, user.id)
if subscription and subscription.status == SubscriptionStatus.DISABLED.value:
# Проверяем что это суточный тариф
is_daily = getattr(subscription, 'is_daily_tariff', False)
if is_daily and subscription.tariff_id:
# Загружаем тариф явно
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff:
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
# Если баланс достаточный для суточной оплаты - возобновляем
if daily_price > 0 and user.balance_kopeks >= daily_price:
await resume_daily_subscription(db, subscription)
logger.info(
'✅ Автоматически возобновлена суточная подписка после пополнения баланса (user_id=)',
subscription_id=subscription.id,
user_id=user.id,
)
# Синхронизируем с RemnaWave
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
except Exception as sync_err:
logger.warning('Не удалось синхронизировать с RemnaWave', sync_err=sync_err)
except Exception as resume_err:
logger.warning('Ошибка при попытке возобновить суточную подписку', resume_err=resume_err)
# Авто-возобновление суточной подписки НЕ делаем здесь —
# это обязанность try_resume_disabled_daily_after_topup (через send_cart_notification_after_topup)
# и DailySubscriptionService.process_auto_resume (30-минутный цикл).
# Они корректно списывают суточную плату при возобновлении.
return True
+10 -1
View File
@@ -133,6 +133,7 @@ class TransactionType(Enum):
REFUND = 'refund'
REFERRAL_REWARD = 'referral_reward'
POLL_REWARD = 'poll_reward'
GIFT_PAYMENT = 'gift_payment'
class PromoCodeType(Enum):
@@ -3077,6 +3078,10 @@ class GuestPurchase(Base):
Index('ix_guest_purchases_status', 'status'),
Index('ix_guest_purchases_contact', 'contact_type', 'contact_value'),
Index('ix_guest_purchases_landing_status_paid', 'landing_id', 'status', 'paid_at'),
Index('ix_guest_purchases_source', 'source'),
Index('ix_guest_purchases_user_gift_status', 'user_id', 'is_gift', 'status'),
Index('ix_guest_purchases_status_paid_at', 'status', 'paid_at'),
Index('ix_guest_purchases_buyer_user_id', 'buyer_user_id'),
)
id = Column(Integer, primary_key=True, index=True)
@@ -3085,6 +3090,8 @@ class GuestPurchase(Base):
contact_type = Column(String(20), nullable=False) # 'email' or 'telegram'
contact_value = Column(String(255), nullable=False)
is_gift = Column(Boolean, nullable=False, default=False)
source = Column(String(20), nullable=False, default='landing', server_default='landing') # 'landing' or 'cabinet'
buyer_user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
gift_recipient_type = Column(String(20), nullable=True)
gift_recipient_value = Column(String(255), nullable=True)
gift_message = Column(Text, nullable=True)
@@ -3103,10 +3110,12 @@ class GuestPurchase(Base):
delivered_at = Column(AwareDateTime(), nullable=True)
cabinet_password = Column(Text, nullable=True)
auto_login_token = Column(Text, nullable=True)
recipient_warning = Column(String(50), nullable=True)
landing = relationship('LandingPage', back_populates='guest_purchases', lazy='selectin')
tariff = relationship('Tariff', lazy='selectin')
user = relationship('User', lazy='selectin')
user = relationship('User', foreign_keys=[user_id], lazy='selectin')
buyer = relationship('User', foreign_keys=[buyer_user_id], lazy='selectin')
def __repr__(self) -> str:
token_prefix = self.token[:5] if self.token else '?'
+49 -16
View File
@@ -2275,7 +2275,7 @@ async def start_edit_tariff_squads(
await callback.answer('Тариф не найден', show_alert=True)
return
squads, _ = await get_all_server_squads(db)
squads, _ = await get_all_server_squads(db, limit=10000)
if not squads:
await callback.answer('Нет доступных серверов', show_alert=True)
@@ -2291,7 +2291,7 @@ async def start_edit_tariff_squads(
[
InlineKeyboardButton(
text=f'{prefix} {squad.display_name}',
callback_data=f'admin_tariff_toggle_squad:{tariff_id}:{squad.squad_uuid}',
callback_data=f'trf_sq:{tariff_id}:{squad.squad_uuid}',
)
]
)
@@ -2344,7 +2344,7 @@ async def toggle_tariff_squad(
tariff = await update_tariff(db, tariff, allowed_squads=list(current_squads))
# Перерисовываем меню
squads, _ = await get_all_server_squads(db)
squads, _ = await get_all_server_squads(db, limit=10000)
texts = get_texts(db_user.language)
buttons = []
@@ -2355,7 +2355,7 @@ async def toggle_tariff_squad(
[
InlineKeyboardButton(
text=f'{prefix} {squad.display_name}',
callback_data=f'admin_tariff_toggle_squad:{tariff_id}:{squad.squad_uuid}',
callback_data=f'trf_sq:{tariff_id}:{squad.squad_uuid}',
)
]
)
@@ -2382,6 +2382,15 @@ async def toggle_tariff_squad(
await callback.answer()
# Применяем изменения серверов к существующим подпискам
from app.services.subscription_service import SubscriptionService
propagate_result = await SubscriptionService().propagate_tariff_squads(db, tariff.id, list(current_squads))
if propagate_result.failed_ids:
await callback.message.answer(
f'⚠️ {len(propagate_result.failed_ids)} из {propagate_result.total} подписок не синхронизированы с RemnaWave',
)
@admin_required
@error_handler
@@ -2402,7 +2411,7 @@ async def clear_tariff_squads(
await callback.answer('Все серверы очищены')
# Перерисовываем меню
squads, _ = await get_all_server_squads(db)
squads, _ = await get_all_server_squads(db, limit=10000)
texts = get_texts(db_user.language)
buttons = []
@@ -2411,7 +2420,7 @@ async def clear_tariff_squads(
[
InlineKeyboardButton(
text=f'{squad.display_name}',
callback_data=f'admin_tariff_toggle_squad:{tariff_id}:{squad.squad_uuid}',
callback_data=f'trf_sq:{tariff_id}:{squad.squad_uuid}',
)
]
)
@@ -2436,6 +2445,15 @@ async def clear_tariff_squads(
except TelegramBadRequest:
pass
# Применяем изменения серверов к существующим подпискам (пустой список = все серверы)
from app.services.subscription_service import SubscriptionService
propagate_result = await SubscriptionService().propagate_tariff_squads(db, tariff.id, [])
if propagate_result.failed_ids:
await callback.message.answer(
f'⚠️ {len(propagate_result.failed_ids)} из {propagate_result.total} подписок не синхронизированы с RemnaWave',
)
@admin_required
@error_handler
@@ -2452,8 +2470,8 @@ async def select_all_tariff_squads(
await callback.answer('Тариф не найден', show_alert=True)
return
squads, _ = await get_all_server_squads(db)
all_uuids = [s.squad_uuid for s in squads]
squads, _ = await get_all_server_squads(db, limit=10000)
all_uuids = [s.squad_uuid for s in squads if s.squad_uuid]
tariff = await update_tariff(db, tariff, allowed_squads=all_uuids)
await callback.answer('Все серверы выбраны')
@@ -2466,7 +2484,7 @@ async def select_all_tariff_squads(
[
InlineKeyboardButton(
text=f'{squad.display_name}',
callback_data=f'admin_tariff_toggle_squad:{tariff_id}:{squad.squad_uuid}',
callback_data=f'trf_sq:{tariff_id}:{squad.squad_uuid}',
)
]
)
@@ -2491,6 +2509,15 @@ async def select_all_tariff_squads(
except TelegramBadRequest:
pass
# Применяем изменения серверов к существующим подпискам
from app.services.subscription_service import SubscriptionService
propagate_result = await SubscriptionService().propagate_tariff_squads(db, tariff.id, all_uuids)
if propagate_result.failed_ids:
await callback.message.answer(
f'⚠️ {len(propagate_result.failed_ids)} из {propagate_result.total} подписок не синхронизированы с RemnaWave',
)
# ============ РЕДАКТИРОВАНИЕ ПРОМОГРУПП ============
@@ -2799,7 +2826,13 @@ def register_handlers(dp: Dispatcher):
# Просмотр и переключение
dp.callback_query.register(view_tariff, F.data.startswith('admin_tariff_view:'))
dp.callback_query.register(
toggle_tariff, F.data.startswith('admin_tariff_toggle:') & ~F.data.startswith('admin_tariff_toggle_trial:')
toggle_tariff,
F.data.startswith('admin_tariff_toggle:')
& ~F.data.startswith('admin_tariff_toggle_trial:')
& ~F.data.startswith('trf_sq:')
& ~F.data.startswith('admin_tariff_toggle_promo:')
& ~F.data.startswith('admin_tariff_toggle_traffic_topup:')
& ~F.data.startswith('admin_tariff_toggle_daily:'),
)
dp.callback_query.register(toggle_trial_tariff, F.data.startswith('admin_tariff_toggle_trial:'))
@@ -2821,7 +2854,8 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(start_edit_tariff_description, F.data.startswith('admin_tariff_edit_desc:'))
dp.message.register(process_edit_tariff_description, AdminStates.editing_tariff_description)
# Редактирование трафика
# Редактирование трафика (traffic_topup BEFORE traffic to avoid prefix conflict)
dp.callback_query.register(start_edit_tariff_traffic_topup, F.data.startswith('admin_tariff_edit_traffic_topup:'))
dp.callback_query.register(start_edit_tariff_traffic, F.data.startswith('admin_tariff_edit_traffic:'))
dp.message.register(process_edit_tariff_traffic, AdminStates.editing_tariff_traffic)
@@ -2849,8 +2883,7 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(start_edit_tariff_trial_days, F.data.startswith('admin_tariff_edit_trial_days:'))
dp.message.register(process_edit_tariff_trial_days, AdminStates.editing_tariff_trial_days)
# Редактирование докупки трафика
dp.callback_query.register(start_edit_tariff_traffic_topup, F.data.startswith('admin_tariff_edit_traffic_topup:'))
# Редактирование докупки трафика (start_edit_tariff_traffic_topup registered above with traffic)
dp.callback_query.register(toggle_tariff_traffic_topup, F.data.startswith('admin_tariff_toggle_traffic_topup:'))
dp.callback_query.register(
start_edit_traffic_topup_packages, F.data.startswith('admin_tariff_edit_topup_packages:')
@@ -2861,13 +2894,13 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(start_edit_max_topup_traffic, F.data.startswith('admin_tariff_edit_max_topup:'))
dp.message.register(process_edit_max_topup_traffic, AdminStates.editing_tariff_max_topup_traffic)
# Удаление
dp.callback_query.register(confirm_delete_tariff, F.data.startswith('admin_tariff_delete:'))
# Удаление (delete_confirm BEFORE delete to avoid prefix conflict)
dp.callback_query.register(delete_tariff_confirmed, F.data.startswith('admin_tariff_delete_confirm:'))
dp.callback_query.register(confirm_delete_tariff, F.data.startswith('admin_tariff_delete:'))
# Редактирование серверов
dp.callback_query.register(start_edit_tariff_squads, F.data.startswith('admin_tariff_edit_squads:'))
dp.callback_query.register(toggle_tariff_squad, F.data.startswith('admin_tariff_toggle_squad:'))
dp.callback_query.register(toggle_tariff_squad, F.data.startswith('trf_sq:'))
dp.callback_query.register(clear_tariff_squads, F.data.startswith('admin_tariff_clear_squads:'))
dp.callback_query.register(select_all_tariff_squads, F.data.startswith('admin_tariff_select_all_squads:'))
+9 -33
View File
@@ -19,7 +19,6 @@ from app.database.crud.campaign import (
from app.database.crud.promo_group import get_promo_groups_with_counts
from app.database.crud.server_squad import (
get_all_server_squads,
get_server_ids_by_uuids,
get_server_squad_by_id,
get_server_squad_by_uuid,
)
@@ -1019,9 +1018,9 @@ async def delete_user_account(callback: types.CallbackQuery, db_user: User, db:
user_id = int(callback.data.split('_')[-1])
user_service = UserService()
success = await user_service.delete_user_account(db, user_id, db_user.id)
delete_result = await user_service.delete_user_account(db, user_id, db_user.id)
if success:
if delete_result.bot_deleted:
await callback.message.edit_text(
'✅ Пользователь успешно удален',
reply_markup=types.InlineKeyboardMarkup(
@@ -4168,44 +4167,21 @@ async def _calculate_subscription_period_price(
service = subscription_service or SubscriptionService()
connected_squads = list(subscription.connected_squads or [])
server_ids = []
if connected_squads:
# Загружаем тариф для корректного расчёта в тарифном режиме
if subscription.tariff_id:
try:
server_ids = await get_server_ids_by_uuids(db, connected_squads)
if len(server_ids) != len(connected_squads):
logger.warning(
'Не удалось сопоставить все сервера подписки пользователя для расчёта цены',
telegram_id=target_user.telegram_id,
)
await db.refresh(subscription, ['tariff'])
except Exception as e:
logger.error(
'Не удалось получить идентификаторы серверов для расчёта цены подписки пользователя',
telegram_id=target_user.telegram_id,
e=e,
)
server_ids = []
traffic_limit_gb = subscription.traffic_limit_gb
if traffic_limit_gb is None:
traffic_limit_gb = settings.DEFAULT_TRAFFIC_LIMIT_GB
logger.warning('Не удалось загрузить тариф для расчёта цены', error=e)
device_limit = subscription.device_limit
if not device_limit or device_limit < 0:
device_limit = settings.DEFAULT_DEVICE_LIMIT
total_price, _ = await service.calculate_subscription_price(
return await service.calculate_renewal_price(
subscription=subscription,
period_days=period_days,
traffic_gb=traffic_limit_gb,
server_squad_ids=server_ids,
devices=device_limit,
db=db,
user=target_user,
promo_group=target_user.promo_group,
promo_group=getattr(target_user, 'promo_group', None),
)
return total_price
@admin_required
@error_handler
+78 -46
View File
@@ -439,68 +439,100 @@ async def show_payment_methods(callback: types.CallbackQuery, db_user: User, db:
from app.database.crud.tariff import get_tariff_by_id
# В режиме тарифов берём цену из тарифа пользователя
tariff = None
tariff_price_found = False
base_price_original = 0
if settings.is_tariffs_mode() and subscription.tariff_id:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff and tariff.period_prices:
base_price_original = tariff.period_prices.get(str(duration_days), 0)
if base_price_original > 0:
tariff_price_found = True
# Если не нашли в тарифе - используем PERIOD_PRICES
if base_price_original <= 0:
base_price_original = PERIOD_PRICES.get(duration_days, 0)
period_discount_percent = db_user.get_promo_discount('period', duration_days)
base_price, base_discount_total = apply_percentage_discount(
base_price_original,
period_discount_percent,
)
if tariff_price_found:
# Тарифный режим: серверы и трафик включены в цену тарифа.
# Порядок: база + устройства → скидка на полную сумму (как в calculate_renewal_price).
from app.utils.promo_offer import get_user_active_promo_discount_percent
# Рассчитываем стоимость серверов
from app.services.subscription_service import SubscriptionService
original_price = base_price_original
subscription_service = SubscriptionService()
(
servers_price_per_month,
per_server_monthly_prices,
) = await subscription_service.get_countries_price_by_uuids(
current_connected_squads,
db,
promo_group_id=db_user.promo_group_id,
)
servers_discount_percent = db_user.get_promo_discount('servers', duration_days)
total_servers_price = 0
for server_price in per_server_monthly_prices:
discounted_per_month, discount_per_month = apply_percentage_discount(
server_price,
servers_discount_percent,
tariff_device_limit = tariff.device_limit if tariff.device_limit is not None else 0
device_limit = (
subscription.device_limit if subscription.device_limit is not None else tariff_device_limit
)
total_servers_price += discounted_per_month
extra_devices = max(0, device_limit - tariff_device_limit)
device_price_per_unit = (
tariff.device_price_kopeks
if tariff and tariff.device_price_kopeks is not None
else settings.PRICE_PER_DEVICE
)
months_in_period = calculate_months_from_days(duration_days)
devices_price = extra_devices * device_price_per_unit * months_in_period
original_price += devices_price
# Рассчитываем стоимость трафика
traffic_price_per_month = settings.get_traffic_price(current_traffic)
traffic_discount_percent = db_user.get_promo_discount('traffic', duration_days)
traffic_discounted_per_month, traffic_discount_per_month = apply_percentage_discount(
traffic_price_per_month,
traffic_discount_percent,
)
# Скидка промогруппы на полную сумму (база + устройства)
period_discount_percent = db_user.get_promo_discount('period', duration_days)
discount_total = original_price * period_discount_percent // 100
total_price = original_price - discount_total
# Рассчитываем стоимость устройств
additional_devices = max(0, (current_device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_discount_percent = db_user.get_promo_discount('devices', duration_days)
devices_discounted_per_month, devices_discount_per_month = apply_percentage_discount(
devices_price_per_month,
devices_discount_percent,
)
# Promo-offer скидка (временная)
promo_offer_percent = get_user_active_promo_discount_percent(db_user)
if promo_offer_percent > 0:
promo_offer_discount = total_price * promo_offer_percent // 100
total_price = total_price - promo_offer_discount
else:
# Классический режим: серверы + трафик + устройства считаются отдельно
period_discount_percent = db_user.get_promo_discount('period', duration_days)
base_price, base_discount_total = apply_percentage_discount(
base_price_original,
period_discount_percent,
)
from app.services.subscription_service import SubscriptionService
# Общая стоимость
months_in_period = calculate_months_from_days(duration_days)
total_price = (
base_price
+ total_servers_price * months_in_period
+ traffic_discounted_per_month * months_in_period
+ devices_discounted_per_month * months_in_period
)
subscription_service = SubscriptionService()
(
servers_price_per_month,
per_server_monthly_prices,
) = await subscription_service.get_countries_price_by_uuids(
current_connected_squads,
db,
promo_group_id=db_user.promo_group_id,
)
servers_discount_percent = db_user.get_promo_discount('servers', duration_days)
total_servers_price = 0
for server_price in per_server_monthly_prices:
discounted_per_month, discount_per_month = apply_percentage_discount(
server_price,
servers_discount_percent,
)
total_servers_price += discounted_per_month
traffic_price_per_month = settings.get_traffic_price(current_traffic)
traffic_discount_percent = db_user.get_promo_discount('traffic', duration_days)
traffic_discounted_per_month, traffic_discount_per_month = apply_percentage_discount(
traffic_price_per_month,
traffic_discount_percent,
)
additional_devices = max(0, (current_device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_discount_percent = db_user.get_promo_discount('devices', duration_days)
devices_discounted_per_month, devices_discount_per_month = apply_percentage_discount(
devices_price_per_month,
devices_discount_percent,
)
months_in_period = calculate_months_from_days(duration_days)
total_price = (
base_price
+ total_servers_price * months_in_period
+ traffic_discounted_per_month * months_in_period
+ devices_discounted_per_month * months_in_period
)
traffic_value = current_traffic or 0
if traffic_value <= 0:
+40 -21
View File
@@ -1295,32 +1295,48 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
best_period = None
best_price = 0
for period in available_periods:
price, _ = await subscription_service.calculate_subscription_price_with_months(
period, traffic_limit_gb, server_ids, device_limit, db, user=db_user
)
if price <= balance:
best_period = period
best_price = price
break
# Для продления используем тот же сервис, что и при реальном списании,
# чтобы сумма проверки совпадала с суммой списания.
renewal_service = SubscriptionRenewalService() if subscription else None
if not best_period:
# Показать сколько не хватает для минимального периода
min_period = min(available_periods) if available_periods else 30
min_price, _ = await subscription_service.calculate_subscription_price_with_months(
min_period, traffic_limit_gb, server_ids, device_limit, db, user=db_user
)
missing = min_price - balance
await callback.answer(
texts.t('INSUFFICIENT_FUNDS_DETAILED', f'❌ Недостаточно средств. Не хватает {missing // 100}'),
show_alert=True,
)
try:
for period in available_periods:
if subscription and renewal_service:
pricing = await renewal_service.calculate_pricing(db, db_user, subscription, period)
price = pricing.final_total
else:
price, _ = await subscription_service.calculate_subscription_price_with_months(
period, traffic_limit_gb, server_ids, device_limit, db, user=db_user
)
if price <= balance:
best_period = period
best_price = price
break
if not best_period:
# Показать сколько не хватает для минимального периода
min_period = min(available_periods) if available_periods else 30
if subscription and renewal_service:
pricing = await renewal_service.calculate_pricing(db, db_user, subscription, min_period)
min_price = pricing.final_total
else:
min_price, _ = await subscription_service.calculate_subscription_price_with_months(
min_period, traffic_limit_gb, server_ids, device_limit, db, user=db_user
)
missing = min_price - balance
await callback.answer(
texts.t('INSUFFICIENT_FUNDS_DETAILED', f'❌ Недостаточно средств. Не хватает {missing // 100}'),
show_alert=True,
)
return
except Exception as e:
logger.error('Ошибка расчёта стоимости при активации', error=e)
await callback.answer('❌ Ошибка расчёта стоимости', show_alert=True)
return
try:
if subscription:
# Продление существующей подписки
renewal_service = SubscriptionRenewalService()
pricing = await renewal_service.calculate_pricing(db, db_user, subscription, best_period)
await renewal_service.finalize(
@@ -1333,7 +1349,10 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
)
await callback.answer(
texts.t('ACTIVATION_SUCCESS', f'✅ Подписка продлена на {best_period} дней за {best_price // 100} ₽!'),
texts.t(
'ACTIVATION_SUCCESS',
f'✅ Подписка продлена на {best_period} дней за {pricing.final_total // 100} ₽!',
),
show_alert=True,
)
else:
+4 -4
View File
@@ -1958,7 +1958,7 @@ async def get_main_menu_text_simple(user_name, texts, db: AsyncSession):
async def required_sub_channel_check(
query: types.CallbackQuery, bot: Bot, state: FSMContext, db: AsyncSession, db_user=None
):
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
from app.utils.message_patch import _cache_logo_file_id, caption_exceeds_telegram_limit, get_logo_media
language = DEFAULT_LANGUAGE
texts = get_texts(language)
@@ -2129,7 +2129,7 @@ async def required_sub_channel_check(
if pinned_message and pinned_message.send_before_menu:
await _send_pinned_message(bot, db, user, pinned_message)
if settings.ENABLE_LOGO_MODE and len(menu_text) <= 900:
if settings.ENABLE_LOGO_MODE and not caption_exceeds_telegram_limit(menu_text):
_result = await bot.send_photo(
chat_id=query.from_user.id,
photo=get_logo_media(),
@@ -2255,7 +2255,7 @@ async def required_sub_channel_check(
if pinned_message and pinned_message.send_before_menu:
await _send_pinned_message(bot, db, user, pinned_message)
if settings.ENABLE_LOGO_MODE and len(menu_text) <= 900:
if settings.ENABLE_LOGO_MODE and not caption_exceeds_telegram_limit(menu_text):
_result = await bot.send_photo(
chat_id=query.from_user.id,
photo=get_logo_media(),
@@ -2286,7 +2286,7 @@ async def required_sub_channel_check(
else:
rules_text = await get_rules(language)
if settings.ENABLE_LOGO_MODE and len(rules_text) <= 900:
if settings.ENABLE_LOGO_MODE and not caption_exceeds_telegram_limit(rules_text):
_result = await bot.send_photo(
chat_id=query.from_user.id,
photo=get_logo_media(),
+35 -9
View File
@@ -276,12 +276,19 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
)
return
if settings.MAX_DEVICES_LIMIT > 0 and new_devices_count > settings.MAX_DEVICES_LIMIT:
# Используем max_device_limit из тарифа если есть, иначе глобальную настройку
tariff_max_devices = getattr(tariff, 'max_device_limit', None) if tariff else None
effective_max = (
tariff_max_devices
if tariff_max_devices
else (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
)
if effective_max and new_devices_count > effective_max:
await callback.answer(
texts.t(
'DEVICES_LIMIT_EXCEEDED',
'⚠️ Превышен максимальный лимит устройств ({limit})',
).format(limit=settings.MAX_DEVICES_LIMIT),
).format(limit=effective_max),
show_alert=True,
)
return
@@ -564,8 +571,13 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
# Re-validate: prevent double-charge and max-limit violation
if new_devices_count > current_devices:
max_devices = settings.MAX_DEVICES_LIMIT
if max_devices > 0 and new_devices_count > max_devices:
tariff_max_recheck = getattr(tariff, 'max_device_limit', None) if tariff else None
max_devices = (
tariff_max_recheck
if tariff_max_recheck
else (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
)
if max_devices and new_devices_count > max_devices:
if price > 0:
user_refund = await db.execute(
select(User)
@@ -1126,10 +1138,19 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
new_total_devices = subscription.device_limit + devices_count
if settings.MAX_DEVICES_LIMIT > 0 and new_total_devices > settings.MAX_DEVICES_LIMIT:
# Используем max_device_limit из тарифа если есть, иначе глобальную настройку
tariff_max_devices = getattr(tariff, 'max_device_limit', None) if tariff else None
effective_max = (
tariff_max_devices
if tariff_max_devices
else (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
)
if effective_max and new_total_devices > effective_max:
await callback.answer(
f'⚠️ Превышен максимальный лимит устройств ({settings.MAX_DEVICES_LIMIT}). '
f'У вас: {subscription.device_limit}, добавляете: {devices_count}',
texts.t(
'DEVICES_LIMIT_EXCEEDED_DETAIL',
'⚠️ Превышен максимальный лимит устройств ({limit}). У вас: {current}, добавляете: {adding}',
).format(limit=effective_max, current=subscription.device_limit, adding=devices_count),
show_alert=True,
)
return
@@ -1257,8 +1278,13 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
# Re-validate max device limit after re-lock
actual_current = subscription.device_limit or 1
actual_new = actual_current + devices_count
max_devices = settings.MAX_DEVICES_LIMIT
if max_devices > 0 and actual_new > max_devices:
tariff_max_recheck = getattr(tariff, 'max_device_limit', None) if tariff else None
max_devices = (
tariff_max_recheck
if tariff_max_recheck
else (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
)
if max_devices and actual_new > max_devices:
# Concurrent purchase exceeded limit — refund
user_refund = await db.execute(
select(User).where(User.id == db_user.id).with_for_update().execution_options(populate_existing=True)
+84 -48
View File
@@ -309,11 +309,11 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
return 0
from app.config import settings
from app.database.crud.tariff import get_tariff_by_id
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
base_cost_original = PERIOD_PRICES.get(30, 0)
try:
owner = subscription.user
except AttributeError:
@@ -321,65 +321,101 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
promo_group_id = getattr(owner, 'promo_group_id', None) if owner else None
period_discount_percent = 0
if owner:
# В тарифном режиме цена тарифа уже включает серверы и трафик
tariff = None
tariff_price_found = False
if settings.is_tariffs_mode() and subscription.tariff_id:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff and tariff.period_prices:
base_cost_original = tariff.period_prices.get('30', 0) or tariff.period_prices.get(30, 0)
if base_cost_original > 0:
tariff_price_found = True
if not tariff_price_found:
base_cost_original = PERIOD_PRICES.get(30, 0)
if tariff_price_found:
# Тарифный режим: серверы и трафик включены в цену.
# Порядок: база + устройства → скидка на полную сумму (как в calculate_renewal_price).
from app.utils.promo_offer import get_user_active_promo_discount_percent
original_price = base_cost_original
tariff_device_limit = tariff.device_limit if tariff.device_limit is not None else 0
device_limit = subscription.device_limit if subscription.device_limit is not None else tariff_device_limit
extra_devices = max(0, device_limit - tariff_device_limit)
device_price_per_unit = (
tariff.device_price_kopeks
if tariff and tariff.device_price_kopeks is not None
else settings.PRICE_PER_DEVICE
)
devices_price = extra_devices * device_price_per_unit
original_price += devices_price
# Скидка промогруппы на полную сумму (база + устройства)
period_discount_percent = 0
if owner:
try:
period_discount_percent = owner.get_promo_discount('period', 30)
except AttributeError:
pass
discount_total = original_price * period_discount_percent // 100
total_cost = original_price - discount_total
# Promo-offer скидка (временная)
promo_offer_percent = get_user_active_promo_discount_percent(owner)
if promo_offer_percent > 0:
promo_offer_discount = total_cost * promo_offer_percent // 100
total_cost = total_cost - promo_offer_discount
else:
# Классический режим: серверы + трафик + устройства считаются отдельно
period_discount_percent = 0
if owner:
try:
period_discount_percent = owner.get_promo_discount('period', 30)
except AttributeError:
period_discount_percent = 0
base_cost, _ = apply_percentage_discount(
base_cost_original,
period_discount_percent,
)
try:
period_discount_percent = owner.get_promo_discount('period', 30)
servers_cost, _ = await subscription_service.get_countries_price_by_uuids(
subscription.connected_squads,
db,
promo_group_id=promo_group_id,
)
except AttributeError:
period_discount_percent = 0
servers_cost, _ = await get_countries_price_by_uuids_fallback(
subscription.connected_squads,
db,
promo_group_id=promo_group_id,
)
base_cost, _ = apply_percentage_discount(
base_cost_original,
period_discount_percent,
)
try:
servers_cost, _ = await subscription_service.get_countries_price_by_uuids(
subscription.connected_squads,
db,
promo_group_id=promo_group_id,
)
except AttributeError:
servers_cost, _ = await get_countries_price_by_uuids_fallback(
subscription.connected_squads,
db,
promo_group_id=promo_group_id,
)
traffic_cost = settings.get_traffic_price(subscription.traffic_limit_gb)
device_limit = subscription.device_limit
if device_limit is None:
if settings.is_devices_selection_enabled():
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
forced_limit = settings.get_disabled_mode_device_limit()
if forced_limit is None:
traffic_cost = settings.get_traffic_price(subscription.traffic_limit_gb)
device_limit = subscription.device_limit
if device_limit is None:
if settings.is_devices_selection_enabled():
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
device_limit = forced_limit
forced_limit = settings.get_disabled_mode_device_limit()
if forced_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
device_limit = forced_limit
devices_cost = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
devices_cost = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
total_cost = base_cost + servers_cost + traffic_cost + devices_cost
total_cost = base_cost + servers_cost + traffic_cost + devices_cost
logger.info('📊 Месячная стоимость конфигурации подписки', subscription_id=subscription.id)
base_log = f' 📅 Базовый тариф (30 дней): {base_cost_original / 100}'
if period_discount_percent > 0:
discount_value = base_cost_original * period_discount_percent // 100
base_log += f'{base_cost / 100}₽ (скидка {period_discount_percent}%: -{discount_value / 100}₽)'
logger.info(base_log)
if servers_cost > 0:
logger.info('🌍 Серверы: ₽', servers_cost=servers_cost / 100)
if traffic_cost > 0:
logger.info('📊 Трафик: ₽', traffic_cost=traffic_cost / 100)
if devices_cost > 0:
logger.info('📱 Устройства: ₽', devices_cost=devices_cost / 100)
logger.info('💎 ИТОГО: ₽', total_cost=total_cost / 100)
logger.info('Месячная стоимость подписки', subscription_id=subscription.id, total_cost_kopeks=total_cost)
return total_cost
except Exception as e:
logger.error('⚠️ Ошибка расчета стоимости подписки', error=e)
logger.error('Ошибка расчета стоимости подписки', error=e)
return 0
+41 -1
View File
@@ -208,7 +208,11 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d
current_time = datetime.now(UTC)
if subscription.status == 'expired' or subscription.end_date <= current_time:
if subscription.status == 'disabled':
actual_status = 'disabled'
status_display = texts.t('SUBSCRIPTION_STATUS_DISABLED', 'Приостановлена')
status_emoji = '⏸️'
elif subscription.status == 'expired' or subscription.end_date <= current_time:
actual_status = 'expired'
status_display = texts.t('SUBSCRIPTION_STATUS_EXPIRED', 'Истекла')
status_emoji = '🔴'
@@ -3222,6 +3226,42 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
return
if needs_resume:
# Списываем суточную оплату ДО активации (чтобы не было бесплатного дня)
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
if daily_price > 0 and is_inactive:
from app.database.crud.user import subtract_user_balance
deducted = await subtract_user_balance(
db,
db_user,
daily_price,
f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
mark_as_paid_subscription=True,
)
if not deducted:
await callback.answer(
texts.t(
'INSUFFICIENT_BALANCE_FOR_RESUME',
f'❌ Недостаточно средств для возобновления. Требуется: {settings.format_price(daily_price)}',
),
show_alert=True,
)
return
from app.database.crud.transaction import create_transaction
from app.database.models import TransactionType
try:
await create_transaction(
db=db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=daily_price,
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
)
except Exception as tx_error:
logger.warning('Не удалось создать транзакцию при возобновлении', error=tx_error)
# Принудительный resume: снимаем паузу + восстанавливаем статус ACTIVE
from app.database.crud.subscription import resume_daily_subscription
+142 -69
View File
@@ -332,6 +332,32 @@ def get_language_selection_keyboard(
return InlineKeyboardMarkup(inline_keyboard=buttons)
def _get_balance_text(cached_styles: dict, language: str, texts, balance_kopeks: int) -> str:
"""Build balance button text with formatting."""
bal_cfg = cached_styles.get('balance', {})
safe_balance = balance_kopeks or 0
# Custom label overrides the whole text including balance amount
custom_bal = bal_cfg.get('labels', {}).get(language, '')
if custom_bal:
return custom_bal
if hasattr(texts, 'BALANCE_BUTTON') and safe_balance > 0:
return texts.BALANCE_BUTTON.format(balance=texts.format_price(safe_balance))
return texts.t('BALANCE_BUTTON_DEFAULT', '💰 Баланс: {balance}').format(
balance=texts.format_price(safe_balance),
)
def _is_support_enabled() -> bool:
"""Check if support menu is enabled."""
try:
from app.services.support_settings_service import SupportSettingsService
return SupportSettingsService.is_support_menu_enabled()
except Exception:
return settings.SUPPORT_MENU_ENABLED
def _build_cabinet_main_menu_keyboard(
language: str,
texts,
@@ -342,10 +368,12 @@ def _build_cabinet_main_menu_keyboard(
) -> InlineKeyboardMarkup:
"""Build the main-menu keyboard for Cabinet mode.
Each button opens the corresponding section of the cabinet frontend
via ``MINIAPP_CUSTOM_URL`` + path (e.g. ``/subscription``, ``/balance``).
Row layout and button arrangement are driven by the cached menu layout
(``get_cached_menu_layout``). Each row specifies which buttons it contains
and how many fit per keyboard row (``max_per_row``).
"""
from app.utils.button_styles_cache import CALLBACK_TO_SECTION, get_cached_button_styles
from app.utils.menu_layout_cache import get_cached_menu_layout
from app.utils.miniapp_buttons import (
CALLBACK_TO_CABINET_STYLE,
_resolve_style,
@@ -354,6 +382,8 @@ def _build_cabinet_main_menu_keyboard(
global_style = _resolve_style((settings.CABINET_BUTTON_STYLE or '').strip())
cached_styles = get_cached_button_styles()
layout = get_cached_menu_layout()
custom_buttons_cfg: dict[str, dict] = layout.get('custom_buttons', {})
def _cabinet_button(
text: str,
@@ -385,84 +415,127 @@ def _build_cabinet_main_menu_keyboard(
)
return InlineKeyboardButton(text=text, callback_data=callback_fallback)
# -- Primary action row: Cabinet home --
home_cfg = cached_styles.get('home', {})
if home_cfg.get('enabled', True):
profile_text = home_cfg.get('labels', {}).get(language, '') or texts.t('MENU_PROFILE', '👤 Личный кабинет')
keyboard_rows: list[list[InlineKeyboardButton]] = [
[_cabinet_button(profile_text, '/', 'menu_profile_unavailable')],
]
else:
keyboard_rows: list[list[InlineKeyboardButton]] = []
# -- Collect row definitions sorted by row_N key --
row_keys = sorted(
(k for k in layout if k.startswith('row_')),
key=lambda k: int(k.split('_', 1)[1]) if k.split('_', 1)[1].isdigit() else 0,
)
# -- Section buttons as paired rows --
paired: list[InlineKeyboardButton] = []
keyboard_rows: list[list[InlineKeyboardButton]] = []
# Subscription (green — main action)
sub_cfg = cached_styles.get('subscription', {})
if sub_cfg.get('enabled', True):
sub_text = sub_cfg.get('labels', {}).get(language, '') or texts.MENU_SUBSCRIPTION
paired.append(_cabinet_button(sub_text, '/subscription', 'menu_subscription'))
for row_key in row_keys:
row_def = layout[row_key]
btn_ids: list[str] = row_def.get('buttons', [])
max_per_row: int = row_def.get('max_per_row', 1)
row_buttons: list[InlineKeyboardButton] = []
# Balance
bal_cfg = cached_styles.get('balance', {})
if bal_cfg.get('enabled', True):
safe_balance = balance_kopeks or 0
# Custom label overrides the whole text including balance amount
custom_bal = bal_cfg.get('labels', {}).get(language, '')
if custom_bal:
balance_text = custom_bal
elif hasattr(texts, 'BALANCE_BUTTON') and safe_balance > 0:
balance_text = texts.BALANCE_BUTTON.format(balance=texts.format_price(safe_balance))
else:
balance_text = texts.t('BALANCE_BUTTON_DEFAULT', '💰 Баланс: {balance}').format(
balance=texts.format_price(safe_balance),
)
paired.append(_cabinet_button(balance_text, '/balance', 'menu_balance'))
for btn_id in btn_ids:
# --- Custom URL buttons ---
if btn_id.startswith('custom_'):
custom_cfg = custom_buttons_cfg.get(btn_id)
if not custom_cfg or not custom_cfg.get('url') or not custom_cfg.get('enabled', True):
continue
custom_text = (
custom_cfg.get('labels', {}).get(language, '')
or custom_cfg.get('labels', {}).get('ru', '')
or 'Link'
)
resolved_style = _resolve_style(custom_cfg.get('style'))
resolved_emoji = custom_cfg.get('icon_custom_emoji_id') or None
open_in = custom_cfg.get('open_in', 'external')
link_kwarg = (
{'web_app': types.WebAppInfo(url=custom_cfg['url'])}
if open_in == 'webapp'
else {'url': custom_cfg['url']}
)
row_buttons.append(
InlineKeyboardButton(
text=custom_text,
**link_kwarg,
style=resolved_style,
icon_custom_emoji_id=resolved_emoji,
),
)
continue
# Referrals (if enabled)
ref_cfg = cached_styles.get('referral', {})
if settings.is_referral_program_enabled() and ref_cfg.get('enabled', True):
ref_text = ref_cfg.get('labels', {}).get(language, '') or texts.MENU_REFERRALS
paired.append(_cabinet_button(ref_text, '/referral', 'menu_referrals'))
# --- Built-in buttons ---
section_cfg = cached_styles.get(btn_id, {})
# Support
support_enabled = False
try:
from app.services.support_settings_service import SupportSettingsService
match btn_id:
case 'home':
if not section_cfg.get('enabled', True):
continue
home_text = section_cfg.get('labels', {}).get(language, '') or texts.t(
'MENU_PROFILE', '👤 Личный кабинет'
)
row_buttons.append(_cabinet_button(home_text, '/', 'menu_profile_unavailable'))
support_enabled = SupportSettingsService.is_support_menu_enabled()
except Exception:
support_enabled = settings.SUPPORT_MENU_ENABLED
case 'subscription':
if not section_cfg.get('enabled', True):
continue
sub_text = section_cfg.get('labels', {}).get(language, '') or texts.MENU_SUBSCRIPTION
row_buttons.append(_cabinet_button(sub_text, '/subscription', 'menu_subscription'))
sup_cfg = cached_styles.get('support', {})
if support_enabled and sup_cfg.get('enabled', True):
sup_text = sup_cfg.get('labels', {}).get(language, '') or texts.MENU_SUPPORT
paired.append(_cabinet_button(sup_text, '/support', 'menu_support'))
case 'balance':
if not section_cfg.get('enabled', True):
continue
balance_text = _get_balance_text(cached_styles, language, texts, balance_kopeks)
row_buttons.append(_cabinet_button(balance_text, '/balance', 'menu_balance'))
# Info
info_cfg = cached_styles.get('info', {})
if info_cfg.get('enabled', True):
info_text = info_cfg.get('labels', {}).get(language, '') or texts.t('MENU_INFO', '️ Инфо')
paired.append(_cabinet_button(info_text, '/info', 'menu_info'))
case 'referral':
if not settings.is_referral_program_enabled():
continue
if not section_cfg.get('enabled', True):
continue
ref_text = section_cfg.get('labels', {}).get(language, '') or texts.MENU_REFERRALS
row_buttons.append(_cabinet_button(ref_text, '/referral', 'menu_referrals'))
# Language selection (stays as callback — not a cabinet section)
if settings.is_language_selection_enabled():
paired.append(InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data='menu_language'))
case 'support':
if not _is_support_enabled():
continue
if not section_cfg.get('enabled', True):
continue
sup_text = section_cfg.get('labels', {}).get(language, '') or texts.MENU_SUPPORT
row_buttons.append(_cabinet_button(sup_text, '/support', 'menu_support'))
# Lay out in pairs
for i in range(0, len(paired), 2):
keyboard_rows.append(paired[i : i + 2])
case 'info':
if not section_cfg.get('enabled', True):
continue
info_text = section_cfg.get('labels', {}).get(language, '') or texts.t('MENU_INFO', '️ Инфо')
row_buttons.append(_cabinet_button(info_text, '/info', 'menu_info'))
# Admin / Moderator
admin_cfg = cached_styles.get('admin', {})
if is_admin:
admin_buttons = [InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data='admin_panel')]
if admin_cfg.get('enabled', True):
admin_web_text = admin_cfg.get('labels', {}).get(language, '') or '🖥 Веб-Админка'
admin_buttons.append(_cabinet_button(admin_web_text, '/admin', 'admin_panel'))
keyboard_rows.append(admin_buttons)
elif is_moderator:
case 'language':
if not section_cfg.get('enabled', True):
continue
if not settings.is_language_selection_enabled():
continue
lang_text = section_cfg.get('labels', {}).get(language, '') or texts.MENU_LANGUAGE
resolved_lang_emoji = section_cfg.get('icon_custom_emoji_id') or None
row_buttons.append(
InlineKeyboardButton(
text=lang_text,
callback_data='menu_language',
icon_custom_emoji_id=resolved_lang_emoji,
)
)
case 'admin':
if not is_admin:
continue
admin_row = [InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data='admin_panel')]
if section_cfg.get('enabled', True):
admin_web_text = section_cfg.get('labels', {}).get(language, '') or '🖥 Веб-Админка'
admin_row.append(_cabinet_button(admin_web_text, '/admin', 'admin_panel'))
keyboard_rows.append(admin_row)
continue # bypass max_per_row chunking
# Split collected buttons into keyboard rows respecting max_per_row
if row_buttons:
for i in range(0, len(row_buttons), max_per_row):
keyboard_rows.append(row_buttons[i : i + max_per_row])
# -- Moderator panel (only when not admin — admin row handled above) --
if is_moderator and not is_admin:
keyboard_rows.append([InlineKeyboardButton(text='🧑‍⚖️ Модерация', callback_data='moderator_panel')])
return InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
+1
View File
@@ -1704,6 +1704,7 @@
"DAILY_SWITCH_WARNING": "⚠️ <b>Warning!</b> You have {days} days left.\nThey will be lost when switching to daily tariff!",
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Subscription paused",
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Subscription resumed!",
"DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ <b>Subscription resumed!</b>\n\nYour daily plan «{tariff_name}» has been resumed after balance top-up.\n\n💳 Charged: {amount}\n💰 Remaining: {balance}",
"WEBHOOK_SUB_EXPIRED": "❌ <b>Subscription expired</b>\n\nYour subscription has ended. Renew to restore VPN access.",
"WEBHOOK_SUB_DISABLED": "🚫 <b>Subscription disabled</b>\n\nYour subscription has been disabled by the administrator.",
+1
View File
@@ -1723,6 +1723,7 @@
"DAILY_SWITCH_WARNING": "⚠️ <b>توجه!</b> {days} روز اشتراک باقی مانده.\nبا تغییر به تعرفه روزانه از دست می‌روند!",
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ اشتراک متوقف شد",
"DAILY_SUBSCRIPTION_RESUMED": "▶️ اشتراک از سر گرفته شد!",
"DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ <b>اشتراک از سر گرفته شد!</b>\n\nتعرفه روزانه «{tariff_name}» پس از شارژ موجودی از سر گرفته شد.\n\n💳 کسر شده: {amount}\n💰 باقی‌مانده: {balance}",
"WEBHOOK_SUB_EXPIRED": "❌ <b>اشتراک منقضی شد</b>\n\nاشتراک شما به پایان رسیده است. برای بازیابی دسترسی VPN تمدید کنید.",
"WEBHOOK_SUB_DISABLED": "🚫 <b>اشتراک غیرفعال شد</b>\n\nاشتراک شما توسط مدیر غیرفعال شده است.",
"WEBHOOK_SUB_ENABLED": "✅ <b>اشتراک فعال شد</b>\n\nاشتراک شما دوباره فعال است. از استفاده لذت ببرید!",
+1
View File
@@ -1725,6 +1725,7 @@
"DAILY_SWITCH_WARNING": "⚠️ <b>Внимание!</b> У вас осталось {days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!",
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Подписка приостановлена",
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Подписка возобновлена!",
"DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ <b>Подписка возобновлена!</b>\n\nВаш суточный тариф «{tariff_name}» возобновлён после пополнения баланса.\n\n💳 Списано: {amount}\n💰 Остаток: {balance}",
"WEBHOOK_SUB_EXPIRED": "❌ <b>Подписка истекла</b>\n\nВаша подписка завершена. Продлите подписку, чтобы восстановить доступ к VPN.",
"WEBHOOK_SUB_DISABLED": "🚫 <b>Подписка отключена</b>\n\nВаша подписка была отключена администратором.",
+3
View File
@@ -1592,6 +1592,9 @@
"MODEM_PRICE_WITH_DISCOUNT": "Вартість: <s>{base_price}</s> <b>{final_price}</b> (за {months} міс)\n🎁 Знижка {discount}%: -{discount_amount}",
"MODEM_PRICE_NO_DISCOUNT": "Вартість: {price} (за {months} міс)",
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>Підтвердження підключення модема</b>\n\n{price_text}\n\nПри підключенні модема:\n• До підписки додасться додатковий пристрій\n• Щомісячна плата збільшиться на {monthly_price}\n\nПідтвердити підключення?",
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Підписка призупинена",
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Підписка відновлена!",
"DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ <b>Підписка відновлена!</b>\n\nВаш добовий тариф «{tariff_name}» відновлено після поповнення балансу.\n\n💳 Списано: {amount}\n💰 Залишок: {balance}",
"WEBHOOK_SUB_EXPIRED": "❌ <b>Підписка закінчилась</b>\n\nВаша підписка завершена. Продовжте підписку, щоб відновити доступ до VPN.",
"WEBHOOK_SUB_DISABLED": "🚫 <b>Підписку вимкнено</b>\n\nВашу підписку було вимкнено адміністратором.",
"WEBHOOK_SUB_ENABLED": "✅ <b>Підписку активовано</b>\n\nВаша підписка знову активна. Приємного використання!",
+3
View File
@@ -1588,6 +1588,9 @@
"MODEM_PRICE_WITH_DISCOUNT": "费用:<s>{base_price}</s> <b>{final_price}</b>{months}个月)\n🎁 折扣{discount}%-{discount_amount}",
"MODEM_PRICE_NO_DISCOUNT": "费用:{price}{months}个月)",
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>确认连接调制解调器</b>\n\n{price_text}\n\n连接调制解调器时:\n• 将向您的订阅添加额外设备\n• 月费将增加{monthly_price}\n\n确认连接?",
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ 订阅已暂停",
"DAILY_SUBSCRIPTION_RESUMED": "▶️ 订阅已恢复!",
"DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ <b>订阅已恢复!</b>\n\n您的日套餐「{tariff_name}」已在充值后恢复。\n\n💳 扣费:{amount}\n💰 余额:{balance}",
"WEBHOOK_SUB_EXPIRED": "❌ <b>订阅已过期</b>\n\n您的订阅已结束。请续订以恢复VPN访问。",
"WEBHOOK_SUB_DISABLED": "🚫 <b>订阅已禁用</b>\n\n您的订阅已被管理员禁用。",
"WEBHOOK_SUB_ENABLED": "✅ <b>订阅已激活</b>\n\n您的订阅已重新激活。祝使用愉快!",
+3 -2
View File
@@ -22,6 +22,7 @@ from app.database.models import (
Transaction,
User,
)
from app.utils.message_patch import caption_exceeds_telegram_limit
from app.utils.timezone import format_local_datetime
@@ -1915,7 +1916,7 @@ class AdminNotificationService:
keyboard: types.InlineKeyboardMarkup | None = None,
) -> bool:
"""Отправить фото с текстом в тикет-топик.
Если текст <= 1024 символов отправляем фото с caption.
Если текст помещается в caption (1024 символов после парсинга HTML) фото с caption.
Иначе сначала текст, потом фото в тот же топик.
"""
if not self.chat_id:
@@ -1924,7 +1925,7 @@ class AdminNotificationService:
thread_id = self.ticket_topic_id or self.topic_id
try:
if len(text) <= 1024:
if not caption_exceeds_telegram_limit(text):
# Фото с caption — всё в одном сообщении
photo_kwargs: dict = {
'chat_id': self.chat_id,
+100 -26
View File
@@ -3,11 +3,11 @@
import asyncio
import re
import secrets
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from typing import Literal
import structlog
from sqlalchemy import func, select
from sqlalchemy import func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -115,7 +115,7 @@ async def validate_and_calculate(
async def create_purchase(
db: AsyncSession,
landing: LandingPage,
landing: LandingPage | None,
tariff: Tariff,
period_days: int,
amount_kopeks: int,
@@ -126,13 +126,15 @@ async def create_purchase(
gift_recipient_type: str | None = None,
gift_recipient_value: str | None = None,
gift_message: str | None = None,
source: str = 'landing',
buyer_user_id: int | None = None,
commit: bool = True,
) -> GuestPurchase:
"""Create a guest purchase record."""
purchase = await create_guest_purchase(
db,
commit=commit,
landing_id=landing.id,
landing_id=landing.id if landing else None,
tariff_id=tariff.id,
period_days=period_days,
amount_kopeks=amount_kopeks,
@@ -143,6 +145,8 @@ async def create_purchase(
gift_recipient_type=gift_recipient_type,
gift_recipient_value=gift_recipient_value,
gift_message=gift_message,
source=source,
buyer_user_id=buyer_user_id,
status=GuestPurchaseStatus.PENDING.value,
)
@@ -150,23 +154,32 @@ async def create_purchase(
'Guest purchase created',
purchase_id=purchase.id,
token_prefix=purchase.token[:5],
landing_slug=landing.slug,
landing_slug=landing.slug if landing else None,
tariff_id=tariff.id,
period_days=period_days,
amount_kopeks=amount_kopeks,
is_gift=is_gift,
source=source,
)
return purchase
async def fulfill_purchase(db: AsyncSession, purchase_token: str) -> GuestPurchase | None:
async def fulfill_purchase(
db: AsyncSession,
purchase_token: str,
pre_resolved_telegram_id: int | None = None,
) -> GuestPurchase | None:
"""After payment: find/create user, create subscription, send notification.
Uses SELECT ... FOR UPDATE to prevent concurrent fulfillment of the same purchase.
The PENDING_ACTIVATION path commits early and returns (terminal for this call).
The DELIVERED path commits after subscription creation.
Returns the updated purchase or None if not found.
Args:
pre_resolved_telegram_id: If caller already resolved the recipient's telegram_id
via Bot API, pass it here to avoid a duplicate API call.
"""
result = await db.execute(select(GuestPurchase).where(GuestPurchase.token == purchase_token).with_for_update())
purchase = result.scalars().first()
@@ -188,7 +201,13 @@ async def fulfill_purchase(db: AsyncSession, purchase_token: str) -> GuestPurcha
recipient_type, recipient_value = _get_recipient_contact(purchase)
# Find or create user for the recipient (no commit — stays within our transaction)
user, is_new_account = await _find_or_create_user(db, recipient_type, recipient_value, purchase=purchase)
user, is_new_account = await _find_or_create_user(
db,
recipient_type,
recipient_value,
purchase=purchase,
pre_resolved_telegram_id=pre_resolved_telegram_id,
)
# Load tariff early — needed for both PENDING_ACTIVATION and DELIVERED paths
tariff = await get_tariff_by_id(db, purchase.tariff_id)
@@ -359,6 +378,7 @@ async def _find_or_create_user(
contact_type: Literal['email', 'telegram'],
contact_value: str,
purchase: GuestPurchase | None = None,
pre_resolved_telegram_id: int | None = None,
) -> tuple[User, bool]:
"""Find user by email/telegram username or create a new one.
@@ -367,6 +387,10 @@ async def _find_or_create_user(
Returns (user, is_new_account) where is_new_account means a new password was generated.
Args:
pre_resolved_telegram_id: If caller already resolved the telegram_id via Bot API,
pass it here to skip the redundant API call.
NOTE: Does NOT commit caller is responsible for committing the transaction.
This preserves FOR UPDATE locks held by the caller.
"""
@@ -438,22 +462,23 @@ async def _find_or_create_user(
normalized = username.lower()
# Try to resolve telegram_id via Bot API (works if user has interacted with the bot)
resolved_telegram_id: int | None = None
try:
from aiogram import Bot
resolved_telegram_id: int | None = pre_resolved_telegram_id
if resolved_telegram_id is None:
try:
from aiogram import Bot
async with Bot(token=settings.BOT_TOKEN) as bot:
chat = await asyncio.wait_for(
bot.get_chat(chat_id=f'@{username}'),
timeout=5.0,
)
resolved_telegram_id = chat.id
# Use the canonical username from Telegram if available
if chat.username:
username = chat.username
normalized = username.lower()
except Exception as exc:
logger.debug('Could not resolve telegram_id for username', username=username, error=str(exc))
async with Bot(token=settings.BOT_TOKEN) as bot:
chat = await asyncio.wait_for(
bot.get_chat(chat_id=f'@{username}'),
timeout=5.0,
)
resolved_telegram_id = chat.id
# Use the canonical username from Telegram if available
if chat.username:
username = chat.username
normalized = username.lower()
except Exception as exc:
logger.debug('Could not resolve telegram_id for username', username=username, error=str(exc))
# Search by telegram_id first (most reliable), then by username (case-insensitive)
user = None
@@ -709,8 +734,8 @@ async def send_guest_notification(
notification_type=notification_type.value,
)
# Send separate credentials email for new/upgraded accounts (non-gift self-purchases)
if purchase.cabinet_password and not purchase.is_gift:
# Send separate credentials email for new accounts (self-purchases and gifts)
if purchase.cabinet_password:
cred_template = None
try:
from app.cabinet.services.email_template_overrides import get_rendered_override
@@ -724,8 +749,8 @@ async def send_guest_notification(
'subject': cred_subject,
'body_html': cred_body,
}
except Exception:
pass
except Exception as e:
logger.debug('Failed to check credentials template override', e=e)
if not cred_template:
cred_template = templates.get_template(NotificationType.GUEST_CABINET_CREDENTIALS, language, context)
if cred_template:
@@ -869,3 +894,52 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
raise GuestPurchaseError('Activation failed, please try again', status_code=500)
return purchase
async def retry_stuck_paid_purchases(
db: AsyncSession,
stale_minutes: int = 5,
limit: int = 10,
max_age_hours: int = 24,
) -> int:
"""Retry fulfillment for purchases stuck in PAID status.
Finds purchases that have been in PAID status for longer than stale_minutes
(but not older than max_age_hours) and attempts to fulfill them in isolated
sessions. Returns the number of successfully retried purchases.
Purchases older than max_age_hours are left for manual investigation.
"""
from app.database.database import AsyncSessionLocal
cutoff = datetime.now(UTC) - timedelta(minutes=stale_minutes)
max_age = datetime.now(UTC) - timedelta(hours=max_age_hours)
# Collect tokens only — each retry gets its own session.
# NULL paid_at is included via or_() as a safety net for data anomalies.
result = await db.execute(
select(GuestPurchase.token)
.where(
GuestPurchase.status == GuestPurchaseStatus.PAID.value,
or_(GuestPurchase.paid_at < cutoff, GuestPurchase.paid_at.is_(None)),
or_(GuestPurchase.paid_at > max_age, GuestPurchase.paid_at.is_(None)),
)
.order_by(GuestPurchase.paid_at.asc().nulls_first())
.limit(limit)
)
tokens = result.scalars().all()
if not tokens:
return 0
retried = 0
for token in tokens:
try:
async with AsyncSessionLocal() as retry_db:
await fulfill_purchase(retry_db, token)
retried += 1
logger.info('Retried stuck purchase successfully', token_prefix=token[:5])
except Exception:
logger.exception('Failed to retry stuck purchase', token_prefix=token[:5])
return retried
+13 -1
View File
@@ -58,6 +58,7 @@ from app.services.notification_settings_service import NotificationSettingsServi
from app.services.promo_offer_service import promo_offer_service
from app.services.subscription_service import SubscriptionService
from app.utils.cache import cache
from app.utils.message_patch import caption_exceeds_telegram_limit
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.utils.promo_offer import get_user_active_promo_discount_percent
from app.utils.subscription_utils import (
@@ -110,7 +111,7 @@ class MonitoringService:
logger.debug('Пропуск уведомления: пользователь недоступен', user_id=user.id, status=user.status)
return None
if settings.ENABLE_LOGO_MODE and LOGO_PATH.exists() and (text is None or len(text) <= 1000):
if settings.ENABLE_LOGO_MODE and LOGO_PATH.exists() and not caption_exceeds_telegram_limit(text):
try:
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
@@ -225,6 +226,7 @@ class MonitoringService:
await self._check_trial_expiring_soon(db)
await self._check_trial_channel_subscriptions(db)
await self._check_expired_subscription_followups(db)
await self._retry_stuck_guest_purchases(db)
await self._cleanup_inactive_users(db)
await self._sync_with_remnawave(db)
@@ -1678,6 +1680,16 @@ class MonitoringService:
'Ошибка отправки уведомления о неудачном автоплатеже пользователю', telegram_id=user.telegram_id, e=e
)
async def _retry_stuck_guest_purchases(self, db: AsyncSession):
try:
from app.services.guest_purchase_service import retry_stuck_paid_purchases
retried = await retry_stuck_paid_purchases(db, stale_minutes=5, limit=10)
if retried:
logger.info('Retried stuck guest purchases', retried=retried)
except Exception:
logger.error('Error retrying stuck guest purchases', exc_info=True)
async def _cleanup_inactive_users(self, db: AsyncSession):
try:
now = datetime.now(UTC)
+2
View File
@@ -29,6 +29,7 @@ class CloudPaymentsPaymentMixin:
language: str | None = None,
email: str | None = None,
return_url: str | None = None,
failed_url: str | None = None,
) -> dict[str, Any] | None:
"""
Create a CloudPayments payment and return payment link info.
@@ -80,6 +81,7 @@ class CloudPaymentsPaymentMixin:
description=description,
email=email,
success_redirect_url=return_url,
fail_redirect_url=failed_url,
)
except CloudPaymentsAPIError as error:
logger.error('Ошибка создания CloudPayments платежа', error=error)
+31 -1
View File
@@ -306,7 +306,37 @@ async def send_cart_notification_after_topup(
from aiogram import types
from app.database.crud.user import get_user_by_id
from app.services.subscription_auto_purchase_service import auto_purchase_saved_cart_after_topup
from app.services.subscription_auto_purchase_service import (
auto_purchase_saved_cart_after_topup,
try_auto_extend_expired_after_topup,
try_resume_disabled_daily_after_topup,
)
# Try to resume DISABLED daily subscription immediately (highest priority)
try:
daily_resumed = await try_resume_disabled_daily_after_topup(db, user, bot=bot)
if daily_resumed:
return False
except Exception as daily_error:
logger.error(
'Ошибка авто-возобновления суточной подписки после пополнения',
user_id=user.id,
error=daily_error,
exc_info=True,
)
# Try to auto-extend expired subscription (works without cart)
try:
auto_extended = await try_auto_extend_expired_after_topup(db, user, bot=bot)
if auto_extended:
return False
except Exception as extend_error:
logger.error(
'Ошибка автопродления истёкшей подписки после пополнения',
user_id=user.id,
error=extend_error,
exc_info=True,
)
cart_data = await user_cart_service.get_user_cart(user.id)
if not cart_data:
+2 -1
View File
@@ -28,6 +28,7 @@ class HeleketPaymentMixin:
*,
language: str | None = None,
return_url: str | None = None,
success_url: str | None = None,
) -> dict[str, Any] | None:
if not getattr(self, 'heleket_service', None):
logger.error('Heleket сервис не инициализирован')
@@ -72,7 +73,7 @@ class HeleketPaymentMixin:
payload['url_callback'] = callback_url
effective_return = return_url or settings.HELEKET_RETURN_URL
effective_success = return_url or settings.HELEKET_SUCCESS_URL
effective_success = success_url or return_url or settings.HELEKET_SUCCESS_URL
if effective_return:
payload['url_return'] = effective_return
if effective_success:
+4 -2
View File
@@ -33,6 +33,7 @@ class PlategaPaymentMixin:
language: str,
payment_method_code: int,
return_url: str | None = None,
failed_url: str | None = None,
) -> dict[str, Any] | None:
service: PlategaService | None = getattr(self, 'platega_service', None)
if not service or not service.is_configured:
@@ -61,6 +62,7 @@ class PlategaPaymentMixin:
amount_value = amount_kopeks / 100
effective_return_url = return_url or settings.get_platega_return_url()
effective_failed_url = failed_url or settings.get_platega_failed_url()
try:
response = await service.create_payment(
@@ -69,7 +71,7 @@ class PlategaPaymentMixin:
currency=settings.PLATEGA_CURRENCY,
description=description,
return_url=effective_return_url,
failed_url=settings.get_platega_failed_url(),
failed_url=effective_failed_url,
payload=payload_token,
)
except Exception as error: # pragma: no cover - network errors
@@ -105,7 +107,7 @@ class PlategaPaymentMixin:
platega_transaction_id=transaction_id,
redirect_url=redirect_url,
return_url=effective_return_url,
failed_url=settings.get_platega_failed_url(),
failed_url=effective_failed_url,
payload=payload_token,
metadata=metadata,
expires_at=expires_at,
+2
View File
@@ -74,6 +74,7 @@ class WataPaymentMixin:
*,
language: str | None = None,
return_url: str | None = None,
failed_url: str | None = None,
) -> dict[str, Any] | None:
if not getattr(self, 'wata_service', None):
logger.error('WATA service is not initialised')
@@ -120,6 +121,7 @@ class WataPaymentMixin:
description=description,
order_id=order_id,
success_url=return_url,
fail_url=failed_url,
)
except WataAPIError as error:
logger.error('Ошибка создания WATA платежа', error=error)
@@ -15,7 +15,7 @@ from app.config import settings
from app.database.crud.subscription import extend_subscription
from app.database.crud.transaction import create_transaction
from app.database.crud.user import get_user_by_id, subtract_user_balance
from app.database.models import Subscription, TransactionType, User
from app.database.models import Subscription, SubscriptionStatus, TransactionType, User
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.subscription_checkout_service import clear_subscription_checkout_draft
@@ -1241,17 +1241,41 @@ async def _auto_add_devices(
await user_cart_service.delete_user_cart(user.id)
return False
# Load tariff for device price and max limit
tariff = None
if subscription.tariff_id:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff and tariff.device_price_kopeks is not None:
tariff_device_price = tariff.device_price_kopeks
tariff_max_device_limit = tariff.max_device_limit
else:
tariff_device_price = settings.PRICE_PER_DEVICE
tariff_max_device_limit = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
# Block purchase if device price is 0 or negative (purchase unavailable for this tariff)
if not tariff_device_price or tariff_device_price <= 0:
logger.warning(
'🔁 Автопокупка устройств: докупка устройств недоступна для тарифа, корзина удалена',
format_user_id=_format_user_id(user),
tariff_id=subscription.tariff_id,
tariff_device_price=tariff_device_price,
)
await user_cart_service.delete_user_cart(user.id)
return False
# Check max device limit before charging
old_device_limit = subscription.device_limit or 1
new_device_limit = old_device_limit + devices_to_add
max_devices = settings.MAX_DEVICES_LIMIT
if max_devices > 0 and new_device_limit > max_devices:
if tariff_max_device_limit and new_device_limit > tariff_max_device_limit:
logger.warning(
'🔁 Автопокупка устройств: превышен лимит устройств',
format_user_id=_format_user_id(user),
current=old_device_limit,
requested=new_device_limit,
max_devices=max_devices,
tariff_max_device_limit=tariff_max_device_limit,
)
await user_cart_service.delete_user_cart(user.id)
return False
@@ -1293,7 +1317,7 @@ async def _auto_add_devices(
old_device_limit = subscription.device_limit or 1
new_device_limit = old_device_limit + devices_to_add
if max_devices > 0 and new_device_limit > max_devices:
if tariff_max_device_limit and new_device_limit > tariff_max_device_limit:
# Concurrent modification exceeded limit — refund
user_refund = await db.execute(
select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True)
@@ -1641,6 +1665,577 @@ async def _auto_add_traffic(
return True
async def try_auto_extend_expired_after_topup(
db: AsyncSession,
user: User,
*,
bot: Bot | None = None,
) -> bool:
"""Try to auto-extend an expired subscription after balance top-up.
Unlike cart-based auto-purchase, this works without a saved cart.
It finds the user's expired subscription and attempts to extend it
with the shortest available period if the balance is sufficient.
Returns True if the subscription was successfully extended.
"""
from app.cabinet.routes.websocket import notify_user_subscription_renewed
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.crud.transaction import get_user_transactions
if not user or not getattr(user, 'id', None):
return False
subscription = await get_subscription_by_user_id(db, user.id)
if subscription is None:
logger.debug(
'🔄 Автопродление expired: у пользователя нет подписки',
format_user_id=_format_user_id(user),
)
return False
# Only process expired subscriptions (not trial, not disabled)
if subscription.status != SubscriptionStatus.EXPIRED.value:
return False
if subscription.is_trial:
return False
# Only process subscriptions expired within the last 30 days
if subscription.end_date is None:
return False
expired_delta = datetime.now(UTC) - subscription.end_date
if expired_delta.days > 30:
logger.info(
'🔄 Автопродление expired: подписка истекла более 30 дней назад',
format_user_id=_format_user_id(user),
expired_days=expired_delta.days,
)
return False
# Determine renewal period from tariff or default to 30 days
tariff = getattr(subscription, 'tariff', None)
if tariff:
period_days = tariff.get_shortest_period() or 30
else:
period_days = 30
# Calculate renewal price
subscription_service = SubscriptionService()
try:
renewal_cost = await subscription_service.calculate_renewal_price(
subscription,
period_days,
db,
user=user,
)
except Exception as error:
logger.error(
'❌ Автопродление expired: ошибка расчёта стоимости',
format_user_id=_format_user_id(user),
error=error,
exc_info=True,
)
return False
if renewal_cost <= 0:
logger.warning(
'❌ Автопродление expired: некорректная стоимость',
format_user_id=_format_user_id(user),
renewal_cost=renewal_cost,
)
return False
# Check balance
if user.balance_kopeks < renewal_cost:
logger.info(
'🔄 Автопродление expired: недостаточно средств',
format_user_id=_format_user_id(user),
balance_kopeks=user.balance_kopeks,
renewal_cost=renewal_cost,
)
return False
# Race condition guard: skip if a subscription payment was made in the last 60 seconds
try:
recent_transactions = await get_user_transactions(db, user.id, limit=1)
if recent_transactions:
last_tx = recent_transactions[0]
if (
last_tx.type == TransactionType.SUBSCRIPTION_PAYMENT
and last_tx.created_at
and (datetime.now(UTC) - last_tx.created_at) < timedelta(seconds=60)
):
logger.info(
'🔄 Автопродление expired: пропуск — подписка оплачена секунд назад',
format_user_id=_format_user_id(user),
total_seconds=(datetime.now(UTC) - last_tx.created_at).total_seconds(),
)
return False
except Exception as check_error:
logger.warning(
'🔄 Автопродление expired: ошибка проверки последней транзакции',
format_user_id=_format_user_id(user),
check_error=check_error,
)
# Determine if promo offer discount was applied (for consume flag)
from app.utils.promo_offer import get_user_active_promo_discount_percent
consume_promo_offer = get_user_active_promo_discount_percent(user) > 0
# Deduct balance
description = f'Автопродление истёкшей подписки на {period_days} дней'
try:
deducted = await subtract_user_balance(
db,
user,
renewal_cost,
description,
consume_promo_offer=consume_promo_offer,
mark_as_paid_subscription=True,
)
except Exception as error:
logger.error(
'❌ Автопродление expired: ошибка списания средств',
format_user_id=_format_user_id(user),
error=error,
exc_info=True,
)
return False
if not deducted:
logger.warning(
'❌ Автопродление expired: списание средств не выполнено',
format_user_id=_format_user_id(user),
)
return False
old_end_date = subscription.end_date
was_trial = subscription.is_trial
# Extend subscription
try:
updated_subscription = await extend_subscription(db, subscription, period_days)
# Convert trial to paid if needed
if was_trial and subscription.is_trial:
subscription.is_trial = False
subscription.status = 'active'
await db.commit()
logger.info(
'✅ Триал конвертирован в платную подписку (автопродление expired)',
subscription_id=subscription.id,
format_user_id=_format_user_id(user),
)
except Exception as error:
logger.error(
'❌ Автопродление expired: не удалось продлить подписку',
format_user_id=_format_user_id(user),
error=error,
exc_info=True,
)
await db.rollback()
return False
# Create transaction record
transaction = None
try:
transaction = await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=renewal_cost,
description=description,
)
except Exception as error:
logger.error(
'⚠️ Автопродление expired: не удалось зафиксировать транзакцию',
format_user_id=_format_user_id(user),
error=error,
exc_info=True,
)
# Update RemnaWave
try:
await subscription_service.update_remnawave_user(
db,
updated_subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason='автопродление истёкшей подписки',
)
except Exception as error:
logger.error(
'⚠️ Автопродление expired: не удалось обновить RemnaWave',
format_user_id=_format_user_id(user),
error=error,
)
texts = get_texts(getattr(user, 'language', 'ru'))
period_label = format_period_description(period_days, getattr(user, 'language', 'ru'))
new_end_date = updated_subscription.end_date
end_date_label = format_local_datetime(new_end_date, '%d.%m.%Y %H:%M')
# Admin notification
try:
from app.services.subscription_renewal_service import with_admin_notification_service
await with_admin_notification_service(
lambda svc: svc.send_subscription_extension_notification(
db,
user,
updated_subscription,
transaction,
period_days,
old_end_date,
new_end_date=new_end_date,
balance_after=user.balance_kopeks,
)
)
except Exception as error:
logger.error(
'⚠️ Автопродление expired: не удалось уведомить администраторов',
format_user_id=_format_user_id(user),
error=error,
)
# Send user notification (only for Telegram users)
if bot and user.telegram_id:
try:
auto_message = texts.t(
'AUTO_PURCHASE_SUBSCRIPTION_EXTENDED',
'✅ Subscription automatically extended for {period}.',
).format(period=period_label)
details_message = texts.t(
'AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS',
'New expiration date: {date}.',
).format(date=end_date_label)
hint_message = texts.t(
'AUTO_PURCHASE_SUBSCRIPTION_HINT',
"Open the 'My subscription' section to access your link.",
)
full_message = '\n\n'.join(
part.strip() for part in [auto_message, details_message, hint_message] if part and part.strip()
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 My subscription'),
callback_data='menu_subscription',
)
],
[
InlineKeyboardButton(
text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '🏠 Main menu'),
callback_data='back_to_menu',
)
],
]
)
await bot.send_message(
chat_id=user.telegram_id,
text=full_message,
reply_markup=keyboard,
parse_mode='HTML',
)
except Exception as error:
logger.error(
'⚠️ Автопродление expired: не удалось уведомить пользователя',
telegram_id=user.telegram_id or user.id,
error=error,
)
logger.info(
'✅ Автопродление expired: подписка продлена для пользователя',
period_days=period_days,
renewal_cost=renewal_cost,
format_user_id=_format_user_id(user),
)
# Send WebSocket notification
try:
await notify_user_subscription_renewed(
user_id=user.id,
new_expires_at=new_end_date.isoformat() if new_end_date else '',
amount_kopeks=renewal_cost,
)
except Exception as ws_error:
logger.warning(
'⚠️ Автопродление expired: не удалось отправить WS уведомление',
format_user_id=_format_user_id(user),
ws_error=ws_error,
)
return True
async def try_resume_disabled_daily_after_topup(
db: AsyncSession,
user: User,
*,
bot: Bot | None = None,
) -> bool:
"""Resume a DISABLED daily subscription immediately after balance top-up.
Daily subscriptions get DISABLED when balance is insufficient.
The DailySubscriptionService loop picks them up every 30 minutes,
but this function provides instant resumption right when the user tops up.
Returns True if the subscription was successfully resumed and charged.
"""
from app.cabinet.routes.websocket import notify_user_subscription_renewed
from app.database.crud.subscription import get_subscription_by_user_id, update_daily_charge_time
if not user or not getattr(user, 'id', None):
return False
subscription = await get_subscription_by_user_id(db, user.id)
if subscription is None:
return False
# Only handle DISABLED (or EXPIRED) daily tariff subscriptions
if subscription.status not in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value):
return False
if not getattr(subscription, 'is_daily_tariff', False):
return False
if subscription.is_trial:
return False
# Skip user-paused subscriptions — they chose to pause, don't auto-resume
if getattr(subscription, 'is_daily_paused', False):
return False
tariff = getattr(subscription, 'tariff', None)
if not tariff:
return False
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
if daily_price <= 0:
return False
# Check balance
if user.balance_kopeks < daily_price:
logger.info(
'🔄 Авто-возобновление daily: недостаточно средств',
format_user_id=_format_user_id(user),
balance_kopeks=user.balance_kopeks,
daily_price=daily_price,
)
return False
# Race condition guard: skip if a subscription payment was made in the last 60 seconds
from app.database.crud.transaction import get_user_transactions
try:
recent_transactions = await get_user_transactions(db, user.id, limit=1)
if recent_transactions:
last_tx = recent_transactions[0]
if (
last_tx.type == TransactionType.SUBSCRIPTION_PAYMENT
and last_tx.created_at
and (datetime.now(UTC) - last_tx.created_at) < timedelta(seconds=60)
):
logger.info(
'🔄 Авто-возобновление daily: пропуск — оплата секунд назад',
format_user_id=_format_user_id(user),
)
return False
except Exception as check_error:
logger.warning(
'🔄 Авто-возобновление daily: ошибка проверки последней транзакции',
format_user_id=_format_user_id(user),
check_error=check_error,
)
# Deduct daily price FIRST (before changing status to avoid free-access window)
previous_status = subscription.status
description = f'Суточная оплата тарифа «{tariff.name}» (авто-возобновление)'
try:
deducted = await subtract_user_balance(
db,
user,
daily_price,
description,
mark_as_paid_subscription=True,
)
except Exception as error:
logger.error(
'❌ Авто-возобновление daily: ошибка списания средств',
format_user_id=_format_user_id(user),
error=error,
exc_info=True,
)
return False
if not deducted:
logger.warning(
'❌ Авто-возобновление daily: списание не выполнено',
format_user_id=_format_user_id(user),
)
return False
# Activate the subscription (balance already deducted)
subscription.status = SubscriptionStatus.ACTIVE.value
try:
await db.commit()
await db.refresh(subscription)
except Exception as error:
logger.error(
'❌ Авто-возобновление daily: ошибка активации подписки',
format_user_id=_format_user_id(user),
error=error,
exc_info=True,
)
await db.rollback()
return False
logger.info(
'✅ Авто-возобновление daily: подписка → ACTIVE после пополнения',
format_user_id=_format_user_id(user),
previous_status=previous_status,
subscription_id=subscription.id,
)
# Create transaction
transaction = None
try:
transaction = await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=daily_price,
description=description,
)
except Exception as error:
logger.error(
'⚠️ Авто-возобновление daily: не удалось создать транзакцию',
format_user_id=_format_user_id(user),
error=error,
)
# Update charge time and end_date (+24h)
old_end_date = subscription.end_date
try:
subscription = await update_daily_charge_time(db, subscription)
except Exception as error:
logger.error(
'⚠️ Авто-возобновление daily: не удалось обновить время списания',
format_user_id=_format_user_id(user),
error=error,
)
# Sync with RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=False,
reset_reason=None,
)
except Exception as error:
logger.error(
'⚠️ Авто-возобновление daily: не удалось обновить RemnaWave',
format_user_id=_format_user_id(user),
error=error,
)
# Admin notification
try:
from app.services.subscription_renewal_service import with_admin_notification_service
await with_admin_notification_service(
lambda svc: svc.send_subscription_extension_notification(
db,
user,
subscription,
transaction,
1,
old_end_date,
new_end_date=subscription.end_date,
balance_after=user.balance_kopeks,
)
)
except Exception as error:
logger.error(
'⚠️ Авто-возобновление daily: не удалось уведомить администраторов',
format_user_id=_format_user_id(user),
error=error,
)
# User notification
if bot and user.telegram_id:
try:
texts = get_texts(getattr(user, 'language', 'ru'))
message = texts.t(
'DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP',
'✅ <b>Подписка возобновлена!</b>\n\n'
'Ваш суточный тариф «{tariff_name}» возобновлён после пополнения баланса.\n\n'
'💳 Списано: {amount}\n'
'💰 Остаток: {balance}',
).format(
tariff_name=tariff.name,
amount=settings.format_price(daily_price),
balance=settings.format_price(user.balance_kopeks),
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 My subscription'),
callback_data='menu_subscription',
)
],
[
InlineKeyboardButton(
text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '🏠 Main menu'),
callback_data='back_to_menu',
)
],
]
)
await bot.send_message(
chat_id=user.telegram_id,
text=message,
reply_markup=keyboard,
parse_mode='HTML',
)
except Exception as error:
logger.error(
'⚠️ Авто-возобновление daily: не удалось уведомить пользователя',
telegram_id=user.telegram_id or user.id,
error=error,
)
logger.info(
'✅ Авто-возобновление daily: подписка возобновлена для пользователя',
format_user_id=_format_user_id(user),
daily_price=daily_price,
tariff_name=tariff.name,
)
# WebSocket notification
try:
await notify_user_subscription_renewed(
user_id=user.id,
new_expires_at=subscription.end_date.isoformat() if subscription.end_date else '',
amount_kopeks=daily_price,
)
except Exception as ws_error:
logger.warning(
'⚠️ Авто-возобновление daily: не удалось отправить WS уведомление',
format_user_id=_format_user_id(user),
ws_error=ws_error,
)
return True
async def auto_purchase_saved_cart_after_topup(
db: AsyncSession,
user: User,
+164
View File
@@ -1,10 +1,14 @@
import asyncio
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.server_squad import get_all_server_squads
from app.database.crud.user import get_user_by_id
from app.database.models import PromoGroup, Subscription, SubscriptionStatus, User
from app.external.remnawave_api import RemnaWaveAPI, RemnaWaveAPIError, RemnaWaveUser, TrafficLimitStrategy, UserStatus
@@ -93,6 +97,15 @@ def get_traffic_reset_strategy(tariff=None):
return getattr(TrafficLimitStrategy, mapped_strategy)
@dataclass
class PropagateSquadsResult:
"""Результат применения скводов тарифа к подпискам."""
total: int = 0
synced: int = 0
failed_ids: list[int] = field(default_factory=list)
class SubscriptionService:
def __init__(self):
self._config_error: str | None = None
@@ -1485,3 +1498,154 @@ class SubscriptionService:
if bytes_value == 0:
return 0.0
return bytes_value / (1024 * 1024 * 1024)
async def propagate_tariff_squads(
self, db: AsyncSession, tariff_id: int, new_squads: list[str], *, concurrency: int = 5
) -> PropagateSquadsResult:
"""Применяет изменение серверов тарифа к активным подпискам и синхронизирует с RemnaWave.
Если new_squads пустой означает "все серверы", будут подставлены все доступные.
Синхронизация с RemnaWave выполняется параллельно с ограничением concurrency.
Паттерн: предзагрузка данных параллельные API-вызовы один commit.
"""
squads_to_set = list(new_squads)
if not squads_to_set:
all_servers, _ = await get_all_server_squads(db, available_only=True, limit=10000)
squads_to_set = [s.squad_uuid for s in all_servers if s.squad_uuid]
result = await db.execute(
select(Subscription).where(
Subscription.tariff_id == tariff_id,
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
)
)
subscriptions = result.scalars().all()
if not subscriptions:
return PropagateSquadsResult(total=0, synced=0)
for sub in subscriptions:
sub.connected_squads = squads_to_set
await db.commit()
# Предзагружаем пользователей и тарифы — никаких DB-операций внутри gather
user_ids = [sub.user_id for sub in subscriptions]
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
users_map = {u.id: u for u in users_result.scalars().all()}
for sub in subscriptions:
try:
await db.refresh(sub, ['tariff'])
except Exception as exc:
logger.warning('Не удалось предзагрузить тариф подписки', subscription_id=sub.id, error=exc)
# Вычисляем стратегию сброса трафика один раз — все подписки одного тарифа
sample_tariff = subscriptions[0].tariff if subscriptions[0].tariff else None
traffic_strategy = get_traffic_reset_strategy(sample_tariff)
# Параллельная синхронизация: один API-клиент, только HTTP-вызовы внутри gather
failed_ids: list[int] = []
synced = 0
async with self.get_api_client() as api:
semaphore = asyncio.Semaphore(concurrency)
async def _sync_one(sub: Subscription) -> bool:
async with semaphore:
try:
user = users_map.get(sub.user_id)
if not user or not user.remnawave_uuid:
return False
current_time = datetime.now(UTC)
is_actually_active = (
sub.status == SubscriptionStatus.ACTIVE.value and sub.end_date > current_time
)
user_tag = self._resolve_user_tag(sub)
ext_squad_uuid = sub.tariff.external_squad_uuid if sub.tariff else None
hwid_limit = resolve_hwid_device_limit_for_payload(sub)
update_kwargs = dict(
uuid=user.remnawave_uuid,
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
expire_at=sub.end_date,
traffic_limit_bytes=self._gb_to_bytes(sub.traffic_limit_gb),
traffic_limit_strategy=traffic_strategy,
telegram_id=user.telegram_id,
email=user.email,
description=settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id,
email=user.email,
user_id=user.id,
),
)
if sub.connected_squads:
update_kwargs['active_internal_squads'] = sub.connected_squads
if user_tag is not None:
update_kwargs['tag'] = user_tag
if hwid_limit is not None:
update_kwargs['hwid_device_limit'] = hwid_limit
if ext_squad_uuid is not None:
update_kwargs['external_squad_uuid'] = ext_squad_uuid
else:
update_kwargs['external_squad_uuid'] = None
updated_user = await api.update_user(**update_kwargs)
# Сохраняем в памяти — commit будет после gather
sub.subscription_url = updated_user.subscription_url
sub.subscription_crypto_link = updated_user.happ_crypto_link
return True
except Exception as e:
logger.warning(
'Не удалось обновить сквады в RemnaWave',
subscription_id=sub.id,
user_id=sub.user_id,
error=e,
)
return False
results = await asyncio.gather(*[_sync_one(sub) for sub in subscriptions])
for i, success in enumerate(results):
if success:
synced += 1
else:
failed_ids.append(subscriptions[i].id)
# Один commit после всех API-вызовов
try:
await db.commit()
except Exception as commit_error:
logger.error('Ошибка фиксации транзакции при синхронизации скводов', error=commit_error)
await db.rollback()
failed_ids = [sub.id for sub in subscriptions]
synced = 0
propagate_result = PropagateSquadsResult(total=len(subscriptions), synced=synced, failed_ids=failed_ids)
if failed_ids:
logger.warning(
'Частичная синхронизация скводов с RemnaWave',
tariff_id=tariff_id,
total=propagate_result.total,
synced=synced,
failed_ids=failed_ids,
)
else:
logger.info(
'Обновлены сквады подписок для тарифа',
tariff_id=tariff_id,
total=propagate_result.total,
synced=synced,
)
return propagate_result
+22 -19
View File
@@ -275,27 +275,30 @@ class TributeService:
async for session in get_db():
user = await get_user_by_telegram_id(session, user_id)
if not user:
logger.warning('Пользователь не найден для уведомления Tribute', user_id=user_id)
break
# Сначала отправляем стандартное уведомление
payment_service = PaymentService(self.bot)
keyboard = await payment_service.build_topup_success_keyboard(user)
text = (
f'✅ **Платеж успешно получен!**\n\n'
f'💰 Сумма: {int(amount_rubles)}\n'
f'💳 Способ оплаты: Tribute\n'
f'🎉 Средства зачислены на баланс!\n\n'
f'Спасибо за оплату! 🙏'
)
await self.bot.send_message(user_id, text, reply_markup=keyboard, parse_mode='Markdown')
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
from app.services.payment.common import send_cart_notification_after_topup
await send_cart_notification_after_topup(user, amount_kopeks, session, self.bot)
break
# Сначала отправляем стандартное уведомление
payment_service = PaymentService(self.bot)
keyboard = await payment_service.build_topup_success_keyboard(user)
text = (
f'✅ **Платеж успешно получен!**\n\n'
f'💰 Сумма: {int(amount_rubles)}\n'
f'💳 Способ оплаты: Tribute\n'
f'🎉 Средства зачислены на баланс!\n\n'
f'Спасибо за оплату! 🙏'
)
await self.bot.send_message(user_id, text, reply_markup=keyboard, parse_mode='Markdown')
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
from app.services.payment.common import send_cart_notification_after_topup
await send_cart_notification_after_topup(user, amount_kopeks, session, self.bot)
except Exception as e:
logger.error('Ошибка отправки уведомления об успешном платеже', error=e)
+60 -20
View File
@@ -1,3 +1,4 @@
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any
@@ -62,6 +63,15 @@ from app.services.notification_delivery_service import (
logger = structlog.get_logger(__name__)
@dataclass
class DeleteUserResult:
"""Результат удаления пользователя."""
bot_deleted: bool = False
panel_deleted: bool = False
panel_error: str | None = None
class UserService:
async def send_topup_success_to_user(
self,
@@ -735,12 +745,21 @@ class UserService:
logger.error('Ошибка разблокировки пользователя', error=e)
return False
async def delete_user_account(self, db: AsyncSession, user_id: int, admin_id: int) -> bool:
async def delete_user_account(
self, db: AsyncSession, user_id: int, admin_id: int, *, force_panel_delete: bool = False
) -> DeleteUserResult:
"""Полное удаление пользователя из бота и (опционально) из панели RemnaWave.
force_panel_delete=True: пропускает проверку активной подписки и принудительно
удаляет (не деактивирует) пользователя из панели RemnaWave. Используется
при полном удалении через кабинет администратора.
"""
result = DeleteUserResult()
try:
user = await get_user_by_id(db, user_id)
if not user:
logger.warning('Пользователь не найден для удаления', user_id=user_id)
return False
return result
user_id_display = user.telegram_id or user.email or f'#{user.id}'
logger.info(
@@ -751,14 +770,14 @@ class UserService:
from app.config import settings
from app.database.crud.subscription import is_active_paid_subscription
if is_active_paid_subscription(user.subscription):
if not force_panel_delete and is_active_paid_subscription(user.subscription):
logger.info(
'⏭️ Пропуск отключения RemnaWave при удалении: у пользователя активная оплаченная подписка',
user_id=user_id,
remnawave_uuid=user.remnawave_uuid,
)
else:
delete_mode = settings.get_remnawave_user_delete_mode()
delete_mode = 'delete' if force_panel_delete else settings.get_remnawave_user_delete_mode()
try:
from app.services.remnawave_service import RemnaWaveService
@@ -770,11 +789,13 @@ class UserService:
async with remnawave_service.get_api_client() as api:
delete_success = await api.delete_user(user.remnawave_uuid)
if delete_success:
result.panel_deleted = True
logger.info(
'✅ RemnaWave пользователь удален из панели',
remnawave_uuid=user.remnawave_uuid,
)
else:
result.panel_error = 'Remnawave API вернул ошибку удаления'
logger.warning(
'⚠️ Не удалось удалить пользователя из панели Remnawave',
remnawave_uuid=user.remnawave_uuid,
@@ -784,14 +805,24 @@ class UserService:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(
'✅ RemnaWave пользователь деактивирован (режим: )',
remnawave_uuid=user.remnawave_uuid,
delete_mode=delete_mode,
)
disabled = await subscription_service.disable_remnawave_user(user.remnawave_uuid)
result.panel_deleted = disabled
if disabled:
logger.info(
'✅ RemnaWave пользователь деактивирован',
remnawave_uuid=user.remnawave_uuid,
delete_mode=delete_mode,
)
else:
result.panel_error = 'disable_remnawave_user вернул False'
logger.warning(
'⚠️ Не удалось деактивировать пользователя в RemnaWave',
remnawave_uuid=user.remnawave_uuid,
delete_mode=delete_mode,
)
except Exception as e:
result.panel_error = 'Ошибка обработки пользователя в Remnawave'
logger.warning(
'⚠️ Ошибка обработки пользователя в Remnawave (режим: )',
delete_mode=delete_mode,
@@ -803,11 +834,19 @@ class UserService:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(
'✅ RemnaWave пользователь деактивирован как fallback',
remnawave_uuid=user.remnawave_uuid,
)
disabled = await subscription_service.disable_remnawave_user(user.remnawave_uuid)
if disabled:
result.panel_deleted = True
result.panel_error = 'Удаление не удалось, пользователь деактивирован'
logger.info(
'✅ RemnaWave пользователь деактивирован как fallback',
remnawave_uuid=user.remnawave_uuid,
)
else:
logger.warning(
'⚠️ Fallback деактивация RemnaWave тоже не удалась',
remnawave_uuid=user.remnawave_uuid,
)
except Exception as fallback_e:
logger.error('❌ Ошибка деактивации RemnaWave как fallback', fallback_e=fallback_e)
@@ -1225,20 +1264,21 @@ class UserService:
except Exception as e:
logger.error('❌ Ошибка финального удаления пользователя', error=e)
await db.rollback()
return False
return result
result.bot_deleted = True
logger.info(
'✅ Пользователь (ID: ) полностью удален администратором',
user_id_display=user_id_display,
user_id=user_id,
admin_id=admin_id,
)
return True
return result
except Exception as e:
logger.error('❌ Критическая ошибка удаления пользователя', user_id=user_id, error=e)
await db.rollback()
return False
return result
async def get_user_statistics(self, db: AsyncSession) -> dict[str, Any]:
try:
@@ -1276,8 +1316,8 @@ class UserService:
skipped_active_sub += 1
continue
success = await self.delete_user_account(db, user.id, 0)
if success:
delete_result = await self.delete_user_account(db, user.id, 0)
if delete_result.bot_deleted:
deleted_count += 1
if skipped_active_sub > 0:
+1
View File
@@ -23,6 +23,7 @@ DEFAULT_BUTTON_STYLES: dict[str, dict] = {
'support': {'style': 'primary', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
'info': {'style': 'primary', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
'admin': {'style': 'danger', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
'language': {'style': 'primary', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
}
BOT_LOCALES = ('ru', 'en', 'ua', 'zh', 'fa')
+191
View File
@@ -0,0 +1,191 @@
"""Lightweight in-process cache for cabinet menu row layout configuration.
Stores per-row button arrangement (which buttons per row, max_per_row)
and custom URL buttons. Loaded from SystemSetting key ``CABINET_MENU_LAYOUT``.
"""
import json
import structlog
from app.database.database import AsyncSessionLocal
logger = structlog.get_logger(__name__)
# ---- Constants ---------------------------------------------------------------
MENU_LAYOUT_KEY = 'CABINET_MENU_LAYOUT'
BUILTIN_SECTIONS: tuple[str, ...] = (
'home',
'subscription',
'balance',
'referral',
'support',
'info',
'admin',
'language',
)
VALID_MAX_PER_ROW = frozenset({1, 2, 3})
# Valid Telegram Bot API style values for custom buttons.
VALID_CUSTOM_BUTTON_STYLES = frozenset({'primary', 'success', 'danger', 'default'})
DEFAULT_MENU_LAYOUT: dict[str, object] = {
'row_1': {'id': 'row_1', 'buttons': ['home'], 'max_per_row': 1},
'row_2': {'id': 'row_2', 'buttons': ['subscription', 'balance'], 'max_per_row': 2},
'row_3': {'id': 'row_3', 'buttons': ['referral', 'support'], 'max_per_row': 2},
'row_4': {'id': 'row_4', 'buttons': ['info', 'language'], 'max_per_row': 2},
'row_5': {'id': 'row_5', 'buttons': ['admin'], 'max_per_row': 1},
'custom_buttons': {},
}
# ---- Module-level cache ------------------------------------------------------
_cached_layout: dict[str, object] | None = None
def _deep_copy_layout(source: dict[str, object]) -> dict[str, object]:
"""Return a deep copy of layout dict via JSON round-trip."""
return json.loads(json.dumps(source))
def get_cached_menu_layout() -> dict[str, object]:
"""Return the current layout config (DB overrides + defaults).
If the cache has not been loaded yet, returns defaults.
"""
if _cached_layout is not None:
return _deep_copy_layout(_cached_layout)
return _deep_copy_layout(DEFAULT_MENU_LAYOUT)
def _validate_row(row_id: str, data: dict) -> dict | None:
"""Validate and sanitize a single row entry. Returns cleaned dict or None."""
if not isinstance(data, dict):
return None
buttons = data.get('buttons')
if not isinstance(buttons, list) or not buttons:
return None
# Allow known built-in section names AND custom_* button IDs in rows
clean_buttons = [b for b in buttons if isinstance(b, str) and (b in BUILTIN_SECTIONS or b.startswith('custom_'))]
if not clean_buttons:
return None
max_per_row = data.get('max_per_row')
if not isinstance(max_per_row, int) or max_per_row not in VALID_MAX_PER_ROW:
max_per_row = 1
return {'id': row_id, 'buttons': clean_buttons, 'max_per_row': max_per_row}
def _validate_custom_button(btn_id: str, data: dict) -> dict | None:
"""Validate and sanitize a single custom URL button. Returns cleaned dict or None."""
if not isinstance(data, dict):
return None
if not btn_id.startswith('custom_'):
return None
url = data.get('url')
if not isinstance(url, str) or not url.strip():
return None
style = data.get('style', 'primary')
if style not in VALID_CUSTOM_BUTTON_STYLES:
style = 'primary'
labels = data.get('labels')
if not isinstance(labels, dict):
labels = {}
clean_labels = {k: v for k, v in labels.items() if isinstance(k, str) and isinstance(v, str)}
icon_custom_emoji_id = data.get('icon_custom_emoji_id', '')
if not isinstance(icon_custom_emoji_id, str):
icon_custom_emoji_id = ''
enabled = data.get('enabled', True)
if not isinstance(enabled, bool):
enabled = True
open_in = data.get('open_in', 'external')
if open_in not in ('external', 'webapp'):
open_in = 'external'
if open_in == 'webapp' and not url.strip().startswith('https://'):
open_in = 'external'
return {
'id': btn_id,
'url': url.strip(),
'style': style,
'labels': clean_labels,
'icon_custom_emoji_id': icon_custom_emoji_id,
'enabled': enabled,
'open_in': open_in,
}
def _validate_layout(data: dict) -> dict[str, object]:
"""Validate and sanitize full layout data from DB.
Returns a clean layout dict; invalid entries are silently dropped.
"""
result: dict[str, object] = {}
for key, value in data.items():
if key == 'custom_buttons':
if isinstance(value, dict):
clean_customs: dict[str, dict] = {}
for btn_id, btn_data in value.items():
validated = _validate_custom_button(str(btn_id), btn_data)
if validated is not None:
clean_customs[str(btn_id)] = validated
result['custom_buttons'] = clean_customs
elif key.startswith('row_'):
validated_row = _validate_row(key, value)
if validated_row is not None:
result[key] = validated_row
# Ensure custom_buttons key always exists
if 'custom_buttons' not in result:
result['custom_buttons'] = {}
return result
async def load_menu_layout_cache() -> dict[str, object]:
"""Load menu layout from DB and refresh the module cache.
Called at bot startup and after admin updates via the cabinet API.
"""
global _cached_layout
merged = _deep_copy_layout(DEFAULT_MENU_LAYOUT)
try:
from sqlalchemy import select
from app.database.models import SystemSetting
async with AsyncSessionLocal() as session:
result = await session.execute(select(SystemSetting).where(SystemSetting.key == MENU_LAYOUT_KEY))
setting = result.scalar_one_or_none()
if setting and setting.value:
db_data: dict = json.loads(setting.value)
if isinstance(db_data, dict):
validated = _validate_layout(db_data)
if validated and any(k.startswith('row_') for k in validated):
# Replace rows and custom_buttons from DB only if at least one row exists
merged = validated
# Ensure custom_buttons always present
if 'custom_buttons' not in merged:
merged['custom_buttons'] = {}
except Exception:
logger.exception('Failed to load menu layout from DB, using defaults')
_cached_layout = merged
logger.info('Menu layout cache loaded', rows=len([k for k in merged if k.startswith('row_')]))
return merged
+18 -2
View File
@@ -1,3 +1,5 @@
import html as html_module
import re
from pathlib import Path
from typing import Any
@@ -9,6 +11,20 @@ from app.localization.texts import get_texts
LOGO_PATH = Path(settings.LOGO_FILE)
# Telegram API: caption limit is 1024 characters AFTER HTML entity parsing (tags stripped)
TELEGRAM_CAPTION_LIMIT = 1024
_HTML_TAG_RE = re.compile(r'<[^>]+>')
def caption_exceeds_telegram_limit(text: str | None) -> bool:
"""Check if text exceeds Telegram's caption limit (1024 parsed chars)."""
if not text:
return False
stripped = html_module.unescape(_HTML_TAG_RE.sub('', text))
return len(stripped) > TELEGRAM_CAPTION_LIMIT
_PRIVACY_RESTRICTED_CODE = 'BUTTON_USER_PRIVACY_RESTRICTED'
# Кеш file_id логотипа: после первой загрузки Telegram возвращает file_id,
@@ -139,7 +155,7 @@ async def _answer_with_photo(self: Message, text: str = None, **kwargs):
return await _original_answer(self, text, **kwargs)
# Если caption слишком длинный для фото — отправим как текст
try:
if text is not None and len(text) > 900:
if caption_exceeds_telegram_limit(text):
return await _text_answer(self, text, **kwargs)
except Exception:
pass
@@ -207,7 +223,7 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
language = _get_language(self)
# Если caption потенциально слишком длинный — отправим как текст вместо caption
try:
if text is not None and len(text) > 900:
if caption_exceeds_telegram_limit(text):
try:
await self.delete()
except Exception:
+2 -1
View File
@@ -11,6 +11,7 @@ from .message_patch import (
LOGO_PATH,
_cache_logo_file_id,
append_privacy_hint,
caption_exceeds_telegram_limit,
get_logo_media,
is_privacy_restricted_error,
is_qr_message,
@@ -137,7 +138,7 @@ async def edit_or_answer_photo(
return
# Если текст слишком длинный для caption — отправим как текст
if caption and len(caption) > 1000:
if caption_exceeds_telegram_limit(caption):
try:
if callback.message.photo:
await callback.message.delete()
+111 -29
View File
@@ -1155,8 +1155,6 @@ async def create_payment_link(
option = (payload.payment_option or '').strip().lower()
if option not in {'card', 'sbp'}:
option = 'sbp'
provider_method = 'card' if option == 'card' else 'sbp'
payment_service = PaymentService()
result = await payment_service.create_pal24_payment(
db=db,
@@ -1164,7 +1162,6 @@ async def create_payment_link(
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id),
language=user.language or settings.DEFAULT_LANGUAGE,
payment_method=provider_method,
)
if not result:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail='Failed to create payment')
@@ -5024,14 +5021,29 @@ async def _build_subscription_settings(
default_device_limit = max(settings.DEFAULT_DEVICE_LIMIT, 1)
current_device_limit = int(subscription.device_limit or default_device_limit)
max_devices_setting = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
# Load tariff for device price and max limit
tariff = None
if subscription.tariff_id:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
# Determine device price and max limit from tariff or global settings
if tariff and tariff.device_price_kopeks is not None:
base_device_price = tariff.device_price_kopeks
max_devices_setting = tariff.max_device_limit
else:
base_device_price = settings.PRICE_PER_DEVICE
max_devices_setting = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
# If device price is 0 or negative, device purchase is unavailable
devices_can_update = bool(base_device_price and base_device_price > 0)
if max_devices_setting is not None:
max_devices = max(max_devices_setting, current_device_limit, default_device_limit)
else:
max_devices = max(current_device_limit, default_device_limit) + 10
discounted_single_device, _ = apply_percentage_discount(
settings.PRICE_PER_DEVICE,
base_device_price,
devices_discount,
)
@@ -5039,7 +5051,7 @@ async def _build_subscription_settings(
for value in range(1, max_devices + 1):
chargeable = max(0, value - default_device_limit)
discounted_per_month, _ = apply_percentage_discount(
chargeable * settings.PRICE_PER_DEVICE,
chargeable * base_device_price,
devices_discount,
)
devices_options.append(
@@ -5074,7 +5086,7 @@ async def _build_subscription_settings(
),
devices=MiniAppSubscriptionDevicesSettings(
options=devices_options,
can_update=True,
can_update=devices_can_update,
min=1,
max=max_devices_setting or 0,
step=1,
@@ -6102,12 +6114,32 @@ async def update_subscription_devices_endpoint(
detail={'code': 'validation_error', 'message': 'Device limit must be positive'},
)
if settings.MAX_DEVICES_LIMIT > 0 and new_devices > settings.MAX_DEVICES_LIMIT:
# Load tariff for device price and max limit
tariff = None
if subscription.tariff_id:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff and tariff.device_price_kopeks is not None:
tariff_device_price = tariff.device_price_kopeks
tariff_max_device_limit = tariff.max_device_limit
else:
tariff_device_price = settings.PRICE_PER_DEVICE
tariff_max_device_limit = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
# Block purchase if device price is 0 (purchase unavailable for this tariff)
if not tariff_device_price or tariff_device_price <= 0:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail={'code': 'devices_unavailable', 'message': 'Докупка устройств недоступна'},
)
# Enforce tariff max device limit
if tariff_max_device_limit and new_devices > tariff_max_device_limit:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail={
'code': 'devices_limit_exceeded',
'message': (f'Превышен максимальный лимит устройств ({settings.MAX_DEVICES_LIMIT})'),
'message': f'Превышен максимальный лимит устройств ({tariff_max_device_limit})',
},
)
@@ -6140,7 +6172,7 @@ async def update_subscription_devices_endpoint(
new_chargeable = max(0, new_devices - settings.DEFAULT_DEVICE_LIMIT)
chargeable_diff = new_chargeable - current_chargeable
price_per_month = chargeable_diff * settings.PRICE_PER_DEVICE
price_per_month = chargeable_diff * tariff_device_price
months_remaining = get_remaining_months(subscription.end_date)
period_hint_days = months_remaining * 30 if months_remaining > 0 else None
devices_discount = _get_addon_discount_percent_for_user(
@@ -6206,9 +6238,8 @@ async def update_subscription_devices_endpoint(
actual_current = subscription.device_limit or 1
actual_delta = new_devices - actual_current
max_devices_limit = settings.MAX_DEVICES_LIMIT
if actual_delta <= 0 or (max_devices_limit > 0 and new_devices > max_devices_limit):
if actual_delta <= 0 or (tariff_max_device_limit and new_devices > tariff_max_device_limit):
# Concurrent request already applied the change or pushed limit beyond max — refund
user_refund = await db.execute(
select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True)
@@ -6228,7 +6259,7 @@ async def update_subscription_devices_endpoint(
status.HTTP_409_CONFLICT,
detail={
'code': 'devices_limit_exceeded',
'message': f'Превышен максимальный лимит устройств ({max_devices_limit}). Баланс возвращён.',
'message': f'Превышен максимальный лимит устройств ({tariff_max_device_limit}). Баланс возвращён.',
},
)
@@ -7337,14 +7368,26 @@ async def toggle_daily_subscription_pause_endpoint(
detail={'code': 'not_daily_tariff', 'message': 'Subscription is not on a daily tariff'},
)
# Переключаем состояние паузы
# Определяем состояние
from app.database.models import SubscriptionStatus
is_currently_paused = getattr(subscription, 'is_daily_paused', False)
new_paused_state = not is_currently_paused
was_disabled = subscription.status in (
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.EXPIRED.value,
)
# System-DISABLED subs (is_daily_paused=False) должны идти по пути resume
if was_disabled and not is_currently_paused:
new_paused_state = False # Force resume path
else:
new_paused_state = not is_currently_paused
subscription.is_daily_paused = new_paused_state
# Если снимаем с паузы, нужно проверить баланс для активации
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
# Если снимаем с паузы, проверяем баланс и списываем оплату
if not new_paused_state:
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
if daily_price > 0 and user.balance_kopeks < daily_price:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
@@ -7356,29 +7399,68 @@ async def toggle_daily_subscription_pause_endpoint(
},
)
# Восстанавливаем статус ACTIVE если подписка была DISABLED (недостаток средств)
from app.database.models import SubscriptionStatus
# Списываем суточную оплату ПЕРЕД активацией
if was_disabled:
if daily_price > 0:
from app.database.crud.user import subtract_user_balance
if subscription.status == SubscriptionStatus.DISABLED.value:
deducted = await subtract_user_balance(
db,
user,
daily_price,
f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
mark_as_paid_subscription=True,
)
if not deducted:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
'code': 'insufficient_balance',
'message': 'Balance deduction failed',
'required': daily_price,
'balance': user.balance_kopeks,
},
)
from app.database.crud.transaction import create_transaction
from app.database.models import TransactionType
try:
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=daily_price,
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
)
except Exception as exc:
logger.warning('Failed to create resume transaction in miniapp', error=exc)
# Баланс списан — теперь активируем
subscription.status = SubscriptionStatus.ACTIVE.value
# Обновляем время последнего списания для корректного расчёта следующего
subscription.last_daily_charge_at = datetime.now(UTC)
subscription.end_date = datetime.now(UTC) + timedelta(days=1)
logger.info('✅ Суточная подписка восстановлена из DISABLED в ACTIVE', subscription_id=subscription.id)
logger.info(
'✅ Суточная подписка восстановлена в ACTIVE (miniapp)',
subscription_id=subscription.id,
previous_status='disabled/expired',
)
await db.commit()
await db.refresh(subscription)
await db.refresh(user)
# Синхронизация с RemnaWave
# При паузе VPN продолжает работать до конца оплаченного времени,
# поэтому НЕ отключаем пользователя в RemnaWave
# При возобновлении включаем если был отключен (например, из-за истечения срока)
if not new_paused_state:
# Синхронизация с RemnaWave только при возобновлении из DISABLED/EXPIRED
if not new_paused_state and was_disabled:
try:
service = SubscriptionService()
if user.remnawave_uuid:
await service.enable_remnawave_user(user.remnawave_uuid)
await service.create_remnawave_user(
db,
subscription,
reset_traffic=False,
reset_reason=None,
)
except Exception as e:
logger.error('Ошибка синхронизации с RemnaWave при возобновлении', error=e)
@@ -15,11 +15,19 @@ branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _table_exists(table_name: str) -> bool:
conn = op.get_bind()
insp = sa.inspect(conn)
return insp.has_table(table_name)
def upgrade() -> None:
op.alter_column('yookassa_payments', 'user_id', existing_type=sa.Integer(), nullable=True)
if _table_exists('yookassa_payments'):
op.alter_column('yookassa_payments', 'user_id', existing_type=sa.Integer(), nullable=True)
def downgrade() -> None:
# WARNING: Will fail if any rows have user_id=NULL (guest payments).
# Backfill required: UPDATE yookassa_payments SET user_id = 0 WHERE user_id IS NULL;
op.alter_column('yookassa_payments', 'user_id', existing_type=sa.Integer(), nullable=False)
if _table_exists('yookassa_payments'):
op.alter_column('yookassa_payments', 'user_id', existing_type=sa.Integer(), nullable=False)
@@ -30,13 +30,21 @@ _TABLES = [
]
def _table_exists(table_name: str) -> bool:
conn = op.get_bind()
insp = sa.inspect(conn)
return insp.has_table(table_name)
def upgrade() -> None:
for table in _TABLES:
op.alter_column(table, 'user_id', existing_type=sa.Integer(), nullable=True)
if _table_exists(table):
op.alter_column(table, 'user_id', existing_type=sa.Integer(), nullable=True)
def downgrade() -> None:
# WARNING: Will fail if any rows have user_id=NULL (guest payments).
# Backfill required before downgrading.
for table in _TABLES:
op.alter_column(table, 'user_id', existing_type=sa.Integer(), nullable=False)
if _table_exists(table):
op.alter_column(table, 'user_id', existing_type=sa.Integer(), nullable=False)
@@ -15,6 +15,12 @@ branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _table_exists(table_name: str) -> bool:
conn = op.get_bind()
insp = sa.inspect(conn)
return insp.has_table(table_name)
def _has_column(table: str, column: str) -> bool:
conn = op.get_bind()
inspector = sa.inspect(conn)
@@ -22,12 +28,12 @@ def _has_column(table: str, column: str) -> bool:
def upgrade() -> None:
if _has_column('contest_templates', 'prize_days'):
if _table_exists('contest_templates') and _has_column('contest_templates', 'prize_days'):
op.drop_column('contest_templates', 'prize_days')
def downgrade() -> None:
if not _has_column('contest_templates', 'prize_days'):
if _table_exists('contest_templates') and not _has_column('contest_templates', 'prize_days'):
op.add_column(
'contest_templates',
sa.Column('prize_days', sa.Integer(), nullable=True),
@@ -0,0 +1,57 @@
"""Add source and buyer_user_id columns to guest_purchases
Supports cabinet gift purchases by tracking purchase origin
and linking to the authenticated buyer.
Revision ID: 0032
Revises: 0031
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0032'
down_revision: Union[str, None] = '0031'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _has_column(table: str, column: str) -> bool:
conn = op.get_bind()
inspector = sa.inspect(conn)
return column in [c['name'] for c in inspector.get_columns(table)]
def upgrade() -> None:
if not _has_column('guest_purchases', 'source'):
op.add_column(
'guest_purchases',
sa.Column('source', sa.String(20), nullable=False, server_default='landing'),
)
op.create_index('ix_guest_purchases_source', 'guest_purchases', ['source'])
if not _has_column('guest_purchases', 'buyer_user_id'):
op.add_column(
'guest_purchases',
sa.Column('buyer_user_id', sa.Integer(), nullable=True),
)
op.create_foreign_key(
'fk_guest_purchases_buyer_user_id',
'guest_purchases',
'users',
['buyer_user_id'],
['id'],
ondelete='SET NULL',
)
def downgrade() -> None:
if _has_column('guest_purchases', 'buyer_user_id'):
op.drop_constraint('fk_guest_purchases_buyer_user_id', 'guest_purchases', type_='foreignkey')
op.drop_column('guest_purchases', 'buyer_user_id')
if _has_column('guest_purchases', 'source'):
op.drop_index('ix_guest_purchases_source', table_name='guest_purchases')
op.drop_column('guest_purchases', 'source')
@@ -0,0 +1,44 @@
"""Add indexes for gift pending queries and retry on guest_purchases
Adds three indexes:
- (user_id, is_gift, status) for dashboard pending gifts query
- (status, paid_at) for retry_stuck_paid_purchases query
- (buyer_user_id) for FK lookup performance
Revision ID: 0033
Revises: 0032
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0033'
down_revision: Union[str, None] = '0032'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
INDEXES = [
('ix_guest_purchases_user_gift_status', ['user_id', 'is_gift', 'status']),
('ix_guest_purchases_status_paid_at', ['status', 'paid_at']),
('ix_guest_purchases_buyer_user_id', ['buyer_user_id']),
]
def _has_index(table: str, index_name: str) -> bool:
conn = op.get_bind()
inspector = sa.inspect(conn)
return index_name in [idx['name'] for idx in inspector.get_indexes(table)]
def upgrade() -> None:
for index_name, columns in INDEXES:
if not _has_index('guest_purchases', index_name):
op.create_index(index_name, 'guest_purchases', columns)
def downgrade() -> None:
for index_name, _ in reversed(INDEXES):
if _has_index('guest_purchases', index_name):
op.drop_index(index_name, table_name='guest_purchases')
@@ -0,0 +1,22 @@
"""Add recipient_warning column to guest_purchases.
Revision ID: 0034
Revises: 0033
"""
from alembic import op
import sqlalchemy as sa
revision = '0034'
down_revision = '0033'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('guest_purchases', sa.Column('recipient_warning', sa.String(50), nullable=True))
def downgrade() -> None:
op.drop_column('guest_purchases', 'recipient_warning')
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = 'remnawave-bedolaga-telegram-bot'
version = "3.26.0"
version = "3.28.0"
description = 'Telegram bot for RemnaWave VPN service'
readme = 'README.md'
license = { text = 'MIT' }
Generated
+1 -1
View File
@@ -1115,7 +1115,7 @@ wheels = [
[[package]]
name = "remnawave-bedolaga-telegram-bot"
version = "3.24.0"
version = "3.25.0"
source = { virtual = "." }
dependencies = [
{ name = "aiogram" },