{"name":"Google + Meta sync workflow","nodes":[{"id":"bb000001-1111-4222-8333-444455556601","name":"Every Morning At 7","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-700,300],"parameters":{"rule":{"interval":[{"field":"days","daysInterval":1}]}}},{"id":"bb000002-1111-4222-8333-444455556602","name":"Set Shared Date Range","type":"n8n-nodes-base.code","typeVersion":2,"position":[-460,300],"parameters":{"jsCode":"// Both platforms MUST be asked for the exact same window or the blend is a lie.\n// Meta takes since/until, Google takes a BETWEEN clause in GAQL - same dates, two dialects.\nconst LOOKBACK_DAYS = 7;\n\nconst iso = (d) => d.toISOString().slice(0, 10);\nconst today = new Date();\n// yesterday is the last fully-attributed day; today's data is always half-baked\nconst until = new Date(today.getTime() - 24 * 60 * 60 * 1000);\nconst since = new Date(until.getTime() - (LOOKBACK_DAYS - 1) * 24 * 60 * 60 * 1000);\n\n// previous equal-length window, for period-over-period on the blended numbers\nconst prevUntil = new Date(since.getTime() - 24 * 60 * 60 * 1000);\nconst prevSince = new Date(prevUntil.getTime() - (LOOKBACK_DAYS - 1) * 24 * 60 * 60 * 1000);\n\nreturn [{\n  json: {\n    lookback_days: LOOKBACK_DAYS,\n    since: iso(since),\n    until: iso(until),\n    prev_since: iso(prevSince),\n    prev_until: iso(prevUntil),\n    time_range: JSON.stringify({ since: iso(since), until: iso(until) }),\n    // Shopify filters on full timestamps: a bare date as created_at_max means\n    // midnight, which silently drops the whole last day of orders.\n    since_ts: iso(since) + 'T00:00:00Z',\n    until_ts: iso(until) + 'T23:59:59Z',\n    gaql_between: \"segments.date BETWEEN '\" + iso(since) + \"' AND '\" + iso(until) + \"'\",\n    label: iso(since) + ' → ' + iso(until)\n  }\n}];"}},{"id":"bb000003-1111-4222-8333-444455556603","name":"Fetch Meta Campaign Insights","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[-220,140],"parameters":{"url":"https://graph.facebook.com/v21.0/act_ID/insights","sendQuery":true,"queryParameters":{"parameters":[{"name":"level","value":"campaign"},{"name":"fields","value":"campaign_id,campaign_name,objective,spend,impressions,clicks,ctr,cpm,frequency,reach,actions,action_values,purchase_roas"},{"name":"action_attribution_windows","value":"7d_click,1d_view"},{"name":"time_range","value":"={{ $json.time_range }}"},{"name":"limit","value":"500"}]},"options":{}}},{"id":"bb000004-1111-4222-8333-444455556604","name":"Fetch Google Campaign Stats","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[-220,460],"parameters":{"method":"POST","url":"https://googleads.googleapis.com/v18/customers/CUSTOMER_ID/googleAds:searchStream","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ query: \"SELECT campaign.id, campaign.name, campaign.advertising_channel_type, metrics.cost_micros, metrics.impressions, metrics.clicks, metrics.ctr, metrics.conversions, metrics.conversions_value, metrics.average_cpc, metrics.search_impression_share FROM campaign WHERE \" + $json.gaql_between + \" AND metrics.impressions > 0\" }) }}","options":{}}},{"id":"bb000005-1111-4222-8333-444455556605","name":"Normalise Meta Rows","type":"n8n-nodes-base.code","typeVersion":2,"position":[20,140],"parameters":{"jsCode":"// Meta speaks in arrays of {action_type, value}. Flatten it into the same\n// shape Google will be flattened into, so the merge downstream is trivial.\nconst num = (v) => {\n  const n = typeof v === 'string' ? parseFloat(v) : v;\n  return Number.isFinite(n) ? n : 0;\n};\n\nconst pick = (arr, ...types) => {\n  if (!Array.isArray(arr)) return 0;\n  for (const t of types) {\n    const hit = arr.find((a) => a.action_type === t);\n    if (hit) return num(hit.value);\n  }\n  return 0;\n};\n\n// the API can wrap the rows in { data: [...] } or hand them over one per item\nconst raw = [];\nfor (const item of $input.all()) {\n  const j = item.json;\n  if (Array.isArray(j.data)) raw.push(...j.data);\n  else if (j.campaign_id || j.campaign_name) raw.push(j);\n}\n\nconst out = raw.map((r) => {\n  const spend = num(r.spend);\n  const impressions = num(r.impressions);\n  const clicks = num(r.clicks);\n  const conversions = pick(r.actions, 'purchase', 'omni_purchase', 'offsite_conversion.fb_pixel_purchase');\n  const revenue = pick(r.action_values, 'purchase', 'omni_purchase', 'offsite_conversion.fb_pixel_purchase');\n  const reportedRoas = Array.isArray(r.purchase_roas)\n    ? pick(r.purchase_roas, 'purchase', 'omni_purchase')\n    : num(r.purchase_roas);\n  // trust the reported ROAS, fall back to computing it ourselves\n  const roas = reportedRoas || (spend ? revenue / spend : 0);\n\n  return {\n    json: {\n      channel: 'meta',\n      campaign_id: String(r.campaign_id || ''),\n      campaign_name: r.campaign_name || '(unnamed)',\n      objective: r.objective || '',\n      spend: spend,\n      impressions: impressions,\n      clicks: clicks,\n      conversions: conversions,\n      revenue: revenue || roas * spend,\n      roas: roas,\n      ctr: impressions ? (clicks / impressions) * 100 : 0,\n      cpm: impressions ? (spend / impressions) * 1000 : 0,\n      cpc: clicks ? spend / clicks : 0,\n      frequency: num(r.frequency),\n      reach: num(r.reach),\n      impression_share: null\n    }\n  };\n});\n\nreturn out;"}},{"id":"bb000006-1111-4222-8333-444455556606","name":"Normalise Google Rows","type":"n8n-nodes-base.code","typeVersion":2,"position":[20,460],"parameters":{"jsCode":"// Google searchStream returns an array of chunks, each { results: [ { campaign, metrics } ] }.\n// Money is in micros (1,000,000 micros = 1 unit of currency) - convert or every\n// blended number will be off by six orders of magnitude.\nconst num = (v) => {\n  const n = typeof v === 'string' ? parseFloat(v) : v;\n  return Number.isFinite(n) ? n : 0;\n};\n\nconst results = [];\nfor (const item of $input.all()) {\n  const j = item.json;\n  if (Array.isArray(j.results)) results.push(...j.results);\n  else if (Array.isArray(j)) {\n    for (const chunk of j) if (Array.isArray(chunk.results)) results.push(...chunk.results);\n  } else if (j.campaign || j.metrics) results.push(j);\n}\n\nconst CHANNEL_LABEL = {\n  SEARCH: 'Search',\n  PERFORMANCE_MAX: 'PMax',\n  SHOPPING: 'Shopping',\n  DISPLAY: 'Display',\n  VIDEO: 'YouTube',\n  DEMAND_GEN: 'Demand Gen'\n};\n\nreturn results.map((r) => {\n  const c = r.campaign || {};\n  const m = r.metrics || {};\n  const spend = num(m.costMicros !== undefined ? m.costMicros : m.cost_micros) / 1e6;\n  const impressions = num(m.impressions);\n  const clicks = num(m.clicks);\n  const conversions = num(m.conversions);\n  const revenue = num(m.conversionsValue !== undefined ? m.conversionsValue : m.conversions_value);\n  const type = c.advertisingChannelType || c.advertising_channel_type || '';\n  const share = m.searchImpressionShare !== undefined ? m.searchImpressionShare : m.search_impression_share;\n\n  return {\n    json: {\n      channel: 'google',\n      campaign_id: String(c.id || ''),\n      campaign_name: c.name || '(unnamed)',\n      objective: CHANNEL_LABEL[type] || type,\n      spend: spend,\n      impressions: impressions,\n      clicks: clicks,\n      conversions: conversions,\n      revenue: revenue,\n      roas: spend ? revenue / spend : 0,\n      ctr: impressions ? (clicks / impressions) * 100 : 0,\n      cpm: impressions ? (spend / impressions) * 1000 : 0,\n      cpc: clicks ? spend / clicks : 0,\n      frequency: null,\n      reach: null,\n      impression_share: share === undefined || share === null ? null : num(share) * 100\n    }\n  };\n});"}},{"id":"bb000007-1111-4222-8333-444455556607","name":"Merge Both Channels","type":"n8n-nodes-base.merge","typeVersion":3,"position":[260,300],"parameters":{"mode":"append","options":{}}},{"id":"bb000008-1111-4222-8333-444455556608","name":"Fetch Shopify Total Revenue","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[260,620],"parameters":{"url":"https://STORE.myshopify.com/admin/api/2024-10/orders.json","sendQuery":true,"queryParameters":{"parameters":[{"name":"status","value":"any"},{"name":"financial_status","value":"paid"},{"name":"created_at_min","value":"={{ $('Set Shared Date Range').first().json.since_ts }}"},{"name":"created_at_max","value":"={{ $('Set Shared Date Range').first().json.until_ts }}"},{"name":"fields","value":"id,created_at,total_price,current_total_price"},{"name":"limit","value":"250"}]},"options":{}}},{"id":"bb000009-1111-4222-8333-444455556609","name":"Blend Into One View","type":"n8n-nodes-base.code","typeVersion":2,"position":[500,300],"parameters":{"jsCode":"// The whole point of this workflow: two platforms that each grade their own\n// homework, forced into one table where spend share and MER are comparable.\n//\n// MER (Marketing Efficiency Ratio) = TOTAL store revenue / TOTAL ad spend.\n// Platform-attributed ROAS is always inflated (both channels claim the same\n// order). MER is the only number that can't be double-counted, so we report\n// blended MER as truth and per-channel ROAS as directional.\nconst rows = $input.all().map((i) => i.json).filter((r) => r && r.channel);\nconst range = $('Set Shared Date Range').first().json;\n\n// Shopify gives us the denominator-free truth: real paid revenue in the window.\nlet storeRevenue = 0;\nlet storeOrders = 0;\ntry {\n  const orderItems = $('Fetch Shopify Total Revenue').all();\n  for (const it of orderItems) {\n    const list = Array.isArray(it.json.orders) ? it.json.orders : (it.json.total_price ? [it.json] : []);\n    for (const o of list) {\n      storeRevenue += parseFloat(o.current_total_price || o.total_price || 0) || 0;\n      storeOrders += 1;\n    }\n  }\n} catch (e) {\n  storeRevenue = 0;\n}\n\nconst r2 = (n) => Math.round((Number(n) || 0) * 100) / 100;\n\n// ---- 1. roll campaigns up to channel level -------------------------------\nconst channels = {};\nfor (const r of rows) {\n  const key = r.channel;\n  if (!channels[key]) {\n    channels[key] = {\n      channel: key,\n      campaigns: 0,\n      spend: 0,\n      impressions: 0,\n      clicks: 0,\n      conversions: 0,\n      attributed_revenue: 0,\n      freq_weight: 0,\n      freq_sum: 0\n    };\n  }\n  const c = channels[key];\n  c.campaigns += 1;\n  c.spend += Number(r.spend) || 0;\n  c.impressions += Number(r.impressions) || 0;\n  c.clicks += Number(r.clicks) || 0;\n  c.conversions += Number(r.conversions) || 0;\n  c.attributed_revenue += Number(r.revenue) || 0;\n  if (r.frequency) {\n    // frequency only averages honestly when weighted by impressions\n    c.freq_sum += Number(r.frequency) * (Number(r.impressions) || 0);\n    c.freq_weight += Number(r.impressions) || 0;\n  }\n}\n\nconst totalSpend = Object.values(channels).reduce((s, c) => s + c.spend, 0);\nconst totalAttributed = Object.values(channels).reduce((s, c) => s + c.attributed_revenue, 0);\n\n// If Shopify was unreachable, fall back to attributed revenue but say so, so\n// nobody reads an inflated MER as if it were the real one.\nconst revenueSource = storeRevenue > 0 ? 'shopify' : 'platform_attributed';\nconst trueRevenue = storeRevenue > 0 ? storeRevenue : totalAttributed;\n\n// Over-attribution factor: how much more revenue the platforms claim than the\n// store actually took. >1.3 means the two channels are fighting over credit.\nconst overAttribution = trueRevenue > 0 ? totalAttributed / trueRevenue : 0;\n\n// ---- 2. per-channel derived metrics --------------------------------------\nconst channelRows = Object.values(channels).map((c) => {\n  const spendShare = totalSpend ? (c.spend / totalSpend) * 100 : 0;\n  const revShare = totalAttributed ? (c.attributed_revenue / totalAttributed) * 100 : 0;\n  // Channel MER: this channel's fair slice of REAL store revenue (allocated by\n  // its share of attributed revenue) divided by its own spend.\n  const fairRevenue = trueRevenue * (revShare / 100);\n  return {\n    channel: c.channel,\n    campaigns: c.campaigns,\n    spend: r2(c.spend),\n    impressions: c.impressions,\n    clicks: c.clicks,\n    conversions: r2(c.conversions),\n    attributed_revenue: r2(c.attributed_revenue),\n    platform_roas: r2(c.spend ? c.attributed_revenue / c.spend : 0),\n    channel_mer: r2(c.spend ? fairRevenue / c.spend : 0),\n    spend_share_pct: r2(spendShare),\n    revenue_share_pct: r2(revShare),\n    // >0 means the channel earns more of the revenue than it takes of the budget\n    efficiency_gap_pct: r2(revShare - spendShare),\n    cpa: c.conversions ? r2(c.spend / c.conversions) : null,\n    cpc: c.clicks ? r2(c.spend / c.clicks) : null,\n    cpm: c.impressions ? r2((c.spend / c.impressions) * 1000) : null,\n    ctr: c.impressions ? r2((c.clicks / c.impressions) * 100) : null,\n    frequency: c.freq_weight ? r2(c.freq_sum / c.freq_weight) : null\n  };\n}).sort((a, b) => b.spend - a.spend);\n\n// ---- 3. blended totals ----------------------------------------------------\nconst blendedMer = totalSpend ? trueRevenue / totalSpend : 0;\nconst totalConversions = channelRows.reduce((s, c) => s + c.conversions, 0);\n\n// ---- 4. the one call to action -------------------------------------------\n// Whichever channel is furthest ahead on efficiency_gap_pct is under-funded.\nconst best = channelRows.slice().sort((a, b) => b.efficiency_gap_pct - a.efficiency_gap_pct)[0];\nconst worst = channelRows.slice().sort((a, b) => a.efficiency_gap_pct - b.efficiency_gap_pct)[0];\nlet recommendation = 'Spend split looks balanced against revenue share — hold.';\nlet shiftAmount = 0;\nif (best && worst && best.channel !== worst.channel && best.efficiency_gap_pct > 5) {\n  // move 10% of the laggard's daily budget, capped so we never swing violently\n  shiftAmount = r2(Math.min(worst.spend * 0.10, totalSpend * 0.05) / (range.lookback_days || 7));\n  recommendation = 'Shift ~$' + shiftAmount.toFixed(0) + '/day from ' + worst.channel +\n    ' to ' + best.channel + ' (' + best.channel + ' earns ' + best.revenue_share_pct +\n    '% of revenue on ' + best.spend_share_pct + '% of spend).';\n}\n\n// ---- 5. top campaigns across BOTH platforms, ranked on one scale ---------\nconst topCampaigns = rows\n  .filter((r) => (Number(r.spend) || 0) > 0)\n  .map((r) => ({\n    channel: r.channel,\n    campaign_name: r.campaign_name,\n    objective: r.objective,\n    spend: r2(r.spend),\n    revenue: r2(r.revenue),\n    roas: r2(r.roas),\n    conversions: r2(r.conversions)\n  }))\n  .sort((a, b) => b.spend - a.spend)\n  .slice(0, 10);\n\nreturn [{\n  json: {\n    date_range: range.label,\n    since: range.since,\n    until: range.until,\n    lookback_days: range.lookback_days,\n    revenue_source: revenueSource,\n    store_revenue: r2(trueRevenue),\n    store_orders: storeOrders,\n    total_spend: r2(totalSpend),\n    total_attributed_revenue: r2(totalAttributed),\n    total_conversions: r2(totalConversions),\n    blended_mer: r2(blendedMer),\n    blended_cac: totalConversions ? r2(totalSpend / totalConversions) : null,\n    over_attribution_factor: r2(overAttribution),\n    channels: channelRows,\n    top_campaigns: topCampaigns,\n    recommendation: recommendation,\n    suggested_daily_shift: shiftAmount,\n    generated_at: new Date().toISOString()\n  }\n}];"}},{"id":"bb00000a-1111-4222-8333-44445555660a","name":"Is Blended MER Healthy?","type":"n8n-nodes-base.if","typeVersion":2,"position":[740,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"m1","leftValue":"={{ $json.blended_mer }}","rightValue":2.5,"operator":{"type":"number","operation":"gte"}}]},"options":{}}},{"id":"bb00000b-1111-4222-8333-44445555660b","name":"Mark Report Healthy","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[980,140],"parameters":{"assignments":{"assignments":[{"id":"h1","name":"status","value":"healthy","type":"string"},{"id":"h2","name":"status_emoji","value":"🟢","type":"string"},{"id":"h3","name":"status_line","value":"=Blended MER {{ $json.blended_mer }} is at or above the 2.5 target on ${{ $json.total_spend }} of spend.","type":"string"},{"id":"h4","name":"needs_escalation","value":"={{ false }}","type":"boolean"}]},"options":{}}},{"id":"bb00000c-1111-4222-8333-44445555660c","name":"Mark Report At Risk","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[980,460],"parameters":{"assignments":{"assignments":[{"id":"r1","name":"status","value":"at_risk","type":"string"},{"id":"r2","name":"status_emoji","value":"🔴","type":"string"},{"id":"r3","name":"status_line","value":"=Blended MER {{ $json.blended_mer }} is BELOW the 2.5 target — ${{ $json.total_spend }} spent returned ${{ $json.store_revenue }}.","type":"string"},{"id":"r4","name":"needs_escalation","value":"={{ true }}","type":"boolean"}]},"options":{}}},{"id":"bb00000d-1111-4222-8333-44445555660d","name":"Build Unified HTML Report","type":"n8n-nodes-base.code","typeVersion":2,"position":[1220,300],"parameters":{"jsCode":"// One email, both platforms, one set of numbers everybody argues from.\nconst flag = $input.first().json;\nconst d = $('Blend Into One View').first().json;\n\nconst money = (n) => '$' + Number(n || 0).toLocaleString('en-US', { maximumFractionDigits: 0 });\nconst pct = (n) => (Number(n) || 0).toFixed(1) + '%';\nconst NAMES = { meta: 'Meta', google: 'Google' };\n\nconst chip = (v, good) => '<span style=\"color:' + (v >= good ? '#0a7d34' : '#b3261e') + ';font-weight:700\">' + v.toFixed(2) + '</span>';\n\n// ---- channel block: MER + share of spend side by side --------------------\nconst channelBlocks = d.channels.map((c) => {\n  const gap = c.efficiency_gap_pct;\n  const verdict = gap > 5 ? '↑ under-funded — earns more than it costs'\n    : gap < -5 ? '↓ over-funded — takes more budget than it returns'\n    : '→ in balance';\n  const extra = c.channel === 'meta'\n    ? 'frequency ' + (c.frequency === null ? 'n/a' : c.frequency.toFixed(2))\n    : 'CPC ' + money(c.cpc);\n  return [\n    '<div style=\"border:1px solid #e3e3e3;border-radius:6px;padding:14px;margin:0 0 10px\">',\n    '<div style=\"font-size:15px;font-weight:700;margin-bottom:6px\">' + (NAMES[c.channel] || c.channel) + ' · ' + c.campaigns + ' campaigns</div>',\n    '<div style=\"font-size:13px;line-height:1.7;color:#333\">',\n    'Spend <b>' + money(c.spend) + '</b> — <b>' + pct(c.spend_share_pct) + '</b> of total budget<br>',\n    'Attributed revenue <b>' + money(c.attributed_revenue) + '</b> — <b>' + pct(c.revenue_share_pct) + '</b> of total<br>',\n    'Channel MER <b>' + chip(c.channel_mer, 2.5) + '</b> (platform ROAS ' + c.platform_roas.toFixed(2) + ')<br>',\n    'CPA ' + (c.cpa === null ? 'n/a' : money(c.cpa)) + ' · CTR ' + (c.ctr === null ? 'n/a' : pct(c.ctr)) + ' · ' + extra + '<br>',\n    '<span style=\"color:#666\">' + verdict + ' (' + (gap >= 0 ? '+' : '') + gap.toFixed(1) + ' pts)</span>',\n    '</div></div>'\n  ].join('');\n}).join('');\n\n// ---- top campaigns, both platforms on one leaderboard --------------------\nconst campaignRows = d.top_campaigns.map((c) =>\n  '<tr>' +\n  '<td style=\"padding:6px 8px;border-bottom:1px solid #eee\">' + (NAMES[c.channel] || c.channel) + '</td>' +\n  '<td style=\"padding:6px 8px;border-bottom:1px solid #eee\">' + c.campaign_name + '</td>' +\n  '<td style=\"padding:6px 8px;border-bottom:1px solid #eee;text-align:right\">' + money(c.spend) + '</td>' +\n  '<td style=\"padding:6px 8px;border-bottom:1px solid #eee;text-align:right\">' + money(c.revenue) + '</td>' +\n  '<td style=\"padding:6px 8px;border-bottom:1px solid #eee;text-align:right\">' + c.roas.toFixed(2) + '</td>' +\n  '</tr>'\n).join('');\n\nconst attributionNote = d.over_attribution_factor > 1.3\n  ? '<p style=\"font-size:12px;color:#b3261e;margin:6px 0 0\">⚠️ The platforms together claim ' +\n    d.over_attribution_factor.toFixed(2) + 'x the revenue the store actually took. Per-channel ROAS is double-counting — trust blended MER.</p>'\n  : '';\n\nconst sourceNote = d.revenue_source === 'shopify'\n  ? 'Revenue from Shopify paid orders (' + d.store_orders + ' orders).'\n  : '⚠️ Shopify unavailable — MER computed on platform-attributed revenue and is optimistic.';\n\nconst html = [\n  '<div style=\"font-family:-apple-system,Segoe UI,Helvetica,Arial,sans-serif;max-width:680px;color:#111\">',\n  '<h2 style=\"margin:0 0 4px\">' + flag.status_emoji + ' Blended paid report — ' + d.date_range + '</h2>',\n  '<p style=\"margin:0 0 14px;color:#555;font-size:13px\">' + flag.status_line + '</p>',\n  '<div style=\"background:#f6f7f9;border-radius:6px;padding:14px;margin:0 0 16px;font-size:14px;line-height:1.8\">',\n  'Total spend <b>' + money(d.total_spend) + '</b><br>',\n  'Store revenue <b>' + money(d.store_revenue) + '</b><br>',\n  'Blended MER <b>' + chip(d.blended_mer, 2.5) + '</b> · Blended CAC <b>' + (d.blended_cac === null ? 'n/a' : money(d.blended_cac)) + '</b><br>',\n  'Conversions <b>' + Math.round(d.total_conversions) + '</b>',\n  '</div>',\n  '<p style=\"font-size:12px;color:#666;margin:0 0 14px\">' + sourceNote + '</p>',\n  attributionNote,\n  '<h3 style=\"margin:18px 0 8px\">Channel split</h3>',\n  channelBlocks,\n  '<h3 style=\"margin:18px 0 8px\">Top 10 campaigns (both platforms)</h3>',\n  '<table style=\"width:100%;border-collapse:collapse;font-size:13px\">',\n  '<tr style=\"text-align:left;color:#666\"><th style=\"padding:6px 8px\">Channel</th><th style=\"padding:6px 8px\">Campaign</th>',\n  '<th style=\"padding:6px 8px;text-align:right\">Spend</th><th style=\"padding:6px 8px;text-align:right\">Revenue</th>',\n  '<th style=\"padding:6px 8px;text-align:right\">ROAS</th></tr>',\n  campaignRows,\n  '</table>',\n  '<div style=\"background:#fff8e1;border-left:3px solid #f5a623;padding:12px;margin:18px 0 0;font-size:13px\">',\n  '<b>Recommendation:</b> ' + d.recommendation,\n  '</div>',\n  '</div>'\n].join('');\n\nreturn [{\n  json: {\n    status: flag.status,\n    needs_escalation: flag.needs_escalation === true || flag.needs_escalation === 'true',\n    subject: flag.status_emoji + ' Blended paid report ' + d.date_range + ' — MER ' + d.blended_mer.toFixed(2),\n    html: html,\n    date_range: d.date_range,\n    total_spend: d.total_spend,\n    store_revenue: d.store_revenue,\n    blended_mer: d.blended_mer,\n    blended_cac: d.blended_cac,\n    over_attribution_factor: d.over_attribution_factor,\n    meta_spend_share: (d.channels.find((c) => c.channel === 'meta') || {}).spend_share_pct || 0,\n    google_spend_share: (d.channels.find((c) => c.channel === 'google') || {}).spend_share_pct || 0,\n    meta_mer: (d.channels.find((c) => c.channel === 'meta') || {}).channel_mer || 0,\n    google_mer: (d.channels.find((c) => c.channel === 'google') || {}).channel_mer || 0,\n    recommendation: d.recommendation\n  }\n}];"}},{"id":"bb00000e-1111-4222-8333-44445555660e","name":"Email Unified Report","type":"n8n-nodes-base.gmail","typeVersion":2.1,"position":[1460,300],"parameters":{"sendTo":"growth@brand.com","subject":"={{ $json.subject }}","message":"={{ $json.html }}","options":{}}},{"id":"bb00000f-1111-4222-8333-44445555660f","name":"Needs Escalation?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1700,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"e1","leftValue":"={{ $json.needs_escalation }}","rightValue":"","operator":{"type":"boolean","operation":"true","singleValue":true}}]},"options":{}}},{"id":"bb000010-1111-4222-8333-444455556610","name":"Ping Buyers In Slack","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[1940,140],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#paid-media","mode":"name"},"text":"=🔴 *Blended MER below target* — {{ $json.date_range }}\nSpend {{ $json.total_spend }} · Store revenue {{ $json.store_revenue }} · MER *{{ $json.blended_mer }}* · CAC {{ $json.blended_cac }}\nSpend split: Meta {{ $json.meta_spend_share }}% (MER {{ $json.meta_mer }}) vs Google {{ $json.google_spend_share }}% (MER {{ $json.google_mer }})\n{{ $json.recommendation }}\nFull report is in your inbox.","otherOptions":{}}},{"id":"bb000011-1111-4222-8333-444455556611","name":"Log Blended Day To Sheet","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1940,460],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Blended Daily"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"bb000012-1111-4222-8333-444455556612","name":"Note Fetch","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-740,-60],"parameters":{"content":"## 1. SAME WINDOW, BOTH PLATFORMS\nOne Code node computes the date range once and hands Meta a `time_range` JSON and Google a GAQL `segments.date BETWEEN` clause. Meta returns spend / purchase_roas / frequency; Google returns cost_micros / conversions_value. Each is flattened into an identical row shape.","height":300,"width":460,"color":4}},{"id":"bb000013-1111-4222-8333-444455556613","name":"Note Blend","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[260,-60],"parameters":{"content":"## 2. BLEND + MER\nAppend both channels, pull real paid Shopify revenue for the same days, then compute blended MER (store revenue / total spend), each channel's share of spend vs share of attributed revenue, and the over-attribution factor. Positive efficiency gap = under-funded channel.","height":300,"width":460,"color":3}},{"id":"bb000014-1111-4222-8333-444455556614","name":"Note Report","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1220,640],"parameters":{"content":"## 3. ONE REPORT, ONE ACTION\nMER >= 2.5 is healthy, below is at-risk. Either way a single HTML email goes out with both channels side by side and a budget-shift recommendation. At-risk also pings #paid-media; at-risk also pings #paid-media. Every day — healthy or not — lands in the Blended Daily sheet, so the MER trend line has no gaps on the bad days.","height":280,"width":460,"color":5}}],"connections":{"Every Morning At 7":{"main":[[{"node":"Set Shared Date Range","type":"main","index":0}]]},"Set Shared Date Range":{"main":[[{"node":"Fetch Meta Campaign Insights","type":"main","index":0},{"node":"Fetch Google Campaign Stats","type":"main","index":0},{"node":"Fetch Shopify Total Revenue","type":"main","index":0}]]},"Fetch Meta Campaign Insights":{"main":[[{"node":"Normalise Meta Rows","type":"main","index":0}]]},"Fetch Google Campaign Stats":{"main":[[{"node":"Normalise Google Rows","type":"main","index":0}]]},"Normalise Meta Rows":{"main":[[{"node":"Merge Both Channels","type":"main","index":0}]]},"Normalise Google Rows":{"main":[[{"node":"Merge Both Channels","type":"main","index":1}]]},"Merge Both Channels":{"main":[[{"node":"Blend Into One View","type":"main","index":0}]]},"Blend Into One View":{"main":[[{"node":"Is Blended MER Healthy?","type":"main","index":0}]]},"Is Blended MER Healthy?":{"main":[[{"node":"Mark Report Healthy","type":"main","index":0}],[{"node":"Mark Report At Risk","type":"main","index":0}]]},"Mark Report Healthy":{"main":[[{"node":"Build Unified HTML Report","type":"main","index":0}]]},"Mark Report At Risk":{"main":[[{"node":"Build Unified HTML Report","type":"main","index":0}]]},"Build Unified HTML Report":{"main":[[{"node":"Email Unified Report","type":"main","index":0}]]},"Email Unified Report":{"main":[[{"node":"Needs Escalation?","type":"main","index":0}]]},"Ping Buyers In Slack":{"main":[[{"node":"Log Blended Day To Sheet","type":"main","index":0}]]},"Needs Escalation?":{"main":[[{"node":"Ping Buyers In Slack","type":"main","index":0}],[{"node":"Log Blended Day To Sheet","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}