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:
price > 0state = completed⇒items[].externalDataнепустой для каждой позицииstate = cancelled⇒cancelReason IS NOT NULLstate = failed⇒ как минимум одна позиция содержитitems[].error- Терминальные состояния (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. Получите доступ.
