From dffb637e8df48141576911a437364b503b31f160 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 28 Jan 2026 11:06:51 +0300 Subject: [PATCH 1/9] Update remnawave_service.py --- app/services/remnawave_service.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 680d0c19..cb5f2cb6 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -253,10 +253,8 @@ class RemnaWaveService: parsed_date = datetime.fromisoformat(cleaned_date) - if parsed_date.tzinfo is not None: - localized = parsed_date.astimezone(self._panel_timezone) - else: - localized = parsed_date.replace(tzinfo=self._panel_timezone) + naive_date = parsed_date.replace(tzinfo=None) + localized = naive_date.replace(tzinfo=self._panel_timezone) utc_normalized = localized.astimezone(self._utc_timezone).replace(tzinfo=None) From 4602f720305cb0c88362ed3a658b834f4c6dcf3d Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 28 Jan 2026 11:41:40 +0300 Subject: [PATCH 2/9] Update remnawave_service.py --- app/services/remnawave_service.py | 38 +++++++++++++++++++------------ 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index cb5f2cb6..2dce044f 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -253,6 +253,7 @@ class RemnaWaveService: parsed_date = datetime.fromisoformat(cleaned_date) + # Убираем tzinfo и интерпретируем время как локальное время панели naive_date = parsed_date.replace(tzinfo=None) localized = naive_date.replace(tzinfo=self._panel_timezone) @@ -266,27 +267,34 @@ class RemnaWaveService: return self._now_utc() + timedelta(days=30) def _safe_expire_at_for_panel(self, expire_at: datetime | None) -> datetime: - """Гарантирует, что дата окончания не в прошлом для панели.""" + """Гарантирует, что дата окончания не в прошлом для панели. + + Принимает naive UTC datetime, возвращает naive datetime в таймзоне панели. + """ now = self._now_utc() minimum_expire = now + timedelta(minutes=1) if not expire_at: - return minimum_expire + result = minimum_expire + else: + normalized_expire = expire_at + if normalized_expire.tzinfo is not None: + normalized_expire = normalized_expire.replace(tzinfo=None) - normalized_expire = expire_at - if normalized_expire.tzinfo is not None: - normalized_expire = normalized_expire.replace(tzinfo=None) + if normalized_expire < minimum_expire: + logger.debug( + '⚙️ Коррекция даты истечения (%s) до минимально допустимой (%s) для панели', + normalized_expire, + minimum_expire, + ) + result = minimum_expire + else: + result = normalized_expire - if normalized_expire < minimum_expire: - logger.debug( - '⚙️ Коррекция даты истечения (%s) до минимально допустимой (%s) для панели', - normalized_expire, - minimum_expire, - ) - return minimum_expire - - return normalized_expire + # Конвертируем из naive UTC в локальное время панели (naive) + utc_aware = result.replace(tzinfo=self._utc_timezone) + return utc_aware.astimezone(self._panel_timezone).replace(tzinfo=None) def _safe_panel_expire_date(self, panel_user: dict[str, Any]) -> datetime: """Парсит дату окончания подписки пользователя панели для сравнения.""" @@ -1137,7 +1145,7 @@ class RemnaWaveService: 'status': user_obj.status.value, 'telegramId': user_obj.telegram_id, 'email': user_obj.email, # Email для синхронизации email-only пользователей - 'expireAt': user_obj.expire_at.isoformat() + 'Z', + 'expireAt': user_obj.expire_at.replace(tzinfo=None).isoformat(), 'trafficLimitBytes': user_obj.traffic_limit_bytes, 'usedTrafficBytes': user_obj.used_traffic_bytes, 'hwidDeviceLimit': user_obj.hwid_device_limit, From e557504309aa852e4cfe40b10efb5b4b0e358bde Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 28 Jan 2026 11:42:45 +0300 Subject: [PATCH 3/9] Implement panel_datetime_to_naive_utc function Add function to convert panel datetime to naive UTC. --- app/utils/timezone.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/utils/timezone.py b/app/utils/timezone.py index 715c7522..c3e720a9 100644 --- a/app/utils/timezone.py +++ b/app/utils/timezone.py @@ -34,6 +34,18 @@ def get_local_timezone() -> ZoneInfo: return ZoneInfo('UTC') +def panel_datetime_to_naive_utc(dt: datetime) -> datetime: + """Convert a panel datetime to naive UTC. + + Panel API returns local time with a misleading UTC offset (+00:00 / Z). + This strips the offset, interprets the raw value as panel-local time, + then converts to naive UTC for database storage. + """ + naive = dt.replace(tzinfo=None) + localized = naive.replace(tzinfo=get_local_timezone()) + return localized.astimezone(ZoneInfo('UTC')).replace(tzinfo=None) + + def to_local_datetime(dt: datetime | None) -> datetime | None: """Convert a datetime value to the configured local timezone.""" From f8cd3076e93f78f3da6d775ed855b20709ecab2f Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 28 Jan 2026 11:43:22 +0300 Subject: [PATCH 4/9] Add files via upload --- app/cabinet/routes/admin_users.py | 26 +++++++++----------------- app/cabinet/routes/auth.py | 27 ++++++++++----------------- 2 files changed, 19 insertions(+), 34 deletions(-) diff --git a/app/cabinet/routes/admin_users.py b/app/cabinet/routes/admin_users.py index dc03502a..560723d6 100644 --- a/app/cabinet/routes/admin_users.py +++ b/app/cabinet/routes/admin_users.py @@ -33,6 +33,8 @@ from app.database.models import ( UserStatus, ) +from app.utils.timezone import panel_datetime_to_naive_utc + from ..dependencies import get_cabinet_db, get_current_admin_user from ..schemas.users import ( DeleteUserRequest, @@ -1372,12 +1374,8 @@ async def get_user_sync_status( bot_end_utc = ( bot_sub_end_date.replace(tzinfo=None) if bot_sub_end_date.tzinfo else bot_sub_end_date ) - # Panel dates might be timezone-aware, convert to UTC first - if panel_expire_at.tzinfo: - panel_end_utc = panel_expire_at.astimezone(UTC).replace(tzinfo=None) - else: - # Panel might return naive datetime in MSK (UTC+3), try both interpretations - panel_end_utc = panel_expire_at + # Panel returns local time with misleading +00:00 offset + panel_end_utc = panel_datetime_to_naive_utc(panel_expire_at) diff_seconds = abs((bot_end_utc - panel_end_utc).total_seconds()) # Allow for timezone offset (3 hours = MSK) and small sync delays @@ -1508,7 +1506,7 @@ async def sync_user_from_panel( short_uuid=panel_user.short_uuid, username=panel_user.username, status=panel_user.status.value if panel_user.status else None, - expire_at=panel_user.expire_at, + expire_at=panel_datetime_to_naive_utc(panel_user.expire_at) if panel_user.expire_at else None, traffic_limit_gb=panel_user.traffic_limit_bytes / (1024**3) if panel_user.traffic_limit_bytes else 0, traffic_used_gb=panel_user.used_traffic_bytes / (1024**3) if panel_user.used_traffic_bytes else 0, device_limit=panel_user.hwid_device_limit or 1, @@ -1527,11 +1525,8 @@ async def sync_user_from_panel( # Update end date (normalize timezone) if panel_user.expire_at: - # Convert panel expire_at to naive UTC for storage - if panel_user.expire_at.tzinfo: - panel_expire_utc = panel_user.expire_at.astimezone(UTC).replace(tzinfo=None) - else: - panel_expire_utc = panel_user.expire_at + # Panel returns local time with misleading +00:00 offset + panel_expire_utc = panel_datetime_to_naive_utc(panel_user.expire_at) sub_end_naive = ( sub.end_date.replace(tzinfo=None) if sub.end_date and sub.end_date.tzinfo else sub.end_date @@ -1602,11 +1597,8 @@ async def sync_user_from_panel( panel_traffic_limit = ( int(panel_user.traffic_limit_bytes / (1024**3)) if panel_user.traffic_limit_bytes else 100 ) - # Normalize panel expire date for calculation - if panel_user.expire_at.tzinfo: - panel_expire_naive = panel_user.expire_at.astimezone(UTC).replace(tzinfo=None) - else: - panel_expire_naive = panel_user.expire_at + # Panel returns local time with misleading +00:00 offset + panel_expire_naive = panel_datetime_to_naive_utc(panel_user.expire_at) days_remaining = max(1, (panel_expire_naive - datetime.utcnow()).days) new_sub = await create_paid_subscription( diff --git a/app/cabinet/routes/auth.py b/app/cabinet/routes/auth.py index fe8c4964..c9465836 100644 --- a/app/cabinet/routes/auth.py +++ b/app/cabinet/routes/auth.py @@ -10,6 +10,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings +from app.utils.timezone import panel_datetime_to_naive_utc from app.database.crud.user import ( create_user, create_user_by_email, @@ -162,8 +163,8 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) - existing_sub = await get_subscription_by_user_id(db, user.id) - # Parse panel data - expire_at = panel_user.expire_at + # Parse panel data — panel returns local time with misleading +00:00 offset + expire_at = panel_datetime_to_naive_utc(panel_user.expire_at) traffic_limit_gb = panel_user.traffic_limit_bytes // (1024**3) if panel_user.traffic_limit_bytes > 0 else 0 traffic_used_gb = panel_user.used_traffic_bytes / (1024**3) if panel_user.used_traffic_bytes > 0 else 0 @@ -173,11 +174,8 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) - # Device limit from panel device_limit = panel_user.hwid_device_limit or 1 - # Determine status - use timezone-aware datetime for comparison - current_time = datetime.now(UTC) - # Make expire_at timezone-aware if it's naive - if expire_at.tzinfo is None: - expire_at = expire_at.replace(tzinfo=UTC) + # Determine status — expire_at is now naive UTC + current_time = datetime.now(UTC).replace(tzinfo=None) if panel_user.status.value == 'ACTIVE' and expire_at > current_time: sub_status = SubscriptionStatus.ACTIVE @@ -187,10 +185,8 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) - sub_status = SubscriptionStatus.DISABLED if existing_sub: - # Update existing subscription - # Convert to naive datetime for database storage - end_date_naive = expire_at.replace(tzinfo=None) if expire_at.tzinfo else expire_at - existing_sub.end_date = end_date_naive + # Update existing subscription (expire_at already naive UTC) + existing_sub.end_date = expire_at existing_sub.traffic_limit_gb = traffic_limit_gb existing_sub.traffic_used_gb = traffic_used_gb existing_sub.status = sub_status.value @@ -204,14 +200,11 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) - f'Updated subscription for email user {user.email}, squads: {connected_squads}, devices: {device_limit}' ) else: - # Create new subscription - # Convert current_time to naive for database storage if needed - start_date_naive = current_time.replace(tzinfo=None) - end_date_naive = expire_at.replace(tzinfo=None) if expire_at.tzinfo else expire_at + # Create new subscription (expire_at and current_time already naive UTC) new_sub = Subscription( user_id=user.id, - start_date=start_date_naive, - end_date=end_date_naive, + start_date=current_time, + end_date=expire_at, traffic_limit_gb=traffic_limit_gb, traffic_used_gb=traffic_used_gb, status=sub_status.value, From 1e93f24f7890e58e87d3058c93573372bf39ff4c Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 28 Jan 2026 11:46:12 +0300 Subject: [PATCH 5/9] Add files via upload --- app/cabinet/routes/admin_users.py | 3 +-- app/cabinet/routes/auth.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/cabinet/routes/admin_users.py b/app/cabinet/routes/admin_users.py index 560723d6..ab47d61b 100644 --- a/app/cabinet/routes/admin_users.py +++ b/app/cabinet/routes/admin_users.py @@ -1,7 +1,7 @@ """Admin routes for managing users in cabinet.""" import logging -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import Integer, and_, func, or_, select @@ -32,7 +32,6 @@ from app.database.models import ( User, UserStatus, ) - from app.utils.timezone import panel_datetime_to_naive_utc from ..dependencies import get_cabinet_db, get_current_admin_user diff --git a/app/cabinet/routes/auth.py b/app/cabinet/routes/auth.py index c9465836..6202f44a 100644 --- a/app/cabinet/routes/auth.py +++ b/app/cabinet/routes/auth.py @@ -10,7 +10,6 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.utils.timezone import panel_datetime_to_naive_utc from app.database.crud.user import ( create_user, create_user_by_email, @@ -20,6 +19,7 @@ from app.database.crud.user import ( ) from app.database.models import CabinetRefreshToken, User from app.services.referral_service import process_referral_registration +from app.utils.timezone import panel_datetime_to_naive_utc from ..auth import ( create_access_token, From a37ec7a308666811cc8ecc0e6a9d135a8de1bda3 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 28 Jan 2026 11:55:29 +0300 Subject: [PATCH 6/9] Update payment_method_config_service.py --- app/services/payment_method_config_service.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/services/payment_method_config_service.py b/app/services/payment_method_config_service.py index f748d239..6348c244 100644 --- a/app/services/payment_method_config_service.py +++ b/app/services/payment_method_config_service.py @@ -109,6 +109,13 @@ def _get_method_defaults() -> dict: {'id': 'sbp', 'name': 'СБП'}, ], }, + 'kassa_ai': { + 'default_display_name': settings.get_kassa_ai_display_name(), + 'is_configured': settings.is_kassa_ai_enabled(), + 'default_min': settings.KASSA_AI_MIN_AMOUNT_KOPEKS, + 'default_max': settings.KASSA_AI_MAX_AMOUNT_KOPEKS, + 'available_sub_options': None, + }, } @@ -146,6 +153,7 @@ DEFAULT_METHOD_ORDER = [ 'wata', 'freekassa', 'cloudpayments', + 'kassa_ai', ] From 0c3070a0cc0df4c69a8269c130ff1e10d1870cbc Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 28 Jan 2026 11:56:02 +0300 Subject: [PATCH 7/9] Add Kassa AI payment method support --- app/cabinet/routes/balance.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py index f0e6e6d8..2682bc78 100644 --- a/app/cabinet/routes/balance.py +++ b/app/cabinet/routes/balance.py @@ -156,6 +156,7 @@ async def _get_available_payment_methods( 'wata': settings.is_wata_enabled(), 'freekassa': settings.is_freekassa_enabled(), 'cloudpayments': settings.is_cloudpayments_enabled(), + 'kassa_ai': settings.is_kassa_ai_enabled(), } # Default options builder (for methods with sub-options) @@ -700,6 +701,32 @@ async def create_topup( detail='Failed to create FreeKassa payment', ) + elif request.payment_method == 'kassa_ai': + if not settings.is_kassa_ai_enabled(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Kassa AI payment method is unavailable', + ) + + payment_service = PaymentService() + result = await payment_service.create_kassa_ai_payment( + db=db, + user_id=user.id, + amount_kopeks=request.amount_kopeks, + description=settings.get_balance_payment_description(request.amount_kopeks), + email=getattr(user, 'email', None), + language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, + ) + + if result and result.get('payment_url'): + payment_url = result.get('payment_url') + payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending') + else: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to create Kassa AI payment', + ) + elif request.payment_method == 'tribute': if not settings.TRIBUTE_ENABLED or not settings.TRIBUTE_DONATE_LINK: raise HTTPException( From 9cf24deb93ac6d9e199f50566885b68c1c8a1c5f Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 28 Jan 2026 12:47:25 +0300 Subject: [PATCH 8/9] Update remnawave_service.py --- app/services/remnawave_service.py | 41 ++++++++++++++----------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 2dce044f..ad14e8f8 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -1289,20 +1289,23 @@ class RemnaWaveService: logger.info(f'🔄 Обновлены поля {updated_fields} для пользователя {telegram_id}') await db.flush() # Сохраняем изменения без коммита - # Проверяем, есть ли у пользователя подписка, загруженная с пользователем - if hasattr(db_user, 'subscription') and db_user.subscription: - # Используем уже загруженную подписку - await self._update_subscription_from_panel_data(db, db_user, panel_user) - else: - # Если подписки нет, создаем новую - await self._create_subscription_from_panel_data(db, db_user, panel_user) - + # Обновляем UUID ДО операций с подпиской, чтобы избежать + # greenlet_spawn ошибки при доступе к атрибутам после flush _, uuid_mutation = self._ensure_user_remnawave_uuid( db_user, panel_user.get('uuid'), bot_users_by_uuid, ) + # Используем async запрос вместо доступа к relationship, + # чтобы избежать lazy-load в async контексте + from app.database.crud.subscription import get_subscription_by_user_id as _get_sub + existing_sub = await _get_sub(db, db_user.id) + if existing_sub: + await self._update_subscription_from_panel_data(db, db_user, panel_user) + else: + await self._create_subscription_from_panel_data(db, db_user, panel_user) + stats['updated'] += 1 logger.debug(f'✅ Обновлён пользователь {telegram_id}') @@ -1375,8 +1378,11 @@ class RemnaWaveService: if panel_uuid and not db_user.remnawave_uuid: db_user.remnawave_uuid = panel_uuid - # Обновляем или создаем подписку - if hasattr(db_user, 'subscription') and db_user.subscription: + # Используем async запрос вместо доступа к relationship, + # чтобы избежать lazy-load (greenlet_spawn) в async контексте + from app.database.crud.subscription import get_subscription_by_user_id as _get_sub_email + existing_sub = await _get_sub_email(db, db_user.id) + if existing_sub: await self._update_subscription_from_panel_data(db, db_user, panel_user) else: await self._create_subscription_from_panel_data(db, db_user, panel_user) @@ -1643,18 +1649,9 @@ class RemnaWaveService: from app.database.crud.subscription import get_subscription_by_user_id from app.database.models import SubscriptionStatus - # Сначала пытаемся использовать уже загруженную подписку, если она есть - subscription = None - try: - # Проверяем, что подписка уже загружена (была загружена через selectinload) - if hasattr(user, 'subscription') and user.subscription: - subscription = user.subscription - else: - # В противном случае, получаем подписку через CRUD метод - subscription = await get_subscription_by_user_id(db, user.id) - except: - # Если не удалось получить подписку через ленивую загрузку - subscription = await get_subscription_by_user_id(db, user.id) + # Всегда используем async CRUD запрос для получения подписки, + # чтобы избежать lazy-load (greenlet_spawn) в async контексте + subscription = await get_subscription_by_user_id(db, user.id) if not subscription: await self._create_subscription_from_panel_data(db, user, panel_user) From bf65e16d4d7594bc7728053a6476edff81e5964f Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 28 Jan 2026 12:48:29 +0300 Subject: [PATCH 9/9] Update remnawave_service.py --- app/services/remnawave_service.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index ad14e8f8..9d066642 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -1300,6 +1300,7 @@ class RemnaWaveService: # Используем async запрос вместо доступа к relationship, # чтобы избежать lazy-load в async контексте from app.database.crud.subscription import get_subscription_by_user_id as _get_sub + existing_sub = await _get_sub(db, db_user.id) if existing_sub: await self._update_subscription_from_panel_data(db, db_user, panel_user) @@ -1381,6 +1382,7 @@ class RemnaWaveService: # Используем async запрос вместо доступа к relationship, # чтобы избежать lazy-load (greenlet_spawn) в async контексте from app.database.crud.subscription import get_subscription_by_user_id as _get_sub_email + existing_sub = await _get_sub_email(db, db_user.id) if existing_sub: await self._update_subscription_from_panel_data(db, db_user, panel_user)