This commit is contained in:
Lucas Tettamanti
2026-02-04 16:06:51 -03:00
parent 2f8e267268
commit 5e79f17d00
21 changed files with 291 additions and 599 deletions

View File

@@ -0,0 +1,21 @@
-- migrate:up
-- Eliminar la tabla mp_payments (integración de MercadoPago removida)
drop table if exists mp_payments;
-- migrate:down
-- Recrear la tabla si se necesita rollback
create table if not exists mp_payments (
tenant_id uuid not null references tenants(id) on delete cascade,
woo_order_id bigint null,
preference_id text null,
payment_id text null,
status text null,
paid_at timestamptz null,
raw jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
primary key (tenant_id, payment_id)
);
create index if not exists mp_payments_tenant_order_idx
on mp_payments (tenant_id, woo_order_id);

View File

@@ -0,0 +1,7 @@
-- migrate:up
-- Agregar columna delivery_zones para configurar zonas de entrega por barrio CABA
ALTER TABLE tenant_settings
ADD COLUMN IF NOT EXISTS delivery_zones JSONB DEFAULT '{}';
-- migrate:down
ALTER TABLE tenant_settings DROP COLUMN IF EXISTS delivery_zones;

6
package-lock.json generated
View File

@@ -3163,9 +3163,9 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz",
"integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==",
"version": "7.19.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.19.1.tgz",
"integrity": "sha512-Gpq0iNm5M6cQWlyHQv9MV+uOj1jWk7LpkoE5vSp/7zjb4zMdAcUD+VL5y0nH4p9EbUklq00eVIIX/XcDHzu5xg==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"

View File

@@ -472,7 +472,7 @@ class OrdersCrud extends HTMLElement {
container.querySelectorAll("tr[data-order-id]").forEach(row => {
row.onclick = () => {
const orderId = parseInt(row.dataset.orderId);
const order = this.orders.find(o => o.id === orderId);
const order = this.orders.find(o => o.id == orderId);
if (order) {
this.selectOrder(order);
}

View File

@@ -1,13 +1,27 @@
import { api } from "../lib/api.js";
const DAYS = [
{ id: "lun", label: "Lunes", short: "Lun" },
{ id: "mar", label: "Martes", short: "Mar" },
{ id: "mie", label: "Miércoles", short: "Mié" },
{ id: "jue", label: "Jueves", short: "Jue" },
{ id: "vie", label: "Viernes", short: "Vie" },
{ id: "sab", label: "Sábado", short: "Sáb" },
{ id: "dom", label: "Domingo", short: "Dom" },
{ id: "lun", label: "Lunes", short: "L" },
{ id: "mar", label: "Martes", short: "M" },
{ id: "mie", label: "Miércoles", short: "X" },
{ id: "jue", label: "Jueves", short: "J" },
{ id: "vie", label: "Viernes", short: "V" },
{ id: "sab", label: "Sábado", short: "S" },
{ id: "dom", label: "Domingo", short: "D" },
];
// Lista oficial de 48 barrios de CABA
const CABA_BARRIOS = [
"Agronomía", "Almagro", "Balvanera", "Barracas", "Belgrano", "Boedo",
"Caballito", "Chacarita", "Coghlan", "Colegiales", "Constitución",
"Flores", "Floresta", "La Boca", "La Paternal", "Liniers",
"Mataderos", "Monte Castro", "Montserrat", "Nueva Pompeya", "Núñez",
"Palermo", "Parque Avellaneda", "Parque Chacabuco", "Parque Chas",
"Parque Patricios", "Puerto Madero", "Recoleta", "Retiro", "Saavedra",
"San Cristóbal", "San Nicolás", "San Telmo", "Vélez Sársfield", "Versalles",
"Villa Crespo", "Villa del Parque", "Villa Devoto", "Villa General Mitre",
"Villa Lugano", "Villa Luro", "Villa Ortúzar", "Villa Pueyrredón",
"Villa Real", "Villa Riachuelo", "Villa Santa Rita", "Villa Soldati", "Villa Urquiza"
];
class SettingsCrud extends HTMLElement {
@@ -113,6 +127,62 @@ class SettingsCrud extends HTMLElement {
}
.min-order-field { margin-top:16px; padding-top:16px; border-top:1px solid #1e2a3a; }
/* Zonas de entrega */
.zones-search { margin-bottom:12px; }
.zones-search input {
width:100%; padding:10px 14px;
background:#0f1520 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%236c7a89'%3E%3Cpath d='M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z'/%3E%3C/svg%3E") no-repeat 12px center;
background-size:18px; padding-left:38px;
}
.zones-list { max-height:400px; overflow-y:auto; display:flex; flex-direction:column; gap:4px; }
.zone-row {
display:grid;
grid-template-columns:32px 1fr;
gap:12px;
align-items:start;
padding:10px 12px;
background:#0f1520;
border-radius:8px;
border:1px solid #1e2a3a;
transition:border-color .2s;
}
.zone-row.active { border-color:#1f6feb; background:#0f1825; }
.zone-row.hidden { display:none; }
.zone-toggle {
width:32px; height:18px; background:#253245; border-radius:9px;
cursor:pointer; position:relative; transition:background .2s; margin-top:2px;
}
.zone-toggle.active { background:#2ecc71; }
.zone-toggle::after {
content:''; position:absolute; top:2px; left:2px;
width:14px; height:14px; background:#fff; border-radius:50%;
transition:transform .2s;
}
.zone-toggle.active::after { transform:translateX(14px); }
.zone-content { display:flex; flex-direction:column; gap:8px; }
.zone-name { font-size:14px; color:#e7eef7; font-weight:500; }
.zone-config { display:none; gap:16px; flex-wrap:wrap; align-items:center; }
.zone-row.active .zone-config { display:flex; }
.zone-days { display:flex; gap:4px; }
.zone-day {
width:28px; height:28px; border-radius:6px;
background:#253245; color:#8aa0b5;
display:flex; align-items:center; justify-content:center;
font-size:11px; font-weight:600; cursor:pointer;
transition:all .15s;
}
.zone-day.active { background:#1f6feb; color:#fff; }
.zone-day:hover { background:#2d3e52; }
.zone-day.active:hover { background:#1a5fd0; }
.zone-cost { display:flex; align-items:center; gap:6px; }
.zone-cost label { font-size:12px; color:#8aa0b5; }
.zone-cost input { width:90px; padding:6px 10px; font-size:13px; text-align:right; }
.zones-summary {
margin-top:12px; padding:12px; background:#0f1520;
border-radius:8px; font-size:13px; color:#8aa0b5;
}
.zones-summary strong { color:#e7eef7; }
</style>
<div class="container">
@@ -138,6 +208,10 @@ class SettingsCrud extends HTMLElement {
if (!this.settings.schedule) {
this.settings.schedule = { delivery: {}, pickup: {} };
}
// Asegurar que delivery_zones existe
if (!this.settings.delivery_zones) {
this.settings.delivery_zones = {};
}
this.loading = false;
this.render();
} catch (e) {
@@ -201,6 +275,72 @@ class SettingsCrud extends HTMLElement {
}).join("");
}
// Convierte nombre de barrio a key (ej: "Villa Crespo" -> "villa_crespo")
barrioToKey(name) {
return name.toLowerCase()
.normalize("NFD").replace(/[\u0300-\u036f]/g, "") // quitar acentos
.replace(/\s+/g, "_");
}
getZoneConfig(barrioKey) {
return this.settings?.delivery_zones?.[barrioKey] || null;
}
setZoneConfig(barrioKey, config) {
if (!this.settings.delivery_zones) {
this.settings.delivery_zones = {};
}
if (config === null) {
delete this.settings.delivery_zones[barrioKey];
} else {
this.settings.delivery_zones[barrioKey] = config;
}
}
renderZonesList() {
return CABA_BARRIOS.map(barrio => {
const key = this.barrioToKey(barrio);
const config = this.getZoneConfig(key);
const isActive = config?.enabled === true;
const days = config?.days || [];
const cost = config?.delivery_cost || 0;
return `
<div class="zone-row ${isActive ? 'active' : ''}" data-barrio="${key}">
<div class="zone-toggle ${isActive ? 'active' : ''}" data-barrio="${key}"></div>
<div class="zone-content">
<span class="zone-name">${barrio}</span>
<div class="zone-config">
<div class="zone-days">
${DAYS.map(d => `
<div class="zone-day ${days.includes(d.id) ? 'active' : ''}"
data-barrio="${key}" data-day="${d.id}"
title="${d.label}">${d.short}</div>
`).join("")}
</div>
<div class="zone-cost">
<label>Costo:</label>
<input type="number" class="zone-cost-input" data-barrio="${key}"
value="${cost}" min="0" step="100" placeholder="0" />
</div>
</div>
</div>
</div>
`;
}).join("");
}
renderZonesSummary() {
const zones = this.settings?.delivery_zones || {};
const activeZones = Object.entries(zones).filter(([k, v]) => v?.enabled);
if (activeZones.length === 0) {
return `<div class="zones-summary">No hay zonas de entrega configuradas. Activá los barrios donde hacés delivery.</div>`;
}
return `<div class="zones-summary"><strong>${activeZones.length}</strong> zona${activeZones.length > 1 ? 's' : ''} de entrega activa${activeZones.length > 1 ? 's' : ''}</div>`;
}
render() {
const content = this.shadowRoot.getElementById("content");
@@ -290,6 +430,24 @@ class SettingsCrud extends HTMLElement {
</div>
</div>
<!-- Zonas de Entrega -->
<div class="panel">
<div class="panel-title">
<svg viewBox="0 0 24 24"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>
Zonas de Entrega (Barrios CABA)
</div>
<div class="zones-search">
<input type="text" id="zoneSearch" placeholder="Buscar barrio..." />
</div>
<div class="zones-list" id="zonesList">
${this.renderZonesList()}
</div>
${this.renderZonesSummary()}
</div>
<div class="actions">
<button id="saveBtn" ${this.saving ? "disabled" : ""}>${this.saving ? "Guardando..." : "Guardar Configuración"}</button>
<button id="resetBtn" class="secondary">Restaurar</button>
@@ -359,6 +517,73 @@ class SettingsCrud extends HTMLElement {
// Reset button
this.shadowRoot.getElementById("resetBtn")?.addEventListener("click", () => this.load());
// Zone search
this.shadowRoot.getElementById("zoneSearch")?.addEventListener("input", (e) => {
const query = e.target.value.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
this.shadowRoot.querySelectorAll(".zone-row").forEach(row => {
const barrio = row.dataset.barrio;
const barrioName = CABA_BARRIOS.find(b => this.barrioToKey(b) === barrio) || "";
const normalized = barrioName.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
row.classList.toggle("hidden", query && !normalized.includes(query));
});
});
// Zone toggles
this.shadowRoot.querySelectorAll(".zone-toggle").forEach(toggle => {
toggle.addEventListener("click", () => {
const barrio = toggle.dataset.barrio;
const config = this.getZoneConfig(barrio);
if (config?.enabled) {
// Desactivar zona
this.setZoneConfig(barrio, null);
} else {
// Activar zona con días default (lun-sab)
this.setZoneConfig(barrio, {
enabled: true,
days: ["lun", "mar", "mie", "jue", "vie", "sab"],
delivery_cost: 0
});
}
this.render();
});
});
// Zone day toggles
this.shadowRoot.querySelectorAll(".zone-day").forEach(dayBtn => {
dayBtn.addEventListener("click", () => {
const barrio = dayBtn.dataset.barrio;
const day = dayBtn.dataset.day;
const config = this.getZoneConfig(barrio);
if (!config) return;
const days = config.days || [];
const idx = days.indexOf(day);
if (idx >= 0) {
days.splice(idx, 1);
} else {
days.push(day);
}
config.days = days;
this.setZoneConfig(barrio, config);
// Update UI without full re-render
dayBtn.classList.toggle("active", days.includes(day));
});
});
// Zone cost inputs
this.shadowRoot.querySelectorAll(".zone-cost-input").forEach(input => {
input.addEventListener("change", () => {
const barrio = input.dataset.barrio;
const config = this.getZoneConfig(barrio);
if (!config) return;
config.delivery_cost = parseFloat(input.value) || 0;
this.setZoneConfig(barrio, config);
});
});
}
collectScheduleFromInputs() {
@@ -386,6 +611,9 @@ class SettingsCrud extends HTMLElement {
// Collect schedule from inputs
const schedule = this.collectScheduleFromInputs();
// Collect delivery zones (already in settings from event handlers)
const delivery_zones = this.settings.delivery_zones || {};
const data = {
store_name: this.shadowRoot.getElementById("storeName")?.value || "",
bot_name: this.shadowRoot.getElementById("botName")?.value || "",
@@ -395,6 +623,7 @@ class SettingsCrud extends HTMLElement {
pickup_enabled: this.settings.pickup_enabled,
delivery_min_order: parseFloat(this.shadowRoot.getElementById("deliveryMinOrder")?.value) || 0,
schedule,
delivery_zones,
};
// Update settings with form values

View File

@@ -46,7 +46,6 @@ class TestPanel extends HTMLElement {
this.selectedProducts = [];
this.testUser = null;
this.lastOrder = null;
this.lastPaymentLink = null;
this.loading = false;
this.shadowRoot.innerHTML = `
@@ -66,11 +65,12 @@ class TestPanel extends HTMLElement {
height: 100%;
background: var(--bg);
color: var(--text);
display: grid;
grid-template-columns: 1fr 1fr;
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
overflow: auto;
max-width: 600px;
}
.panel {
background: var(--panel);
@@ -227,50 +227,6 @@ class TestPanel extends HTMLElement {
</div>
</div>
</div>
<div class="panel">
<div class="panel-title">2. Link de Pago (MercadoPago)</div>
<div class="section">
<div class="section-title">Monto</div>
<div class="row">
<input type="number" id="inputAmount" placeholder="Monto en ARS" class="flex-1" />
<button id="btnPaymentLink" disabled>Generar Link de Pago</button>
</div>
</div>
<div class="section" id="paymentResult" style="display:none;">
<div class="result success">
<div class="result-label">Link de pago</div>
<a class="result-link" id="paymentLinkValue" href="#" target="_blank">—</a>
<div style="margin-top:8px;">
<span class="result-label">Preference ID:</span>
<span id="preferenceIdValue">—</span>
</div>
</div>
</div>
<div class="panel-title" style="margin-top:24px;">3. Simular Pago Exitoso</div>
<div class="section">
<p style="font-size:12px;color:var(--muted);margin:0;">
Simula el webhook de MercadoPago con status "approved".
Esto actualiza la orden en WooCommerce a "processing".
</p>
<button id="btnSimulateWebhook" disabled>Simular Pago Exitoso</button>
</div>
<div class="section" id="webhookResult" style="display:none;">
<div class="result success">
<div class="result-label">Pago simulado</div>
<div class="result-value" id="webhookStatusValue">—</div>
<div style="margin-top:8px;">
<span class="result-label">Orden status:</span>
<span id="webhookOrderStatusValue">—</span>
</div>
</div>
</div>
</div>
</div>
`;
}
@@ -279,8 +235,6 @@ class TestPanel extends HTMLElement {
this.shadowRoot.getElementById("btnGenerate").onclick = () => this.generateRandomOrder();
this.shadowRoot.getElementById("btnClear").onclick = () => this.clearAll();
this.shadowRoot.getElementById("btnCreateOrder").onclick = () => this.createOrder();
this.shadowRoot.getElementById("btnPaymentLink").onclick = () => this.createPaymentLink();
this.shadowRoot.getElementById("btnSimulateWebhook").onclick = () => this.simulateWebhook();
this.loadProducts();
}
@@ -379,16 +333,7 @@ class TestPanel extends HTMLElement {
updateButtonStates() {
const hasProducts = this.selectedProducts.length > 0;
const hasOrder = this.lastOrder?.woo_order_id;
const hasPaymentLink = this.lastPaymentLink?.init_point;
this.shadowRoot.getElementById("btnCreateOrder").disabled = !hasProducts;
this.shadowRoot.getElementById("btnPaymentLink").disabled = !hasOrder;
this.shadowRoot.getElementById("btnSimulateWebhook").disabled = !hasOrder;
if (hasOrder) {
this.shadowRoot.getElementById("inputAmount").value = this.lastOrder.total || "";
}
}
async createOrder() {
@@ -433,101 +378,16 @@ class TestPanel extends HTMLElement {
}
}
async createPaymentLink() {
if (this.loading) return;
if (!this.lastOrder?.woo_order_id) {
modal.warn("Primero creá una orden");
return;
}
const amount = parseFloat(this.shadowRoot.getElementById("inputAmount").value);
if (!amount || amount <= 0) {
modal.warn("Ingresá un monto válido");
return;
}
this.loading = true;
const btn = this.shadowRoot.getElementById("btnPaymentLink");
btn.disabled = true;
btn.textContent = "Generando...";
try {
const result = await api.createPaymentLink({
woo_order_id: this.lastOrder.woo_order_id,
amount,
});
if (result.ok) {
this.lastPaymentLink = result;
const linkEl = this.shadowRoot.getElementById("paymentLinkValue");
linkEl.href = result.init_point || result.sandbox_init_point || "#";
linkEl.textContent = result.init_point || result.sandbox_init_point || "—";
this.shadowRoot.getElementById("preferenceIdValue").textContent = result.preference_id || "—";
this.shadowRoot.getElementById("paymentResult").style.display = "block";
} else {
modal.error("Error: " + (result.error || "Error desconocido"));
}
} catch (e) {
console.error("[test-panel] createPaymentLink error:", e);
modal.error("Error generando link: " + e.message);
} finally {
this.loading = false;
btn.textContent = "Generar Link de Pago";
this.updateButtonStates();
}
}
async simulateWebhook() {
if (this.loading) return;
if (!this.lastOrder?.woo_order_id) {
modal.warn("Primero creá una orden");
return;
}
this.loading = true;
const btn = this.shadowRoot.getElementById("btnSimulateWebhook");
btn.disabled = true;
btn.textContent = "Simulando...";
try {
const amount = parseFloat(this.shadowRoot.getElementById("inputAmount").value) || this.lastOrder.total || 0;
const result = await api.simulateMpWebhook({
woo_order_id: this.lastOrder.woo_order_id,
amount,
});
if (result.ok) {
this.shadowRoot.getElementById("webhookStatusValue").textContent = `Payment ${result.payment_id} - ${result.status}`;
this.shadowRoot.getElementById("webhookOrderStatusValue").textContent = result.order_status || "processing";
this.shadowRoot.getElementById("webhookResult").style.display = "block";
} else {
modal.error("Error: " + (result.error || "Error desconocido"));
}
} catch (e) {
console.error("[test-panel] simulateWebhook error:", e);
modal.error("Error simulando webhook: " + e.message);
} finally {
this.loading = false;
btn.textContent = "Simular Pago Exitoso";
this.updateButtonStates();
}
}
clearAll() {
this.selectedProducts = [];
this.testUser = null;
this.lastOrder = null;
this.lastPaymentLink = null;
this.renderProductList();
this.renderUserInfo();
this.updateButtonStates();
this.shadowRoot.getElementById("orderResult").style.display = "none";
this.shadowRoot.getElementById("paymentResult").style.display = "none";
this.shadowRoot.getElementById("webhookResult").style.display = "none";
this.shadowRoot.getElementById("inputAmount").value = "";
}
}

View File

@@ -214,22 +214,6 @@ export const api = {
}).then(r => r.json());
},
async createPaymentLink({ woo_order_id, amount }) {
return fetch("/test/payment-link", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ woo_order_id, amount }),
}).then(r => r.json());
},
async simulateMpWebhook({ woo_order_id, amount }) {
return fetch("/test/simulate-webhook", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ woo_order_id, amount }),
}).then(r => r.json());
},
// --- Prompts CRUD ---
async prompts() {
return fetch("/prompts").then(r => r.json());

View File

@@ -70,7 +70,6 @@ function upsertConversation(chat_id, patch) {
* - call LLM (structured output)
* - product search (LIMITED) + resolve ids
* - create/update Woo order
* - create MercadoPago link
* - save state
*/
async function processMessage({ chat_id, from, text }) {

View File

@@ -5,7 +5,6 @@ import { fileURLToPath } from "url";
import { createSimulatorRouter } from "./modules/1-intake/routes/simulator.js";
import { createEvolutionRouter } from "./modules/1-intake/routes/evolution.js";
import { createMercadoPagoRouter } from "./modules/6-mercadopago/routes/mercadoPago.js";
import { createWooWebhooksRouter } from "./modules/2-identity/routes/wooWebhooks.js";
export function createApp({ tenantId }) {
@@ -23,7 +22,6 @@ export function createApp({ tenantId }) {
// --- Integraciones / UI ---
app.use(createSimulatorRouter({ tenantId }));
app.use(createEvolutionRouter());
app.use("/payments/meli", createMercadoPagoRouter());
app.use(createWooWebhooksRouter());
// Home (UI)

View File

@@ -2,8 +2,6 @@ import {
handleListOrders,
handleGetProductsWithStock,
handleCreateTestOrder,
handleCreatePaymentLink,
handleSimulateMpWebhook,
} from "../handlers/testing.js";
import { handleGetOrderStats } from "../handlers/stats.js";
@@ -59,47 +57,3 @@ export const makeCreateTestOrder = (tenantIdOrFn) => async (req, res) => {
}
};
export const makeCreatePaymentLink = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const { woo_order_id, amount } = req.body || {};
if (!woo_order_id) {
return res.status(400).json({ ok: false, error: "woo_order_id_required" });
}
if (!amount || Number(amount) <= 0) {
return res.status(400).json({ ok: false, error: "amount_required" });
}
const result = await handleCreatePaymentLink({
tenantId,
wooOrderId: woo_order_id,
amount: Number(amount)
});
res.json(result);
} catch (err) {
console.error("[testing] createPaymentLink error:", err);
res.status(500).json({ ok: false, error: err.message || "internal_error" });
}
};
export const makeSimulateMpWebhook = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const { woo_order_id, amount } = req.body || {};
if (!woo_order_id) {
return res.status(400).json({ ok: false, error: "woo_order_id_required" });
}
const result = await handleSimulateMpWebhook({
tenantId,
wooOrderId: woo_order_id,
amount: Number(amount) || 0
});
res.json(result);
} catch (err) {
console.error("[testing] simulateMpWebhook error:", err);
res.status(500).json({ ok: false, error: err.message || "internal_error" });
}
};

View File

@@ -20,6 +20,7 @@ export async function getSettings({ tenantId }) {
pickup_hours_start::text as pickup_hours_start,
pickup_hours_end::text as pickup_hours_end,
schedule,
delivery_zones,
created_at, updated_at
FROM tenant_settings
WHERE tenant_id = $1
@@ -48,6 +49,7 @@ export async function upsertSettings({ tenantId, settings }) {
pickup_hours_start,
pickup_hours_end,
schedule,
delivery_zones,
} = settings;
const sql = `
@@ -55,9 +57,9 @@ export async function upsertSettings({ tenantId, settings }) {
tenant_id, store_name, bot_name, store_address, store_phone,
delivery_enabled, delivery_days, delivery_hours_start, delivery_hours_end, delivery_min_order,
pickup_enabled, pickup_days, pickup_hours_start, pickup_hours_end,
schedule
schedule, delivery_zones
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
ON CONFLICT (tenant_id) DO UPDATE SET
store_name = COALESCE(EXCLUDED.store_name, tenant_settings.store_name),
bot_name = COALESCE(EXCLUDED.bot_name, tenant_settings.bot_name),
@@ -73,6 +75,7 @@ export async function upsertSettings({ tenantId, settings }) {
pickup_hours_start = COALESCE(EXCLUDED.pickup_hours_start, tenant_settings.pickup_hours_start),
pickup_hours_end = COALESCE(EXCLUDED.pickup_hours_end, tenant_settings.pickup_hours_end),
schedule = COALESCE(EXCLUDED.schedule, tenant_settings.schedule),
delivery_zones = COALESCE(EXCLUDED.delivery_zones, tenant_settings.delivery_zones),
updated_at = NOW()
RETURNING
id, tenant_id,
@@ -85,6 +88,7 @@ export async function upsertSettings({ tenantId, settings }) {
pickup_hours_start::text as pickup_hours_start,
pickup_hours_end::text as pickup_hours_end,
schedule,
delivery_zones,
created_at, updated_at
`;
@@ -104,6 +108,7 @@ export async function upsertSettings({ tenantId, settings }) {
pickup_hours_start || null,
pickup_hours_end || null,
schedule ? JSON.stringify(schedule) : null,
delivery_zones ? JSON.stringify(delivery_zones) : null,
];
const { rows } = await pool.query(sql, params);

View File

@@ -42,6 +42,7 @@ export async function handleGetSettings({ tenantId }) {
pickup_hours_start: "08:00",
pickup_hours_end: "20:00",
schedule: createDefaultSchedule(),
delivery_zones: {},
is_default: true,
};
}
@@ -60,6 +61,7 @@ export async function handleGetSettings({ tenantId }) {
pickup_hours_start: settings.pickup_hours_start?.slice(0, 5) || "08:00",
pickup_hours_end: settings.pickup_hours_end?.slice(0, 5) || "20:00",
schedule,
delivery_zones: settings.delivery_zones || {},
is_default: false,
};
}
@@ -103,7 +105,8 @@ function buildScheduleFromLegacy(settings) {
function validateSchedule(schedule) {
if (!schedule || typeof schedule !== "object") return;
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/;
// Acepta HH:MM o HH:MM:SS (la BD puede devolver con segundos)
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$/;
for (const type of ["delivery", "pickup"]) {
const typeSchedule = schedule[type];
@@ -240,6 +243,7 @@ export async function handleSaveSettings({ tenantId, settings }) {
delivery_hours_end: result.delivery_hours_end?.slice(0, 5),
pickup_hours_start: result.pickup_hours_start?.slice(0, 5),
pickup_hours_end: result.pickup_hours_end?.slice(0, 5),
delivery_zones: result.delivery_zones || {},
},
message: "Configuración guardada correctamente",
};

View File

@@ -1,5 +1,4 @@
import { createOrder, syncOrdersIncremental } from "../../4-woo-orders/wooOrders.js";
import { createPreference, reconcilePayment } from "../../6-mercadopago/mercadoPago.js";
import { listProducts } from "../db/repo.js";
import * as ordersRepo from "../../4-woo-orders/ordersRepo.js";
@@ -73,74 +72,3 @@ export async function handleCreateTestOrder({ tenantId, basket, address, wa_chat
};
}
/**
* Crea un link de pago de MercadoPago
*/
export async function handleCreatePaymentLink({ tenantId, wooOrderId, amount }) {
if (!wooOrderId) {
throw new Error("missing_woo_order_id");
}
if (!amount || Number(amount) <= 0) {
throw new Error("invalid_amount");
}
const pref = await createPreference({
tenantId,
wooOrderId,
amount: Number(amount),
});
return {
ok: true,
preference_id: pref?.preference_id || null,
init_point: pref?.init_point || null,
sandbox_init_point: pref?.sandbox_init_point || null,
};
}
/**
* Simula un webhook de MercadoPago con pago exitoso
* No pasa por el endpoint real (requiere firma HMAC)
* Crea un payment mock y llama a reconcilePayment directamente
*/
export async function handleSimulateMpWebhook({ tenantId, wooOrderId, amount }) {
if (!wooOrderId) {
throw new Error("missing_woo_order_id");
}
// Crear payment mock con status approved
const mockPaymentId = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const mockPayment = {
id: mockPaymentId,
status: "approved",
status_detail: "accredited",
external_reference: `${tenantId}|${wooOrderId}`,
transaction_amount: Number(amount) || 0,
currency_id: "ARS",
date_approved: new Date().toISOString(),
date_created: new Date().toISOString(),
payment_method_id: "test",
payment_type_id: "credit_card",
payer: {
email: "test@test.com",
},
order: {
id: `pref-test-${wooOrderId}`,
},
};
// Reconciliar el pago (actualiza mp_payments y cambia status de orden a processing)
const result = await reconcilePayment({
tenantId,
payment: mockPayment,
});
return {
ok: true,
payment_id: mockPaymentId,
woo_order_id: result?.woo_order_id || wooOrderId,
status: "approved",
order_status: "processing",
reconciled: result?.payment || null,
};
}

View File

@@ -14,7 +14,7 @@ import { makeListPrompts, makeGetPrompt, makeSavePrompt, makeRollbackPrompt, mak
import { makeListPendingTakeovers, makeListAllTakeovers, makeGetTakeover, makeRespondToTakeover, makeCancelTakeover, makeCheckPendingTakeover } from "../../0-ui/controllers/takeovers.js";
import { makeGetSettings, makeSaveSettings } from "../../0-ui/controllers/settings.js";
import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../../0-ui/controllers/admin.js";
import { makeListOrders, makeGetOrderStats, makeGetProductsWithStock, makeCreateTestOrder, makeCreatePaymentLink, makeSimulateMpWebhook } from "../../0-ui/controllers/testing.js";
import { makeListOrders, makeGetOrderStats, makeGetProductsWithStock, makeCreateTestOrder } from "../../0-ui/controllers/testing.js";
function nowIso() {
return new Date().toISOString();
@@ -114,8 +114,6 @@ export function createSimulatorRouter({ tenantId }) {
// --- Testing routes ---
router.get("/test/products-with-stock", makeGetProductsWithStock(getTenantId));
router.post("/test/order", makeCreateTestOrder(getTenantId));
router.post("/test/payment-link", makeCreatePaymentLink(getTenantId));
router.post("/test/simulate-webhook", makeSimulateMpWebhook(getTenantId));
return router;
}

View File

@@ -733,51 +733,4 @@ export async function upsertProductEmbedding({
return rows[0] || null;
}
export async function upsertMpPayment({
tenant_id,
woo_order_id = null,
preference_id = null,
payment_id = null,
status = null,
paid_at = null,
raw = {},
}) {
if (!payment_id) throw new Error("payment_id_required");
const sql = `
insert into mp_payments
(tenant_id, woo_order_id, preference_id, payment_id, status, paid_at, raw, created_at, updated_at)
values
($1, $2, $3, $4, $5, $6::timestamptz, $7::jsonb, now(), now())
on conflict (tenant_id, payment_id)
do update set
woo_order_id = excluded.woo_order_id,
preference_id = excluded.preference_id,
status = excluded.status,
paid_at = excluded.paid_at,
raw = excluded.raw,
updated_at = now()
returning tenant_id, woo_order_id, preference_id, payment_id, status, paid_at, raw, updated_at
`;
const { rows } = await pool.query(sql, [
tenant_id,
woo_order_id,
preference_id,
payment_id,
status,
paid_at,
JSON.stringify(raw ?? {}),
]);
return rows[0] || null;
}
export async function getMpPaymentById({ tenant_id, payment_id }) {
const sql = `
select tenant_id, woo_order_id, preference_id, payment_id, status, paid_at, raw, updated_at
from mp_payments
where tenant_id=$1 and payment_id=$2
limit 1
`;
const { rows } = await pool.query(sql, [tenant_id, payment_id]);
return rows[0] || null;
}

View File

@@ -17,7 +17,6 @@ import { debug as dbg } from "../../shared/debug.js";
import { runTurnV3 } from "../../3-turn-engine/turnEngineV3.js";
import { safeNextState } from "../../3-turn-engine/fsm.js";
import { createOrder, updateOrder } from "../../4-woo-orders/wooOrders.js";
import { createPreference } from "../../6-mercadopago/mercadoPago.js";
import { handleCreateTakeover } from "../../0-ui/handlers/takeovers.js";
import { sendTextMessage, isEvolutionEnabled } from "../../1-intake/services/evolutionSender.js";
@@ -313,25 +312,6 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
pending_query: act.payload?.pending_query,
});
}
} else if (act.type === "send_payment_link") {
const total = Number(actionPatch?.order_total || reducedContext?.order_total || 0) || null;
if (!total || total <= 0) {
throw new Error("order_total_missing");
}
const pref = await createPreference({
tenantId,
wooOrderId: actionPatch.woo_order_id || reducedContext?.woo_order_id || prev?.last_order_id,
amount: total || 0,
});
actionPatch.payment_link = pref?.init_point || null;
actionPatch.mp = {
preference_id: pref?.preference_id || null,
init_point: pref?.init_point || null,
};
newTools.push({ type: "send_payment_link", ok: true, preference_id: pref?.preference_id || null });
if (pref?.init_point) {
plan.reply = `Listo, acá tenés el link de pago: ${pref.init_point}\nAvisame cuando esté listo.`;
}
}
} catch (e) {
newTools.push({ type: act.type, ok: false, error: String(e?.message || e) });

View File

@@ -162,6 +162,12 @@ function shouldSkipRouter(text, state, quickDomain) {
return true;
}
// En estado SHIPPING, si quickDomain ya detectó "shipping" (dirección), confiar en eso
// Esto evita que el router LLM clasifique direcciones como productos
if (state === "SHIPPING" && quickDomain === "shipping") {
return true;
}
return false;
}

View File

@@ -31,10 +31,6 @@ export async function handlePaymentState({ tenantId, text, nlu, order, audit })
currentOrder = { ...currentOrder, payment_type: paymentMethod };
actions.push({ type: "create_order", payload: { payment: paymentMethod } });
if (paymentMethod === "link") {
actions.push({ type: "send_payment_link", payload: {} });
}
const { next_state } = safeNextState(ConversationState.PAYMENT, currentOrder, { payment_selected: true });
const paymentLabel = paymentMethod === "cash" ? "efectivo" : "link de pago";
@@ -43,7 +39,7 @@ export async function handlePaymentState({ tenantId, text, nlu, order, audit })
: "Retiro en sucursal.";
const paymentInfo = paymentMethod === "link"
? "Te paso el link de pago en un momento."
? "Te contactamos para coordinar el pago."
: "Pagás en " + (currentOrder.is_delivery ? "la entrega" : "sucursal") + ".";
return {

View File

@@ -1,42 +0,0 @@
import { fetchPayment, reconcilePayment, verifyWebhookSignature } from "../mercadoPago.js";
export function makeMercadoPagoWebhook() {
return async function handleMercadoPagoWebhook(req, res) {
try {
const signature = verifyWebhookSignature({ headers: req.headers, query: req.query || {} });
if (!signature.ok) {
return res.status(401).json({ ok: false, error: "invalid_signature", reason: signature.reason });
}
const paymentId =
req?.query?.["data.id"] ||
req?.query?.data?.id ||
req?.body?.data?.id ||
null;
if (!paymentId) {
return res.status(400).json({ ok: false, error: "missing_payment_id" });
}
const payment = await fetchPayment({ paymentId });
const reconciled = await reconcilePayment({ payment });
return res.status(200).json({
ok: true,
payment_id: payment?.id || null,
status: payment?.status || null,
woo_order_id: reconciled?.woo_order_id || null,
});
} catch (e) {
return res.status(500).json({ ok: false, error: String(e?.message || e) });
}
};
}
export function makeMercadoPagoReturn() {
return function handleMercadoPagoReturn(req, res) {
const status = req.query?.status || "unknown";
res.status(200).send(`OK - ${status}`);
};
}

View File

@@ -1,178 +0,0 @@
import crypto from "crypto";
import { upsertMpPayment } from "../2-identity/db/repo.js";
import { updateOrderStatus } from "../4-woo-orders/wooOrders.js";
function getAccessToken() {
return process.env.MP_ACCESS_TOKEN || null;
}
function getWebhookSecret() {
return process.env.MP_WEBHOOK_SECRET || null;
}
function normalizeBaseUrl(base) {
if (!base) return null;
return base.endsWith("/") ? base : `${base}/`;
}
function getBaseUrl() {
return normalizeBaseUrl(process.env.MP_BASE_URL || process.env.MP_WEBHOOK_BASE_URL || null);
}
async function fetchMp({ url, method = "GET", body = null }) {
const token = getAccessToken();
if (!token) throw new Error("MP_ACCESS_TOKEN is not set");
const res = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let parsed;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = text;
}
if (!res.ok) {
const err = new Error(`MP HTTP ${res.status}`);
err.status = res.status;
err.body = parsed;
throw err;
}
return parsed;
}
export async function createPreference({
tenantId,
wooOrderId,
amount,
payer = null,
items = null,
baseUrl = null,
}) {
const root = normalizeBaseUrl(baseUrl || getBaseUrl());
if (!root) throw new Error("MP_BASE_URL is not set");
const notificationUrl = `${root}webhook/mercadopago`;
const backUrls = {
success: `${root}return?status=success`,
failure: `${root}return?status=failure`,
pending: `${root}return?status=pending`,
};
const statementDescriptor = process.env.MP_STATEMENT_DESCRIPTOR || "Whatsapp Store";
const externalReference = `${tenantId}|${wooOrderId}`;
const unitPrice = Number(amount);
if (!Number.isFinite(unitPrice)) throw new Error("invalid_amount");
const payload = {
auto_return: "approved",
back_urls: backUrls,
statement_descriptor: statementDescriptor,
binary_mode: false,
external_reference: externalReference,
items: Array.isArray(items) && items.length
? items
: [
{
id: String(wooOrderId || "order"),
title: "Productos x whatsapp",
quantity: 1,
currency_id: "ARS",
unit_price: unitPrice,
},
],
notification_url: notificationUrl,
...(payer ? { payer } : {}),
};
const data = await fetchMp({
url: "https://api.mercadopago.com/checkout/preferences",
method: "POST",
body: payload,
});
return {
preference_id: data?.id || null,
init_point: data?.init_point || null,
sandbox_init_point: data?.sandbox_init_point || null,
raw: data,
};
}
function parseSignatureHeader(header) {
const h = String(header || "");
const parts = h.split(",");
let ts = null;
let v1 = null;
for (const p of parts) {
const [k, v] = p.split("=");
if (!k || !v) continue;
const key = k.trim();
const val = v.trim();
if (key === "ts") ts = val;
if (key === "v1") v1 = val;
}
return { ts, v1 };
}
export function verifyWebhookSignature({ headers = {}, query = {} }) {
const secret = getWebhookSecret();
if (!secret) return { ok: false, reason: "MP_WEBHOOK_SECRET is not set" };
const xSignature = headers["x-signature"] || headers["X-Signature"] || headers["x-signature"];
const xRequestId = headers["x-request-id"] || headers["X-Request-Id"] || headers["x-request-id"];
const { ts, v1 } = parseSignatureHeader(xSignature);
const dataId = query["data.id"] || query?.data?.id || null;
if (!xRequestId || !ts || !v1 || !dataId) {
return { ok: false, reason: "missing_signature_fields" };
}
const manifest = `id:${String(dataId).toLowerCase()};request-id:${xRequestId};ts:${ts};`;
const hmac = crypto.createHmac("sha256", secret);
hmac.update(manifest);
const hash = hmac.digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(v1));
return ok ? { ok: true } : { ok: false, reason: "invalid_signature" };
}
export async function fetchPayment({ paymentId }) {
if (!paymentId) throw new Error("missing_payment_id");
return await fetchMp({
url: `https://api.mercadopago.com/v1/payments/${encodeURIComponent(paymentId)}`,
method: "GET",
});
}
export function parseExternalReference(externalReference) {
if (!externalReference) return { tenantId: null, wooOrderId: null };
const parts = String(externalReference).split("|").filter(Boolean);
if (parts.length >= 2) {
return { tenantId: parts[0], wooOrderId: Number(parts[1]) || null };
}
return { tenantId: null, wooOrderId: Number(externalReference) || null };
}
export async function reconcilePayment({ tenantId, payment }) {
const status = payment?.status || null;
const paidAt = payment?.date_approved || payment?.date_created || null;
const { tenantId: refTenantId, wooOrderId } = parseExternalReference(payment?.external_reference);
const resolvedTenantId = tenantId || refTenantId;
if (!resolvedTenantId) throw new Error("tenant_id_missing_from_payment");
const saved = await upsertMpPayment({
tenant_id: resolvedTenantId,
woo_order_id: wooOrderId,
preference_id: payment?.order?.id || payment?.preference_id || null,
payment_id: String(payment?.id || ""),
status,
paid_at: paidAt,
raw: payment,
});
if (status === "approved" && wooOrderId) {
await updateOrderStatus({ tenantId: resolvedTenantId, wooOrderId, status: "processing" });
}
return { payment: saved, woo_order_id: wooOrderId, tenant_id: resolvedTenantId };
}

View File

@@ -1,10 +0,0 @@
import express from "express";
import { makeMercadoPagoReturn, makeMercadoPagoWebhook } from "../controllers/mercadoPago.js";
export function createMercadoPagoRouter() {
const router = express.Router();
router.post("/webhook/mercadopago", makeMercadoPagoWebhook());
router.get("/return", makeMercadoPagoReturn());
return router;
}