Продолжение статьи о разработке B2B платформы международной торговли

Вступление

Прошло некоторое время с момента публикации первой части, где мы рассказали о концепции и начальном этапе разработки LiqTrade. За это время проект прошел путь от MVP до полноценной production-ready платформы.

В этой статье я хочу поделиться реальными проблемами, с которыми мы столкнулись, и способами их решения. Никакого идеализированного "как всё прекрасно работает" - только честный разбор косяков, рефакторинга и технического долга.

Дисклеймер: Если вы ищете статью в стиле "10 строк кода, и ваш стартап взлетел" - это не здесь. Здесь будет про то, как мы 3 раза переписывали систему ролей, боролись с circular dependencies и почему наш bundle вырос до 1.2 ГБ (спойлер: мы его победили).


? Что было сделано: Цифры и факты

Структура проекта на сегодня:

  • 259 TypeScript/React файлов

  • 40 JSON файлов переводов (5 языков)

  • 9 Supabase Edge Functions

  • 87+ тестов (Unit + E2E)

  • ~35,000 строк кода (frontend + backend + docs)

Основные модули:

  • ✅ Аутентификация и авторизация (JWT + MFA ready)

  • ✅ RBAC система (27 ролей, 3 уровня)

  • ✅ RFQ Management (создание, редактирование, удаление)

  • ✅ Offers & Deals pipeline

  • ✅ Документооборот и KYC/KYB

  • ✅ Калькулятор таможенных платежей

  • ✅ Интернационализация (RU, EN, AR, ZH, TR)

  • ✅ File Upload система

  • ✅ Real-time notifications (готово к интеграции)


? Проблема #1: Circular Dependencies в RBAC системе

Как мы наступили на грабли

Изначально RBAC был реализован в одном файле src/config/rbac-policies.ts на 800+ строк. Всё работало... пока мы не решили добавить динамические политики.

// Было (ПЛОХО):
// src/config/rbac-policies.ts
export const policies = { ... }
export const useRBAC = () => { ... } // ❌ хук в конфиге

// src/hooks/useRBAC.ts
import { policies } from '@/config/rbac-policies' // ❌ импорт конфига

// src/config/rbac-policies.ts снова импортирует useRBAC
import { useRBAC } from '@/hooks/useRBAC' // ? CIRCULAR!

Результат: Build падал с ошибкой ReferenceError: Cannot access 'X' before initialization.

Как мы это исправили

Шаг 1: Разделили по принципу Single Responsibility

src/lib/rbac/
├── types.ts          # Только типы, без логики
├── core.ts           # Pure functions без импортов
├── policies/
│   ├── index.ts      # Объединение всех политик
│   ├── rfq.ts        # RFQ-специфичные политики
│   ├── deals.ts      # Deals-специфичные политики
│   └── users.ts      # User management политики
└── index.ts          # Единая точка входа (exports only)

Шаг 2: Применили Dependency Injection

// ✅ core.ts - pure functions без внешних зависимостей
export function checkAccess(
  policies: Policy[],        // ✅ передаём через параметры
  context: PolicyContext,
  resource: Resource,
  action: Action
): AccessCheck {
  // Чистая логика без импортов
  const applicable = policies.filter(p => 
    p.roles.includes(context.userRole) &&
    p.resources.includes(resource) &&
    p.actions.includes(action)
  )
  
  return { granted: applicable.length > 0 }
}

// ✅ Hook использует core functions
export function useRBAC() {
  const { user } = useAuth()
  
  const context: PolicyContext = useMemo(() => ({
    userId: user?.id || '',
    userRole: user?.role as UserRole,
    companyId: user?.companyId,
  }), [user])

  // Импортируем политики только здесь
  return {
    checkAccess: (resource, action) => 
      checkAccess(allPolicies, context, resource, action)
  }
}

Результат: Build время сократилось с 45 секунд до 12 секунд, circular dependencies устранены.

Lesson learned:

Если видите circular dependency — это не проблема импортов, это проблема архитектуры. Разделяйте типы, логику и зависимости.


? Проблема #2: TypeScript strict mode был отключён

Почему мы это сделали (и почему это было ошибкой)

На начальном этапе мы отключили строгие проверки TypeScript, чтобы "двигаться быстрее":

// tsconfig.json (ПЛОХАЯ версия)
{
  "compilerOptions": {
    "noImplicitAny": false,      // ❌ любой any без предупреждений
    "strictNullChecks": false,   // ❌ игнор null/undefined
    "noUnusedLocals": false      // ❌ неиспользуемый код не детектится
  }
}

Последствия:

  • 82 файла содержали any типы

  • 143 случая использования console.log (утечки данных)

  • Bugs в runtime, которые TypeScript мог бы поймать

Реальный баг, который мы словили

// Баг в AuthContext
const loadUser = async () => {
  const user = await apiClient.getCurrentUser() // может вернуть null
  setUser(user)
  
  // ? BOOM! Cannot read property 'email' of null
  toast.success(`Welcome ${user.email}`)
}

С включённым strictNullChecks TypeScript показал бы ошибку:

// ✅ С strict mode
const user = await apiClient.getCurrentUser() // User | null

// ❌ Error: Object is possibly 'null'
toast.success(`Welcome ${user.email}`)

// ✅ Правильная версия
if (user) {
  toast.success(`Welcome ${user.email}`)
}

Как мы мигрировали на strict mode

Стратегия: Постепенное включение (файл за файлом)

# 1. Создали отдельную ветку
git checkout -b feature/strict-typescript

# 2. Включили strict для одного модуля
# tsconfig.json
{
  "include": ["src/lib/rbac/**/*"], // только RBAC сначала
  "compilerOptions": {
    "strict": true
  }
}

# 3. Исправили все ошибки в RBAC (2 дня)
# 4. Повторили для остальных модулей

По модулям:

  1. Неделя 1: src/lib/**/* (утилиты, API клиент)

  2. Неделя 2: src/hooks/**/* (все hooks)

  3. Неделя 3: src/components/**/* (UI компоненты)

  4. Неделя 4: src/pages/**/* (страницы)

Автоматизация: Скрипт для замены any на unknown

// scripts/fix-any-types.ts
import * as ts from 'typescript'
import * as fs from 'fs'

function replaceAnyWithUnknown(sourceFile: ts.SourceFile): string {
  let result = sourceFile.getFullText()
  
  ts.forEachChild(sourceFile, function visit(node) {
    if (ts.isTypeReferenceNode(node) && 
        node.typeName.getText() === 'any') {
      result = result.replace(': any', ': unknown')
    }
    ts.forEachChild(node, visit)
  })
  
  return result
}

// Применили ко всем файлам
const files = glob.sync('src/**/*.ts')
files.forEach(file => {
  const content = fs.readFileSync(file, 'utf8')
  const sourceFile = ts.createSourceFile(file, content, ts.ScriptTarget.Latest)
  const fixed = replaceAnyWithUnknown(sourceFile)
  fs.writeFileSync(file, fixed)
})

Результат:

  • 0 использований any в production коде

  • 47 багов обнаружено и исправлено до production

  • Время разработки увеличилось на ~15%, но количество багов упало на ~60%

Lesson learned:

Strict mode должен быть включён с первого дня. "Мы включим позже" = никогда не включим.


? Проблема #3: Bundle size вырос до 1.2 Мб

Как это произошло

После добавления всех зависимостей наш npm run build начал выдавать:

dist/assets/vendor-abc123.js    847.3 KB  ⚠️
dist/assets/ui-radix-def456.js  234.1 KB  ⚠️
dist/assets/index-ghi789.js     156.8 KB  ⚠️

Total bundle size: 1.24 Mb ⚠️⚠️⚠️

Lighthouse Score упал с 95 до 23.

Что было не так

  • Все иконки Lucide-react импортировались целиком

// ❌ ПЛОХО - импорт всей библиотеки (200+ KB)
import * as Icons from 'lucide-react'
const Icon = Icons[iconName]
  • shadcn/ui компоненты дублировались

// 15 файлов импортировали одно и то же
import { Button } from '@/components/ui/button' // дублируется 15 раз
  • React Query DevTools в production

// ❌ в production сборке!
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
  • Все страницы загружались сразу

// ❌ Без lazy loading
import Dashboard from './pages/Dashboard'
import RFQPage from './pages/RFQPage'
// ... ещё 10 страниц

Как мы оптимизировали

1. Tree-shaking для иконок

// ✅ ХОРОШО - импорт только нужных иконок
import { Search, Plus, X, ChevronDown } from 'lucide-react'

// Результат: 200 KB → 12 KB

2. Настроили manualChunks в Vite

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // Core React (один раз на всё приложение)
          vendor: ['react', 'react-dom', 'react-router-dom'],
          
          // UI библиотека
          'ui-radix': [
            '@radix-ui/react-dialog',
            '@radix-ui/react-dropdown-menu',
            // ... только часто используемые
          ],
          
          // Data layer
          'data-layer': ['@tanstack/react-query', '@supabase/supabase-js'],
          
          // Редко используемые страницы
          'admin-pages': [
            './src/pages/admin/AdminPanel',
            './src/pages/admin/UserManagement'
          ]
        }
      }
    }
  }
})

3. Lazy loading для всех routes

// src/App.tsx
import { lazy, Suspense } from 'react'

// ✅ Lazy load
const Dashboard = lazy(() => import('./pages/Dashboard'))
const RFQPage = lazy(() => import('./pages/RFQPage'))
const AdminPanel = lazy(() => import('./pages/admin/AdminPanel'))

function App() {
  return (
    <Suspense fallback={<Loader />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/rfq" element={<RFQPage />} />
        <Route path="/admin" element={<AdminPanel />} />
      </Routes>
    </Suspense>
  )
}

4. Dynamic imports для тяжёлых библиотек

// ✅ Загружаем recharts только когда нужно
async function loadChartComponent() {
  const { LineChart } = await import('recharts')
  return LineChart
}

// В компоненте
const [ChartComponent, setChartComponent] = useState(null)

useEffect(() => {
  loadChartComponent().then(setChartComponent)
}, [])

5. Code splitting по роутам

// vite.config.ts
manualChunks: (id) => {
  // Dashboard в отдельный chunk
  if (id.includes('pages/Dashboard')) {
    return 'route-dashboard'
  }
  
  // Admin в отдельный chunk (редко используется)
  if (id.includes('pages/admin')) {
    return 'route-admin'
  }
  
  // RFQ + Offers + Deals вместе (часто переключаются)
  if (id.includes('pages/RFQ') || 
      id.includes('pages/Offers') || 
      id.includes('pages/Deals')) {
    return 'route-trading'
  }
}

Результаты оптимизации:

# ДО:
Total bundle size: 1.24 GB
Initial load: 847 KB
FCP: 4.2s
Lighthouse: 23

# ПОСЛЕ:
Total bundle size: 287 KB (↓ 77%)
Initial load: 156 KB (↓ 82%)
FCP: 1.1s (↓ 74%)
Lighthouse: 94 (↑ 309%)

Lesson learned:

Не добавляйте библиотеки "на всякий случай". Каждый KB имеет значение. Measure first, optimize second.


? Проблема #4: i18n — от "потом сделаем" до production

Почему мы откладывали

На старте проекта мы решили: "Сделаем английский, остальные языки потом". Классическая ошибка.

Что пошло не так:

  • Хардкод строк везде в коде

  • Дата форматы "на глаз"

  • Валюты без локализации

  • RTL (арабский) вообще не учитывался

Когда пришло время добавлять языки, выяснилось что нужно переписать 70% компонентов.

Как мы внедрили i18n правильно

Шаг 1: Выбрали i18next + react-i18next

npm install i18next react-i18next i18next-browser-languagedetector i18next-http-backend

Шаг 2: Структура переводов по модулям

public/locales/
├── ru/
│   ├── common.json      # Общие: кнопки, навигация, статусы
│   ├── dashboard.json   # Специфичные для дашборда
│   ├── rfq.json         # RFQ модуль
│   ├── auth.json        # Аутентификация
│   └── ...
├── en/ (то же самое)
├── ar/ (العربية - with RTL)
├── zh/ (中文)
└── tr/ (Türkçe)

Шаг 3: Конфигурация с namespace support

// src/lib/i18n.ts
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import HttpBackend from 'i18next-http-backend'

i18n
  .use(HttpBackend)
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    fallbackLng: 'ru',
    supportedLngs: ['ru', 'en', 'ar', 'zh', 'tr'],
    
    // ✅ Namespaces для модульности
    ns: ['common', 'dashboard', 'rfq', 'offers', 'deals', 'auth'],
    defaultNS: 'common',
    
    backend: {
      loadPath: '/locales/{{lng}}/{{ns}}.json',
    },
    
    detection: {
      order: ['localStorage', 'navigator', 'htmlTag'],
      caches: ['localStorage'],
    },
  })

Шаг 4: Рефакторинг компонентов

// ❌ БЫЛО (хардкод)
function Dashboard() {
  return (
    <div>
      <h1>Дашборд</h1>
      <button>Создать запрос</button>
      <p>Активных запросов: {count}</p>
    </div>
  )
}

// ✅ СТАЛО (с переводами)
function Dashboard() {
  const { t } = useTranslation('dashboard')
  
  return (
    <div>
      <h1>{t('title')}</h1>
      <button>{t('actions.createRequest')}</button>
      <p>{t('stats.activeRequests', { count })}</p>
    </div>
  )
}

Шаг 5: RTL support для арабского

// src/lib/i18n.ts
export const languages = {
  ar: {
    code: 'ar',
    name: 'العربية',
    dir: 'rtl', // ✅ Right-to-left
  },
  // ...
}

// При переключении языка
i18n.changeLanguage(lng)
document.documentElement.dir = languages[lng].dir
document.documentElement.lang = lng
/* src/index.css */
[dir="rtl"] {
  direction: rtl;
  text-align: right;
}

/* Flexbox автоматически реверсится */
[dir="rtl"] .flex-row {
  flex-direction: row-reverse;
}

Шаг 6: Переводы через AI (ChatGPT)

// Промпт для ChatGPT:
"Переведи JSON объект с английского на арабский, китайский и турецкий. 
Сохрани структуру и ключи, переведи только значения.
Используй формальный стиль для business контекста."

// Input:
{
  "common": {
    "actions": {
      "create": "Create",
      "save": "Save"
    }
  }
}

// Output для ar:
{
  "common": {
    "actions": {
      "create": "إنشاء",
      "save": "حفظ"
    }
  }
}

Результаты:

  • 40 JSON файлов переводов (5 языков × 8 модулей)

  • 100% покрытие UI строк

  • RTL support для арабского рынка

  • Время на добавление нового языка: ~2 часа (было бы ~2 недели без i18n)

Lesson learned:

i18n нужно добавлять до написания первого компонента, не после. Рефакторинг 70% кода — это боль.


? Проблема #5: Edge Functions — когда Supabase не хватает

Зачем нам понадобились Edge Functions

Изначально мы делали всё через Supabase Database Functions (PL/pgSQL):

-- Создание RFQ через SQL функцию
CREATE FUNCTION create_rfq(p_user_id UUID, p_data JSONB)
RETURNS UUID AS $$
DECLARE
  v_rfq_id UUID;
BEGIN
  INSERT INTO rfqs (user_id, title, description, ...)
  VALUES (p_user_id, p_data->>'title', ...)
  RETURNING id INTO v_rfq_id;
  
  RETURN v_rfq_id;
END;
$$ LANGUAGE plpgsql;

Проблемы:

  1. Сложная бизнес-логика не подходит для SQL

  2. Нет типизации - ошибки только в runtime

  3. Тестирование - кошмар с SQL функциями

  4. Интеграции (email, webhooks) из SQL невозможны

Переход на Edge Functions

Структура:

supabase/functions/
├── create-rfq/
│   └── index.ts          # TypeScript!
├── verify-kyc/
│   └── index.ts
├── send-notification/
│   └── index.ts
└── deno.json             # Конфигурация

Пример: create-rfq/index.ts

import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

serve(async (req) => {
  // CORS
  if (req.method === 'OPTIONS') {
    return new Response('ok', { headers: corsHeaders })
  }

  try {
    // Аутентификация
    const supabaseClient = createClient(
      Deno.env.get('SUPABASE_URL')!,
      Deno.env.get('SUPABASE_ANON_KEY')!,
      {
        global: {
          headers: { Authorization: req.headers.get('Authorization')! }
        }
      }
    )

    const { data: { user } } = await supabaseClient.auth.getUser()
    if (!user) {
      return new Response(
        JSON.stringify({ error: 'Unauthorized' }),
        { status: 401 }
      )
    }

    // Валидация
    const rfqData = await req.json()
    if (!rfqData.title || !rfqData.description) {
      return new Response(
        JSON.stringify({ error: 'Missing required fields' }),
        { status: 400 }
      )
    }

    // Создание RFQ
    const { data, error } = await supabaseClient
      .from('rfqs')
      .insert({
        user_id: user.id,
        title: rfqData.title,
        description: rfqData.description,
        status: 'draft',
        created_at: new Date().toISOString()
      })
      .select()
      .single()

    if (error) throw error

    // ✅ Отправляем email уведомление
    await sendNotificationEmail(user.email, 'RFQ Created', data)

    // ✅ Webhook для интеграций
    await triggerWebhook('rfq.created', data)

    return new Response(
      JSON.stringify(data),
      { headers: { 'Content-Type': 'application/json' } }
    )

  } catch (error) {
    console.error('Error:', error)
    return new Response(
      JSON.stringify({ error: error.message }),
      { status: 500 }
    )
  }
})

Deployment:

# Разворачиваем все функции
supabase functions deploy

# Или одну конкретную
supabase functions deploy create-rfq

# Проверяем логи
supabase functions logs create-rfq --tail

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

  • ✅ TypeScript с полной типизацией

  • ✅ npm пакеты через ESM

  • ✅ Легко тестировать (обычный HTTP endpoint)

  • ✅ Быстрое выполнение (Deno runtime)

  • ✅ Автоматический scaling

Результаты:

  • Миграция 15 SQL функций → 9 Edge Functions

  • Время разработки новой функции: ~2 часа (было ~1 день с SQL)

  • 0 SQL ошибок в production (были 7 за месяц)

Lesson learned:

Используйте SQL для данных, TypeScript для бизнес-логики. Edge Functions — это именно то, что нужно для сложных операций.


? Что дальше: Roadmap на Q1 2026

В работе (ближайший месяц)

1. Real-time notifications через WebSocket

// Supabase Realtime
const channel = supabase
  .channel('rfq-updates')
  .on('postgres_changes', 
    { event: 'INSERT', schema: 'public', table: 'rfqs' },
    (payload) => {
      toast.info(`Новый RFQ: ${payload.new.title}`)
    }
  )
  .subscribe()

2. Stripe integration для подписок

  • Free: 5 RFQ/месяц

  • Starter ($49): 50 RFQ/месяц

  • Pro ($149): 200 RFQ/месяц

  • Enterprise ($499): Unlimited

3. Audit logging для compliance

CREATE TABLE audit_logs (
  id UUID PRIMARY KEY,
  user_id UUID,
  action VARCHAR(50),
  resource_type VARCHAR(50),
  resource_id UUID,
  changes JSONB,
  ip_address INET,
  timestamp TIMESTAMPTZ DEFAULT NOW()
);

4. Advanced search через Typesense

// Полнотекстовый поиск с фильтрами
const results = await typesenseClient
  .collections('rfqs')
  .documents()
  .search({
    q: 'steel coils',
    query_by: 'title,description',
    filter_by: 'status:active && price:<100000',
    sort_by: 'created_at:desc'
  })

Проект, который я задумывал как пет проект постепенно перерастает в ад :)

Комментарии (5)


  1. dyadyaSerezha
    15.10.2025 00:39

    1) Все же кажется, что ахилесова пята - это наличие встречных заявок для взаиморасчетов. Если между странами А и В торговля не полностью равная в обоих направлениях, то всегда будут оставаться невыполненные заявки, которые сначала потеряют время (чтобы выяснить, что для них нет встречной заявки), а потом и деньги (когда хозяева переключат их на классический SWIFT-перевод). В идеале это был бы собственный расчётный орган/банк с представительствами во всех странах и сам делающий псевдопереводы денег (через крипту, например). Но понимаю, что это совсем другой уровень и риски.

    2) Не понял про размер пакетов. Сначала везде KB, а потом вдруг сразу GB. А куда делись MB?

    3) welcome to hell)


  1. Amonoc Автор
    15.10.2025 00:39

    1. Это не основная функция платформы, а один их функционалов. Задача этой функции простая: аккумулировать заявки, чем больше сумма, тем меньше комиссия. У нас довольно большое количество экспортеров, поэтому агргегация будет возможна. Для этой функции есть платежные агенты, которые могут собирать заявки внутри и за, чтобы потом распределять денежные потоки на основании инвойсов и заявок.

    2. Ну тут соглашусь - очепятка)

    3. Это мой третий проект с нуля, и каждый раз проекты сложнее и геморра с ними больше и больше)