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 // 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 // Modelos por defecto para cada prompt
export const DEFAULT_MODELS = { export const DEFAULT_MODELS = {
@@ -13,7 +13,6 @@ export const DEFAULT_MODELS = {
greeting: "gpt-4-turbo", greeting: "gpt-4-turbo",
orders: "gpt-4-turbo", orders: "gpt-4-turbo",
shipping: "gpt-4o-mini", shipping: "gpt-4o-mini",
payment: "gpt-4o-mini",
browse: "gpt-4-turbo", browse: "gpt-4-turbo",
}; };

View File

@@ -205,7 +205,6 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
ok: true, ok: true,
checks: [ checks: [
{ name: "required_keys_present", ok: true }, { name: "required_keys_present", ok: true },
{ name: "no_checkout_without_payment_link", ok: true },
{ name: "no_order_action_without_items", ok: true }, { name: "no_order_action_without_items", ok: true },
], ],
}; };
@@ -257,16 +256,14 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
...baseAddress, ...baseAddress,
phone: baseAddress.phone || phoneFromWa, 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 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({ const order = await createOrder({
tenantId, tenantId,
wooCustomerId: externalCustomerId, wooCustomerId: externalCustomerId,
basket: basketToUse, basket: basketToUse,
address: addressWithPhone, address: addressWithPhone,
shippingMethod, shippingMethod,
paymentMethod,
run_id: null, run_id: null,
}); });
actionPatch.woo_order_id = order?.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 orderForFsm = context?.order || context?.order_basket || {};
const signals = { const signals = {
confirm_order: plan.intent === "confirm_order", 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; const nextState = safeNextState(prev_state, orderForFsm, signals).next_state;
plan.next_state = nextState; plan.next_state = nextState;
@@ -448,7 +445,6 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
invariants, invariants,
final_reply: plan.reply, final_reply: plan.reply,
order_id: actionPatch.woo_order_id || null, order_id: actionPatch.woo_order_id || null,
payment_link: actionPatch.payment_link || null,
latency_ms: end_to_end_ms, latency_ms: end_to_end_ms,
}); });

View File

@@ -1,7 +1,8 @@
/** /**
* FSM simplificada para el flujo conversacional. * 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. * Regla universal: add_to_cart SIEMPRE vuelve a CART desde cualquier estado.
*/ */
@@ -9,8 +10,6 @@ export const ConversationState = Object.freeze({
IDLE: "IDLE", IDLE: "IDLE",
CART: "CART", CART: "CART",
SHIPPING: "SHIPPING", SHIPPING: "SHIPPING",
PAYMENT: "PAYMENT",
WAITING_WEBHOOKS: "WAITING_WEBHOOKS",
AWAITING_HUMAN: "AWAITING_HUMAN", // Esperando respuesta de un humano 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 // Intents válidos por estado
export const INTENTS_BY_STATE = Object.freeze({ export const INTENTS_BY_STATE = Object.freeze({
[ConversationState.IDLE]: [ [ConversationState.IDLE]: [
"greeting", "add_to_cart", "browse", "price_query", "recommend", "other" "greeting", "add_to_cart", "browse", "price_query", "recommend", "other",
], ],
[ConversationState.CART]: [ [ConversationState.CART]: [
"add_to_cart", "remove_from_cart", "browse", "price_query", "add_to_cart", "remove_from_cart", "browse", "price_query",
"recommend", "view_cart", "confirm_order", "other" "recommend", "view_cart", "confirm_order", "other",
], ],
[ConversationState.SHIPPING]: [ [ConversationState.SHIPPING]: [
"provide_address", "select_shipping", "add_to_cart", "view_cart", "other" "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"
], ],
[ConversationState.AWAITING_HUMAN]: [ [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 = "") { export function shouldReturnToCart(state, nlu, text = "") {
if (state === ConversationState.CART || state === ConversationState.IDLE) { 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 // En SHIPPING, números solos son selecciones de opción, no productos
const isCheckoutState = state === ConversationState.SHIPPING || state === ConversationState.PAYMENT; const isCheckoutState = state === ConversationState.SHIPPING;
const isJustNumber = /^\s*\d+([.,]\d+)?\s*$/.test(text || ""); const isJustNumber = /^\s*\d+([.,]\d+)?\s*$/.test(text || "");
if (isCheckoutState && isJustNumber) { if (isCheckoutState && isJustNumber) {
return false; // No redirigir, es una selección de opción return false;
} }
const intent = nlu?.intent; 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)) { if (["add_to_cart", "browse", "price_query", "recommend"].includes(intent)) {
// Verificar que hay un producto real mencionado
const hasRealProduct = nlu?.entities?.product_query && const hasRealProduct = nlu?.entities?.product_query &&
String(nlu.entities.product_query).trim().length > 2; String(nlu.entities.product_query).trim().length > 2;
const hasRealItems = Array.isArray(nlu?.entities?.items) && const hasRealItems = Array.isArray(nlu?.entities?.items) &&
@@ -66,11 +56,9 @@ export function shouldReturnToCart(state, nlu, text = "") {
if (hasRealProduct || hasRealItems) { if (hasRealProduct || hasRealItems) {
return true; return true;
} }
// Si no hay producto real, no redirigir (probablemente es una selección numérica mal interpretada)
return false; 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 (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; 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; 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. * Deriva el siguiente estado basado en el contexto y signals.
* *
* signals: { * signals: {
* confirm_order: boolean, // Usuario quiere cerrar pedido * confirm_order: boolean, // Usuario quiere cerrar pedido
* shipping_selected: boolean, // Usuario seleccionó delivery/pickup * shipping_completed: boolean, // Shipping info quedó completa (gatilla create_order + IDLE)
* payment_selected: boolean, // Usuario seleccionó método de pago
* return_to_cart: boolean, // Forzar volver a CART * return_to_cart: boolean, // Forzar volver a CART
* } * }
*/ */
export function deriveNextState(prevState, order = {}, signals = {}) { export function deriveNextState(prevState, order = {}, signals = {}) {
// Regla 0: Si se fuerza volver a CART
if (signals.return_to_cart) { if (signals.return_to_cart) {
return ConversationState.CART; return ConversationState.CART;
} }
// Regla 1: Si está pagado, completado (volver a IDLE para nueva conversación) // Si la orden ya fue creada en Woo, volvemos a IDLE para nueva conversación.
if (isPaid(order)) { if (order?.woo_order_id) {
return ConversationState.IDLE; 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 // Desde IDLE
if (prevState === ConversationState.IDLE) { if (prevState === ConversationState.IDLE) {
// Si hay cart o pending items, ir a CART
if (hasCartItems(order) || hasPendingItems(order)) { if (hasCartItems(order) || hasPendingItems(order)) {
return ConversationState.CART; return ConversationState.CART;
} }
@@ -145,11 +117,9 @@ export function deriveNextState(prevState, order = {}, signals = {}) {
// Desde CART // Desde CART
if (prevState === ConversationState.CART) { if (prevState === ConversationState.CART) {
// Si hay pending items sin resolver, quedarse en CART
if (hasPendingItems(order)) { if (hasPendingItems(order)) {
return ConversationState.CART; return ConversationState.CART;
} }
// Si usuario confirma orden y hay items en cart, ir a SHIPPING
if (signals.confirm_order && hasCartItems(order)) { if (signals.confirm_order && hasCartItems(order)) {
return ConversationState.SHIPPING; return ConversationState.SHIPPING;
} }
@@ -158,69 +128,38 @@ export function deriveNextState(prevState, order = {}, signals = {}) {
// Desde SHIPPING // Desde SHIPPING
if (prevState === ConversationState.SHIPPING) { if (prevState === ConversationState.SHIPPING) {
// Si ya tiene shipping info completa, ir a PAYMENT // Una vez completado el shipping, la orden se crea y vuelve a IDLE.
if (hasShippingInfo(order)) { if (signals.shipping_completed || hasShippingInfo(order)) {
return ConversationState.PAYMENT; return ConversationState.IDLE;
} }
return ConversationState.SHIPPING; 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; return prevState || ConversationState.IDLE;
} }
// Transiciones permitidas (para validación)
const ALLOWED = Object.freeze({ const ALLOWED = Object.freeze({
[ConversationState.IDLE]: [ [ConversationState.IDLE]: [
ConversationState.IDLE, ConversationState.IDLE,
ConversationState.CART, ConversationState.CART,
ConversationState.AWAITING_HUMAN, // Puede ir a esperar humano ConversationState.AWAITING_HUMAN,
], ],
[ConversationState.CART]: [ [ConversationState.CART]: [
ConversationState.CART, ConversationState.CART,
ConversationState.SHIPPING, ConversationState.SHIPPING,
ConversationState.IDLE, // Si vacía el carrito ConversationState.IDLE,
ConversationState.AWAITING_HUMAN, // Producto no encontrado ConversationState.AWAITING_HUMAN,
], ],
[ConversationState.SHIPPING]: [ [ConversationState.SHIPPING]: [
ConversationState.SHIPPING, ConversationState.SHIPPING,
ConversationState.PAYMENT, ConversationState.IDLE,
ConversationState.CART, // Volver a agregar productos ConversationState.CART,
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.AWAITING_HUMAN, ConversationState.AWAITING_HUMAN,
], ],
[ConversationState.AWAITING_HUMAN]: [ [ConversationState.AWAITING_HUMAN]: [
ConversationState.AWAITING_HUMAN, // Sigue esperando ConversationState.AWAITING_HUMAN,
ConversationState.CART, // Humano respondió, volver a procesar ConversationState.CART,
ConversationState.IDLE, // Humano canceló ConversationState.IDLE,
], ],
}); });
@@ -237,7 +176,5 @@ export function safeNextState(prevState, order, signals) {
const desired = deriveNextState(prevState, order, signals); const desired = deriveNextState(prevState, order, signals);
const v = validateTransition(prevState, desired); const v = validateTransition(prevState, desired);
if (v.ok) return { next_state: desired, validation: v }; 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 } }; 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 { describe, it, expect } from 'vitest';
import { import {
@@ -11,555 +11,210 @@ import {
hasPendingItems, hasPendingItems,
hasReadyPendingItems, hasReadyPendingItems,
hasShippingInfo, hasShippingInfo,
hasPaymentInfo,
isPaid,
deriveNextState, deriveNextState,
validateTransition, validateTransition,
safeNextState, safeNextState,
} from './fsm.js'; } from './fsm.js';
// ─────────────────────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────────────────────
describe('ConversationState', () => { describe('ConversationState', () => {
it('tiene todos los estados definidos', () => { it('tiene los estados del flujo (sin payment/waiting)', () => {
expect(ConversationState.IDLE).toBe('IDLE'); expect(ConversationState.IDLE).toBe('IDLE');
expect(ConversationState.CART).toBe('CART'); expect(ConversationState.CART).toBe('CART');
expect(ConversationState.SHIPPING).toBe('SHIPPING'); 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.AWAITING_HUMAN).toBe('AWAITING_HUMAN');
expect(ConversationState.PAYMENT).toBeUndefined();
expect(ConversationState.WAITING_WEBHOOKS).toBeUndefined();
}); });
it('ALL_STATES contiene todos', () => { it('ALL_STATES contiene 4 estados', () => {
expect(ALL_STATES).toContain('IDLE'); expect(ALL_STATES).toEqual(expect.arrayContaining(['IDLE', 'CART', 'SHIPPING', 'AWAITING_HUMAN']));
expect(ALL_STATES).toContain('CART'); expect(ALL_STATES).toHaveLength(4);
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('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.IDLE]).toContain('greeting');
expect(INTENTS_BY_STATE[ConversationState.CART]).toContain('add_to_cart'); expect(INTENTS_BY_STATE[ConversationState.CART]).toContain('add_to_cart');
expect(INTENTS_BY_STATE[ConversationState.SHIPPING]).toContain('provide_address'); expect(INTENTS_BY_STATE[ConversationState.SHIPPING]).toContain('provide_address');
expect(INTENTS_BY_STATE[ConversationState.PAYMENT]).toContain('select_payment');
}); });
}); });
// ─────────────────────────────────────────────────────────────
// hasCartItems
// ─────────────────────────────────────────────────────────────
describe('hasCartItems', () => { describe('hasCartItems', () => {
it('retorna true si cart tiene items', () => { it('retorna true si cart tiene items', () => {
const order = { cart: [{ woo_id: 1, qty: 1 }] }; expect(hasCartItems({ cart: [{ woo_id: 1, qty: 1 }] })).toBe(true);
expect(hasCartItems(order)).toBe(true);
}); });
it('retorna false si cart está vacío', () => { it('retorna false si cart está vacío', () => {
const order = { cart: [] }; expect(hasCartItems({ cart: [] })).toBe(false);
expect(hasCartItems(order)).toBe(false);
}); });
it('retorna false si cart es undefined', () => { it('retorna false si cart es undefined', () => {
const order = {}; expect(hasCartItems({})).toBe(false);
expect(hasCartItems(order)).toBe(false);
}); });
it('retorna false si order es null/undefined', () => {
it('retorna false si order es null', () => {
expect(hasCartItems(null)).toBe(false); expect(hasCartItems(null)).toBe(false);
});
it('retorna false si order es undefined', () => {
expect(hasCartItems(undefined)).toBe(false); expect(hasCartItems(undefined)).toBe(false);
}); });
}); });
// ─────────────────────────────────────────────────────────────
// hasPendingItems
// ─────────────────────────────────────────────────────────────
describe('hasPendingItems', () => { describe('hasPendingItems', () => {
it('retorna true si hay NEEDS_TYPE', () => { it('retorna true si hay NEEDS_TYPE', () => {
const order = { pending: [{ status: 'NEEDS_TYPE' }] }; expect(hasPendingItems({ pending: [{ status: 'NEEDS_TYPE' }] })).toBe(true);
expect(hasPendingItems(order)).toBe(true);
}); });
it('retorna true si hay NEEDS_QUANTITY', () => { it('retorna true si hay NEEDS_QUANTITY', () => {
const order = { pending: [{ status: 'NEEDS_QUANTITY' }] }; expect(hasPendingItems({ pending: [{ status: 'NEEDS_QUANTITY' }] })).toBe(true);
expect(hasPendingItems(order)).toBe(true);
}); });
it('retorna false si solo hay READY', () => { it('retorna false si solo hay READY', () => {
const order = { pending: [{ status: 'READY' }] }; expect(hasPendingItems({ pending: [{ status: 'READY' }] })).toBe(false);
expect(hasPendingItems(order)).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', () => { it('detecta entre múltiples items', () => {
const order = { expect(hasPendingItems({ pending: [{ status: 'READY' }, { status: 'NEEDS_TYPE' }] })).toBe(true);
pending: [
{ status: 'READY' },
{ status: 'NEEDS_TYPE' },
]
};
expect(hasPendingItems(order)).toBe(true);
}); });
}); });
// ─────────────────────────────────────────────────────────────
// hasReadyPendingItems
// ─────────────────────────────────────────────────────────────
describe('hasReadyPendingItems', () => { describe('hasReadyPendingItems', () => {
it('retorna true si hay READY', () => { it('retorna true si hay READY', () => {
const order = { pending: [{ status: 'READY' }] }; expect(hasReadyPendingItems({ pending: [{ status: 'READY' }] })).toBe(true);
expect(hasReadyPendingItems(order)).toBe(true);
}); });
it('retorna false si no hay READY', () => { it('retorna false si no hay READY', () => {
const order = { pending: [{ status: 'NEEDS_TYPE' }] }; expect(hasReadyPendingItems({ pending: [{ status: 'NEEDS_TYPE' }] })).toBe(false);
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);
}); });
}); });
// ─────────────────────────────────────────────────────────────
// hasShippingInfo
// ─────────────────────────────────────────────────────────────
describe('hasShippingInfo', () => { describe('hasShippingInfo', () => {
it('retorna true para pickup (no necesita dirección)', () => { it('retorna true para pickup (no necesita dirección)', () => {
const order = { is_delivery: false }; expect(hasShippingInfo({ is_delivery: false })).toBe(true);
expect(hasShippingInfo(order)).toBe(true);
}); });
it('retorna true para delivery con dirección', () => { it('retorna true para delivery con dirección', () => {
const order = { is_delivery: true, shipping_address: 'Calle Falsa 123' }; expect(hasShippingInfo({ is_delivery: true, shipping_address: 'Calle Falsa 123' })).toBe(true);
expect(hasShippingInfo(order)).toBe(true);
}); });
it('retorna false para delivery sin dirección', () => { it('retorna false para delivery sin dirección', () => {
const order = { is_delivery: true, shipping_address: null }; expect(hasShippingInfo({ is_delivery: true, shipping_address: null })).toBe(false);
expect(hasShippingInfo(order)).toBe(false);
}); });
it('retorna false si is_delivery es null', () => { it('retorna false si is_delivery es null', () => {
const order = { is_delivery: null }; expect(hasShippingInfo({ is_delivery: null })).toBe(false);
expect(hasShippingInfo(order)).toBe(false);
});
it('retorna false para order vacío', () => {
expect(hasShippingInfo({})).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('shouldReturnToCart', () => {
describe('no redirige si ya está en CART o IDLE', () => { it('no redirige si ya está en CART o IDLE', () => {
it('retorna false en CART', () => {
const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } }; const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } };
expect(shouldReturnToCart(ConversationState.CART, nlu)).toBe(false); 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); expect(shouldReturnToCart(ConversationState.IDLE, nlu)).toBe(false);
}); });
});
describe('redirige desde otros estados', () => { it('redirige add_to_cart desde SHIPPING con producto real', () => {
it('redirige add_to_cart desde SHIPPING', () => {
const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } }; const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true); 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', () => { it('redirige browse desde SHIPPING', () => {
const nlu = { intent: 'browse', entities: { product_query: 'carnes' } }; const nlu = { intent: 'browse', entities: { product_query: 'carnes' } };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true); expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
}); });
});
describe('no redirige números solos en checkout', () => { it('no redirige números solos en SHIPPING (selección de opción)', () => {
it('no redirige "1" en SHIPPING', () => {
const nlu = { intent: 'add_to_cart', entities: {} }; const nlu = { intent: 'add_to_cart', entities: {} };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, '1')).toBe(false); 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); expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, '1.5')).toBe(false);
}); });
});
describe('requiere producto real', () => { it('no redirige sin producto real', () => {
it('no redirige sin product_query', () => {
const nlu = { intent: 'add_to_cart', entities: {} }; const nlu = { intent: 'add_to_cart', entities: {} };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, 'algo')).toBe(false); expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, 'algo')).toBe(false);
expect(shouldReturnToCart(ConversationState.SHIPPING, { intent: 'add_to_cart', entities: { product_query: 'ab' } })).toBe(false);
}); });
it('no redirige con product_query muy corto', () => { it('redirige con items array que tenga producto real', () => {
const nlu = { intent: 'add_to_cart', entities: { product_query: 'ab' } }; const nlu = { intent: 'add_to_cart', entities: { items: [{ product_query: 'provoleta' }] } };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).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); expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
}); });
});
}); });
// ─────────────────────────────────────────────────────────────
// deriveNextState
// ─────────────────────────────────────────────────────────────
describe('deriveNextState', () => { describe('deriveNextState', () => {
describe('return_to_cart signal', () => { it('return_to_cart fuerza CART', () => {
it('fuerza CART si return_to_cart', () => { expect(deriveNextState(ConversationState.SHIPPING, {}, { return_to_cart: true })).toBe(ConversationState.CART);
const result = deriveNextState(
ConversationState.PAYMENT,
{},
{ return_to_cart: true }
);
expect(result).toBe(ConversationState.CART);
});
}); });
describe('pagado', () => { it('IDLE va a CART si hay cart o pending', () => {
it('va a IDLE si está pagado', () => { expect(deriveNextState(ConversationState.IDLE, { cart: [{ woo_id: 1 }], pending: [] }, {})).toBe(ConversationState.CART);
const order = { is_paid: true }; expect(deriveNextState(ConversationState.IDLE, { cart: [], pending: [{ status: 'NEEDS_TYPE' }] }, {})).toBe(ConversationState.CART);
const result = deriveNextState(ConversationState.WAITING_WEBHOOKS, order, {});
expect(result).toBe(ConversationState.IDLE);
});
}); });
describe('esperando pago', () => { it('IDLE queda en IDLE si vacío', () => {
it('va a WAITING_WEBHOOKS si tiene woo_order_id', () => { expect(deriveNextState(ConversationState.IDLE, { cart: [], pending: [] }, {})).toBe(ConversationState.IDLE);
const order = { woo_order_id: 123, is_paid: false };
const result = deriveNextState(ConversationState.PAYMENT, order, {});
expect(result).toBe(ConversationState.WAITING_WEBHOOKS);
});
}); });
describe('IDLE -> CART', () => { it('CART queda en CART con pending', () => {
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);
});
});
describe('CART -> SHIPPING', () => {
it('queda en CART si hay pending items', () => {
const order = { cart: [{ woo_id: 1 }], pending: [{ status: 'NEEDS_TYPE' }] }; const order = { cart: [{ woo_id: 1 }], pending: [{ status: 'NEEDS_TYPE' }] };
const result = deriveNextState(ConversationState.CART, order, { confirm_order: true }); expect(deriveNextState(ConversationState.CART, order, { confirm_order: true })).toBe(ConversationState.CART);
expect(result).toBe(ConversationState.CART);
}); });
it('va a SHIPPING con confirm_order y cart items', () => { it('CART → SHIPPING con confirm + items', () => {
const order = { cart: [{ woo_id: 1 }], pending: [] }; const order = { cart: [{ woo_id: 1 }], pending: [] };
const result = deriveNextState(ConversationState.CART, order, { confirm_order: true }); expect(deriveNextState(ConversationState.CART, order, { confirm_order: true })).toBe(ConversationState.SHIPPING);
expect(result).toBe(ConversationState.SHIPPING);
}); });
it('queda en CART sin confirm_order', () => { it('SHIPPING → IDLE cuando shipping queda completo (orden creada offline)', () => {
const order = { cart: [{ woo_id: 1 }], pending: [] }; expect(deriveNextState(ConversationState.SHIPPING, { is_delivery: false }, {})).toBe(ConversationState.IDLE);
const result = deriveNextState(ConversationState.CART, order, {}); expect(deriveNextState(ConversationState.SHIPPING, { is_delivery: true, shipping_address: 'Calle 1' }, {})).toBe(ConversationState.IDLE);
expect(result).toBe(ConversationState.CART);
});
}); });
describe('SHIPPING -> PAYMENT', () => { it('SHIPPING queda en SHIPPING sin info completa', () => {
it('va a PAYMENT con shipping info (pickup)', () => { expect(deriveNextState(ConversationState.SHIPPING, { is_delivery: true }, {})).toBe(ConversationState.SHIPPING);
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)', () => { it('woo_order_id existente vuelve a IDLE (orden ya creada)', () => {
const order = { is_delivery: true, shipping_address: 'Calle 123' }; expect(deriveNextState(ConversationState.SHIPPING, { woo_order_id: 999 }, {})).toBe(ConversationState.IDLE);
const result = deriveNextState(ConversationState.SHIPPING, order, {});
expect(result).toBe(ConversationState.PAYMENT);
}); });
it('queda en SHIPPING sin info completa', () => { it('default sin estado previo retorna IDLE', () => {
const order = { is_delivery: true, shipping_address: null }; expect(deriveNextState(null, {}, {})).toBe(ConversationState.IDLE);
const result = deriveNextState(ConversationState.SHIPPING, order, {});
expect(result).toBe(ConversationState.SHIPPING);
});
});
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);
});
});
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);
});
});
describe('default', () => {
it('retorna IDLE si no hay estado previo', () => {
const result = deriveNextState(null, {}, {});
expect(result).toBe(ConversationState.IDLE);
});
}); });
}); });
// ─────────────────────────────────────────────────────────────
// validateTransition
// ─────────────────────────────────────────────────────────────
describe('validateTransition', () => { describe('validateTransition', () => {
describe('transiciones válidas', () => { it('IDLE → CART es válida', () => {
it('IDLE -> IDLE es válido', () => { expect(validateTransition(ConversationState.IDLE, ConversationState.CART).ok).toBe(true);
const result = validateTransition(ConversationState.IDLE, ConversationState.IDLE);
expect(result.ok).toBe(true);
}); });
it('CART → SHIPPING es válida', () => {
it('IDLE -> CART es válido', () => { expect(validateTransition(ConversationState.CART, ConversationState.SHIPPING).ok).toBe(true);
const result = validateTransition(ConversationState.IDLE, ConversationState.CART);
expect(result.ok).toBe(true);
}); });
it('SHIPPING → IDLE es válida (cierre de orden)', () => {
it('CART -> SHIPPING es válido', () => { expect(validateTransition(ConversationState.SHIPPING, ConversationState.IDLE).ok).toBe(true);
const result = validateTransition(ConversationState.CART, ConversationState.SHIPPING);
expect(result.ok).toBe(true);
}); });
it('SHIPPING → CART (volver a agregar) es válida', () => {
it('SHIPPING -> PAYMENT es válido', () => { expect(validateTransition(ConversationState.SHIPPING, ConversationState.CART).ok).toBe(true);
const result = validateTransition(ConversationState.SHIPPING, ConversationState.PAYMENT);
expect(result.ok).toBe(true);
}); });
it('IDLE → SHIPPING es inválida (debe pasar por CART)', () => {
it('PAYMENT -> WAITING_WEBHOOKS es válido', () => { const r = validateTransition(ConversationState.IDLE, ConversationState.SHIPPING);
const result = validateTransition(ConversationState.PAYMENT, ConversationState.WAITING_WEBHOOKS); expect(r.ok).toBe(false);
expect(result.ok).toBe(true); expect(r.reason).toBe('invalid_transition');
}); });
it('SHIPPING -> CART (volver) es válido', () => {
const result = validateTransition(ConversationState.SHIPPING, ConversationState.CART);
expect(result.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);
});
});
describe('estados desconocidos', () => {
it('estado previo desconocido', () => { it('estado previo desconocido', () => {
const result = validateTransition('UNKNOWN', ConversationState.CART); const r = validateTransition('UNKNOWN', ConversationState.CART);
expect(result.ok).toBe(false); expect(r.ok).toBe(false);
expect(result.reason).toBe('unknown_prev_state'); expect(r.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');
});
});
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('null se trata como IDLE', () => {
expect(validateTransition(null, ConversationState.CART).ok).toBe(true);
expect(validateTransition(ConversationState.IDLE, null).ok).toBe(true);
}); });
}); });
// ─────────────────────────────────────────────────────────────
// safeNextState
// ─────────────────────────────────────────────────────────────
describe('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 order = { cart: [{ woo_id: 1 }], pending: [] };
const result = safeNextState(ConversationState.CART, order, { confirm_order: true }); const r = safeNextState(ConversationState.CART, order, { confirm_order: true });
expect(r.next_state).toBe(ConversationState.SHIPPING);
expect(result.next_state).toBe(ConversationState.SHIPPING); expect(r.validation.ok).toBe(true);
expect(result.validation.ok).toBe(true);
}); });
it('fuerza CART si transición inválida', () => { it('flow IDLE → CART → SHIPPING → IDLE', () => {
// Forzar una situación donde deriveNextState retornaría un estado inválido let r = safeNextState(ConversationState.IDLE, { cart: [{ woo_id: 1 }], pending: [] }, {});
// Esto es difícil de provocar porque deriveNextState ya es bastante seguro expect(r.next_state).toBe(ConversationState.CART);
// Pero podemos verificar que la lógica de fallback existe
const order = {};
const result = safeNextState(ConversationState.IDLE, order, {});
// Debería quedarse en IDLE (transición válida) r = safeNextState(ConversationState.CART, { cart: [{ woo_id: 1 }], pending: [] }, { confirm_order: true });
expect(result.next_state).toBe(ConversationState.IDLE); expect(r.next_state).toBe(ConversationState.SHIPPING);
expect(result.validation.ok).toBe(true);
});
it('incluye validation en resultado', () => { r = safeNextState(ConversationState.SHIPPING, { is_delivery: false }, { shipping_completed: true });
const order = { is_delivery: false }; expect(r.next_state).toBe(ConversationState.IDLE);
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);
}); });
}); });

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({ export const enqueueWooCreateOrder = assign({
pending_actions: ({ context }) => [ pending_actions: ({ context }) => [
...(context.pending_actions || []), ...(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 }) => ({ export const replyShippingAddressRecorded = makeReplyAction("shipping.address_recorded", ({ context }) => ({
address: context.order?.shipping_address || "", address: context.order?.shipping_address || "",
})); }));
export const replyShippingPickupToPayment = makeReplyAction("shipping.pickup_to_payment"); export const replyOrderConfirmed = makeReplyAction("order.confirmed");
export const replyPaymentAskMethod = makeReplyAction("payment.ask_method");
export const replyPaymentConfirmed = makeReplyAction("payment.confirmed");
export const replyWaitingInProgress = makeReplyAction("waiting.in_progress");
// View cart: necesita armar reply con cartDisplay + ask_more // View cart: necesita armar reply con cartDisplay + ask_more
export const replyViewCart = assign({ export const replyViewCart = assign({
@@ -372,7 +361,6 @@ export const actions = {
capturePeopleCount, capturePeopleCount,
setShipping, setShipping,
setAddress, setAddress,
setPayment,
enqueueWooCreateOrder, enqueueWooCreateOrder,
enqueueAddToCart, enqueueAddToCart,
enqueueRemoveFromCart, enqueueRemoveFromCart,
@@ -390,10 +378,7 @@ export const actions = {
replyShippingAskMethod, replyShippingAskMethod,
replyShippingAskAddress, replyShippingAskAddress,
replyShippingAddressRecorded, replyShippingAddressRecorded,
replyShippingPickupToPayment, replyOrderConfirmed,
replyPaymentAskMethod,
replyPaymentConfirmed,
replyWaitingInProgress,
replyViewCart, replyViewCart,
replyOptions, replyOptions,
replyAskQuantity, replyAskQuantity,

View File

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

View File

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

View File

@@ -168,18 +168,20 @@ describe("machine — checkout flow", () => {
a.stop(); 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(); const a = makeActor();
a.start(); a.start();
await buildCartWithItem(a); await buildCartWithItem(a);
a.send({ type: "CONFIRM_ORDER" }); a.send({ type: "CONFIRM_ORDER" });
a.send({ type: "SELECT_SHIPPING", method: "pickup" }); a.send({ type: "SELECT_SHIPPING", method: "pickup" });
expect(a.getSnapshot().value).toBe("payment"); const snap = a.getSnapshot();
expect(a.getSnapshot().context.order.is_delivery).toBe(false); 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(); 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(); const a = makeActor();
a.start(); a.start();
await buildCartWithItem(a); await buildCartWithItem(a);
@@ -187,21 +189,10 @@ describe("machine — checkout flow", () => {
a.send({ type: "SELECT_SHIPPING", method: "delivery" }); a.send({ type: "SELECT_SHIPPING", method: "delivery" });
expect(a.getSnapshot().value).toBe("shipping"); expect(a.getSnapshot().value).toBe("shipping");
a.send({ type: "PROVIDE_ADDRESS", address: "Corrientes 1234" }); 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(); const snap = a.getSnapshot();
expect(snap.value).toBe("waiting"); expect(snap.value).toBe("idle");
expect(snap.context.pending_actions.some((a) => a.type === "create_order")).toBe(true); expect(snap.context.order.shipping_address).toBe("Corrientes 1234");
expect(snap.context.pending_actions.some((x) => x.type === "create_order")).toBe(true);
a.stop(); a.stop();
}); });
}); });
@@ -229,11 +220,9 @@ describe("machine — snapshot persistence", () => {
}); });
describe("xstateToLegacyState", () => { 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("idle")).toBe("IDLE");
expect(xstateToLegacyState("shipping")).toBe("SHIPPING"); expect(xstateToLegacyState("shipping")).toBe("SHIPPING");
expect(xstateToLegacyState("payment")).toBe("PAYMENT");
expect(xstateToLegacyState("waiting")).toBe("WAITING_WEBHOOKS");
}); });
it("maps cart sub-states to CART", () => { it("maps cart sub-states to CART", () => {
expect(xstateToLegacyState({ cart: "idle" })).toBe("CART"); expect(xstateToLegacyState({ cart: "idle" })).toBe("CART");

View File

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

View File

@@ -132,7 +132,6 @@ function buildContextPatch(snapshot, recentReplies, finalTemplateId, persistedSn
unit: p.unit, unit: p.unit,
status: p.status?.toLowerCase() || "needs_type", status: p.status?.toLowerCase() || "needs_type",
})), })),
payment_method: order.payment_type,
shipping_method: order.is_delivery === true ? "delivery" shipping_method: order.is_delivery === true ? "delivery"
: order.is_delivery === false ? "pickup" : null, : order.is_delivery === false ? "pickup" : null,
delivery_address: order.shipping_address ? { text: order.shipping_address } : 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 { greetingNlu } from "./specialists/greeting.js";
import { ordersNlu } from "./specialists/orders.js"; import { ordersNlu } from "./specialists/orders.js";
import { shippingNlu } from "./specialists/shipping.js"; import { shippingNlu } from "./specialists/shipping.js";
import { paymentNlu } from "./specialists/payment.js";
import { browseNlu } from "./specialists/browse.js"; import { browseNlu } from "./specialists/browse.js";
import { createEmptyNlu } from "./schemas.js"; import { createEmptyNlu } from "./schemas.js";
@@ -50,7 +49,7 @@ export async function llmNluModular({ input, tenantId, storeConfig = {} } = {})
// Casos donde podemos saltar el router: // Casos donde podemos saltar el router:
// - Saludos simples // - Saludos simples
// - Números solos (1, 2) en estados SHIPPING/PAYMENT // - Números solos (1, 2) en estado SHIPPING
// - Patrones muy claros // - Patrones muy claros
const skipRouter = shouldSkipRouter(text, state, quickDomain); const skipRouter = shouldSkipRouter(text, state, quickDomain);
@@ -86,11 +85,6 @@ export async function llmNluModular({ input, tenantId, storeConfig = {} } = {})
result = await shippingNlu({ tenantId, text, storeConfig }); result = await shippingNlu({ tenantId, text, storeConfig });
break; break;
case "payment":
routing.specialist_used = "payment";
result = await paymentNlu({ tenantId, text, storeConfig });
break;
case "browse": case "browse":
routing.specialist_used = "browse"; routing.specialist_used = "browse";
result = await browseNlu({ tenantId, text, storeConfig }); result = await browseNlu({ tenantId, text, storeConfig });
@@ -145,15 +139,8 @@ function shouldSkipRouter(text, state, quickDomain) {
return true; return true;
} }
// Números solos en estados específicos // Números solos en estado SHIPPING (selección 1/2)
if (/^[12]$/.test(t)) { if (/^[12]$/.test(t) && state === "SHIPPING") {
if (state === "SHIPPING" || state === "PAYMENT") {
return true;
}
}
// "efectivo" o "tarjeta" solos en estado PAYMENT
if (state === "PAYMENT" && /^(efectivo|tarjeta|link|transfer)$/i.test(t)) {
return true; return true;
} }

View File

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

View File

@@ -103,7 +103,7 @@ function detectDomainByPatterns(text, state) {
return "greeting"; 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 (state === "SHIPPING") {
if (/delivery|env[ií]o|retiro|buscar|sucursal|direcci[oó]n/i.test(t)) { if (/delivery|env[ií]o|retiro|buscar|sucursal|direcci[oó]n/i.test(t)) {
return "shipping"; 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 // Orders patterns
const orderPatterns = [ const orderPatterns = [
/\b(quiero|dame|anotame|poneme|agregame|necesito)\b/i, /\b(quiero|dame|anotame|poneme|agregame|necesito)\b/i,
@@ -153,15 +143,9 @@ function detectDomainByPatterns(text, state) {
return "shipping"; return "shipping";
} }
// Payment patterns
if (/\b(efectivo|tarjeta|link|transfer|mercadopago)\b/i.test(t)) {
return "payment";
}
// Default basado en estado // Default basado en estado
if (state === "CART") return "orders"; if (state === "CART") return "orders";
if (state === "SHIPPING") return "shipping"; if (state === "SHIPPING") return "shipping";
if (state === "PAYMENT") return "payment";
return "other"; return "other";
} }

View File

@@ -18,7 +18,7 @@ export const RouterSchema = {
properties: { properties: {
domain: { domain: {
type: "string", 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); 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 // Schema: Browse
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
@@ -164,7 +142,7 @@ export const UnifiedNluSchema = {
type: "string", type: "string",
enum: [ enum: [
"price_query", "browse", "add_to_cart", "remove_from_cart", "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" "provide_address", "greeting", "recommend", "view_cart", "other"
], ],
}, },
@@ -194,7 +172,6 @@ export const UnifiedNluSchema = {
}, },
attributes: { type: "array", items: { type: "string" } }, attributes: { type: "array", items: { type: "string" } },
preparation: { 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" }] }, shipping_method: { anyOf: [{ type: "string", enum: ["delivery", "pickup"] }, { type: "null" }] },
address: { anyOf: [{ type: "string" }, { type: "null" }] }, address: { anyOf: [{ type: "string" }, { type: "null" }] },
items: { items: {
@@ -267,7 +244,6 @@ export function createEmptyNlu() {
selection: null, selection: null,
attributes: [], attributes: [],
preparation: [], preparation: [],
payment_method: null,
shipping_method: null, shipping_method: null,
address: null, address: null,
items: 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: { properties: {
intent: { intent: {
type: "string", 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 }, confidence: { type: "number", minimum: 0, maximum: 1 },
language: { type: "string" }, language: { type: "string" },
@@ -104,8 +104,7 @@ const NluV3JsonSchema = {
}, },
attributes: { type: "array", items: { type: "string" } }, attributes: { type: "array", items: { type: "string" } },
preparation: { type: "array", items: { type: "string" } }, preparation: { type: "array", items: { type: "string" } },
// Checkout: método de pago, envío, dirección // Checkout: envío y dirección. (El bot no maneja pagos.)
payment_method: { anyOf: [{ type: "string", enum: ["cash", "link"] }, { type: "null" }] },
shipping_method: { anyOf: [{ type: "string", enum: ["delivery", "pickup"] }, { type: "null" }] }, shipping_method: { anyOf: [{ type: "string", enum: ["delivery", "pickup"] }, { type: "null" }] },
address: { anyOf: [{ type: "string" }, { type: "null" }] }, address: { anyOf: [{ type: "string" }, { type: "null" }] },
// Soporte para múltiples productos en un mensaje // Soporte para múltiples productos en un mensaje
@@ -236,8 +235,7 @@ function normalizeNluOutput(parsed, input) {
selection: entities.selection ?? null, selection: entities.selection ?? null,
attributes: Array.isArray(entities.attributes) ? entities.attributes : [], attributes: Array.isArray(entities.attributes) ? entities.attributes : [],
preparation: Array.isArray(entities.preparation) ? entities.preparation : [], preparation: Array.isArray(entities.preparation) ? entities.preparation : [],
// Checkout entities (opcionales) // Checkout entities (opcionales). El bot NO maneja pagos.
payment_method: entities.payment_method ?? null,
shipping_method: entities.shipping_method ?? null, shipping_method: entities.shipping_method ?? null,
address: entities.address ?? null, address: entities.address ?? null,
items: normalizedItems, items: normalizedItems,
@@ -282,7 +280,6 @@ function nluV3Fallback() {
selection: null, selection: null,
attributes: [], attributes: [],
preparation: [], preparation: [],
payment_method: null,
shipping_method: null, shipping_method: null,
address: null, address: null,
items: 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" + "- 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" + "- 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" + "- 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" + "- 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" + "- 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" + "- 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. * Modelo unificado de orden para el contexto de conversación.
* *
* Reemplaza: order_basket, pending_items, pending_clarification, pending_item, * 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 // Status de items pendientes
@@ -19,11 +22,9 @@ export function createEmptyOrder() {
return { return {
cart: [], // Items confirmados: [{ woo_id, qty, unit, name, price }] cart: [], // Items confirmados: [{ woo_id, qty, unit, name, price }]
pending: [], // Items por clarificar pending: [], // Items por clarificar
payment_type: null, // "link" | "cash" | null
is_delivery: null, // true | false | null is_delivery: null, // true | false | null
shipping_address: null, shipping_address: null,
woo_order_id: null, woo_order_id: null,
is_paid: false,
}; };
} }
@@ -294,14 +295,10 @@ export function migrateOldContext(ctx) {
} }
// Migrar checkout info // Migrar checkout info
order.payment_type = ctx.payment_method || null;
order.is_delivery = ctx.shipping_method === "delivery" ? true : order.is_delivery = ctx.shipping_method === "delivery" ? true :
ctx.shipping_method === "pickup" ? false : null; ctx.shipping_method === "pickup" ? false : null;
order.shipping_address = ctx.delivery_address?.text || ctx.address?.text || ctx.address_text || 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.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; return order;
} }

View File

@@ -23,15 +23,15 @@ import {
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
describe('createEmptyOrder', () => { describe('createEmptyOrder', () => {
it('crea orden con estructura correcta', () => { it('crea orden con estructura correcta (sin payment)', () => {
const order = createEmptyOrder(); const order = createEmptyOrder();
expect(order).toHaveProperty('cart'); expect(order).toHaveProperty('cart');
expect(order).toHaveProperty('pending'); expect(order).toHaveProperty('pending');
expect(order).toHaveProperty('payment_type');
expect(order).toHaveProperty('is_delivery'); expect(order).toHaveProperty('is_delivery');
expect(order).toHaveProperty('shipping_address'); expect(order).toHaveProperty('shipping_address');
expect(order).toHaveProperty('woo_order_id'); 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', () => { it('inicializa arrays vacíos', () => {
@@ -40,13 +40,11 @@ describe('createEmptyOrder', () => {
expect(order.pending).toEqual([]); expect(order.pending).toEqual([]);
}); });
it('inicializa valores null/false', () => { it('inicializa valores null', () => {
const order = createEmptyOrder(); const order = createEmptyOrder();
expect(order.payment_type).toBeNull();
expect(order.is_delivery).toBeNull(); expect(order.is_delivery).toBeNull();
expect(order.shipping_address).toBeNull(); expect(order.shipping_address).toBeNull();
expect(order.woo_order_id).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); 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 = { const ctx = {
payment_method: 'cash',
shipping_method: 'delivery', shipping_method: 'delivery',
delivery_address: { text: 'Calle Falsa 123' }, delivery_address: { text: 'Calle Falsa 123' },
woo_order_id: 456, woo_order_id: 456,
}; };
const result = migrateOldContext(ctx); const result = migrateOldContext(ctx);
expect(result.payment_type).toBe('cash');
expect(result.is_delivery).toBe(true); expect(result.is_delivery).toBe(true);
expect(result.shipping_address).toBe('Calle Falsa 123'); expect(result.shipping_address).toBe('Calle Falsa 123');
expect(result.woo_order_id).toBe(456); expect(result.woo_order_id).toBe(456);
expect(result.payment_type).toBeUndefined();
expect(result.is_paid).toBeUndefined();
}); });
it('migra shipping_method pickup', () => { it('migra shipping_method pickup', () => {

View File

@@ -99,25 +99,11 @@ export const DEFAULTS = {
"Anotado: {{address}}.", "Anotado: {{address}}.",
"Listo, dirección guardada: {{address}}.", "Listo, dirección guardada: {{address}}.",
], ],
"shipping.pickup_to_payment": [ // ---------------- ORDER CLOSE ----------------
"Genial, lo pasás a buscar. ¿Cómo abonás?", "order.confirmed": [
"Pickup confirmado. ¿Pago en efectivo o link?", "¡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á.",
// ---------------- 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?",
], ],
}; };
@@ -282,7 +268,7 @@ const REWRITE_KEYS = new Set([
"idle.greeting", // se filtra adicionalmente: solo en 1er turno "idle.greeting", // se filtra adicionalmente: solo en 1er turno
"shipping.ask_method", "shipping.ask_method",
"shipping.ask_address", "shipping.ask_address",
"payment.ask_method", "order.confirmed",
]); ]);
function shouldRewrite(templateKey, history) { 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. * Estructura:
* La implementación real está en ./stateHandlers/
*
* Estructura del módulo:
* - stateHandlers/utils.js - Utilidades de parseo y detección de texto * - stateHandlers/utils.js - Utilidades de parseo y detección de texto
* - stateHandlers/cartHelpers.js - Helpers para manejo del carrito * - stateHandlers/cartHelpers.js - Helpers para manejo del carrito
* - stateHandlers/idle.js - Handler estado IDLE * - stateHandlers/idle.js - Handler estado IDLE
* - stateHandlers/cart.js - Handler estado CART * - stateHandlers/cart.js - Handler estado CART
* - stateHandlers/shipping.js - Handler estado SHIPPING * - stateHandlers/shipping.js - Handler estado SHIPPING (cierra orden)
* - stateHandlers/payment.js - Handler estado PAYMENT
* - stateHandlers/waiting.js - Handler estado WAITING_WEBHOOKS
*/ */
export { export {
// Handlers principales
handleIdleState, handleIdleState,
handleCartState, handleCartState,
handleShippingState, handleShippingState,
handlePaymentState,
handleWaitingState,
// Utilidades
inferDefaultUnit, inferDefaultUnit,
parseIndexSelection, parseIndexSelection,
isShowMoreRequest, isShowMoreRequest,
@@ -32,7 +23,6 @@ export {
normalizeUnit, normalizeUnit,
unitAskFor, unitAskFor,
// Helpers de carrito
extractProductQueries, extractProductQueries,
createPendingItemFromSearch, createPendingItemFromSearch,
processPendingClarification, processPendingClarification,

View File

@@ -9,8 +9,6 @@
export { handleIdleState } from "./idle.js"; export { handleIdleState } from "./idle.js";
export { handleCartState } from "./cart.js"; export { handleCartState } from "./cart.js";
export { handleShippingState } from "./shipping.js"; export { handleShippingState } from "./shipping.js";
export { handlePaymentState } from "./payment.js";
export { handleWaitingState } from "./waiting.js";
// Utilidades (para uso interno principalmente) // Utilidades (para uso interno principalmente)
export { 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 { createEmptyOrder, formatCartForDisplay } from "../orderModel.js";
import { parseIndexSelection } from "./utils.js"; import { parseIndexSelection } from "./utils.js";
import { renderReply } from "../replyTemplates.js"; import { renderReply } from "../replyTemplates.js";
import { buildStoreContextVars } from "../storeContext.js"; import { buildStoreContextVars } from "../storeContext.js";
const SHIPPING_OPTIONS_TAIL = "\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal"; 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 }) { export async function handleShippingState({ tenantId, text, nlu, order, audit, recentReplies, storeConfig, conversation_history }) {
const intent = nlu?.intent || "other"; const intent = nlu?.intent || "other";
let currentOrder = order || createEmptyOrder(); let currentOrder = order || createEmptyOrder();
@@ -37,18 +37,8 @@ export async function handleShippingState({ tenantId, text, nlu, order, audit, r
currentOrder = { ...currentOrder, is_delivery: shippingMethod === "delivery" }; currentOrder = { ...currentOrder, is_delivery: shippingMethod === "delivery" };
if (shippingMethod === "pickup") { if (shippingMethod === "pickup") {
const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {}); // Pickup: orden lista, cerrarla.
const r = await renderReply({ tenantId, templateKey: "shipping.pickup_to_payment", vars: storeVars, recentReplies, ...rewriteCtx }); return finalizeOrder({ tenantId, currentOrder, audit, recentReplies, storeVars, 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] },
};
} }
// Delivery: pedir dirección si no la tiene // Delivery: pedir dirección si no la tiene
@@ -73,29 +63,7 @@ export async function handleShippingState({ tenantId, text, nlu, order, audit, r
if (address) { if (address) {
currentOrder = { ...currentOrder, shipping_address: address }; currentOrder = { ...currentOrder, shipping_address: address };
const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {}); return finalizeOrder({ tenantId, currentOrder, audit, recentReplies, storeVars, rewriteCtx, addressJustRecorded: true });
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],
},
};
} }
const r = await renderReply({ tenantId, templateKey: "shipping.ask_address", vars: storeVars, recentReplies, ...rewriteCtx }); 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] }, 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 * 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. * 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. * Feature flag USE_MODULAR_NLU=true para usar el nuevo sistema NLU modular.
@@ -15,8 +15,6 @@ import {
handleIdleState, handleIdleState,
handleCartState, handleCartState,
handleShippingState, handleShippingState,
handlePaymentState,
handleWaitingState,
} from "./stateHandlers.js"; } from "./stateHandlers.js";
import { getStoreConfig } from "../0-ui/db/settingsRepo.js"; import { getStoreConfig } from "../0-ui/db/settingsRepo.js";
import { pushRecent } from "./replyTemplates.js"; import { pushRecent } from "./replyTemplates.js";
@@ -200,14 +198,6 @@ export async function runTurnV3({
result = await handleShippingState(handlerParams); result = await handleShippingState(handlerParams);
break; break;
case ConversationState.PAYMENT:
result = await handlePaymentState(handlerParams);
break;
case ConversationState.WAITING_WEBHOOKS:
result = await handleWaitingState(handlerParams);
break;
default: default:
// Estado desconocido, tratar como IDLE // Estado desconocido, tratar como IDLE
result = await handleIdleState(handlerParams); result = await handleIdleState(handlerParams);
@@ -242,20 +232,16 @@ function normalizeState(state) {
if (s === "IDLE") return ConversationState.IDLE; if (s === "IDLE") return ConversationState.IDLE;
if (s === "CART") return ConversationState.CART; if (s === "CART") return ConversationState.CART;
if (s === "SHIPPING") return ConversationState.SHIPPING; 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)) { if (["CART_ACTIVE", "BROWSING", "CLARIFYING_ITEMS", "AWAITING_QUANTITY"].includes(s)) {
return ConversationState.CART; return ConversationState.CART;
} }
if (s === "CLARIFYING_SHIPPING" || s === "AWAITING_ADDRESS") return ConversationState.SHIPPING;
// Estados de checkout viejos // Estados que ya no existen (payment / waiting / completed) vuelven a IDLE
if (s === "CLARIFYING_PAYMENT") return ConversationState.PAYMENT; if (["PAYMENT", "WAITING_WEBHOOKS", "CLARIFYING_PAYMENT", "AWAITING_PAYMENT", "COMPLETED"].includes(s)) {
if (s === "CLARIFYING_SHIPPING") return ConversationState.SHIPPING; return ConversationState.IDLE;
if (s === "AWAITING_ADDRESS") return ConversationState.SHIPPING; }
if (s === "AWAITING_PAYMENT") return ConversationState.WAITING_WEBHOOKS;
if (s === "COMPLETED") return ConversationState.IDLE; // Nuevo ciclo
return ConversationState.IDLE; return ConversationState.IDLE;
} }
@@ -311,7 +297,6 @@ function formatResult(result, prevContext, recentReplies = [], failedSearches =
unit: p.unit, unit: p.unit,
status: p.status?.toLowerCase() || "needs_type", status: p.status?.toLowerCase() || "needs_type",
})), })),
payment_method: order.payment_type,
shipping_method: order.is_delivery === true ? "delivery" : shipping_method: order.is_delivery === true ? "delivery" :
order.is_delivery === false ? "pickup" : null, order.is_delivery === false ? "pickup" : null,
delivery_address: order.shipping_address ? { text: order.shipping_address } : 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"}`; const lockKey = `${tenantId}:${wooCustomerId || "anon"}`;
return withLock(lockKey, async () => { return withLock(lockKey, async () => {
const client = await getWooClient({ tenantId }); const client = await getWooClient({ tenantId });
const lineItems = await buildLineItems({ tenantId, basket }); const lineItems = await buildLineItems({ tenantId, basket });
if (!lineItems.length) throw new Error("order_empty_basket"); if (!lineItems.length) throw new Error("order_empty_basket");
const addr = mapAddress(address); const addr = mapAddress(address);
// Estado "pending" en Woo = el pago/cobro lo gestiona el comercio offline.
const payload = { const payload = {
status: "pending", status: "pending",
customer_id: wooCustomerId || undefined, customer_id: wooCustomerId || undefined,
line_items: lineItems, line_items: lineItems,
...(addr ? { billing: addr, shipping: addr } : {}), ...(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: [ meta_data: [
{ key: "source", value: "whatsapp" }, { key: "source", value: "whatsapp" },
...(run_id ? [{ key: "run_id", value: run_id }] : []), ...(run_id ? [{ key: "run_id", value: run_id }] : []),
// Guardar shipping_method como metadata para poder leerlo después
...(shippingMethod ? [{ key: "shipping_method", value: shippingMethod }] : []), ...(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`; const url = `${client.base}/orders`;