diff --git a/app/cabinet/routes/admin_tariffs.py b/app/cabinet/routes/admin_tariffs.py index 359fb02a..01e43e16 100644 --- a/app/cabinet/routes/admin_tariffs.py +++ b/app/cabinet/routes/admin_tariffs.py @@ -202,6 +202,19 @@ async def get_tariff( servers=servers, promo_groups=promo_groups, subscriptions_count=subs_count, + # Произвольное количество дней + custom_days_enabled=tariff.custom_days_enabled, + price_per_day_kopeks=tariff.price_per_day_kopeks, + min_days=tariff.min_days, + max_days=tariff.max_days, + # Произвольный трафик при покупке + custom_traffic_enabled=tariff.custom_traffic_enabled, + traffic_price_per_gb_kopeks=tariff.traffic_price_per_gb_kopeks, + min_traffic_gb=tariff.min_traffic_gb, + max_traffic_gb=tariff.max_traffic_gb, + # Дневной тариф + is_daily=tariff.is_daily, + daily_price_kopeks=tariff.daily_price_kopeks, created_at=tariff.created_at, updated_at=tariff.updated_at, ) @@ -238,6 +251,19 @@ async def create_new_tariff( allowed_squads=request.allowed_squads, server_traffic_limits=server_limits_dict, promo_group_ids=request.promo_group_ids if request.promo_group_ids else None, + # Произвольное количество дней + custom_days_enabled=request.custom_days_enabled, + price_per_day_kopeks=request.price_per_day_kopeks, + min_days=request.min_days, + max_days=request.max_days, + # Произвольный трафик при покупке + custom_traffic_enabled=request.custom_traffic_enabled, + traffic_price_per_gb_kopeks=request.traffic_price_per_gb_kopeks, + min_traffic_gb=request.min_traffic_gb, + max_traffic_gb=request.max_traffic_gb, + # Дневной тариф + is_daily=request.is_daily, + daily_price_kopeks=request.daily_price_kopeks, ) logger.info(f"Admin {admin.id} created tariff {tariff.id}: {tariff.name}") @@ -299,6 +325,29 @@ async def update_existing_tariff( updates["server_traffic_limits"] = { uuid: limit.model_dump() for uuid, limit in request.server_traffic_limits.items() } + # Произвольное количество дней + if request.custom_days_enabled is not None: + updates["custom_days_enabled"] = request.custom_days_enabled + if request.price_per_day_kopeks is not None: + updates["price_per_day_kopeks"] = request.price_per_day_kopeks + if request.min_days is not None: + updates["min_days"] = request.min_days + if request.max_days is not None: + updates["max_days"] = request.max_days + # Произвольный трафик при покупке + if request.custom_traffic_enabled is not None: + updates["custom_traffic_enabled"] = request.custom_traffic_enabled + if request.traffic_price_per_gb_kopeks is not None: + updates["traffic_price_per_gb_kopeks"] = request.traffic_price_per_gb_kopeks + if request.min_traffic_gb is not None: + updates["min_traffic_gb"] = request.min_traffic_gb + if request.max_traffic_gb is not None: + updates["max_traffic_gb"] = request.max_traffic_gb + # Дневной тариф + if request.is_daily is not None: + updates["is_daily"] = request.is_daily + if request.daily_price_kopeks is not None: + updates["daily_price_kopeks"] = request.daily_price_kopeks if updates: await update_tariff(db, tariff, **updates) diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index 736f58af..98c58d5b 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -98,6 +98,17 @@ def _subscription_to_response( else: traffic_used_percent = 0 + # Check if this is a daily tariff + is_daily_paused = getattr(subscription, 'is_daily_paused', False) or False + tariff_id = getattr(subscription, 'tariff_id', None) + + # Use subscription's is_daily_tariff property if available + is_daily = False + if hasattr(subscription, 'is_daily_tariff'): + is_daily = subscription.is_daily_tariff + elif tariff_id and hasattr(subscription, 'tariff') and subscription.tariff: + is_daily = getattr(subscription.tariff, 'is_daily', False) + return SubscriptionResponse( id=subscription.id, status=actual_status, # Use actual_status instead of raw status @@ -119,6 +130,9 @@ def _subscription_to_response( subscription_url=subscription.subscription_url, is_active=is_active, is_expired=is_expired, + is_daily=is_daily, + is_daily_paused=is_daily_paused, + tariff_id=tariff_id, ) @@ -139,6 +153,12 @@ async def get_subscription( detail="No subscription found", ) + # Load tariff for daily subscription check + if fresh_user.subscription.tariff_id: + tariff = await get_tariff_by_id(db, fresh_user.subscription.tariff_id) + if tariff: + fresh_user.subscription.tariff = tariff + # Fetch server names for connected squads servers: List[ServerInfo] = [] connected_squads = fresh_user.subscription.connected_squads or [] @@ -263,15 +283,46 @@ async def get_traffic_packages( db: AsyncSession = Depends(get_cabinet_db), ): """Get available traffic packages.""" - # Проверяем глобальную настройку + from app.database.crud.user import get_user_by_id + from app.database.crud.tariff import get_tariff_by_id + + fresh_user = await get_user_by_id(db, user.id) + if not fresh_user or not fresh_user.subscription: + return [] + + # Режим тарифов - берём пакеты из тарифа + if settings.is_tariffs_mode() and fresh_user.subscription.tariff_id: + tariff = await get_tariff_by_id(db, fresh_user.subscription.tariff_id) + if not tariff: + return [] + + # Проверяем, разрешена ли докупка для этого тарифа + if not getattr(tariff, 'traffic_topup_enabled', False): + return [] + + # Проверяем безлимит + if tariff.traffic_limit_gb == 0: + return [] + + packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {} + result = [] + + for gb, price in packages.items(): + result.append(TrafficPackageResponse( + gb=gb, + price_kopeks=price, + price_rubles=price / 100, + is_unlimited=False, + )) + + return sorted(result, key=lambda x: x.gb) + + # Classic режим - глобальные настройки if not settings.is_traffic_topup_enabled(): return [] - # Проверяем настройку тарифа пользователя - from app.database.crud.user import get_user_by_id - fresh_user = await get_user_by_id(db, user.id) - if fresh_user and fresh_user.subscription and fresh_user.subscription.tariff_id: - from app.database.crud.tariff import get_tariff_by_id + # Проверяем настройку тарифа пользователя (allow_traffic_topup) + if fresh_user.subscription.tariff_id: tariff = await get_tariff_by_id(db, fresh_user.subscription.tariff_id) if tariff and not tariff.allow_traffic_topup: return [] @@ -300,12 +351,9 @@ async def purchase_traffic( db: AsyncSession = Depends(get_cabinet_db), ): """Purchase additional traffic.""" - # Проверяем глобальную настройку - if not settings.is_traffic_topup_enabled(): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Traffic top-up feature is disabled", - ) + from app.database.crud.subscription import add_subscription_traffic + from app.database.crud.tariff import get_tariff_by_id + from app.utils.pricing_utils import calculate_prorated_price await db.refresh(user, ["subscription"]) @@ -315,56 +363,166 @@ async def purchase_traffic( detail="No subscription found", ) - # Проверяем настройку тарифа - if user.subscription.tariff_id: - from app.database.crud.tariff import get_tariff_by_id - tariff = await get_tariff_by_id(db, user.subscription.tariff_id) - if tariff and not tariff.allow_traffic_topup: + subscription = user.subscription + tariff = None + base_price_kopeks = 0 + is_tariff_mode = settings.is_tariffs_mode() and subscription.tariff_id + + # Режим тарифов + if is_tariff_mode: + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if not tariff: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Traffic top-up is not available for your tariff", + status_code=status.HTTP_404_NOT_FOUND, + detail="Tariff not found", ) - # Find matching package - packages = settings.get_traffic_packages() - matching_pkg = next( - (pkg for pkg in packages if pkg["gb"] == request.gb and pkg.get("enabled", True)), - None + # Проверяем, разрешена ли докупка + if not getattr(tariff, 'traffic_topup_enabled', False): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Traffic top-up is disabled for this tariff", + ) + + # Проверяем безлимит + if tariff.traffic_limit_gb == 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot add traffic to unlimited subscription", + ) + + # Проверяем лимит докупки + max_topup_limit = getattr(tariff, 'max_topup_traffic_gb', 0) or 0 + if max_topup_limit > 0: + current_traffic = subscription.traffic_limit_gb or 0 + new_traffic = current_traffic + request.gb + if new_traffic > max_topup_limit: + available_gb = max(0, max_topup_limit - current_traffic) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Traffic limit exceeded. Max: {max_topup_limit} GB, available: {available_gb} GB", + ) + + # Получаем цену из тарифа + packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {} + if request.gb not in packages: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Traffic package {request.gb}GB is not available", + ) + base_price_kopeks = packages[request.gb] + + else: + # Classic режим + if not settings.is_traffic_topup_enabled(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Traffic top-up feature is disabled", + ) + + # Проверяем настройку тарифа (allow_traffic_topup) + if subscription.tariff_id: + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if tariff and not tariff.allow_traffic_topup: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Traffic top-up is not available for your tariff", + ) + + # Получаем цену из глобальных настроек + packages = settings.get_traffic_packages() + matching_pkg = next( + (pkg for pkg in packages if pkg["gb"] == request.gb and pkg.get("enabled", True)), + None + ) + if not matching_pkg: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid traffic package", + ) + base_price_kopeks = matching_pkg["price"] + + # Применяем скидку промогруппы + traffic_discount_percent = 0 + promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None) + if promo_group: + apply_to_addons = getattr(promo_group, 'apply_discounts_to_addons', True) + if apply_to_addons: + traffic_discount_percent = max(0, min(100, int(getattr(promo_group, 'traffic_discount_percent', 0) or 0))) + + if traffic_discount_percent > 0: + base_price_kopeks = int(base_price_kopeks * (100 - traffic_discount_percent) / 100) + + # Пропорциональный расчёт цены + final_price, months_charged = calculate_prorated_price( + base_price_kopeks, + subscription.end_date, ) - if not matching_pkg: + # Проверяем баланс + if user.balance_kopeks < final_price: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid traffic package", + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail=f"Insufficient balance. Need {final_price / 100:.2f} RUB, have {user.balance_kopeks / 100:.2f} RUB", ) - price_kopeks = matching_pkg["price"] - - # Check balance - if user.balance_kopeks < price_kopeks: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Insufficient balance", - ) - - # Deduct balance and add traffic - user.balance_kopeks -= price_kopeks - - if request.gb == 0: - # Unlimited traffic - user.subscription.traffic_limit = 0 # 0 means unlimited + # Формируем описание + if traffic_discount_percent > 0: + traffic_description = f"Докупка {request.gb} ГБ трафика (скидка {traffic_discount_percent}%)" else: - # Add GB to current limit - current_limit = user.subscription.traffic_limit or 0 - additional_bytes = request.gb * (1024 ** 3) - user.subscription.traffic_limit = current_limit + additional_bytes + traffic_description = f"Докупка {request.gb} ГБ трафика" + + # Списываем баланс + success = await subtract_user_balance(db, user, final_price, traffic_description) + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to charge balance", + ) + + # Добавляем трафик + await add_subscription_traffic(db, subscription, request.gb) + + # Обновляем purchased_traffic_gb + current_purchased = getattr(subscription, 'purchased_traffic_gb', 0) or 0 + subscription.purchased_traffic_gb = current_purchased + request.gb + + # Устанавливаем дату сброса трафика (только при первой докупке) + # При повторной докупке дата НЕ продлевается + if not subscription.traffic_reset_at: + from datetime import timedelta + subscription.traffic_reset_at = datetime.utcnow() + timedelta(days=30) + logger.info(f"Set traffic_reset_at for subscription {subscription.id}: {subscription.traffic_reset_at}") await db.commit() + # Синхронизируем с RemnaWave + try: + subscription_service = SubscriptionService() + await subscription_service.update_remnawave_user(db, subscription) + except Exception as e: + logger.error(f"Failed to sync traffic with RemnaWave: {e}") + + # Создаём транзакцию + await create_transaction( + db=db, + user_id=user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=final_price, + description=traffic_description, + ) + + await db.refresh(user) + await db.refresh(subscription) + return { + "success": True, "message": "Traffic purchased successfully", "gb_added": request.gb, - "amount_paid_kopeks": price_kopeks, + "new_traffic_limit_gb": subscription.traffic_limit_gb, + "amount_paid_kopeks": final_price, + "discount_percent": traffic_discount_percent, + "new_balance_kopeks": user.balance_kopeks, } @@ -668,11 +826,26 @@ async def _build_tariff_response( "traffic_limit_label": traffic_label, "is_unlimited_traffic": tariff.traffic_limit_gb == 0, "device_limit": tariff.device_limit, + "device_price_kopeks": tariff.device_price_kopeks, "servers_count": servers_count, "servers": servers, "periods": periods, "is_current": current_tariff_id == tariff.id if current_tariff_id else False, "is_available": tariff.is_active, + # Произвольное количество дней + "custom_days_enabled": tariff.custom_days_enabled, + "price_per_day_kopeks": tariff.price_per_day_kopeks, + "min_days": tariff.min_days, + "max_days": tariff.max_days, + # Произвольный трафик при покупке + "custom_traffic_enabled": tariff.custom_traffic_enabled, + "traffic_price_per_gb_kopeks": tariff.traffic_price_per_gb_kopeks, + "min_traffic_gb": tariff.min_traffic_gb, + "max_traffic_gb": tariff.max_traffic_gb, + # Докупка трафика + "traffic_topup_enabled": tariff.traffic_topup_enabled, + "traffic_topup_packages": tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}, + "max_topup_traffic_gb": tariff.max_topup_traffic_gb, } @@ -849,13 +1022,36 @@ async def purchase_tariff( detail="This tariff is not available for your promo group", ) - # Get price for period + # Get price for period (support custom days) price_kopeks = tariff.get_price_for_period(request.period_days) if price_kopeks is None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid period for this tariff", - ) + # Check for custom days + if tariff.can_purchase_custom_days(): + price_kopeks = tariff.get_price_for_custom_days(request.period_days) + if price_kopeks is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Period must be between {tariff.min_days} and {tariff.max_days} days", + ) + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid period for this tariff", + ) + + # Calculate traffic limit and price + traffic_limit_gb = tariff.traffic_limit_gb + traffic_price_kopeks = 0 + if request.traffic_gb is not None and tariff.can_purchase_custom_traffic(): + # Custom traffic requested + traffic_price_kopeks = tariff.get_price_for_custom_traffic(request.traffic_gb) + if traffic_price_kopeks is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Traffic must be between {tariff.min_traffic_gb} and {tariff.max_traffic_gb} GB", + ) + traffic_limit_gb = request.traffic_gb + price_kopeks += traffic_price_kopeks # Check balance if user.balance_kopeks < price_kopeks: @@ -896,7 +1092,7 @@ async def purchase_tariff( subscription=subscription, days=request.period_days, tariff_id=tariff.id, - traffic_limit_gb=tariff.traffic_limit_gb, + traffic_limit_gb=traffic_limit_gb, device_limit=tariff.device_limit, connected_squads=tariff.allowed_squads or [], ) @@ -906,7 +1102,7 @@ async def purchase_tariff( db=db, user_id=user.id, days=request.period_days, - traffic_limit_gb=tariff.traffic_limit_gb, + traffic_limit_gb=traffic_limit_gb, device_limit=tariff.device_limit, connected_squads=tariff.allowed_squads or [], tariff_id=tariff.id, @@ -954,6 +1150,169 @@ async def purchase_tariff( ) +# ============ Device Purchase ============ + +@router.post("/devices/purchase") +async def purchase_devices( + request: DevicePurchaseRequest, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Purchase additional device slots for subscription.""" + try: + await db.refresh(user, ["subscription"]) + subscription = user.subscription + + if not subscription: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="У вас нет активной подписки", + ) + + if subscription.status not in ['active', 'trial']: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ваша подписка неактивна", + ) + + # Get tariff for device price + tariff = None + if subscription.tariff_id: + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, subscription.tariff_id) + + if not tariff or not tariff.device_price_kopeks: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Докупка устройств недоступна для вашего тарифа", + ) + + # Calculate prorated price based on remaining days + from datetime import datetime, timezone + now = datetime.now(timezone.utc) + end_date = subscription.end_date + if end_date.tzinfo is None: + end_date = end_date.replace(tzinfo=timezone.utc) + + days_left = max(1, (end_date - now).days) + total_days = 30 # Base period for device price calculation + + # Price = device_price * devices * (days_left / 30) + price_kopeks = int(tariff.device_price_kopeks * request.devices * days_left / total_days) + price_kopeks = max(100, price_kopeks) # Minimum 1 ruble + + # Check balance + if user.balance_kopeks < price_kopeks: + missing = price_kopeks - user.balance_kopeks + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail={ + "error": "Insufficient balance", + "required_kopeks": price_kopeks, + "current_kopeks": user.balance_kopeks, + "missing_kopeks": missing, + }, + ) + + # Deduct balance + from app.database.crud.user import subtract_user_balance + await subtract_user_balance( + db=db, + user=user, + amount_kopeks=price_kopeks, + description=f"Покупка {request.devices} доп. устройств", + ) + + # Increase device limit + subscription.device_limit += request.devices + await db.commit() + await db.refresh(subscription) + + # Sync with RemnaWave + service = SubscriptionService() + await service.update_remnawave_user(db, subscription) + + await db.refresh(user) + + logger.info( + f"User {user.telegram_id} purchased {request.devices} devices for {price_kopeks} kopeks" + ) + + return { + "success": True, + "message": f"Добавлено {request.devices} устройств", + "devices_added": request.devices, + "new_device_limit": subscription.device_limit, + "price_kopeks": price_kopeks, + "price_label": settings.format_price(price_kopeks), + "balance_kopeks": user.balance_kopeks, + "balance_label": settings.format_price(user.balance_kopeks), + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to purchase devices for user {user.id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Не удалось обработать покупку устройств", + ) + + +@router.get("/devices/price") +async def get_device_price( + devices: int = 1, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get price for additional devices.""" + await db.refresh(user, ["subscription"]) + subscription = user.subscription + + if not subscription or subscription.status not in ['active', 'trial']: + return { + "available": False, + "reason": "Нет активной подписки", + } + + tariff = None + if subscription.tariff_id: + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, subscription.tariff_id) + + if not tariff or not tariff.device_price_kopeks: + return { + "available": False, + "reason": "Докупка устройств недоступна для вашего тарифа", + } + + # Calculate prorated price + from datetime import datetime, timezone + now = datetime.now(timezone.utc) + end_date = subscription.end_date + if end_date.tzinfo is None: + end_date = end_date.replace(tzinfo=timezone.utc) + + days_left = max(1, (end_date - now).days) + total_days = 30 + + price_per_device_kopeks = int(tariff.device_price_kopeks * days_left / total_days) + price_per_device_kopeks = max(100, price_per_device_kopeks) + total_price_kopeks = price_per_device_kopeks * devices + + return { + "available": True, + "devices": devices, + "price_per_device_kopeks": price_per_device_kopeks, + "price_per_device_label": settings.format_price(price_per_device_kopeks), + "total_price_kopeks": total_price_kopeks, + "total_price_label": settings.format_price(total_price_kopeks), + "current_device_limit": subscription.device_limit, + "days_left": days_left, + "base_device_price_kopeks": tariff.device_price_kopeks, + } + + # ============ App Config for Connection ============ def _load_app_config() -> Dict[str, Any]: @@ -1346,3 +1705,681 @@ async def get_app_config( "subscriptionUrl": subscription_url, "branding": config.get("config", {}).get("branding", {}), } + + +# ============ Device Management ============ + +@router.get("/devices") +async def get_devices( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +) -> Dict[str, Any]: + """Get list of connected devices.""" + from app.services.remnawave_service import RemnaWaveService + + await db.refresh(user, ["subscription"]) + + if not user.subscription: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No subscription found", + ) + + if not user.remnawave_uuid: + return { + "devices": [], + "total": 0, + "device_limit": user.subscription.device_limit or 1, + } + + try: + service = RemnaWaveService() + async with service.get_api_client() as api: + response = await api.get_user_devices(user.remnawave_uuid) + + devices_list = response.get('devices', []) + formatted_devices = [] + for device in devices_list: + hwid = device.get("hwid") or device.get("deviceId") or device.get("id") + platform = device.get("platform") or device.get("platformType") or "Unknown" + model = device.get("deviceModel") or device.get("model") or device.get("name") or "Unknown" + created_at = device.get("updatedAt") or device.get("lastSeen") or device.get("createdAt") + + formatted_devices.append({ + "hwid": hwid, + "platform": platform, + "device_model": model, + "created_at": created_at, + }) + + return { + "devices": formatted_devices, + "total": response.get('total', len(formatted_devices)), + "device_limit": user.subscription.device_limit or 1, + } + + except Exception as e: + logger.error(f"Error fetching devices: {e}") + return { + "devices": [], + "total": 0, + "device_limit": user.subscription.device_limit or 1, + } + + +@router.delete("/devices/{hwid}") +async def delete_device( + hwid: str, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +) -> Dict[str, Any]: + """Delete a specific device by HWID.""" + from app.services.remnawave_service import RemnaWaveService + + await db.refresh(user, ["subscription"]) + + if not user.subscription: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No subscription found", + ) + + if not user.remnawave_uuid: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="User UUID not found", + ) + + try: + service = RemnaWaveService() + async with service.get_api_client() as api: + delete_data = { + "userUuid": user.remnawave_uuid, + "hwid": hwid + } + await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data) + + return { + "success": True, + "message": "Device deleted successfully", + "deleted_hwid": hwid, + } + + except Exception as e: + logger.error(f"Error deleting device: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to delete device", + ) + + +@router.delete("/devices") +async def delete_all_devices( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +) -> Dict[str, Any]: + """Delete all connected devices.""" + from app.services.remnawave_service import RemnaWaveService + + await db.refresh(user, ["subscription"]) + + if not user.subscription: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No subscription found", + ) + + if not user.remnawave_uuid: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="User UUID not found", + ) + + try: + service = RemnaWaveService() + async with service.get_api_client() as api: + # Get all devices first + response = await api._make_request('GET', f'/api/hwid/devices/{user.remnawave_uuid}') + + if not response or 'response' not in response: + return { + "success": True, + "message": "No devices to delete", + "deleted_count": 0, + } + + devices_list = response['response'].get('devices', []) + if not devices_list: + return { + "success": True, + "message": "No devices to delete", + "deleted_count": 0, + } + + deleted_count = 0 + for device in devices_list: + device_hwid = device.get('hwid') + if device_hwid: + try: + delete_data = { + "userUuid": user.remnawave_uuid, + "hwid": device_hwid + } + await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data) + deleted_count += 1 + except Exception as device_error: + logger.error(f"Error deleting device {device_hwid}: {device_error}") + + return { + "success": True, + "message": f"Deleted {deleted_count} devices", + "deleted_count": deleted_count, + } + + except Exception as e: + logger.error(f"Error deleting all devices: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to delete devices", + ) + + +# ============ Tariff Switch ============ + +@router.post("/tariff/switch/preview") +async def preview_tariff_switch( + request: TariffPurchaseRequest, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +) -> Dict[str, Any]: + """Preview tariff switch - shows cost calculation.""" + if not settings.is_tariffs_mode(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Tariffs mode is not enabled", + ) + + await db.refresh(user, ["subscription"]) + + if not user.subscription or not user.subscription.tariff_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No active subscription with tariff", + ) + + if user.subscription.status not in ("active", "trial"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Subscription is not active", + ) + + current_tariff = await get_tariff_by_id(db, user.subscription.tariff_id) + new_tariff = await get_tariff_by_id(db, request.tariff_id) + + if not new_tariff or not new_tariff.is_active: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tariff not found or inactive", + ) + + if user.subscription.tariff_id == request.tariff_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Already on this tariff", + ) + + # Check tariff availability for user's promo group + promo_group = getattr(user, "promo_group", None) + promo_group_id = promo_group.id if promo_group else None + if not new_tariff.is_available_for_promo_group(promo_group_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Tariff not available for your promo group", + ) + + # Calculate remaining days + remaining_days = 0 + if user.subscription.end_date and user.subscription.end_date > datetime.utcnow(): + delta = user.subscription.end_date - datetime.utcnow() + remaining_days = max(0, delta.days) + + # Calculate switch cost + current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False + new_is_daily = getattr(new_tariff, 'is_daily', False) + + if current_is_daily and not new_is_daily: + # Switching FROM daily TO periodic - full payment for new tariff + min_period_price = 0 + if new_tariff.period_prices: + min_period_price = min(new_tariff.period_prices.values()) + upgrade_cost = min_period_price + is_upgrade = min_period_price > 0 + else: + # Calculate proportional cost difference + current_daily_price = 0 + new_daily_price = 0 + + if current_tariff and current_tariff.period_prices: + # Get price per day from current tariff + for period_str, price in current_tariff.period_prices.items(): + period_days = int(period_str) + if period_days > 0: + current_daily_price = price / period_days + break + + if new_tariff.period_prices: + # Get price per day from new tariff + for period_str, price in new_tariff.period_prices.items(): + period_days = int(period_str) + if period_days > 0: + new_daily_price = price / period_days + break + + price_diff_per_day = new_daily_price - current_daily_price + if price_diff_per_day > 0: + upgrade_cost = int(price_diff_per_day * remaining_days) + is_upgrade = True + else: + upgrade_cost = 0 + is_upgrade = False + + balance = user.balance_kopeks or 0 + has_enough = balance >= upgrade_cost + missing = max(0, upgrade_cost - balance) if not has_enough else 0 + + return { + "can_switch": has_enough, + "current_tariff_id": current_tariff.id if current_tariff else None, + "current_tariff_name": current_tariff.name if current_tariff else None, + "new_tariff_id": new_tariff.id, + "new_tariff_name": new_tariff.name, + "remaining_days": remaining_days, + "upgrade_cost_kopeks": upgrade_cost, + "upgrade_cost_label": settings.format_price(upgrade_cost) if upgrade_cost > 0 else "Бесплатно", + "balance_kopeks": balance, + "balance_label": settings.format_price(balance), + "has_enough_balance": has_enough, + "missing_amount_kopeks": missing, + "missing_amount_label": settings.format_price(missing) if missing > 0 else "", + "is_upgrade": is_upgrade, + } + + +@router.post("/tariff/switch") +async def switch_tariff( + request: TariffPurchaseRequest, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +) -> Dict[str, Any]: + """Switch to a different tariff without changing end date.""" + from datetime import timedelta + + if not settings.is_tariffs_mode(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Tariffs mode is not enabled", + ) + + await db.refresh(user, ["subscription"]) + + if not user.subscription or not user.subscription.tariff_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No active subscription with tariff", + ) + + if user.subscription.status not in ("active", "trial"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Subscription is not active", + ) + + current_tariff = await get_tariff_by_id(db, user.subscription.tariff_id) + new_tariff = await get_tariff_by_id(db, request.tariff_id) + + if not new_tariff or not new_tariff.is_active: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tariff not found or inactive", + ) + + if user.subscription.tariff_id == request.tariff_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Already on this tariff", + ) + + # Check tariff availability + promo_group = getattr(user, "promo_group", None) + promo_group_id = promo_group.id if promo_group else None + if not new_tariff.is_available_for_promo_group(promo_group_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Tariff not available", + ) + + # Calculate remaining days + remaining_days = 0 + if user.subscription.end_date and user.subscription.end_date > datetime.utcnow(): + delta = user.subscription.end_date - datetime.utcnow() + remaining_days = max(0, delta.days) + + # Calculate cost + current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False + new_is_daily = getattr(new_tariff, 'is_daily', False) + switching_from_daily = current_is_daily and not new_is_daily + + if switching_from_daily: + min_period_days = 30 + min_period_price = 0 + if new_tariff.period_prices: + min_period_days = min(int(k) for k in new_tariff.period_prices.keys()) + min_period_price = new_tariff.period_prices.get(str(min_period_days), 0) + upgrade_cost = min_period_price + new_period_days = min_period_days + else: + # Calculate proportional cost difference + current_daily_price = 0 + new_daily_price = 0 + + if current_tariff and current_tariff.period_prices: + for period_str, price in current_tariff.period_prices.items(): + period_days = int(period_str) + if period_days > 0: + current_daily_price = price / period_days + break + + if new_tariff.period_prices: + for period_str, price in new_tariff.period_prices.items(): + period_days = int(period_str) + if period_days > 0: + new_daily_price = price / period_days + break + + price_diff_per_day = new_daily_price - current_daily_price + if price_diff_per_day > 0: + upgrade_cost = int(price_diff_per_day * remaining_days) + else: + upgrade_cost = 0 + new_period_days = 0 + + # Charge if upgrade + if upgrade_cost > 0: + if user.balance_kopeks < upgrade_cost: + missing = upgrade_cost - user.balance_kopeks + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail={ + "code": "insufficient_funds", + "message": f"Insufficient funds. Missing {settings.format_price(missing)}", + "missing_amount": missing, + }, + ) + + if switching_from_daily: + description = f"Switch from daily to tariff '{new_tariff.name}' ({new_period_days} days)" + else: + description = f"Switch to tariff '{new_tariff.name}' (upgrade for {remaining_days} days)" + + success = await subtract_user_balance(db, user, upgrade_cost, description) + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to charge balance", + ) + + # Create transaction + await create_transaction( + db=db, + user_id=user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=upgrade_cost, + description=description, + ) + + # Update subscription + old_tariff_name = current_tariff.name if current_tariff else "Unknown" + user.subscription.tariff_id = new_tariff.id + user.subscription.traffic_limit_gb = new_tariff.traffic_limit_gb + user.subscription.device_limit = new_tariff.device_limit + user.subscription.connected_squads = new_tariff.allowed_squads or [] + user.subscription.purchased_traffic_gb = 0 # Reset purchased traffic on tariff switch + user.subscription.traffic_reset_at = None # Reset traffic reset date + + if switching_from_daily: + user.subscription.end_date = datetime.utcnow() + timedelta(days=new_period_days) + user.subscription.is_daily_paused = False + + user.subscription.updated_at = datetime.utcnow() + await db.commit() + + # Sync with RemnaWave + try: + subscription_service = SubscriptionService() + await subscription_service.update_remnawave_user(db, user.subscription) + except Exception as e: + logger.error(f"Failed to sync tariff switch with RemnaWave: {e}") + + await db.refresh(user) + await db.refresh(user.subscription) + + return { + "success": True, + "message": f"Switched from '{old_tariff_name}' to '{new_tariff.name}'", + "subscription": _subscription_to_response(user.subscription), + "old_tariff_name": old_tariff_name, + "new_tariff_id": new_tariff.id, + "new_tariff_name": new_tariff.name, + "charged_kopeks": upgrade_cost, + "balance_kopeks": user.balance_kopeks, + "balance_label": settings.format_price(user.balance_kopeks), + } + + +# ============ Daily Subscription Pause ============ + +@router.post("/pause") +async def toggle_subscription_pause( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +) -> Dict[str, Any]: + """Toggle pause/resume for daily subscription.""" + from datetime import timedelta + + await db.refresh(user, ["subscription"]) + + if not user.subscription: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No subscription found", + ) + + tariff_id = getattr(user.subscription, 'tariff_id', None) + if not tariff_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Subscription has no tariff", + ) + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff or not getattr(tariff, 'is_daily', False): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Pause is only available for daily tariffs", + ) + + # Toggle pause state + is_currently_paused = getattr(user.subscription, 'is_daily_paused', False) + new_paused_state = not is_currently_paused + user.subscription.is_daily_paused = new_paused_state + + # If resuming, check balance + if not new_paused_state: + daily_price = getattr(tariff, 'daily_price_kopeks', 0) + if daily_price > 0 and user.balance_kopeks < daily_price: + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail={ + "code": "insufficient_balance", + "message": "Insufficient balance to resume daily subscription", + "required": daily_price, + "balance": user.balance_kopeks, + }, + ) + + # Restore ACTIVE status if was DISABLED + from app.database.models import SubscriptionStatus + if user.subscription.status == SubscriptionStatus.DISABLED.value: + user.subscription.status = SubscriptionStatus.ACTIVE.value + user.subscription.last_daily_charge_at = datetime.utcnow() + user.subscription.end_date = datetime.utcnow() + timedelta(days=1) + + await db.commit() + await db.refresh(user.subscription) + await db.refresh(user) + + # Sync with RemnaWave when resuming + if not new_paused_state: + try: + subscription_service = SubscriptionService() + if user.remnawave_uuid: + await subscription_service.enable_remnawave_user(user.remnawave_uuid) + except Exception as e: + logger.error(f"Error syncing with RemnaWave on resume: {e}") + + if new_paused_state: + message = "Daily subscription paused" + else: + message = "Daily subscription resumed" + + return { + "success": True, + "message": message, + "is_paused": new_paused_state, + "balance_kopeks": user.balance_kopeks, + "balance_label": settings.format_price(user.balance_kopeks), + } + + +# ============ Traffic Switch (Change Traffic Package) ============ + +@router.put("/traffic") +async def switch_traffic_package( + request: TrafficPurchaseRequest, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +) -> Dict[str, Any]: + """Switch to a different traffic package (change limit).""" + from app.utils.pricing_utils import calculate_prorated_price, apply_percentage_discount + + await db.refresh(user, ["subscription"]) + + if not user.subscription: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No subscription found", + ) + + if user.subscription.is_trial: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Traffic management is only available for paid subscriptions", + ) + + current_traffic = user.subscription.traffic_limit_gb or 0 + new_traffic = request.gb + + if current_traffic == new_traffic: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Already on this traffic package", + ) + + # Get available packages + packages = settings.get_traffic_packages() + current_pkg = next((p for p in packages if p["gb"] == current_traffic and p.get("enabled", True)), None) + new_pkg = next((p for p in packages if p["gb"] == new_traffic and p.get("enabled", True)), None) + + if not new_pkg: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid traffic package", + ) + + # Calculate price difference (only charge for upgrade) + current_price = current_pkg["price"] if current_pkg else 0 + new_price = new_pkg["price"] + + if new_price > current_price: + # Upgrade - charge difference + price_diff = new_price - current_price + + # Apply promo discount + traffic_discount_percent = 0 + promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None) + if promo_group: + apply_to_addons = getattr(promo_group, 'apply_discounts_to_addons', True) + if apply_to_addons: + traffic_discount_percent = max(0, min(100, int(getattr(promo_group, 'traffic_discount_percent', 0) or 0))) + + if traffic_discount_percent > 0: + price_diff = int(price_diff * (100 - traffic_discount_percent) / 100) + + # Prorated calculation + final_price, months_charged = calculate_prorated_price(price_diff, user.subscription.end_date) + + if user.balance_kopeks < final_price: + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail=f"Insufficient balance. Need {final_price / 100:.2f} RUB", + ) + + # Charge balance + description = f"Traffic upgrade from {current_traffic}GB to {new_traffic}GB" + success = await subtract_user_balance(db, user, final_price, description) + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to charge balance", + ) + + # Create transaction + await create_transaction( + db=db, + user_id=user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=final_price, + description=description, + ) + + charged = final_price + else: + # Downgrade - no charge, no refund + charged = 0 + + # Update subscription + user.subscription.traffic_limit_gb = new_traffic + user.subscription.purchased_traffic_gb = 0 # Reset purchased traffic on switch + user.subscription.traffic_reset_at = None # Reset traffic reset date + user.subscription.updated_at = datetime.utcnow() + await db.commit() + + # Sync with RemnaWave + try: + subscription_service = SubscriptionService() + await subscription_service.update_remnawave_user(db, user.subscription) + except Exception as e: + logger.error(f"Failed to sync traffic switch with RemnaWave: {e}") + + await db.refresh(user) + await db.refresh(user.subscription) + + return { + "success": True, + "message": f"Traffic changed from {current_traffic}GB to {new_traffic}GB", + "old_traffic_gb": current_traffic, + "new_traffic_gb": new_traffic, + "charged_kopeks": charged, + "balance_kopeks": user.balance_kopeks, + "balance_label": settings.format_price(user.balance_kopeks), + } diff --git a/app/cabinet/schemas/subscription.py b/app/cabinet/schemas/subscription.py index 7ccfcfb8..6f500260 100644 --- a/app/cabinet/schemas/subscription.py +++ b/app/cabinet/schemas/subscription.py @@ -34,6 +34,10 @@ class SubscriptionResponse(BaseModel): subscription_url: Optional[str] = None is_active: bool is_expired: bool + # Daily tariff fields + is_daily: bool = False + is_daily_paused: bool = False + tariff_id: Optional[int] = None class Config: from_attributes = True @@ -111,3 +115,4 @@ class TariffPurchaseRequest(BaseModel): """Request to purchase a tariff.""" tariff_id: int = Field(..., description="Tariff ID to purchase") period_days: int = Field(..., description="Period in days") + traffic_gb: Optional[int] = Field(None, ge=0, description="Custom traffic in GB (for custom_traffic_enabled tariffs)") diff --git a/app/cabinet/schemas/tariffs.py b/app/cabinet/schemas/tariffs.py index dc13ed55..e05e773d 100644 --- a/app/cabinet/schemas/tariffs.py +++ b/app/cabinet/schemas/tariffs.py @@ -87,6 +87,19 @@ class TariffDetailResponse(BaseModel): servers: List[ServerInfo] promo_groups: List[PromoGroupInfo] subscriptions_count: int + # Произвольное количество дней + custom_days_enabled: bool = False + price_per_day_kopeks: int = 0 + min_days: int = 1 + max_days: int = 365 + # Произвольный трафик при покупке + custom_traffic_enabled: bool = False + traffic_price_per_gb_kopeks: int = 0 + min_traffic_gb: int = 1 + max_traffic_gb: int = 1000 + # Дневной тариф + is_daily: bool = False + daily_price_kopeks: int = 0 created_at: datetime updated_at: Optional[datetime] = None @@ -111,6 +124,19 @@ class TariffCreateRequest(BaseModel): allowed_squads: List[str] = Field(default_factory=list, description="Server UUIDs") server_traffic_limits: Dict[str, ServerTrafficLimit] = Field(default_factory=dict, description="Per-server traffic limits") promo_group_ids: List[int] = Field(default_factory=list) + # Произвольное количество дней + custom_days_enabled: bool = False + price_per_day_kopeks: int = Field(0, ge=0) + min_days: int = Field(1, ge=1) + max_days: int = Field(365, ge=1) + # Произвольный трафик при покупке + custom_traffic_enabled: bool = False + traffic_price_per_gb_kopeks: int = Field(0, ge=0) + min_traffic_gb: int = Field(1, ge=1) + max_traffic_gb: int = Field(1000, ge=1) + # Дневной тариф + is_daily: bool = False + daily_price_kopeks: int = Field(0, ge=0) class TariffUpdateRequest(BaseModel): @@ -131,6 +157,19 @@ class TariffUpdateRequest(BaseModel): allowed_squads: Optional[List[str]] = None server_traffic_limits: Optional[Dict[str, ServerTrafficLimit]] = None promo_group_ids: Optional[List[int]] = None + # Произвольное количество дней + custom_days_enabled: Optional[bool] = None + price_per_day_kopeks: Optional[int] = Field(None, ge=0) + min_days: Optional[int] = Field(None, ge=1) + max_days: Optional[int] = Field(None, ge=1) + # Произвольный трафик при покупке + custom_traffic_enabled: Optional[bool] = None + traffic_price_per_gb_kopeks: Optional[int] = Field(None, ge=0) + min_traffic_gb: Optional[int] = Field(None, ge=1) + max_traffic_gb: Optional[int] = Field(None, ge=1) + # Дневной тариф + is_daily: Optional[bool] = None + daily_price_kopeks: Optional[int] = Field(None, ge=0) class TariffToggleResponse(BaseModel): diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index df18478b..f4ff5719 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -243,6 +243,7 @@ async def replace_subscription( subscription.traffic_limit_gb = traffic_limit_gb subscription.traffic_used_gb = 0.0 subscription.purchased_traffic_gb = 0 # Сбрасываем докупленный трафик при замене подписки + subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика subscription.device_limit = device_limit subscription.connected_squads = list(new_squads) subscription.subscription_url = None @@ -411,14 +412,17 @@ async def extend_subscription( subscription.traffic_limit_gb = traffic_limit_gb subscription.traffic_used_gb = 0.0 subscription.purchased_traffic_gb = 0 + subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика logger.info(f"📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ") elif settings.RESET_TRAFFIC_ON_PAYMENT: subscription.traffic_used_gb = 0.0 # В режиме тарифов сохраняем докупленный трафик при продлении if subscription.tariff_id is None: subscription.purchased_traffic_gb = 0 + subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика logger.info("🔄 Сбрасываем использованный и докупленный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT") else: + # При продлении в режиме тарифов - сохраняем purchased_traffic_gb и traffic_reset_at logger.info("🔄 Сбрасываем использованный трафик, докупленный сохранен (режим тарифов)") if device_limit is not None: @@ -458,6 +462,7 @@ async def extend_subscription( if subscription.traffic_limit_gb != fixed_limit or (subscription.purchased_traffic_gb or 0) > 0: subscription.traffic_limit_gb = fixed_limit subscription.purchased_traffic_gb = 0 + subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика logger.info(f"🔄 Сброс трафика при продлении (fixed_with_topup): {old_limit} ГБ → {fixed_limit} ГБ") subscription.updated_at = current_time diff --git a/app/database/crud/tariff.py b/app/database/crud/tariff.py index 0fcf5824..ca3b4fca 100644 --- a/app/database/crud/tariff.py +++ b/app/database/crud/tariff.py @@ -175,6 +175,16 @@ async def create_tariff( max_topup_traffic_gb: int = 0, is_daily: bool = False, daily_price_kopeks: int = 0, + # Произвольное количество дней + custom_days_enabled: bool = False, + price_per_day_kopeks: int = 0, + min_days: int = 1, + max_days: int = 365, + # Произвольный трафик при покупке + custom_traffic_enabled: bool = False, + traffic_price_per_gb_kopeks: int = 0, + min_traffic_gb: int = 1, + max_traffic_gb: int = 1000, ) -> Tariff: """Создает новый тариф.""" normalized_prices = _normalize_period_prices(period_prices) @@ -198,6 +208,16 @@ async def create_tariff( max_topup_traffic_gb=max(0, max_topup_traffic_gb), is_daily=is_daily, daily_price_kopeks=max(0, daily_price_kopeks), + # Произвольное количество дней + custom_days_enabled=custom_days_enabled, + price_per_day_kopeks=max(0, price_per_day_kopeks), + min_days=max(1, min_days), + max_days=max(1, max_days), + # Произвольный трафик при покупке + custom_traffic_enabled=custom_traffic_enabled, + traffic_price_per_gb_kopeks=max(0, traffic_price_per_gb_kopeks), + min_traffic_gb=max(1, min_traffic_gb), + max_traffic_gb=max(1, max_traffic_gb), ) db.add(tariff) @@ -250,6 +270,16 @@ async def update_tariff( max_topup_traffic_gb: Optional[int] = None, is_daily: Optional[bool] = None, daily_price_kopeks: Optional[int] = None, + # Произвольное количество дней + custom_days_enabled: Optional[bool] = None, + price_per_day_kopeks: Optional[int] = None, + min_days: Optional[int] = None, + max_days: Optional[int] = None, + # Произвольный трафик при покупке + custom_traffic_enabled: Optional[bool] = None, + traffic_price_per_gb_kopeks: Optional[int] = None, + min_traffic_gb: Optional[int] = None, + max_traffic_gb: Optional[int] = None, ) -> Tariff: """Обновляет существующий тариф.""" if name is not None: @@ -289,6 +319,24 @@ async def update_tariff( tariff.is_daily = is_daily if daily_price_kopeks is not None: tariff.daily_price_kopeks = max(0, daily_price_kopeks) + # Произвольное количество дней + if custom_days_enabled is not None: + tariff.custom_days_enabled = custom_days_enabled + if price_per_day_kopeks is not None: + tariff.price_per_day_kopeks = max(0, price_per_day_kopeks) + if min_days is not None: + tariff.min_days = max(1, min_days) + if max_days is not None: + tariff.max_days = max(1, max_days) + # Произвольный трафик при покупке + if custom_traffic_enabled is not None: + tariff.custom_traffic_enabled = custom_traffic_enabled + if traffic_price_per_gb_kopeks is not None: + tariff.traffic_price_per_gb_kopeks = max(0, traffic_price_per_gb_kopeks) + if min_traffic_gb is not None: + tariff.min_traffic_gb = max(1, min_traffic_gb) + if max_traffic_gb is not None: + tariff.max_traffic_gb = max(1, max_traffic_gb) # Обновляем промогруппы если указаны if promo_group_ids is not None: diff --git a/app/database/models.py b/app/database/models.py index affd1010..ea1255d6 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -793,6 +793,18 @@ class Tariff(Base): is_daily = Column(Boolean, default=False, nullable=False) # Является ли тариф суточным daily_price_kopeks = Column(Integer, default=0, nullable=False) # Цена за день в копейках + # Произвольное количество дней + custom_days_enabled = Column(Boolean, default=False, nullable=False) # Разрешить произвольное кол-во дней + price_per_day_kopeks = Column(Integer, default=0, nullable=False) # Цена за 1 день в копейках + min_days = Column(Integer, default=1, nullable=False) # Минимальное количество дней + max_days = Column(Integer, default=365, nullable=False) # Максимальное количество дней + + # Произвольный трафик при покупке + custom_traffic_enabled = Column(Boolean, default=False, nullable=False) # Разрешить произвольный трафик + traffic_price_per_gb_kopeks = Column(Integer, default=0, nullable=False) # Цена за 1 ГБ в копейках + min_traffic_gb = Column(Integer, default=1, nullable=False) # Минимальный трафик в ГБ + max_traffic_gb = Column(Integer, default=1000, nullable=False) # Максимальный трафик в ГБ + created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) @@ -878,6 +890,30 @@ class Tariff(Base): """Возвращает суточную цену в рублях.""" return self.daily_price_kopeks / 100 if self.daily_price_kopeks else 0 + def get_price_for_custom_days(self, days: int) -> Optional[int]: + """Возвращает цену для произвольного количества дней.""" + if not self.custom_days_enabled or not self.price_per_day_kopeks: + return None + if days < self.min_days or days > self.max_days: + return None + return self.price_per_day_kopeks * days + + def get_price_for_custom_traffic(self, gb: int) -> Optional[int]: + """Возвращает цену для произвольного количества трафика.""" + if not self.custom_traffic_enabled or not self.traffic_price_per_gb_kopeks: + return None + if gb < self.min_traffic_gb or gb > self.max_traffic_gb: + return None + return self.traffic_price_per_gb_kopeks * gb + + def can_purchase_custom_days(self) -> bool: + """Проверяет, можно ли купить произвольное количество дней.""" + return self.custom_days_enabled and self.price_per_day_kopeks > 0 + + def can_purchase_custom_traffic(self) -> bool: + """Проверяет, можно ли купить произвольный трафик.""" + return self.custom_traffic_enabled and self.traffic_price_per_gb_kopeks > 0 + def __repr__(self): return f"" @@ -1013,7 +1049,8 @@ class Subscription(Base): traffic_limit_gb = Column(Integer, default=0) traffic_used_gb = Column(Float, default=0.0) - purchased_traffic_gb = Column(Integer, default=0) # Докупленный трафик (для расчета цены сброса) + purchased_traffic_gb = Column(Integer, default=0) # Докупленный трафик + traffic_reset_at = Column(DateTime, nullable=True) # Дата сброса докупленного трафика (30 дней после первой докупки) subscription_url = Column(String, nullable=True) subscription_crypto_link = Column(String, nullable=True) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 825bf9bc..fa7ccb02 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -5770,6 +5770,130 @@ async def add_tariff_daily_columns() -> bool: return False +async def add_tariff_custom_days_traffic_columns() -> bool: + """Добавляет колонки для произвольных дней и трафика в тарифы.""" + try: + columns_added = 0 + db_type = await get_database_type() + + # === ПРОИЗВОЛЬНОЕ КОЛИЧЕСТВО ДНЕЙ === + # custom_days_enabled + if not await check_column_exists('tariffs', 'custom_days_enabled'): + async with engine.begin() as conn: + if db_type == 'sqlite': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN custom_days_enabled INTEGER DEFAULT 0 NOT NULL" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN custom_days_enabled BOOLEAN DEFAULT FALSE NOT NULL" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN custom_days_enabled TINYINT(1) DEFAULT 0 NOT NULL" + )) + logger.info("✅ Колонка custom_days_enabled добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка custom_days_enabled уже существует в tariffs") + + # price_per_day_kopeks + if not await check_column_exists('tariffs', 'price_per_day_kopeks'): + async with engine.begin() as conn: + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN price_per_day_kopeks INTEGER DEFAULT 0 NOT NULL" + )) + logger.info("✅ Колонка price_per_day_kopeks добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка price_per_day_kopeks уже существует в tariffs") + + # min_days + if not await check_column_exists('tariffs', 'min_days'): + async with engine.begin() as conn: + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN min_days INTEGER DEFAULT 1 NOT NULL" + )) + logger.info("✅ Колонка min_days добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка min_days уже существует в tariffs") + + # max_days + if not await check_column_exists('tariffs', 'max_days'): + async with engine.begin() as conn: + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN max_days INTEGER DEFAULT 365 NOT NULL" + )) + logger.info("✅ Колонка max_days добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка max_days уже существует в tariffs") + + # === ПРОИЗВОЛЬНЫЙ ТРАФИК ПРИ ПОКУПКЕ === + # custom_traffic_enabled + if not await check_column_exists('tariffs', 'custom_traffic_enabled'): + async with engine.begin() as conn: + if db_type == 'sqlite': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN custom_traffic_enabled INTEGER DEFAULT 0 NOT NULL" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN custom_traffic_enabled BOOLEAN DEFAULT FALSE NOT NULL" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN custom_traffic_enabled TINYINT(1) DEFAULT 0 NOT NULL" + )) + logger.info("✅ Колонка custom_traffic_enabled добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка custom_traffic_enabled уже существует в tariffs") + + # traffic_price_per_gb_kopeks + if not await check_column_exists('tariffs', 'traffic_price_per_gb_kopeks'): + async with engine.begin() as conn: + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN traffic_price_per_gb_kopeks INTEGER DEFAULT 0 NOT NULL" + )) + logger.info("✅ Колонка traffic_price_per_gb_kopeks добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка traffic_price_per_gb_kopeks уже существует в tariffs") + + # min_traffic_gb + if not await check_column_exists('tariffs', 'min_traffic_gb'): + async with engine.begin() as conn: + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN min_traffic_gb INTEGER DEFAULT 1 NOT NULL" + )) + logger.info("✅ Колонка min_traffic_gb добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка min_traffic_gb уже существует в tariffs") + + # max_traffic_gb + if not await check_column_exists('tariffs', 'max_traffic_gb'): + async with engine.begin() as conn: + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN max_traffic_gb INTEGER DEFAULT 1000 NOT NULL" + )) + logger.info("✅ Колонка max_traffic_gb добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка max_traffic_gb уже существует в tariffs") + + if columns_added > 0: + logger.info(f"✅ Добавлено {columns_added} колонок для произвольных дней/трафика") + + return True + + except Exception as error: + logger.error(f"❌ Ошибка добавления колонок произвольных дней/трафика: {error}") + return False + + async def add_subscription_daily_columns() -> bool: """Добавляет колонки для суточных подписок.""" try: @@ -5828,6 +5952,37 @@ async def add_subscription_daily_columns() -> bool: return False +async def add_subscription_traffic_reset_at_column() -> bool: + """Добавляет колонку traffic_reset_at в subscriptions для сброса докупленного трафика через 30 дней.""" + try: + if not await check_column_exists('subscriptions', 'traffic_reset_at'): + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text( + "ALTER TABLE subscriptions ADD COLUMN traffic_reset_at DATETIME NULL" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE subscriptions ADD COLUMN traffic_reset_at TIMESTAMP NULL" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE subscriptions ADD COLUMN traffic_reset_at DATETIME NULL" + )) + + logger.info("✅ Колонка traffic_reset_at добавлена в subscriptions") + return True + else: + logger.info("ℹ️ Колонка traffic_reset_at уже существует в subscriptions") + return True + + except Exception as error: + logger.error(f"❌ Ошибка добавления колонки traffic_reset_at: {error}") + return False + + async def run_universal_migration(): logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===") @@ -6355,6 +6510,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с колонками суточных тарифов в tariffs") + logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК ПРОИЗВОЛЬНЫХ ДНЕЙ/ТРАФИКА ===") + custom_days_traffic_ready = await add_tariff_custom_days_traffic_columns() + if custom_days_traffic_ready: + logger.info("✅ Колонки произвольных дней/трафика в tariffs готовы") + else: + logger.warning("⚠️ Проблемы с колонками произвольных дней/трафика в tariffs") + logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК СУТОЧНЫХ ПОДПИСОК ===") daily_subscription_columns_ready = await add_subscription_daily_columns() if daily_subscription_columns_ready: @@ -6362,6 +6524,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с колонками суточных подписок в subscriptions") + logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ СБРОСА ТРАФИКА ===") + traffic_reset_column_ready = await add_subscription_traffic_reset_at_column() + if traffic_reset_column_ready: + logger.info("✅ Колонка traffic_reset_at в subscriptions готова") + else: + logger.warning("⚠️ Проблемы с колонкой traffic_reset_at в subscriptions") + logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===") fk_updated = await fix_foreign_keys_for_user_deletion() if fk_updated: diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 488ee8c8..b9518499 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -1967,6 +1967,7 @@ async def confirm_extend_subscription( traffic_was_reset = True subscription.traffic_limit_gb = fixed_limit subscription.purchased_traffic_gb = 0 + subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика logger.info(f"🔄 Сброс трафика при продлении: {old_traffic_limit} ГБ → {fixed_limit} ГБ") await db.commit() diff --git a/app/handlers/subscription/traffic.py b/app/handlers/subscription/traffic.py index cec2adbd..00feb030 100644 --- a/app/handlers/subscription/traffic.py +++ b/app/handlers/subscription/traffic.py @@ -603,11 +603,16 @@ async def add_traffic( subscription.traffic_limit_gb = 0 # При переходе на безлимит сбрасываем докупленный трафик subscription.purchased_traffic_gb = 0 + subscription.traffic_reset_at = None else: await add_subscription_traffic(db, subscription, traffic_gb) # Записываем докупленный трафик для корректного расчета цены сброса current_purchased = getattr(subscription, 'purchased_traffic_gb', 0) or 0 subscription.purchased_traffic_gb = current_purchased + traffic_gb + # Устанавливаем дату сброса при первой докупке (не продлеваем при повторной) + if not subscription.traffic_reset_at: + from datetime import timedelta + subscription.traffic_reset_at = datetime.utcnow() + timedelta(days=30) subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) @@ -866,6 +871,7 @@ async def execute_switch_traffic( subscription.traffic_limit_gb = new_traffic_gb # Сбрасываем докупленный трафик при переключении пакета subscription.purchased_traffic_gb = 0 + subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика subscription.updated_at = datetime.utcnow() await db.commit() diff --git a/app/services/daily_subscription_service.py b/app/services/daily_subscription_service.py index c2a290b7..14172de3 100644 --- a/app/services/daily_subscription_service.py +++ b/app/services/daily_subscription_service.py @@ -1,6 +1,7 @@ """ Сервис для автоматического списания суточных подписок. Проверяет подписки с суточным тарифом и списывает плату раз в сутки. +Также сбрасывает докупленный трафик по истечении 30 дней. """ import logging import asyncio @@ -8,6 +9,8 @@ from datetime import datetime from typing import Optional from aiogram import Bot +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.database import get_db @@ -18,7 +21,7 @@ from app.database.crud.subscription import ( ) from app.database.crud.user import subtract_user_balance, get_user_by_id from app.database.crud.transaction import create_transaction -from app.database.models import TransactionType, PaymentMethod +from app.database.models import TransactionType, PaymentMethod, Subscription, User from app.localization.texts import get_texts @@ -253,8 +256,114 @@ class DailySubscriptionService: except Exception as e: logger.warning(f"Не удалось отправить уведомление о недостатке средств: {e}") + async def process_traffic_resets(self) -> dict: + """ + Сбрасывает докупленный трафик у подписок, у которых истёк срок. + + Returns: + dict: Статистика обработки + """ + stats = { + "checked": 0, + "reset": 0, + "errors": 0, + } + + try: + async for db in get_db(): + # Находим подписки с истёкшим сроком сброса трафика + now = datetime.utcnow() + query = ( + select(Subscription) + .where(Subscription.traffic_reset_at.isnot(None)) + .where(Subscription.traffic_reset_at <= now) + .where(Subscription.purchased_traffic_gb > 0) + ) + result = await db.execute(query) + subscriptions = result.scalars().all() + stats["checked"] = len(subscriptions) + + for subscription in subscriptions: + try: + await self._reset_subscription_traffic(db, subscription) + stats["reset"] += 1 + except Exception as e: + logger.error( + f"Ошибка сброса трафика подписки {subscription.id}: {e}", + exc_info=True + ) + stats["errors"] += 1 + + except Exception as e: + logger.error(f"Ошибка при получении подписок для сброса трафика: {e}", exc_info=True) + + return stats + + async def _reset_subscription_traffic(self, db: AsyncSession, subscription: Subscription): + """Сбрасывает докупленный трафик у подписки.""" + purchased_gb = subscription.purchased_traffic_gb or 0 + old_limit = subscription.traffic_limit_gb + + # Получаем тариф для базового лимита + if subscription.tariff_id: + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, subscription.tariff_id) + base_limit = tariff.traffic_limit_gb if tariff else old_limit - purchased_gb + else: + base_limit = old_limit - purchased_gb + + # Сбрасываем докупленный трафик + subscription.traffic_limit_gb = max(0, base_limit) + subscription.purchased_traffic_gb = 0 + subscription.traffic_reset_at = None + subscription.updated_at = datetime.utcnow() + + await db.commit() + + logger.info( + f"🔄 Сброс докупленного трафика: подписка {subscription.id}, " + f"было {old_limit} ГБ, стало {subscription.traffic_limit_gb} ГБ " + f"(сброшено {purchased_gb} ГБ)" + ) + + # Синхронизируем с RemnaWave + try: + from app.services.subscription_service import SubscriptionService + subscription_service = SubscriptionService() + await subscription_service.update_remnawave_user(db, subscription) + except Exception as e: + logger.warning(f"Не удалось синхронизировать с RemnaWave после сброса трафика: {e}") + + # Уведомляем пользователя + if self._bot and subscription.user_id: + user = await get_user_by_id(db, subscription.user_id) + if user: + await self._notify_traffic_reset(user, subscription, purchased_gb) + + async def _notify_traffic_reset(self, user: User, subscription: Subscription, reset_gb: int): + """Уведомляет пользователя о сбросе докупленного трафика.""" + if not self._bot: + return + + try: + message = ( + f"ℹ️ Сброс докупленного трафика\n\n" + f"Ваш докупленный трафик ({reset_gb} ГБ) был сброшен, " + f"так как прошло 30 дней с момента первой докупки.\n\n" + f"Текущий лимит трафика: {subscription.traffic_limit_gb} ГБ\n\n" + f"Вы можете докупить трафик снова в любое время." + ) + + await self._bot.send_message( + chat_id=user.telegram_id, + text=message, + parse_mode="HTML", + ) + except Exception as e: + logger.warning(f"Не удалось отправить уведомление о сбросе трафика: {e}") + async def start_monitoring(self): - """Запускает периодическую проверку суточных подписок.""" + """Запускает периодическую проверку суточных подписок и сброса трафика.""" self._running = True interval_minutes = self.get_check_interval_minutes() @@ -264,6 +373,7 @@ class DailySubscriptionService: while self._running: try: + # Обработка суточных списаний stats = await self.process_daily_charges() if stats["charged"] > 0 or stats["suspended"] > 0: @@ -272,6 +382,14 @@ class DailySubscriptionService: f"списано={stats['charged']}, приостановлено={stats['suspended']}, " f"ошибок={stats['errors']}" ) + + # Обработка сброса докупленного трафика + traffic_stats = await self.process_traffic_resets() + if traffic_stats["reset"] > 0: + logger.info( + f"📊 Сброс трафика: проверено={traffic_stats['checked']}, " + f"сброшено={traffic_stats['reset']}, ошибок={traffic_stats['errors']}" + ) except Exception as e: logger.error(f"Ошибка в цикле проверки суточных подписок: {e}", exc_info=True) diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 4231dd45..50cc3e0f 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -2689,3 +2689,7 @@ class RemnaWaveService: "api_url": settings.REMNAWAVE_API_URL, "attempts_used": attempts, } + + +# Singleton instance for backward compatibility +remnawave_service = RemnaWaveService() diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index a728d302..9f2598cd 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -7019,6 +7019,7 @@ async def switch_tariff_endpoint( subscription.connected_squads = squads # Сбрасываем докупленный трафик при смене тарифа subscription.purchased_traffic_gb = 0 + subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика # Обработка daily полей при смене тарифа new_is_daily = getattr(new_tariff, 'is_daily', False)