diff --git a/.env.example b/.env.example index 0aecb243..a153d08e 100644 --- a/.env.example +++ b/.env.example @@ -1029,10 +1029,4 @@ WEB_API_TOKEN_HASH_ALGORITHM=sha256 # Логирование запросов WEB_API_REQUEST_LOGGING=true -# Внешний админ-токен (для интеграции с другими ботами/системами) -# Токен для доступа через API другого бота -# EXTERNAL_ADMIN_TOKEN= -# ID бота, от которого принимается токен -# EXTERNAL_ADMIN_TOKEN_BOT_ID= - MINIAPP_STATIC_PATH=miniapp diff --git a/Dockerfile b/Dockerfile index 42260259..5724b608 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,8 +33,8 @@ WORKDIR /app COPY --chown=app:app . . -RUN mkdir -p logs data uploads/images uploads/videos uploads/thumbnails && \ - chown -R app:app logs data uploads +RUN mkdir -p logs data uploads/images uploads/videos uploads/thumbnails locales && \ + chown -R app:app logs data uploads locales USER app diff --git a/app/cabinet/dependencies.py b/app/cabinet/dependencies.py index 2d07d58f..b9557588 100644 --- a/app/cabinet/dependencies.py +++ b/app/cabinet/dependencies.py @@ -1,5 +1,7 @@ """FastAPI dependencies for cabinet module.""" +from datetime import UTC, datetime + import structlog from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer @@ -176,6 +178,15 @@ async def get_current_cabinet_user( }, ) + # Throttled update of cabinet_last_login (at most every 5 minutes) + now = datetime.now(UTC) + if not user.cabinet_last_login or (now - user.cabinet_last_login).total_seconds() > 300: + try: + user.cabinet_last_login = now + await db.commit() + except Exception: + pass + return user diff --git a/app/cabinet/routes/admin_bulk_actions.py b/app/cabinet/routes/admin_bulk_actions.py index d102a46d..5b970898 100644 --- a/app/cabinet/routes/admin_bulk_actions.py +++ b/app/cabinet/routes/admin_bulk_actions.py @@ -502,6 +502,16 @@ async def _do_delete_subscription( tariff_name = sub.tariff.name if sub.tariff else f'#{sub.id}' + # Protect active paid subscriptions from accidental deletion + if sub.is_active and not sub.is_trial and not params.force_delete_active_paid: + return BulkUserResult( + user_id=user.id, + success=False, + message=f'Skipped: {tariff_name} is active and paid (enable force_delete_active_paid to override)', + username=user.username, + subscriptions=_build_subscription_info(getattr(user, 'subscriptions', None) or []), + ) + if dry_run: return BulkUserResult( user_id=user.id, @@ -890,7 +900,7 @@ async def _execute_for_subscription( async def bulk_execute( request: BulkExecuteRequest, stream: bool = Query(default=False, description='Stream progress via SSE'), - admin: User = Depends(require_permission('users:edit')), + admin: User = Depends(require_permission('bulk_actions:execute')), db: AsyncSession = Depends(get_cabinet_db), ): """Execute a bulk action on multiple users or subscriptions. diff --git a/app/cabinet/routes/admin_info_pages.py b/app/cabinet/routes/admin_info_pages.py index 3749e186..fe672ac6 100644 --- a/app/cabinet/routes/admin_info_pages.py +++ b/app/cabinet/routes/admin_info_pages.py @@ -34,7 +34,7 @@ router = APIRouter(prefix='/admin/info-pages', tags=['Cabinet Admin Info Pages'] @router.get('', response_model=list[InfoPageListItem]) async def list_all_info_pages( page_type: str | None = Query(None, pattern=r'^(page|faq)$'), - admin: User = Depends(require_permission('settings:read')), + admin: User = Depends(require_permission('info_pages:read')), db: AsyncSession = Depends(get_cabinet_db), ) -> list[InfoPageListItem]: """Get all info pages (admin view, includes inactive).""" @@ -54,7 +54,7 @@ async def list_all_info_pages( @router.get('/{page_id}', response_model=InfoPageResponse) async def get_info_page_detail( page_id: int, - admin: User = Depends(require_permission('settings:read')), + admin: User = Depends(require_permission('info_pages:read')), db: AsyncSession = Depends(get_cabinet_db), ) -> InfoPageResponse: """Get a single info page by ID (admin view).""" @@ -70,7 +70,7 @@ async def get_info_page_detail( @router.post('', response_model=InfoPageResponse, status_code=status.HTTP_201_CREATED) async def create_page( request: InfoPageCreateRequest, - admin: User = Depends(require_permission('settings:edit')), + admin: User = Depends(require_permission('info_pages:edit')), db: AsyncSession = Depends(get_cabinet_db), ) -> InfoPageResponse: """Create a new info page.""" @@ -108,7 +108,7 @@ async def create_page( async def update_page( page_id: int, request: InfoPageUpdateRequest, - admin: User = Depends(require_permission('settings:edit')), + admin: User = Depends(require_permission('info_pages:edit')), db: AsyncSession = Depends(get_cabinet_db), ) -> InfoPageResponse: """Update an existing info page.""" @@ -150,7 +150,7 @@ async def update_page( @router.delete('/{page_id}', status_code=status.HTTP_204_NO_CONTENT) async def remove_page( page_id: int, - admin: User = Depends(require_permission('settings:edit')), + admin: User = Depends(require_permission('info_pages:edit')), db: AsyncSession = Depends(get_cabinet_db), ) -> None: """Delete an info page.""" @@ -174,7 +174,7 @@ async def remove_page( @router.post('/reorder', status_code=status.HTTP_204_NO_CONTENT) async def reorder_pages( request: ReorderRequest, - admin: User = Depends(require_permission('settings:edit')), + admin: User = Depends(require_permission('info_pages:edit')), db: AsyncSession = Depends(get_cabinet_db), ) -> None: """Bulk update sort_order for info pages.""" @@ -191,7 +191,7 @@ async def reorder_pages( @router.post('/{page_id}/toggle-active', response_model=InfoPageResponse) async def toggle_active( page_id: int, - admin: User = Depends(require_permission('settings:edit')), + admin: User = Depends(require_permission('info_pages:edit')), db: AsyncSession = Depends(get_cabinet_db), ) -> InfoPageResponse: """Toggle the active status of an info page.""" diff --git a/app/cabinet/routes/admin_users.py b/app/cabinet/routes/admin_users.py index 297a43b4..2b892696 100644 --- a/app/cabinet/routes/admin_users.py +++ b/app/cabinet/routes/admin_users.py @@ -1,5 +1,6 @@ """Admin routes for managing users in cabinet.""" +import math from datetime import UTC, datetime, timedelta import structlog @@ -157,6 +158,7 @@ def _build_user_list_item(user: User, spending_stats: dict = None) -> UserListIt tariff_id=s.tariff_id, tariff_name=s.tariff.name if s.tariff else None, status=s.status, + is_trial=bool(s.is_trial), end_date=s.end_date, days_remaining=s_days, traffic_used_gb=s.traffic_used_gb or 0.0, @@ -391,7 +393,10 @@ async def _sync_subscription_to_panel( changes['action'] = 'updated' logger.info('Updated user in Remnawave panel', user_id=user.id) except Exception as update_error: - if hasattr(update_error, 'status_code') and update_error.status_code == 404: + error_code = (getattr(update_error, 'response_data', None) or {}).get('errorCode', '') + if ( + hasattr(update_error, 'status_code') and update_error.status_code == 404 + ) or error_code == 'A018': panel_uuid = None # Will create new else: raise @@ -895,6 +900,50 @@ async def get_user_panel_info( return UserPanelInfoResponse(found=False) +@router.get('/{user_id}/subscription-request-history') +async def get_subscription_request_history( + user_id: int, + admin: User = Depends(require_permission('users:read')), + db: AsyncSession = Depends(get_cabinet_db), + subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'), + offset: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), +): + """Get subscription request history from RemnaWave panel.""" + from app.database.crud.user import get_user_by_id + + user = await get_user_by_id(db, user_id) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found') + + panel_uuid = None + if settings.is_multi_tariff_enabled() and subscription_id: + from app.database.crud.subscription import get_subscription_by_id_for_user + + sub = await get_subscription_by_id_for_user(db, subscription_id, user_id) + if sub: + panel_uuid = sub.remnawave_uuid + else: + panel_uuid = getattr(user, 'remnawave_uuid', None) + + if not panel_uuid: + return {'total': 0, 'records': []} + + try: + from app.services.remnawave_service import RemnaWaveService + + service = RemnaWaveService() + if not service.is_configured: + return {'total': 0, 'records': []} + + async with service.get_api_client() as api: + result = await api.get_subscription_request_history(panel_uuid, offset=offset, limit=limit) + return result + except Exception as e: + logger.error('Error getting subscription request history', user_id=user_id, error=e) + return {'total': 0, 'records': []} + + @router.get('/{user_id}/node-usage', response_model=UserNodeUsageResponse) async def get_user_node_usage( user_id: int, @@ -1743,9 +1792,25 @@ async def block_user( admin: User = Depends(require_permission('users:block')), db: AsyncSession = Depends(get_cabinet_db), ): - """Block a user (shortcut for status update).""" - request = UpdateUserStatusRequest(status=UserStatusEnum.BLOCKED, reason=reason) - return await update_user_status(user_id, request, admin, db) + """Block a user — sets DB status AND disables panel user in RemnaWave.""" + from app.services.user_service import UserService + + user_service = UserService() + success = await user_service.block_user( + db, + user_id, + admin.id, + reason=reason or 'Заблокирован администратором', + ) + if not success: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found or block failed') + + return UpdateUserStatusResponse( + success=True, + old_status='active', + new_status='blocked', + message='User blocked', + ) @router.post('/{user_id}/unblock', response_model=UpdateUserStatusResponse) @@ -1754,9 +1819,20 @@ async def unblock_user( admin: User = Depends(require_permission('users:block')), db: AsyncSession = Depends(get_cabinet_db), ): - """Unblock a user (shortcut for status update).""" - request = UpdateUserStatusRequest(status=UserStatusEnum.ACTIVE) - return await update_user_status(user_id, request, admin, db) + """Unblock a user — sets DB status AND re-enables panel user in RemnaWave.""" + from app.services.user_service import UserService + + user_service = UserService() + success = await user_service.unblock_user(db, user_id, admin.id) + if not success: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found or unblock failed') + + return UpdateUserStatusResponse( + success=True, + old_status='blocked', + new_status='active', + message='User unblocked', + ) # === Restrictions Management === @@ -3159,7 +3235,7 @@ async def sync_user_from_panel( int(panel_user.traffic_limit_bytes / (1024**3)) if panel_user.traffic_limit_bytes else 100 ) panel_expire_utc = panel_datetime_to_utc(panel_user.expire_at) - days_remaining = max(1, (panel_expire_utc - datetime.now(UTC)).days) + days_remaining = max(1, math.ceil((panel_expire_utc - datetime.now(UTC)).total_seconds() / 86400)) new_sub = await create_paid_subscription( db=db, @@ -3362,7 +3438,10 @@ async def sync_user_to_panel( await api.update_user(**update_kwargs) action = 'updated' except Exception as update_error: - if hasattr(update_error, 'status_code') and update_error.status_code == 404: + error_code = (getattr(update_error, 'response_data', None) or {}).get('errorCode', '') + if ( + hasattr(update_error, 'status_code') and update_error.status_code == 404 + ) or error_code == 'A018': # User not found in panel, create new panel_uuid = None else: diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py index 130c2d50..c437ab7a 100644 --- a/app/cabinet/routes/balance.py +++ b/app/cabinet/routes/balance.py @@ -1206,15 +1206,20 @@ async def get_latest_payment_by_method( from sqlalchemy.orm import selectinload from app.database.models import ( + AuraPayPayment, CloudPaymentsPayment, CryptoBotPayment, FreekassaPayment, HeleketPayment, KassaAiPayment, MulenPayPayment, + OverpayPayment, Pal24Payment, + PayPearPayment, PlategaPayment, RioPayPayment, + RollyPayPayment, + SeverPayPayment, WataPayment, YooKassaPayment, ) @@ -1231,6 +1236,11 @@ async def get_latest_payment_by_method( PaymentMethod.FREEKASSA: FreekassaPayment, PaymentMethod.KASSA_AI: KassaAiPayment, PaymentMethod.RIOPAY: RioPayPayment, + PaymentMethod.SEVERPAY: SeverPayPayment, + PaymentMethod.ROLLYPAY: RollyPayPayment, + PaymentMethod.PAYPEAR: PayPearPayment, + PaymentMethod.OVERPAY: OverpayPayment, + PaymentMethod.AURAPAY: AuraPayPayment, } model = model_map.get(payment_method) diff --git a/app/cabinet/routes/media.py b/app/cabinet/routes/media.py index 23f5c955..0cb68d61 100644 --- a/app/cabinet/routes/media.py +++ b/app/cabinet/routes/media.py @@ -99,25 +99,35 @@ async def upload_media( bot = create_bot() try: + # Send with disable_notification to avoid pinging admins — this is just staging if media_type_normalized == 'photo': message = await bot.send_photo( chat_id=target_chat_id, photo=upload, + disable_notification=True, ) media = message.photo[-1] elif media_type_normalized == 'video': message = await bot.send_video( chat_id=target_chat_id, video=upload, + disable_notification=True, ) media = message.video else: message = await bot.send_document( chat_id=target_chat_id, document=upload, + disable_notification=True, ) media = message.document + # Delete the staging message immediately — file_id persists after deletion + try: + await bot.delete_message(chat_id=target_chat_id, message_id=message.message_id) + except Exception: + pass # Best-effort cleanup — file_id is already captured + media_url = _build_media_url(request, media.file_id) logger.info( diff --git a/app/cabinet/routes/polls.py b/app/cabinet/routes/polls.py index 30f623c1..9b7b1457 100644 --- a/app/cabinet/routes/polls.py +++ b/app/cabinet/routes/polls.py @@ -144,7 +144,7 @@ async def get_available_polls( selectinload(PollResponse.poll).selectinload(Poll.questions), selectinload(PollResponse.answers), ) - .order_by(PollResponse.created_at.desc()) + .order_by(PollResponse.sent_at.desc()) ) responses = result.scalars().all() diff --git a/app/cabinet/routes/promocode.py b/app/cabinet/routes/promocode.py index 720bc6fa..c2806696 100644 --- a/app/cabinet/routes/promocode.py +++ b/app/cabinet/routes/promocode.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession +from app.config import settings from app.database.models import User from app.services.promocode_service import PromoCodeService @@ -67,6 +68,29 @@ async def activate_promocode( balance_before_rubles = result.get('balance_before_kopeks', 0) / 100 balance_after_rubles = result.get('balance_after_kopeks', 0) / 100 + # Send admin notification (same as bot handler) + if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN: + try: + from aiogram import Bot + + from app.services.admin_notification_service import AdminNotificationService + + bot = Bot(token=settings.BOT_TOKEN) + try: + notification_service = AdminNotificationService(bot) + await notification_service.send_promocode_activation_notification( + db, + user, + result.get('promocode', {'code': request.code.strip()}), + result.get('description', ''), + result.get('balance_before_kopeks'), + result.get('balance_after_kopeks'), + ) + finally: + await bot.session.close() + except Exception: + pass + return PromocodeActivateResponse( success=True, message='Promo code activated successfully', diff --git a/app/cabinet/routes/subscription_modules/autopay.py b/app/cabinet/routes/subscription_modules/autopay.py index af080522..d05037b2 100644 --- a/app/cabinet/routes/subscription_modules/autopay.py +++ b/app/cabinet/routes/subscription_modules/autopay.py @@ -49,7 +49,8 @@ async def update_autopay( ) # Триальные подписки — пробник, автопродление не имеет смысла - if subscription.is_trial: + # NULL-safe: is_trial can be None in legacy rows — treat as trial + if subscription.is_trial is not False: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail='Autopay is not available for trial subscriptions', diff --git a/app/cabinet/routes/subscription_modules/devices.py b/app/cabinet/routes/subscription_modules/devices.py index 60728108..90a8ab60 100644 --- a/app/cabinet/routes/subscription_modules/devices.py +++ b/app/cabinet/routes/subscription_modules/devices.py @@ -13,6 +13,7 @@ POST /subscription/devices/save-cart from __future__ import annotations +import math from datetime import UTC, datetime from typing import Any @@ -380,7 +381,7 @@ async def purchase_devices( if end_date.tzinfo is None: end_date = end_date.replace(tzinfo=UTC) - days_left = max(1, (end_date - now).days) + days_left = max(1, math.ceil((end_date - now).total_seconds() / 86400)) total_days = 30 # Base period for device price calculation # Устройства в пределах тарифного лимита — бесплатные @@ -658,7 +659,7 @@ async def save_devices_cart( if end_date.tzinfo is None: end_date = end_date.replace(tzinfo=UTC) - days_left = max(1, (end_date - now).days) + days_left = max(1, math.ceil((end_date - now).total_seconds() / 86400)) total_days = 30 # Устройства в пределах тарифного лимита — бесплатные @@ -772,7 +773,7 @@ async def get_device_price( if end_date.tzinfo is None: end_date = end_date.replace(tzinfo=UTC) - days_left = max(1, (end_date - now).days) + days_left = max(1, math.ceil((end_date - now).total_seconds() / 86400)) total_days = 30 # Устройства в пределах тарифного лимита — бесплатные diff --git a/app/cabinet/routes/subscription_modules/purchase.py b/app/cabinet/routes/subscription_modules/purchase.py index 1c56429d..76779fbc 100644 --- a/app/cabinet/routes/subscription_modules/purchase.py +++ b/app/cabinet/routes/subscription_modules/purchase.py @@ -895,8 +895,14 @@ async def purchase_tariff( except Exception as trial_err: logger.warning('Failed to disable trial on RemnaWave', error=trial_err, trial_id=trial_sub.id) try: - if subscription.remnawave_uuid: - # Existing subscription with Remnawave user — update it + # Mirror the bot handler logic: in single-tariff mode, check user.remnawave_uuid + # (webhook clears it on panel deletion), not subscription.remnawave_uuid + if settings.is_multi_tariff_enabled(): + _should_create = not subscription.remnawave_uuid + else: + _should_create = not getattr(user, 'remnawave_uuid', None) + + if not _should_create: await service.update_remnawave_user( db, subscription, @@ -905,7 +911,6 @@ async def purchase_tariff( sync_squads=True, ) else: - # New subscription — create new Remnawave user await service.create_remnawave_user( db, subscription, @@ -919,7 +924,7 @@ async def purchase_tariff( remnawave_retry_queue.enqueue( subscription_id=subscription.id, user_id=user.id, - action='create' if not subscription.remnawave_uuid else 'update', + action='create' if _should_create else 'update', ) # Save cart for auto-renewal (not for daily tariffs - they have their own charging) diff --git a/app/cabinet/routes/subscription_modules/traffic.py b/app/cabinet/routes/subscription_modules/traffic.py index 4a186a43..8c5553ba 100644 --- a/app/cabinet/routes/subscription_modules/traffic.py +++ b/app/cabinet/routes/subscription_modules/traffic.py @@ -9,6 +9,7 @@ POST /subscription/traffic/save-cart from __future__ import annotations +import math from datetime import UTC, datetime from typing import Any @@ -478,7 +479,7 @@ async def save_traffic_cart( from app.utils.pricing_utils import calculate_prorated_price as _calc_prorated now = datetime.now(UTC) - days_left = max(1, (subscription.end_date - now).days) + days_left = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400)) prorated_price, _ = _calc_prorated( base_price_kopeks, subscription.end_date, diff --git a/app/cabinet/schemas/bulk_actions.py b/app/cabinet/schemas/bulk_actions.py index 26587fd5..e7cbb2a6 100644 --- a/app/cabinet/schemas/bulk_actions.py +++ b/app/cabinet/schemas/bulk_actions.py @@ -29,6 +29,7 @@ class BulkActionParams(BaseModel): promo_group_id: int | None = None device_limit: int | None = Field(None, ge=1, le=50) delete_from_panel: bool = Field(default=True) + force_delete_active_paid: bool = Field(default=False) class BulkSubscriptionInfo(BaseModel): diff --git a/app/cabinet/schemas/users.py b/app/cabinet/schemas/users.py index 3b48e2f9..f151b7c2 100644 --- a/app/cabinet/schemas/users.py +++ b/app/cabinet/schemas/users.py @@ -89,6 +89,7 @@ class SubscriptionListItem(BaseModel): tariff_id: int | None = None tariff_name: str | None = None status: str + is_trial: bool = False end_date: datetime | None = None days_remaining: int = 0 traffic_used_gb: float = 0 diff --git a/app/config.py b/app/config.py index 2281e5ef..662c7bdd 100644 --- a/app/config.py +++ b/app/config.py @@ -1,5 +1,3 @@ -import hashlib -import hmac import html import os import re @@ -67,6 +65,18 @@ class Settings(BaseSettings): ADMIN_NOTIFICATIONS_PROMO_TOPIC_ID: int | None = None # Промокоды, кампании, промогруппы ADMIN_NOTIFICATIONS_PARTNERS_TOPIC_ID: int | None = None # Партнёрки, выводы, админ-действия + # Per-category enable/disable (default True for backwards compatibility) + ADMIN_NOTIFICATIONS_PURCHASES_ENABLED: bool = True + ADMIN_NOTIFICATIONS_RENEWALS_ENABLED: bool = True + ADMIN_NOTIFICATIONS_TRIALS_ENABLED: bool = True + ADMIN_NOTIFICATIONS_BALANCE_ENABLED: bool = True + ADMIN_NOTIFICATIONS_ADDONS_ENABLED: bool = True + ADMIN_NOTIFICATIONS_INFRASTRUCTURE_ENABLED: bool = True + ADMIN_NOTIFICATIONS_ERRORS_ENABLED: bool = True + ADMIN_NOTIFICATIONS_PROMO_ENABLED: bool = True + ADMIN_NOTIFICATIONS_PARTNERS_ENABLED: bool = True + ADMIN_NOTIFICATIONS_TICKETS_ENABLED: bool = True + # Настройки очереди чеков NaloGO NALOGO_QUEUE_CHECK_INTERVAL: int = 600 # Интервал проверки очереди (секунды, 10 мин) NALOGO_QUEUE_RECEIPT_DELAY: int = 3 # Задержка между отправкой чеков (секунды) @@ -844,9 +854,6 @@ class Settings(BaseSettings): BACKUP_SEND_TOPIC_ID: int | None = None BACKUP_ARCHIVE_PASSWORD: str | None = None - EXTERNAL_ADMIN_TOKEN: str | None = None - EXTERNAL_ADMIN_TOKEN_BOT_ID: int | None = None - # Cabinet (Personal Account) settings CABINET_ENABLED: bool = False CABINET_JWT_SECRET: str | None = None @@ -1653,37 +1660,6 @@ class Settings(BaseSettings): def get_app_config_cache_ttl(self) -> int: return self.APP_CONFIG_CACHE_TTL - def build_external_admin_token(self, bot_username: str) -> str: - """Генерирует детерминированный и криптографически стойкий токен внешней админки.""" - normalized = (bot_username or '').strip().lstrip('@').lower() - if not normalized: - raise ValueError('Bot username is required to build external admin token') - - secret = (self.BOT_TOKEN or '').strip() - if not secret: - raise ValueError('Bot token is required to build external admin token') - - digest = hmac.new( - key=secret.encode('utf-8'), - msg=f'remnawave.external_admin::{normalized}'.encode(), - digestmod=hashlib.sha256, - ).hexdigest() - return digest[:48] - - def get_external_admin_token(self) -> str | None: - token = (self.EXTERNAL_ADMIN_TOKEN or '').strip() - return token or None - - def get_external_admin_bot_id(self) -> int | None: - try: - return int(self.EXTERNAL_ADMIN_TOKEN_BOT_ID) if self.EXTERNAL_ADMIN_TOKEN_BOT_ID else None - except (TypeError, ValueError): # pragma: no cover - защитная ветка для некорректных значений - logger.warning( - 'Некорректный идентификатор бота для внешней админки', - EXTERNAL_ADMIN_TOKEN_BOT_ID=self.EXTERNAL_ADMIN_TOKEN_BOT_ID, - ) - return None - def is_traffic_selectable(self) -> bool: return self.TRAFFIC_SELECTION_MODE.lower() == 'selectable' diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index d05ce143..28496601 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -1,3 +1,4 @@ +import math import secrets from collections.abc import Iterable from datetime import UTC, datetime, timedelta @@ -1296,7 +1297,7 @@ async def add_subscription_servers( if paid_prices is None: now = datetime.now(UTC) - days_remaining = max(1, (subscription.end_date - now).days) + days_remaining = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400)) paid_prices = [] from app.database.models import ServerSquad diff --git a/app/database/crud/transaction.py b/app/database/crud/transaction.py index 3a765213..a2d6f490 100644 --- a/app/database/crud/transaction.py +++ b/app/database/crud/transaction.py @@ -27,6 +27,10 @@ REAL_PAYMENT_METHODS = [ PaymentMethod.KASSA_AI.value, PaymentMethod.RIOPAY.value, PaymentMethod.SEVERPAY.value, + PaymentMethod.ROLLYPAY.value, + PaymentMethod.PAYPEAR.value, + PaymentMethod.OVERPAY.value, + PaymentMethod.AURAPAY.value, ] diff --git a/app/external/remnawave_api.py b/app/external/remnawave_api.py index 27e0f31d..17fd36f2 100644 --- a/app/external/remnawave_api.py +++ b/app/external/remnawave_api.py @@ -541,6 +541,27 @@ class RemnaWaveAPI: return [] raise + async def get_subscription_request_history( + self, + uuid: str, + offset: int = 0, + limit: int = 20, + ) -> dict: + """Get subscription request history for a panel user. + + Returns dict with 'total' and 'records' list. + Each record has: id, userUuid, requestAt, requestIp, userAgent. + """ + try: + response = await self._make_request( + 'GET', + f'/api/users/{uuid}/subscription-request-history', + params={'offset': offset, 'limit': limit}, + ) + return response.get('response', {'total': 0, 'records': []}) + except RemnaWaveAPIError: + return {'total': 0, 'records': []} + async def update_user( self, uuid: str, diff --git a/app/handlers/admin/backup.py b/app/handlers/admin/backup.py index 10f3630a..778e11d6 100644 --- a/app/handlers/admin/backup.py +++ b/app/handlers/admin/backup.py @@ -313,7 +313,7 @@ async def restore_backup_start(callback: types.CallbackQuery, db_user: User, db: else: text = """📥 Восстановление из бекапа -📎 Отправьте файл бекапа (.json или .json.gz) +📎 Отправьте файл бекапа (.json, .json.gz или .tar.gz) ⚠️ ВАЖНО: • Файл должен быть создан этой системой бекапов @@ -383,7 +383,7 @@ async def restore_backup_execute(callback: types.CallbackQuery, db_user: User, d async def handle_backup_file_upload(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext): if not message.document: await message.answer( - '❌ Пожалуйста, отправьте файл бекапа (.json или .json.gz)', + '❌ Пожалуйста, отправьте файл бекапа (.json, .json.gz или .tar.gz)', reply_markup=InlineKeyboardMarkup( inline_keyboard=[[InlineKeyboardButton(text='◀️ Отмена', callback_data='backup_panel')]] ), @@ -391,10 +391,11 @@ async def handle_backup_file_upload(message: types.Message, db_user: User, db: A return document = message.document + allowed_extensions = ('.json', '.json.gz', '.tar.gz', '.tar') - if not (document.file_name.endswith('.json') or document.file_name.endswith('.json.gz')): + if not document.file_name or not any(document.file_name.endswith(ext) for ext in allowed_extensions): await message.answer( - '❌ Неподдерживаемый формат файла. Загрузите .json или .json.gz файл', + '❌ Неподдерживаемый формат файла. Загрузите .json, .json.gz или .tar.gz файл', reply_markup=InlineKeyboardMarkup( inline_keyboard=[[InlineKeyboardButton(text='◀️ Отмена', callback_data='backup_panel')]] ), diff --git a/app/handlers/admin/bot_configuration.py b/app/handlers/admin/bot_configuration.py index 6ca53208..106f191d 100644 --- a/app/handlers/admin/bot_configuration.py +++ b/app/handlers/admin/bot_configuration.py @@ -158,7 +158,6 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = { 'LOG', 'MODERATION', 'DEBUG', - 'EXTERNAL_ADMIN', ), }, } diff --git a/app/handlers/referral.py b/app/handlers/referral.py index ee91eb32..c7e2374e 100644 --- a/app/handlers/referral.py +++ b/app/handlers/referral.py @@ -120,8 +120,7 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A # Show bot link referral_text += ( - texts.t('REFERRAL_BOT_LINK_TITLE', '🤖 Ссылка на бота:') - + f'\n{html_escape(bot_referral_link)}\n' + texts.t('REFERRAL_BOT_LINK_TITLE', '🤖 Ссылка на бота:') + f'\n{html_escape(bot_referral_link)}\n' ) # Show cabinet link if configured @@ -129,7 +128,7 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A referral_text += ( '\n' + texts.t('REFERRAL_CABINET_LINK_TITLE', '🌐 Ссылка на кабинет:') - + f'\n{html_escape(cabinet_referral_link)}\n' + + f'\n{html_escape(cabinet_referral_link)}\n' ) referral_text += ( @@ -551,7 +550,7 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User): 'Нажмите на текст ниже, чтобы скопировать:', ) + '\n\n' - f'
{html_escape(invite_text)}
' + f'
{html_escape(invite_text)}
' ), keyboard, ) diff --git a/app/handlers/subscription/autopay.py b/app/handlers/subscription/autopay.py index 25f8bab6..7f78274b 100644 --- a/app/handlers/subscription/autopay.py +++ b/app/handlers/subscription/autopay.py @@ -101,6 +101,18 @@ async def toggle_autopay(callback: types.CallbackQuery, db_user: User, db: Async enable = callback.data.startswith('autopay_enable') if enable: + # Trial subscriptions cannot use autopay + if subscription.is_trial or subscription.is_trial is None: + texts = get_texts(db_user.language) + await callback.answer( + texts.t( + 'AUTOPAY_NOT_AVAILABLE_TRIAL', + 'Автоплатеж недоступен для пробных подписок.', + ), + show_alert=True, + ) + return + # Classic subscriptions cannot use autopay when tariff mode is enabled if settings.is_tariffs_mode() and not subscription.tariff_id: texts = get_texts(db_user.language) diff --git a/app/handlers/subscription/common.py b/app/handlers/subscription/common.py index 3d00b2d1..c33f86b2 100644 --- a/app/handlers/subscription/common.py +++ b/app/handlers/subscription/common.py @@ -1,6 +1,7 @@ import asyncio import base64 import html as html_mod +import math import re import time from datetime import UTC, datetime @@ -545,7 +546,7 @@ def get_traffic_switch_keyboard( # Считаем по дням (как в кабинете и подтверждении) if subscription_end_date: now = datetime.now(UTC) - days_left = max(1, (subscription_end_date - now).days) + days_left = max(1, math.ceil((subscription_end_date - now).total_seconds() / 86400)) price_multiplier = days_left / 30 period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)' else: diff --git a/app/handlers/subscription/countries.py b/app/handlers/subscription/countries.py index 43beab0f..4613ebd1 100644 --- a/app/handlers/subscription/countries.py +++ b/app/handlers/subscription/countries.py @@ -1,4 +1,5 @@ import html +import math from datetime import UTC, datetime from aiogram import types @@ -266,7 +267,7 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User, logger.info('🔧 Добавлено: Удалено', added=added, removed=removed) now = datetime.now(UTC) - days_to_pay = max(1, (subscription.end_date - now).days) + days_to_pay = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400)) period_hint_days = days_to_pay if days_to_pay > 0 else None diff --git a/app/handlers/subscription/devices.py b/app/handlers/subscription/devices.py index 63087c89..082b5d1c 100644 --- a/app/handlers/subscription/devices.py +++ b/app/handlers/subscription/devices.py @@ -1,4 +1,5 @@ import html as html_mod +import math from datetime import UTC, datetime from aiogram import types @@ -343,7 +344,7 @@ async def confirm_change_devices( # Считаем стоимость по оставшимся дням подписки now = datetime.now(UTC) - days_left = max(1, (subscription.end_date - now).days) + days_left = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400)) period_hint_days = days_left devices_discount_percent = PricingEngine.get_addon_discount_percent( @@ -572,7 +573,7 @@ async def execute_change_devices( chargeable_devices = devices_difference devices_price_per_month = chargeable_devices * price_per_device - days_left = max(1, (subscription.end_date - datetime.now(UTC)).days) + days_left = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400)) devices_discount_percent = PricingEngine.get_addon_discount_percent( db_user, 'devices', @@ -601,7 +602,7 @@ async def execute_change_devices( ) return - charged_days = max(1, (subscription.end_date - datetime.now(UTC)).days) + charged_days = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400)) await create_transaction( db=db, user_id=db_user.id, @@ -1253,7 +1254,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db: if is_daily_tariff: # Для суточных тарифов считаем по дням (как в кабинете) now = datetime.now(UTC) - days_left = max(1, (subscription.end_date - now).days) + days_left = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400)) period_hint_days = days_left devices_discount_percent = PricingEngine.get_addon_discount_percent( @@ -1274,7 +1275,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db: else: # Для обычных тарифов - по дням (как в кабинете) now = datetime.now(UTC) - days_left = max(1, (subscription.end_date - now).days) + days_left = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400)) period_hint_days = days_left devices_discount_percent = PricingEngine.get_addon_discount_percent( diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index 870c61d2..174c965c 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -939,6 +939,13 @@ async def handle_custom_confirm( await callback.answer('Недостаточно средств на балансе', show_alert=True) return + # Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции), + # иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest + try: + await callback.answer() + except Exception: + pass + texts = get_texts(db_user.language) # Save promo offer state before deduction (for restore on failure) @@ -958,11 +965,17 @@ async def handle_custom_confirm( mark_as_paid_subscription=True, ) if not success: - await callback.answer('Ошибка списания баланса', show_alert=True) + try: + await callback.message.edit_text('❌ Ошибка списания баланса') + except Exception: + pass return except Exception as e: logger.error('Ошибка списания баланса при покупке кастомного тарифа', error=e, exc_info=True) - await callback.answer('Ошибка списания баланса', show_alert=True) + try: + await callback.message.edit_text('❌ Ошибка списания баланса') + except Exception: + pass return # Получаем список серверов из тарифа @@ -1049,7 +1062,10 @@ async def handle_custom_confirm( price_kopeks=total_price, refund_error=refund_error, ) - await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True) + try: + await callback.message.edit_text('❌ Произошла ошибка при оформлении подписки') + except Exception: + pass return try: @@ -1148,11 +1164,12 @@ async def handle_custom_confirm( ), parse_mode='HTML', ) - await callback.answer('Подписка оформлена!', show_alert=True) - except Exception as e: logger.error('Ошибка при покупке тарифа с кастомными параметрами', error=e, exc_info=True) - await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True) + try: + await callback.message.edit_text('❌ Произошла ошибка при оформлении подписки') + except Exception: + pass @error_handler @@ -1377,6 +1394,13 @@ async def confirm_tariff_purchase( await callback.answer('Недостаточно средств на балансе', show_alert=True) return + # Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции), + # иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest + try: + await callback.answer() + except Exception: + pass + texts = get_texts(db_user.language) # Списываем баланс @@ -1395,11 +1419,17 @@ async def confirm_tariff_purchase( mark_as_paid_subscription=True, ) if not success: - await callback.answer('Ошибка списания баланса', show_alert=True) + try: + await callback.message.edit_text('❌ Ошибка списания баланса') + except Exception: + pass return except Exception as e: logger.error('Ошибка списания баланса при покупке тарифа', error=e, exc_info=True) - await callback.answer('Ошибка списания баланса', show_alert=True) + try: + await callback.message.edit_text('❌ Ошибка списания баланса') + except Exception: + pass return # Получаем список серверов из тарифа @@ -1457,10 +1487,12 @@ async def confirm_tariff_purchase( db_user.promo_offer_discount_source = saved_promo_source db_user.promo_offer_discount_expires_at = saved_promo_expires await db.commit() - await callback.answer( - f'Максимум подписок: {settings.get_max_active_subscriptions()}', - show_alert=True, - ) + try: + await callback.message.edit_text( + f'❌ Максимум подписок: {settings.get_max_active_subscriptions()}' + ) + except Exception: + pass return # Create NEW subscription for this tariff (multi-tariff: new Remnawave user) @@ -1537,7 +1569,10 @@ async def confirm_tariff_purchase( reason='Возврат: тариф уже активен', error=refund_error, ) - await callback.answer('У вас уже есть активная подписка на этот тариф', show_alert=True) + try: + await callback.message.edit_text('❌ У вас уже есть активная подписка на этот тариф') + except Exception: + pass return except Exception as e: logger.error('Ошибка создания/продления подписки при покупке тарифа', error=e, exc_info=True) @@ -1581,7 +1616,10 @@ async def confirm_tariff_purchase( reason='Возврат: ошибка покупки тарифа', error=refund_error, ) - await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True) + try: + await callback.message.edit_text('❌ Произошла ошибка при оформлении подписки') + except Exception: + pass return # Обновляем пользователя в Remnawave @@ -1686,7 +1724,6 @@ async def confirm_tariff_purchase( ), parse_mode='HTML', ) - await callback.answer('Подписка оформлена!', show_alert=True) # ==================== Покупка суточного тарифа ==================== @@ -1741,6 +1778,13 @@ async def confirm_daily_tariff_purchase( await callback.answer('Недостаточно средств на балансе', show_alert=True) return + # Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции), + # иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest + try: + await callback.answer() + except Exception: + pass + texts = get_texts(db_user.language) try: @@ -1754,11 +1798,17 @@ async def confirm_daily_tariff_purchase( mark_as_paid_subscription=True, ) if not success: - await callback.answer('Ошибка списания баланса', show_alert=True) + try: + await callback.message.edit_text('❌ Ошибка списания баланса') + except Exception: + pass return except Exception as e: logger.error('Ошибка списания баланса при покупке суточного тарифа', error=e, exc_info=True) - await callback.answer('Ошибка списания баланса', show_alert=True) + try: + await callback.message.edit_text('❌ Ошибка списания баланса') + except Exception: + pass return # Получаем список серверов из тарифа @@ -1864,7 +1914,10 @@ async def confirm_daily_tariff_purchase( price_kopeks=final_daily_price, refund_error=refund_error, ) - await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True) + try: + await callback.message.edit_text('❌ Произошла ошибка при оформлении подписки') + except Exception: + pass return # Обновляем пользователя в Remnawave @@ -1964,7 +2017,6 @@ async def confirm_daily_tariff_purchase( ), parse_mode='HTML', ) - await callback.answer('Подписка оформлена!', show_alert=True) # ==================== Продление по тарифу ==================== @@ -2364,6 +2416,13 @@ async def confirm_tariff_extend( await callback.answer('Недостаточно средств на балансе', show_alert=True) return + # Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции), + # иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest + try: + await callback.answer() + except Exception: + pass + texts = get_texts(db_user.language) try: @@ -2377,7 +2436,10 @@ async def confirm_tariff_extend( mark_as_paid_subscription=True, ) if not success: - await callback.answer('Ошибка списания баланса', show_alert=True) + try: + await callback.message.edit_text('❌ Ошибка списания баланса') + except Exception: + pass return # Запоминаем, был ли триал ДО продления @@ -2487,11 +2549,12 @@ async def confirm_tariff_extend( ), parse_mode='HTML', ) - await callback.answer('Подписка продлена!', show_alert=True) - except Exception as e: logger.error('Ошибка при продлении тарифа', error=e, exc_info=True) - await callback.answer('Произошла ошибка при продлении подписки', show_alert=True) + try: + await callback.message.edit_text('❌ Произошла ошибка при продлении подписки') + except Exception: + pass # ==================== Переключение тарифов ==================== @@ -3031,6 +3094,13 @@ async def confirm_tariff_switch( await callback.answer('Недостаточно средств на балансе', show_alert=True) return + # Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции), + # иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest + try: + await callback.answer() + except Exception: + pass + texts = get_texts(db_user.language) try: @@ -3044,7 +3114,10 @@ async def confirm_tariff_switch( mark_as_paid_subscription=True, ) if not success: - await callback.answer('Ошибка списания баланса', show_alert=True) + try: + await callback.message.edit_text('❌ Ошибка списания баланса') + except Exception: + pass return # Получаем список серверов из тарифа @@ -3198,11 +3271,13 @@ async def confirm_tariff_switch( ), parse_mode='HTML', ) - await callback.answer('Тариф изменён!', show_alert=True) except Exception as e: logger.error('Ошибка при переключении тарифа', error=e, exc_info=True) - await callback.answer('Произошла ошибка при переключении тарифа', show_alert=True) + try: + await callback.message.edit_text('❌ Произошла ошибка при переключении тарифа') + except Exception: + pass # ==================== Смена на суточный тариф ==================== @@ -3276,6 +3351,13 @@ async def confirm_daily_tariff_switch( await callback.answer('Понижение тарифа недоступно', show_alert=True) return + # Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции), + # иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest + try: + await callback.answer() + except Exception: + pass + texts = get_texts(db_user.language) try: @@ -3289,7 +3371,10 @@ async def confirm_daily_tariff_switch( mark_as_paid_subscription=True, ) if not success: - await callback.answer('Ошибка списания баланса', show_alert=True) + try: + await callback.message.edit_text('❌ Ошибка списания баланса') + except Exception: + pass return # Получаем список серверов из тарифа @@ -3445,7 +3530,6 @@ async def confirm_daily_tariff_switch( ), parse_mode='HTML', ) - await callback.answer('Тариф изменён!', show_alert=True) except Exception as e: logger.error('Ошибка при смене на суточный тариф', error=e, exc_info=True) @@ -3478,7 +3562,10 @@ async def confirm_daily_tariff_switch( price_kopeks=final_daily_price, refund_error=refund_error, ) - await callback.answer('Произошла ошибка при смене тарифа', show_alert=True) + try: + await callback.message.edit_text('❌ Произошла ошибка при смене тарифа') + except Exception: + pass # ==================== Мгновенное переключение тарифов (без выбора периода) ==================== @@ -3976,6 +4063,13 @@ async def confirm_instant_switch( await callback.answer('Недостаточно средств на балансе', show_alert=True) return + # Отвечаем на callback СРАЗУ — до тяжёлых операций (панель, транзакции), + # иначе Telegram инвалидирует query через 30 сек → TelegramBadRequest + try: + await callback.answer() + except Exception: + pass + texts = get_texts(db_user.language) try: @@ -3991,7 +4085,10 @@ async def confirm_instant_switch( mark_as_paid_subscription=True, ) if not success: - await callback.answer('Ошибка списания баланса', show_alert=True) + try: + await callback.message.edit_text('❌ Ошибка списания баланса') + except Exception: + pass return # Получаем список серверов из нового тарифа @@ -4058,7 +4155,10 @@ async def confirm_instant_switch( mark_as_paid_subscription=True, ) if not success: - await callback.answer('❌ Недостаточно средств', show_alert=True) + try: + await callback.message.edit_text('❌ Недостаточно средств') + except Exception: + pass return await create_transaction( db, @@ -4232,11 +4332,13 @@ async def confirm_instant_switch( ), parse_mode='HTML', ) - await callback.answer('Тариф изменён!', show_alert=True) except Exception as e: logger.error('Ошибка при мгновенном переключении тарифа', error=e, exc_info=True) - await callback.answer('Произошла ошибка при переключении тарифа', show_alert=True) + try: + await callback.message.edit_text('❌ Произошла ошибка при переключении тарифа') + except Exception: + pass async def return_to_saved_tariff_cart( diff --git a/app/handlers/subscription/traffic.py b/app/handlers/subscription/traffic.py index f6ee44e6..f2c7e61d 100644 --- a/app/handlers/subscription/traffic.py +++ b/app/handlers/subscription/traffic.py @@ -1,3 +1,4 @@ +import math from datetime import UTC, datetime from aiogram import types @@ -807,7 +808,7 @@ async def confirm_switch_traffic( new_price_per_month = settings.get_traffic_price(new_traffic_gb) now = datetime.now(UTC) - days_remaining = max(1, (subscription.end_date - now).days) + days_remaining = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400)) period_hint_days = days_remaining if days_remaining > 0 else None traffic_discount_percent = PricingEngine.get_addon_discount_percent( db_user, @@ -911,7 +912,7 @@ async def execute_switch_traffic( base_traffic = current_traffic - purchased_traffic old_price_per_month = settings.get_traffic_price(base_traffic) new_price_per_month = settings.get_traffic_price(new_traffic_gb) - days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days) + days_remaining = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400)) traffic_discount_percent = PricingEngine.get_addon_discount_percent( db_user, 'traffic', @@ -936,7 +937,7 @@ async def execute_switch_traffic( await callback.answer('⚠️ Ошибка списания средств', show_alert=True) return - days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days) + days_remaining = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400)) await create_transaction( db=db, user_id=db_user.id, diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index b7934210..5b6dc0d9 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1,3 +1,4 @@ +import math from datetime import UTC, datetime import structlog @@ -2169,7 +2170,7 @@ def get_add_traffic_keyboard( # Считаем по дням (как в кабинете и подтверждении) if subscription_end_date: now = datetime.now(UTC) - days_left = max(1, (subscription_end_date - now).days) + days_left = max(1, math.ceil((subscription_end_date - now).total_seconds() / 86400)) price_multiplier = days_left / 30 period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)' else: @@ -2311,7 +2312,7 @@ def get_change_devices_keyboard( # Считаем по дням (как в кабинете и подтверждении) if subscription_end_date: now = datetime.now(UTC) - days_left = max(1, (subscription_end_date - now).days) + days_left = max(1, math.ceil((subscription_end_date - now).total_seconds() / 86400)) price_multiplier = days_left / 30 period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)' else: @@ -2473,7 +2474,7 @@ def get_manage_countries_keyboard( # Считаем по дням (как в кабинете и подтверждении) if subscription_end_date: now = datetime.now(UTC) - days_left = max(1, (subscription_end_date - now).days) + days_left = max(1, math.ceil((subscription_end_date - now).total_seconds() / 86400)) price_multiplier = days_left / 30 logger.info( '🔍 Расчет для управления странами: осталось дней до', diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py index de42aaa8..f156ef1c 100644 --- a/app/services/admin_notification_service.py +++ b/app/services/admin_notification_service.py @@ -67,6 +67,12 @@ class AdminNotificationService: NotificationCategory.TICKETS: self.ticket_topic_id, } + # Per-category enabled flags (default True — backwards compatible) + self.category_enabled: dict[NotificationCategory, bool] = {} + for cat in NotificationCategory: + key = f'ADMIN_NOTIFICATIONS_{cat.value.upper()}_ENABLED' + self.category_enabled[cat] = getattr(settings, key, True) + async def _get_referrer_info(self, db: AsyncSession, referred_by_id: int | None) -> str: if not referred_by_id: return 'Нет' @@ -1266,6 +1272,11 @@ class AdminNotificationService: logger.warning('ADMIN_NOTIFICATIONS_CHAT_ID не настроен') return False + # Per-category suppression + if category and not self.category_enabled.get(category, True): + logger.debug('Уведомление подавлено (категория отключена)', category=category.value) + return False + try: message_kwargs = { 'chat_id': self.chat_id, diff --git a/app/services/backup_service.py b/app/services/backup_service.py index 6fbe1ad9..2d3759d1 100644 --- a/app/services/backup_service.py +++ b/app/services/backup_service.py @@ -31,6 +31,7 @@ from app.database.models import ( AdminRole, AdvertisingCampaign, AdvertisingCampaignRegistration, + AuraPayPayment, BroadcastHistory, ButtonClickLog, CabinetRefreshToken, @@ -40,18 +41,27 @@ from app.database.models import ( ContestTemplate, CryptoBotPayment, DiscountOffer, + EmailTemplate, FaqPage, FaqSetting, FreekassaPayment, + GuestPurchase, HeleketPayment, + InfoPage, KassaAiPayment, + LandingPage, MainMenuButton, MenuLayoutHistory, MonitoringLog, MulenPayPayment, + NewsArticle, + NewsCategory, + NewsTag, + OverpayPayment, Pal24Payment, PartnerApplication, PaymentMethodConfig, + PayPearPayment, PinnedMessage, PlategaPayment, Poll, @@ -71,9 +81,13 @@ from app.database.models import ( ReferralContestVirtualParticipant, ReferralEarning, RequiredChannel, + RioPayPayment, + RollyPayPayment, + SavedPaymentMethod, SentNotification, ServerSquad, ServiceRule, + SeverPayPayment, Squad, Subscription, SubscriptionConversion, @@ -102,6 +116,7 @@ from app.database.models import ( WheelPrize, WheelSpin, WithdrawalRequest, + YandexClientIdMap, YooKassaPayment, payment_method_promo_groups, server_squad_promo_groups, @@ -183,6 +198,13 @@ class BackupService: CloudPaymentsPayment, FreekassaPayment, KassaAiPayment, + RioPayPayment, + SeverPayPayment, + PayPearPayment, + RollyPayPayment, + OverpayPayment, + AuraPayPayment, + SavedPaymentMethod, # --- Settings/content --- PaymentMethodConfig, PrivacyPolicy, @@ -192,6 +214,17 @@ class BackupService: PinnedMessage, MainMenuButton, MenuLayoutHistory, + EmailTemplate, + InfoPage, + # --- News (FK: none / self-contained) --- + NewsCategory, + NewsTag, + NewsArticle, + # --- Landing / Guest purchases (FK: users, tariffs, landings) --- + LandingPage, + GuestPurchase, + # --- Yandex analytics (FK: users) --- + YandexClientIdMap, # --- User data (FK: users, promo_groups, subscriptions) --- UserPromoGroup, TrafficPurchase, @@ -1476,6 +1509,13 @@ class BackupService: 'cloudpayments_payments', 'freekassa_payments', 'kassa_ai_payments', + 'riopay_payments', + 'severpay_payments', + 'paypear_payments', + 'rollypay_payments', + 'overpay_payments', + 'aurapay_payments', + 'saved_payment_methods', # --- Content/config --- 'pinned_messages', 'main_menu_buttons', @@ -1485,6 +1525,17 @@ class BackupService: 'privacy_policies', 'public_offers', 'payment_method_configs', + 'email_templates', + 'info_pages', + # --- News --- + 'news_articles', + 'news_categories', + 'news_tags', + # --- Landing / Guest purchases --- + 'guest_purchases', + 'landing_pages', + # --- Yandex analytics --- + 'yandex_client_id_map', # --- Support --- 'support_audit_logs', 'ticket_messages', diff --git a/app/services/external_admin_service.py b/app/services/external_admin_service.py deleted file mode 100644 index 12a7b797..00000000 --- a/app/services/external_admin_service.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Утилиты для синхронизации токена внешней админки.""" - -from __future__ import annotations - -import structlog -from sqlalchemy import select -from sqlalchemy.exc import SQLAlchemyError - -from app.config import settings -from app.database.database import AsyncSessionLocal -from app.database.models import SystemSetting -from app.services.system_settings_service import ( - ReadOnlySettingError, - bot_configuration_service, -) - - -logger = structlog.get_logger(__name__) - - -async def ensure_external_admin_token( - bot_username: str | None, - bot_id: int | None, -) -> str | None: - """Генерирует и сохраняет токен внешней админки, если требуется.""" - - username_raw = (bot_username or '').strip() - if not username_raw: - logger.warning( - '⚠️ Не удалось обеспечить токен внешней админки: username бота отсутствует', - ) - return None - - normalized_username = username_raw.lstrip('@').lower() - if not normalized_username: - logger.warning( - '⚠️ Не удалось обеспечить токен внешней админки: username пустой после нормализации', - ) - return None - - try: - token = settings.build_external_admin_token(normalized_username) - except Exception as error: # pragma: no cover - защитный блок - logger.error('❌ Ошибка генерации токена внешней админки', error=error) - return None - - try: - async with AsyncSessionLocal() as session: - result = await session.execute( - select(SystemSetting.key, SystemSetting.value).where( - SystemSetting.key.in_(['EXTERNAL_ADMIN_TOKEN', 'EXTERNAL_ADMIN_TOKEN_BOT_ID']) - ) - ) - rows = dict(result.all()) - existing_token = rows.get('EXTERNAL_ADMIN_TOKEN') - existing_bot_id_raw = rows.get('EXTERNAL_ADMIN_TOKEN_BOT_ID') - - existing_bot_id: int | None = None - if existing_bot_id_raw is not None: - try: - existing_bot_id = int(existing_bot_id_raw) - except (TypeError, ValueError): # pragma: no cover - защита от мусорных значений - logger.warning( - '⚠️ Не удалось разобрать сохраненный идентификатор бота внешней админки', - existing_bot_id_raw=existing_bot_id_raw, - ) - - if existing_token == token and existing_bot_id == bot_id: - if settings.get_external_admin_token() != token: - settings.EXTERNAL_ADMIN_TOKEN = token - if existing_bot_id != settings.EXTERNAL_ADMIN_TOKEN_BOT_ID: - settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = existing_bot_id - return token - - if existing_bot_id is not None and bot_id is not None and existing_bot_id != bot_id: - logger.error( - '❌ Обнаружено несовпадение ID бота для токена внешней админки: сохранен , текущий', - existing_bot_id=existing_bot_id, - bot_id=bot_id, - ) - - try: - await bot_configuration_service.reset_value( - session, - 'EXTERNAL_ADMIN_TOKEN', - force=True, - ) - await bot_configuration_service.reset_value( - session, - 'EXTERNAL_ADMIN_TOKEN_BOT_ID', - force=True, - ) - await session.commit() - logger.warning( - '⚠️ Токен внешней админки очищен из-за несовпадения идентификаторов бота', - ) - except Exception as cleanup_error: # pragma: no cover - защитный блок - await session.rollback() - logger.error( - '❌ Не удалось очистить токен внешней админки после обнаружения подмены', - cleanup_error=cleanup_error, - ) - finally: - settings.EXTERNAL_ADMIN_TOKEN = None - settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = None - - return None - - updates: list[tuple[str, object]] = [] - if existing_token != token: - updates.append(('EXTERNAL_ADMIN_TOKEN', token)) - - if bot_id is not None and existing_bot_id != bot_id: - updates.append(('EXTERNAL_ADMIN_TOKEN_BOT_ID', bot_id)) - - if not updates: - # Токен совпал, но могли отсутствовать значения в настройках приложения - if settings.get_external_admin_token() != (existing_token or token): - settings.EXTERNAL_ADMIN_TOKEN = existing_token or token - if existing_bot_id is not None and (existing_bot_id != settings.EXTERNAL_ADMIN_TOKEN_BOT_ID): - settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = existing_bot_id - elif bot_id is not None and bot_id != settings.EXTERNAL_ADMIN_TOKEN_BOT_ID and existing_bot_id is None: - settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = bot_id - return existing_token or token - - try: - for key, value in updates: - await bot_configuration_service.set_value( - session, - key, - value, - force=True, - ) - await session.commit() - logger.info('✅ Токен внешней админки синхронизирован для @', normalized_username=normalized_username) - except ReadOnlySettingError: # pragma: no cover - force=True предотвращает исключение - await session.rollback() - logger.warning( - '⚠️ Не удалось сохранить токен внешней админки из-за ограничения доступа', - ) - return None - - return token - except SQLAlchemyError as error: - logger.error('❌ Ошибка сохранения токена внешней админки', error=error) - return None diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 4264902f..a3476e01 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -373,7 +373,22 @@ class MonitoringService: user = await get_user_by_id(db, subscription.user_id) if user and self.bot: - await self._send_subscription_expired_notification(user, subscription, tariff_name=_tariff_name) + # Skip notification if user has another ACTIVE subscription (multi-tariff) + skip_notify = False + if settings.is_multi_tariff_enabled(): + other_active = await db.execute( + select(Subscription.id) + .where( + Subscription.user_id == user.id, + Subscription.id != subscription.id, + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.end_date > datetime.now(UTC), + ) + .limit(1) + ) + skip_notify = other_active.scalar_one_or_none() is not None + if not skip_notify: + await self._send_subscription_expired_notification(user, subscription, tariff_name=_tariff_name) logger.info( "🔴 Подписка пользователя истекла и статус изменен на 'expired'", user_id=subscription.user_id @@ -965,8 +980,12 @@ class MonitoringService: try: now = datetime.now(UTC) + # Lookback window — don't re-check subscriptions expired more than 30 days ago + lookback = now - timedelta(days=30) + result = await db.execute( select(Subscription) + .join(User, Subscription.user_id == User.id) .options( selectinload(Subscription.user), selectinload(Subscription.tariff), @@ -974,7 +993,10 @@ class MonitoringService: .where( and_( Subscription.is_trial == False, + Subscription.status == SubscriptionStatus.EXPIRED.value, Subscription.end_date <= now, + Subscription.end_date >= lookback, + User.status == UserStatus.ACTIVE.value, ) ) ) @@ -998,6 +1020,21 @@ class MonitoringService: if subscription.end_date is None: continue + # Skip if user has another ACTIVE subscription — they still have service + if settings.is_multi_tariff_enabled(): + other_active = await db.execute( + select(Subscription.id) + .where( + Subscription.user_id == user.id, + Subscription.id != subscription.id, + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.end_date > now, + ) + .limit(1) + ) + if other_active.scalar_one_or_none() is not None: + continue + time_since_end = now - subscription.end_date if time_since_end.total_seconds() < 0: continue @@ -1090,6 +1127,7 @@ class MonitoringService: result = await db.execute( select(Subscription) + .join(User, Subscription.user_id == User.id) .options( selectinload(Subscription.user), selectinload(Subscription.tariff), @@ -1100,6 +1138,7 @@ class MonitoringService: Subscription.is_trial == False, Subscription.end_date > current_time, Subscription.end_date <= threshold_date, + User.status == UserStatus.ACTIVE.value, ) ) ) diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py index 5bf69e33..49e8f574 100644 --- a/app/services/payment/pal24.py +++ b/app/services/payment/pal24.py @@ -558,8 +558,8 @@ class Pal24PaymentMixin: payment_id_str = str(payment.payment_id) try: payment_response = await service.get_payment_status(payment_id_str) - except Pal24APIError as error: - logger.error('Ошибка Pal24 API при получении статуса платежа', error=error) + except Pal24APIError: + logger.debug('Pal24 payment_id не найден или невалиден', payment_id=payment_id_str) else: if payment_response: remote_payloads['payment_status'] = payment_response @@ -569,8 +569,8 @@ class Pal24PaymentMixin: try: payments_response = await service.get_bill_payments(bill_id_str) - except Pal24APIError as error: - logger.error('Ошибка Pal24 API при получении списка платежей', error=error) + except Pal24APIError: + logger.debug('Pal24 bill payments не найдены', bill_id=bill_id_str) else: if payments_response: remote_payloads['bill_payments'] = payments_response diff --git a/app/services/paypear_service.py b/app/services/paypear_service.py index 04f1f299..c9ebb7c1 100644 --- a/app/services/paypear_service.py +++ b/app/services/paypear_service.py @@ -208,27 +208,57 @@ class PayPearService: logger.exception('PayPear API connection error', error=e) raise - def verify_webhook_signature(self, raw_body: bytes, received_signature: str) -> bool: - """Верификация подписи webhook PayPear через HMAC-SHA256. + # PayPear documented webhook source IPs + WEBHOOK_ALLOWED_IPS: set[str] = {'158.160.85.101'} - PayPear sends signature in the webhook JSON field 'signature'. - The signature is HMAC-SHA256(secret_key, raw_body). + def verify_webhook_signature(self, raw_body: bytes, received_signature: str, client_ip: str | None = None) -> bool: + """Верификация webhook PayPear. + + PayPear documentation does not specify the exact signature algorithm. + We try HMAC-SHA256(secret_key, body_without_signature_field) — the most common pattern. + If signature verification fails, fall back to IP allowlist check (recommended by PayPear docs). """ - try: - if not received_signature: - logger.warning('PayPear webhook: отсутствует signature') - return False + import json as json_mod - expected = hmac.new( - self.secret_key.encode('utf-8'), - raw_body, - hashlib.sha256, - ).hexdigest() + # Try signature verification (body without 'signature' field, sorted keys, compact separators) + if received_signature and self.secret_key: + try: + payload = json_mod.loads(raw_body) + payload_without_sig = {k: v for k, v in payload.items() if k != 'signature'} + body_to_sign = json_mod.dumps(payload_without_sig, separators=(',', ':'), sort_keys=True).encode( + 'utf-8' + ) - return hmac.compare_digest(expected, received_signature) - except Exception as e: - logger.error('PayPear webhook verify error', error=e) - return False + expected = hmac.new( + self.secret_key.encode('utf-8'), + body_to_sign, + hashlib.sha256, + ).hexdigest() + + if hmac.compare_digest(expected, received_signature): + return True + + # Try without sort_keys (original key order) + body_to_sign_unsorted = json_mod.dumps(payload_without_sig, separators=(',', ':')).encode('utf-8') + expected_unsorted = hmac.new( + self.secret_key.encode('utf-8'), + body_to_sign_unsorted, + hashlib.sha256, + ).hexdigest() + + if hmac.compare_digest(expected_unsorted, received_signature): + return True + + logger.debug('PayPear signature mismatch, falling back to IP check') + except Exception as e: + logger.debug('PayPear signature verify error, falling back to IP check', error=e) + + # Fallback: IP allowlist (recommended by PayPear docs) + if client_ip and client_ip in self.WEBHOOK_ALLOWED_IPS: + return True + + logger.warning('PayPear webhook: signature mismatch and IP not in allowlist', client_ip=client_ip) + return False # Singleton instance diff --git a/app/services/permission_service.py b/app/services/permission_service.py index fbcebd66..0acb4037 100644 --- a/app/services/permission_service.py +++ b/app/services/permission_service.py @@ -77,6 +77,9 @@ PERMISSION_REGISTRY: dict[str, list[str]] = { 'pinned_messages': ['read', 'create', 'edit', 'delete'], 'landings': ['read', 'create', 'edit', 'delete'], 'updates': ['read', 'manage'], + 'bulk_actions': ['read', 'execute'], + 'info_pages': ['read', 'create', 'edit', 'delete'], + 'news': ['read', 'create', 'edit', 'delete'], } diff --git a/app/services/pricing_engine.py b/app/services/pricing_engine.py index 499da965..4d6d3c8f 100644 --- a/app/services/pricing_engine.py +++ b/app/services/pricing_engine.py @@ -603,6 +603,9 @@ class PricingEngine: period_pct = 0 devices_pct = 0 promo_group = self.resolve_promo_group(user) + # Only apply promo group discount if the tariff is available for this group + if promo_group is not None and not tariff.is_available_for_promo_group(promo_group.id): + promo_group = None if promo_group is not None: period_pct = promo_group.get_discount_percent('period', period_days) devices_pct = promo_group.get_discount_percent('devices', period_days) @@ -612,9 +615,9 @@ class PricingEngine: discounted_base = self.apply_discount(base_price, period_pct) discounted_devices = self.apply_discount(devices_price, devices_pct) - # Traffic uses addon discount (checks apply_discounts_to_addons flag) + # Traffic uses addon discount — but only if promo_group passed the tariff availability check discounted_traffic = traffic_price - if traffic_price > 0 and user: + if traffic_price > 0 and user and promo_group is not None: discounted_traffic, _, _ = self.calculate_traffic_discount(traffic_price, user) base_group_disc = base_price - discounted_base diff --git a/app/services/referral_contest_service.py b/app/services/referral_contest_service.py index 1a091289..9c437014 100644 --- a/app/services/referral_contest_service.py +++ b/app/services/referral_contest_service.py @@ -301,6 +301,10 @@ class ReferralContestService: lines.append('') lines.append(f'Приз: {html.escape(contest.prize_text)}') + # Respect per-category enable/disable + if not getattr(settings, 'ADMIN_NOTIFICATIONS_PROMO_ENABLED', True): + return + try: await self.bot.send_message( chat_id=chat_id, diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 1dab85bb..ac49dace 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -2397,7 +2397,9 @@ class RemnaWaveService: user.remnawave_uuid = panel_uuid return ('updated', sub, None) except RemnaWaveAPIError as api_error: - if api_error.status_code == 404: + # A018 = "user not found" in some RemnaWave versions (may return 400 or 404) + error_code = (api_error.response_data or {}).get('errorCode', '') + if api_error.status_code == 404 or error_code == 'A018': new_user = await api.create_user(**create_kwargs) return ('created', sub, new_user) raise diff --git a/app/services/remnawave_webhook_service.py b/app/services/remnawave_webhook_service.py index b3d62835..d987a3e3 100644 --- a/app/services/remnawave_webhook_service.py +++ b/app/services/remnawave_webhook_service.py @@ -994,6 +994,16 @@ class RemnaWaveWebhookService: async def _handle_user_deleted( self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict ) -> None: + # Suppress webhook if this deletion was initiated by delete_user_account — + # prevents deadlock between the ongoing deletion transaction and this handler + if self._is_intentional_panel_deletion_event(data): + logger.info( + 'Webhook user.deleted suppressed — intentional panel deletion in progress', + user_id=user.id, + uuid=data.get('uuid'), + ) + return + user_id = user.id sub_id = subscription.id if subscription else None @@ -1075,8 +1085,8 @@ class RemnaWaveWebhookService: subscription.connected_squads = [] subscription.updated_at = datetime.now(UTC) - if settings.is_multi_tariff_enabled(): - subscription.remnawave_uuid = None + # Always clear stale UUID — panel user was deleted + subscription.remnawave_uuid = None await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == sub_id)) diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index ac9678b5..7866fc7a 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -3,6 +3,7 @@ from __future__ import annotations import html +import math from dataclasses import dataclass from datetime import UTC, datetime, timedelta @@ -1533,7 +1534,7 @@ async def _auto_add_devices( # Recompute price fresh under lock (pricing config may have changed since cart was saved) devices_price_per_month = devices_to_add * tariff_device_price - days_left = max(1, (subscription.end_date - datetime.now(UTC)).days) + days_left = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400)) devices_discount_percent = PricingEngine.get_addon_discount_percent( user, 'devices', @@ -2137,7 +2138,7 @@ async def try_auto_extend_expired_after_topup( from app.database.crud.subscription import get_all_subscriptions_by_user_id all_subs = await get_all_subscriptions_by_user_id(db, user.id) - expired_subs = [s for s in all_subs if s.status == SubscriptionStatus.EXPIRED.value and not s.is_trial] + expired_subs = [s for s in all_subs if s.status == SubscriptionStatus.EXPIRED.value and s.is_trial is False] if not expired_subs: subscription = None else: @@ -2153,9 +2154,10 @@ async def try_auto_extend_expired_after_topup( return False # Only process expired subscriptions (not trial, not disabled) + # NULL-safe: is_trial can be None in legacy rows — treat as trial if subscription.status != SubscriptionStatus.EXPIRED.value: return False - if subscription.is_trial: + if subscription.is_trial is not False: return False # Only process subscriptions expired within the last 30 days diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index d5976229..47b25c26 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -70,8 +70,8 @@ class ReadOnlySettingError(RuntimeError): class BotConfigurationService: EXCLUDED_KEYS: set[str] = {'BOT_TOKEN', 'ADMIN_IDS'} - READ_ONLY_KEYS: set[str] = {'EXTERNAL_ADMIN_TOKEN', 'EXTERNAL_ADMIN_TOKEN_BOT_ID'} - PLAIN_TEXT_KEYS: set[str] = {'EXTERNAL_ADMIN_TOKEN', 'EXTERNAL_ADMIN_TOKEN_BOT_ID'} + READ_ONLY_KEYS: set[str] = set() + PLAIN_TEXT_KEYS: set[str] = set() CATEGORY_TITLES: dict[str, str] = { 'CORE': '🤖 Основные настройки', @@ -101,7 +101,6 @@ class BotConfigurationService: 'MULENPAY': '💰 {mulenpay_name}', 'PAL24': '🏦 PAL24 / PayPalych', 'WATA': '💠 Wata', - 'EXTERNAL_ADMIN': '🛡️ Внешняя админка', 'SUBSCRIPTIONS_CORE': '📅 Подписки и лимиты', 'SIMPLE_SUBSCRIPTION': '⚡ Простая покупка', 'PERIODS': '📆 Периоды подписок', @@ -168,7 +167,6 @@ class BotConfigurationService: 'TELEGRAM_WIDGET': 'Внешний вид виджета авторизации Telegram на странице входа в кабинет.', 'TELEGRAM_OIDC': 'OpenID Connect авторизация через Telegram (новая система). Требует настройки в BotFather > Bot Settings > Web Login.', 'WATA': 'Wata: токен доступа, тип платежа и пределы сумм.', - 'EXTERNAL_ADMIN': 'Токен внешней админки для проверки запросов.', 'SUBSCRIPTIONS_CORE': 'Лимиты устройств, трафика и базовые цены подписок.', 'SIMPLE_SUBSCRIPTION': 'Параметры упрощённой покупки: период, трафик, устройства и сквады.', 'PERIODS': 'Доступные периоды подписок и продлений.', @@ -381,7 +379,6 @@ class BotConfigurationService: 'PAYMENT_': 'PAYMENT', 'PAYMENT_VERIFICATION_': 'PAYMENT_VERIFICATION', 'WATA_': 'WATA', - 'EXTERNAL_ADMIN_': 'EXTERNAL_ADMIN', 'SIMPLE_SUBSCRIPTION_': 'SIMPLE_SUBSCRIPTION', 'CONNECT_BUTTON_HAPP': 'HAPP', 'HAPP_': 'HAPP', @@ -738,20 +735,6 @@ class BotConfigurationService: 'Если результат пустой, используется user_{telegram_id}.' ), }, - 'EXTERNAL_ADMIN_TOKEN': { - 'description': 'Приватный токен, который использует внешняя админка для проверки запросов.', - 'format': 'Значение генерируется автоматически из username бота и его токена и доступно только для чтения.', - 'example': 'Генерируется автоматически', - 'warning': 'Токен обновится при смене username или токена бота.', - 'dependencies': 'Username телеграм-бота, токен бота', - }, - 'EXTERNAL_ADMIN_TOKEN_BOT_ID': { - 'description': 'Идентификатор телеграм-бота, с которым связан токен внешней админки.', - 'format': 'Проставляется автоматически после первого запуска и не редактируется вручную.', - 'example': '123456789', - 'warning': 'Несовпадение ID блокирует обновление токена, предотвращая его подмену на другом боте.', - 'dependencies': 'Результат вызова getMe() в Telegram Bot API', - }, 'TRIAL_USER_TAG': { 'description': ( 'Тег, который бот передаст пользователю при активации триальной подписки в панели RemnaWave.' diff --git a/app/utils/payment_utils.py b/app/utils/payment_utils.py index 31fa926d..5cabdcac 100644 --- a/app/utils/payment_utils.py +++ b/app/utils/payment_utils.py @@ -184,6 +184,66 @@ def get_available_payment_methods() -> list[dict[str, str]]: } ) + if settings.is_severpay_enabled(): + severpay_name = settings.get_severpay_display_name() + methods.append( + { + 'id': 'severpay', + 'name': f'Банковская карта ({severpay_name})', + 'icon': '💳', + 'description': f'через {severpay_name}', + 'callback': 'topup_severpay', + } + ) + + if settings.is_paypear_enabled(): + paypear_name = settings.get_paypear_display_name() + methods.append( + { + 'id': 'paypear', + 'name': paypear_name, + 'icon': '💳', + 'description': f'через {paypear_name}', + 'callback': 'topup_paypear', + } + ) + + if settings.is_rollypay_enabled(): + rollypay_name = settings.get_rollypay_display_name() + methods.append( + { + 'id': 'rollypay', + 'name': rollypay_name, + 'icon': '💳', + 'description': f'через {rollypay_name}', + 'callback': 'topup_rollypay', + } + ) + + if settings.is_overpay_enabled(): + overpay_name = settings.get_overpay_display_name() + methods.append( + { + 'id': 'overpay', + 'name': overpay_name, + 'icon': '💳', + 'description': f'через {overpay_name}', + 'callback': 'topup_overpay', + } + ) + + if settings.is_aurapay_enabled(): + aurapay_name = settings.get_aurapay_display_name() + methods.append( + { + 'id': 'aurapay', + 'name': aurapay_name, + 'icon': '💳', + 'description': f'через {aurapay_name}', + 'callback': 'topup_aurapay', + } + ) + if settings.is_support_topup_enabled(): methods.append( { @@ -311,6 +371,16 @@ def is_payment_method_available(method_id: str) -> bool: return settings.is_kassa_ai_enabled() if method_id == 'riopay': return settings.is_riopay_enabled() + if method_id == 'severpay': + return settings.is_severpay_enabled() + if method_id == 'paypear': + return settings.is_paypear_enabled() + if method_id == 'rollypay': + return settings.is_rollypay_enabled() + if method_id == 'overpay': + return settings.is_overpay_enabled() + if method_id == 'aurapay': + return settings.is_aurapay_enabled() if method_id == 'support': return settings.is_support_topup_enabled() return False @@ -333,6 +403,12 @@ def get_payment_method_status() -> dict[str, bool]: 'cloudpayments': settings.is_cloudpayments_enabled(), 'freekassa': settings.is_freekassa_enabled(), 'kassa_ai': settings.is_kassa_ai_enabled(), + 'riopay': settings.is_riopay_enabled(), + 'severpay': settings.is_severpay_enabled(), + 'paypear': settings.is_paypear_enabled(), + 'rollypay': settings.is_rollypay_enabled(), + 'overpay': settings.is_overpay_enabled(), + 'aurapay': settings.is_aurapay_enabled(), 'support': settings.is_support_topup_enabled(), } @@ -366,4 +442,16 @@ def get_enabled_payment_methods_count() -> int: count += 1 if settings.is_kassa_ai_enabled(): count += 1 + if settings.is_riopay_enabled(): + count += 1 + if settings.is_severpay_enabled(): + count += 1 + if settings.is_paypear_enabled(): + count += 1 + if settings.is_rollypay_enabled(): + count += 1 + if settings.is_overpay_enabled(): + count += 1 + if settings.is_aurapay_enabled(): + count += 1 return count diff --git a/app/utils/pricing_utils.py b/app/utils/pricing_utils.py index 9282e5da..453948e1 100644 --- a/app/utils/pricing_utils.py +++ b/app/utils/pricing_utils.py @@ -1,3 +1,4 @@ +import math from collections.abc import Sequence from datetime import UTC, datetime from typing import TYPE_CHECKING, Any, Optional @@ -19,14 +20,14 @@ def calculate_months_from_days(days: int) -> int: return max(1, round(days / 30)) -def calculate_prorated_price(monthly_price: int, end_date: datetime, min_charge_days: int = 30) -> tuple[int, int]: +def calculate_prorated_price(monthly_price: int, end_date: datetime, min_charge_days: int = 1) -> tuple[int, int]: """Calculate prorated price based on remaining days. Returns: tuple of (total_price_kopeks, days_charged) """ now = datetime.now(UTC) - days_remaining = max(1, (end_date - now).days) + days_remaining = max(1, math.ceil((end_date - now).total_seconds() / 86400)) days_to_charge = max(min_charge_days, days_remaining) total_price = monthly_price * days_to_charge // 30 diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 9b47b0a5..b78fc2f7 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -5755,7 +5755,7 @@ async def update_subscription_servers_endpoint( subscription.end_date, ) else: - charged_days = max(1, (subscription.end_date - datetime.now(UTC)).days) + charged_days = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400)) added_server_ids = [catalog[uuid].get('server_id') for uuid in added if catalog[uuid].get('server_id') is not None] added_server_prices = [ @@ -5933,7 +5933,7 @@ async def update_subscription_traffic_endpoint( }, ) - days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days) + days_remaining = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400)) period_hint_days = days_remaining # Lock user BEFORE discount computation to prevent TOCTOU on promo group @@ -6111,7 +6111,7 @@ async def update_subscription_devices_endpoint( chargeable_diff = new_chargeable - current_chargeable price_per_month = chargeable_diff * tariff_device_price - days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days) + days_remaining = max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400)) period_hint_days = days_remaining # Lock user BEFORE price computation to prevent TOCTOU on promo discount @@ -6159,7 +6159,7 @@ async def update_subscription_devices_endpoint( user_id=user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=price_to_charge, - description=f'{description} за {charged_days or max(1, (subscription.end_date - datetime.now(UTC)).days)} дн.', + description=f'{description} за {charged_days or max(1, math.ceil((subscription.end_date - datetime.now(UTC)).total_seconds() / 86400))} дн.', ) if price_to_charge > 0: diff --git a/app/webserver/payments.py b/app/webserver/payments.py index c075962d..b4e158d0 100644 --- a/app/webserver/payments.py +++ b/app/webserver/payments.py @@ -1268,8 +1268,13 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute from app.services.paypear_service import paypear_service - if not paypear_service.verify_webhook_signature(raw_body, received_signature): - logger.warning('PayPear webhook: invalid signature') + client_ip = ( + request.headers.get('x-real-ip') + or request.headers.get('x-forwarded-for', '').split(',')[0].strip() + or (request.client.host if request.client else None) + ) + if not paypear_service.verify_webhook_signature(raw_body, received_signature, client_ip=client_ip): + logger.warning('PayPear webhook: invalid signature and IP', client_ip=client_ip) return JSONResponse({'status': False}, status_code=status.HTTP_403_FORBIDDEN) try: diff --git a/docs/project_structure_reference.md b/docs/project_structure_reference.md index b54b8190..c1ec1320 100644 --- a/docs/project_structure_reference.md +++ b/docs/project_structure_reference.md @@ -402,9 +402,6 @@ - `app/services/campaign_service.py` — Python-модуль Классы: `CampaignBonusResult`, `AdvertisingCampaignService` (1 методов) Функции: нет -- `app/services/external_admin_service.py` — Утилиты для синхронизации токена внешней админки. - Классы: нет - Функции: нет - `app/services/faq_service.py` — Python-модуль Классы: `FaqService` (3 методов) Функции: нет diff --git a/main.py b/main.py index b662f334..8d7353bf 100644 --- a/main.py +++ b/main.py @@ -22,7 +22,6 @@ from app.services.ban_notification_service import ban_notification_service from app.services.broadcast_service import broadcast_service from app.services.contest_rotation_service import contest_rotation_service from app.services.daily_subscription_service import daily_subscription_service -from app.services.external_admin_service import ensure_external_admin_token from app.services.log_rotation_service import log_rotation_service from app.services.maintenance_service import maintenance_service from app.services.monitoring_service import monitoring_service @@ -515,24 +514,6 @@ async def main(): else: stage.skip('NaloGO отключен настройками') - async with timeline.stage( - 'Внешняя админка', - '🛡️', - success_message='Токен внешней админки готов', - ) as stage: - try: - token = await ensure_external_admin_token( - bot_user.username, - bot_user.id, - ) - if token: - stage.log('Токен синхронизирован') - else: - stage.warning('Не удалось получить токен внешней админки') - except Exception as error: # pragma: no cover - защитный блок - stage.warning(f'Ошибка подготовки внешней админки: {error}') - logger.error('❌ Ошибка подготовки внешней админки', error=error) - bot_run_mode = settings.get_bot_run_mode() polling_enabled = bot_run_mode == 'polling' telegram_webhook_enabled = bot_run_mode == 'webhook'