П/ВИН

YooKassa Billing в LobeChat: Phase 3 миграции

·8 мин чтения

Когда берёшься за форк крупного open-source проекта, самое интересное начинается не в момент git clone, а когда ты открываешь исходники и понимаешь: половина функционала, который ты планировал переиспользовать, — это красивые заглушки. Именно это случилось с биллингом в LobeChat. Phase 3 нашей миграции — это история о том, как мы превратили no-op функции в реальную платёжную систему на YooKassa, попутно заменив маскота и выстроив upsell-механику.

Контекст: что такое ai-aggregator и зачем нам LobeChat

Проект ai-aggregator (ask.gptweb.ru) — это наш AI-ассистент с поддержкой множества моделей: GPT-4, Claude, Gemini, локальные модели через LiteLLM. К моменту начала Phase 3 у нас уже была полностью работающая V2.0: монорепо с packages/core, web, bot и litellm, PostgreSQL через Supabase, Telegram-бот, базовый чат.

Почему LobeChat? Потому что это один из лучших open-source AI-чатов с продуманным UX, поддержкой плагинов, агентов и мультимодальности. Вместо того чтобы строить всё с нуля, мы решили сделать форк и адаптировать под российский рынок — с YooKassa вместо Stripe, с рублёвыми тарифами и локальной инфраструктурой.

План миграции состоял из нескольких фаз. Phase 1 и 2 — настройка окружения и базовая интеграция. Phase 3 — биллинг. И вот тут началось самое интересное.

Проблема: биллинг LobeChat — это красивый фасад

Первое, что я сделал при изучении исходников — пошёл смотреть на биллинговую инфраструктуру. LobeChat использует Stripe в SaaS-версии на lobechat.com. Логично предположить, что в open-source версии есть хотя бы скелет.

Реальность оказалась другой:

// Вот как выглядит getSubscriptionPlan() в LobeChat
export const getSubscriptionPlan = async () => {
  // TODO: implement subscription plan
  return { plan: 'Free', tokens: 0 };
};

Все роутеры — subscription, spend, topUp — зарегистрированы в root router, но реализации нет. Функции chargeBeforeGenerate и chargeAfterGenerate — no-op. Stripe SDK установлен в package.json, но не используется ни в одном файле. В миграции 0009 старые Stripe-таблицы удалены.

UI биллинга в SaaS-версии работает через iframe на lobechat.com. То есть даже фронтенд не содержит реальной логики — просто загружает страницу с основного сайта.

Это хорошая новость и плохая одновременно. Хорошая: нам не нужно ничего выкорчёвывать или переписывать поверх чужого кода. Плохая: придётся строить с нуля, используя только архитектурный скелет.

Аудит: что реально работает

Прежде чем писать код, я провёл полный аудит того, что есть:

Работает:

  • Usage tracking — стоимость запросов и количество токенов хранятся в метаданных сообщений
  • Drizzle ORM с PostgreSQL — чистая схема, удобные миграции
  • tRPC роутеры — паттерн понятен, достаточно заполнить процедуры
  • Middleware для аутентификации

Не работает (заглушки):

  • Проверка лимитов перед генерацией
  • Списание токенов после генерации
  • Управление подписками
  • Пополнение баланса

Отсутствует полностью:

  • Интеграция с платёжной системой
  • Таблицы биллинга в БД
  • Webhook-обработчик платежей

Дизайн решения: три новые таблицы

После аудита стал очевиден минимально необходимый набор изменений. Я решил придерживаться принципа YAGNI — не строить биллинговый движок уровня Stripe, а сделать то, что нужно прямо сейчас.

Схема БД (Drizzle-миграция):

// packages/core/src/db/schema/billing.ts
export const billingPlans = pgTable('billing_plans', {
  id: varchar('id', { length: 50 }).primaryKey(),
  name: varchar('name', { length: 100 }).notNull(),
  price_rub: integer('price_rub').notNull().default(0),
  tokens_monthly: bigint('tokens_monthly', { mode: 'number' }).notNull(),
  is_active: boolean('is_active').notNull().default(true),
  created_at: timestamp('created_at').defaultNow(),
});
 
export const billingPayments = pgTable('billing_payments', {
  id: uuid('id').primaryKey().defaultRandom(),
  user_id: uuid('user_id').notNull().references(() => users.id),
  yookassa_payment_id: varchar('yookassa_payment_id', { length: 255 }),
  type: varchar('type', { length: 20 }).notNull(), // 'subscription' | 'topup'
  amount_rub: integer('amount_rub').notNull(),
  tokens_added: bigint('tokens_added', { mode: 'number' }),
  status: varchar('status', { length: 20 }).notNull().default('pending'),
  created_at: timestamp('created_at').defaultNow(),
});
 
export const billingUsage = pgTable('billing_usage', {
  id: uuid('id').primaryKey().defaultRandom(),
  user_id: uuid('user_id').notNull().references(() => users.id),
  tokens_used: bigint('tokens_used', { mode: 'number' }).notNull(),
  period_start: timestamp('period_start').notNull(),
  period_end: timestamp('period_end').notNull(),
});

Три таблицы вместо десяти. Никаких invoice, subscription_items, proration — всё это можно добавить потом, когда появится реальная потребность.

Тарифы:

| План | Цена | Токены/мес | Целевая аудитория | |------|------|------------|------------------| | Free | 0₽ | 50 000 | Знакомство с продуктом | | Basic | 490₽ | 500 000 | Активные пользователи | | Pro | 1 490₽ | 5 000 000 | Профессионалы |

Пополнения баланса (разовые): 199₽/500K, 699₽/2M, 1499₽/5M.

Реализация: YooKassa интеграция

YooKassa — главная платёжная система для российского рынка. В отличие от Stripe, она работает без VPN и принимает карты Мир, СБП, электронные кошельки.

Основной модуль биллинга:

// packages/core/src/billing/yookassa.ts
import { ICreatePayment, YooCheckout } from '@a2seven/yoo-checkout';
 
const checkout = new YooCheckout({
  shopId: process.env.YOOKASSA_SHOP_ID!,
  secretKey: process.env.YOOKASSA_SECRET_KEY!,
});
 
export async function createSubscriptionPayment(
  userId: string,
  planId: 'basic' | 'pro',
  returnUrl: string
) {
  const plan = PLANS[planId];
  
  const payment = await checkout.createPayment({
    amount: {
      value: plan.price_rub.toFixed(2),
      currency: 'RUB',
    },
    confirmation: {
      type: 'redirect',
      return_url: returnUrl,
    },
    capture: true,
    description: `Подписка ${plan.name} — WebGPT`,
    metadata: {
      user_id: userId,
      type: 'subscription',
      plan_id: planId,
    },
  });
 
  return payment;
}
 
export async function handleWebhook(event: YooKassaEvent) {
  if (event.event !== 'payment.succeeded') return;
  
  const { user_id, type, plan_id, tokens } = event.object.metadata;
  
  if (type === 'subscription') {
    await activateSubscription(user_id, plan_id);
  } else if (type === 'topup') {
    await addTokens(user_id, parseInt(tokens));
  }
}

Webhook-обработчик — критически важная часть. YooKassa отправляет уведомление о платеже асинхронно, поэтому нельзя полагаться только на redirect после оплаты. Пользователь может закрыть вкладку до возврата на сайт.

// apps/web/src/app/api/billing/webhook/route.ts
export async function POST(req: Request) {
  const body = await req.json();
  
  // Верификация IP YooKassa (185.71.76.0/27, 185.71.77.0/27)
  const forwarded = req.headers.get('x-forwarded-for');
  if (!isYooKassaIP(forwarded)) {
    return new Response('Forbidden', { status: 403 });
  }
  
  await handleWebhook(body);
  return new Response('OK');
}

Upsell-механика: когда и как предлагать апгрейд

Один из ключевых вопросов при проектировании биллинга — когда показывать предложение об апгрейде, чтобы это не раздражало, но конвертировало.

Мы выбрали три триггерных момента:

1. При исчерпании лимита. Пользователь попытался отправить сообщение, но токены кончились. Вместо ошибки — модал с предложением пополнить баланс или апгрейдиться.

// Проверка лимитов в chat pipeline
export async function checkTokenLimits(userId: string): Promise<LimitCheck> {
  const user = await getUserWithBilling(userId);
  const used = user.tokens_used_this_month;
  const limit = PLAN_LIMITS[user.plan].tokens_monthly;
  
  if (used >= limit) {
    return { 
      allowed: false, 
      reason: 'limit_exceeded',
      used_percent: 100 
    };
  }
  
  return { 
    allowed: true,
    used_percent: Math.round((used / limit) * 100)
  };
}

2. При достижении 80% лимита. Тихое уведомление в интерфейсе: "Вы использовали 80% токенов этого месяца". Без навязчивых модалов.

3. При использовании дорогих моделей на Free-плане. Когда пользователь выбирает GPT-4o или Claude Opus на бесплатном тарифе — показываем, насколько быстро расходуются токены, и предлагаем Pro.

Замена маскота: технические детали

Параллельно с биллингом мы разобрались с брендингом. LobeChat имеет оранжевого маскота-робота в четырёх вариациях:

  • lobe-ai.png — основной маскот с фиолетовым облаком
  • agent-default.png — серый робот (дефолтный аватар агентов)
  • agent-builder.png — маскот в строительной каске
  • doc-copilot.png — маскот в академической шапке

Простое решение: заменить все четыре файла на наш logo.png. Но был один нюанс — в builtin-agents/src/agents/inbox/index.ts путь к аватару был захардкожен:

// До
export const inboxAgent = {
  avatar: '/avatars/lobe-ai.png',
  // ...
};
 
// После
import { BRANDING_LOGO_URL } from '@lobechat/business-const';
 
export const inboxAgent = {
  avatar: BRANDING_LOGO_URL,
  // ...
};

Константа BRANDING_LOGO_URL уже была в системе и указывала на /logo.png. Нужно было только убрать хардкод в одном месте и заменить физические файлы изображений.

Итоги Phase 3 и V3.0

После завершения Phase 3 мы также реализовали оставшиеся 8 фич из плана V3.0. Билд прошёл успешно, все новые роуты появились в выводе Next.js:

○ /api/billing/create-payment
○ /api/billing/payments  
○ /api/billing/webhook
○ /api/upload
○ /v1/chat/completions
○ /v1/models
○ /api/settings/api-keys
○ /chat/compare
○ /api/conversations/[id]/branch
○ /api/auth/telegram-webapp
○ /tg-app

9 фич за одну сессию — файловые загрузки с vision, OpenAI-совместимый API, ветвление разговоров, голосовые сообщения в боте, inline-режим бота, генерация изображений через DALL-E 3, Telegram Mini App.

Git-история отражает прогресс: от feat: implement V3.0: 9 new features до последующих коммитов с фиксами и редизайном в стиле glass-morphism.

Выводы: чему учит миграция крупного open-source проекта

Первый и главный урок — аудит перед реализацией экономит в разы больше времени, чем кажется. Мы потратили несколько часов на изучение исходников LobeChat перед тем, как написать первую строку кода. Это позволило избежать ситуации, когда пишешь интеграцию поверх системы, которая на самом деле не работает.

Второй урок — заглушки в open-source это не баг, это фича. LobeChat намеренно оставил биллинг пустым, потому что каждый оператор хочет свою платёжную систему. Stripe в США, YooKassa в России, Razorpay в Индии. Архитекторы LobeChat это понимали и оставили чистые точки расширения. Нужно уметь их читать.

Третий урок — YAGNI работает даже в биллинге. Три таблицы вместо полноценной биллинговой системы — это осознанное решение. Когда появится потребность в invoice, prorations, tax handling — добавим. Сейчас важнее запустить и получить первых платящих пользователей, чем построить идеальную архитектуру для масштаба, которого ещё нет.

Четвёртый урок — webhook важнее redirect. В платёжных интеграциях всегда есть соблазн полагаться на return_url и обрабатывать результат при возврате пользователя. Это ошибка. Пользователи закрывают вкладки, теряют соединение, используют блокировщики редиректов. Webhook — единственный надёжный способ узнать о завершённом платеже. Настраивай его первым.

Пятый урок — брендинг это не мелочь. Казалось бы, замена четырёх PNG-файлов — дело на пять минут. Но если пропустить хардкод в глубинах builtin-agents, маскот конкурента будет появляться в неожиданных местах у твоих пользователей. Полный grep по кодовой базе перед заменой изображений — обязательный шаг.

Что дальше

Phase 4 миграции — перенос чат-интерфейса LobeChat с его плагинной системой и агентами. Это значительно сложнее биллинга, потому что затрагивает core пайплайн генерации. Параллельно продолжаем итерировать V3.1 — список задач уже сформирован: 17 задач в 7 фазах.

Если вы тоже работаете с форком LobeChat или строите биллинг на YooKassa — документация YooKassa SDK и официальный npm-пакет помогут быстро разобраться. Для работы с Drizzle ORM и миграциями — официальная документация очень подробная и содержит примеры именно для PostgreSQL.

Исходный план миграции и design-документы хранятся в репозитории проекта — стараемся документировать каждое архитектурное решение в момент его принятия, пока контекст свеж.

Паша Вин
Паша Вин

Фулстек-разработчик, строю SaaS-продукты и автоматизации на Next.js, Python и AI. Пишу о реальных кейсах из продакшена.

Связанный проект

Смотреть в портфолио →