diff --git a/db/migrations/20260203123701_drop_mp_payments.sql b/db/migrations/20260203123701_drop_mp_payments.sql new file mode 100644 index 0000000..848c9f0 --- /dev/null +++ b/db/migrations/20260203123701_drop_mp_payments.sql @@ -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); diff --git a/db/migrations/20260204155247_delivery_zones.sql b/db/migrations/20260204155247_delivery_zones.sql new file mode 100644 index 0000000..98bb674 --- /dev/null +++ b/db/migrations/20260204155247_delivery_zones.sql @@ -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; diff --git a/package-lock.json b/package-lock.json index 0372d54..ee666ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/public/components/orders-crud.js b/public/components/orders-crud.js index 576ca59..5d898ab 100644 --- a/public/components/orders-crud.js +++ b/public/components/orders-crud.js @@ -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); } diff --git a/public/components/settings-crud.js b/public/components/settings-crud.js index f1a023e..60eef2b 100644 --- a/public/components/settings-crud.js +++ b/public/components/settings-crud.js @@ -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; }
@@ -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 ` +
+
+
+ ${barrio} +
+
+ ${DAYS.map(d => ` +
${d.short}
+ `).join("")} +
+
+ + +
+
+
+
+ `; + }).join(""); + } + + renderZonesSummary() { + const zones = this.settings?.delivery_zones || {}; + const activeZones = Object.entries(zones).filter(([k, v]) => v?.enabled); + + if (activeZones.length === 0) { + return `
No hay zonas de entrega configuradas. Activá los barrios donde hacés delivery.
`; + } + + return `
${activeZones.length} zona${activeZones.length > 1 ? 's' : ''} de entrega activa${activeZones.length > 1 ? 's' : ''}
`; + } + render() { const content = this.shadowRoot.getElementById("content"); @@ -290,6 +430,24 @@ class SettingsCrud extends HTMLElement {
+ +
+
+ + Zonas de Entrega (Barrios CABA) +
+ + + +
+ ${this.renderZonesList()} +
+ + ${this.renderZonesSummary()} +
+
@@ -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 diff --git a/public/components/test-panel.js b/public/components/test-panel.js index 93de0fd..ef732cd 100644 --- a/public/components/test-panel.js +++ b/public/components/test-panel.js @@ -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 {
- -
-
2. Link de Pago (MercadoPago)
- -
-
Monto
-
- - -
-
- - - -
3. Simular Pago Exitoso
- -
-

- Simula el webhook de MercadoPago con status "approved". - Esto actualiza la orden en WooCommerce a "processing". -

- -
- - -
`; } @@ -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 = ""; } } diff --git a/public/lib/api.js b/public/lib/api.js index 65d5af6..3ce6583 100644 --- a/public/lib/api.js +++ b/public/lib/api.js @@ -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()); diff --git a/public/main.js b/public/main.js index e9bceb2..7ea0c6f 100644 --- a/public/main.js +++ b/public/main.js @@ -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 }) { diff --git a/src/app.js b/src/app.js index c40682f..abf03e4 100644 --- a/src/app.js +++ b/src/app.js @@ -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) diff --git a/src/modules/0-ui/controllers/testing.js b/src/modules/0-ui/controllers/testing.js index ff0da29..62f80d5 100644 --- a/src/modules/0-ui/controllers/testing.js +++ b/src/modules/0-ui/controllers/testing.js @@ -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" }); - } -}; diff --git a/src/modules/0-ui/db/settingsRepo.js b/src/modules/0-ui/db/settingsRepo.js index c75e938..1711ed5 100644 --- a/src/modules/0-ui/db/settingsRepo.js +++ b/src/modules/0-ui/db/settingsRepo.js @@ -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); diff --git a/src/modules/0-ui/handlers/settings.js b/src/modules/0-ui/handlers/settings.js index c31e295..86da45b 100644 --- a/src/modules/0-ui/handlers/settings.js +++ b/src/modules/0-ui/handlers/settings.js @@ -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", }; diff --git a/src/modules/0-ui/handlers/testing.js b/src/modules/0-ui/handlers/testing.js index 8225caa..59c76ea 100644 --- a/src/modules/0-ui/handlers/testing.js +++ b/src/modules/0-ui/handlers/testing.js @@ -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, - }; -} diff --git a/src/modules/1-intake/routes/simulator.js b/src/modules/1-intake/routes/simulator.js index 8d6121d..799e125 100644 --- a/src/modules/1-intake/routes/simulator.js +++ b/src/modules/1-intake/routes/simulator.js @@ -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; } diff --git a/src/modules/2-identity/db/repo.js b/src/modules/2-identity/db/repo.js index c9c9d73..2e704ed 100644 --- a/src/modules/2-identity/db/repo.js +++ b/src/modules/2-identity/db/repo.js @@ -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; -} diff --git a/src/modules/2-identity/services/pipeline.js b/src/modules/2-identity/services/pipeline.js index 50cd5c3..5531d12 100644 --- a/src/modules/2-identity/services/pipeline.js +++ b/src/modules/2-identity/services/pipeline.js @@ -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) }); diff --git a/src/modules/3-turn-engine/nlu/index.js b/src/modules/3-turn-engine/nlu/index.js index 3a3bc95..a5af1d4 100644 --- a/src/modules/3-turn-engine/nlu/index.js +++ b/src/modules/3-turn-engine/nlu/index.js @@ -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; } diff --git a/src/modules/3-turn-engine/stateHandlers/payment.js b/src/modules/3-turn-engine/stateHandlers/payment.js index 7bd7060..be7a667 100644 --- a/src/modules/3-turn-engine/stateHandlers/payment.js +++ b/src/modules/3-turn-engine/stateHandlers/payment.js @@ -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 { diff --git a/src/modules/6-mercadopago/controllers/mercadoPago.js b/src/modules/6-mercadopago/controllers/mercadoPago.js deleted file mode 100644 index eb2ba23..0000000 --- a/src/modules/6-mercadopago/controllers/mercadoPago.js +++ /dev/null @@ -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}`); - }; -} - diff --git a/src/modules/6-mercadopago/mercadoPago.js b/src/modules/6-mercadopago/mercadoPago.js deleted file mode 100644 index d90028c..0000000 --- a/src/modules/6-mercadopago/mercadoPago.js +++ /dev/null @@ -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 }; -} - diff --git a/src/modules/6-mercadopago/routes/mercadoPago.js b/src/modules/6-mercadopago/routes/mercadoPago.js deleted file mode 100644 index ad1ebe0..0000000 --- a/src/modules/6-mercadopago/routes/mercadoPago.js +++ /dev/null @@ -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; -} -