{"name":"Abandoned cart recovery workflow","nodes":[{"id":"f3a1c001-1111-4222-8333-444455556601","name":"Cart Abandoned Webhook","type":"n8n-nodes-base.webhook","typeVersion":2,"position":[-320,300],"parameters":{"path":"shopify-cart-abandoned","options":{}}},{"id":"f3a1c002-1111-4222-8333-444455556602","name":"Score Cart & Plan Sequence","type":"n8n-nodes-base.code","typeVersion":2,"position":[-60,300],"parameters":{"jsCode":"// ── Cart abandonment intake ────────────────────────────────────────────────\n// Shopify checkouts/abandoned payload. Score the cart, decide whether it is\n// worth a recovery sequence, and lay out the escalation plan up front.\nconst events = $input.all().map(i => i.json.body || i.json);\n\n// Discount ladder: never lead with money. Touch 1 = reminder only.\nconst LADDER = [\n  { touch: 1, delay_hours: 1,  discount_pct: 0,  angle: 'gentle_reminder' },\n  { touch: 2, delay_hours: 22, discount_pct: 10, angle: 'objection_handling' },\n  { touch: 3, delay_hours: 48, discount_pct: 15, angle: 'last_call_scarcity' }\n];\n\nconst MIN_CART_VALUE = 25;      // below this the email cost outruns the margin\nconst SEEN_WINDOW_HOURS = 72;   // dedupe: one sequence per email per 3 days\n\nconst seen = new Map();\nconst out = [];\n\nfor (const e of events) {\n  const email = String(e.email || e.customer?.email || '').trim().toLowerCase();\n  if (!email || !email.includes('@')) continue;\n\n  const lineItems = e.line_items || [];\n  const units = lineItems.reduce((s, li) => s + Number(li.quantity || 0), 0);\n  const cartValue = Number(\n    e.total_price != null\n      ? e.total_price\n      : lineItems.reduce((s, li) => s + Number(li.price || 0) * Number(li.quantity || 0), 0)\n  );\n\n  // Dedupe inside this batch — Shopify fires checkouts/abandoned per update.\n  const prev = seen.get(email);\n  if (prev && Math.abs(Date.now() - prev.ts) < SEEN_WINDOW_HOURS * 3600e3) {\n    if (cartValue <= prev.value) continue;       // keep the richer cart\n    out.splice(out.findIndex(r => r.email === email), 1);\n  }\n  seen.set(email, { ts: Date.now(), value: cartValue });\n\n  // ── Intent score 0-100 ───────────────────────────────────────────────────\n  // Value (0-40): log-ish scaling so a $2k cart doesn't dwarf everything else.\n  const valueScore = Math.min(40, Math.round(Math.log10(Math.max(cartValue, 1)) * 18));\n  // Depth of checkout reached (0-25).\n  const stepScore = e.shipping_address ? 25 : (e.billing_address ? 15 : 5);\n  // Returning customer (0-20).\n  const orders = Number(e.customer?.orders_count || 0);\n  const loyaltyScore = orders >= 3 ? 20 : orders === 2 ? 14 : orders === 1 ? 9 : 0;\n  // Freshness (0-15): decays 1pt per hour since abandonment.\n  const ageHours = (Date.now() - new Date(e.abandoned_checkout_at || e.updated_at || Date.now()).getTime()) / 3600e3;\n  const freshScore = Math.max(0, 15 - Math.round(ageHours));\n\n  const score = valueScore + stepScore + loyaltyScore + freshScore;\n\n  // Hot carts get chased sooner and skip the fat discount at the end.\n  const hot = score >= 70;\n  const plan = LADDER.map(step => ({\n    ...step,\n    delay_hours: hot ? Math.max(1, Math.round(step.delay_hours * 0.6)) : step.delay_hours,\n    discount_pct: hot ? Math.max(0, step.discount_pct - 5) : step.discount_pct\n  }));\n\n  const topItem = lineItems.slice().sort((a, b) =>\n    Number(b.price || 0) * Number(b.quantity || 0) - Number(a.price || 0) * Number(a.quantity || 0)\n  )[0] || {};\n\n  out.push({\n    checkout_id: e.id || e.token || `ck_${email}_${Date.now()}`,\n    email,\n    first_name: e.customer?.first_name || (email.split('@')[0] || 'there'),\n    currency: e.currency || 'USD',\n    cart_value: Number(cartValue.toFixed(2)),\n    units,\n    item_count: lineItems.length,\n    top_product: topItem.title || 'your item',\n    top_product_price: Number(topItem.price || 0),\n    orders_count: orders,\n    recovery_url: e.abandoned_checkout_url || `https://shop.example.com/checkout/recover/${e.token || ''}`,\n    intent_score: score,\n    segment: score >= 70 ? 'hot' : score >= 45 ? 'warm' : 'cold',\n    sequence_plan: plan,\n    max_touches: plan.length,\n    touch: 1,\n    first_delay_hours: plan[0].delay_hours,\n    abandoned_at: new Date().toISOString()\n  });\n}\n\nreturn out.map(r => ({ json: r }));"}},{"id":"f3a1c003-1111-4222-8333-444455556603","name":"Cart Worth Chasing?","type":"n8n-nodes-base.if","typeVersion":2,"position":[200,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.cart_value }}","rightValue":25,"operator":{"type":"number","operation":"gte"}},{"id":"c2","leftValue":"={{ $json.intent_score }}","rightValue":30,"operator":{"type":"number","operation":"gte"}}]},"options":{}}},{"id":"f3a1c004-1111-4222-8333-444455556604","name":"Log Ignored Low-Intent Carts","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[460,520],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=1","mode":"list","cachedResultName":"Ignored Carts"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"f3a1c005-1111-4222-8333-444455556605","name":"Wait Before Touch","type":"n8n-nodes-base.wait","typeVersion":1.1,"position":[460,300],"parameters":{"amount":"={{ $json.first_delay_hours }}","unit":"hours"}},{"id":"f3a1c006-1111-4222-8333-444455556606","name":"Fetch Customer Orders","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[720,300],"parameters":{"url":"https://SHOP.myshopify.com/admin/api/2024-10/orders.json","sendQuery":true,"queryParameters":{"parameters":[{"name":"email","value":"={{ $json.email }}"},{"name":"status","value":"any"},{"name":"created_at_min","value":"={{ $json.abandoned_at }}"},{"name":"fields","value":"id,email,total_price,financial_status,created_at,line_items"}]},"options":{}}},{"id":"f3a1c007-1111-4222-8333-444455556607","name":"Check Order Status","type":"n8n-nodes-base.code","typeVersion":2,"position":[980,300],"parameters":{"jsCode":"// ── Did they come back on their own? Which touch is next? ──────────────────\n// Input: Shopify /orders.json search response for this customer's email.\nconst cart = $('Score Cart & Plan Sequence').first().json;\nconst resp = $input.first().json || {};\nconst orders = resp.orders || [];\n\n// Which touch already went out? On the first pass the send node has never run.\nlet lastTouchSent = 0;\ntry { lastTouchSent = Number($('Assemble Recovery Email').first().json.touch) || 0; } catch (e) { lastTouchSent = 0; }\nconst touch = Math.min(lastTouchSent + 1, cart.max_touches);\nconst step = cart.sequence_plan[touch - 1];\n\nconst abandonedTs = new Date(cart.abandoned_at).getTime();\nconst recovering = orders.filter(o =>\n  String(o.financial_status || '').toLowerCase() !== 'voided' &&\n  new Date(o.created_at).getTime() >= abandonedTs - 5 * 60e3\n);\nconst won = recovering.sort((a, b) => Number(b.total_price) - Number(a.total_price))[0];\n\nconst recovered = Boolean(won);\nconst recoveredValue = recovered ? Number(won.total_price) : 0;\n// Attributed only if the order is at least 60% of the abandoned cart —\n// a $9 reorder of socks is not a recovered $400 cart.\nconst attributed = recovered && recoveredValue >= cart.cart_value * 0.6;\n\nreturn [{ json: {\n  ...cart,\n  touch,\n  is_last_touch: touch >= cart.max_touches,\n  has_next_touch: touch < cart.max_touches,\n  next_delay_hours: cart.sequence_plan[touch] ? cart.sequence_plan[touch].delay_hours : 0,\n  discount_pct: step.discount_pct,\n  discount_code: step.discount_pct > 0 ? `BACK${step.discount_pct}-${String(cart.checkout_id).slice(-5)}` : '',\n  angle: step.angle,\n  recovered,\n  attributed_recovery: attributed,\n  recovered_order_id: recovered ? won.id : null,\n  recovered_value: recoveredValue,\n  checked_at: new Date().toISOString()\n} }];"}},{"id":"f3a1c008-1111-4222-8333-444455556608","name":"Already Purchased?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1240,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.attributed_recovery }}","rightValue":true,"operator":{"type":"boolean","operation":"true","singleValue":true}}]},"options":{}}},{"id":"f3a1c009-1111-4222-8333-444455556609","name":"Log Recovered Revenue","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1500,80],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=2","mode":"list","cachedResultName":"Recovered"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"f3a1c010-1111-4222-8333-444455556610","name":"Announce Recovery In Slack","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[1760,80],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#revenue","mode":"name"},"text":"=💰 Cart recovered: {{ $json.email }} — {{ $json.currency }} {{ $json.recovered_value }} after touch {{ $json.touch - 1 }} ({{ $json.segment }} cart, score {{ $json.intent_score }})","otherOptions":{}}},{"id":"f3a1c011-1111-4222-8333-444455556611","name":"Write Subject Line","type":"@n8n/n8n-nodes-langchain.openAi","typeVersion":1.8,"position":[1500,300],"parameters":{"modelId":{"__rl":true,"value":"gpt-4o-mini","mode":"list"},"messages":{"values":[{"role":"system","content":"You are a DTC ecommerce email copywriter. Return ONE subject line only, under 55 characters, no quotes, no emoji, no \"Re:\", plain sentence case. Never mention the word \"abandoned\"."},{"content":"=Angle: {{ $json.angle }} (touch {{ $json.touch }} of {{ $json.max_touches }}).\nCustomer: {{ $json.first_name }}, {{ $json.orders_count }} past orders, segment {{ $json.segment }}.\nCart: {{ $json.top_product }} plus {{ $json.item_count - 1 }} other items, {{ $json.currency }} {{ $json.cart_value }}.\nDiscount offered: {{ $json.discount_pct }}%.\nWrite the subject line."}]},"options":{}}},{"id":"f3a1c012-1111-4222-8333-444455556612","name":"Assemble Recovery Email","type":"n8n-nodes-base.code","typeVersion":2,"position":[1760,300],"parameters":{"jsCode":"// ── Build the actual email body for this touch ─────────────────────────────\nconst d = $('Check Order Status').first().json;\nconst raw = $input.first().json;\n\n// The LLM node returns the subject in message.content — strip quotes/prefixes\n// and hard-cap at 58 chars so Gmail doesn't truncate mid-word.\nlet subject = String(\n  raw.message?.content ?? raw.content ?? raw.text ?? `${d.first_name}, your cart is still here`\n).trim().replace(/^[\"'\\s]+|[\"'\\s]+$/g, '').replace(/^subject:\\s*/i, '').split('\\n')[0];\nif (subject.length > 58) {\n  const cut = subject.slice(0, 58);\n  subject = cut.slice(0, Math.max(cut.lastIndexOf(' '), 40)).trim();\n}\n\nconst money = n => `${d.currency === 'USD' ? '$' : d.currency + ' '}${Number(n).toFixed(2)}`;\n\nconst bodyByAngle = {\n  gentle_reminder: `You left <b>${d.top_product}</b> in your cart — we saved it for you, no rush.`,\n  objection_handling: `Still thinking it over? Free returns for 30 days, and it ships in 48h. Here's ${d.discount_pct}% off to make the call easy.`,\n  last_call_scarcity: `Last note on this one. We can only hold <b>${d.top_product}</b> for another 24 hours, and ${d.discount_pct}% off goes with it.`\n};\n\nconst discountBlock = d.discount_pct > 0\n  ? `<p style=\"font-size:15px\">Use code <b>${d.discount_code}</b> for <b>${d.discount_pct}% off</b> (${money(d.cart_value * d.discount_pct / 100)} saved).</p>`\n  : '';\n\nconst html = [\n  `<p>Hi ${d.first_name},</p>`,\n  `<p>${bodyByAngle[d.angle] || bodyByAngle.gentle_reminder}</p>`,\n  `<p style=\"color:#555\">${d.item_count} item${d.item_count === 1 ? '' : 's'} · ${money(d.cart_value)}</p>`,\n  discountBlock,\n  `<p><a href=\"${d.recovery_url}\" style=\"background:#111;color:#fff;padding:12px 22px;text-decoration:none\">Finish checkout</a></p>`,\n  `<p style=\"font-size:12px;color:#999\">Touch ${d.touch} of ${d.max_touches} · segment: ${d.segment}</p>`\n].join('\\n');\n\nreturn [{ json: { ...d, subject, html, sent_at: new Date().toISOString() } }];"}},{"id":"f3a1c013-1111-4222-8333-444455556613","name":"Send Recovery Email","type":"n8n-nodes-base.gmail","typeVersion":2.1,"position":[2020,300],"parameters":{"sendTo":"={{ $json.email }}","subject":"={{ $json.subject }}","message":"={{ $json.html }}","options":{}}},{"id":"f3a1c014-1111-4222-8333-444455556614","name":"Log Touch Sent","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[2280,300],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Touches"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"f3a1c015-1111-4222-8333-444455556615","name":"More Touches Left?","type":"n8n-nodes-base.if","typeVersion":2,"position":[2540,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.touch }}","rightValue":"={{ $json.max_touches }}","operator":{"type":"number","operation":"lt"}}]},"options":{}}},{"id":"f3a1c016-1111-4222-8333-444455556616","name":"Wait For Next Escalation","type":"n8n-nodes-base.wait","typeVersion":1.1,"position":[2800,520],"parameters":{"amount":"={{ $json.next_delay_hours }}","unit":"hours"}},{"id":"f3a1c017-1111-4222-8333-444455556617","name":"Push To Winback Audience","type":"n8n-nodes-base.airtable","typeVersion":2.1,"position":[2800,300],"parameters":{"operation":"create","base":{"__rl":true,"value":"appXXXXXXXX","mode":"id"},"table":{"__rl":true,"value":"tblWINBACK01","mode":"id"},"columns":{"mappingMode":"autoMapInputData","value":{}},"options":{}}},{"id":"f3a1c090-1111-4222-8333-444455556690","name":"Note Intake","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-360,0],"parameters":{"content":"## 1. INTAKE + SCORING\nShopify checkouts/abandoned webhook.\nCode node dedupes by email (72h window),\nscores intent 0-100 from cart value, checkout\ndepth, past orders and freshness, then builds\na 3-touch ladder (0% / 10% / 15%, compressed\nfor hot carts). Carts under $25 or score < 30\nare logged and dropped.","height":320,"width":460,"color":4}},{"id":"f3a1c091-1111-4222-8333-444455556691","name":"Note Guardrail","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[700,0],"parameters":{"content":"## 2. DID THEY BUY ANYWAY?\nBefore every send we re-query Shopify orders\nfor that email since abandonment. A recovery\nonly counts if the order is >= 60% of the\nabandoned cart value, so a small unrelated\nreorder does not suppress the sequence.","height":280,"width":460,"color":3}},{"id":"f3a1c092-1111-4222-8333-444455556692","name":"Note Send Loop","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1480,-220],"parameters":{"content":"## 3. PERSONALISED SEND + ESCALATION\nLLM writes only the subject line (capped at 58\nchars in code); the body is deterministic HTML\nper angle. After logging, if touches remain we\nwait the ladder delay and loop back to the order\ncheck. When the ladder is exhausted the contact\ngoes to the Airtable winback audience.","height":300,"width":480,"color":5}}],"connections":{"Cart Abandoned Webhook":{"main":[[{"node":"Score Cart & Plan Sequence","type":"main","index":0}]]},"Score Cart & Plan Sequence":{"main":[[{"node":"Cart Worth Chasing?","type":"main","index":0}]]},"Cart Worth Chasing?":{"main":[[{"node":"Wait Before Touch","type":"main","index":0}],[{"node":"Log Ignored Low-Intent Carts","type":"main","index":0}]]},"Wait Before Touch":{"main":[[{"node":"Fetch Customer Orders","type":"main","index":0}]]},"Fetch Customer Orders":{"main":[[{"node":"Check Order Status","type":"main","index":0}]]},"Check Order Status":{"main":[[{"node":"Already Purchased?","type":"main","index":0}]]},"Already Purchased?":{"main":[[{"node":"Log Recovered Revenue","type":"main","index":0}],[{"node":"Write Subject Line","type":"main","index":0}]]},"Log Recovered Revenue":{"main":[[{"node":"Announce Recovery In Slack","type":"main","index":0}]]},"Write Subject Line":{"main":[[{"node":"Assemble Recovery Email","type":"main","index":0}]]},"Assemble Recovery Email":{"main":[[{"node":"Send Recovery Email","type":"main","index":0}]]},"Send Recovery Email":{"main":[[{"node":"Log Touch Sent","type":"main","index":0}]]},"Log Touch Sent":{"main":[[{"node":"More Touches Left?","type":"main","index":0}]]},"More Touches Left?":{"main":[[{"node":"Wait For Next Escalation","type":"main","index":0}],[{"node":"Push To Winback Audience","type":"main","index":0}]]},"Wait For Next Escalation":{"main":[[{"node":"Fetch Customer Orders","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}