Eliminar payment + waiting (legacy): el bot toma pedidos, no cobra

El bot conversacional no maneja pagos. Su trabajo: pedidos, datos de
entrega, dejar la orden anotada en Woo (status=pending). El cobro lo
gestiona el comercio offline. Todo lo de payment_type / is_paid /
PAYMENT / WAITING_WEBHOOKS era legacy de un flow viejo que se baja.

Nuevo flow: IDLE → CART → SHIPPING → IDLE (con orden creada).
Cuando el usuario completa shipping (pickup elegido OR delivery+address),
shipping.js emite create_order y el bot cierra con order.confirmed.

- fsm.js: 4 estados (IDLE/CART/SHIPPING/AWAITING_HUMAN). hasPaymentInfo
  e isPaid eliminados. deriveNextState gira SHIPPING→IDLE en vez de
  →PAYMENT→WAITING. ALLOWED transitions actualizadas.
- orderModel.js: createEmptyOrder() sin payment_type/is_paid.
  migrateOldContext deja de leer payment_method / mp.payment_status.
- stateHandlers: payment.js y waiting.js eliminados. shipping.js gana
  finalizeOrder() que emite create_order action y vuelve a IDLE.
- replyTemplates: payment.* y waiting.* fuera. order.confirmed nuevo,
  con 3 variantes y rewriter habilitado.
- NLU openai.js + nlu/schemas.js: select_payment fuera del enum, payment_method
  fuera de entities. Prompt sin la regla de SELECCIONAR PAGO.
- nlu/router.js + nlu/index.js: dominio "payment" eliminado.
  shouldSkipRouter ya no chequea PAYMENT.
- nlu/specialists/payment.js: eliminado.
- promptsRepo.js + promptLoader.js: PROMPT_KEYS sin "payment".
- turnEngineV3.js: switch ya no dispatcha a PAYMENT/WAITING. normalizeState
  mapea estados legacy (PAYMENT/WAITING_WEBHOOKS/COMPLETED) a IDLE.
  context_patch ya no emite payment_method.
- wooOrders.createOrder: paymentMethod param eliminado. Order queda en
  status=pending sin payment_method (cobro offline).
- pipeline.js: paymentMethod fuera del create_order glue. Invariant
  "no_checkout_without_payment_link" eliminado. signal payment_selected
  reemplazado por shipping_completed.
- XState machine: top-level PAYMENT y WAITING eliminados. SELECT_PAYMENT
  event fuera. SHIPPING ahora cierra con enqueueWooCreateOrder +
  replyOrderConfirmed → IDLE. Guards hasPayment/isPaid borrados.
- Tests fsm.test.js / orderModel.test.js / machine/index.test.js
  actualizados al nuevo contrato. 188 tests pasando.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lucas Tettamanti
2026-05-01 20:53:19 -03:00
parent 04ac33430f
commit 17cea4aa9e
26 changed files with 254 additions and 1185 deletions

View File

@@ -5,7 +5,7 @@ import { pool } from "../../shared/db/pool.js";
// ─────────────────────────────────────────────────────────────
// Prompt keys válidos
export const PROMPT_KEYS = ["router", "greeting", "orders", "shipping", "payment", "browse"];
export const PROMPT_KEYS = ["router", "greeting", "orders", "shipping", "browse"];
// Modelos por defecto para cada prompt
export const DEFAULT_MODELS = {
@@ -13,7 +13,6 @@ export const DEFAULT_MODELS = {
greeting: "gpt-4-turbo",
orders: "gpt-4-turbo",
shipping: "gpt-4o-mini",
payment: "gpt-4o-mini",
browse: "gpt-4-turbo",
};

View File

@@ -205,7 +205,6 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
ok: true,
checks: [
{ name: "required_keys_present", ok: true },
{ name: "no_checkout_without_payment_link", ok: true },
{ name: "no_order_action_without_items", ok: true },
],
};
@@ -257,16 +256,14 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
...baseAddress,
phone: baseAddress.phone || phoneFromWa,
};
// Obtener shipping_method y payment_method del contexto (preferir decision que es el resultado del turn)
// shipping_method del contexto (delivery|pickup). El cobro se gestiona offline.
const shippingMethod = decision?.context_patch?.shipping_method || reducedContext?.shipping_method || null;
const paymentMethod = decision?.context_patch?.payment_method || reducedContext?.payment_method || null;
const order = await createOrder({
tenantId,
wooCustomerId: externalCustomerId,
basket: basketToUse,
address: addressWithPhone,
shippingMethod,
paymentMethod,
run_id: null,
});
actionPatch.woo_order_id = order?.id || null;
@@ -391,7 +388,7 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
const orderForFsm = context?.order || context?.order_basket || {};
const signals = {
confirm_order: plan.intent === "confirm_order",
payment_selected: plan.intent === "select_payment",
shipping_completed: plan.order_action === "create_order",
};
const nextState = safeNextState(prev_state, orderForFsm, signals).next_state;
plan.next_state = nextState;
@@ -448,7 +445,6 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
invariants,
final_reply: plan.reply,
order_id: actionPatch.woo_order_id || null,
payment_link: actionPatch.payment_link || null,
latency_ms: end_to_end_ms,
});

View File

@@ -1,7 +1,8 @@
/**
* FSM simplificada para el flujo conversacional.
*
* Estados lineales: IDLE → CART → SHIPPING → PAYMENT → WAITING_WEBHOOKS
* Estados lineales: IDLE → CART → SHIPPING → IDLE (post-confirmación).
* El bot toma pedidos y datos de entrega; el cobro se gestiona offline.
* Regla universal: add_to_cart SIEMPRE vuelve a CART desde cualquier estado.
*/
@@ -9,8 +10,6 @@ export const ConversationState = Object.freeze({
IDLE: "IDLE",
CART: "CART",
SHIPPING: "SHIPPING",
PAYMENT: "PAYMENT",
WAITING_WEBHOOKS: "WAITING_WEBHOOKS",
AWAITING_HUMAN: "AWAITING_HUMAN", // Esperando respuesta de un humano
});
@@ -19,23 +18,17 @@ export const ALL_STATES = Object.freeze(Object.values(ConversationState));
// Intents válidos por estado
export const INTENTS_BY_STATE = Object.freeze({
[ConversationState.IDLE]: [
"greeting", "add_to_cart", "browse", "price_query", "recommend", "other"
"greeting", "add_to_cart", "browse", "price_query", "recommend", "other",
],
[ConversationState.CART]: [
"add_to_cart", "remove_from_cart", "browse", "price_query",
"recommend", "view_cart", "confirm_order", "other"
"recommend", "view_cart", "confirm_order", "other",
],
[ConversationState.SHIPPING]: [
"provide_address", "select_shipping", "add_to_cart", "view_cart", "other"
],
[ConversationState.PAYMENT]: [
"select_payment", "add_to_cart", "view_cart", "other"
],
[ConversationState.WAITING_WEBHOOKS]: [
"add_to_cart", "view_cart", "other"
"provide_address", "select_shipping", "add_to_cart", "view_cart", "other",
],
[ConversationState.AWAITING_HUMAN]: [
"other" // En este estado, el bot no procesa - espera respuesta humana
"other",
],
});
@@ -44,21 +37,18 @@ export const INTENTS_BY_STATE = Object.freeze({
*/
export function shouldReturnToCart(state, nlu, text = "") {
if (state === ConversationState.CART || state === ConversationState.IDLE) {
return false; // Ya está en CART o IDLE (IDLE irá a CART naturalmente)
return false;
}
// En SHIPPING/PAYMENT, números solos son selecciones de opción, no productos
const isCheckoutState = state === ConversationState.SHIPPING || state === ConversationState.PAYMENT;
// En SHIPPING, números solos son selecciones de opción, no productos
const isCheckoutState = state === ConversationState.SHIPPING;
const isJustNumber = /^\s*\d+([.,]\d+)?\s*$/.test(text || "");
if (isCheckoutState && isJustNumber) {
return false; // No redirigir, es una selección de opción
return false;
}
const intent = nlu?.intent;
// Si el intent es add_to_cart, browse, price_query, o recommend → volver a CART
// Pero solo si hay una query de producto real (no vacía)
if (["add_to_cart", "browse", "price_query", "recommend"].includes(intent)) {
// Verificar que hay un producto real mencionado
const hasRealProduct = nlu?.entities?.product_query &&
String(nlu.entities.product_query).trim().length > 2;
const hasRealItems = Array.isArray(nlu?.entities?.items) &&
@@ -66,11 +56,9 @@ export function shouldReturnToCart(state, nlu, text = "") {
if (hasRealProduct || hasRealItems) {
return true;
}
// Si no hay producto real, no redirigir (probablemente es una selección numérica mal interpretada)
return false;
}
// Si hay menciones de producto en entities (con contenido real)
if (nlu?.entities?.product_query && String(nlu.entities.product_query).trim().length > 2) return true;
if (Array.isArray(nlu?.entities?.items) && nlu.entities.items.some(i => i?.product_query?.trim().length > 2)) return true;
@@ -100,43 +88,27 @@ export function hasShippingInfo(order) {
return false;
}
export function hasPaymentInfo(order) {
return order?.payment_type === "cash" || order?.payment_type === "link";
}
export function isPaid(order) {
return order?.is_paid === true;
}
/**
* Deriva el siguiente estado basado en el contexto y signals.
*
* signals: {
* confirm_order: boolean, // Usuario quiere cerrar pedido
* shipping_selected: boolean, // Usuario seleccionó delivery/pickup
* payment_selected: boolean, // Usuario seleccionó método de pago
* shipping_completed: boolean, // Shipping info quedó completa (gatilla create_order + IDLE)
* return_to_cart: boolean, // Forzar volver a CART
* }
*/
export function deriveNextState(prevState, order = {}, signals = {}) {
// Regla 0: Si se fuerza volver a CART
if (signals.return_to_cart) {
return ConversationState.CART;
}
// Regla 1: Si está pagado, completado (volver a IDLE para nueva conversación)
if (isPaid(order)) {
// Si la orden ya fue creada en Woo, volvemos a IDLE para nueva conversación.
if (order?.woo_order_id) {
return ConversationState.IDLE;
}
// Regla 2: Si tiene woo_order_id y espera pago
if (order?.woo_order_id && !isPaid(order)) {
return ConversationState.WAITING_WEBHOOKS;
}
// Desde IDLE
if (prevState === ConversationState.IDLE) {
// Si hay cart o pending items, ir a CART
if (hasCartItems(order) || hasPendingItems(order)) {
return ConversationState.CART;
}
@@ -145,11 +117,9 @@ export function deriveNextState(prevState, order = {}, signals = {}) {
// Desde CART
if (prevState === ConversationState.CART) {
// Si hay pending items sin resolver, quedarse en CART
if (hasPendingItems(order)) {
return ConversationState.CART;
}
// Si usuario confirma orden y hay items en cart, ir a SHIPPING
if (signals.confirm_order && hasCartItems(order)) {
return ConversationState.SHIPPING;
}
@@ -158,69 +128,38 @@ export function deriveNextState(prevState, order = {}, signals = {}) {
// Desde SHIPPING
if (prevState === ConversationState.SHIPPING) {
// Si ya tiene shipping info completa, ir a PAYMENT
if (hasShippingInfo(order)) {
return ConversationState.PAYMENT;
// Una vez completado el shipping, la orden se crea y vuelve a IDLE.
if (signals.shipping_completed || hasShippingInfo(order)) {
return ConversationState.IDLE;
}
return ConversationState.SHIPPING;
}
// Desde PAYMENT
if (prevState === ConversationState.PAYMENT) {
// Si ya tiene payment info, ir a WAITING_WEBHOOKS
if (signals.payment_selected || hasPaymentInfo(order)) {
return ConversationState.WAITING_WEBHOOKS;
}
return ConversationState.PAYMENT;
}
// Desde WAITING_WEBHOOKS
if (prevState === ConversationState.WAITING_WEBHOOKS) {
if (isPaid(order)) {
return ConversationState.IDLE;
}
return ConversationState.WAITING_WEBHOOKS;
}
// Default
return prevState || ConversationState.IDLE;
}
// Transiciones permitidas (para validación)
const ALLOWED = Object.freeze({
[ConversationState.IDLE]: [
ConversationState.IDLE,
ConversationState.CART,
ConversationState.AWAITING_HUMAN, // Puede ir a esperar humano
ConversationState.AWAITING_HUMAN,
],
[ConversationState.CART]: [
ConversationState.CART,
ConversationState.SHIPPING,
ConversationState.IDLE, // Si vacía el carrito
ConversationState.AWAITING_HUMAN, // Producto no encontrado
ConversationState.IDLE,
ConversationState.AWAITING_HUMAN,
],
[ConversationState.SHIPPING]: [
ConversationState.SHIPPING,
ConversationState.PAYMENT,
ConversationState.CART, // Volver a agregar productos
ConversationState.AWAITING_HUMAN,
],
[ConversationState.PAYMENT]: [
ConversationState.PAYMENT,
ConversationState.WAITING_WEBHOOKS,
ConversationState.CART, // Volver a agregar productos
ConversationState.AWAITING_HUMAN,
],
[ConversationState.WAITING_WEBHOOKS]: [
ConversationState.WAITING_WEBHOOKS,
ConversationState.IDLE, // Pago completado
ConversationState.CART, // Agregar más productos
ConversationState.IDLE,
ConversationState.CART,
ConversationState.AWAITING_HUMAN,
],
[ConversationState.AWAITING_HUMAN]: [
ConversationState.AWAITING_HUMAN, // Sigue esperando
ConversationState.CART, // Humano respondió, volver a procesar
ConversationState.IDLE, // Humano canceló
ConversationState.AWAITING_HUMAN,
ConversationState.CART,
ConversationState.IDLE,
],
});
@@ -237,7 +176,5 @@ export function safeNextState(prevState, order, signals) {
const desired = deriveNextState(prevState, order, signals);
const v = validateTransition(prevState, desired);
if (v.ok) return { next_state: desired, validation: v };
// Si la transición no es válida, forzar a un estado seguro
// En el nuevo modelo, siempre podemos ir a CART
return { next_state: ConversationState.CART, validation: { ...v, forced_to_cart: true } };
}

View File

@@ -1,5 +1,5 @@
/**
* Tests para fsm.js
* Tests para fsm.js (sin payment / waiting — el bot no maneja pagos).
*/
import { describe, it, expect } from 'vitest';
import {
@@ -11,555 +11,210 @@ import {
hasPendingItems,
hasReadyPendingItems,
hasShippingInfo,
hasPaymentInfo,
isPaid,
deriveNextState,
validateTransition,
safeNextState,
} from './fsm.js';
// ─────────────────────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────────────────────
describe('ConversationState', () => {
it('tiene todos los estados definidos', () => {
it('tiene los estados del flujo (sin payment/waiting)', () => {
expect(ConversationState.IDLE).toBe('IDLE');
expect(ConversationState.CART).toBe('CART');
expect(ConversationState.SHIPPING).toBe('SHIPPING');
expect(ConversationState.PAYMENT).toBe('PAYMENT');
expect(ConversationState.WAITING_WEBHOOKS).toBe('WAITING_WEBHOOKS');
expect(ConversationState.AWAITING_HUMAN).toBe('AWAITING_HUMAN');
expect(ConversationState.PAYMENT).toBeUndefined();
expect(ConversationState.WAITING_WEBHOOKS).toBeUndefined();
});
it('ALL_STATES contiene todos', () => {
expect(ALL_STATES).toContain('IDLE');
expect(ALL_STATES).toContain('CART');
expect(ALL_STATES).toContain('SHIPPING');
expect(ALL_STATES).toContain('PAYMENT');
expect(ALL_STATES).toContain('WAITING_WEBHOOKS');
expect(ALL_STATES).toContain('AWAITING_HUMAN');
expect(ALL_STATES).toHaveLength(6);
it('ALL_STATES contiene 4 estados', () => {
expect(ALL_STATES).toEqual(expect.arrayContaining(['IDLE', 'CART', 'SHIPPING', 'AWAITING_HUMAN']));
expect(ALL_STATES).toHaveLength(4);
});
it('INTENTS_BY_STATE define intents para cada estado', () => {
it('INTENTS_BY_STATE define intents por estado', () => {
expect(INTENTS_BY_STATE[ConversationState.IDLE]).toContain('greeting');
expect(INTENTS_BY_STATE[ConversationState.CART]).toContain('add_to_cart');
expect(INTENTS_BY_STATE[ConversationState.SHIPPING]).toContain('provide_address');
expect(INTENTS_BY_STATE[ConversationState.PAYMENT]).toContain('select_payment');
});
});
// ─────────────────────────────────────────────────────────────
// hasCartItems
// ─────────────────────────────────────────────────────────────
describe('hasCartItems', () => {
it('retorna true si cart tiene items', () => {
const order = { cart: [{ woo_id: 1, qty: 1 }] };
expect(hasCartItems(order)).toBe(true);
expect(hasCartItems({ cart: [{ woo_id: 1, qty: 1 }] })).toBe(true);
});
it('retorna false si cart está vacío', () => {
const order = { cart: [] };
expect(hasCartItems(order)).toBe(false);
expect(hasCartItems({ cart: [] })).toBe(false);
});
it('retorna false si cart es undefined', () => {
const order = {};
expect(hasCartItems(order)).toBe(false);
expect(hasCartItems({})).toBe(false);
});
it('retorna false si order es null', () => {
it('retorna false si order es null/undefined', () => {
expect(hasCartItems(null)).toBe(false);
});
it('retorna false si order es undefined', () => {
expect(hasCartItems(undefined)).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────
// hasPendingItems
// ─────────────────────────────────────────────────────────────
describe('hasPendingItems', () => {
it('retorna true si hay NEEDS_TYPE', () => {
const order = { pending: [{ status: 'NEEDS_TYPE' }] };
expect(hasPendingItems(order)).toBe(true);
expect(hasPendingItems({ pending: [{ status: 'NEEDS_TYPE' }] })).toBe(true);
});
it('retorna true si hay NEEDS_QUANTITY', () => {
const order = { pending: [{ status: 'NEEDS_QUANTITY' }] };
expect(hasPendingItems(order)).toBe(true);
expect(hasPendingItems({ pending: [{ status: 'NEEDS_QUANTITY' }] })).toBe(true);
});
it('retorna false si solo hay READY', () => {
const order = { pending: [{ status: 'READY' }] };
expect(hasPendingItems(order)).toBe(false);
expect(hasPendingItems({ pending: [{ status: 'READY' }] })).toBe(false);
});
it('retorna false si pending está vacío', () => {
const order = { pending: [] };
expect(hasPendingItems(order)).toBe(false);
});
it('retorna false si order es null', () => {
expect(hasPendingItems(null)).toBe(false);
});
it('detecta entre múltiples items', () => {
const order = {
pending: [
{ status: 'READY' },
{ status: 'NEEDS_TYPE' },
]
};
expect(hasPendingItems(order)).toBe(true);
expect(hasPendingItems({ pending: [{ status: 'READY' }, { status: 'NEEDS_TYPE' }] })).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────
// hasReadyPendingItems
// ─────────────────────────────────────────────────────────────
describe('hasReadyPendingItems', () => {
it('retorna true si hay READY', () => {
const order = { pending: [{ status: 'READY' }] };
expect(hasReadyPendingItems(order)).toBe(true);
expect(hasReadyPendingItems({ pending: [{ status: 'READY' }] })).toBe(true);
});
it('retorna false si no hay READY', () => {
const order = { pending: [{ status: 'NEEDS_TYPE' }] };
expect(hasReadyPendingItems(order)).toBe(false);
});
it('retorna false si pending vacío', () => {
const order = { pending: [] };
expect(hasReadyPendingItems(order)).toBe(false);
});
it('retorna false si order es null', () => {
expect(hasReadyPendingItems(null)).toBe(false);
expect(hasReadyPendingItems({ pending: [{ status: 'NEEDS_TYPE' }] })).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────
// hasShippingInfo
// ─────────────────────────────────────────────────────────────
describe('hasShippingInfo', () => {
it('retorna true para pickup (no necesita dirección)', () => {
const order = { is_delivery: false };
expect(hasShippingInfo(order)).toBe(true);
expect(hasShippingInfo({ is_delivery: false })).toBe(true);
});
it('retorna true para delivery con dirección', () => {
const order = { is_delivery: true, shipping_address: 'Calle Falsa 123' };
expect(hasShippingInfo(order)).toBe(true);
expect(hasShippingInfo({ is_delivery: true, shipping_address: 'Calle Falsa 123' })).toBe(true);
});
it('retorna false para delivery sin dirección', () => {
const order = { is_delivery: true, shipping_address: null };
expect(hasShippingInfo(order)).toBe(false);
expect(hasShippingInfo({ is_delivery: true, shipping_address: null })).toBe(false);
});
it('retorna false si is_delivery es null', () => {
const order = { is_delivery: null };
expect(hasShippingInfo(order)).toBe(false);
});
it('retorna false para order vacío', () => {
expect(hasShippingInfo({})).toBe(false);
expect(hasShippingInfo({ is_delivery: null })).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────
// hasPaymentInfo
// ─────────────────────────────────────────────────────────────
describe('hasPaymentInfo', () => {
it('retorna true para cash', () => {
const order = { payment_type: 'cash' };
expect(hasPaymentInfo(order)).toBe(true);
});
it('retorna true para link', () => {
const order = { payment_type: 'link' };
expect(hasPaymentInfo(order)).toBe(true);
});
it('retorna false para null', () => {
const order = { payment_type: null };
expect(hasPaymentInfo(order)).toBe(false);
});
it('retorna false para undefined', () => {
const order = {};
expect(hasPaymentInfo(order)).toBe(false);
});
it('retorna false para otros valores', () => {
const order = { payment_type: 'bitcoin' };
expect(hasPaymentInfo(order)).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────
// isPaid
// ─────────────────────────────────────────────────────────────
describe('isPaid', () => {
it('retorna true si is_paid es true', () => {
const order = { is_paid: true };
expect(isPaid(order)).toBe(true);
});
it('retorna false si is_paid es false', () => {
const order = { is_paid: false };
expect(isPaid(order)).toBe(false);
});
it('retorna false si is_paid es undefined', () => {
const order = {};
expect(isPaid(order)).toBe(false);
});
it('retorna false si order es null', () => {
expect(isPaid(null)).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────
// shouldReturnToCart
// ─────────────────────────────────────────────────────────────
describe('shouldReturnToCart', () => {
describe('no redirige si ya está en CART o IDLE', () => {
it('retorna false en CART', () => {
const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } };
expect(shouldReturnToCart(ConversationState.CART, nlu)).toBe(false);
});
it('retorna false en IDLE', () => {
const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } };
expect(shouldReturnToCart(ConversationState.IDLE, nlu)).toBe(false);
});
it('no redirige si ya está en CART o IDLE', () => {
const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } };
expect(shouldReturnToCart(ConversationState.CART, nlu)).toBe(false);
expect(shouldReturnToCart(ConversationState.IDLE, nlu)).toBe(false);
});
describe('redirige desde otros estados', () => {
it('redirige add_to_cart desde SHIPPING', () => {
const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
});
it('redirige add_to_cart desde PAYMENT', () => {
const nlu = { intent: 'add_to_cart', entities: { product_query: 'vacío' } };
expect(shouldReturnToCart(ConversationState.PAYMENT, nlu)).toBe(true);
});
it('redirige browse desde SHIPPING', () => {
const nlu = { intent: 'browse', entities: { product_query: 'carnes' } };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
});
it('redirige add_to_cart desde SHIPPING con producto real', () => {
const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
});
describe('no redirige números solos en checkout', () => {
it('no redirige "1" en SHIPPING', () => {
const nlu = { intent: 'add_to_cart', entities: {} };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, '1')).toBe(false);
});
it('no redirige "2" en PAYMENT', () => {
const nlu = { intent: 'other', entities: {} };
expect(shouldReturnToCart(ConversationState.PAYMENT, nlu, '2')).toBe(false);
});
it('no redirige "1.5" en SHIPPING', () => {
const nlu = { intent: 'add_to_cart', entities: {} };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, '1.5')).toBe(false);
});
it('redirige browse desde SHIPPING', () => {
const nlu = { intent: 'browse', entities: { product_query: 'carnes' } };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
});
describe('requiere producto real', () => {
it('no redirige sin product_query', () => {
const nlu = { intent: 'add_to_cart', entities: {} };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, 'algo')).toBe(false);
});
it('no redirige números solos en SHIPPING (selección de opción)', () => {
const nlu = { intent: 'add_to_cart', entities: {} };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, '1')).toBe(false);
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, '1.5')).toBe(false);
});
it('no redirige con product_query muy corto', () => {
const nlu = { intent: 'add_to_cart', entities: { product_query: 'ab' } };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(false);
});
it('no redirige sin producto real', () => {
const nlu = { intent: 'add_to_cart', entities: {} };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, 'algo')).toBe(false);
expect(shouldReturnToCart(ConversationState.SHIPPING, { intent: 'add_to_cart', entities: { product_query: 'ab' } })).toBe(false);
});
it('redirige con items array', () => {
const nlu = {
intent: 'add_to_cart',
entities: { items: [{ product_query: 'provoleta' }] }
};
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
});
it('redirige con items array que tenga producto real', () => {
const nlu = { intent: 'add_to_cart', entities: { items: [{ product_query: 'provoleta' }] } };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────
// deriveNextState
// ─────────────────────────────────────────────────────────────
describe('deriveNextState', () => {
describe('return_to_cart signal', () => {
it('fuerza CART si return_to_cart', () => {
const result = deriveNextState(
ConversationState.PAYMENT,
{},
{ return_to_cart: true }
);
expect(result).toBe(ConversationState.CART);
});
it('return_to_cart fuerza CART', () => {
expect(deriveNextState(ConversationState.SHIPPING, {}, { return_to_cart: true })).toBe(ConversationState.CART);
});
describe('pagado', () => {
it('va a IDLE si está pagado', () => {
const order = { is_paid: true };
const result = deriveNextState(ConversationState.WAITING_WEBHOOKS, order, {});
expect(result).toBe(ConversationState.IDLE);
});
it('IDLE va a CART si hay cart o pending', () => {
expect(deriveNextState(ConversationState.IDLE, { cart: [{ woo_id: 1 }], pending: [] }, {})).toBe(ConversationState.CART);
expect(deriveNextState(ConversationState.IDLE, { cart: [], pending: [{ status: 'NEEDS_TYPE' }] }, {})).toBe(ConversationState.CART);
});
describe('esperando pago', () => {
it('va a WAITING_WEBHOOKS si tiene woo_order_id', () => {
const order = { woo_order_id: 123, is_paid: false };
const result = deriveNextState(ConversationState.PAYMENT, order, {});
expect(result).toBe(ConversationState.WAITING_WEBHOOKS);
});
it('IDLE queda en IDLE si vacío', () => {
expect(deriveNextState(ConversationState.IDLE, { cart: [], pending: [] }, {})).toBe(ConversationState.IDLE);
});
describe('IDLE -> CART', () => {
it('va a CART si hay cart items', () => {
const order = { cart: [{ woo_id: 1 }], pending: [] };
const result = deriveNextState(ConversationState.IDLE, order, {});
expect(result).toBe(ConversationState.CART);
});
it('va a CART si hay pending items', () => {
const order = { cart: [], pending: [{ status: 'NEEDS_TYPE' }] };
const result = deriveNextState(ConversationState.IDLE, order, {});
expect(result).toBe(ConversationState.CART);
});
it('queda en IDLE si vacío', () => {
const order = { cart: [], pending: [] };
const result = deriveNextState(ConversationState.IDLE, order, {});
expect(result).toBe(ConversationState.IDLE);
});
it('CART queda en CART con pending', () => {
const order = { cart: [{ woo_id: 1 }], pending: [{ status: 'NEEDS_TYPE' }] };
expect(deriveNextState(ConversationState.CART, order, { confirm_order: true })).toBe(ConversationState.CART);
});
describe('CART -> SHIPPING', () => {
it('queda en CART si hay pending items', () => {
const order = { cart: [{ woo_id: 1 }], pending: [{ status: 'NEEDS_TYPE' }] };
const result = deriveNextState(ConversationState.CART, order, { confirm_order: true });
expect(result).toBe(ConversationState.CART);
});
it('va a SHIPPING con confirm_order y cart items', () => {
const order = { cart: [{ woo_id: 1 }], pending: [] };
const result = deriveNextState(ConversationState.CART, order, { confirm_order: true });
expect(result).toBe(ConversationState.SHIPPING);
});
it('queda en CART sin confirm_order', () => {
const order = { cart: [{ woo_id: 1 }], pending: [] };
const result = deriveNextState(ConversationState.CART, order, {});
expect(result).toBe(ConversationState.CART);
});
it('CART SHIPPING con confirm + items', () => {
const order = { cart: [{ woo_id: 1 }], pending: [] };
expect(deriveNextState(ConversationState.CART, order, { confirm_order: true })).toBe(ConversationState.SHIPPING);
});
describe('SHIPPING -> PAYMENT', () => {
it('va a PAYMENT con shipping info (pickup)', () => {
const order = { is_delivery: false };
const result = deriveNextState(ConversationState.SHIPPING, order, {});
expect(result).toBe(ConversationState.PAYMENT);
});
it('va a PAYMENT con shipping info (delivery + address)', () => {
const order = { is_delivery: true, shipping_address: 'Calle 123' };
const result = deriveNextState(ConversationState.SHIPPING, order, {});
expect(result).toBe(ConversationState.PAYMENT);
});
it('queda en SHIPPING sin info completa', () => {
const order = { is_delivery: true, shipping_address: null };
const result = deriveNextState(ConversationState.SHIPPING, order, {});
expect(result).toBe(ConversationState.SHIPPING);
});
it('SHIPPING → IDLE cuando shipping queda completo (orden creada offline)', () => {
expect(deriveNextState(ConversationState.SHIPPING, { is_delivery: false }, {})).toBe(ConversationState.IDLE);
expect(deriveNextState(ConversationState.SHIPPING, { is_delivery: true, shipping_address: 'Calle 1' }, {})).toBe(ConversationState.IDLE);
});
describe('PAYMENT -> WAITING_WEBHOOKS', () => {
it('va a WAITING con payment_selected', () => {
const order = {};
const result = deriveNextState(
ConversationState.PAYMENT,
order,
{ payment_selected: true }
);
expect(result).toBe(ConversationState.WAITING_WEBHOOKS);
});
it('va a WAITING si ya tiene payment_type', () => {
const order = { payment_type: 'cash' };
const result = deriveNextState(ConversationState.PAYMENT, order, {});
expect(result).toBe(ConversationState.WAITING_WEBHOOKS);
});
it('SHIPPING queda en SHIPPING sin info completa', () => {
expect(deriveNextState(ConversationState.SHIPPING, { is_delivery: true }, {})).toBe(ConversationState.SHIPPING);
});
describe('WAITING_WEBHOOKS', () => {
it('va a IDLE si está pagado', () => {
const order = { is_paid: true };
const result = deriveNextState(ConversationState.WAITING_WEBHOOKS, order, {});
expect(result).toBe(ConversationState.IDLE);
});
it('queda en WAITING si no está pagado', () => {
const order = { is_paid: false };
const result = deriveNextState(ConversationState.WAITING_WEBHOOKS, order, {});
expect(result).toBe(ConversationState.WAITING_WEBHOOKS);
});
it('woo_order_id existente vuelve a IDLE (orden ya creada)', () => {
expect(deriveNextState(ConversationState.SHIPPING, { woo_order_id: 999 }, {})).toBe(ConversationState.IDLE);
});
describe('default', () => {
it('retorna IDLE si no hay estado previo', () => {
const result = deriveNextState(null, {}, {});
expect(result).toBe(ConversationState.IDLE);
});
it('default sin estado previo retorna IDLE', () => {
expect(deriveNextState(null, {}, {})).toBe(ConversationState.IDLE);
});
});
// ─────────────────────────────────────────────────────────────
// validateTransition
// ─────────────────────────────────────────────────────────────
describe('validateTransition', () => {
describe('transiciones válidas', () => {
it('IDLE -> IDLE es válido', () => {
const result = validateTransition(ConversationState.IDLE, ConversationState.IDLE);
expect(result.ok).toBe(true);
});
it('IDLE -> CART es válido', () => {
const result = validateTransition(ConversationState.IDLE, ConversationState.CART);
expect(result.ok).toBe(true);
});
it('CART -> SHIPPING es válido', () => {
const result = validateTransition(ConversationState.CART, ConversationState.SHIPPING);
expect(result.ok).toBe(true);
});
it('SHIPPING -> PAYMENT es válido', () => {
const result = validateTransition(ConversationState.SHIPPING, ConversationState.PAYMENT);
expect(result.ok).toBe(true);
});
it('PAYMENT -> WAITING_WEBHOOKS es válido', () => {
const result = validateTransition(ConversationState.PAYMENT, ConversationState.WAITING_WEBHOOKS);
expect(result.ok).toBe(true);
});
it('SHIPPING -> CART (volver) es válido', () => {
const result = validateTransition(ConversationState.SHIPPING, ConversationState.CART);
expect(result.ok).toBe(true);
});
it('IDLE → CART es válida', () => {
expect(validateTransition(ConversationState.IDLE, ConversationState.CART).ok).toBe(true);
});
describe('transiciones inválidas', () => {
it('IDLE -> PAYMENT es inválido', () => {
const result = validateTransition(ConversationState.IDLE, ConversationState.PAYMENT);
expect(result.ok).toBe(false);
expect(result.reason).toBe('invalid_transition');
});
it('CART -> WAITING_WEBHOOKS es inválido', () => {
const result = validateTransition(ConversationState.CART, ConversationState.WAITING_WEBHOOKS);
expect(result.ok).toBe(false);
});
it('CART → SHIPPING es válida', () => {
expect(validateTransition(ConversationState.CART, ConversationState.SHIPPING).ok).toBe(true);
});
describe('estados desconocidos', () => {
it('estado previo desconocido', () => {
const result = validateTransition('UNKNOWN', ConversationState.CART);
expect(result.ok).toBe(false);
expect(result.reason).toBe('unknown_prev_state');
});
it('estado siguiente desconocido', () => {
const result = validateTransition(ConversationState.IDLE, 'UNKNOWN');
expect(result.ok).toBe(false);
expect(result.reason).toBe('unknown_next_state');
});
it('SHIPPING → IDLE es válida (cierre de orden)', () => {
expect(validateTransition(ConversationState.SHIPPING, ConversationState.IDLE).ok).toBe(true);
});
describe('maneja null/undefined', () => {
it('prevState null se trata como IDLE', () => {
const result = validateTransition(null, ConversationState.CART);
expect(result.ok).toBe(true);
});
it('nextState null se trata como IDLE', () => {
const result = validateTransition(ConversationState.IDLE, null);
expect(result.ok).toBe(true);
});
it('SHIPPING → CART (volver a agregar) es válida', () => {
expect(validateTransition(ConversationState.SHIPPING, ConversationState.CART).ok).toBe(true);
});
it('IDLE → SHIPPING es inválida (debe pasar por CART)', () => {
const r = validateTransition(ConversationState.IDLE, ConversationState.SHIPPING);
expect(r.ok).toBe(false);
expect(r.reason).toBe('invalid_transition');
});
it('estado previo desconocido', () => {
const r = validateTransition('UNKNOWN', ConversationState.CART);
expect(r.ok).toBe(false);
expect(r.reason).toBe('unknown_prev_state');
});
it('null se trata como IDLE', () => {
expect(validateTransition(null, ConversationState.CART).ok).toBe(true);
expect(validateTransition(ConversationState.IDLE, null).ok).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────
// safeNextState
// ─────────────────────────────────────────────────────────────
describe('safeNextState', () => {
it('retorna estado derivado si transición válida', () => {
it('retorna estado derivado si la transición es válida', () => {
const order = { cart: [{ woo_id: 1 }], pending: [] };
const result = safeNextState(ConversationState.CART, order, { confirm_order: true });
expect(result.next_state).toBe(ConversationState.SHIPPING);
expect(result.validation.ok).toBe(true);
const r = safeNextState(ConversationState.CART, order, { confirm_order: true });
expect(r.next_state).toBe(ConversationState.SHIPPING);
expect(r.validation.ok).toBe(true);
});
it('fuerza CART si transición inválida', () => {
// Forzar una situación donde deriveNextState retornaría un estado inválido
// Esto es difícil de provocar porque deriveNextState ya es bastante seguro
// Pero podemos verificar que la lógica de fallback existe
const order = {};
const result = safeNextState(ConversationState.IDLE, order, {});
it('flow IDLE → CART → SHIPPING → IDLE', () => {
let r = safeNextState(ConversationState.IDLE, { cart: [{ woo_id: 1 }], pending: [] }, {});
expect(r.next_state).toBe(ConversationState.CART);
// Debería quedarse en IDLE (transición válida)
expect(result.next_state).toBe(ConversationState.IDLE);
expect(result.validation.ok).toBe(true);
});
r = safeNextState(ConversationState.CART, { cart: [{ woo_id: 1 }], pending: [] }, { confirm_order: true });
expect(r.next_state).toBe(ConversationState.SHIPPING);
it('incluye validation en resultado', () => {
const order = { is_delivery: false };
const result = safeNextState(ConversationState.SHIPPING, order, {});
expect(result).toHaveProperty('next_state');
expect(result).toHaveProperty('validation');
expect(result.validation).toHaveProperty('ok');
});
it('maneja transition IDLE -> CART -> SHIPPING flow', () => {
// Paso 1: IDLE con cart items -> CART
let result = safeNextState(ConversationState.IDLE, { cart: [{ woo_id: 1 }], pending: [] }, {});
expect(result.next_state).toBe(ConversationState.CART);
// Paso 2: CART con confirm -> SHIPPING
result = safeNextState(ConversationState.CART, { cart: [{ woo_id: 1 }], pending: [] }, { confirm_order: true });
expect(result.next_state).toBe(ConversationState.SHIPPING);
// Paso 3: SHIPPING con pickup -> PAYMENT
result = safeNextState(ConversationState.SHIPPING, { is_delivery: false }, {});
expect(result.next_state).toBe(ConversationState.PAYMENT);
// Paso 4: PAYMENT con payment_selected -> WAITING
result = safeNextState(ConversationState.PAYMENT, {}, { payment_selected: true });
expect(result.next_state).toBe(ConversationState.WAITING_WEBHOOKS);
r = safeNextState(ConversationState.SHIPPING, { is_delivery: false }, { shipping_completed: true });
expect(r.next_state).toBe(ConversationState.IDLE);
});
});

View File

@@ -235,18 +235,10 @@ export const setAddress = assign({
},
});
export const setPayment = assign({
order: ({ context, event }) => {
const method = event.method;
if (method !== "cash" && method !== "link") return context.order;
return { ...context.order, payment_type: method };
},
});
export const enqueueWooCreateOrder = assign({
pending_actions: ({ context }) => [
...(context.pending_actions || []),
{ type: "create_order", payload: { payment: context.order?.payment_type } },
{ type: "create_order", payload: { source: "wa_bot" } },
],
});
@@ -324,10 +316,7 @@ export const replyShippingAskAddress = makeReplyAction("shipping.ask_address");
export const replyShippingAddressRecorded = makeReplyAction("shipping.address_recorded", ({ context }) => ({
address: context.order?.shipping_address || "",
}));
export const replyShippingPickupToPayment = makeReplyAction("shipping.pickup_to_payment");
export const replyPaymentAskMethod = makeReplyAction("payment.ask_method");
export const replyPaymentConfirmed = makeReplyAction("payment.confirmed");
export const replyWaitingInProgress = makeReplyAction("waiting.in_progress");
export const replyOrderConfirmed = makeReplyAction("order.confirmed");
// View cart: necesita armar reply con cartDisplay + ask_more
export const replyViewCart = assign({
@@ -372,7 +361,6 @@ export const actions = {
capturePeopleCount,
setShipping,
setAddress,
setPayment,
enqueueWooCreateOrder,
enqueueAddToCart,
enqueueRemoveFromCart,
@@ -390,10 +378,7 @@ export const actions = {
replyShippingAskMethod,
replyShippingAskAddress,
replyShippingAddressRecorded,
replyShippingPickupToPayment,
replyPaymentAskMethod,
replyPaymentConfirmed,
replyWaitingInProgress,
replyOrderConfirmed,
replyViewCart,
replyOptions,
replyAskQuantity,

View File

@@ -8,8 +8,6 @@ import {
hasPendingItems as hasPending,
hasReadyPendingItems as hasReadyPending,
hasShippingInfo as hasShipping,
hasPaymentInfo as hasPayment,
isPaid,
} from "../fsm.js";
import {
parseIndexSelection,
@@ -24,8 +22,6 @@ export const guards = {
hasPending: ({ context }) => hasPending(context.order),
hasReadyPending: ({ context }) => hasReadyPending(context.order),
hasShipping: ({ context }) => hasShipping(context.order),
hasPayment: ({ context }) => hasPayment(context.order),
isPaid: ({ context }) => isPaid(context.order),
noCart: ({ context }) => !hasCart(context.order),
noShipping: ({ context }) => !hasShipping(context.order),

View File

@@ -24,8 +24,6 @@ export const ConversationStates = Object.freeze({
IDLE: "idle",
CART: "cart",
SHIPPING: "shipping",
PAYMENT: "payment",
WAITING: "waiting",
AWAITING_HUMAN: "awaiting_human",
});
@@ -289,8 +287,8 @@ export const machine = setup({
SELECT_SHIPPING: [
{
guard: ({ event }) => event.method === "pickup",
actions: ["setShipping", "replyShippingPickupToPayment"],
target: ConversationStates.PAYMENT,
actions: ["setShipping", "enqueueWooCreateOrder", "replyOrderConfirmed", "resetFailedSearch"],
target: ConversationStates.IDLE,
},
{
guard: ({ event }) => event.method === "delivery",
@@ -303,8 +301,8 @@ export const machine = setup({
PROVIDE_ADDRESS: [
{
guard: ({ context }) => context.order?.is_delivery === true,
actions: ["setAddress", "replyShippingAddressRecorded"],
target: ConversationStates.PAYMENT,
actions: ["setAddress", "enqueueWooCreateOrder", "replyOrderConfirmed", "resetFailedSearch"],
target: ConversationStates.IDLE,
},
{
actions: ["replyShippingAskMethod"],
@@ -315,30 +313,6 @@ export const machine = setup({
},
},
// ─────────────────────────────────────────────────────────
[ConversationStates.PAYMENT]: {
entry: ({ context }) => { context.fsmState = "PAYMENT"; },
on: {
SELECT_PAYMENT: {
guard: ({ event }) => event.method === "cash" || event.method === "link",
actions: ["setPayment", "enqueueWooCreateOrder", "replyPaymentConfirmed"],
target: ConversationStates.WAITING,
},
VIEW_CART: { actions: "replyPaymentAskMethod" },
OTHER: { actions: "replyPaymentAskMethod" },
},
},
// ─────────────────────────────────────────────────────────
[ConversationStates.WAITING]: {
entry: ({ context }) => { context.fsmState = "WAITING_WEBHOOKS"; },
on: {
WEBHOOK_PAID: { target: ConversationStates.IDLE },
VIEW_CART: { actions: "replyWaitingInProgress" },
OTHER: { actions: "replyWaitingInProgress" },
},
},
// ─────────────────────────────────────────────────────────
[ConversationStates.AWAITING_HUMAN]: {
// Estado terminal hasta que un humano resuelva. No emite reply propio.
@@ -353,15 +327,11 @@ export function xstateToLegacyState(value) {
if (typeof value === "string") {
if (value === "idle") return "IDLE";
if (value === "shipping") return "SHIPPING";
if (value === "payment") return "PAYMENT";
if (value === "waiting") return "WAITING_WEBHOOKS";
if (value === "awaiting_human") return "AWAITING_HUMAN";
}
if (value && typeof value === "object") {
if (value.cart) return "CART";
if (value.shipping) return "SHIPPING";
if (value.payment) return "PAYMENT";
if (value.waiting) return "WAITING_WEBHOOKS";
}
return "IDLE";
}

View File

@@ -168,18 +168,20 @@ describe("machine — checkout flow", () => {
a.stop();
});
it("SELECT_SHIPPING pickup goes to payment", async () => {
it("SELECT_SHIPPING pickup cierra la orden y vuelve a IDLE", async () => {
const a = makeActor();
a.start();
await buildCartWithItem(a);
a.send({ type: "CONFIRM_ORDER" });
a.send({ type: "SELECT_SHIPPING", method: "pickup" });
expect(a.getSnapshot().value).toBe("payment");
expect(a.getSnapshot().context.order.is_delivery).toBe(false);
const snap = a.getSnapshot();
expect(snap.value).toBe("idle");
expect(snap.context.order.is_delivery).toBe(false);
expect(snap.context.pending_actions.some((x) => x.type === "create_order")).toBe(true);
a.stop();
});
it("SELECT_SHIPPING delivery + PROVIDE_ADDRESS goes to payment", async () => {
it("SELECT_SHIPPING delivery + PROVIDE_ADDRESS cierra y vuelve a IDLE", async () => {
const a = makeActor();
a.start();
await buildCartWithItem(a);
@@ -187,21 +189,10 @@ describe("machine — checkout flow", () => {
a.send({ type: "SELECT_SHIPPING", method: "delivery" });
expect(a.getSnapshot().value).toBe("shipping");
a.send({ type: "PROVIDE_ADDRESS", address: "Corrientes 1234" });
expect(a.getSnapshot().value).toBe("payment");
expect(a.getSnapshot().context.order.shipping_address).toBe("Corrientes 1234");
a.stop();
});
it("SELECT_PAYMENT cash goes to waiting and enqueues create_order", async () => {
const a = makeActor();
a.start();
await buildCartWithItem(a);
a.send({ type: "CONFIRM_ORDER" });
a.send({ type: "SELECT_SHIPPING", method: "pickup" });
a.send({ type: "SELECT_PAYMENT", method: "cash" });
const snap = a.getSnapshot();
expect(snap.value).toBe("waiting");
expect(snap.context.pending_actions.some((a) => a.type === "create_order")).toBe(true);
expect(snap.value).toBe("idle");
expect(snap.context.order.shipping_address).toBe("Corrientes 1234");
expect(snap.context.pending_actions.some((x) => x.type === "create_order")).toBe(true);
a.stop();
});
});
@@ -229,11 +220,9 @@ describe("machine — snapshot persistence", () => {
});
describe("xstateToLegacyState", () => {
it("maps top-level idle/shipping/payment/waiting", () => {
it("maps top-level idle/shipping (sin payment/waiting)", () => {
expect(xstateToLegacyState("idle")).toBe("IDLE");
expect(xstateToLegacyState("shipping")).toBe("SHIPPING");
expect(xstateToLegacyState("payment")).toBe("PAYMENT");
expect(xstateToLegacyState("waiting")).toBe("WAITING_WEBHOOKS");
});
it("maps cart sub-states to CART", () => {
expect(xstateToLegacyState({ cart: "idle" })).toBe("CART");

View File

@@ -40,9 +40,6 @@ export function nluToEvent(nlu, text) {
case "provide_address":
return { type: "PROVIDE_ADDRESS", address: entities.address || text };
case "select_payment":
return { type: "SELECT_PAYMENT", method: entities.payment_method || null };
default:
return { type: "OTHER", text };
}

View File

@@ -132,7 +132,6 @@ function buildContextPatch(snapshot, recentReplies, finalTemplateId, persistedSn
unit: p.unit,
status: p.status?.toLowerCase() || "needs_type",
})),
payment_method: order.payment_type,
shipping_method: order.is_delivery === true ? "delivery"
: order.is_delivery === false ? "pickup" : null,
delivery_address: order.shipping_address ? { text: order.shipping_address } : null,

View File

@@ -9,7 +9,6 @@ import { routerClassify, quickDomainDetect } from "./router.js";
import { greetingNlu } from "./specialists/greeting.js";
import { ordersNlu } from "./specialists/orders.js";
import { shippingNlu } from "./specialists/shipping.js";
import { paymentNlu } from "./specialists/payment.js";
import { browseNlu } from "./specialists/browse.js";
import { createEmptyNlu } from "./schemas.js";
@@ -50,7 +49,7 @@ export async function llmNluModular({ input, tenantId, storeConfig = {} } = {})
// Casos donde podemos saltar el router:
// - Saludos simples
// - Números solos (1, 2) en estados SHIPPING/PAYMENT
// - Números solos (1, 2) en estado SHIPPING
// - Patrones muy claros
const skipRouter = shouldSkipRouter(text, state, quickDomain);
@@ -86,11 +85,6 @@ export async function llmNluModular({ input, tenantId, storeConfig = {} } = {})
result = await shippingNlu({ tenantId, text, storeConfig });
break;
case "payment":
routing.specialist_used = "payment";
result = await paymentNlu({ tenantId, text, storeConfig });
break;
case "browse":
routing.specialist_used = "browse";
result = await browseNlu({ tenantId, text, storeConfig });
@@ -145,15 +139,8 @@ function shouldSkipRouter(text, state, quickDomain) {
return true;
}
// Números solos en estados específicos
if (/^[12]$/.test(t)) {
if (state === "SHIPPING" || state === "PAYMENT") {
return true;
}
}
// "efectivo" o "tarjeta" solos en estado PAYMENT
if (state === "PAYMENT" && /^(efectivo|tarjeta|link|transfer)$/i.test(t)) {
// Números solos en estado SHIPPING (selección 1/2)
if (/^[12]$/.test(t) && state === "SHIPPING") {
return true;
}

View File

@@ -183,7 +183,7 @@ export function getCacheStats() {
* Pre-carga todos los prompts de un tenant (útil al inicio)
*/
export async function preloadPrompts({ tenantId, storeConfig = {} }) {
const promptKeys = ["router", "greeting", "orders", "shipping", "payment", "browse"];
const promptKeys = ["router", "greeting", "orders", "shipping", "browse"];
const results = {};
for (const key of promptKeys) {

View File

@@ -103,7 +103,7 @@ function detectDomainByPatterns(text, state) {
return "greeting";
}
// Si el estado ya es SHIPPING o PAYMENT, priorizar esos dominios
// Si el estado ya es SHIPPING, priorizar ese dominio
if (state === "SHIPPING") {
if (/delivery|env[ií]o|retiro|buscar|sucursal|direcci[oó]n/i.test(t)) {
return "shipping";
@@ -114,16 +114,6 @@ function detectDomainByPatterns(text, state) {
}
}
if (state === "PAYMENT") {
if (/efectivo|cash|tarjeta|link|transfer|mercadopago|mp|qr/i.test(t)) {
return "payment";
}
// Números simples (1 o 2) en estado PAYMENT
if (/^[12]$/.test(t.trim())) {
return "payment";
}
}
// Orders patterns
const orderPatterns = [
/\b(quiero|dame|anotame|poneme|agregame|necesito)\b/i,
@@ -153,15 +143,9 @@ function detectDomainByPatterns(text, state) {
return "shipping";
}
// Payment patterns
if (/\b(efectivo|tarjeta|link|transfer|mercadopago)\b/i.test(t)) {
return "payment";
}
// Default basado en estado
if (state === "CART") return "orders";
if (state === "SHIPPING") return "shipping";
if (state === "PAYMENT") return "payment";
return "other";
}

View File

@@ -18,7 +18,7 @@ export const RouterSchema = {
properties: {
domain: {
type: "string",
enum: ["greeting", "orders", "shipping", "payment", "browse", "other"],
enum: ["greeting", "orders", "shipping", "browse", "other"],
},
},
};
@@ -106,28 +106,6 @@ export const ShippingSchema = {
export const validateShipping = ajv.compile(ShippingSchema);
// ─────────────────────────────────────────────────────────────
// Schema: Payment
// ─────────────────────────────────────────────────────────────
export const PaymentSchema = {
$id: "Payment",
type: "object",
additionalProperties: false,
required: ["intent"],
properties: {
intent: {
type: "string",
enum: ["select_payment"],
},
payment_method: {
anyOf: [{ type: "string", enum: ["cash", "link"] }, { type: "null" }],
},
},
};
export const validatePayment = ajv.compile(PaymentSchema);
// ─────────────────────────────────────────────────────────────
// Schema: Browse
// ─────────────────────────────────────────────────────────────
@@ -164,7 +142,7 @@ export const UnifiedNluSchema = {
type: "string",
enum: [
"price_query", "browse", "add_to_cart", "remove_from_cart",
"checkout", "confirm_order", "select_payment", "select_shipping",
"checkout", "confirm_order", "select_shipping",
"provide_address", "greeting", "recommend", "view_cart", "other"
],
},
@@ -194,7 +172,6 @@ export const UnifiedNluSchema = {
},
attributes: { type: "array", items: { type: "string" } },
preparation: { type: "array", items: { type: "string" } },
payment_method: { anyOf: [{ type: "string", enum: ["cash", "link"] }, { type: "null" }] },
shipping_method: { anyOf: [{ type: "string", enum: ["delivery", "pickup"] }, { type: "null" }] },
address: { anyOf: [{ type: "string" }, { type: "null" }] },
items: {
@@ -267,7 +244,6 @@ export function createEmptyNlu() {
selection: null,
attributes: [],
preparation: [],
payment_method: null,
shipping_method: null,
address: null,
items: null,

View File

@@ -1,135 +0,0 @@
/**
* Payment Specialist - Extracción de método de pago
*/
import OpenAI from "openai";
import { loadPrompt } from "../promptLoader.js";
import { validatePayment, getValidationErrors, createEmptyNlu } from "../schemas.js";
let _client = null;
function getClient() {
const apiKey = process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY;
if (!apiKey) {
throw new Error("OPENAI_API_KEY is not set");
}
if (!_client) {
_client = new OpenAI({ apiKey });
}
return _client;
}
function extractJson(text) {
const s = String(text || "");
const i = s.indexOf("{");
const j = s.lastIndexOf("}");
if (i >= 0 && j > i) {
try {
return JSON.parse(s.slice(i, j + 1));
} catch {
return null;
}
}
return null;
}
/**
* Detecta método de pago por patrones simples
*/
function detectPaymentMethod(text) {
const t = String(text || "").toLowerCase().trim();
// Números (asumiendo 1=efectivo, 2=link del contexto)
if (/^1$/.test(t)) return "cash";
if (/^2$/.test(t)) return "link";
// Cash patterns
if (/\b(efectivo|cash|plata|billete|cuando (llega|llegue)|en mano)\b/i.test(t)) {
return "cash";
}
// Link patterns
if (/\b(tarjeta|link|transfer|qr|mercadopago|mp|d[eé]bito|cr[eé]dito)\b/i.test(t)) {
return "link";
}
return null;
}
/**
* Procesa un mensaje de pago
*
* @param {Object} params
* @param {number} params.tenantId - ID del tenant
* @param {string} params.text - Mensaje del usuario
* @param {Object} params.storeConfig - Config de la tienda
* @returns {Object} NLU unificado
*/
export async function paymentNlu({ tenantId, text, storeConfig = {} }) {
// Intentar detección rápida primero
const quickMethod = detectPaymentMethod(text);
// Si es claramente un número o patrón simple, no llamar al LLM
if (quickMethod && text.trim().length < 30) {
const nlu = createEmptyNlu();
nlu.intent = "select_payment";
nlu.confidence = 0.9;
nlu.entities.payment_method = quickMethod;
return {
nlu,
raw_text: "",
model: null,
usage: null,
validation: { ok: true, skipped_llm: true },
};
}
const openai = getClient();
// Cargar prompt de payment
const { content: systemPrompt, model } = await loadPrompt({
tenantId,
promptKey: "payment",
variables: storeConfig,
});
// Hacer la llamada al LLM
const response = await openai.chat.completions.create({
model: model || "gpt-4o-mini",
temperature: 0.1,
max_tokens: 100,
response_format: { type: "json_object" },
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: text },
],
});
const rawText = response?.choices?.[0]?.message?.content || "";
let parsed = extractJson(rawText);
// Validar
if (!parsed || !validatePayment(parsed)) {
// Fallback con detección por patrones
parsed = {
intent: "select_payment",
payment_method: quickMethod,
};
}
// Convertir a formato NLU unificado
const nlu = createEmptyNlu();
nlu.intent = "select_payment";
nlu.confidence = 0.85;
nlu.entities.payment_method = parsed.payment_method || null;
nlu.needs.catalog_lookup = false;
return {
nlu,
raw_text: rawText,
model,
usage: response?.usage || null,
validation: { ok: true },
};
}

View File

@@ -76,7 +76,7 @@ const NluV3JsonSchema = {
properties: {
intent: {
type: "string",
enum: ["price_query", "browse", "add_to_cart", "remove_from_cart", "checkout", "confirm_order", "select_payment", "select_shipping", "provide_address", "greeting", "recommend", "view_cart", "other"],
enum: ["price_query", "browse", "add_to_cart", "remove_from_cart", "checkout", "confirm_order", "select_shipping", "provide_address", "greeting", "recommend", "view_cart", "other"],
},
confidence: { type: "number", minimum: 0, maximum: 1 },
language: { type: "string" },
@@ -104,8 +104,7 @@ const NluV3JsonSchema = {
},
attributes: { type: "array", items: { type: "string" } },
preparation: { type: "array", items: { type: "string" } },
// Checkout: método de pago, envío, dirección
payment_method: { anyOf: [{ type: "string", enum: ["cash", "link"] }, { type: "null" }] },
// Checkout: envío y dirección. (El bot no maneja pagos.)
shipping_method: { anyOf: [{ type: "string", enum: ["delivery", "pickup"] }, { type: "null" }] },
address: { anyOf: [{ type: "string" }, { type: "null" }] },
// Soporte para múltiples productos en un mensaje
@@ -236,8 +235,7 @@ function normalizeNluOutput(parsed, input) {
selection: entities.selection ?? null,
attributes: Array.isArray(entities.attributes) ? entities.attributes : [],
preparation: Array.isArray(entities.preparation) ? entities.preparation : [],
// Checkout entities (opcionales)
payment_method: entities.payment_method ?? null,
// Checkout entities (opcionales). El bot NO maneja pagos.
shipping_method: entities.shipping_method ?? null,
address: entities.address ?? null,
items: normalizedItems,
@@ -282,7 +280,6 @@ function nluV3Fallback() {
selection: null,
attributes: [],
preparation: [],
payment_method: null,
shipping_method: null,
address: null,
items: null,
@@ -352,7 +349,6 @@ export async function llmNluV3({ input, model } = {}) {
"- SALUDOS: Si el usuario SOLO saluda sin mencionar productos (hola, buen día, buenas tardes, buenas noches, qué tal, hey, hi), usá intent='greeting'. needs.catalog_lookup=false.\n" +
"- VER CARRITO: Si el usuario pregunta qué tiene anotado/pedido/en el carrito (ej: 'qué tengo?', 'qué llevó?', 'qué anoté?', 'mostrame mi pedido'), usá intent='view_cart'. needs.catalog_lookup=false.\n" +
"- CONFIRMAR ORDEN: Si el usuario quiere cerrar/confirmar el pedido (ej: 'listo', 'eso es todo', 'cerrar pedido', 'ya está', 'nada más'), usá intent='confirm_order'. needs.catalog_lookup=false.\n" +
"- SELECCIONAR PAGO: Si el usuario elige método de pago (ej: 'efectivo', 'tarjeta', 'link de pago', 'transferencia'), usá intent='select_payment'. Extraer entities.payment_method='cash'|'link'.\n" +
"- SELECCIONAR ENVÍO: Si el usuario elige envío (ej: 'delivery', 'envío', 'que me lo traigan', 'retiro', 'paso a buscar'), usá intent='select_shipping'. Extraer entities.shipping_method='delivery'|'pickup'.\n" +
"- DAR DIRECCIÓN: Si el usuario da una dirección de entrega, usá intent='provide_address'. Extraer entities.address con el texto de la dirección.\n" +
"- MULTI-ITEMS: Si el usuario menciona MÚLTIPLES productos en un mensaje (ej: '1 chimichurri y 2 provoletas'), usá entities.items con array de objetos.\n" +

View File

@@ -2,7 +2,10 @@
* Modelo unificado de orden para el contexto de conversación.
*
* Reemplaza: order_basket, pending_items, pending_clarification, pending_item,
* checkout_step, payment_method, shipping_method, delivery_address
* checkout_step, shipping_method, delivery_address.
*
* El bot toma pedidos y datos de entrega; NO maneja pagos. El cobro se
* coordina offline.
*/
// Status de items pendientes
@@ -19,11 +22,9 @@ export function createEmptyOrder() {
return {
cart: [], // Items confirmados: [{ woo_id, qty, unit, name, price }]
pending: [], // Items por clarificar
payment_type: null, // "link" | "cash" | null
is_delivery: null, // true | false | null
shipping_address: null,
woo_order_id: null,
is_paid: false,
};
}
@@ -294,14 +295,10 @@ export function migrateOldContext(ctx) {
}
// Migrar checkout info
order.payment_type = ctx.payment_method || null;
order.is_delivery = ctx.shipping_method === "delivery" ? true :
ctx.shipping_method === "pickup" ? false : null;
order.shipping_address = ctx.delivery_address?.text || ctx.address?.text || ctx.address_text || null;
order.woo_order_id = ctx.woo_order_id || ctx.last_order_id || null;
order.is_paid = ctx.mp?.payment_status === "approved" ||
ctx.payment?.status === "paid" ||
ctx.payment_status === "approved" || false;
return order;
}

View File

@@ -23,15 +23,15 @@ import {
// ─────────────────────────────────────────────────────────────
describe('createEmptyOrder', () => {
it('crea orden con estructura correcta', () => {
it('crea orden con estructura correcta (sin payment)', () => {
const order = createEmptyOrder();
expect(order).toHaveProperty('cart');
expect(order).toHaveProperty('pending');
expect(order).toHaveProperty('payment_type');
expect(order).toHaveProperty('is_delivery');
expect(order).toHaveProperty('shipping_address');
expect(order).toHaveProperty('woo_order_id');
expect(order).toHaveProperty('is_paid');
expect(order).not.toHaveProperty('payment_type');
expect(order).not.toHaveProperty('is_paid');
});
it('inicializa arrays vacíos', () => {
@@ -40,13 +40,11 @@ describe('createEmptyOrder', () => {
expect(order.pending).toEqual([]);
});
it('inicializa valores null/false', () => {
it('inicializa valores null', () => {
const order = createEmptyOrder();
expect(order.payment_type).toBeNull();
expect(order.is_delivery).toBeNull();
expect(order.shipping_address).toBeNull();
expect(order.woo_order_id).toBeNull();
expect(order.is_paid).toBe(false);
});
});
@@ -569,19 +567,19 @@ describe('migrateOldContext', () => {
expect(result.pending[0].status).toBe(PendingStatus.NEEDS_TYPE);
});
it('migra checkout info', () => {
it('migra checkout info (shipping/address/order_id, no payment)', () => {
const ctx = {
payment_method: 'cash',
shipping_method: 'delivery',
delivery_address: { text: 'Calle Falsa 123' },
woo_order_id: 456,
};
const result = migrateOldContext(ctx);
expect(result.payment_type).toBe('cash');
expect(result.is_delivery).toBe(true);
expect(result.shipping_address).toBe('Calle Falsa 123');
expect(result.woo_order_id).toBe(456);
expect(result.payment_type).toBeUndefined();
expect(result.is_paid).toBeUndefined();
});
it('migra shipping_method pickup', () => {

View File

@@ -99,25 +99,11 @@ export const DEFAULTS = {
"Anotado: {{address}}.",
"Listo, dirección guardada: {{address}}.",
],
"shipping.pickup_to_payment": [
"Genial, lo pasás a buscar. ¿Cómo abonás?",
"Pickup confirmado. ¿Pago en efectivo o link?",
],
// ---------------- PAYMENT ----------------
"payment.ask_method": [
"¿Cómo querés abonar? Efectivo o link de pago.",
"Para cerrar, ¿pagás en efectivo o con link?",
],
"payment.confirmed": [
"Listo, te paso los datos del pedido.",
"Perfecto, queda armado. Te paso los datos.",
],
// ---------------- WAITING ----------------
"waiting.in_progress": [
"Tu pedido está en proceso. Cualquier cosa avisame.",
"Esperando confirmación del pago. ¿Necesitás algo más?",
// ---------------- ORDER CLOSE ----------------
"order.confirmed": [
"¡Listo! Anotamos tu pedido. Te coordinamos por acá la entrega y el pago.",
"Perfecto, ya quedó registrado. Te confirmamos en breve los detalles de entrega.",
"Genial, anotado. Cualquier ajuste avisame por acá.",
],
};
@@ -282,7 +268,7 @@ const REWRITE_KEYS = new Set([
"idle.greeting", // se filtra adicionalmente: solo en 1er turno
"shipping.ask_method",
"shipping.ask_address",
"payment.ask_method",
"order.confirmed",
]);
function shouldRewrite(templateKey, history) {

View File

@@ -1,28 +1,19 @@
/**
* State Handlers - Re-export desde módulo refactorizado
* State Handlers - Re-export desde módulo refactorizado.
*
* Este archivo mantiene compatibilidad con imports existentes.
* La implementación real está en ./stateHandlers/
*
* Estructura del módulo:
* - stateHandlers/utils.js - Utilidades de parseo y detección de texto
* Estructura:
* - stateHandlers/utils.js - Utilidades de parseo y detección de texto
* - stateHandlers/cartHelpers.js - Helpers para manejo del carrito
* - stateHandlers/idle.js - Handler estado IDLE
* - stateHandlers/cart.js - Handler estado CART
* - stateHandlers/shipping.js - Handler estado SHIPPING
* - stateHandlers/payment.js - Handler estado PAYMENT
* - stateHandlers/waiting.js - Handler estado WAITING_WEBHOOKS
* - stateHandlers/idle.js - Handler estado IDLE
* - stateHandlers/cart.js - Handler estado CART
* - stateHandlers/shipping.js - Handler estado SHIPPING (cierra orden)
*/
export {
// Handlers principales
handleIdleState,
handleCartState,
handleShippingState,
handlePaymentState,
handleWaitingState,
// Utilidades
inferDefaultUnit,
parseIndexSelection,
isShowMoreRequest,
@@ -32,7 +23,6 @@ export {
normalizeUnit,
unitAskFor,
// Helpers de carrito
extractProductQueries,
createPendingItemFromSearch,
processPendingClarification,

View File

@@ -9,8 +9,6 @@
export { handleIdleState } from "./idle.js";
export { handleCartState } from "./cart.js";
export { handleShippingState } from "./shipping.js";
export { handlePaymentState } from "./payment.js";
export { handleWaitingState } from "./waiting.js";
// Utilidades (para uso interno principalmente)
export {

View File

@@ -1,83 +0,0 @@
/**
* Handler para el estado PAYMENT
*/
import { ConversationState, safeNextState } from "../fsm.js";
import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js";
import { parseIndexSelection } from "./utils.js";
import { renderReply } from "../replyTemplates.js";
const PAYMENT_OPTIONS_TAIL = "\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.";
export async function handlePaymentState({ tenantId, text, nlu, order, audit, recentReplies, conversation_history }) {
const intent = nlu?.intent || "other";
let currentOrder = order || createEmptyOrder();
const actions = [];
const rewriteCtx = { conversation_history, state: "PAYMENT", userText: text };
let paymentMethod = nlu?.entities?.payment_method;
if (!paymentMethod) {
const t = String(text || "").toLowerCase();
const idx = parseIndexSelection(text);
if (idx === 1 || /efectivo|cash|plata/i.test(t)) {
paymentMethod = "cash";
} else if (idx === 2 || /link|tarjeta|transfer|qr|mercado\s*pago/i.test(t)) {
paymentMethod = "link";
}
}
if (paymentMethod) {
currentOrder = { ...currentOrder, payment_type: paymentMethod };
actions.push({ type: "create_order", payload: { payment: paymentMethod } });
const { next_state } = safeNextState(ConversationState.PAYMENT, currentOrder, { payment_selected: true });
const paymentLabel = paymentMethod === "cash" ? "efectivo" : "link de pago";
const deliveryInfo = currentOrder.is_delivery
? `Te lo llevamos a ${currentOrder.shipping_address || "la dirección indicada"}.`
: "Retiro en sucursal.";
const paymentInfo = paymentMethod === "link"
? "Te contactamos para coordinar el pago."
: "Pagás en " + (currentOrder.is_delivery ? "la entrega" : "sucursal") + ".";
const r = await renderReply({ tenantId, templateKey: "payment.confirmed", recentReplies, ...rewriteCtx });
return {
plan: {
reply: `${r.reply} Pagás en ${paymentLabel}. ${deliveryInfo}\n\n${paymentInfo}`,
next_state,
intent: "select_payment",
missing_fields: [],
order_action: "create_order",
},
decision: { actions, order: currentOrder, audit, template_ids_used: [r.template_id] },
};
}
if (intent === "view_cart") {
const cartDisplay = formatCartForDisplay(currentOrder);
const r = await renderReply({ tenantId, templateKey: "payment.ask_method", recentReplies, ...rewriteCtx });
return {
plan: {
reply: `${cartDisplay}\n\n${r.reply}`,
next_state: ConversationState.PAYMENT,
intent: "view_cart",
missing_fields: ["payment_method"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
};
}
const r = await renderReply({ tenantId, templateKey: "payment.ask_method", recentReplies, ...rewriteCtx });
return {
plan: {
reply: `${r.reply}${PAYMENT_OPTIONS_TAIL}`,
next_state: ConversationState.PAYMENT,
intent: "other",
missing_fields: ["payment_method"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
};
}

View File

@@ -1,19 +1,19 @@
/**
* Handler para el estado SHIPPING
* Handler para el estado SHIPPING.
*
* Pide modo de entrega (delivery / pickup) y, si es delivery, la dirección.
* Cuando completa, emite acción `create_order` y vuelve a IDLE.
* El bot NO maneja pago — el cobro se gestiona offline.
*/
import { ConversationState, safeNextState } from "../fsm.js";
import { ConversationState } from "../fsm.js";
import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js";
import { parseIndexSelection } from "./utils.js";
import { renderReply } from "../replyTemplates.js";
import { buildStoreContextVars } from "../storeContext.js";
const SHIPPING_OPTIONS_TAIL = "\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal";
const PAYMENT_OPTIONS_TAIL = "\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.";
/**
* Maneja el estado SHIPPING (selección de envío)
*/
export async function handleShippingState({ tenantId, text, nlu, order, audit, recentReplies, storeConfig, conversation_history }) {
const intent = nlu?.intent || "other";
let currentOrder = order || createEmptyOrder();
@@ -37,18 +37,8 @@ export async function handleShippingState({ tenantId, text, nlu, order, audit, r
currentOrder = { ...currentOrder, is_delivery: shippingMethod === "delivery" };
if (shippingMethod === "pickup") {
const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {});
const r = await renderReply({ tenantId, templateKey: "shipping.pickup_to_payment", vars: storeVars, recentReplies, ...rewriteCtx });
return {
plan: {
reply: `${r.reply}${PAYMENT_OPTIONS_TAIL}`,
next_state,
intent: "select_shipping",
missing_fields: ["payment_method"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
};
// Pickup: orden lista, cerrarla.
return finalizeOrder({ tenantId, currentOrder, audit, recentReplies, storeVars, rewriteCtx });
}
// Delivery: pedir dirección si no la tiene
@@ -73,29 +63,7 @@ export async function handleShippingState({ tenantId, text, nlu, order, audit, r
if (address) {
currentOrder = { ...currentOrder, shipping_address: address };
const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {});
const recorded = await renderReply({
tenantId,
templateKey: "shipping.address_recorded",
vars: { address },
recentReplies,
});
const askPay = await renderReply({ tenantId, templateKey: "payment.ask_method", vars: storeVars, recentReplies, ...rewriteCtx });
return {
plan: {
reply: `${recorded.reply}\n\n${askPay.reply}${PAYMENT_OPTIONS_TAIL}`,
next_state,
intent: "provide_address",
missing_fields: ["payment_method"],
order_action: "none",
},
decision: {
actions: [],
order: currentOrder,
audit,
template_ids_used: [recorded.template_id, askPay.template_id],
},
};
return finalizeOrder({ tenantId, currentOrder, audit, recentReplies, storeVars, rewriteCtx, addressJustRecorded: true });
}
const r = await renderReply({ tenantId, templateKey: "shipping.ask_address", vars: storeVars, recentReplies, ...rewriteCtx });
@@ -140,3 +108,39 @@ export async function handleShippingState({ tenantId, text, nlu, order, audit, r
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
};
}
/**
* Cierra la orden: emite acción create_order y vuelve a IDLE.
*/
async function finalizeOrder({ tenantId, currentOrder, audit, recentReplies, storeVars, rewriteCtx, addressJustRecorded = false }) {
const cartDisplay = formatCartForDisplay(currentOrder);
const confirmed = await renderReply({ tenantId, templateKey: "order.confirmed", vars: storeVars, recentReplies, ...rewriteCtx });
const addressEcho = addressJustRecorded
? await renderReply({ tenantId, templateKey: "shipping.address_recorded", vars: { ...storeVars, address: currentOrder.shipping_address }, recentReplies })
: null;
const reply = [
addressEcho?.reply,
cartDisplay,
confirmed.reply,
].filter(Boolean).join("\n\n");
return {
plan: {
reply,
next_state: ConversationState.IDLE,
intent: "confirm_order",
missing_fields: [],
order_action: "create_order",
},
decision: {
actions: [{ type: "create_order", payload: { source: "wa_bot" } }],
order: currentOrder,
audit,
template_ids_used: [
addressEcho?.template_id,
confirmed.template_id,
].filter(Boolean),
},
};
}

View File

@@ -1,133 +0,0 @@
/**
* Handler para el estado WAITING_WEBHOOKS
*/
import { ConversationState } from "../fsm.js";
import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js";
import { renderReply } from "../replyTemplates.js";
/**
* Detecta si el usuario pregunta por horarios/días de entrega
*/
function isDeliveryInfoQuestion(text) {
const t = String(text || "").toLowerCase();
const patterns = [
/cu[aá]ndo\s+(entrega|llega|env[ií])/i,
/qu[eé]\s+d[ií]as?\s+(entrega|env[ií]|repart)/i,
/d[ií]as?\s+de\s+(entrega|env[ií]|reparto)/i,
/horario\s+de\s+(entrega|env[ií]|reparto)/i,
/qu[eé]\s+horarios?/i,
/a\s+qu[eé]\s+hora/i,
/en\s+qu[eé]\s+horario/i,
/cuando\s+(me\s+)?lo\s+traen/i,
/d[ií]a\s+y\s+hora/i,
];
return patterns.some(p => p.test(t));
}
/**
* Detecta si el usuario pregunta por horarios de retiro
*/
function isPickupInfoQuestion(text) {
const t = String(text || "").toLowerCase();
const patterns = [
/horario.*(retir|buscar|pasar)/i,
/cu[aá]ndo.*(retir|buscar|pasar)/i,
/a\s+qu[eé]\s+hora.*(retir|buscar)/i,
/d[ií]as?.*(retir|buscar)/i,
];
return patterns.some(p => p.test(t));
}
/**
* Maneja el estado WAITING_WEBHOOKS (esperando confirmación de pago)
*/
export async function handleWaitingState({ tenantId, text, nlu, order, audit, storeConfig = {}, recentReplies }) {
const intent = nlu?.intent || "other";
const currentOrder = order || createEmptyOrder();
// view_cart
if (intent === "view_cart") {
const cartDisplay = formatCartForDisplay(currentOrder);
const status = currentOrder.is_paid ? "✓ Pagado" : "Esperando pago...";
return {
plan: {
reply: `${cartDisplay}\n\nEstado: ${status}`,
next_state: ConversationState.WAITING_WEBHOOKS,
intent: "view_cart",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
};
}
// Preguntas sobre horarios/días de entrega
if (isDeliveryInfoQuestion(text)) {
// Usar deliveryHours que ya viene formateado desde getStoreConfig
// (agrupa días con mismos horarios: "Lunes a Viernes de 9:00 a 14:00, Sábado de 9:00 a 13:00")
const deliveryHours = storeConfig.deliveryHours;
let reply = "";
if (deliveryHours && deliveryHours !== "No disponible") {
reply = `Hacemos entregas: ${deliveryHours}. `;
} else if (storeConfig.deliveryEnabled === false) {
reply = "Por el momento no ofrecemos delivery. ";
} else {
reply = "Todavía no tengo configurados los horarios de entrega. ";
}
reply += "Tu pedido ya está en proceso, avisame cualquier cosa.";
return {
plan: {
reply,
next_state: ConversationState.WAITING_WEBHOOKS,
intent: "delivery_info",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
};
}
// Preguntas sobre horarios de retiro
if (isPickupInfoQuestion(text)) {
const pickupHours = storeConfig.pickupHours;
let reply = "";
if (pickupHours && pickupHours !== "No disponible") {
reply = `Podés retirar: ${pickupHours}. `;
} else if (storeConfig.pickupEnabled === false) {
reply = "Por el momento no ofrecemos retiro en tienda. ";
} else {
reply = "Todavía no tengo configurados los horarios de retiro. ";
}
reply += "Tu pedido ya está en proceso, avisame cualquier cosa.";
return {
plan: {
reply,
next_state: ConversationState.WAITING_WEBHOOKS,
intent: "pickup_info",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit },
};
}
// Default
const r = await renderReply({ tenantId, templateKey: "waiting.in_progress", recentReplies });
return {
plan: {
reply: r.reply,
next_state: ConversationState.WAITING_WEBHOOKS,
intent: "other",
missing_fields: [],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
};
}

View File

@@ -1,7 +1,7 @@
/**
* Turn Engine V3 - Dispatcher basado en estados
*
* Flujo: IDLE → CART → SHIPPING → PAYMENT → WAITING_WEBHOOKS
* Flujo: IDLE → CART → SHIPPING → IDLE (orden creada offline)
* Regla universal: add_to_cart SIEMPRE vuelve a CART desde cualquier estado.
*
* Feature flag USE_MODULAR_NLU=true para usar el nuevo sistema NLU modular.
@@ -15,8 +15,6 @@ import {
handleIdleState,
handleCartState,
handleShippingState,
handlePaymentState,
handleWaitingState,
} from "./stateHandlers.js";
import { getStoreConfig } from "../0-ui/db/settingsRepo.js";
import { pushRecent } from "./replyTemplates.js";
@@ -200,14 +198,6 @@ export async function runTurnV3({
result = await handleShippingState(handlerParams);
break;
case ConversationState.PAYMENT:
result = await handlePaymentState(handlerParams);
break;
case ConversationState.WAITING_WEBHOOKS:
result = await handleWaitingState(handlerParams);
break;
default:
// Estado desconocido, tratar como IDLE
result = await handleIdleState(handlerParams);
@@ -242,20 +232,16 @@ function normalizeState(state) {
if (s === "IDLE") return ConversationState.IDLE;
if (s === "CART") return ConversationState.CART;
if (s === "SHIPPING") return ConversationState.SHIPPING;
if (s === "PAYMENT") return ConversationState.PAYMENT;
if (s === "WAITING_WEBHOOKS") return ConversationState.WAITING_WEBHOOKS;
// Estados viejos → CART
// Estados viejos / payment-flow legacy → mapeos seguros
if (["CART_ACTIVE", "BROWSING", "CLARIFYING_ITEMS", "AWAITING_QUANTITY"].includes(s)) {
return ConversationState.CART;
}
// Estados de checkout viejos
if (s === "CLARIFYING_PAYMENT") return ConversationState.PAYMENT;
if (s === "CLARIFYING_SHIPPING") return ConversationState.SHIPPING;
if (s === "AWAITING_ADDRESS") return ConversationState.SHIPPING;
if (s === "AWAITING_PAYMENT") return ConversationState.WAITING_WEBHOOKS;
if (s === "COMPLETED") return ConversationState.IDLE; // Nuevo ciclo
if (s === "CLARIFYING_SHIPPING" || s === "AWAITING_ADDRESS") return ConversationState.SHIPPING;
// Estados que ya no existen (payment / waiting / completed) vuelven a IDLE
if (["PAYMENT", "WAITING_WEBHOOKS", "CLARIFYING_PAYMENT", "AWAITING_PAYMENT", "COMPLETED"].includes(s)) {
return ConversationState.IDLE;
}
return ConversationState.IDLE;
}
@@ -311,7 +297,6 @@ function formatResult(result, prevContext, recentReplies = [], failedSearches =
unit: p.unit,
status: p.status?.toLowerCase() || "needs_type",
})),
payment_method: order.payment_type,
shipping_method: order.is_delivery === true ? "delivery" :
order.is_delivery === false ? "pickup" : null,
delivery_address: order.shipping_address ? { text: order.shipping_address } : null,

View File

@@ -193,27 +193,23 @@ function mapAddress(address) {
};
}
export async function createOrder({ tenantId, wooCustomerId, basket, address, shippingMethod, paymentMethod, run_id }) {
export async function createOrder({ tenantId, wooCustomerId, basket, address, shippingMethod, run_id }) {
const lockKey = `${tenantId}:${wooCustomerId || "anon"}`;
return withLock(lockKey, async () => {
const client = await getWooClient({ tenantId });
const lineItems = await buildLineItems({ tenantId, basket });
if (!lineItems.length) throw new Error("order_empty_basket");
const addr = mapAddress(address);
// Estado "pending" en Woo = el pago/cobro lo gestiona el comercio offline.
const payload = {
status: "pending",
customer_id: wooCustomerId || undefined,
line_items: lineItems,
...(addr ? { billing: addr, shipping: addr } : {}),
// Si es cash, usar payment_method "cod" (Cash On Delivery) de WooCommerce
...(paymentMethod === "cash" ? { payment_method: "cod", payment_method_title: "Efectivo" } : {}),
meta_data: [
{ key: "source", value: "whatsapp" },
...(run_id ? [{ key: "run_id", value: run_id }] : []),
// Guardar shipping_method como metadata para poder leerlo después
...(shippingMethod ? [{ key: "shipping_method", value: shippingMethod }] : []),
// Guardar payment_method como metadata también
...(paymentMethod ? [{ key: "payment_method_wa", value: paymentMethod }] : []),
],
};
const url = `${client.base}/orders`;