Files
app_umbrix/PRICING-LOCATION-ANALYSIS.md
Umbrix Dev a0f5d69448 📊 Полный анализ системы тарифов с выбором локаций
- PRICING-LOCATION-ANALYSIS.md (45KB): технический анализ Marzban API
- IMPLEMENTATION-SUMMARY.md (12KB): краткая выжимка для разработки
- MINI_APP_ARCHITECTURE.md: обновленная архитектура

Ключевые решения:
 Скидки за период: -10% (3м), -15% (6м), -20% (год)
 Выбор локаций из реальных нод Marzban
 Мультишаговая воронка: тариф → период → локации → оплата
 Динамическое ценообразование отклонено
⏸️ Семейные планы отложены на V2.0 (требуют Device Monitor)
 Реферальная система уже работает

MVP план: 2 недели (20 часов)
- Week 1: Backend API (8h) - /api/nodes + /api/create-user updates
- Week 2: Frontend UI (12h) - мультишаговый /app/plans/page.tsx
2026-02-08 09:16:55 +03:00

45 KiB
Raw Blame History

🎯 Анализ: Система тарифов с выбором локаций + скидки за период

📊 Текущее состояние

Что есть сейчас:

  1. Тарифы определены в /app/plans/page.tsx:

    • Trial (7 дней бесплатно)
    • Start (100₽/мес, 1 локация)
    • Plus (299₽/мес, 3 локации)
    • Max (350₽/мес, 15+ локаций)
  2. SetupWizard (/components/SetupWizard.tsx):

    • Есть UI для выбора локаций
    • Но используется ПОСЛЕ покупки (в wizard'е настройки)
    • НЕ влияет на создание пользователя
  3. API создания пользователя (/app/api/create-user/route.ts):

    • Создает пользователя с фиксированными inbounds
    • НЕТ привязки к нодам/локациям
    • Используется hardcoded список inbounds:
    inbounds: {
      vless: ['VLESS TCP', 'VLESS Reality'],
      vmess: ['VMess WS'],
      trojan: ['Trojan TCP']
    }
    
  4. Что НЕ работает:

    • Выбор локаций НЕ сохраняется в Marzban
    • Нет связи между тарифом и доступными нодами
    • Нет скидок за длительные периоды (3 мес, 6 мес, год)

🔍 Анализ Marzban API

Доступные endpoints (из https://panel.umbrix.net/docs):

1. Управление нодами (/api/nodes):

GET /api/nodes              # Список всех нод с их параметрами
GET /api/node/{node_id}     # Детали конкретной ноды
POST /api/node              # Создать ноду
PUT /api/node/{node_id}     # Изменить ноду
DELETE /api/node/{node_id}  # Удалить ноду
GET /api/nodes/usage        # Статистика по нодам

Структура ноды (NodeResponse):

{
  "id": 1,
  "name": "🇺🇸 USA Node",
  "address": "194.113.210.187",
  "port": 62050,
  "api_port": 62051,
  "status": "connected",
  "message": null,
  "xray_version": "1.8.21",
  "usage": {
    "uplink": 123456789,
    "downlink": 987654321
  }
}

2. Inbounds (/api/inbounds):

GET /api/inbounds  # Список всех inbounds (протоколов)

Структура inbound:

{
  "tag": "VLESS TCP",
  "protocol": "vless",
  "network": "tcp",
  "tls": "reality",
  "port": 443
}

3. Создание пользователя (POST /api/user):

{
  "username": "test_user",
  "status": "active",
  "expire": 1735689600,  // Unix timestamp
  "data_limit": 107374182400,  // Bytes
  "data_limit_reset_strategy": "no_reset",
  "proxies": {
    "vmess": {"id": "uuid-here"},
    "vless": {},
    "trojan": {}
  },
  "inbounds": {
    "vless": ["VLESS TCP", "VLESS Reality"],
    "vmess": ["VMess WS"],
    "trojan": ["Trojan TCP"]
  },
  "note": "Тариф Plus, TG: 12345"
}

🎨 Новая воронка с выбором локаций

Идеальный User Flow:

1. Пользователь открывает /plans
   ↓
2. Выбирает тариф (Start/Plus/Max)
   ↓
3. Видит варианты периода оплаты:
   ┌─────────────────────────────────────┐
   │ ⭕ 1 месяц    - 299₽  (без скидки) │
   │ ⭕ 3 месяца   - 807₽  (-10% = 897₽) │
   │ ⭕ 6 месяцев  - 1523₽ (-15% = 1794₽)│
   │ ⭕ 1 год      - 2870₽ (-20% = 3588₽)│
   └─────────────────────────────────────┘
   ↓
4. Выбирает локации (в зависимости от тарифа):
   ┌─────────────────────────────────────┐
   │ Start: 1 локация из списка          │
   │ Plus:  3 локации из списка          │
   │ Max:   Все локации                  │
   └─────────────────────────────────────┘
   
   Список локаций (из Marzban API):
   ☐ 🇳🇱 Нидерланды (ping: 15ms)
   ☐ 🇩🇪 Германия (ping: 20ms)
   ☐ 🇺🇸 США (ping: 120ms)
   ☐ 🇸🇬 Сингапур (ping: 180ms)
   ☑ 🇯🇵 Япония (ping: 160ms)
   ☐ 🇬🇧 Великобритания (ping: 35ms)
   ↓
5. Нажимает "Оплатить" → Payment gateway
   ↓
6. После оплаты → API создает пользователя с выбранными локациями

🚀 Техническая реализация

Этап 1: Получить список нод из Marzban

Создать новый API endpoint /api/nodes:

// app/api/nodes/route.ts
import { NextResponse } from 'next/server';

const MARZBAN_API = process.env.MARZBAN_PANEL_URL;
const ADMIN_USERNAME = process.env.MARZBAN_ADMIN_USERNAME;
const ADMIN_PASSWORD = process.env.MARZBAN_ADMIN_PASSWORD;

export async function GET() {
  try {
    // 1. Получить токен админа
    const tokenResponse = await fetch(`${MARZBAN_API}/api/admin/token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: `grant_type=password&username=${ADMIN_USERNAME}&password=${ADMIN_PASSWORD}`,
    });
    
    const { access_token } = await tokenResponse.json();
    
    // 2. Получить список нод
    const nodesResponse = await fetch(`${MARZBAN_API}/api/nodes`, {
      headers: { 'Authorization': `Bearer ${access_token}` },
    });
    
    const nodes = await nodesResponse.json();
    
    // 3. Форматировать для фронтенда
    const locations = nodes
      .filter(node => node.status === 'connected')
      .map(node => ({
        id: node.id,
        name: node.name, // "🇺🇸 USA Node"
        address: node.address,
        ping: calculatePing(node.address), // TODO: реальный ping
        country: extractCountry(node.name), // "US"
      }));
    
    return NextResponse.json({ success: true, locations });
  } catch (error) {
    return NextResponse.json({ success: false, error: error.message }, { status: 500 });
  }
}

function extractCountry(nodeName: string): string {
  // Извлечь код страны из emoji флага
  const match = nodeName.match(/🇦-🇿{2}/);
  return match ? getFlagCode(match[0]) : 'Unknown';
}

function calculatePing(address: string): string {
  // TODO: Реальный ping check (или хардкод по регионам)
  const mockPings = {
    '194.113.210.187': '120ms', // USA
    '193.168.175.128': '15ms',  // NL
    // ...
  };
  return mockPings[address] || '50ms';
}

Этап 2: Страница выбора тарифа с периодом

Обновить /app/plans/page.tsx:

'use client';

import { useState, useEffect } from 'react';
import { Check, ChevronRight } from 'lucide-react';

type Period = '1month' | '3months' | '6months' | '1year';
type PlanType = 'start' | 'plus' | 'max';

interface Location {
  id: number;
  name: string;
  country: string;
  ping: string;
}

export default function Plans() {
  const [selectedPlan, setSelectedPlan] = useState<PlanType | null>(null);
  const [selectedPeriod, setSelectedPeriod] = useState<Period>('1month');
  const [selectedLocations, setSelectedLocations] = useState<number[]>([]);
  const [availableLocations, setAvailableLocations] = useState<Location[]>([]);
  
  // Загрузить локации из API
  useEffect(() => {
    async function fetchLocations() {
      const response = await fetch('/api/nodes');
      const data = await response.json();
      if (data.success) {
        setAvailableLocations(data.locations);
      }
    }
    fetchLocations();
  }, []);
  
  // Тарифы с ценами за разные периоды
  const plans = {
    start: {
      name: 'Start',
      locations: 1,
      prices: {
        '1month': 100,
        '3months': 270,   // -10%
        '6months': 510,   // -15%
        '1year': 960,     // -20%
      }
    },
    plus: {
      name: 'Plus',
      locations: 3,
      prices: {
        '1month': 299,
        '3months': 807,   // -10%
        '6months': 1523,  // -15%
        '1year': 2870,    // -20%
      }
    },
    max: {
      name: 'Max',
      locations: 999,  // Все
      prices: {
        '1month': 350,
        '3months': 945,   // -10%
        '6months': 1785,  // -15%
        '1year': 3360,    // -20%
      }
    }
  };
  
  const handlePlanSelect = (plan: PlanType) => {
    setSelectedPlan(plan);
  };
  
  const handlePeriodSelect = (period: Period) => {
    setSelectedPeriod(period);
  };
  
  const handleLocationToggle = (locationId: number) => {
    if (!selectedPlan) return;
    
    const maxLocations = plans[selectedPlan].locations;
    
    if (selectedLocations.includes(locationId)) {
      setSelectedLocations(selectedLocations.filter(id => id !== locationId));
    } else if (maxLocations === 999 || selectedLocations.length < maxLocations) {
      setSelectedLocations([...selectedLocations, locationId]);
    }
  };
  
  const handlePurchase = async () => {
    if (!selectedPlan || selectedLocations.length === 0) {
      alert('Выберите тариф и хотя бы одну локацию');
      return;
    }
    
    // Отправить на payment gateway
    const response = await fetch('/api/create-payment', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        plan: selectedPlan,
        period: selectedPeriod,
        locations: selectedLocations,
        telegramId: getTelegramId(),
      }),
    });
    
    const data = await response.json();
    if (data.success) {
      window.location.href = data.paymentUrl;
    }
  };
  
  const getDiscountPercent = (period: Period): number => {
    const discounts = {
      '1month': 0,
      '3months': 10,
      '6months': 15,
      '1year': 20,
    };
    return discounts[period];
  };
  
  const calculateSavings = (plan: PlanType, period: Period): number => {
    const basePrices = plans[plan].prices;
    const monthlyPrice = basePrices['1month'];
    const months = { '1month': 1, '3months': 3, '6months': 6, '1year': 12 }[period];
    const fullPrice = monthlyPrice * months;
    const discountedPrice = basePrices[period];
    return fullPrice - discountedPrice;
  };
  
  return (
    <div className="min-h-screen bg-slate-900 text-white p-4">
      <h1 className="text-2xl font-bold mb-6">Выбор тарифа</h1>
      
      {/* Шаг 1: Выбор тарифа */}
      {!selectedPlan && (
        <div className="space-y-4">
          {Object.entries(plans).map(([key, plan]) => (
            <button
              key={key}
              onClick={() => handlePlanSelect(key as PlanType)}
              className="w-full p-4 bg-slate-800 rounded-lg text-left hover:bg-slate-700"
            >
              <h3 className="text-xl font-bold">{plan.name}</h3>
              <p className="text-slate-400">
                {plan.locations === 999 ? 'Все локации' : `${plan.locations} локаций`}
              </p>
              <p className="text-blue-400 mt-2">От {plan.prices['1month']}/мес</p>
            </button>
          ))}
        </div>
      )}
      
      {/* Шаг 2: Выбор периода */}
      {selectedPlan && !selectedLocations.length && (
        <div className="space-y-4">
          <button onClick={() => setSelectedPlan(null)} className="text-blue-400"> Назад</button>
          
          <h2 className="text-xl font-bold mb-4">Выберите период</h2>
          
          {Object.entries(plans[selectedPlan].prices).map(([period, price]) => {
            const discount = getDiscountPercent(period as Period);
            const savings = calculateSavings(selectedPlan, period as Period);
            
            return (
              <button
                key={period}
                onClick={() => {
                  handlePeriodSelect(period as Period);
                  // Переходим к выбору локаций
                }}
                className="w-full p-4 bg-slate-800 rounded-lg text-left hover:bg-slate-700"
              >
                <div className="flex justify-between items-center">
                  <div>
                    <h3 className="text-lg font-bold">
                      {period === '1month' && '1 месяц'}
                      {period === '3months' && '3 месяца'}
                      {period === '6months' && '6 месяцев'}
                      {period === '1year' && '1 год'}
                    </h3>
                    {discount > 0 && (
                      <p className="text-green-400 text-sm">
                        💰 Экономия {savings} (-{discount}%)
                      </p>
                    )}
                  </div>
                  <div className="text-right">
                    <p className="text-2xl font-bold">{price}</p>
                    {discount > 0 && (
                      <p className="text-slate-400 line-through text-sm">
                        {plans[selectedPlan].prices['1month'] * 
                          ({ '3months': 3, '6months': 6, '1year': 12 }[period] || 1)}
                      </p>
                    )}
                  </div>
                </div>
              </button>
            );
          })}
        </div>
      )}
      
      {/* Шаг 3: Выбор локаций */}
      {selectedPlan && selectedPeriod && (
        <div className="space-y-4">
          <button onClick={() => setSelectedPeriod('1month')} className="text-blue-400"> Назад</button>
          
          <h2 className="text-xl font-bold mb-2">Выберите локации</h2>
          <p className="text-slate-400 mb-4">
            {plans[selectedPlan].locations === 999 
              ? 'Доступны все локации' 
              : `Выберите до ${plans[selectedPlan].locations} локаций`}
          </p>
          
          <div className="space-y-2">
            {availableLocations.map(location => (
              <button
                key={location.id}
                onClick={() => handleLocationToggle(location.id)}
                disabled={
                  !selectedLocations.includes(location.id) &&
                  plans[selectedPlan].locations !== 999 &&
                  selectedLocations.length >= plans[selectedPlan].locations
                }
                className={`w-full p-4 rounded-lg border transition-colors ${
                  selectedLocations.includes(location.id)
                    ? 'bg-blue-600 border-blue-400'
                    : 'bg-slate-800 border-slate-700 hover:border-slate-600'
                }`}
              >
                <div className="flex justify-between items-center">
                  <div className="flex items-center gap-3">
                    {selectedLocations.includes(location.id) && (
                      <Check className="w-5 h-5 text-white" />
                    )}
                    <span>{location.name}</span>
                  </div>
                  <span className="text-slate-400 text-sm">{location.ping}</span>
                </div>
              </button>
            ))}
          </div>
          
          {selectedLocations.length > 0 && (
            <button
              onClick={handlePurchase}
              className="w-full py-4 bg-blue-600 hover:bg-blue-700 rounded-lg font-bold mt-6"
            >
              Перейти к оплате {plans[selectedPlan].prices[selectedPeriod]}
              <ChevronRight className="inline ml-2" />
            </button>
          )}
        </div>
      )}
    </div>
  );
}

function getTelegramId() {
  return (window as any).Telegram?.WebApp?.initDataUnsafe?.user?.id || null;
}

Этап 3: Обновить API создания пользователя

Модифицировать /app/api/create-user/route.ts:

export async function POST(request: NextRequest) {
  const { 
    planType, 
    period,           // NEW: '1month' | '3months' | '6months' | '1year'
    locationIds,      // NEW: [1, 3, 5] - выбранные ID нод
    telegramId, 
    telegramUsername, 
    firstName, 
    lastName 
  } = await request.json();
  
  // 1. Получить токен админа (same as before)
  
  // 2. Определить параметры тарифа с учетом периода
  const planConfig = getPlanConfig(planType, period);
  
  // 3. Получить список нод и их inbounds
  const nodesResponse = await fetch(`${MARZBAN_API}/api/nodes`, {
    headers: { 'Authorization': `Bearer ${access_token}` },
  });
  const allNodes = await nodesResponse.json();
  
  // 4. Фильтровать только выбранные ноды
  const selectedNodes = allNodes.filter(node => locationIds.includes(node.id));
  
  // 5. Получить inbounds выбранных нод
  const inboundsResponse = await fetch(`${MARZBAN_API}/api/inbounds`, {
    headers: { 'Authorization': `Bearer ${access_token}` },
  });
  const allInbounds = await inboundsResponse.json();
  
  // 6. Создать mapping inbounds для выбранных нод
  const userInbounds = {
    vless: [],
    vmess: [],
    trojan: []
  };
  
  selectedNodes.forEach(node => {
    // Добавить inbounds этой ноды в конфиг пользователя
    allInbounds
      .filter(inbound => inbound.node_id === node.id)
      .forEach(inbound => {
        if (inbound.protocol === 'vless') {
          userInbounds.vless.push(inbound.tag);
        } else if (inbound.protocol === 'vmess') {
          userInbounds.vmess.push(inbound.tag);
        } else if (inbound.protocol === 'trojan') {
          userInbounds.trojan.push(inbound.tag);
        }
      });
  });
  
  // 7. Создать пользователя с кастомными inbounds
  const createUserResponse = await fetch(`${MARZBAN_API}/api/user`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${access_token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      username: username,
      status: 'active',
      expire: planConfig.expireTimestamp,
      data_limit: planConfig.dataLimitBytes,
      data_limit_reset_strategy: 'no_reset',
      proxies: {
        vmess: { id: randomUUID() },
        vless: {},
        trojan: {}
      },
      inbounds: userInbounds, // <-- ГЛАВНОЕ ИЗМЕНЕНИЕ!
      note: `Тариф: ${planConfig.name}, Период: ${period}, Локации: ${selectedNodes.map(n => n.name).join(', ')}, TG: ${telegramId}`,
    }),
  });
  
  // Rest of the code...
}

function getPlanConfig(planType: PlanType, period: Period) {
  const basePlans = {
    trial: { name: 'Trial', dataLimitGB: 10, durationDays: 7 },
    start: { name: 'Start', dataLimitGB: 50, basePriceRub: 100 },
    plus: { name: 'Plus', dataLimitGB: 299, basePriceRub: 299 },
    max: { name: 'Max', dataLimitGB: 999999, basePriceRub: 350 },
  };
  
  const periodMultipliers = {
    '1month': { months: 1, discount: 0 },
    '3months': { months: 3, discount: 0.10 },
    '6months': { months: 6, discount: 0.15 },
    '1year': { months: 12, discount: 0.20 },
  };
  
  const plan = basePlans[planType];
  const periodConfig = periodMultipliers[period];
  
  const durationDays = periodConfig.months * 30;
  const fullPrice = plan.basePriceRub * periodConfig.months;
  const discountedPrice = Math.round(fullPrice * (1 - periodConfig.discount));
  
  return {
    name: plan.name,
    dataLimitBytes: plan.dataLimitGB * 1024 * 1024 * 1024,
    expireTimestamp: Math.floor(Date.now() / 1000) + (durationDays * 24 * 60 * 60),
    priceRub: discountedPrice,
    periodDays: durationDays,
  };
}

Этап 4: Payment gateway integration

Создать /app/api/create-payment/route.ts:

// Интеграция с платежным шлюзом (Stripe / YooKassa / Coinbase)
export async function POST(request: NextRequest) {
  const { plan, period, locations, telegramId } = await request.json();
  
  const planConfig = getPlanConfig(plan, period);
  
  // 1. Создать заказ в БД (pending payment)
  const orderId = createOrder({
    telegramId,
    plan,
    period,
    locations,
    amount: planConfig.priceRub,
    status: 'pending',
  });
  
  // 2. Создать платежную ссылку
  const paymentUrl = await createStripeCheckout({
    amount: planConfig.priceRub,
    currency: 'RUB',
    metadata: { orderId, telegramId },
    successUrl: `https://app.umbrix.net/payment-success?order=${orderId}`,
    cancelUrl: `https://app.umbrix.net/plans`,
  });
  
  return NextResponse.json({ success: true, paymentUrl });
}

Этап 5: Webhook для обработки оплаты

Создать /app/api/payment-webhook/route.ts:

// Webhook от платежного провайдера
export async function POST(request: NextRequest) {
  const payload = await request.json();
  
  // 1. Verify signature (Stripe/YooKassa)
  
  // 2. Проверить статус оплаты
  if (payload.status === 'succeeded') {
    const { orderId } = payload.metadata;
    
    // 3. Получить данные заказа из БД
    const order = getOrder(orderId);
    
    // 4. Создать пользователя в Marzban
    await fetch('/api/create-user', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        planType: order.plan,
        period: order.period,
        locationIds: order.locations,
        telegramId: order.telegramId,
      }),
    });
    
    // 5. Обновить статус заказа
    updateOrder(orderId, { status: 'completed' });
    
    // 6. Отправить уведомление в Telegram
    sendTelegramMessage(order.telegramId, '✅ Оплата прошла! Ваш VPN активирован.');
  }
  
  return NextResponse.json({ success: true });
}

🤖 Обновление n8n бота

Добавить новые шаги в workflow:

{
  "nodes": [
    {
      "name": "Show Plans Menu",
      "type": "n8n-nodes-base.telegram",
      "parameters": {
        "chatId": "={{ $json.userId }}",
        "text": "💎 Выберите тариф:",
        "replyMarkup": {
          "inline_keyboard": [
            [{"text": "🌍 Start (100₽/мес)", "web_app": {"url": "https://app.umbrix.net/plans?plan=start"}}],
            [{"text": "🌎 Plus (299₽/мес)", "web_app": {"url": "https://app.umbrix.net/plans?plan=plus"}}],
            [{"text": "🌏 Max (350₽/мес)", "web_app": {"url": "https://app.umbrix.net/plans?plan=max"}}]
          ]
        }
      }
    }
  ]
}

ВАЖНО: Все тарифы теперь открывают Mini App с pre-selected планом → выбор периода → выбор локаций → оплата.


📋 Чеклист внедрения

Неделя 1: Backend API

  • Создать /api/nodes - список доступных локаций из Marzban
  • Обновить /api/create-user - добавить параметры period и locationIds
  • Добавить функцию getPlanConfig(planType, period) с расчетом скидок
  • Реализовать mapping нод → inbounds для создания пользователя

Неделя 2: Frontend UI

  • Переработать /app/plans/page.tsx - мультишаговый выбор:
    • Шаг 1: Тариф (Start/Plus/Max)
    • Шаг 2: Период (1м/3м/6м/1год) с показом скидок
    • Шаг 3: Локации (чекбоксы с ограничением по тарифу)
  • Добавить компонент PeriodSelector с расчетом экономии
  • Добавить компонент LocationSelector с ping и флагами стран
  • Стилизация под Telegram Design System

Неделя 3: Payment Integration

  • Выбрать платежный шлюз (YooKassa для RU, Stripe для международных)
  • Создать /api/create-payment - генерация платежных ссылок
  • Создать /api/payment-webhook - обработка успешных оплат
  • Создать БД таблицу orders для хранения pending платежей
  • Добавить страницу /payment-success - редирект после оплаты

Неделя 4: n8n Bot Update

  • Добавить inline кнопки с pre-selected тарифами
  • Обновить WebApp URLs с query параметрами ?plan=start
  • Добавить уведомления о успешной оплате
  • Добавить команду /renew для продления подписки

Неделя 5: Testing & QA

  • E2E тесты полной воронки (выбор → оплата → создание пользователя)
  • Тестирование на разных тарифах и периодах
  • Проверка корректности создания inbounds для выбранных нод
  • Load testing (100 одновременных покупок)
  • Security audit payment webhook

💡 Кардинальные предложения

Предложение 1: Динамическое ценообразование - ОТКЛОНЕНО

Вместо фиксированных цен - учитывать нагрузку на ноды

Причина отклонения: Усложняет ценообразование для пользователей, все локации должны быть одинаковы по цене.

Предложение 2: Гибкие пакеты трафика

Разделить тарифы на "Устройства" и "Трафик":

Base Plan (1 устройство):
  ├── 50 ГБ   - 100₽/мес
  ├── 200 ГБ  - 250₽/мес
  └── ∞ ГБ    - 350₽/мес

+100₽ за каждое доп. устройство
+50₽ за каждую доп. локацию

⚠️ Предложение 3: Семейные планы - ТРЕБУЕТ АНАЛИЗА

Family Plan (5 устройств, безлимит):
- 499₽/мес для семьи
- Каждый член семьи выбирает свои локации
- Общий аккаунт с sub-users в Marzban

Проблема: Marzban не имеет встроенного механизма ограничения количества устройств на уровне API.

Варианты решения:

Вариант 1: Мониторинг online_at (рекомендуемый)

// GET /api/user/{username} возвращает:
{
  "online_at": "2026-02-08T10:30:00Z",  // Последняя активность
  "sub_last_user_agent": "v2rayNG/1.8.5"
}

// Логика:
// - online_at обновляется при каждом подключении
// - Если разница между текущим временем и online_at < 5 минут - устройство активно
// - Подсчитываем активные сессии через периодический poll

Ограничения:

  • НЕТ прямого поля active_connections или device_count
  • НЕТ поля ip_limit в UserCreate
  • ЕСТЬ online_at - последняя активность
  • ЕСТЬ sub_last_user_agent - информация об устройстве

Реализация (см. детали ниже в разделе "Семейные планы: Детальный анализ")

Вариант 2: Ограничение на уровне Xray (требует кастомных патчей)

  • Модифицировать xray_config.json с ограничением по IP
  • Требует fork Marzban с кастомной логикой
  • НЕ рекомендуется для MVP

Вариант 3: IP-based limiting (приблизительный)

  • Собирать статистику подключений через /api/user/{username}/usage
  • Отслеживать уникальные IP адреса
  • Ограничение: один пользователь может иметь несколько устройств с одним IP (домашняя сеть)

Решение: Отложить семейные планы на V2.0 до реализации мониторинга устройств.

Предложение 4: Gamification - УЖЕ РЕАЛИЗОВАНО

Текущая система рефералов:

// /api/referral/track - отслеживание приглашений
// /api/referral/stats - статистика пользователя

Бонусы:
- +7 дней за каждого друга
- +30 дней milestone bonus за каждые 5 друзей

Формула:
bonusDays = (referralCount * 7) + (Math.floor(referralCount / 5) * 30)

Примеры:
1 друг    +7 дней
5 друзей  +35 дней (+30 milestone)
10 друзей  +70 дней (+60 milestone) = 130 дней

Что можно улучшить:

  • Добавить визуальные badges (Bronze/Silver/Gold)
  • Добавить leaderboard на странице /referral
  • Уведомления в Telegram при достижении milestone

🔥 Критические улучшения воронки

1. A/B Testing периодов оплаты

// Показывать разным пользователям разные default периоды
const defaultPeriod = userId % 2 === 0 ? '3months' : '6months';

// Трекинг конверсии:
// - Какой период чаще выбирают?
// - Какая скидка конвертирует лучше?

2. Urgency триггеры

⏰ Специальное предложение!
🔥 -30% на годовую подписку (осталось 2 часа)
💰 При оплате сегодня - бонус +7 дней

3. Social proof

✅ Уже 1,234 пользователей выбрали тариф Plus
⭐ 4.9/5 - средняя оценка
🌍 Топ-3 локации: 🇳🇱 🇩🇪 🇺🇸

4. Упрощенная воронка для новичков

Вместо 3 шагов (тариф → период → локации):

"Быстрый старт" кнопка:
→ Plus (3 месяца) + Авто-выбор 3 лучших локаций по ping
→ Сразу на оплату (экономия 807₽ вместо 897₽)

🎯 Priority Roadmap

MVP (2 недели):

  1. API /api/nodes - список локаций
  2. Обновить /app/plans/page.tsx - мультишаговый выбор
  3. Обновить /api/create-user - кастомные inbounds
  4. ⚠️ YooKassa integration (payment gateway)

V1.1 (4 недели):

  1. Скидки за период (3м/6м/1год)
  2. Показ экономии при выборе периода
  3. n8n bot updates

V1.2 (6 недель):

  1. Динамическое ценообразование ОТКЛОНЕНО
  2. A/B testing периодов
  3. Analytics dashboard (конверсия по воронке)

V2.0 (8 недель):

  1. Device Monitor Service - мониторинг устройств через Xray логи
  2. Семейные планы (после реализации п.11)
  3. Gamification улучшения (badges, leaderboard)
  4. Автоматическое продление подписки

Вопросы для обсуждения

  1. Какой payment gateway использовать?

    • YooKassa (для РФ)
    • Stripe (международный)
    • Crypto (Coinbase Commerce) ?
  2. Какие скидки оптимальны?

    • 3 мес: -10%
    • 6 мес: -15%
    • 1 год: -20%
  3. Нужна ли локация-специфичная цена? ОТКЛОНЕНО

    • Все локации одинаковы
  4. Trial период - как ограничить abuse?

    • 1 trial на Telegram ID
    • Требовать номер телефона? ⚠️
    • Требовать payment method (0₽ charge)? ⚠️
  5. Семейные планы - реализовывать в MVP?

    • Отложить на V2.0
    • ⚠️ Требует Device Monitor Service (40 часов разработки)
    • Для MVP - добавить в ToS ограничение без технического enforcement

📞 Следующие шаги

  1. Проанализировать текущее состояние
  2. Изучить Marzban API
  3. Спроектировать новую воронку
  4. Проанализировать ограничения устройств
  5. Изучить реферальную систему
  6. Согласовать со stakeholder'ами
  7. Начать разработку MVP

Готово к обсуждению! 🚀


🔬 ДОПОЛНЕНИЕ: Семейные планы - Детальный анализ ограничения устройств

Проблема:

Marzban НЕ имеет встроенного механизма ограничения количества одновременных подключений (devices/connections).

Анализ Marzban API:

Что НЕ доступно в API:

// POST /api/user - UserCreate schema НЕ имеет:
{
  "ip_limit": 5,           // ❌ Нет такого поля
  "device_limit": 5,       // ❌ Нет такого поля
  "max_connections": 5,    // ❌ Нет такого поля
  "active_sessions": []    // ❌ Нет такого поля
}

Что ДОСТУПНО в API:

// GET /api/user/{username} - UserResponse:
{
  "username": "test_user",
  "status": "active",
  "online_at": "2026-02-08T10:30:00Z",  // ✅ Последняя активность
  "sub_last_user_agent": "v2rayNG/1.8.5", // ✅ User-Agent устройства
  "used_traffic": 123456789,
  "data_limit": 107374182400
}

// GET /api/user/{username}/usage - UsageResponse:
{
  "username": "test_user",
  "usages": [
    {
      "date": "2026-02-08",
      "upload": 12345,
      "download": 67890,
      "total": 80235
    }
  ]
}

Варианты решения:


Вариант 1: Server-side мониторинг через online_at (Рекомендуемый для MVP)

Как работает:

  1. Периодически (каждые 30 секунд) опрашиваем GET /api/user/{username}
  2. Проверяем online_at - если разница < 2 минуты → устройство активно
  3. Если активных устройств > лимита → временно блокируем (status: 'disabled')

Реализация:

// app/api/family-plans/monitor-devices/route.ts

import { NextRequest, NextResponse } from 'next/server';

const MARZBAN_API = process.env.MARZBAN_PANEL_URL;
const ADMIN_USERNAME = process.env.MARZBAN_ADMIN_USERNAME;
const ADMIN_PASSWORD = process.env.MARZBAN_ADMIN_PASSWORD;

interface FamilyPlanUser {
  username: string;
  deviceLimit: number;
  lastCheck: Date;
}

// В production - хранить в Redis или БД
const familyPlanUsers: Map<string, FamilyPlanUser> = new Map();

// Cron job - запускать каждые 30 секунд
export async function POST(request: NextRequest) {
  try {
    // 1. Получить токен админа
    const tokenResponse = await fetch(`${MARZBAN_API}/api/admin/token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: `grant_type=password&username=${ADMIN_USERNAME}&password=${ADMIN_PASSWORD}`,
    });
    
    const { access_token } = await tokenResponse.json();
    
    // 2. Проверить каждого пользователя Family Plan
    for (const [username, config] of familyPlanUsers.entries()) {
      const userResponse = await fetch(`${MARZBAN_API}/api/user/${username}`, {
        headers: { 'Authorization': `Bearer ${access_token}` },
      });
      
      const user = await userResponse.json();
      
      // 3. Проверить активность
      const onlineAt = new Date(user.online_at);
      const now = new Date();
      const minutesSinceActive = (now.getTime() - onlineAt.getTime()) / 1000 / 60;
      
      // Если пользователь активен (подключался менее 2 минут назад)
      if (minutesSinceActive < 2) {
        // ПРОБЛЕМА: Мы НЕ МОЖЕМ узнать сколько устройств подключено СЕЙЧАС!
        // online_at показывает только ПОСЛЕДНЮЮ активность
        
        // Вариант решения: собирать статистику подключений
        // Но это требует доступа к логам Xray на уровне нод
      }
    }
    
    return NextResponse.json({ success: true, message: 'Device monitoring completed' });
  } catch (error) {
    return NextResponse.json({ success: false, error: error.message }, { status: 500 });
  }
}

Проблема: online_at показывает только последнюю активность, а НЕ количество активных сессий.


Вариант 2: IP-based мониторинг (Приблизительный)

Идея: Отслеживать уникальные IP адреса через usage API.

Проблема:

  • Несколько устройств могут быть за одним IP (домашний роутер)
  • Один телефон может менять IP (мобильный интернет)
  • НЕ точный метод

Вариант 3: Кастомизация Xray config (Профессиональный)

Идея: Модифицировать xray_config.json для ограничения по IP.

{
  "inbounds": [
    {
      "tag": "VLESS TCP",
      "protocol": "vless",
      "settings": {
        "clients": [
          {
            "id": "uuid-here",
            "email": "test_user@marzban",
            "level": 0,
            "maxIps": 5  // ← Кастомное поле (требует патч Xray)
          }
        ]
      }
    }
  ]
}

Проблема:

  • Требует fork Xray с кастомной логикой
  • Нужно пропатчить Marzban для поддержки maxIps
  • Сложно для MVP

Вариант 4: Использовать sub-users (Встроенная функция Marzban)

Идея: Создать родительский аккаунт + 4 дочерних аккаунта.

// Семейный план:
// 1. Создать главного пользователя: family_john_doe
// 2. Создать 4 sub-users:
//    - family_john_doe_device_1
//    - family_john_doe_device_2
//    - family_john_doe_device_3
//    - family_john_doe_device_4
//    - family_john_doe_device_5

// Каждый sub-user = отдельное устройство с собственной подпиской

Преимущества:

  • Работает "из коробки" в Marzban
  • Каждое устройство = отдельная подписка
  • Легко контролировать (5 username'ов)

Недостатки:

  • Пользователю нужно выдавать 5 разных subscription URLs
  • Неудобно для конечного пользователя
  • Не настоящий "Family Plan" (просто 5 отдельных аккаунтов)

Рекомендация для V1.0:

MVP подход:

  1. Отказаться от ограничения устройств на техническом уровне
  2. Полагаться на честность пользователей
  3. Добавить в ToS: "Семейный план предназначен для использования в одной семье (до 5 устройств)"
  4. Мониторить аномальный трафик: если один аккаунт генерирует 1TB/день - вручную проверить

V2.0 подход (после MVP):

  1. Реализовать Server-side webhook для мониторинга Xray логов
  2. Парсить логи подключений в реальном времени:
    # Xray лог показывает:
    [Info] accepted tcp:xxx.xxx.xxx.xxx:12345 [email:test_user@marzban]
    
  3. Собирать статистику активных IP за последние 5 минут
  4. Если > deviceLimit → отправить уведомление или временно заблокировать

Архитектура для V2.0:

┌─────────────────────────────────────────┐
│ Marzban Panel (API)                     │
│ - Создание пользователей                │
│ - Управление подписками                 │
└────────────┬────────────────────────────┘
             │
             ▼
┌─────────────────────────────────────────┐
│ Xray Core (на каждой ноде)              │
│ - Обработка трафика                     │
│ - Генерация логов подключений           │
└────────────┬────────────────────────────┘
             │
             │ (Webhooks или log streaming)
             ▼
┌─────────────────────────────────────────┐
│ Device Monitor Service (NEW!)           │
│ - Парсинг логов Xray                    │
│ - Подсчет активных IP per user          │
│ - Проверка лимитов устройств            │
│ - Автоблокировка при превышении         │
└────────────┬────────────────────────────┘
             │
             ▼
┌─────────────────────────────────────────┐
│ PostgreSQL / Redis                      │
│ - Хранение активных сессий              │
│ - Хранение семейных планов              │
└─────────────────────────────────────────┘

Стоимость разработки: ~40 часов (Device Monitor Service)


Итоговое решение:

Вариант Точность Сложность Время реализации Рекомендация
1. online_at мониторинг Низкая Средняя 8 часов ⚠️ Не точно
2. IP-based Низкая Низкая 4 часа ⚠️ Не рекомендуется
3. Xray патчи Высокая Очень высокая 80+ часов Слишком сложно
4. Sub-users Высокая Средняя 12 часов ⚠️ Неудобно для пользователей
5. Без ограничений + ToS N/A Очень низкая 0 часов Для MVP
6. Log-based monitor (V2.0) Высокая Высокая 40 часов Для V2.0

Вывод: Для MVP - НЕ реализовывать техническое ограничение устройств. Добавить в ToS и мониторить аномальный трафик вручную.

Для V2.0 - разработать Device Monitor Service с парсингом Xray логов.

Готово к обсуждению! 🚀