20260204
This commit is contained in:
21
db/migrations/20260203123701_drop_mp_payments.sql
Normal file
21
db/migrations/20260203123701_drop_mp_payments.sql
Normal 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);
|
||||
7
db/migrations/20260204155247_delivery_zones.sql
Normal file
7
db/migrations/20260204155247_delivery_zones.sql
Normal 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
6
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = "";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user