top of page

// backend/printful/core.js // Pure logic, no wix-* imports, so it runs unchanged in Velo and in node tests. // All I/O is injected (fetchFn, token, clock). export class PrintfulError extends Error { /** * @param {string} message * @param {{status?: number, body?: any, path?: string}} [info] */ constructor(message, { status, body, path } = {}) { super(message); this.name = 'PrintfulError'; this.status = status; this.body = body; this.path = path; } } /** * Minimal Printful v1 client with a hard timeout. * @param {{fetchFn: Function, token: string, base?: string, timeoutMs?: number}} opts */ export function createPrintfulApi({ fetchFn, token, base = 'https://api.printful.com', timeoutMs = 6000 }) { if (!fetchFn) throw new Error('fetchFn required'); if (!token) throw new Error('Printful token missing'); /** * @param {string} method * @param {string} path * @param {{storeId?: string|number, body?: any, timeout?: number}} [opts] */ async function request(method, path, { storeId, body, timeout = timeoutMs } = {}) { const headers = { Authorization: `Bearer ${token}`, Accept: 'application/json' }; if (storeId) headers['X-PF-Store-Id'] = String(storeId); if (body !== undefined) headers['Content-Type'] = 'application/json'; const ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null; let timer; const timeoutP = new Promise((_, reject) => { timer = setTimeout(() => { if (ctrl) ctrl.abort(); reject(new PrintfulError(`Printful timeout after ${timeout}ms`, { status: 0, path })); }, timeout); }); try { const res = await Promise.race([ fetchFn(base + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), ...(ctrl ? { signal: ctrl.signal } : {}), }), timeoutP, ]); const text = await res.text(); let json; try { json = text ? JSON.parse(text) : {}; } catch (e) { json = { raw: text }; } if (!res.ok) { const msg = (json && (json.error && json.error.message)) || json.result || `HTTP ${res.status}`; throw new PrintfulError(String(msg), { status: res.status, body: json, path }); } return json; } finally { clearTimeout(timer); } } return { request }; } // ---------------------------------------------------------------- mapping const SKU_VARIANT_RE = /_(\d{3,7})$/; export function skuCatalogVariantId(sku) { if (!sku || typeof sku !== 'string') return null; const m = sku.trim().match(SKU_VARIANT_RE); return m ? Number(m[1]) : null; } /** * Build an index from detailed sync products ([{sync_product, sync_variants}]). * byExternalId: "<wixProductId>:<wixVariantId>" -> variant info * byProductExt: "<wixProductId>" -> product info * byName: normalized product name -> product info */ export function buildIndex(products) { const byExternalId = new Map(); const byProductExt = new Map(); const byName = new Map(); const byProductId = new Map(); for (const p of products || []) { const sp = p.sync_product || {}; const variants = (p.sync_variants || []) .filter((v) => v && v.is_ignored !== true && v.synced !== false) .map((v) => ({ syncVariantId: v.id, catalogVariantId: v.variant_id, externalId: v.external_id || null, sku: v.sku || null, retailPrice: v.retail_price, syncProductId: sp.id, productName: sp.name, })); const info = { syncProductId: sp.id, name: sp.name, externalId: sp.external_id || null, variants }; byProductId.set(String(sp.id), info); if (sp.external_id) byProductExt.set(String(sp.external_id), info); if (sp.name) { const k = normName(sp.name); // duplicate-name guard: ambiguous names resolve to null (override map required) byName.set(k, byName.has(k) ? null : info); } for (const v of variants) if (v.externalId) byExternalId.set(String(v.externalId), v); } const catalogIds = new Set(); for (const p of byProductId.values()) for (const v of p.variants) catalogIds.add(v.catalogVariantId); return { byExternalId, byProductExt, byName, byProductId, catalogIds, size: byProductId.size }; } export function normName(s) { return String(s || '').toLowerCase().replace(/\s+/g, ' ').trim(); } /** Pull productId / variantId / sku / qty from a Wix line item (shipping-rates request OR order). */ export function readWixLineItem(li) { const ref = li.catalogReference || {}; const opts = ref.options || {}; return { appId: ref.appId || null, productId: ref.catalogItemId || null, variantId: opts.variantId || null, sku: (li.physicalProperties && li.physicalProperties.sku) || li.sku || null, quantity: Number(li.quantity || 0), shippable: !(li.physicalProperties && li.physicalProperties.shippable === false), lineItemId: li._id || li.id || null, name: (li.productName && (li.productName.original || li.productName.translated)) || li.name || '', price: li.price && typeof li.price === 'object' ? li.price.amount : li.price, }; } /** * Resolve a Wix line item against the Wix-linked store index. * Returns {catalogVariantId, syncVariantId?, syncProductId?, productName?, via} or null. */ export function resolveWixItem(wixIndex, item) { if (item.productId && item.variantId) { const v = wixIndex.byExternalId.get(`${item.productId}:${item.variantId}`); if (v) return { ...v, via: 'external_id' }; } // products without variant options: external_id may be the bare product id if (item.productId && !item.variantId) { const p = wixIndex.byProductExt.get(String(item.productId)); if (p && p.variants.length === 1) return { ...p.variants[0], via: 'product_external_id' }; } const cv = skuCatalogVariantId(item.sku); if (cv) { const p = item.productId ? wixIndex.byProductExt.get(String(item.productId)) : null; const v = p && p.variants.find((x) => x.catalogVariantId === cv); if (v) return { ...v, via: 'sku+product' }; // only accept a SKU-derived id that is a catalog variant actually synced in the Printful store if (wixIndex.catalogIds && wixIndex.catalogIds.has(cv)) return { catalogVariantId: cv, via: 'sku' }; return null; } return null; } // ---------------------------------------------------------------- addresses export function stateCode(subdivision) { if (!subdivision) return undefined; const s = String(subdivision); return s.includes('-') ? s.split('-').pop() : s; } /** * Wix destination (+ contact) -> Printful recipient. * @param {Object} [dest] * @param {Object} [contact] */ export function toRecipient(dest = {}, contact = {}) { const street = dest.streetAddress ? [dest.streetAddress.number, dest.streetAddress.name].filter(Boolean).join(' ') : ''; const r = { address1: dest.addressLine1 || dest.addressLine || street || undefined, address2: dest.addressLine2 || undefined, city: dest.city || undefined, state_code: stateCode(dest.subdivision), country_code: dest.country || undefined, zip: dest.postalCode || undefined, phone: contact.phone || undefined, }; const name = [contact.firstName, contact.lastName].filter(Boolean).join(' '); if (name) r.name = name; if (contact.email) r.email = contact.email; if (contact.company) r.company = contact.company; Object.keys(r).forEach((k) => r[k] === undefined && delete r[k]); return r; } // ---------------------------------------------------------------- rates const PO_BOX_RE = /(\b(p\.?\s*o\.?\s*box|post\s+office\s+box|pob\s*\d+|apo|fpo|dpo)\b)|(^\s*box\s*#?\s*\d)/i; /** * PO Box / military (APO/FPO/DPO, state AA/AE/AP): Express not offered. * @param {Object} [dest] */ export function isPoBoxOrMilitary(dest = {}) { const st = stateCode(dest.subdivision); if (['AA', 'AE', 'AP'].includes(String(st || '').toUpperCase())) return true; const street = dest.streetAddress ? [dest.streetAddress.number, dest.streetAddress.name].join(' ') : ''; const lines = [dest.addressLine1, dest.addressLine, dest.addressLine2, street, dest.city].filter(Boolean); return lines.some((l) => PO_BOX_RE.test(String(l))); } /** * Item subtotal used for the free-shipping threshold, in integer cents. * Field used (Shipping Rates SPI line item): `totalPrice` = "total price of the line item * after discounts" (docs). Fallback: `price` ("single item after discounts") x quantity. * Shipping is not part of these fields. Only lines passing `include(li)` count (callers pass * "mapped Printful item", so gift cards / non-shippable / non-Printful lines are excluded). * taxIncluded (SPI `taxIncludedInPrices`): subtract the line tax (taxDetails.totalTax / tax / * taxAmount); if any counted line lacks a tax amount -> null (no free shipping). * Returns null if any counted line has no usable price (=> no free shipping, fail closed). */ export function lineTaxAmount(li) { const amt = (v) => (v && typeof v === 'object' ? v.amount : v); const cand = [li.taxDetails && li.taxDetails.totalTax, li.tax, li.taxAmount]; for (const c of cand) { const v = amt(c); if (v != null && v !== '' && Number.isFinite(Number(v))) return Number(v); } return null; } export function itemSubtotalCents(lineItems, { include = () => true, taxIncluded = false } = {}) { let cents = 0; for (const li of lineItems || []) { const qty = Number(li.quantity || 0); if (qty <= 0) continue; if (!include(li)) continue; const amt = (v) => (v && typeof v === 'object' ? v.amount : v); let line; if (amt(li.totalPrice) != null && amt(li.totalPrice) !== '') line = Number(amt(li.totalPrice)); else if (amt(li.price) != null && amt(li.price) !== '') line = Number(amt(li.price)) * qty; else return null; if (!Number.isFinite(line)) return null; if (taxIncluded) { const tax = lineTaxAmount(li); if (tax == null) return null; line -= tax; } cents += Math.round(line * 100); } return cents; } /** Strictly greater than the threshold, US only (cfg.FREE_SHIPPING_COUNTRIES). */ export function qualifiesFreeStandard(cfg, country, subtotalCents) { if (!cfg.FREE_SHIPPING_ENABLED) return false; if (!cfg.FREE_SHIPPING_COUNTRIES.includes(country)) return false; if (subtotalCents == null) return false; return subtotalCents > Math.round(Number(cfg.FREE_SHIPPING_THRESHOLD) * 100); } export function fallbackRates(cfg, country, free = false, currency = cfg.CURRENCY) { const price = free ? '0.00' : (country && cfg.FALLBACK_RATES[country]) || cfg.FALLBACK_RATES.DEFAULT; return [{ code: 'FALLBACK_STANDARD', title: free ? cfg.FREE_STANDARD_TITLE : cfg.FALLBACK_TITLE, logistics: { deliveryTime: cfg.FALLBACK_DELIVERY }, cost: { price: String(price), currency, additionalCharges: [] }, }]; } /** * "12.3" -> "12.30"; null when not a finite number (NaN guard). * @param {any} n * @param {number} [markup] */ export function money(n, markup = 0) { const v = Number(n) + Number(markup || 0); if (n === null || n === undefined || n === '' || !Number.isFinite(v)) return null; return (Math.round(v * 100) / 100).toFixed(2); } function deliveryText(r) { const a = r.minDeliveryDays, b = r.maxDeliveryDays; if (a && b) return a === b ? `${a} business days` : `${a}-${b} business days`; return undefined; } /** * Convert Printful v1 rates -> Wix shippingRates (STANDARD + optional PRINTFUL_FAST only). * @param {Array<Object>} pfRates * @param {Object} cfg * @param {string} country * @param {boolean} [free] * @param {{currency?: string, allowFast?: boolean}} [opts] */ export function mapRates(pfRates, cfg, country, free = false, { currency = cfg.CURRENCY, allowFast = true } = {}) { const out = []; const std = pfRates.find((r) => r.id === cfg.STANDARD_METHOD); const fast = pfRates.find((r) => r.id === cfg.FAST_METHOD); const sameCcy = (r) => !r.currency || String(r.currency).toUpperCase() === String(currency).toUpperCase(); const stdPrice = std && sameCcy(std) ? (free ? '0.00' : money(std.rate, cfg.MARKUP)) : null; if (stdPrice !== null) { out.push({ code: cfg.STANDARD_METHOD, title: free ? cfg.FREE_STANDARD_TITLE : 'Standard', logistics: { deliveryTime: deliveryText(std) || cfg.FALLBACK_DELIVERY }, cost: { price: stdPrice, currency, additionalCharges: [] }, }); } // Express is always charged at the live Printful rate (never free). const fastPrice = fast && sameCcy(fast) ? money(fast.rate, cfg.MARKUP) : null; if (fastPrice !== null && allowFast && cfg.FAST_ENABLED && cfg.FAST_COUNTRIES.includes(country)) { out.push({ code: cfg.FAST_METHOD, title: cfg.FAST_TITLE, logistics: { deliveryTime: deliveryText(fast) || '2-4 business days' }, cost: { price: fastPrice, currency, additionalCharges: [] }, }); } return out; } /** Site currency: options.currency / request.currency / context|metadata.currency, else cfg.CURRENCY. */ export function siteCurrency(options, context, cfg) { const req = (options && options.request) || options || {}; const meta = (options && options.metadata) || context || {}; return String(req.currency || (options && options.currency) || meta.currency || cfg.CURRENCY).toUpperCase(); } /** * Main shipping-rates logic. Never throws: any problem -> fallback. * One deadline (cfg.RATES_DEADLINE_MS, default 8 s) measured from entry covers secret/API * setup, index load and the Printful call, so Wix's 10 s limit is never reached. * deps: { cfg, getApi, getWixIndex, log, now? } */ export async function computeShippingRates(options, deps, context) { const { cfg, log = () => {} } = deps; const now = deps.now || (() => Date.now()); const req = (options && options.request) || options || {}; const dest = req.shippingDestination || {}; const country = dest.country; const currency = siteCurrency(options, context, cfg); const taxIncluded = !!(req.taxIncludedInPrices || (options && options.taxIncludedInPrices)); let index = null; // set once loaded; subtotal counts only lines that map to a Printful variant const isPrintfulLine = (li) => { const it = readWixLineItem(li); if (!it.shippable) return false; return index ? !!resolveWixItem(index, it) : true; // index unavailable: best effort = shippable lines }; const freeNow = () => currency === cfg.CURRENCY && qualifiesFreeStandard(cfg, country, itemSubtotalCents(req.lineItems, { include: isPrintfulLine, taxIncluded })); let free = false; // fallback amounts are defined in cfg.CURRENCY; for another site currency return none rather than a wrong amount const fb = () => ({ shippingRates: currency === cfg.CURRENCY ? fallbackRates(cfg, country, freeNow(), currency) : [] }); const t0 = now(); const deadline = cfg.RATES_DEADLINE_MS || 8000; const minLeft = cfg.MIN_REMAINING_MS || 1000; const remaining = () => deadline - (now() - t0); const budget = (what) => { const r = remaining(); if (r < minLeft) throw new Error(`deadline: ${r}ms left before ${what}`); return r; }; try { if (!cfg.LIVE_RATES_ENABLED) return fb(); if (!country || !(dest.postalCode || country !== 'US')) return fb(); const items = (req.lineItems || []).map(readWixLineItem).filter((i) => i.shippable && i.quantity > 0); if (!items.length) return fb(); // bounded by the single deadline; whatever it leaves is checked again before getApi/rates index = await withTimeout(deps.getWixIndex(), budget('index'), 'index load'); const pfItems = []; for (const it of items) { const r = resolveWixItem(index, it); if (!r) { log('warn', 'unmapped item -> fallback', { productId: it.productId, variantId: it.variantId }); return fb(); } pfItems.push({ variant_id: r.catalogVariantId, quantity: it.quantity }); } const contact = req.buyerContactDetails || (dest.contactDetails) || {}; const recipient = toRecipient(dest, { phone: contact.phone }); const api = await withTimeout(Promise.resolve(deps.getApi()), budget('getApi'), 'getApi'); const res = await api.request('POST', '/shipping/rates', { storeId: cfg.WIX_STORE_ID, body: { recipient, items: pfItems, currency }, timeout: Math.min(cfg.RATE_TIMEOUT_MS || 6000, budget('rates')), }); free = freeNow(); const allowFast = !isPoBoxOrMilitary(dest); const mapped = mapRates((res && res.result) || [], cfg, country, free, { currency, allowFast }); log('info', 'rates', { ms: now() - t0, country, n: mapped.length, free, allowFast }); return mapped.length ? { shippingRates: mapped } : fb(); } catch (e) { log('error', 'rates failed -> fallback', { ms: now() - t0, msg: e.message, status: e.status }); return fb(); } } export function withTimeout(p, ms, what = 'op') { let timer; return Promise.race([p, new Promise((_, rej) => { timer = setTimeout(() => rej(new Error(`${what} timeout ${ms}ms`)), ms); })]) .finally(() => clearTimeout(timer)); } // ---------------------------------------------------------------- index loading /** * List all sync products (+details) in a store. Wix store: /sync/products; native store: /store/products. * @param {{request: Function}} api * @param {string} storeId * @param {{native?: boolean}} [opts] */ export async function loadStoreProducts(api, storeId, { native = false } = {}) { const listPath = native ? '/store/products' : '/sync/products'; const ids = []; let offset = 0; for (let guard = 0; guard < 50; guard++) { const page = await api.request('GET', `${listPath}?limit=100&offset=${offset}`, { storeId }); const rows = page.result || []; rows.forEach((p) => ids.push(p.id)); const total = (page.paging && page.paging.total) || rows.length; offset += rows.length; if (!rows.length || offset >= total) break; } const out = []; for (const id of ids) { const d = await api.request('GET', `${listPath}/${id}`, { storeId }); out.push(d.result); } return out; } /** Tiny TTL memo (per backend instance). */ export function memoTtl(fn, ttlMs, now = () => Date.now()) { let val, at = 0, inflight = null; const wrapped = async () => { if (val && now() - at < ttlMs) return val; if (inflight) return inflight; inflight = fn().then((v) => { val = v; at = now(); inflight = null; return v; }, (e) => { inflight = null; throw e; }); return inflight; }; wrapped.reset = () => { val = undefined; at = 0; }; return wrapped; } // ---------------------------------------------------------------- routing /** * Express detection. Wix may store our rate `code` as-is or replace it with a GUID, so we * also match the title (contains 'Express' or equals cfg.FAST_TITLE). If code and title * disagree we still route (title wins) but flag `mismatch` for an alert. */ const CARRIER_RE = /\b(ups|usps|fedex|fed\s*ex|dhl|priority\s+mail|canada\s+post|royal\s+mail|ontrac|purolator)\b/i; export function detectFast(order, cfg) { const si = (order && order.shippingInfo) || {}; const byCode = si.code === cfg.FAST_METHOD; const title = String(si.title || ''); // title: exactly our returned title, or contains 'Express' and names no other carrier/service const exactTitle = title.trim() === cfg.FAST_TITLE; const hasExpress = /express/i.test(title); const carrier = CARRIER_RE.test(title); const byTitle = exactTitle || (hasExpress && !carrier); const codeIsOurs = si.code === cfg.FAST_METHOD || si.code === cfg.STANDARD_METHOD || si.code === 'FALLBACK_STANDARD'; const mismatch = (byCode && !byTitle) || (byTitle && codeIsOurs && !byCode) || (hasExpress && carrier && !byCode); return { fast: byCode || byTitle, mismatch }; } export function isFastOrder(order, cfg) { return detectFast(order, cfg).fast; } /** Log/return-safe summary: never names, emails, phones or addresses. */ export function safeSummary(res) { const o = { externalId: res.externalId, itemCount: res.itemCount, actions: res.actions }; for (const k of ['alert', 'skipped', 'error', 'expressOrderId', 'pendingWixCopy', 'dryRun']) if (res[k] !== undefined) o[k] = res[k]; return o; } /** failed/unknown rows, or 'claimed' rows older than ROUTING_RECLAIM_MS (10 min). Caller must GET first. */ export function isReclaimable(row, cfg, nowMs) { if (!row) return false; if (row.status === 'failed' || row.status === 'unknown') return true; if (row.status !== 'claimed') return false; const at = Date.parse(row.claimedAt || row._updatedDate || row._createdDate || ''); return Number.isFinite(at) && nowMs - at > (cfg.ROUTING_RECLAIM_MS || 10 * 60 * 1000); } function isExternalIdExists(e) { return e && e.status >= 400 && e.status < 500 && /external[_ ]?id/i.test(String(e.message)) && /exist|already|unique|taken|duplicate/i.test(String(e.message)); } /** Wix order external id for Printful (<=32 chars, [A-Za-z0-9_-]). */ export function externalIdFor(order) { const raw = String(order.number || order._id || ''); return ('wix-' + raw).replace(/[^A-Za-z0-9_-]/g, '').slice(0, 32); } /** Express-store product for a resolved Wix-store variant: stable id map first, name fallback (unique names only). */ export function expressProductFor(expressIndex, resolved, cfg = {}) { const map = cfg.EXPRESS_PRODUCT_MAP || {}; const mapped = resolved.syncProductId != null && map[String(resolved.syncProductId)]; if (mapped) return expressIndex.byProductId.get(String(mapped)) || null; if (cfg.EXPRESS_REQUIRE_MAP) return null; return (resolved.productName && expressIndex.byName.get(normName(resolved.productName))) || null; } /** * Build the Express-store DRAFT order payload. Throws if any item can't be mapped. */ export function buildExpressOrder(order, wixIndex, expressIndex, cfg) { const dest = (order.shippingInfo && order.shippingInfo.logistics && order.shippingInfo.logistics.shippingDestination) || {}; const contact = { ...(dest.contactDetails || {}) }; if (!contact.email && order.buyerInfo && order.buyerInfo.email) contact.email = order.buyerInfo.email; const recipient = toRecipient(dest.address || {}, contact); if (!recipient.phone) throw new Error('recipient phone missing (required for PRINTFUL_FAST)'); const items = []; for (const li of order.lineItems || []) { const it = readWixLineItem(li); if (!it.shippable || it.quantity <= 0) continue; const r = resolveWixItem(wixIndex, it); if (!r) throw new Error(`unmapped Wix item ${it.productId}:${it.variantId}`); const product = expressProductFor(expressIndex, r, cfg); const ev = product && product.variants.find((x) => x.catalogVariantId === r.catalogVariantId); if (!ev) throw new Error(`no Express-store variant for "${r.productName}" catalog ${r.catalogVariantId}`); const item = { sync_variant_id: ev.syncVariantId, quantity: it.quantity }; if (it.lineItemId) item.external_id = lineExternalId(it.lineItemId); if (it.price != null) item.retail_price = money(it.price); items.push(item); } if (!items.length) throw new Error('no shippable items'); const ship = order.shippingInfo && order.shippingInfo.cost && order.shippingInfo.cost.price; return { external_id: externalIdFor(order), shipping: cfg.FAST_METHOD, recipient, items, retail_costs: ship ? { shipping: money(ship.amount != null ? ship.amount : ship) } : undefined, }; } /** Wix line item id -> Printful item external_id (uuid without dashes = 32 chars). */ export function lineExternalId(id) { return String(id).replace(/[^A-Za-z0-9]/g, '').slice(0, 32); } /** Back-compat helper used by tests. */ export function resolveExpressVariant(expressIndex, resolved, _wixProductId, cfg = {}) { if (!resolved || !resolved.catalogVariantId) return null; const product = expressProductFor(expressIndex, resolved, cfg); return (product && product.variants.find((x) => x.catalogVariantId === resolved.catalogVariantId)) || null; } function isUnknownOutcome(e) { return e && (e.status === 0 || e.status === undefined || e.status >= 500); } /** * Express routing. Creates a DRAFT (never confirms) in the Express store, idempotently, * then (if enabled) cancels the verified Wix-store draft copy. * deps: cfg, getApi, getWixIndex, getExpressIndex, claimRouting(id, data) -> 'claimed'|'duplicate', * updateRouting?(id, patch), log * Never throws. */ export async function routeExpressOrder(order, deps) { const { cfg, log = () => {} } = deps; const upd = deps.updateRouting || (async () => {}); const res = { externalId: order ? externalIdFor(order) : undefined, itemCount: 0, actions: [] }; let claimed = false; try { if (!cfg.ROUTING_ENABLED) return { ...res, skipped: 'routing disabled' }; const det = detectFast(order, cfg); if (det.mismatch) { res.alert = true; res.actions.push('ALERT shipping code/title mismatch on Express detection'); log('error', 'express detection mismatch', safeSummary(res)); } if (!det.fast) return { ...res, skipped: 'not FAST' }; const [wixIndex, expressIndex, api] = await Promise.all([deps.getWixIndex(), deps.getExpressIndex(), deps.getApi()]); const payload = buildExpressOrder(order, wixIndex, expressIndex, cfg); res.externalId = payload.external_id; res.itemCount = payload.items.reduce((n, i) => n + i.quantity, 0); if (cfg.ROUTING_DRY_RUN) { const existing = await getOrderByExternal(api, cfg.EXPRESS_STORE_ID, payload.external_id); res.actions.push(existing ? `DRY_RUN express exists #${existing.id}` : 'DRY_RUN would create express draft'); res.dryRun = true; } else { // Atomic claim: PrintfulRouting row with _id = Wix order id (unique key). let claim = await deps.claimRouting(order._id, { externalId: payload.external_id, orderNumber: String(order.number || ''), status: 'claimed', claimedAt: new Date((deps.now || Date.now)()).toISOString() }); if (claim === 'duplicate') { const ex = await getOrderByExternal(api, cfg.EXPRESS_STORE_ID, payload.external_id); if (ex) { res.actions.push(`already routed: express #${ex.id} (${ex.status})`); res.expressOrderId = ex.id; } else { // GET confirmed no Express order: a failed/unknown row, or a claim stale > 10 min, may be reclaimed const row = deps.getRouting ? await deps.getRouting(order._id) : null; if (isReclaimable(row, cfg, (deps.now || Date.now)()) && deps.reclaimRouting && await deps.reclaimRouting(order._id, row)) { res.actions.push(`reclaimed ${row.status} routing row`); claim = 'claimed'; } else { res.actions.push('ALERT claimed earlier but no express order (reconcile will flag)'); res.alert = true; } } } if (claim === 'claimed') { claimed = true; const ex = await getOrderByExternal(api, cfg.EXPRESS_STORE_ID, payload.external_id); if (ex) { res.expressOrderId = ex.id; res.actions.push(`express exists #${ex.id} (${ex.status})`); } else { try { // confirm=false explicitly. NEVER confirm here. No blind retry. const created = await api.request('POST', '/orders?confirm=false', { storeId: cfg.EXPRESS_STORE_ID, body: payload, timeout: cfg.ORDER_TIMEOUT_MS, }); res.expressOrderId = created.result && created.result.id; res.actions.push(`express draft #${res.expressOrderId} created`); } catch (e) { if (!isUnknownOutcome(e) && !isExternalIdExists(e)) throw e; // timeout/5xx: outcome unknown -> re-check instead of retrying const again = await getOrderByExternal(api, cfg.EXPRESS_STORE_ID, payload.external_id); if (again) { res.expressOrderId = again.id; res.actions.push(`POST outcome unknown; found express #${again.id}`); } else { res.actions.push('ALERT POST outcome unknown and order not found; not retrying'); res.alert = true; claimed = false; await upd(order._id, { status: 'unknown', error: e.message }); log('error', 'express routing unknown', safeSummary(res)); return safeSummary(res); } } } await upd(order._id, { status: 'express_created', expressOrderId: res.expressOrderId }); claimed = false; // row is final } } const prior = cfg.CANCEL_WIX_COPY && !cfg.ROUTING_DRY_RUN && deps.getRouting ? await deps.getRouting(order._id) : null; if (prior && WIX_COPY_FINAL.has(prior.wixCopy)) { res.actions.push(`wix copy already ${prior.wixCopy}; not re-checked`); // sticky: no re-alert / re-delete } else if (cfg.CANCEL_WIX_COPY) { const c = await cancelWixCopy(order, { api, cfg, wixIndex, dryRun: cfg.ROUTING_DRY_RUN }); res.actions.push(...c.actions); if (c.alert) res.alert = true; if (c.pending) res.pendingWixCopy = true; if (!cfg.ROUTING_DRY_RUN) await upd(order._id, wixCopyPatch(c)); } else { res.actions.push('CANCEL_WIX_COPY off: Wix-store copy left for manual cancel'); } log(res.alert ? 'error' : 'info', 'express routing', safeSummary(res)); return safeSummary(res); } catch (e) { const out = safeSummary({ ...res, error: String(e.message || e).slice(0, 200), alert: true }); if (claimed) { try { await upd(order._id, { status: 'failed', error: out.error }); } catch (_) { /* reconcile flags */ } } log('error', 'express routing failed', { ...out, status: e.status }); return out; } } export async function getOrderByExternal(api, storeId, externalId) { try { const r = await api.request('GET', `/orders/@${encodeURIComponent(externalId)}`, { storeId }); return r.result || null; } catch (e) { if (e.status === 404) return null; throw e; } } /** Paged order list (Printful max 100/page). */ export async function listOrders(api, storeId, status, maxPages = 20) { const out = []; let offset = 0; for (let i = 0; i < maxPages; i++) { const q = `/orders?limit=100&offset=${offset}${status ? `&status=${status}` : ''}`; const page = await api.request('GET', q, { storeId }); const rows = page.result || []; out.push(...rows); offset += rows.length; const total = (page.paging && page.paging.total) || 0; if (!rows.length || offset >= total) break; } return out; } /** * Exact external_id candidates for the order the Printful-Wix app imports. * cfg.WIX_IMPORT_EXTERNAL_ID: 'ORDER_ID' | 'ORDER_NUMBER' | null (unknown: both, exact only). */ export function wixImportExternalIds(order, cfg) { const id = order._id ? String(order._id) : null; const num = order.number != null ? String(order.number) : null; if (cfg.WIX_IMPORT_EXTERNAL_ID === 'ORDER_ID') return [id].filter(Boolean); if (cfg.WIX_IMPORT_EXTERNAL_ID === 'ORDER_NUMBER') return [num].filter(Boolean); return [id, num].filter(Boolean); } /** Expected catalog variant -> qty for a Wix order (via Wix-store index). */ export function expectedVariantQty(order, wixIndex) { const m = new Map(); for (const li of order.lineItems || []) { const it = readWixLineItem(li); if (!it.shippable || it.quantity <= 0) continue; const r = resolveWixItem(wixIndex, it); if (!r) return null; m.set(r.catalogVariantId, (m.get(r.catalogVariantId) || 0) + it.quantity); } return m; } /** Postal code key: no spaces/dashes, upper-case; US ZIP+4 compares on the 5-digit ZIP. */ export function zipKey(z, country) { const k = String(z || '').replace(/[\s-]+/g, '').toUpperCase(); return String(country || '').toUpperCase() === 'US' && /^\d{9}$/.test(k) ? k.slice(0, 5) : k; } /** Person-name key: accents stripped, punctuation -> space, case/whitespace folded ("José O'Neil" == "jose o neil"). */ export function personName(s) { return String(s || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '') .toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); } /** Express statuses that mean "won't ship": never delete the Wix copy then. */ export const EXPRESS_DEAD = new Set(['canceled', 'archived', 'failed']); /** Wix-copy outcomes that are final: reconcile stops retrying these rows. */ export const WIX_COPY_FINAL = new Set(['canceled', 'gone', 'needs_review']); /** Alerting outcomes that need a human: alert once, then park the row as 'needs_review'. */ export const WIX_COPY_REVIEW = new Set(['confirmed', 'mismatch', 'ambiguous', 'no_express']); /** Ledger patch for a cancelWixCopy outcome (review outcomes park the row; reason kept). */ export function wixCopyPatch(c) { return WIX_COPY_REVIEW.has(c.result) && c.alert ? { wixCopy: 'needs_review', wixCopyReason: c.result } : { wixCopy: c.result }; } /** Row age in ms from claimedAt / _createdDate / _updatedDate; null when no usable date. */ export function rowAgeMs(row, nowMs = Date.now()) { for (const k of ['claimedAt', '_createdDate', '_updatedDate']) { const v = row && row[k]; const t = v == null ? NaN : new Date(v && v.$date ? v.$date : v).getTime(); if (Number.isFinite(t)) return nowMs - t; } return null; } /** Cross-check a Printful order against the Wix order: zip, name, variant ids + qty. */ export function matchesWixOrder(pfOrder, order, wixIndex) { const dest = (order.shippingInfo && order.shippingInfo.logistics && order.shippingInfo.logistics.shippingDestination) || {}; const a = dest.address || {}, c = dest.contactDetails || {}; const r = pfOrder.recipient || {}; if (!a.postalCode || zipKey(r.zip, r.country_code || a.country) !== zipKey(a.postalCode, a.country)) return 'zip mismatch'; const name = personName([c.firstName, c.lastName].filter(Boolean).join(' ')); if (!name || personName(r.name) !== name) return 'name mismatch'; const exp = expectedVariantQty(order, wixIndex); if (!exp) return 'wix items unmapped'; const got = new Map(); for (const it of pfOrder.items || []) got.set(it.variant_id, (got.get(it.variant_id) || 0) + Number(it.quantity || 0)); if (got.size !== exp.size) return 'item count mismatch'; for (const [k, q] of exp) if (got.get(k) !== q) return `item ${k} qty mismatch`; return null; } /** * Find + verify + cancel the Wix-store draft copy. Deletes only when: * exactly one draft has an external_id EXACTLY equal to a candidate, it cross-checks * against the Wix order, the Express order exists and isn't canceled, and a fresh GET * right before DELETE still shows status 'draft'. */ export async function cancelWixCopy(order, { api, cfg, wixIndex, dryRun = false }) { const out = { actions: [], alert: false, pending: false, result: 'none' }; const cands = new Set(wixImportExternalIds(order, cfg)); const drafts = await listOrders(api, cfg.WIX_STORE_ID, 'draft'); const exact = drafts.filter((o) => o.external_id != null && cands.has(String(o.external_id))); if (exact.length === 0) { // Not among drafts: either not imported yet (pending, job retries) or already left draft. const nonDraft = []; for (const ext of cands) { const o = await getOrderByExternal(api, cfg.WIX_STORE_ID, ext); if (o && String(o.external_id) === ext) nonDraft.push(o); } const live = nonDraft.filter((o) => !['archived', 'canceled'].includes(o.status)); if (live.length) { out.alert = true; out.result = 'confirmed'; out.actions.push(`ALERT wix copy #${live.map((o) => `${o.id} is ${o.status}`).join(', ')} (not draft): possible double shipment, check manually`); return out; } if (nonDraft.length) { out.result = 'gone'; out.actions.push(`wix copy #${nonDraft.map((o) => o.id)} already ${nonDraft.map((o) => o.status)}; nothing to do`); return out; } out.pending = true; out.result = 'not_found'; out.actions.push('wix copy: no exact draft match yet'); return out; } if (exact.length > 1) { out.alert = true; out.result = 'ambiguous'; out.actions.push(`ALERT wix copy: ${exact.length} exact candidates (${exact.map((o) => o.id)}); not deleting`); return out; } const copy = exact[0]; const mismatch = matchesWixOrder(copy, order, wixIndex); if (mismatch) { out.alert = true; out.result = 'mismatch'; out.actions.push(`ALERT wix copy #${copy.id} ${mismatch}; not deleting`); return out; } const express = await getOrderByExternal(api, cfg.EXPRESS_STORE_ID, externalIdFor(order)); if (!express || EXPRESS_DEAD.has(express.status)) { out.alert = !dryRun; out.result = 'no_express'; out.actions.push(`${dryRun ? 'DRY_RUN' : 'ALERT'} express order ${express ? express.status : 'missing'}; not cancelling wix copy #${copy.id}`); return out; } if (dryRun) { out.result = 'dry_run'; out.actions.push(`DRY_RUN would cancel wix draft #${copy.id}`); return out; } const fresh = (await api.request('GET', `/orders/${copy.id}`, { storeId: cfg.WIX_STORE_ID })).result || {}; if (fresh.status !== 'draft') { out.alert = true; out.result = 'not_draft'; out.actions.push(`ALERT wix copy #${copy.id} now ${fresh.status}; not deleting`); return out; } await api.request('DELETE', `/orders/${copy.id}`, { storeId: cfg.WIX_STORE_ID }); out.result = 'canceled'; out.actions.push(`wix draft #${copy.id} canceled`); return out; } /** * Reconcile (jobs.js). deps adds: listRoutingRows() -> [{_id, wixCopy, status}], getWixOrder(id), * listRecentFastOrders() -> Wix orders (approved, FAST, last N h). */ export async function reconcile(deps) { const { cfg, log = () => {} } = deps; const report = { canceled: [], alerts: [] }; // Log each alert as it happens, so nothing is lost if a later step throws. const alert = (...msgs) => { for (const m of msgs) { report.alerts.push(m); log('error', 'reconcile alert', { alert: m }); } }; const errMsg = (e) => `${String((e && e.message) || e).slice(0, 120)}${e && e.status ? ` (${e.status})` : ''}`; log('info', 'reconcile start', { job: cfg.JOB_ENABLED, routing: cfg.ROUTING_ENABLED, dryRun: cfg.ROUTING_DRY_RUN, cancelCopy: cfg.CANCEL_WIX_COPY, autoConfirm: cfg.AUTO_CONFIRM_ENABLED }); if (!cfg.JOB_ENABLED || !cfg.ROUTING_ENABLED) return { ...report, skipped: true }; let api; try { api = await deps.getApi(); } catch (e) { alert(`ALERT printful api setup failed: ${errMsg(e)}; job run skipped`); log('error', 'reconcile', { canceled: report.canceled, alertCount: report.alerts.length }); return { ...report, skipped: 'api' }; } // card H fix: each step is isolated so a failure in steps 1-3 can never skip auto-confirm (step 4) let wixIndex = null; try { wixIndex = await deps.getWixIndex(); } catch (e) { alert(`ALERT wix index load failed: ${errMsg(e)}`); } // 1) approved FAST orders with no Express order try { for (const o of (await deps.listRecentFastOrders()) || []) { try { const ex = await getOrderByExternal(api, cfg.EXPRESS_STORE_ID, externalIdFor(o)); if (!ex) alert(`FAST order ${externalIdFor(o)} has no Express order`); } catch (e) { alert(`ALERT fast-order check ${o && o.number} failed: ${errMsg(e)}`); } } } catch (e) { alert(`ALERT fast-order list failed: ${errMsg(e)}`); } // 2) pending Wix copies (drafts only, verified) let copyRows = []; if (cfg.CANCEL_WIX_COPY && wixIndex) { try { copyRows = (await deps.listRoutingRows()) || []; } catch (e) { alert(`ALERT routing rows query failed: ${errMsg(e)}`); } } if (cfg.CANCEL_WIX_COPY && wixIndex) { for (const row of copyRows) { if (WIX_COPY_FINAL.has(row.wixCopy) || row.status !== 'express_created') continue; const label = row.orderNumber || row._id; try { const order = await deps.getWixOrder(row._id); if (!order) { alert(`ALERT wix order for ${label} not found; row skipped`); continue; } const c = await cancelWixCopy(order, { api, cfg, wixIndex, dryRun: cfg.ROUTING_DRY_RUN }); if (c.result === 'canceled') report.canceled.push(row._id); if (c.pending) { const age = rowAgeMs(row); if (age == null) alert(`wix copy for ${label} not found; routing row has no date`); else if (age > 24 * 3600 * 1000) alert(`wix copy for ${label} not found after 24h`); } if (c.alert) alert(...c.actions.filter((a) => a.startsWith('ALERT'))); if (deps.updateRouting && !cfg.ROUTING_DRY_RUN) await deps.updateRouting(row._id, wixCopyPatch(c)); } catch (e) { alert(`ALERT reconcile row ${label} failed: ${String((e && e.message) || e).slice(0, 120)}${e && e.status ? ` (${e.status})` : ''}`); } } } // 3) stale express drafts try { const drafts = await listOrders(api, cfg.EXPRESS_STORE_ID, 'draft'); const stale = drafts.filter((o) => /^wix-/.test(o.external_id || '') && Date.now() / 1000 - o.created > 12 * 3600); if (stale.length) alert(`express drafts awaiting confirmation >12h: ${stale.map((o) => o.id)}`); } catch (e) { alert(`ALERT stale-draft check failed: ${String((e && e.message) || e).slice(0, 120)}${e && e.status ? ` (${e.status})` : ''}`); } // 4) card H: auto-confirm (own try/catch per row; never breaks the steps above) if (cfg.AUTO_CONFIRM_ENABLED && !wixIndex) alert('ALERT auto-confirm skipped: wix index unavailable'); if (cfg.AUTO_CONFIRM_ENABLED && wixIndex) { try { const r = await autoConfirm({ ...deps, api, wixIndex }, alert); report.confirmed = r.confirmed; } catch (e) { alert(`ALERT auto-confirm failed: ${String((e && e.message) || e).slice(0, 120)}${e && e.status ? ` (${e.status})` : ''}`); } } log(report.alerts.length ? 'error' : 'info', 'reconcile', { canceled: report.canceled, confirmed: report.confirmed, alertCount: report.alerts.length }); return report; } // ---------------------------------------------------------------- auto-confirm (card H) /** Ledger `confirm` values that end processing. */ export const CONFIRM_FINAL = new Set(['confirmed', 'needs_review', 'skipped']); /** Printful statuses meaning "already past draft and live" (someone/something confirmed it). */ const PF_LIVE = new Set(['pending', 'inprocess', 'partial', 'fulfilled']); /** 'onhold' can revert to draft: not recorded as confirmed, left pending and re-checked. */ const PF_WAIT = new Set(['onhold']); const amountOf = (v) => (v && typeof v === 'object' ? (v.amount != null ? v.amount : v.price) : v); /** PRE-tax shipping the customer paid (shippingInfo.cost.price.amount, a string) as a number, or null. */ export function wixPaidShipping(order) { const a = amountOf(order && order.shippingInfo && order.shippingInfo.cost && order.shippingInfo.cost.price); return a != null && a !== '' && Number.isFinite(Number(a)) ? Number(a) : null; } /** Why a Wix order must NOT be confirmed (null = ok). Fail closed on unknown/missing values. */ export function wixOrderBlock(order) { if (!order) return 'wix order missing'; if (String(order.status || '').toUpperCase() !== 'APPROVED') return `wix status ${order.status || 'unknown'}`; const pay = String(order.paymentStatus || '').toUpperCase(); if (pay !== 'PAID') return `wix payment ${order.paymentStatus || 'unknown'}`; return null; } /** Blocks that can never clear (cancel/refund) vs. ones that might (unpaid -> paid). */ export function isFinalBlock(reason) { return /CANCEL|REFUND|wix status (?!unknown)/i.test(reason || ''); } function dateMs(v) { const t = v == null ? NaN : new Date(v && v.$date ? v.$date : v).getTime(); return Number.isFinite(t) ? t : null; } /** Raw pre-tax Wix shipping string, for cost-review logs. */ export function wixPaidShippingRaw(order) { const a = amountOf(order && order.shippingInfo && order.shippingInfo.cost && order.shippingInfo.cost.price); return a == null ? null : String(a); } /** Money -> integer cents. undefined = missing; NaN = present but invalid (non-finite or negative). */ export function moneyCents(v) { const a = amountOf(v); if (a == null || a === '') return undefined; const n = Number(a); if (!Number.isFinite(n) || n < 0) return NaN; return Math.round(n * 100); } const usd = (c) => `$${(c / 100).toFixed(2)}`; /** * Cost guard (run before confirm). ok=false => log + alert once ('[printful] cost-review') and record costFlag; NEVER blocks (Sean 2026-10-15). All comparisons in integer cents. * - invalid=true: a negative or non-finite Printful/Wix amount (fail closed: park). * - unknown=true: costs.shipping or paid shipping missing (drafts may lack costs). * - Express / normal Standard: Printful shipping <= paid pre-tax shipping + tolerance. * - Cap cases (Standard only): free US Standard (paid 0) or paid == a fallback flat rate (5.50 US / 10.00 intl) * => Printful shipping <= AUTO_CONFIRM_FREE_SHIP_CAP (default $10). * - Optional total check: costs.total <= Wix subtotal + max(paid, cap if cap case) + tolerance. */ export function costCheck(pfOrder, order, cfg, { express = false } = {}) { const pf = moneyCents(pfOrder && pfOrder.costs && pfOrder.costs.shipping); const paid = moneyCents(order && order.shippingInfo && order.shippingInfo.cost && order.shippingInfo.cost.price); const total = moneyCents(pfOrder && pfOrder.costs && pfOrder.costs.total); const sub = moneyCents(order && order.priceSummary && order.priceSummary.subtotal); const bad = [['printful shipping', pf], ['wix shipping', paid], ['printful total', total], ['wix subtotal', sub]].filter(([, v]) => Number.isNaN(v)); if (bad.length) return { ok: false, invalid: true, pf, paid, reason: `invalid amount: ${bad.map(([k]) => k).join(', ')}` }; if (pf === undefined || paid === undefined) return { ok: false, unknown: true, pf, paid }; const tol = moneyCents(cfg.AUTO_CONFIRM_COST_TOLERANCE != null ? cfg.AUTO_CONFIRM_COST_TOLERANCE : 1); const cap = moneyCents(cfg.AUTO_CONFIRM_FREE_SHIP_CAP != null ? cfg.AUTO_CONFIRM_FREE_SHIP_CAP : 10); if (!Number.isInteger(tol) || !Number.isInteger(cap)) return { ok: false, invalid: true, pf, paid, reason: 'invalid cost-guard config' }; const dest = (order.shippingInfo && order.shippingInfo.logistics && order.shippingInfo.logistics.shippingDestination) || {}; const country = String((dest.address && dest.address.country) || '').toUpperCase(); const fb = cfg.FALLBACK_RATES || {}; const fbCents = moneyCents(fb[country] != null ? fb[country] : fb.DEFAULT); const capCase = !express && ((paid === 0 && (cfg.FREE_SHIPPING_COUNTRIES || ['US']).includes(country)) || (Number.isInteger(fbCents) && paid === fbCents)); let ok, reason; if (capCase) { ok = pf <= cap; if (!ok) reason = `Printful shipping ${usd(pf)} > cap ${usd(cap)} (paid ${usd(paid)})`; } else { ok = pf - paid <= tol; if (!ok) reason = `Printful shipping ${usd(pf)} > paid ${usd(paid)} by ${usd(pf - paid)}`; } if (ok && total !== undefined && sub !== undefined) { const limit = sub + Math.max(paid, capCase ? cap : 0) + tol; if (total > limit) { ok = false; reason = `Printful total ${usd(total)} > Wix subtotal+shipping+tolerance ${usd(limit)}`; } } return { ok, pf, paid, capCase, reason }; } /** * Card H. Discover Standard orders, then confirm eligible drafts. Called from reconcile (job only). * deps: cfg, api, wixIndex, claimRouting, updateRouting, getWixOrder, listConfirmRows, listRecentApprovedOrders, log, now? * alertFn(msg) records + logs an alert. Per-row try/catch. Never confirms in dry-run. */ export async function autoConfirm(deps, alertFn) { const { cfg, api, wixIndex, log = () => {} } = deps; const alert = alertFn || ((m) => log('error', 'auto-confirm alert', { alert: m })); const nowMs = (deps.now || Date.now)(); const out = { confirmed: [], discovered: [] }; if (!cfg.AUTO_CONFIRM_ENABLED || cfg.ROUTING_DRY_RUN || !cfg.JOB_ENABLED || !cfg.ROUTING_ENABLED) return { ...out, skipped: true }; const upd = deps.updateRouting || (async () => {}); // a) Standard discovery: approved, paid, NOT Express (any detection doubt -> not discovered, alerted) if (deps.listRecentApprovedOrders && deps.claimRouting) { const seen = []; let list = []; try { list = (await deps.listRecentApprovedOrders()) || []; } catch (e) { alert(`ALERT standard discovery search failed: ${String((e && e.message) || e).slice(0, 120)}`); } for (const o of list) { const n = String((o && o.number) || '?'); try { if (!o || !o._id) { seen.push(`${n}:no _id`); continue; } const det = detectFast(o, cfg); if (det.fast) { seen.push(`${n}:express`); continue; } if (det.mismatch) { seen.push(`${n}:ambiguous`); alert(`ALERT order ${o.number} shipping code/title ambiguous; not auto-confirming`); continue; } const block = wixOrderBlock(o); if (block) { seen.push(`${n}:${block}`); continue; } // unpaid now: picked up on a later run while in the window const c = await deps.claimRouting(o._id, { kind: 'standard', status: 'standard', orderNumber: String(o.number || ''), externalId: String(o.number || ''), claimedAt: new Date(nowMs).toISOString() }); seen.push(`${n}:${c}`); if (c === 'claimed') out.discovered.push(String(o.number)); } catch (e) { seen.push(`${n}:error`); alert(`ALERT standard discovery ${n} failed: ${String((e && e.message) || e).slice(0, 120)}${e && e.code ? ` [${e.code}]` : ''}`); } } log('info', 'auto-confirm discovery', { found: list.length, seen }); // order numbers + reasons only, no PII } // b) confirm pass for (const row of (await deps.listConfirmRows()) || []) { if (CONFIRM_FINAL.has(row.confirm)) continue; const label = row.orderNumber || row._id; const park = async (reason, msg) => { if (msg) alert(msg); await upd(row._id, { confirm: 'needs_review', confirmReason: reason }); }; try { const express = row.status === 'express_created'; if (!express && row.status !== 'standard') continue; if (row.confirm === 'confirming' && dateMs(row.confirmAt) != null && nowMs - dateMs(row.confirmAt) < (cfg.ROUTING_RECLAIM_MS || 10 * 60 * 1000)) continue; // in flight if (express && !['canceled', 'gone'].includes(row.wixCopy)) continue; // Express waits for the Wix copy const order = await deps.getWixOrder(row._id); if (!order) { await park('wix order missing', `ALERT confirm ${label}: wix order not found`); continue; } const block = wixOrderBlock(order); if (block) { if (isFinalBlock(block)) { log('warn', 'auto-confirm skipped', { order: label, reason: block }); await upd(row._id, { confirm: 'skipped', confirmReason: block }); } continue; // unpaid etc.: retry next run } const delay = cfg.AUTO_CONFIRM_DELAY_MS != null ? Number(cfg.AUTO_CONFIRM_DELAY_MS) : 15 * 60 * 1000; const created = dateMs(order._createdDate || order.createdDate); const age = rowAgeMs(row, nowMs); const wixCopyAt = express ? dateMs(row.wixCopyAt) : null; const youngest = Math.min(created == null ? -Infinity : nowMs - created, age == null ? Infinity : age, wixCopyAt == null ? Infinity : nowMs - wixCopyAt); if (created == null) { await park('no wix createdDate', `ALERT confirm ${label}: Wix order has no createdDate; not confirming`); continue; } if (youngest < delay) continue; // grace period const storeId = express ? cfg.EXPRESS_STORE_ID : cfg.WIX_STORE_ID; const ext = express ? externalIdFor(order) : String(order.number); if (!express && String(row.externalId || order.number) !== String(order.number)) { await park('number mismatch', `ALERT confirm ${label}: ledger/order number mismatch`); continue; } // cross-store double-ship guards if (express) { const copy = await getOrderByExternal(api, cfg.WIX_STORE_ID, String(order.number)); if (copy && String(copy.external_id) === String(order.number) && !['canceled', 'archived'].includes(copy.status)) { await park('wix copy live', `ALERT confirm ${label}: Wix-store copy #${copy.id} is ${copy.status}; Express NOT confirmed`); continue; } } else { const ex = await getOrderByExternal(api, cfg.EXPRESS_STORE_ID, externalIdFor(order)); if (ex) { await park('express exists', `ALERT confirm ${label}: Express order #${ex.id} exists for a Standard order; NOT confirming`); continue; } } const pf = await getOrderByExternal(api, storeId, ext); if (!pf || String(pf.external_id) !== ext) { if (!express && age != null && age > 24 * 3600 * 1000) await park('no printful draft', `ALERT confirm ${label}: no Printful order ${ext} after 24h`); else if (express) await park('express missing', `ALERT confirm ${label}: Express order ${ext} not found`); continue; // Standard: not imported yet } if (PF_WAIT.has(pf.status)) { // onhold: re-check next run; park once after 24h if (age != null && age > 24 * 3600 * 1000) await park('printful onhold', `ALERT confirm ${label}: Printful #${pf.id} onhold > 24h; parked`); continue; } if (PF_LIVE.has(pf.status)) { await upd(row._id, { confirm: 'confirmed', confirmedPfId: pf.id, confirmReason: `already ${pf.status}` }); continue; } if (pf.status !== 'draft') { await park(`printful ${pf.status}`, `ALERT confirm ${label}: Printful #${pf.id} is ${pf.status}; not confirming`); continue; } if (!express) { const mm = matchesWixOrder(pf, order, wixIndex); if (mm) { await park(mm, `ALERT confirm ${label}: Printful #${pf.id} ${mm}; not confirming`); continue; } } const cost = costCheck(pf, order, cfg, { express }); // Sean decision 2026-10-15: cost issues are logged + alerted, NEVER held. Flag is computed here but // written only together with the post-claim update (an earlier write would bump _updatedDate and lose the CAS claim). let costFlag = null; if (!cost.ok && !row.costFlag) { const kind = cost.invalid ? 'invalid' : cost.unknown ? 'unknown' : 'over'; costFlag = { kind, reason: cost.reason || 'shipping cost missing', pfShippingCents: cost.pf, paidShippingCents: cost.paid, capCase: !!cost.capCase, pfTotal: pf.costs && pf.costs.total != null ? String(pf.costs.total) : null, pfShipping: pf.costs && pf.costs.shipping != null ? String(pf.costs.shipping) : null, wixShipping: wixPaidShippingRaw(order), express, printfulId: pf.id, at: new Date(nowMs).toISOString() }; } const withFlag = (patch) => (costFlag ? { ...patch, costFlag } : patch); // fresh GET right before confirm const fresh = (await api.request('GET', `/orders/${pf.id}`, { storeId })).result || {}; if (fresh.status !== 'draft') { if (PF_WAIT.has(fresh.status)) continue; if (PF_LIVE.has(fresh.status)) await upd(row._id, { confirm: 'confirmed', confirmedPfId: pf.id, confirmReason: `already ${fresh.status}` }); else await park(`printful ${fresh.status}`, `ALERT confirm ${label}: Printful #${pf.id} now ${fresh.status}; not confirming`); continue; } // compare-and-set claim: an overlapping run that already claimed this row loses here (no double POST) if (!deps.claimConfirm || !(await deps.claimConfirm(row._id, row, new Date(nowMs).toISOString()))) { log('warn', 'auto-confirm claim lost', { order: label }); continue; } if (costFlag) { // one alert per order, after we own the row log('error', 'cost-review', { order: label, ...costFlag }); alert(`cost-review ${label}: ${costFlag.reason} (printful shipping ${costFlag.pfShipping}, total ${costFlag.pfTotal}, wix shipping ${costFlag.wixShipping}); confirming anyway`); } try { await api.request('POST', `/orders/${pf.id}/confirm`, { storeId, timeout: cfg.ORDER_TIMEOUT_MS }); } catch (e) { // outcome check instead of blind retry let after = null; try { after = (await api.request('GET', `/orders/${pf.id}`, { storeId })).result || null; } catch (_) { /* unknown */ } if (after && PF_LIVE.has(after.status)) { await upd(row._id, withFlag({ confirm: 'confirmed', confirmedPfId: pf.id, confirmedAt: new Date(nowMs).toISOString() })); out.confirmed.push(label); continue; } const attempts = Number(row.confirmAttempts || 0) + 1; const max = cfg.AUTO_CONFIRM_MAX_ATTEMPTS || 3; alert(`ALERT confirm ${label} (#${pf.id}) failed: ${String(e.message || e).slice(0, 120)}${e.status != null ? ` (${e.status})` : ''} attempt ${attempts}/${max}`); await upd(row._id, withFlag(attempts >= max ? { confirm: 'needs_review', confirmReason: 'confirm failed', confirmAttempts: attempts } : { confirm: 'failed', confirmAttempts: attempts })); continue; } await upd(row._id, withFlag({ confirm: 'confirmed', confirmedPfId: pf.id, confirmedAt: new Date(nowMs).toISOString() })); out.confirmed.push(label); log('info', 'auto-confirmed', { order: label, kind: express ? 'express' : 'standard', printfulId: pf.id }); } catch (e) { alert(`ALERT confirm row ${label} failed: ${String((e && e.message) || e).slice(0, 120)}${e && e.status ? ` (${e.status})` : ''}`); } } return out; } /** * READ-ONLY diagnostic for card H (no writes, no confirm). For one Wix order: why would/wouldn't auto-confirm act? * deps: cfg, api, wixIndex, getRouting(id), now? */ export async function diagnoseOrder(order, deps) { const { cfg, api, wixIndex } = deps; const nowMs = (deps.now || Date.now)(); const d = { number: order && order.number, id: order && order._id, status: order && order.status, paymentStatus: order && order.paymentStatus, shippingCode: order && order.shippingInfo && order.shippingInfo.code, shippingTitle: order && order.shippingInfo && order.shippingInfo.title, created: order && (order._createdDate || order.createdDate) ? String(order._createdDate || order.createdDate) : null, switches: { job: cfg.JOB_ENABLED, routing: cfg.ROUTING_ENABLED, dryRun: cfg.ROUTING_DRY_RUN, autoConfirm: cfg.AUTO_CONFIRM_ENABLED, delayMs: cfg.AUTO_CONFIRM_DELAY_MS } }; const step = async (k, fn) => { try { d[k] = await fn(); } catch (e) { d[k] = { error: String((e && e.message) || e).slice(0, 160), status: e && e.status, code: e && e.code }; } }; d.detect = detectFast(order, cfg); d.block = wixOrderBlock(order); await step('routingRow', async () => { const r = deps.getRouting ? await deps.getRouting(order._id) : null; return r ? { _id: r._id, status: r.status, confirm: r.confirm, confirmReason: r.confirmReason, wixCopy: r.wixCopy, claimedAt: r.claimedAt } : null; }); const express = d.detect.fast; const storeId = express ? cfg.EXPRESS_STORE_ID : cfg.WIX_STORE_ID; const ext = express ? externalIdFor(order) : String(order.number); await step('printful', async () => { const o = await getOrderByExternal(api, storeId, ext); return o ? { id: o.id, status: o.status, external_id: o.external_id, shipping: o.costs && o.costs.shipping, total: o.costs && o.costs.total, _o: o } : null; }); const pf = d.printful && d.printful._o; if (pf) { delete d.printful._o; d.match = express ? 'n/a (express)' : (wixIndex ? matchesWixOrder(pf, order, wixIndex) || 'ok' : 'no index'); const c = costCheck(pf, order, cfg, { express }); d.cost = { ok: c.ok, reason: c.reason || (c.unknown ? 'unknown' : null), capCase: c.capCase }; } const created = d.created ? new Date(d.created).getTime() : NaN; d.ageMin = Number.isFinite(created) ? Math.round((nowMs - created) / 60000) : null; return d; } // ---------------------------------------------------------------- tracking webhook /** Parse Printful package_shipped webhook. Returns null if not applicable. */ export function parseShippedWebhook(body) { if (!body || body.type !== 'package_shipped' || !body.data) return null; const { order, shipment } = body.data; if (!order || !shipment) return null; return { printfulOrderId: order.id, externalId: order.external_id, storeId: body.store, trackingNumber: shipment.tracking_number, trackingUrl: shipment.tracking_url, carrier: shipment.carrier, service: shipment.service, }; } /** Constant-time compare of the webhook ?token= against the Wix secret. */ export function verifyWebhookToken(given, expected) { if (!given || !expected) return false; const a = String(given), b = String(expected); let diff = a.length ^ b.length; for (let i = 0; i < Math.max(a.length, b.length); i++) diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0); return diff === 0; } /** "wix-10124" -> 10124; null if not a positive integer (NaN guard). */ export function wixOrderNumberFromExternal(ext) { const m = /^wix-([1-9]\d{0,14})$/.exec(String(ext || '')); if (!m) return null; const n = Number(m[1]); return Number.isSafeInteger(n) && n > 0 ? n : null; } /** * Build Wix createFulfillment payload for ONE Printful shipment (partial shipments supported). * shipment.items = [{item_id, quantity}] referencing pfOrder.items[].id whose external_id is the * Wix line item id without dashes. Falls back to all shippable lines if items are absent. */ export function buildWixFulfillment(pfOrder, wixOrder, shipment) { const shippable = (wixOrder.lineItems || []).filter((li) => !(li.physicalProperties && li.physicalProperties.shippable === false)); let lineItems; if (Array.isArray(shipment.items) && shipment.items.length && pfOrder && Array.isArray(pfOrder.items)) { const byPfId = new Map(pfOrder.items.map((i) => [i.id, i])); const byExt = new Map(shippable.map((li) => [lineExternalId(li._id), li])); lineItems = []; for (const si of shipment.items) { const pfi = byPfId.get(si.item_id); const li = pfi && byExt.get(String(pfi.external_id || '')); if (!li) throw new Error(`shipment item ${si.item_id} not mappable to Wix line`); lineItems.push({ _id: li._id, quantity: Number(si.quantity) }); } } else { lineItems = shippable.map((li) => ({ _id: li._id, quantity: li.quantity })); } return { lineItems, trackingInfo: { trackingNumber: String(shipment.tracking_number || shipment.trackingNumber), shippingProvider: String(shipment.carrier || 'other').toLowerCase(), ...(shipment.tracking_url ? { trackingLink: shipment.tracking_url } : {}), }, }; } // ---- wix-ecom-backend query shapes (Velo: orders.searchOrders(options), options = { search: CursorSearch }) ---- // A top-level `filter` is NOT part of SearchOrdersOptions: Wix ignores it and returns the // default search (newest non-pending orders). `number` is a string on the Order object. /** searchOrders options for one order by its (plain) order number. */ export function searchOrderByNumberOptions(number) { return { search: { filter: { number: { $eq: String(number) } }, cursorPaging: { limit: 2 } } }; } /** searchOrders options for APPROVED orders created since `sinceIso`. */ export function searchRecentApprovedOptions(sinceIso, limit = 100) { return { search: { filter: { $and: [{ status: { $eq: 'APPROVED' } }, { createdDate: { $gte: sinceIso } }] }, cursorPaging: { limit } } }; } /** Exactly one order whose number matches, else null (never trust an unfiltered result). */ export function pickOrderByNumber(res, number) { const hits = ((res && res.orders) || []).filter((o) => String(o.number) === String(number)); return hits.length === 1 ? hits[0] : null; } /** listFulfillmentsForSingleOrder response -> true if a fulfillment already carries this tracking number. */ export function hasTrackingFulfillment(listRes, trackingNumber) { const fl = (listRes && listRes.orderWithFulfillments && listRes.orderWithFulfillments.fulfillments) || []; return fl.some((f) => f && f.trackingInfo && String(f.trackingInfo.trackingNumber) === String(trackingNumber)); }

Inked magazine top rated tattoo shop globally honolulu Waikiki Tattoo shop Tattoolicious

Featured in Inked Magazine's "Top Ink: Our Favorite Tattoo Studios Across the Globe" (July 2025). Article Link.

Cheyenne Tattoo Alliance Shop Top 10 sponsored Tattoo shops Tattoolicious Waikiki Hawaii

Honored to be given machinery that is cutting edge and aimed at delivery the most gentle yet saturated results.

Hawaii Tattoo water scene in paradise _e

Waikiki's Highly Acclaimed Tattoo Studio
Custom, Fine Line, & Traditional Tattoo Art

"Art should flow like water.” Sean McCready, Owner 

Dive into the world of Tattoolicious and brace yourself for an unforgettable tattoo experience, where art flows like water and every tattoo is a unique wave of creativity. Catch the vibe and let our experienced artists take you on a journey to create a one-of-a-kind masterpiece that you'll be proud to wear forever. Featured in Inked Magazine's Top Ink list of favorite tattoo studios across the globe, Tattoolicious is a friendly custom tattoo shop on Ala Wai Blvd in Waikiki, open to walk-ins and appointments.

Welcome to Tattoolicious, Waikiki's most acclaimed tattoo studio and the only Cheyenne Tattoo Alliance Partner in North America. With 14 resident tattoo artists on staff and a rotating roster of internationally recognized guest artists, our portfolios represent some of the most diverse and accomplished tattoo talent anywhere in Hawai'i.

​

Every artist at Tattoolicious is a specialist. Whether you're looking for fine line and dotwork, Polynesian and Hawaiian tattoos, black & grey realism, American traditional, bold color, neo-traditional, portrait, or cover-up tattoos, we have a dedicated expert ready to bring your vision to life. No generalists. No shortcuts.

​

Featured in Inked Magazine's "Top Ink: Our Favorite Tattoo Studios Across the Globe" (July 2025), Tattoolicious isn't just one of the best tattoo shops in Honolulu, it's one of the best anywhere.

​

Walk-ins are welcome. Free consultations available. Come see why locals and visitors from across O'ahu keep coming back.

What tattoo style are you interested in today?

Close

Female guest tattoo artist featured at Tattoolicious Honolulu location

GUEST TATTOO ARTISTS
Arrival Dates & Portfolios

Color realism Tattoolicious shop Waikiki in Honolulu

GERY

Specializes in Neo-Realism, Anime, and Color Realism tattoos

Blackwork Poly tattoo done in Waikiki Shop at Tattoolicious Hawaii

K LA MONNAIE

Specializes in Sacred Geometry, Neck tattoos, Hand tattoos, and Palm tattoos

Cat Eye Portrait Tattoo Molina Tattoolicious Tattoo in Waikiki

MOLINA

Specializes in Illustrative, Color Realism, and Vibrant Color tattoos

Fineline Tattoo by fine line specialist Zee from Tattoolicious in Honolulu Hawaii

FINE LINE STYLE

A Collection of Fine Line Tattoos

Hawaii Honolulu Waikiki Tattoo Shop Best Tattoo Artists

TRADITIONAL HAND TAP

A Rare Opportunity for Those Seeking Traditional Polynesian Hand-Tapped Tattoos

Half sleeve of custom Polynesian tattoo incorporating Hawaiian and Filipino patterns in th

POLYNESIAN STYLE

Wide Range of Styles & Custom Tattoo Designs by Our Team

Harry Potter tattoo by Sean in Waikiki at Tattoolicious

SEAN McCREADY

Specializes in Neo-Realism, Japanese Traditional, Black & Grey, and Color Realism tattoos

Full color traditional Neo realism tattoo of panther done by Andy King at Tattooolicious Hawaii

ANDY KING

Specializes in Watercolor, Color, Black & Grey, and Illustrative tattoos

fineline tattoo of flowers done by specialist David Coronado at Tattoolicious Hawaii

DAVID CORONADO

Specializes in Dotwork, Polynesian Style, and Fine Line tattoos

Color realism tattoo of tropical flowers with ginger and heliconia done by Enson Park at T

ENSON PARK

Specializes in Illustrative Style, Realism, Black & Grey, and Vibrant Color tattoos

CHAD KIERNAN

Specializes in Anime, Japanese Art, and Color tattoos

Anime tattoo in bold color art done by Chad at Tattoolicious Honolulu
Tropical paradise Hawaii tattoo beach scene with plumerias and palm trees done by Keith at

KEITH FEITELSON

Specializes in American Traditional, Polynesian, Asian Traditional, Black & Grey, Color, and Fine Line tattoos

ADRIAAN

Specializes in Neo-Traditional, Symbolism, and Art Nouveau Style tattoos

Swan and hawk full color Neo traditional tattoo chest piece done by Adriaan at Tattoolicio
Fineline floral rib side piece tattoo at Tattoolicious by Dr. Will

DR. WILL

Specializes in Fine Line, Floral, Ornamental, and Symbolic tattoos

Gold Star tooth jewery waikiki

FINE TOOTH JEWELRY

Specializing in Professional Fine Tooth Jewelry by Dr. Will

Micro portrait tattoo of Robin Williams at Tattoolicious Waikiki

LEO

Specializes in Micro Portraits, Fine Detail, and Black & Grey tattoos

Hawaii Honolulu Waikiki Tattoo Shop Best Tattoo Artists

JAMES P

Specializes in Japanese Traditional, and Black & Grey tattoos

Get What You Get!

The fun responsible spontaneous non-drunk-way to get a tattoo with friends!

Get What You Get Tattoo Gumball machine Waikiki

Tattoolicious is the only tattoo shop in the Hawaiian Islands offering traditional hand-tap tatau and kākau in a shop setting, and the shop was blessed by Hawaiian Sulu'ape Keone Nunes. Tattooing in Waikiki since 2000, with 14 resident artists, a spot on Inked Magazine's list of the top 14 studios in the world, and walk-ins until 8:30pm at 1909 Ala Wai Blvd, Suite C2.

Where to Book a Tattoo in Honolulu / Waikiki

Book a tattoo session at Tattoolicious Tattoo Waikiki. You can walk in during open hours or book a free consultation for a custom design or a specific artist.

•  Address: 1909 Ala Wai Blvd, Suite C2, Honolulu, HI 96815

•  Phone: (808) 949-8287

•  Hours: Monday to Saturday 11am to 9pm, Sunday 11am to 5pm

•  Parking: Validated parking across the street at the Waikiki Landmark

•  Shop minimum: $100 to $150 depending on the artist

•  Walk-ins welcome · Free consultations

Featured  Posts

Click below to read

bottom of page