separated in modules
This commit is contained in:
238
src/modules/4-woo-orders/wooOrders.js
Normal file
238
src/modules/4-woo-orders/wooOrders.js
Normal file
@@ -0,0 +1,238 @@
|
||||
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 ?? 20000, 20000),
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
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,
|
||||
variation_id: it.variation_id ?? null,
|
||||
quantity: Math.round(qty),
|
||||
...(total ? { subtotal: total, total } : {}),
|
||||
meta_data: [
|
||||
{ key: "unit", value: "unit" },
|
||||
],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Carne por gramos (enteros): quantity=1 y total calculado en base a ARS/kg
|
||||
const grams = Math.round(qty);
|
||||
const kilos = grams / 1000;
|
||||
const total = pricePerKg != null ? toMoney(pricePerKg * kilos) : null;
|
||||
lineItems.push({
|
||||
product_id: productId,
|
||||
variation_id: it.variation_id ?? null,
|
||||
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;
|
||||
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: address.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 };
|
||||
});
|
||||
}
|
||||
|
||||
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)}`;
|
||||
const data = await fetchWoo({ url, method: "PUT", body: payload, timeout: client.timeout, headers: client.authHeader });
|
||||
return { id: data?.id || wooOrderId, raw: data };
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user