Mejoras: idempotency webhook, métricas rewriter, recommend en XState, seeds

7 frentes en una pasada:

1. Idempotency en pipeline.processMessage: si el message_id ya existía
   (Evolution suele reentregar webhooks), skip todo el turn y devuelve
   { duplicate: true }. Antes el ON CONFLICT DO NOTHING evitaba el insert
   pero igual procesaba NLU + side effects.

2. Limpieza de payment residual de admin/UI:
   - ordersRepo.getMonthlyStats / getTotals: out las queries cash/card
   - home-dashboard: out el donut "Efectivo vs Tarjeta"
   - orders-crud: out columna "Pago" + sección de detalle de pago
   - conversation-inspector: out 💵💳 del resumen
   - takeovers: out payment_link en run, out payment del summary
   - public/main.js: out la invariant no_checkout_without_payment_link
   - prompts-crud: out la entry "payment" del PROMPT_LABELS
   - wooOrders.parseOrder: out lectura de payment_method/is_paid (estado
     del pago lo gestiona el comercio offline, fuera del bot)
   - ordersRepo: out is_cash/is_paid del row mapping

3. Seed migration 20260501130000_seed_piaf_settings_and_replies:
   - schedule realista para piaf (delivery/pickup días+horas)
   - delivery_zones con barrios CABA reales (Palermo, Belgrano, etc)
   - 41 reply_templates con 17 keys + variantes (todo lo de DEFAULTS)
   Permite editar respuestas sin redeploy y desbloquea {{store_hours_today}},
   {{delivery_zones_summary}} reales en los templates.

4. Address validation en shipping: nuevo checkAddressInZone() en
   storeContext.js. Cuando el usuario da dirección, se valida contra
   zonas configuradas. Si está fuera, renderiza shipping.address_out_of_zone
   con sugerencia de zonas alternativas. Sin zonas configuradas → accept-by-default.

5. Métricas rewriter: getRewriterMetrics() expuesto, contadores
   ok/fallback/timeouts + avg_ms + fallback_rate. Endpoint nuevo
   /api/metrics/rewriter.

6. Shadow XState → audit_log: el shadow mode pasa de console.log a
   insertAuditLog con entity_type='xstate_shadow'. Permite review post-mortem.

7. Recommend portado a XState: nuevo recommendActor (fromPromise) wrappea
   handleRecommend; sub-state cart.recommending invoca el actor; ingestRecommendResult
   absorbe { plan, decision } en context. RECOMMEND event funciona desde idle
   y desde cart con USE_XSTATE=1.

8. tenantId opcional en renderReply/loadReplyVariants — defaultea a
   getTenantId() del módulo shared/tenant.js. Backward-compat: callers
   pueden seguir pasando tenantId o omitirlo.

E2E tests nuevos en machine/e2e.test.js: golden flow pickup, golden flow
delivery con address-in-zone, snapshot rehydrate full flow, universal
cart-on-add desde shipping. 192/192 tests pasando (188 previos + 4 E2E).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lucas Tettamanti
2026-05-01 21:18:29 -03:00
parent 6b7889ef4e
commit 7b6c62b23d
21 changed files with 454 additions and 114 deletions

View File

@@ -0,0 +1,103 @@
-- migrate:up
-- Seed mono-tenant: settings de tienda (horarios + zonas de delivery) +
-- reply_templates con todas las variantes hoy hardcodeadas en DEFAULTS.
-- Esto desbloquea editar respuestas vía UI/SQL sin redeploy.
-- 1) tenant_settings: schedule + delivery_zones para piaf
UPDATE tenant_settings
SET
store_name = COALESCE(NULLIF(store_name, 'Mi Negocio'), 'Piaf'),
bot_name = COALESCE(NULLIF(bot_name, 'Bot'), 'Piaf'),
delivery_enabled = true,
pickup_enabled = true,
schedule = '{
"delivery": {
"mon": {"enabled": true, "start": "09:00", "end": "13:00"},
"tue": {"enabled": true, "start": "09:00", "end": "13:00"},
"wed": {"enabled": true, "start": "09:00", "end": "13:00"},
"thu": {"enabled": true, "start": "09:00", "end": "13:00"},
"fri": {"enabled": true, "start": "09:00", "end": "13:00"},
"sat": {"enabled": true, "start": "09:00", "end": "13:00"},
"sun": {"enabled": false}
},
"pickup": {
"mon": {"enabled": true, "start": "09:00", "end": "20:00"},
"tue": {"enabled": true, "start": "09:00", "end": "20:00"},
"wed": {"enabled": true, "start": "09:00", "end": "20:00"},
"thu": {"enabled": true, "start": "09:00", "end": "20:00"},
"fri": {"enabled": true, "start": "09:00", "end": "20:00"},
"sat": {"enabled": true, "start": "09:00", "end": "13:00"},
"sun": {"enabled": false}
}
}'::jsonb,
delivery_zones = '{
"caba": {
"barrios": ["Palermo", "Belgrano", "Recoleta", "Villa Crespo", "Almagro", "Caballito", "Núñez", "Colegiales", "Chacarita", "Las Cañitas"]
}
}'::jsonb
WHERE tenant_id = 'eb71b9a7-9ccf-430e-9b25-951a0c589c0f'::uuid;
-- 2) reply_templates: seed con DEFAULTS de replyTemplates.js para piaf
INSERT INTO reply_templates (tenant_id, template_key, variant, content, weight)
SELECT
'eb71b9a7-9ccf-430e-9b25-951a0c589c0f'::uuid,
v.template_key,
v.variant,
v.content,
v.weight
FROM (VALUES
-- IDLE
('idle.greeting', 1, '¡Hola! ¿En qué te puedo ayudar?', 1),
('idle.greeting', 2, '¡Hola! Estoy para ayudarte con tu pedido. ¿Qué andás buscando?', 1),
('idle.greeting', 3, 'Buenas. ¿Querés que te muestre algo en particular o hacemos un pedido?', 1),
('idle.help_prompt', 1, 'Decime qué necesitás. Podés pedirme productos, precios, o armar el pedido directo.', 1),
('idle.help_prompt', 2, '¿Qué te tiro? Podés pedir algo, preguntar precios o consultar disponibilidad.', 1),
-- CART
('cart.ask_more', 1, '¿Algo más?', 1),
('cart.ask_more', 2, '¿Querés agregar algo más al pedido?', 1),
('cart.ask_more', 3, '¿Sumamos algo más o cerramos así?', 1),
('cart.empty_prompt', 1, 'Tu carrito está vacío. ¿Qué querés agregar?', 1),
('cart.empty_prompt', 2, 'Todavía no hay nada en el carrito. ¿Por dónde empezamos?', 1),
('cart.not_found', 1, 'No encontré "{{query}}". ¿Podés decirlo de otra forma?', 1),
('cart.not_found', 2, 'Mmm, no tengo "{{query}}" exacto. ¿Probamos con otra cosa?', 1),
('cart.not_found', 3, 'No me aparece "{{query}}". Si querés, dame otro nombre o detalle más.', 1),
('cart.didnt_understand', 1, 'Perdón, no te entendí.', 1),
('cart.didnt_understand', 2, 'No me quedó claro, ¿me lo decís de otra forma?', 1),
('cart.didnt_understand', 3, 'No te seguí, ¿podés repetir?', 1),
('cart.skip_acknowledged', 1, 'Ok, lo dejamos.', 1),
('cart.skip_acknowledged', 2, 'Listo, no lo agregamos.', 1),
('cart.confirm_to_shipping', 1, 'Buenísimo. ¿Es para delivery o lo pasás a buscar?', 1),
('cart.confirm_to_shipping', 2, 'Perfecto. ¿Te lo enviamos o lo retirás?', 1),
('cart.pending_before_close', 1, 'Antes de cerrar, ¿qué hacemos con lo que quedó pendiente?', 1),
('cart.pending_before_close', 2, 'Tenemos algo pendiente para resolver antes de cerrar el pedido.', 1),
('cart.added_confirm', 1, 'Anoté {{summary}}. ¿Algo más?', 1),
('cart.added_confirm', 2, 'Listo, {{summary}} agregado. ¿Sumamos algo más?', 1),
('cart.added_confirm', 3, 'Sumé {{summary}}. ¿Querés agregar algo más?', 1),
('cart.added_confirm', 4, 'Va {{summary}}. ¿Algo más?', 1),
('cart.ask_what_product', 1, '¿Qué producto querés?', 1),
('cart.ask_what_product', 2, 'Decime el producto y lo busco.', 1),
('cart.price_no_query', 1, '¿De qué producto querés saber el precio?', 1),
('cart.price_no_query', 2, 'Decime el producto y te paso el precio.', 1),
('cart.price_results_header', 1, 'Estos son los precios:', 1),
('cart.price_results_header', 2, 'Precios disponibles:', 1),
-- SHIPPING (incluye {{delivery_zones_summary}} y {{delivery_hours}} cuando hay datos)
('shipping.ask_method', 1, '¿Lo enviamos a domicilio o lo pasás a buscar?', 1),
('shipping.ask_method', 2, '¿Es para delivery o pickup?', 1),
('shipping.ask_address', 1, 'Pasame la dirección de entrega.', 1),
('shipping.ask_address', 2, 'Decime dónde lo entregamos (calle y altura). Hacemos delivery en {{delivery_zones_summary}}.', 1),
('shipping.address_recorded', 1, 'Anotado: {{address}}.', 1),
('shipping.address_recorded', 2, 'Listo, dirección guardada: {{address}}.', 1),
-- ORDER CLOSE
('order.confirmed', 1, '¡Listo! Anotamos tu pedido. Te coordinamos por acá la entrega.', 1),
('order.confirmed', 2, 'Perfecto, ya quedó registrado. Te confirmamos en breve los detalles de entrega.', 1),
('order.confirmed', 3, 'Genial, anotado. Cualquier ajuste avisame por acá.', 1)
) AS v(template_key, variant, content, weight)
ON CONFLICT (tenant_id, template_key, variant) DO NOTHING;
-- migrate:down
DELETE FROM reply_templates
WHERE tenant_id = 'eb71b9a7-9ccf-430e-9b25-951a0c589c0f'::uuid;
-- Settings: limpiar schedule/zones (no borrar la fila)
UPDATE tenant_settings
SET schedule = '{}'::jsonb, delivery_zones = '{}'::jsonb
WHERE tenant_id = 'eb71b9a7-9ccf-430e-9b25-951a0c589c0f'::uuid;

View File

@@ -206,13 +206,10 @@ class ConversationInspector extends HTMLElement {
parts.push(`[${activePending.length} pendiente(s)]`); parts.push(`[${activePending.length} pendiente(s)]`);
} }
// Checkout info // Checkout info (sólo shipping — el bot no maneja pagos)
const checkoutInfo = []; const checkoutInfo = [];
if (order?.is_delivery === true) checkoutInfo.push("🚚"); if (order?.is_delivery === true) checkoutInfo.push("🚚");
if (order?.is_delivery === false) checkoutInfo.push("🏪"); if (order?.is_delivery === false) checkoutInfo.push("🏪");
if (order?.payment_type === "cash") checkoutInfo.push("💵");
if (order?.payment_type === "link") checkoutInfo.push("💳");
if (order?.is_paid) checkoutInfo.push("✅");
if (checkoutInfo.length) parts.push(checkoutInfo.join("")); if (checkoutInfo.length) parts.push(checkoutInfo.join(""));
return parts.length ? parts.join(" ") : "—"; return parts.length ? parts.join(" ") : "—";
@@ -232,7 +229,6 @@ class ConversationInspector extends HTMLElement {
"ensure_woo_customer": "woo customer", "ensure_woo_customer": "woo customer",
"create_order": "create order", "create_order": "create order",
"update_order": "update order", "update_order": "update order",
"send_payment_link": "payment link",
"request_human_takeover": "human takeover", "request_human_takeover": "human takeover",
"add_to_cart": "add to cart", "add_to_cart": "add to cart",
"human_response_sent": "human response", "human_response_sent": "human response",

View File

@@ -164,12 +164,6 @@ class HomeDashboard extends HTMLElement {
<canvas id="shipping-donut"></canvas> <canvas id="shipping-donut"></canvas>
</div> </div>
</div> </div>
<div class="chart-card">
<div class="chart-title">Efectivo vs Tarjeta</div>
<div class="chart-container short">
<canvas id="payment-donut"></canvas>
</div>
</div>
</div> </div>
<div class="chart-card full-width"> <div class="chart-card full-width">
<div class="chart-title">Top Productos por Facturación</div> <div class="chart-title">Top Productos por Facturación</div>
@@ -455,22 +449,6 @@ class HomeDashboard extends HTMLElement {
}); });
} }
// Payment donut
const paymentCtx = this.shadowRoot.getElementById("payment-donut");
if (paymentCtx) {
if (this.charts.paymentDonut) this.charts.paymentDonut.destroy();
this.charts.paymentDonut = new Chart(paymentCtx, {
type: "doughnut",
data: {
labels: ["Efectivo", "Tarjeta"],
datasets: [{
data: [totals.by_payment?.cash || 0, totals.by_payment?.card || 0],
backgroundColor: ["#10B981", "#EC4899"],
}],
},
options: this.getDonutOptions(),
});
}
} }
getDonutOptions() { getDonutOptions() {

View File

@@ -431,7 +431,6 @@ class OrdersCrud extends HTMLElement {
<th>Tipo</th> <th>Tipo</th>
<th>Estado</th> <th>Estado</th>
<th>Envío</th> <th>Envío</th>
<th>Pago</th>
<th>Cliente</th> <th>Cliente</th>
<th>Total</th> <th>Total</th>
<th>Fecha</th> <th>Fecha</th>
@@ -453,12 +452,6 @@ class OrdersCrud extends HTMLElement {
</td> </td>
<td><span class="status-badge" style="background:${statusColor(order.status)}">${statusLabel(order.status)}</span></td> <td><span class="status-badge" style="background:${statusColor(order.status)}">${statusLabel(order.status)}</span></td>
<td><span class="badge" style="background:${order.is_delivery ? '#3b82f6' : '#8b5cf6'};color:#fff">${order.is_delivery ? 'DEL' : 'RET'}</span></td> <td><span class="badge" style="background:${order.is_delivery ? '#3b82f6' : '#8b5cf6'};color:#fff">${order.is_delivery ? 'DEL' : 'RET'}</span></td>
<td>
<div class="badges">
<span class="badge" style="background:${order.is_cash ? '#f59e0b' : '#1f6feb'};color:${order.is_cash ? '#000' : '#fff'}">${order.is_cash ? '$' : 'MP'}</span>
<span class="badge" style="background:${order.is_paid ? '#22c55e' : '#ef4444'};color:#fff">${order.is_paid ? '✓' : '✗'}</span>
</div>
</td>
<td class="customer-name" title="${customerName}">${customerName}</td> <td class="customer-name" title="${customerName}">${customerName}</td>
<td class="total">$${Number(order.total || 0).toLocaleString("es-AR")}</td> <td class="total">$${Number(order.total || 0).toLocaleString("es-AR")}</td>
<td>${formatDate(order.date_created)}</td> <td>${formatDate(order.date_created)}</td>
@@ -579,28 +572,6 @@ class OrdersCrud extends HTMLElement {
` : ''} ` : ''}
</div> </div>
<div class="detail-section">
<div class="detail-title">Pago</div>
<div class="detail-row">
<span class="detail-label">Método</span>
<span class="detail-value">
<span class="badge" style="background:${order.is_cash ? '#f59e0b' : '#1f6feb'};color:${order.is_cash ? '#000' : '#fff'};padding:3px 8px;border-radius:4px;font-size:10px;">
${order.is_cash ? 'EFECTIVO' : 'LINK'}
</span>
${order.payment_method_title ? `<span style="margin-left:8px;color:var(--muted);font-size:11px;">${order.payment_method_title}</span>` : ''}
</span>
</div>
<div class="detail-row">
<span class="detail-label">Estado</span>
<span class="detail-value">
<span class="badge" style="background:${order.is_paid ? '#22c55e' : '#ef4444'};color:#fff;padding:3px 8px;border-radius:4px;font-size:10px;">
${order.is_paid ? 'PAGADO' : 'PENDIENTE'}
</span>
${order.date_paid ? `<span style="margin-left:8px;color:var(--muted);font-size:11px;">${formatDate(order.date_paid)}</span>` : ''}
</span>
</div>
</div>
<div class="detail-section"> <div class="detail-section">
<div class="detail-title">Cliente</div> <div class="detail-title">Cliente</div>
<div class="detail-row"> <div class="detail-row">

View File

@@ -7,7 +7,6 @@ const PROMPT_LABELS = {
greeting: "Saludos", greeting: "Saludos",
orders: "Pedidos", orders: "Pedidos",
shipping: "Envio/Retiro", shipping: "Envio/Retiro",
payment: "Pago",
browse: "Consultas de catalogo", browse: "Consultas de catalogo",
}; };

View File

@@ -92,7 +92,6 @@ async function processMessage({ chat_id, from, text }) {
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 },
], ],
}; };
@@ -110,7 +109,6 @@ async function processMessage({ chat_id, from, text }) {
invariants, invariants,
final_reply: plan.reply, final_reply: plan.reply,
order_id: null, order_id: null,
payment_link: null,
latency_ms: Date.now() - started_at, latency_ms: Date.now() - started_at,
}; };

View File

@@ -25,7 +25,6 @@ export async function handleGetOrderStats({ tenantId }) {
order_counts: monthlyStats.order_counts, order_counts: monthlyStats.order_counts,
by_source: monthlyStats.by_source, by_source: monthlyStats.by_source,
by_shipping: monthlyStats.by_shipping, by_shipping: monthlyStats.by_shipping,
by_payment: monthlyStats.by_payment,
// Totales agregados (para donuts) // Totales agregados (para donuts)
totals_aggregated: totals, totals_aggregated: totals,

View File

@@ -154,7 +154,6 @@ export async function handleRespondToTakeover({
invariants: { ok: true, checks: [] }, invariants: { ok: true, checks: [] },
final_reply: response, final_reply: response,
order_id: null, order_id: null,
payment_link: null,
latency_ms: 0, latency_ms: 0,
}); });
@@ -348,13 +347,10 @@ function summarizeContext(contextSnapshot) {
summary.push(`Pendiente: ${pendingItems}`); summary.push(`Pendiente: ${pendingItems}`);
} }
// Shipping/Payment // Shipping (sin payment — el bot no maneja pagos)
if (ctx.order?.is_delivery !== null) { if (ctx.order?.is_delivery !== null) {
summary.push(ctx.order.is_delivery ? "Delivery" : "Retiro"); summary.push(ctx.order.is_delivery ? "Delivery" : "Retiro");
} }
if (ctx.order?.payment_type) {
summary.push(`Pago: ${ctx.order.payment_type}`);
}
return summary.join(" | ") || "Sin contexto"; return summary.join(" | ") || "Sin contexto";
} }

View File

@@ -15,6 +15,7 @@ import { makeListPendingTakeovers, makeListAllTakeovers, makeGetTakeover, makeRe
import { makeGetSettings, makeSaveSettings } from "../../0-ui/controllers/settings.js"; import { makeGetSettings, makeSaveSettings } from "../../0-ui/controllers/settings.js";
import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../../0-ui/controllers/admin.js"; import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../../0-ui/controllers/admin.js";
import { makeListOrders, makeGetOrderStats, makeGetProductsWithStock, makeCreateTestOrder } from "../../0-ui/controllers/testing.js"; import { makeListOrders, makeGetOrderStats, makeGetProductsWithStock, makeCreateTestOrder } from "../../0-ui/controllers/testing.js";
import { getRewriterMetrics } from "../../3-turn-engine/replyRewriter.js";
function nowIso() { function nowIso() {
return new Date().toISOString(); return new Date().toISOString();
@@ -50,6 +51,7 @@ export function createSimulatorRouter({ tenantId }) {
* --- UI data endpoints --- * --- UI data endpoints ---
*/ */
router.post("/sim/send", makeSimSend()); router.post("/sim/send", makeSimSend());
router.get("/api/metrics/rewriter", (req, res) => res.json(getRewriterMetrics()));
router.get("/conversations", makeGetConversations(getTenantId)); router.get("/conversations", makeGetConversations(getTenantId));
router.get("/conversations/state", makeGetConversationState(getTenantId)); router.get("/conversations/state", makeGetConversationState(getTenantId));

View File

@@ -141,7 +141,7 @@ let externalCustomerId = await getExternalCustomerIdByChat({
}); });
mark("after_getExternalCustomerIdByChat"); mark("after_getExternalCustomerIdByChat");
await insertMessage({ const inserted = await insertMessage({
tenant_id: tenantId, tenant_id: tenantId,
wa_chat_id: chat_id, wa_chat_id: chat_id,
provider, provider,
@@ -153,6 +153,15 @@ let externalCustomerId = await getExternalCustomerIdByChat({
}); });
mark("after_insertMessage_in"); mark("after_insertMessage_in");
// Idempotency: si el message_id ya estaba insertado (Evolution suele
// reentregar webhooks), evitamos volver a procesar el turn entero.
if (!inserted) {
if (dbg.perf || dbg.evolution) {
console.log("[pipeline] duplicate message ignored", { message_id, chat_id });
}
return { run_id: null, reply: null, duplicate: true };
}
mark("before_getRecentMessagesForLLM_for_plan"); mark("before_getRecentMessagesForLLM_for_plan");
const history = await getRecentMessagesForLLM({ const history = await getRecentMessagesForLLM({
tenant_id: tenantId, tenant_id: tenantId,

View File

@@ -256,6 +256,23 @@ export const enqueueRemoveFromCart = assign({
], ],
}); });
/**
* Absorbe el resultado del recommendActor: reply via rawText (ya viene
* formateado por handleRecommend), merge de order y enqueue de actions.
*/
export const ingestRecommendResult = assign({
pending_reply: ({ event }) => {
const reply = event.output?.plan?.reply;
return reply ? { rawText: reply } : null;
},
order: ({ context, event }) => event.output?.decision?.order || context.order,
pending_actions: ({ context, event }) => {
const incoming = event.output?.decision?.actions || [];
if (!incoming.length) return context.pending_actions || [];
return [...(context.pending_actions || []), ...incoming];
},
});
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// Async reply renderers (entry actions que producen reply async) // Async reply renderers (entry actions que producen reply async)
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
@@ -364,6 +381,7 @@ export const actions = {
enqueueWooCreateOrder, enqueueWooCreateOrder,
enqueueAddToCart, enqueueAddToCart,
enqueueRemoveFromCart, enqueueRemoveFromCart,
ingestRecommendResult,
replyIdleGreeting, replyIdleGreeting,
replyIdleHelp, replyIdleHelp,
replyAskMore, replyAskMore,

View File

@@ -6,6 +6,7 @@
import { fromPromise } from "xstate"; import { fromPromise } from "xstate";
import { retrieveCandidates } from "../catalogRetrieval.js"; import { retrieveCandidates } from "../catalogRetrieval.js";
import { getProductQtyRules } from "../../0-ui/db/repo.js"; import { getProductQtyRules } from "../../0-ui/db/repo.js";
import { handleRecommend } from "../recommendations.js";
/** /**
* Busca candidatos para una lista de queries de producto. * Busca candidatos para una lista de queries de producto.
@@ -39,7 +40,24 @@ export const getQtyRulesActor = fromPromise(async ({ input }) => {
return await getProductQtyRules({ tenantId, wooProductId }); return await getProductQtyRules({ tenantId, wooProductId });
}); });
/**
* Recomendación (cross-sell o planificación). Devuelve `{ plan, decision }`
* con shape compatible con el dispatcher legacy.
*/
export const recommendActor = fromPromise(async ({ input }) => {
const { tenantId, text, nlu, order } = input || {};
return await handleRecommend({
tenantId,
text,
nlu,
order,
prevContext: { order },
audit: {},
});
});
export const actors = { export const actors = {
searchCatalogActor, searchCatalogActor,
getQtyRulesActor, getQtyRulesActor,
recommendActor,
}; };

View File

@@ -0,0 +1,152 @@
/**
* E2E test del flow completo desde NLU output hasta serialización del
* snapshot, sin tocar DB real (mocks) ni LLM real.
*
* Cubre el camino feliz: hola → add → confirm → shipping pickup → IDLE
* con la orden creada. También valida snapshot persist/restore.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { createActor } from "xstate";
// Mock pool de DB para que reply_templates devuelva [] (cae a DEFAULTS)
vi.mock("../../shared/db/pool.js", () => ({
pool: { query: vi.fn().mockResolvedValue({ rows: [] }) },
}));
vi.mock("../replyRewriter.js", () => ({
rewriteReply: vi.fn(async ({ baseText }) => ({ text: baseText, rewritten: false, ms: 0 })),
}));
vi.mock("../catalogRetrieval.js", () => ({
retrieveCandidates: vi.fn(async ({ query }) => ({
candidates: query === "chorizo"
? [{ woo_product_id: 100, name: "Chorizo Parrillero", price: 1500, sell_unit: "kg", _score: 1.0 }]
: query === "vacio"
? [{ woo_product_id: 200, name: "Vacío", price: 4500, sell_unit: "kg", _score: 1.0 }]
: [],
audit: {},
})),
}));
vi.mock("../../0-ui/db/repo.js", () => ({
getProductQtyRules: vi.fn(async () => []),
}));
import { machine } from "./index.js";
const TENANT = "eb71b9a7-9ccf-430e-9b25-951a0c589c0f";
function makeActor(input = {}) {
return createActor(machine, {
input: { tenantId: TENANT, chat_id: "e2e", storeConfig: {}, ...input },
});
}
async function settle(actor) {
for (let i = 0; i < 100; i++) {
const snap = actor.getSnapshot();
const children = Object.values(snap.children || {});
const running = children.some((c) => {
try { return c.getSnapshot()?.status === "active"; } catch { return false; }
});
if (!running) return snap;
await new Promise((r) => setTimeout(r, 5));
}
return actor.getSnapshot();
}
describe("E2E — golden flow pickup", () => {
it("hola → add chorizo (qty+unit) → confirm → pickup → idle con create_order", async () => {
const a = makeActor();
a.start();
// Greeting
a.send({ type: "GREETING" });
expect(a.getSnapshot().value).toBe("idle");
expect(a.getSnapshot().context.pending_reply).toMatchObject({ templateKey: "idle.greeting" });
// Add chorizo con qty/unit completos → strong-match → ready
a.send({ type: "ADD_TO_CART", items: [{ product_query: "chorizo", quantity: 2, unit: "kg" }], text: "2kg de chorizo" });
await settle(a);
let snap = a.getSnapshot();
expect(snap.context.order.cart).toHaveLength(1);
expect(snap.context.order.cart[0].name).toBe("Chorizo Parrillero");
// Confirm
a.send({ type: "CONFIRM_ORDER" });
expect(a.getSnapshot().value).toBe("shipping");
// Pickup → IDLE con create_order
a.send({ type: "SELECT_SHIPPING", method: "pickup" });
snap = a.getSnapshot();
expect(snap.value).toBe("idle");
expect(snap.context.order.is_delivery).toBe(false);
expect(snap.context.pending_actions.some((x) => x.type === "create_order")).toBe(true);
expect(snap.context.pending_reply?.templateKey).toBe("order.confirmed");
a.stop();
});
});
describe("E2E — golden flow delivery con address en zona", () => {
it("flow delivery con dirección en Palermo se confirma", async () => {
const a = makeActor({
storeConfig: { delivery_zones: { caba: { barrios: ["Palermo", "Belgrano"] } } },
});
a.start();
a.send({ type: "ADD_TO_CART", items: [{ product_query: "vacio", quantity: 1, unit: "kg" }], text: "1kg vacio" });
await settle(a);
a.send({ type: "CONFIRM_ORDER" });
a.send({ type: "SELECT_SHIPPING", method: "delivery" });
expect(a.getSnapshot().value).toBe("shipping");
a.send({ type: "PROVIDE_ADDRESS", address: "Av Santa Fe 3000 Palermo" });
const snap = a.getSnapshot();
expect(snap.value).toBe("idle");
expect(snap.context.order.shipping_address).toMatch(/Palermo/);
expect(snap.context.pending_actions.some((x) => x.type === "create_order")).toBe(true);
a.stop();
});
});
describe("E2E — snapshot rehydrate full flow", () => {
it("persiste snapshot a mitad de flow, hidrata, completa", async () => {
const a = makeActor();
a.start();
a.send({ type: "ADD_TO_CART", items: [{ product_query: "vacio", quantity: 1, unit: "kg" }], text: "1kg vacio" });
await settle(a);
a.send({ type: "CONFIRM_ORDER" });
expect(a.getSnapshot().value).toBe("shipping");
const persisted = a.getPersistedSnapshot();
a.stop();
// Re-hidrato y completo
const b = createActor(machine, {
snapshot: persisted,
input: { tenantId: TENANT, chat_id: "e2e", storeConfig: {} },
});
b.start();
expect(b.getSnapshot().value).toBe("shipping");
b.send({ type: "SELECT_SHIPPING", method: "pickup" });
const snap = b.getSnapshot();
expect(snap.value).toBe("idle");
expect(snap.context.order.is_delivery).toBe(false);
expect(snap.context.pending_actions.some((x) => x.type === "create_order")).toBe(true);
b.stop();
});
});
describe("E2E — universal cart-on-add desde shipping", () => {
it("desde shipping, add_to_cart vuelve a cart.searching", async () => {
const a = makeActor();
a.start();
a.send({ type: "ADD_TO_CART", items: [{ product_query: "chorizo", quantity: 2, unit: "kg" }], text: "2kg chorizo" });
await settle(a);
a.send({ type: "CONFIRM_ORDER" });
expect(a.getSnapshot().value).toBe("shipping");
a.send({ type: "ADD_TO_CART", items: [{ product_query: "vacio", quantity: 1, unit: "kg" }], text: "1kg vacio" });
expect(a.getSnapshot().value).toEqual({ cart: "searching" });
await settle(a);
expect(a.getSnapshot().context.order.cart.length).toBe(2);
a.stop();
});
});

View File

@@ -83,6 +83,7 @@ export const machine = setup({
target: `${ConversationStates.CART}.searching`, target: `${ConversationStates.CART}.searching`,
}, },
PRICE_QUERY: { actions: "setUserText", target: `${ConversationStates.CART}.pricing` }, PRICE_QUERY: { actions: "setUserText", target: `${ConversationStates.CART}.pricing` },
RECOMMEND: { actions: "setUserText", target: `${ConversationStates.CART}.recommending` },
VIEW_CART: { target: `${ConversationStates.CART}.showing` }, VIEW_CART: { target: `${ConversationStates.CART}.showing` },
CONFIRM_ORDER: { actions: "replyEmptyCart" }, CONFIRM_ORDER: { actions: "replyEmptyCart" },
OTHER: { actions: "replyIdleHelp" }, OTHER: { actions: "replyIdleHelp" },
@@ -114,6 +115,7 @@ export const machine = setup({
}, },
], ],
PRICE_QUERY: { actions: "setUserText", target: ".pricing" }, PRICE_QUERY: { actions: "setUserText", target: ".pricing" },
RECOMMEND: { actions: "setUserText", target: ".recommending" },
GREETING: { actions: "replyIdleGreeting", target: ".idle" }, GREETING: { actions: "replyIdleGreeting", target: ".idle" },
}, },
states: { states: {
@@ -251,6 +253,26 @@ export const machine = setup({
always: "idle", always: "idle",
}, },
recommending: {
invoke: {
src: "recommendActor",
input: ({ context, event }) => ({
tenantId: context.tenantId,
text: event.text || context.userText,
nlu: event.nlu || null,
order: context.order,
}),
onDone: {
actions: "ingestRecommendResult",
target: "idle",
},
onError: {
actions: "replyDidntUnderstand",
target: "idle",
},
},
},
pricing: { pricing: {
// Para v1, pricing reusa el flow de searching y muestra resultados. // Para v1, pricing reusa el flow de searching y muestra resultados.
// Una iteración futura podría tener un actor separado para no agregar al carrito. // Una iteración futura podría tener un actor separado para no agregar al carrito.

View File

@@ -79,6 +79,27 @@ const _inflightCache = new Map();
const _resultCache = new Map(); const _resultCache = new Map();
const RESULT_TTL_MS = 30_000; const RESULT_TTL_MS = 30_000;
// Métricas exportadas para observabilidad. Se logean cada N rewrites.
const _metrics = { ok: 0, fallback: 0, timeouts: 0, totalMs: 0 };
export function getRewriterMetrics() {
const total = _metrics.ok + _metrics.fallback;
return {
rewrites_ok: _metrics.ok,
rewrites_fallback: _metrics.fallback,
rewrites_timeout: _metrics.timeouts,
fallback_rate: total ? _metrics.fallback / total : 0,
avg_ms: _metrics.ok ? Math.round(_metrics.totalMs / _metrics.ok) : 0,
};
}
export function resetRewriterMetrics() {
_metrics.ok = 0;
_metrics.fallback = 0;
_metrics.timeouts = 0;
_metrics.totalMs = 0;
}
function cacheKey({ templateKey, baseText, lastUserMsg, lastAssistantMsg }) { function cacheKey({ templateKey, baseText, lastUserMsg, lastAssistantMsg }) {
return `${templateKey}|${baseText}|${lastUserMsg}|${lastAssistantMsg}`; return `${templateKey}|${baseText}|${lastUserMsg}|${lastAssistantMsg}`;
} }
@@ -186,9 +207,13 @@ export async function rewriteReply({
const result = { text: safeText, rewritten: true, model }; const result = { text: safeText, rewritten: true, model };
_resultCache.set(key, { value: result, t: Date.now() }); _resultCache.set(key, { value: result, t: Date.now() });
_metrics.ok++;
_metrics.totalMs += (Date.now() - t0);
return result; return result;
} catch (err) { } catch (err) {
const msg = String(err?.message || err); const msg = String(err?.message || err);
_metrics.fallback++;
if (msg.includes("timeout")) _metrics.timeouts++;
if (dbg.llm) console.log("[rewriter] error fallback to base", msg); if (dbg.llm) console.log("[rewriter] error fallback to base", msg);
return { text: baseText, rewritten: false, error: msg }; return { text: baseText, rewritten: false, error: msg };
} }

View File

@@ -13,6 +13,7 @@
import { pool } from "../shared/db/pool.js"; import { pool } from "../shared/db/pool.js";
import { rewriteReply } from "./replyRewriter.js"; import { rewriteReply } from "./replyRewriter.js";
import { getTenantId } from "../shared/tenant.js";
const cache = new Map(); const cache = new Map();
const CACHE_TTL = 5 * 60 * 1000; const CACHE_TTL = 5 * 60 * 1000;
@@ -99,6 +100,10 @@ export const DEFAULTS = {
"Anotado: {{address}}.", "Anotado: {{address}}.",
"Listo, dirección guardada: {{address}}.", "Listo, dirección guardada: {{address}}.",
], ],
"shipping.address_out_of_zone": [
"Esa dirección queda fuera de la zona de delivery. Hacemos entregas en {{delivery_zones_summary}}. ¿Probás otra dirección o pasás a buscar?",
"No llegamos hasta ahí. Cubrimos {{delivery_zones_summary}}. ¿Querés cambiar la dirección o retiro en sucursal?",
],
// ---------------- ORDER CLOSE ---------------- // ---------------- ORDER CLOSE ----------------
"order.confirmed": [ "order.confirmed": [
"¡Listo! Anotamos tu pedido. Te coordinamos por acá la entrega y el pago.", "¡Listo! Anotamos tu pedido. Te coordinamos por acá la entrega y el pago.",
@@ -135,8 +140,9 @@ async function loadFromDb({ tenantId, templateKey }) {
})); }));
} }
export async function loadReplyVariants({ tenantId, templateKey, skipCache = false }) { export async function loadReplyVariants({ tenantId, templateKey, skipCache = false } = {}) {
const cacheKey = `${tenantId}:${templateKey}`; const tid = tenantId || getTenantId();
const cacheKey = `${tid}:${templateKey}`;
if (!skipCache) { if (!skipCache) {
const cached = cache.get(cacheKey); const cached = cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) { if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
@@ -146,7 +152,7 @@ export async function loadReplyVariants({ tenantId, templateKey, skipCache = fal
let variants = []; let variants = [];
try { try {
variants = await loadFromDb({ tenantId, templateKey }); variants = await loadFromDb({ tenantId: tid, templateKey });
} catch (err) { } catch (err) {
console.error(`[replyTemplates] DB error loading ${templateKey}: ${err.message}`); console.error(`[replyTemplates] DB error loading ${templateKey}: ${err.message}`);
} }
@@ -220,8 +226,10 @@ export async function renderReply({
conversation_history = null, conversation_history = null,
state = null, state = null,
userText = null, userText = null,
}) { } = {}) {
const variants = await loadReplyVariants({ tenantId, templateKey }); // tenantId opcional: defaultea al cacheado al boot (mono-tenant).
const tid = tenantId || getTenantId();
const variants = await loadReplyVariants({ tenantId: tid, templateKey });
if (variants.length === 0) { if (variants.length === 0) {
return { reply: "", template_id: `${templateKey}:0`, variant: 0 }; return { reply: "", template_id: `${templateKey}:0`, variant: 0 };
} }

View File

@@ -10,7 +10,7 @@ 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, checkAddressInZone } 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";
@@ -62,6 +62,28 @@ export async function handleShippingState({ tenantId, text, nlu, order, audit, r
const address = nlu?.entities?.address || (text?.length > 5 ? text.trim() : null); const address = nlu?.entities?.address || (text?.length > 5 ? text.trim() : null);
if (address) { if (address) {
// Validar zona si hay zonas cargadas. Si no hay datos cargados, accept-by-default.
const zoneCheck = checkAddressInZone({ address, storeConfig });
audit.address_zone_check = zoneCheck;
if (!zoneCheck.inZone) {
const r = await renderReply({
tenantId,
templateKey: "shipping.address_out_of_zone",
vars: storeVars,
recentReplies,
...rewriteCtx,
});
return {
plan: {
reply: r.reply,
next_state: ConversationState.SHIPPING,
intent: "provide_address",
missing_fields: ["address"],
order_action: "none",
},
decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] },
};
}
currentOrder = { ...currentOrder, shipping_address: address }; currentOrder = { ...currentOrder, shipping_address: address };
return finalizeOrder({ tenantId, currentOrder, audit, recentReplies, storeVars, rewriteCtx, addressJustRecorded: true }); return finalizeOrder({ tenantId, currentOrder, audit, recentReplies, storeVars, rewriteCtx, addressJustRecorded: true });
} }

View File

@@ -48,6 +48,50 @@ function formatDaySlot(slot) {
/** /**
* Resumen de zonas de delivery: lista de barrios o "Consultar zonas". * Resumen de zonas de delivery: lista de barrios o "Consultar zonas".
*/ */
/**
* Lista plana de barrios (lowercase) habilitados para delivery.
*/
function getDeliveryZoneNames(deliveryZones) {
if (!deliveryZones || typeof deliveryZones !== "object") return [];
const names = [];
if (Array.isArray(deliveryZones.zones)) {
for (const z of deliveryZones.zones) if (z?.name) names.push(z.name);
} else if (deliveryZones.caba?.barrios) {
if (Array.isArray(deliveryZones.caba.barrios)) names.push(...deliveryZones.caba.barrios);
} else {
for (const [k, v] of Object.entries(deliveryZones)) {
if (v === true) names.push(k);
else if (v?.name) names.push(v.name);
}
}
return names.map((n) => String(n).toLowerCase().trim()).filter(Boolean);
}
/**
* Verifica si un texto de dirección menciona un barrio en zona.
* - Sin zonas configuradas: retorna `{ inZone: true, reason: "no_zones_configured" }`
* (no bloqueamos hasta que el comercio cargue las zonas).
* - Con zonas: matching simple por inclusion del barrio en el texto (lowercase, sin tildes).
*/
export function checkAddressInZone({ address, storeConfig }) {
const zones = getDeliveryZoneNames(storeConfig?.delivery_zones);
if (!zones.length) {
return { inZone: true, reason: "no_zones_configured", zones: [] };
}
const norm = String(address || "")
.toLowerCase()
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.trim();
if (!norm) return { inZone: false, reason: "empty_address", zones };
const match = zones.find((z) => {
const zNorm = z.normalize("NFD").replace(/[̀-ͯ]/g, "");
return norm.includes(zNorm);
});
if (match) return { inZone: true, reason: "matched", matched: match, zones };
return { inZone: false, reason: "not_in_zone", zones };
}
function summarizeDeliveryZones(deliveryZones) { function summarizeDeliveryZones(deliveryZones) {
if (!deliveryZones || typeof deliveryZones !== "object") return ""; if (!deliveryZones || typeof deliveryZones !== "object") return "";
const names = []; const names = [];

View File

@@ -19,6 +19,7 @@ import {
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";
import { runTurnXState } from "./machine/runner.js"; import { runTurnXState } from "./machine/runner.js";
import { insertAuditLog } from "../0-ui/db/repo.js";
// Feature flag para NLU modular // Feature flag para NLU modular
const USE_MODULAR_NLU = process.env.USE_MODULAR_NLU === "true"; const USE_MODULAR_NLU = process.env.USE_MODULAR_NLU === "true";
@@ -205,13 +206,24 @@ export async function runTurnV3({
const legacyResult = formatResult(result, prev_context, recentReplies, failedSearches); const legacyResult = formatResult(result, prev_context, recentReplies, failedSearches);
// Shadow mode: corre XState en paralelo, devuelve legacy, loguea diffs. // Shadow mode: corre XState en paralelo, devuelve legacy, persiste diffs
// estructurales en audit_log para revisarlos después.
if (shadowXState()) { if (shadowXState()) {
runTurnXState({ tenantId, chat_id, text, prev_state, prev_context, conversation_history }) runTurnXState({ tenantId, chat_id, text, prev_state, prev_context, conversation_history })
.then((xstateResult) => { .then(async (xstateResult) => {
const diffs = diffResults(legacyResult, xstateResult); const diffs = diffResults(legacyResult, xstateResult);
if (diffs.length) { if (!diffs.length) return;
console.log("[xstate-shadow] diffs", { chat_id, diffs }); try {
await insertAuditLog({
tenantId,
entityType: "xstate_shadow",
entityId: chat_id,
action: "diff",
changes: { diffs, prev_state, text_preview: String(text || "").slice(0, 80) },
actor: "system",
});
} catch (err) {
console.error("[xstate-shadow] audit_log failed", err?.message || err);
} }
}) })
.catch((err) => console.error("[xstate-shadow] error", err?.message || err)); .catch((err) => console.error("[xstate-shadow] error", err?.message || err));

View File

@@ -169,11 +169,8 @@ export async function listOrders({ tenantId, page = 1, limit = 50 }) {
total: row.total, total: row.total,
currency: row.currency, currency: row.currency,
date_created: row.date_created, date_created: row.date_created,
date_paid: row.date_paid,
source: row.source, source: row.source,
is_delivery: row.is_delivery, is_delivery: row.is_delivery,
is_cash: row.is_cash,
is_paid: ['processing', 'completed', 'on-hold'].includes(row.status),
is_test: false, // Podemos agregar este campo a la BD si es necesario is_test: false, // Podemos agregar este campo a la BD si es necesario
shipping: { shipping: {
first_name: firstName, first_name: firstName,
@@ -217,9 +214,7 @@ export async function getMonthlyStats({ tenantId }) {
SUM(CASE WHEN source = 'whatsapp' THEN total ELSE 0 END) as whatsapp_revenue, SUM(CASE WHEN source = 'whatsapp' THEN total ELSE 0 END) as whatsapp_revenue,
SUM(CASE WHEN source = 'web' THEN total ELSE 0 END) as web_revenue, SUM(CASE WHEN source = 'web' THEN total ELSE 0 END) as web_revenue,
SUM(CASE WHEN is_delivery THEN total ELSE 0 END) as delivery_revenue, SUM(CASE WHEN is_delivery THEN total ELSE 0 END) as delivery_revenue,
SUM(CASE WHEN NOT is_delivery THEN total ELSE 0 END) as pickup_revenue, SUM(CASE WHEN NOT is_delivery THEN total ELSE 0 END) as pickup_revenue
SUM(CASE WHEN is_cash THEN total ELSE 0 END) as cash_revenue,
SUM(CASE WHEN NOT is_cash THEN total ELSE 0 END) as card_revenue
FROM woo_orders_cache FROM woo_orders_cache
WHERE tenant_id = $1 WHERE tenant_id = $1
GROUP BY TO_CHAR(date_created, 'YYYY-MM') GROUP BY TO_CHAR(date_created, 'YYYY-MM')
@@ -242,10 +237,6 @@ export async function getMonthlyStats({ tenantId }) {
delivery: rows.map(r => parseFloat(r.delivery_revenue) || 0), delivery: rows.map(r => parseFloat(r.delivery_revenue) || 0),
pickup: rows.map(r => parseFloat(r.pickup_revenue) || 0), pickup: rows.map(r => parseFloat(r.pickup_revenue) || 0),
}, },
by_payment: {
cash: rows.map(r => parseFloat(r.cash_revenue) || 0),
card: rows.map(r => parseFloat(r.card_revenue) || 0),
},
}; };
} }
@@ -379,8 +370,6 @@ export async function getTotals({ tenantId }) {
SUM(CASE WHEN source = 'web' THEN total ELSE 0 END) as web_total, SUM(CASE WHEN source = 'web' THEN total ELSE 0 END) as web_total,
SUM(CASE WHEN is_delivery THEN total ELSE 0 END) as delivery_total, SUM(CASE WHEN is_delivery THEN total ELSE 0 END) as delivery_total,
SUM(CASE WHEN NOT is_delivery THEN total ELSE 0 END) as pickup_total, SUM(CASE WHEN NOT is_delivery THEN total ELSE 0 END) as pickup_total,
SUM(CASE WHEN is_cash THEN total ELSE 0 END) as cash_total,
SUM(CASE WHEN NOT is_cash THEN total ELSE 0 END) as card_total,
COUNT(*) as total_orders, COUNT(*) as total_orders,
SUM(total) as total_revenue SUM(total) as total_revenue
FROM woo_orders_cache FROM woo_orders_cache
@@ -398,10 +387,6 @@ export async function getTotals({ tenantId }) {
delivery: parseFloat(r.delivery_total) || 0, delivery: parseFloat(r.delivery_total) || 0,
pickup: parseFloat(r.pickup_total) || 0, pickup: parseFloat(r.pickup_total) || 0,
}, },
by_payment: {
cash: parseFloat(r.cash_total) || 0,
card: parseFloat(r.card_total) || 0,
},
total_orders: parseInt(r.total_orders) || 0, total_orders: parseInt(r.total_orders) || 0,
total_revenue: parseFloat(r.total_revenue) || 0, total_revenue: parseFloat(r.total_revenue) || 0,
}; };

View File

@@ -340,25 +340,12 @@ function normalizeWooOrder(order) {
!wooShippingMethod.toLowerCase().includes("local"); !wooShippingMethod.toLowerCase().includes("local");
} }
// Método de pago
const metaPaymentMethod = order.meta_data?.find(m => m.key === "payment_method_wa")?.value || null;
const paymentMethod = order.payment_method || null;
const paymentMethodTitle = order.payment_method_title || null;
const isCash = metaPaymentMethod === "cash" ||
paymentMethod === "cod" ||
paymentMethodTitle?.toLowerCase().includes("efectivo") ||
paymentMethodTitle?.toLowerCase().includes("cash");
const isPaid = ["processing", "completed", "on-hold"].includes(order.status);
const datePaid = order.date_paid || null;
return { return {
id: order.id, id: order.id,
status: order.status, status: order.status,
total: order.total, total: order.total,
currency: order.currency, currency: order.currency,
date_created: order.date_created, date_created: order.date_created,
date_paid: datePaid,
billing: { billing: {
first_name: order.billing?.first_name || "", first_name: order.billing?.first_name || "",
last_name: order.billing?.last_name || "", last_name: order.billing?.last_name || "",
@@ -395,10 +382,6 @@ function normalizeWooOrder(order) {
is_test: isTest, is_test: isTest,
shipping_method: shippingMethod, shipping_method: shippingMethod,
is_delivery: isDelivery, is_delivery: isDelivery,
payment_method: paymentMethod,
payment_method_title: paymentMethodTitle,
is_cash: isCash,
is_paid: isPaid,
raw: order, raw: order,
}; };
} }