{"name":"Upsell machine workflow","nodes":[{"id":"aaaaaaa1-1111-4222-8333-444455556601","name":"New Order Webhook","type":"n8n-nodes-base.webhook","typeVersion":2,"position":[-160,300],"parameters":{"path":"shopify-order-created","options":{}}},{"id":"aaaaaaa2-1111-4222-8333-444455556602","name":"Normalize Order","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[100,300],"parameters":{"assignments":{"assignments":[{"id":"a1","name":"order","value":"={{ $json.body }}","type":"object"},{"id":"a2","name":"customer_email","value":"={{ $json.body.email }}","type":"string"},{"id":"a3","name":"order_id","value":"={{ $json.body.id }}","type":"string"}]},"options":{}}},{"id":"aaaaaaa3-1111-4222-8333-444455556603","name":"Fetch Customer Order History","type":"n8n-nodes-base.postgres","typeVersion":2.5,"position":[360,140],"parameters":{"operation":"executeQuery","query":"select json_agg(o order by o.created_at desc) as history, max(u.sent_at) as last_upsell_sent_at from orders o left join upsell_sends u on u.email = o.email where o.email = $1 and o.created_at > now() - interval '90 days'","options":{"queryReplacement":"={{ $json.customer_email }}"}}},{"id":"aaaaaaa4-1111-4222-8333-444455556604","name":"Fetch Live Catalog","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[360,460],"parameters":{"url":"https://brand.myshopify.com/admin/api/2024-10/products.json","sendQuery":true,"queryParameters":{"parameters":[{"name":"fields","value":"id,title,status,variants"},{"name":"limit","value":"250"},{"name":"status","value":"active"}]},"options":{}}},{"id":"aaaaaaa5-1111-4222-8333-444455556605","name":"Merge History And Catalog","type":"n8n-nodes-base.merge","typeVersion":3,"position":[620,300],"parameters":{"mode":"combine","combineBy":"combineByPosition","options":{}}},{"id":"aaaaaaa6-1111-4222-8333-444455556606","name":"Score Upsell Affinity","type":"n8n-nodes-base.code","typeVersion":2,"position":[880,300],"parameters":{"jsCode":"// ---- Purchase-affinity upsell engine -------------------------------------\n// Merged input: [0] Postgres history row, [1] Shopify products payload.\nconst ctx = $input.first().json;\nconst normalized = $('Normalize Order').first().json;\n\nconst order = normalized.order || {};\nconst history = Array.isArray(ctx.history) ? ctx.history : [];\n\n// Shopify returns { products: [{ title, variants: [{ sku, price, inventory_quantity }] }] }.\n// Flatten to one row per sellable variant so rules can look up by SKU.\nconst catalog = (ctx.products || []).flatMap(p =>\n  (p.variants || []).map(v => ({\n    sku: v.sku,\n    title: p.title,\n    price: Number(v.price || 0),\n    inventory_quantity: Number(v.inventory_quantity || 0)\n  }))\n).filter(v => v.sku);\n\nconst boughtSkus = new Set([\n  ...(order.line_items || []).map(li => li.sku),\n  ...history.flatMap(o => (o.line_items || []).map(li => li.sku))\n]);\n\n// 1. Affinity rules: co-purchase lift observed in the last 90d of order data.\n//    lift = P(B | A) / P(B). Anything under 1.2 is noise, not affinity.\nconst AFFINITY_RULES = [\n  { anchor: 'CRT-500',  target: 'SHK-BTL',  lift: 3.10, margin: 0.72 },\n  { anchor: 'CRT-500',  target: 'ELEC-30',  lift: 2.40, margin: 0.66 },\n  { anchor: 'MAG-GLY',  target: 'SLP-BUND', lift: 2.85, margin: 0.61 },\n  { anchor: 'MAG-GLY',  target: 'ASH-KSM',  lift: 1.95, margin: 0.58 },\n  { anchor: 'COL-PEP',  target: 'BIO-HAIR', lift: 2.20, margin: 0.64 },\n  { anchor: 'GUT-PRO',  target: 'FIB-MIX',  lift: 1.80, margin: 0.55 },\n  { anchor: 'SHK-BTL',  target: 'CRT-500',  lift: 1.45, margin: 0.72 }\n];\n\nconst MIN_LIFT = 1.2;\nconst MIN_SCORE = 0.45;\nconst MIN_AOV_FOR_UPSELL = 29;      // do not upsell a sample-size order\nconst COOLDOWN_DAYS = 14;           // dedupe: one upsell email per customer / 14d\n\nconst aov = Number(order.total_price || 0);\nconst orderSkus = (order.line_items || []).map(li => li.sku);\n\n// 2. Recency / repeat signals.\nconst orderCount = history.length + 1;\nconst lifetimeValue = history.reduce((s, o) => s + Number(o.total_price || 0), 0) + aov;\nconst daysSinceLast = history.length\n  ? Math.max(0, (Date.now() - new Date(history[0].created_at).getTime()) / 86400000)\n  : 999;\nconst lastUpsellDays = ctx.last_upsell_sent_at\n  ? (Date.now() - new Date(ctx.last_upsell_sent_at).getTime()) / 86400000\n  : 999;\n\n// repeat buyers convert ~2x on upsells; cold-but-returning buyers convert well too.\nconst repeatBoost = Math.min(0.25, (orderCount - 1) * 0.08);\nconst recencyBoost = daysSinceLast < 45 ? 0.10 : 0;\nconst aovBoost = Math.min(0.15, (aov / 200) * 0.15);\n\n// 3. Score every candidate rule fired by this order.\nconst inStock = new Map(catalog.map(p => [p.sku, p]));\nconst candidates = [];\n\nfor (const sku of orderSkus) {\n  for (const rule of AFFINITY_RULES) {\n    if (rule.anchor !== sku) continue;\n    if (rule.lift < MIN_LIFT) continue;\n    if (boughtSkus.has(rule.target)) continue;          // already owns it\n    const product = inStock.get(rule.target);\n    if (!product || Number(product.inventory_quantity || 0) < 5) continue;\n\n    // normalise lift into 0..1, then weight by margin and buyer signals.\n    const liftScore = Math.min(1, (rule.lift - 1) / 2.5);\n    const score = Number((\n      liftScore * 0.55 +\n      rule.margin * 0.25 +\n      repeatBoost + recencyBoost + aovBoost\n    ).toFixed(4));\n\n    // discount we can afford: never eat more than a third of the margin.\n    const maxDiscountPct = Math.floor(rule.margin * 100 / 3);\n    const discountPct = Math.max(10, Math.min(25, maxDiscountPct - (score > 0.7 ? 5 : 0)));\n\n    candidates.push({\n      anchor_sku: sku,\n      anchor_name: ((order.line_items || []).find(li => li.sku === sku) || {}).title || sku,\n      target_sku: rule.target,\n      target_name: product.title,\n      target_price: Number(product.price),\n      lift: rule.lift,\n      margin: rule.margin,\n      score,\n      discount_pct: discountPct,\n      offer_price: Number((product.price * (1 - discountPct / 100)).toFixed(2)),\n      projected_margin: Number((product.price * (1 - discountPct / 100) * rule.margin).toFixed(2))\n    });\n  }\n}\n\n// 4. Dedupe by target sku, keep the highest score.\nconst best = new Map();\nfor (const c of candidates) {\n  const prev = best.get(c.target_sku);\n  if (!prev || c.score > prev.score) best.set(c.target_sku, c);\n}\nconst ranked = [...best.values()].sort((a, b) => b.score - a.score);\nconst winner = ranked[0] || null;\n\nconst blocked =\n  aov < MIN_AOV_FOR_UPSELL ? 'aov_below_floor'\n  : lastUpsellDays < COOLDOWN_DAYS ? 'cooldown_active'\n  : !winner ? 'no_affinity_match'\n  : winner.score < MIN_SCORE ? 'score_below_threshold'\n  : null;\n\nreturn [{ json: {\n  order_id: order.id,\n  customer_email: order.email,\n  first_name: order.first_name,\n  order_total: aov,\n  order_count: orderCount,\n  lifetime_value: Number(lifetimeValue.toFixed(2)),\n  days_since_last_order: Number(daysSinceLast.toFixed(1)),\n  days_since_last_upsell: Number(lastUpsellDays.toFixed(1)),\n  eligible: !blocked,\n  block_reason: blocked,\n  upsell_score: winner ? winner.score : 0,\n  runner_up: ranked[1] ? ranked[1].target_sku : null,\n  candidates_considered: candidates.length,\n  ...(winner || {})\n} }];\n"}},{"id":"aaaaaaa7-1111-4222-8333-444455556607","name":"Upsell Worth Sending?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1140,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.eligible }}","rightValue":true,"operator":{"type":"boolean","operation":"true"}},{"id":"c2","leftValue":"={{ $json.upsell_score }}","rightValue":0.45,"operator":{"type":"number","operation":"gte"}}]},"options":{}}},{"id":"aaaaaaa8-1111-4222-8333-444455556608","name":"Build Offer Window","type":"n8n-nodes-base.code","typeVersion":2,"position":[1400,100],"parameters":{"jsCode":"// ---- Time-limited offer window ------------------------------------------\n// Window length is inverse to intent: hot buyers get urgency, cold ones get room.\nconst r = $input.first().json;\n\nconst baseHours = r.upsell_score >= 0.70 ? 24\n                : r.upsell_score >= 0.55 ? 48\n                : 72;\n\n// Never let the window close while the customer is asleep — push to 09:00 local.\nconst opens = new Date();\nconst closes = new Date(opens.getTime() + baseHours * 3600 * 1000);\nif (closes.getHours() < 8) closes.setHours(10, 0, 0, 0);\nif (closes.getHours() > 21) closes.setHours(21, 0, 0, 0);\n\nconst code = ('UP' + r.target_sku.replace(/[^A-Z0-9]/g, '') + String(r.order_id).slice(-4))\n  .toUpperCase().slice(0, 16);\n\n// Pacing projection: expected revenue from this send at historical CVR by score band.\nconst expectedCvr = r.upsell_score >= 0.70 ? 0.14 : r.upsell_score >= 0.55 ? 0.09 : 0.05;\n\nreturn [{ json: {\n  ...r,\n  discount_code: code,\n  window_hours: baseHours,\n  window_opens_at: opens.toISOString(),\n  window_closes_at: closes.toISOString(),\n  window_closes_human: closes.toUTCString(),\n  expected_cvr: expectedCvr,\n  projected_revenue: Number((r.offer_price * expectedCvr).toFixed(2)),\n  projected_profit: Number((r.projected_margin * expectedCvr).toFixed(2))\n} }];\n"}},{"id":"aaaaaaa9-1111-4222-8333-444455556609","name":"Write Offer To Ledger","type":"n8n-nodes-base.postgres","typeVersion":2.5,"position":[1660,100],"parameters":{"operation":"executeQuery","query":"insert into upsell_sends (email, order_id, target_sku, discount_code, expires_at, sent_at) values ($1,$2,$3,$4,$5, now()) on conflict (discount_code) do nothing","options":{"queryReplacement":"={{ $json.customer_email }},{{ $json.order_id }},{{ $json.target_sku }},{{ $json.discount_code }},{{ $json.window_closes_at }}"}}},{"id":"aaaaaa10-1111-4222-8333-444455556610","name":"Generate Personalized Offer","type":"@n8n/n8n-nodes-langchain.openAi","typeVersion":1.8,"position":[1920,100],"parameters":{"modelId":{"__rl":true,"value":"gpt-4o-mini","mode":"list"},"messages":{"values":[{"role":"system","content":"You are a DTC retention copywriter. Return ONLY JSON: {\"subject\": string, \"body\": string}. Body is max 70 words, second person, no emojis, no hype words, one clear reason the add-on fits the product they just bought."},{"content":"=Customer: {{ $json.first_name }} ({{ $json.order_count }} orders, LTV ${{ $json.lifetime_value }}).\nJust bought: {{ $json.anchor_name }} ({{ $json.anchor_sku }}).\nUpsell: {{ $json.target_name }} at ${{ $json.offer_price }} instead of ${{ $json.target_price }} ({{ $json.discount_pct }}% off).\nAffinity lift: {{ $json.lift }}x. Offer expires in {{ $json.window_hours }} hours."}]},"options":{}}},{"id":"aaaaaa11-1111-4222-8333-444455556611","name":"Assemble Upsell Email","type":"n8n-nodes-base.code","typeVersion":2,"position":[2180,100],"parameters":{"jsCode":"// ---- Assemble the send ---------------------------------------------------\nconst offer = $('Build Offer Window').first().json;\nconst llm = $input.first().json;\n\nconst raw = llm.message?.content ?? llm.content ?? llm.text ?? '';\nlet parsed = {};\ntry { parsed = JSON.parse(String(raw).replace(/```json|```/g, '').trim()); } catch (e) { parsed = {}; }\n\nconst subject = (parsed.subject || `${offer.first_name}, one thing goes with your ${offer.anchor_name}`).slice(0, 90);\nconst body = parsed.body || `You just picked up ${offer.anchor_name}. Most people add ${offer.target_name} next.`;\n\nconst html = [\n  `<p>${body.replace(/\\n/g, '<br>')}</p>`,\n  `<p><strong>${offer.target_name}</strong> — <s>$${offer.target_price}</s> <strong>$${offer.offer_price}</strong> (${offer.discount_pct}% off)</p>`,\n  `<p>Code <strong>${offer.discount_code}</strong> — expires ${offer.window_closes_human} (${offer.window_hours}h).</p>`,\n  `<p><a href=\"https://brand.com/cart/add?sku=${offer.target_sku}&discount=${offer.discount_code}\">Add it to my order</a></p>`\n].join('\\n');\n\nreturn [{ json: { ...offer, subject, body, html } }];\n"}},{"id":"aaaaaa12-1111-4222-8333-444455556612","name":"Send Upsell Email","type":"n8n-nodes-base.gmail","typeVersion":2.1,"position":[2440,100],"parameters":{"sendTo":"={{ $json.customer_email }}","subject":"={{ $json.subject }}","message":"={{ $json.html }}","options":{}}},{"id":"aaaaaa13-1111-4222-8333-444455556613","name":"Log Upsell Sent","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[2700,100],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Upsells"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"aaaaaa14-1111-4222-8333-444455556614","name":"Hold Until Window Closes","type":"n8n-nodes-base.wait","typeVersion":1.1,"position":[2960,100],"parameters":{"amount":"={{ $('Build Offer Window').first().json.window_hours }}","unit":"hours"}},{"id":"aaaaaa15-1111-4222-8333-444455556615","name":"Expire Discount Code","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[3220,100],"parameters":{"method":"POST","url":"https://brand.myshopify.com/admin/api/2024-10/price_rules/PRICE_RULE_ID/discount_codes/expire.json","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ discount_code: $('Build Offer Window').first().json.discount_code, ends_at: $('Build Offer Window').first().json.window_closes_at, reason: \"upsell_window_closed\" }) }}","options":{}}},{"id":"aaaaaa16-1111-4222-8333-444455556616","name":"Tag Skipped Order","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[1400,520],"parameters":{"assignments":{"assignments":[{"id":"s1","name":"order_id","value":"={{ $json.order_id }}","type":"string"},{"id":"s2","name":"skip_reason","value":"={{ $json.block_reason }}","type":"string"},{"id":"s3","name":"upsell_score","value":"={{ $json.upsell_score }}","type":"number"},{"id":"s4","name":"candidates_considered","value":"={{ $json.candidates_considered }}","type":"number"}]},"options":{}}},{"id":"aaaaaa17-1111-4222-8333-444455556617","name":"Log Skipped Order","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1660,520],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=1","mode":"list","cachedResultName":"Skipped"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"aaaaaa18-1111-4222-8333-444455556618","name":"Alert On No Affinity Match","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[1920,520],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#retention","mode":"name"},"text":"=No upsell for order {{ $json.order_id }} — reason: {{ $json.skip_reason }} (best score {{ $json.upsell_score }}, {{ $json.candidates_considered }} candidates). Check the affinity rules if this repeats.","otherOptions":{}}},{"id":"aaaaaa19-1111-4222-8333-444455556619","name":"Sticky Rules","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[340,-140],"parameters":{"content":"## 1. GATHER\nNew order in, then pull 90d of that customer order history (plus the last upsell timestamp for the cooldown) and the live catalog with stock levels. Both land in the Merge.","height":260,"width":460,"color":4}},{"id":"aaaaaa20-1111-4222-8333-444455556620","name":"Sticky Scoring","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[860,-140],"parameters":{"content":"## 2. SCORE\nCo-purchase lift rules -> drop anything already owned or under 5 units in stock -> score = lift 0.55 + margin 0.25 + repeat/recency/AOV boosts. Discount is capped at a third of margin. Gates: AOV >= $29, 14d cooldown, score >= 0.45.","height":300,"width":460,"color":5}},{"id":"aaaaaa21-1111-4222-8333-444455556621","name":"Sticky Send","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1900,-200],"parameters":{"content":"## 3. OFFER + WINDOW\nWindow is inverse to intent: 24h at score 0.70+, 48h mid, 72h cold, never closing overnight. Code is written to the ledger BEFORE the send so a retry cannot double-offer, then the Wait expires it.","height":280,"width":460,"color":3}}],"connections":{"New Order Webhook":{"main":[[{"node":"Normalize Order","type":"main","index":0}]]},"Normalize Order":{"main":[[{"node":"Fetch Customer Order History","type":"main","index":0},{"node":"Fetch Live Catalog","type":"main","index":0}]]},"Fetch Customer Order History":{"main":[[{"node":"Merge History And Catalog","type":"main","index":0}]]},"Fetch Live Catalog":{"main":[[{"node":"Merge History And Catalog","type":"main","index":1}]]},"Merge History And Catalog":{"main":[[{"node":"Score Upsell Affinity","type":"main","index":0}]]},"Score Upsell Affinity":{"main":[[{"node":"Upsell Worth Sending?","type":"main","index":0}]]},"Upsell Worth Sending?":{"main":[[{"node":"Build Offer Window","type":"main","index":0}],[{"node":"Tag Skipped Order","type":"main","index":0}]]},"Build Offer Window":{"main":[[{"node":"Write Offer To Ledger","type":"main","index":0}]]},"Write Offer To Ledger":{"main":[[{"node":"Generate Personalized Offer","type":"main","index":0}]]},"Generate Personalized Offer":{"main":[[{"node":"Assemble Upsell Email","type":"main","index":0}]]},"Assemble Upsell Email":{"main":[[{"node":"Send Upsell Email","type":"main","index":0}]]},"Send Upsell Email":{"main":[[{"node":"Log Upsell Sent","type":"main","index":0}]]},"Log Upsell Sent":{"main":[[{"node":"Hold Until Window Closes","type":"main","index":0}]]},"Hold Until Window Closes":{"main":[[{"node":"Expire Discount Code","type":"main","index":0}]]},"Tag Skipped Order":{"main":[[{"node":"Log Skipped Order","type":"main","index":0}]]},"Log Skipped Order":{"main":[[{"node":"Alert On No Affinity Match","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}