import { getDecryptedTenantEcommerceConfig } from "../2-identity/db/repo.js"; import { debug as dbg } from "../shared/debug.js"; import { getSnapshotPriceByWooId } from "../shared/wooSnapshot.js"; import * as ordersRepo from "./ordersRepo.js"; // --- Simple in-memory lock to serialize work per key --- const locks = new Map(); // --- Sync lock per tenant to prevent concurrent syncs --- const syncLocks = new Map(); const syncInProgress = 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, shippingMethod, 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); // Estado "pending" en Woo = el pago/cobro lo gestiona el comercio offline. 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 }] : []), ...(shippingMethod ? [{ key: "shipping_method", value: shippingMethod }] : []), ], }; 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 => normalizeWooOrder(order)); } /** * Normaliza un pedido de WooCommerce a formato interno */ function normalizeWooOrder(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 metaShippingMethod = order.meta_data?.find(m => m.key === "shipping_method")?.value || null; const shippingLines = order.shipping_lines || []; const wooShippingMethod = shippingLines[0]?.method_title || shippingLines[0]?.method_id || null; const shippingMethod = metaShippingMethod || wooShippingMethod; let isDelivery = false; if (metaShippingMethod) { isDelivery = metaShippingMethod === "delivery"; } else if (wooShippingMethod) { isDelivery = !wooShippingMethod.toLowerCase().includes("retiro") && !wooShippingMethod.toLowerCase().includes("pickup") && !wooShippingMethod.toLowerCase().includes("local"); } return { id: order.id, status: order.status, total: order.total, currency: order.currency, date_created: order.date_created, 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, product_id: li.product_id, variation_id: li.variation_id, quantity: li.quantity, total: li.total, subtotal: li.subtotal, sku: li.sku, meta_data: li.meta_data, })), source, run_id: runId, is_test: isTest, shipping_method: shippingMethod, is_delivery: isDelivery, raw: order, }; } /** * Detecta la unidad de venta de un line item */ function detectSellUnit(lineItem) { // 1. Buscar en meta_data const unitMeta = lineItem.meta_data?.find(m => m.key === "unit"); if (unitMeta?.value === "g" || unitMeta?.value === "kg") return "kg"; if (unitMeta?.value === "unit") return "unit"; // 2. Detectar por nombre del producto const name = (lineItem.name || "").toLowerCase(); if (name.includes("kg") || name.includes("kilo")) return "kg"; // 3. Default a unit return "unit"; } /** * Sincroniza pedidos de WooCommerce a la cache local (incremental) * Usa un lock por tenant para evitar syncs concurrentes */ export async function syncOrdersIncremental({ tenantId }) { // Si ya hay un sync en progreso para este tenant, esperar a que termine const existingPromise = syncInProgress.get(tenantId); if (existingPromise) { console.log(`[wooOrders] syncOrdersIncremental already in progress for tenant ${tenantId}, waiting...`); return existingPromise; } // Crear promise para este sync y registrarla const syncPromise = doSyncOrdersIncremental({ tenantId }); syncInProgress.set(tenantId, syncPromise); try { const result = await syncPromise; return result; } finally { syncInProgress.delete(tenantId); } } /** * Implementación interna del sync (sin lock) * Procesa e inserta página por página para: * - Bajo consumo de RAM (solo ~100 pedidos en memoria a la vez) * - Resiliencia a cortes (progreso se guarda en DB) * - Sync incremental real (puede resumir desde donde quedó) */ async function doSyncOrdersIncremental({ tenantId }) { const client = await getWooClient({ tenantId }); const latestDate = await ordersRepo.getLatestOrderDate({ tenantId }); let synced = 0; let page = 1; const perPage = 100; // Máximo permitido por Woo console.log(`[wooOrders] syncOrdersIncremental starting, latestDate: ${latestDate || 'none (full sync)'}`); while (true) { // Construir URL con paginación let url = `${client.base}/orders?per_page=${perPage}&page=${page}&orderby=date&order=desc`; // Si tenemos fecha, filtrar solo los más recientes if (latestDate) { const afterDate = new Date(latestDate); afterDate.setMinutes(afterDate.getMinutes() - 1); url += `&after=${afterDate.toISOString()}`; } const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader }); if (!Array.isArray(data) || data.length === 0) { break; } // Procesar e insertar INMEDIATAMENTE esta página for (const rawOrder of data) { const order = normalizeWooOrder(rawOrder); const cacheOrder = { woo_order_id: order.id, status: order.status, total: parseFloat(order.total) || 0, currency: order.currency, date_created: order.date_created, date_paid: order.date_paid, source: order.source, is_delivery: order.is_delivery, is_cash: order.is_cash, customer_name: `${order.billing.first_name} ${order.billing.last_name}`.trim(), customer_phone: order.billing.phone, customer_email: order.billing.email, shipping_address_1: order.shipping.address_1, shipping_address_2: order.shipping.address_2, shipping_city: order.shipping.city, shipping_state: order.shipping.state, shipping_postcode: order.shipping.postcode, shipping_country: "AR", billing_address_1: order.billing.address_1, billing_city: order.billing.city, billing_state: order.billing.state, billing_postcode: order.billing.postcode, raw: order.raw, }; await ordersRepo.upsertOrder({ tenantId, order: cacheOrder }); const items = order.line_items.map(li => ({ woo_product_id: li.product_id || li.variation_id, product_name: li.name, sku: li.sku, quantity: parseFloat(li.quantity) || 0, unit_price: li.subtotal ? parseFloat(li.subtotal) / (parseFloat(li.quantity) || 1) : null, line_total: parseFloat(li.total) || 0, sell_unit: detectSellUnit(li), })); await ordersRepo.upsertOrderItems({ tenantId, wooOrderId: order.id, items }); synced++; } // Log de progreso después de insertar const totalInCache = await ordersRepo.countOrders({ tenantId }); console.log(`[wooOrders] syncOrdersIncremental page ${page}: +${data.length} orders (${totalInCache} total in DB)`); // Si es última página if (data.length < perPage) { break; } page++; } const totalInCache = await ordersRepo.countOrders({ tenantId }); console.log(`[wooOrders] syncOrdersIncremental completed: ${synced} synced, ${totalInCache} total in cache`); return { synced, total: totalInCache }; }