@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
# Устройства в пределах тарифного лимита — бесплатные
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
+12
-36
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
Vendored
+21
@@ -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,
|
||||
|
||||
@@ -313,7 +313,7 @@ async def restore_backup_start(callback: types.CallbackQuery, db_user: User, db:
|
||||
else:
|
||||
text = """📥 <b>Восстановление из бекапа</b>
|
||||
|
||||
📎 Отправьте файл бекапа (.json или .json.gz)
|
||||
📎 Отправьте файл бекапа (.json, .json.gz или .tar.gz)
|
||||
|
||||
⚠️ <b>ВАЖНО:</b>
|
||||
• Файл должен быть создан этой системой бекапов
|
||||
@@ -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')]]
|
||||
),
|
||||
|
||||
@@ -158,7 +158,6 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
|
||||
'LOG',
|
||||
'MODERATION',
|
||||
'DEBUG',
|
||||
'EXTERNAL_ADMIN',
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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', '🤖 <b>Ссылка на бота:</b>')
|
||||
+ f'\n<code>{html_escape(bot_referral_link)}</code>\n'
|
||||
texts.t('REFERRAL_BOT_LINK_TITLE', '🤖 <b>Ссылка на бота:</b>') + 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', '🌐 <b>Ссылка на кабинет:</b>')
|
||||
+ f'\n<code>{html_escape(cabinet_referral_link)}</code>\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'<blockquote><code>{html_escape(invite_text)}</code></blockquote>'
|
||||
f'<blockquote>{html_escape(invite_text)}</blockquote>'
|
||||
),
|
||||
keyboard,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
'🔍 Расчет для управления странами: осталось дней до',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 методов)
|
||||
Функции: нет
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user