Files
botino/src/modules/4-woo-orders/wooOrders.js
2026-01-25 20:51:33 -03:00

382 lines
14 KiB
JavaScript

import { getDecryptedTenantEcommerceConfig } from "../2-identity/db/repo.js";
import { debug as dbg } from "../shared/debug.js";
import { getSnapshotPriceByWooId } from "../shared/wooSnapshot.js";
// --- Simple in-memory lock to serialize work per key ---
const locks = new Map();
async function withLock(key, fn) {
const prev = locks.get(key) || Promise.resolve();
let release;
const next = new Promise((r) => (release = r));
locks.set(key, prev.then(() => next));
await prev;
try {
return await fn();
} finally {
release();
if (locks.get(key) === next) locks.delete(key);
}
}
async function fetchWoo({ url, method = "GET", body = null, timeout = 20000, headers = {} }) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new Error("timeout")), timeout);
const t0 = Date.now();
try {
const res = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
...headers,
},
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
if (dbg.wooHttp) console.log("[wooOrders] http", method, res.status, Date.now() - t0, "ms");
const text = await res.text();
let parsed;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = text;
}
if (!res.ok) {
const err = new Error(`Woo HTTP ${res.status}`);
err.status = res.status;
err.body = parsed;
err.url = url;
err.method = method;
throw err;
}
return parsed;
} catch (e) {
const err = new Error(`Woo request failed after ${Date.now() - t0}ms: ${e.message}`);
err.cause = e;
err.status = e?.status || null;
err.body = e?.body || null;
err.url = url;
err.method = method;
throw err;
} finally {
clearTimeout(timer);
}
}
async function getWooClient({ tenantId }) {
const encryptionKey = process.env.APP_ENCRYPTION_KEY;
if (!encryptionKey) throw new Error("APP_ENCRYPTION_KEY is required to decrypt Woo credentials");
const cfg = await getDecryptedTenantEcommerceConfig({
tenant_id: tenantId,
provider: "woo",
encryption_key: encryptionKey,
});
if (!cfg) throw new Error("Woo config not found for tenant");
const consumerKey =
cfg.consumer_key ||
process.env.WOO_CONSUMER_KEY ||
(() => {
throw new Error("consumer_key not set");
})();
const consumerSecret =
cfg.consumer_secret ||
process.env.WOO_CONSUMER_SECRET ||
(() => {
throw new Error("consumer_secret not set");
})();
const base = cfg.base_url.replace(/\/+$/, "");
const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString("base64");
return {
base,
authHeader: { Authorization: `Basic ${auth}` },
timeout: Math.max(cfg.timeout_ms ?? 60000, 60000),
};
}
function parsePrice(p) {
if (p == null) return null;
const n = Number(String(p).replace(",", "."));
return Number.isFinite(n) ? n : null;
}
async function getWooProductPrice({ tenantId, productId }) {
if (!productId) return null;
const snap = await getSnapshotPriceByWooId({ tenantId, wooId: productId });
if (snap != null) return Number(snap);
const client = await getWooClient({ tenantId });
const url = `${client.base}/products/${encodeURIComponent(productId)}`;
const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
return parsePrice(data?.price ?? data?.regular_price ?? data?.sale_price);
}
function normalizeBasketItems(basket) {
const items = Array.isArray(basket?.items) ? basket.items : [];
return items.filter((it) => it && it.product_id && it.quantity && it.unit);
}
function toMoney(value) {
const n = Number(value);
if (!Number.isFinite(n)) return null;
return (Math.round(n * 100) / 100).toFixed(2);
}
async function buildLineItems({ tenantId, basket }) {
const items = normalizeBasketItems(basket);
const lineItems = [];
for (const it of items) {
const productId = Number(it.product_id);
const unit = String(it.unit).toLowerCase();
const qty = Number(it.quantity);
if (!productId || !Number.isFinite(qty) || qty <= 0) continue;
const pricePerKg = await getWooProductPrice({ tenantId, productId });
if (unit === "unit") {
const total = pricePerKg != null ? toMoney(pricePerKg * qty) : null;
lineItems.push({
product_id: productId,
...(it.variation_id ? { variation_id: it.variation_id } : {}),
quantity: Math.round(qty),
...(total ? { subtotal: total, total } : {}),
meta_data: [
{ key: "unit", value: "unit" },
],
});
continue;
}
// Carne por peso: convertir a gramos
// Si qty < 100, asumir que viene en kg (ej: 1.5 kg)
// Si qty >= 100, asumir que ya viene en gramos (ej: 1500 g)
const grams = qty < 100 ? Math.round(qty * 1000) : Math.round(qty);
const kilos = grams / 1000;
const total = pricePerKg != null ? toMoney(pricePerKg * kilos) : null;
lineItems.push({
product_id: productId,
...(it.variation_id ? { variation_id: it.variation_id } : {}),
quantity: 1,
...(total ? { subtotal: total, total } : {}),
meta_data: [
{ key: "unit", value: "g" },
{ key: "weight_g", value: grams },
{ key: "unit_price_per_kg", value: pricePerKg },
],
});
}
return lineItems;
}
function mapAddress(address) {
if (!address || typeof address !== "object") return null;
// Generar email fallback si no hay uno válido (usa formato wa_chat_id)
let email = address.email || "";
if (!email || !email.includes("@")) {
const phone = address.phone || "";
// Formato: {phone}@s.whatsapp.net (igual que wa_chat_id)
email = phone ? `${phone.replace(/[^0-9]/g, "")}@s.whatsapp.net` : `anon-${Date.now()}@s.whatsapp.net`;
}
return {
first_name: address.first_name || "",
last_name: address.last_name || "",
address_1: address.address_1 || address.text || "",
address_2: address.address_2 || "",
city: address.city || "",
state: address.state || "",
postcode: address.postcode || "",
country: address.country || "AR",
phone: address.phone || "",
email,
};
}
export async function createOrder({ tenantId, wooCustomerId, basket, address, run_id }) {
const lockKey = `${tenantId}:${wooCustomerId || "anon"}`;
return withLock(lockKey, async () => {
const client = await getWooClient({ tenantId });
const lineItems = await buildLineItems({ tenantId, basket });
if (!lineItems.length) throw new Error("order_empty_basket");
const addr = mapAddress(address);
const payload = {
status: "pending",
customer_id: wooCustomerId || undefined,
line_items: lineItems,
...(addr ? { billing: addr, shipping: addr } : {}),
meta_data: [
{ key: "source", value: "whatsapp" },
...(run_id ? [{ key: "run_id", value: run_id }] : []),
],
};
const url = `${client.base}/orders`;
const data = await fetchWoo({ url, method: "POST", body: payload, timeout: client.timeout, headers: client.authHeader });
return { id: data?.id || null, raw: data, line_items: lineItems };
});
}
export async function updateOrder({ tenantId, wooOrderId, basket, address, run_id }) {
if (!wooOrderId) throw new Error("missing_woo_order_id");
const lockKey = `${tenantId}:order:${wooOrderId}`;
return withLock(lockKey, async () => {
const client = await getWooClient({ tenantId });
const lineItems = await buildLineItems({ tenantId, basket });
if (!lineItems.length) throw new Error("order_empty_basket");
const addr = mapAddress(address);
const payload = {
line_items: lineItems,
...(addr ? { billing: addr, shipping: addr } : {}),
meta_data: [
{ key: "source", value: "whatsapp" },
...(run_id ? [{ key: "run_id", value: run_id }] : []),
],
};
const url = `${client.base}/orders/${encodeURIComponent(wooOrderId)}`;
const data = await fetchWoo({ url, method: "PUT", body: payload, timeout: client.timeout, headers: client.authHeader });
return { id: data?.id || wooOrderId, raw: data, line_items: lineItems };
});
}
function isRetryableNetworkError(err) {
const e0 = err;
const e1 = err?.cause;
const e2 = err?.cause?.cause;
const candidates = [e0, e1, e2].filter(Boolean);
const codes = new Set(candidates.map((e) => e.code).filter(Boolean));
const names = new Set(candidates.map((e) => e.name).filter(Boolean));
const messages = candidates.map((e) => String(e.message || "")).join(" | ").toLowerCase();
const aborted =
names.has("AbortError") ||
messages.includes("aborted") ||
messages.includes("timeout") ||
messages.includes("timed out");
const retryCodes = new Set(["ECONNRESET", "ETIMEDOUT", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_SOCKET"]);
const byCode = [...codes].some((c) => retryCodes.has(c));
return aborted || byCode;
}
async function getOrderStatus({ client, wooOrderId }) {
const url = `${client.base}/orders/${encodeURIComponent(wooOrderId)}`;
const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
return { id: data?.id, status: data?.status, raw: data };
}
export async function updateOrderStatus({ tenantId, wooOrderId, status }) {
if (!wooOrderId) throw new Error("missing_woo_order_id");
const lockKey = `${tenantId}:order:${wooOrderId}:status`;
return withLock(lockKey, async () => {
const client = await getWooClient({ tenantId });
const payload = { status };
const url = `${client.base}/orders/${encodeURIComponent(wooOrderId)}`;
// Timeout corto para el PUT (Woo procesa pero tarda en responder)
const putTimeout = 3000;
try {
const data = await fetchWoo({ url, method: "PUT", body: payload, timeout: putTimeout, headers: client.authHeader });
return { id: data?.id || wooOrderId, raw: data };
} catch (err) {
// Si es timeout, verificar si el status cambió con un GET
if (isRetryableNetworkError(err)) {
if (dbg.wooHttp) console.log("[wooOrders] updateOrderStatus timeout, checking with GET...");
try {
const current = await getOrderStatus({ client, wooOrderId });
// Si el status ya es el deseado, la operación fue exitosa
if (current.status === status) {
if (dbg.wooHttp) console.log("[wooOrders] updateOrderStatus confirmed via GET", { wooOrderId, status });
return { id: current.id || wooOrderId, raw: current.raw, recovered: true };
}
} catch (getErr) {
// Si falla el GET también, propagar el error original
if (dbg.wooHttp) console.log("[wooOrders] updateOrderStatus GET also failed:", getErr.message);
}
}
throw err;
}
});
}
export async function listRecentOrders({ tenantId, limit = 20 }) {
const client = await getWooClient({ tenantId });
const url = `${client.base}/orders?per_page=${limit}&orderby=date&order=desc`;
const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
if (!Array.isArray(data)) return [];
// Mapear a formato simplificado
return data.map(order => {
// Detectar si es orden de test (run_id empieza con "test-")
const runIdMeta = order.meta_data?.find(m => m.key === "run_id");
const runId = runIdMeta?.value || null;
const isTest = runId?.startsWith("test-") || false;
const sourceMeta = order.meta_data?.find(m => m.key === "source");
const source = sourceMeta?.value || "web";
// Método de envío (shipping)
const shippingLines = order.shipping_lines || [];
const shippingMethod = shippingLines[0]?.method_title || shippingLines[0]?.method_id || null;
const isDelivery = shippingMethod ?
!shippingMethod.toLowerCase().includes("retiro") &&
!shippingMethod.toLowerCase().includes("pickup") &&
!shippingMethod.toLowerCase().includes("local") : false;
// Método de pago
const paymentMethod = order.payment_method || null;
const paymentMethodTitle = order.payment_method_title || null;
const isCash = paymentMethod === "cod" ||
paymentMethodTitle?.toLowerCase().includes("efectivo") ||
paymentMethodTitle?.toLowerCase().includes("cash");
// Estado de pago (basado en status de la orden)
// pending = no pago, processing/completed = pago
const isPaid = ["processing", "completed", "on-hold"].includes(order.status);
const datePaid = order.date_paid || null;
return {
id: order.id,
status: order.status,
total: order.total,
currency: order.currency,
date_created: order.date_created,
date_paid: datePaid,
billing: {
first_name: order.billing?.first_name || "",
last_name: order.billing?.last_name || "",
phone: order.billing?.phone || "",
email: order.billing?.email || "",
address_1: order.billing?.address_1 || "",
address_2: order.billing?.address_2 || "",
city: order.billing?.city || "",
state: order.billing?.state || "",
postcode: order.billing?.postcode || "",
},
shipping: {
first_name: order.shipping?.first_name || "",
last_name: order.shipping?.last_name || "",
address_1: order.shipping?.address_1 || "",
address_2: order.shipping?.address_2 || "",
city: order.shipping?.city || "",
state: order.shipping?.state || "",
postcode: order.shipping?.postcode || "",
},
line_items: (order.line_items || []).map(li => ({
id: li.id,
name: li.name,
quantity: li.quantity,
total: li.total,
})),
source,
run_id: runId,
is_test: isTest,
// Shipping info
shipping_method: shippingMethod,
is_delivery: isDelivery,
// Payment info
payment_method: paymentMethod,
payment_method_title: paymentMethodTitle,
is_cash: isCash,
is_paid: isPaid,
};
});
}