Оптовая платформа цифровых товаров

Order state machine design 2026: проектирование state-машины заказов

Production-дизайн state-машины B2B-заказа: 6 базовых состояний, инварианты переходов, audit log и реализация на TypeScript с учётом модели FoxReload API.

Order state machine design 2026: проектирование state-машины заказов

Order state machine — это сердце любого B2B-фулфилмента. Правильный дизайн делает систему наблюдаемой, отлаживаемой и audit-friendly. Неправильный — порождает impossible states и race conditions, которые ловить можно месяцами. Эта статья — детальный гайд по проектированию FSM для digital-goods заказа, совместимого с реальными статусами FoxReload API.

1. Состояния FoxReload API

FoxReload API использует следующие статусы заказа:

enum OrderState {
  ACTIVE = 'active',           // создан, ожидает оплаты с баланса
  PAID = 'paid',               // средства списаны с баланса
  PROCESSING = 'processing',   // fulfilment у поставщика
  COMPLETED = 'completed',     // коды выданы — items[].externalData заполнен
  CANCELLED = 'cancelled',     // отменён — cancelReason: payment_failure | payment_expiration | user_request
  FAILED = 'failed',           // ошибка выполнения — items[].error содержит причину
}

Допустимые переходы (граф):

active      → paid | cancelled
paid        → processing | cancelled
processing  → completed | failed
completed   → (terminal)
cancelled   → (terminal)
failed      → (terminal)

Все другие переходы (например, completed → processing) — invalid и должны бросать ошибку в вашей системе.

2. Инварианты

Инвариант = свойство, всегда истинное независимо от состояния. Для order:

  1. price > 0
  2. state = completeditems[].externalData непустой для каждой позиции
  3. state = cancelledcancelReason IS NOT NULL
  4. state = failed ⇒ как минимум одна позиция содержит items[].error
  5. Терминальные состояния (completed, cancelled, failed) — необратимы

Инварианты проверяются ассертами в FSM transition handler и в DB через CHECK constraints:

ALTER TABLE orders ADD CONSTRAINT chk_terminal_irreversible
  CHECK (
    (status = 'completed' AND completed_at IS NOT NULL) OR
    (status != 'completed')
  );

3. Получение статуса: опрос вместо вебхуков

FoxReload не поддерживает webhooks. Статус заказа получается только опросом:

async function transition(order: LocalOrder): Promise<LocalOrder> {
  // Опрашиваем FoxReload API
  const remote = await fetch(
    `https://public-api.foxreload.com/api/orders/${order.foxreloadId}`,
    { headers: { 'X-API-Key': process.env.FOXRELOAD_KEY! } }
  ).then(r => r.json());

  if (remote.status === order.status) return order; // без изменений

  // Валидируем переход
  const validTransitions: Record<string, string[]> = {
    active: ['paid', 'cancelled'],
    paid: ['processing', 'cancelled'],
    processing: ['completed', 'failed'],
  };
  if (!validTransitions[order.status]?.includes(remote.status)) {
    throw new Error(`invalid transition ${order.status} → ${remote.status}`);
  }

  // Записываем в audit log
  return db.tx(async (tx) => {
    const updated = await tx.orders.update(order.id, {
      status: remote.status,
      version: order.version + 1, // optimistic lock
      cancelReason: remote.cancelReason ?? null,
      completedData: remote.status === 'completed' ? remote.items : null,
    }, { where: { version: order.version } });

    await tx.audit.insert({
      event_id: randomUUID(),
      order_id: order.id,
      from_state: order.status,
      to_state: remote.status,
      actor: 'poll_worker',
      timestamp: new Date(),
      metadata: JSON.stringify({ cancelReason: remote.cancelReason }),
    });

    return updated;
  });
}

4. Poll worker — фоновый опросчик

// Запускать каждые 15 секунд
async function pollPendingOrders() {
  const pendingOrders = await db.orders.findByStatus(
    ['active', 'paid', 'processing']
  );

  await Promise.allSettled(
    pendingOrders.map(order => transition(order).catch(err =>
      logger.error({ orderId: order.id, err }, 'transition failed')
    ))
  );
}

5. XState или enum+switch?

Для small/medium projects — enum + switch достаточно:

const VALID_TRANSITIONS: Record<string, string[]> = {
  active: ['paid', 'cancelled'],
  paid: ['processing', 'cancelled'],
  processing: ['completed', 'failed'],
  completed: [],
  cancelled: [],
  failed: [],
};

function nextState(current: string, next: string): string {
  if (!VALID_TRANSITIONS[current]?.includes(next)) {
    throw new Error(`invalid transition: ${current} → ${next}`);
  }
  return next;
}

Для complex flows (multi-supplier, partial-delivery, fraud-hold) — XState даёт визуальную диаграмму и hierarchical states.

Approach Lines of code Visualizable Type-safe Performance
Boolean flags 50 No No Best
Enum + switch 150 No Strong Best
XState 200+ Yes (Inspector) Strong Good
Temporal workflow 500+ Yes Strong Good (async)

CTA

FoxReload API экспонирует status, cancelReason и items[].externalData через GET /api/orders/{id} — опрашивайте его в poll worker для обновления вашей FSM. Получите доступ.

Часто задаваемые вопросы

Сколько состояний должно быть в order FSM?
Для FoxReload-интеграции моделируйте 6 состояний API: active, paid, processing, completed, cancelled, failed. Добавляйте собственные состояния (например, awaiting_payment для вашего UI) только если бизнес-логика требует — каждое лишнее состояние это +5% bugs.
Можно ли использовать boolean-флаги вместо state-машины?
Нет. Flags is_paid, is_shipped, is_completed создают impossible states. FSM с enum гарантирует, что каждый order в exactly одном валидном состоянии. Это страхует от 30%+ bugs.
Как получить статус заказа из FoxReload?
Только опросом: GET /api/orders/{order_id} с заголовком X-API-Key. Webhooks в FoxReload не поддерживаются. Опрашивайте до достижения терминального состояния (completed, cancelled, failed).
Что должно быть в audit log каждого transition?
event_id (UUIDv4), order_id, from_state, to_state, timestamp, actor (user_id или 'system'), reason, metadata (например, items[].externalData при переходе в completed). Append-only, никакие updates на старых записях.
Получить доступ к FoxReload API

Похожие статьи