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'