Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bcc35d6e22 | |||
| b850e81897 | |||
| 4d9e42c3f1 | |||
| 834a0478ae | |||
| 0e968987fb | |||
| acd2cff9ca | |||
| 69dbd6a2df | |||
| 497a8ee5b5 | |||
| dd8d7f6920 | |||
| b9089e693f | |||
| b815abf2b1 | |||
| 95a32e8574 | |||
| cd04f3b622 | |||
| 6a4140e3e2 | |||
| f80b058380 | |||
| 6a61b09575 | |||
| 759bfe1bdb | |||
| 0936d4a7f6 | |||
| 680c22c017 | |||
| 8c9efd5127 | |||
| 4663097a24 | |||
| dc51a55c98 | |||
| 275f249bbd | |||
| 7a9264b173 | |||
| 32d58b04b9 | |||
| 7ca96195a7 | |||
| 5752b5e7c6 | |||
| e6f577697b | |||
| f4a776319e | |||
| 2649e12f64 | |||
| 4a5cacda38 | |||
| 79161eaae4 | |||
| 289cbe966e | |||
| 7ccfb66690 | |||
| 536525c9c0 | |||
| 4186159a61 | |||
| 6349b2f442 | |||
| bfbefeb1e2 | |||
| f9f07f360c | |||
| 770b31d3d0 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.26.0"
|
||||
".": "3.28.0"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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 '?'
|
||||
|
||||
@@ -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:'))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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اشتراک شما دوباره فعال است. از استفاده لذت ببرید!",
|
||||
|
||||
@@ -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Ваша подписка была отключена администратором.",
|
||||
|
||||
@@ -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Ваша підписка знову активна. Приємного використання!",
|
||||
|
||||
@@ -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您的订阅已重新激活。祝使用愉快!",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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' }
|
||||
|
||||
Reference in New Issue
Block a user