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, }; }); }