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

@@ -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;
}