{"name":"Ad account scale workflow","nodes":[{"id":"9a1b0001-1111-4222-8333-444455550001","name":"Daily 09:00 Scale Check","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-240,300],"parameters":{"rule":{"interval":[{"field":"hours","hoursInterval":24}]}}},{"id":"9a1b0002-1111-4222-8333-444455550002","name":"Fetch Account Daily Insights","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[20,180],"parameters":{"url":"https://graph.facebook.com/v21.0/act_ID/insights","sendQuery":true,"queryParameters":{"parameters":[{"name":"fields","value":"account_id,spend,purchase_roas,impressions,actions"},{"name":"date_preset","value":"last_30d"},{"name":"time_increment","value":"1"},{"name":"level","value":"account"}]},"options":{}}},{"id":"9a1b0003-1111-4222-8333-444455550003","name":"Fetch Ad Set Insights","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[20,420],"parameters":{"url":"https://graph.facebook.com/v21.0/act_ID/insights","sendQuery":true,"queryParameters":{"parameters":[{"name":"fields","value":"adset_id,adset_name,campaign_id,campaign_name,spend,impressions,frequency,ctr,cpm,purchase_roas,actions"},{"name":"date_preset","value":"last_7d"},{"name":"level","value":"adset"},{"name":"limit","value":"200"}]},"options":{}}},{"id":"9a1b0004-1111-4222-8333-444455550004","name":"Merge Account + Ad Sets","type":"n8n-nodes-base.merge","typeVersion":3,"position":[280,300],"parameters":{"mode":"combine","combineBy":"combineByPosition","options":{}}},{"id":"9a1b0005-1111-4222-8333-444455550005","name":"Score Account Health","type":"n8n-nodes-base.code","typeVersion":2,"position":[540,300],"parameters":{"jsCode":"// ---- ACCOUNT HEALTH SCORE -------------------------------------------------\n// Input 0 = account-level insights (last_30d, time_increment=1)\n// Input 1 = ad set level insights (last_7d)\n// We fold both into ONE health object that gates the whole scale ladder.\n\n// The merge is combineByPosition, so both responses land on ONE item and their\n// shared `data` key collides — read each source node directly instead.\nconst acct   = $('Fetch Account Daily Insights').first().json || {};\nconst adsetsRes = $('Fetch Ad Set Insights').first().json || {};\nconst days  = (acct.data || []).map(d => ({\n  date:  d.date_start,\n  spend: Number(d.spend || 0),\n  rev:   Number(d.spend || 0) * Number((d.purchase_roas && d.purchase_roas[0] && d.purchase_roas[0].value) || 0),\n  purch: Number((d.actions || []).find(a => a.action_type === 'purchase')?.value || 0),\n}));\n\nconst adsets = (adsetsRes.data || []).map(a => ({\n  adset_id:        a.adset_id,\n  adset_name:      a.adset_name,\n  campaign_id:     a.campaign_id,\n  campaign_name:   a.campaign_name,\n  spend:           Number(a.spend || 0),\n  impressions:     Number(a.impressions || 0),\n  frequency:       Number(a.frequency || 0),\n  ctr:             Number(a.ctr || 0),\n  cpm:             Number(a.cpm || 0),\n  daily_budget:    Number(a.daily_budget || 0) / 100,      // Meta returns minor units\n  learning_stage:  a.learning_stage_info?.status || 'SUCCESS',\n  purchases:       Number((a.actions || []).find(x => x.action_type === 'purchase')?.value || 0),\n  purchase_roas:   Number((a.purchase_roas && a.purchase_roas[0] && a.purchase_roas[0].value) || 0),\n}));\n\n// --- 1. spend pacing: are we tracking to the monthly cap? ------------------\nconst MONTHLY_CAP = 90000;                       // account spend ceiling, USD\nconst last7  = days.slice(-7);\nconst prev7  = days.slice(-14, -7);\nconst spend7 = last7.reduce((s, d) => s + d.spend, 0);\nconst dailyRun = spend7 / Math.max(last7.length, 1);\nconst projected30 = dailyRun * 30;\nconst pacing = projected30 / MONTHLY_CAP;        // 1.0 = exactly on cap\n\n// --- 2. ROAS trend: this week vs last week --------------------------------\nconst roasOf = arr => {\n  const s = arr.reduce((a, d) => a + d.spend, 0);\n  const r = arr.reduce((a, d) => a + d.rev, 0);\n  return s > 0 ? r / s : 0;\n};\nconst roas7 = roasOf(last7);\nconst roasPrev7 = roasOf(prev7);\nconst roasTrend = roasPrev7 > 0 ? (roas7 - roasPrev7) / roasPrev7 : 0;   // -0.18 = down 18%\n\n// --- 3. learning phase share: how much budget is still unstable? ----------\nconst totalAdsetSpend = adsets.reduce((s, a) => s + a.spend, 0) || 1;\nconst learningSpend = adsets\n  .filter(a => a.learning_stage === 'LEARNING' || a.learning_stage === 'LEARNING_LIMITED')\n  .reduce((s, a) => s + a.spend, 0);\nconst learningShare = learningSpend / totalAdsetSpend;\n\n// --- 4. blended health score (0-100) --------------------------------------\n// Each pillar scores 0-100, then a weighted blend. Scale only above 70.\nconst TARGET_ROAS = 2.2;\nconst pRoas     = Math.max(0, Math.min(100, (roas7 / TARGET_ROAS) * 100));\nconst pTrend    = Math.max(0, Math.min(100, 50 + roasTrend * 250));   // flat = 50, +20% = 100\nconst pLearning = Math.max(0, Math.min(100, (1 - learningShare / 0.35) * 100));\nconst pPacing   = pacing > 1.05 ? Math.max(0, 100 - (pacing - 1.05) * 400)\n                : pacing < 0.6  ? 60 + pacing * 40\n                : 100;\n\nconst healthScore = Math.round(\n  pRoas * 0.40 + pTrend * 0.25 + pLearning * 0.20 + pPacing * 0.15\n);\n\n// --- 5. how much new budget the ladder is allowed to add today ------------\n// Ladder rungs: never add more than 20% of current daily run rate per day,\n// and never push projected spend past 100% of the monthly cap.\nconst headroomDaily = Math.max(0, (MONTHLY_CAP / 30) - dailyRun);\nconst ladderCap = Math.min(dailyRun * 0.20, headroomDaily);\n\nreturn [{ json: {\n  account_id: (acct.data && acct.data[0] && acct.data[0].account_id) || 'act_ID',\n  health_score: healthScore,\n  pillars: { roas: Math.round(pRoas), trend: Math.round(pTrend), learning: Math.round(pLearning), pacing: Math.round(pPacing) },\n  spend_7d: Number(spend7.toFixed(2)),\n  daily_run_rate: Number(dailyRun.toFixed(2)),\n  projected_30d: Number(projected30.toFixed(2)),\n  pacing_ratio: Number(pacing.toFixed(3)),\n  roas_7d: Number(roas7.toFixed(2)),\n  roas_prev_7d: Number(roasPrev7.toFixed(2)),\n  roas_trend_pct: Number((roasTrend * 100).toFixed(1)),\n  learning_share: Number(learningShare.toFixed(3)),\n  ladder_budget_cap: Number(ladderCap.toFixed(2)),\n  adsets,\n} }];"}},{"id":"9a1b0006-1111-4222-8333-444455550006","name":"Account Healthy Enough To Scale?","type":"n8n-nodes-base.if","typeVersion":2,"position":[800,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c-health","leftValue":"={{ $json.health_score }}","rightValue":70,"operator":{"type":"number","operation":"gte"}}]},"options":{}}},{"id":"9a1b0007-1111-4222-8333-444455550007","name":"Rank Scale Candidates","type":"n8n-nodes-base.code","typeVersion":2,"position":[1060,140],"parameters":{"jsCode":"// ---- RANK SCALE CANDIDATES ------------------------------------------------\n// Account is healthy. Pick which ad sets earn a duplicate in the scale CBO.\nconst h = $input.first().json;\nconst SCALE_CAMPAIGN_ID = '120210000000000000';   // evergreen scale CBO\nconst TARGET_ROAS = 2.2;\n\n// De-dupe: an ad set already cloned in the last 14 days must not be cloned again.\n// The scale campaign clones carry a \"[SCALE]\" prefix + the source id in the name.\nconst alreadyCloned = new Set(\n  h.adsets\n    .filter(a => a.campaign_id === SCALE_CAMPAIGN_ID)\n    .map(a => (a.adset_name.match(/src:(\\d+)/) || [])[1])\n    .filter(Boolean)\n);\n\nconst candidates = h.adsets\n  .filter(a => a.campaign_id !== SCALE_CAMPAIGN_ID)\n  .filter(a => !alreadyCloned.has(a.adset_id))\n  .filter(a => a.learning_stage === 'SUCCESS')          // out of learning\n  .filter(a => a.purchases >= 15)                       // statistically real\n  .filter(a => a.spend >= 300)                          // enough spend behind it\n  .filter(a => a.purchase_roas >= TARGET_ROAS * 1.15)   // clearly above target\n  .filter(a => a.frequency <= 2.4)                      // audience not burnt\n  .map(a => {\n    // score = efficiency x volume x freshness headroom\n    const roasIdx = Math.min(a.purchase_roas / TARGET_ROAS, 2.5);            // 1.0 - 2.5\n    const volIdx  = Math.min(Math.log10(Math.max(a.purchases, 1)) / 2, 1.5); // diminishing\n    const freqIdx = Math.max(0.4, 1.6 - a.frequency / 3);                    // fresher = better\n    const ctrIdx  = Math.min(a.ctr / 1.2, 1.4);                              // 1.2% CTR = par\n    const score = Number((roasIdx * 40 + volIdx * 25 + freqIdx * 20 + ctrIdx * 15).toFixed(1));\n    return { ...a, score };\n  })\n  .sort((x, y) => y.score - x.score)\n  .slice(0, 5);\n\n// Split the ladder budget across winners, weighted by score, min $30/day each.\nconst scoreSum = candidates.reduce((s, c) => s + c.score, 0) || 1;\nconst rows = candidates.map((c, i) => {\n  const share = c.score / scoreSum;\n  const budget = Math.max(30, Math.round((h.ladder_budget_cap * share) / 5) * 5);\n  return { json: {\n    account_id: h.account_id,\n    health_score: h.health_score,\n    ladder_budget_cap: h.ladder_budget_cap,\n    rank: i + 1,\n    source_adset_id: c.adset_id,\n    source_adset_name: c.adset_name,\n    source_campaign_name: c.campaign_name,\n    scale_campaign_id: SCALE_CAMPAIGN_ID,\n    new_adset_name: `[SCALE] ${c.adset_name} src:${c.adset_id}`,\n    new_daily_budget: budget,\n    new_daily_budget_minor: budget * 100,\n    score: c.score,\n    purchase_roas: c.purchase_roas,\n    purchases: c.purchases,\n    spend: c.spend,\n    frequency: c.frequency,\n    ctr: c.ctr,\n  } };\n});\n\nreturn rows.length ? rows : [{ json: {\n  account_id: h.account_id,\n  health_score: h.health_score,\n  ladder_budget_cap: h.ladder_budget_cap,\n  rank: 0,\n  source_adset_id: '',\n  new_daily_budget: 0,\n  score: 0,\n  note: 'account healthy but no ad set cleared the duplication bar today',\n} }];"}},{"id":"9a1b0008-1111-4222-8333-444455550008","name":"Keep Only Fundable Candidates","type":"n8n-nodes-base.filter","typeVersion":2,"position":[1320,140],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"f1","leftValue":"={{ $json.new_daily_budget }}","rightValue":30,"operator":{"type":"number","operation":"gte"}},{"id":"f2","leftValue":"={{ $json.source_adset_id }}","rightValue":"","operator":{"type":"string","operation":"notEmpty","singleValue":true}}]},"options":{}}},{"id":"9a1b0009-1111-4222-8333-444455550009","name":"Loop Scale Candidates","type":"n8n-nodes-base.splitInBatches","typeVersion":3,"position":[1580,140],"parameters":{"batchSize":1,"options":{}}},{"id":"9a1b0010-1111-4222-8333-444455550010","name":"Duplicate Ad Set Into Scale CBO","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[1840,-20],"parameters":{"method":"POST","url":"=https://graph.facebook.com/v21.0/{{ $json.source_adset_id }}/copies","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ campaign_id: $json.scale_campaign_id, deep_copy: true, status_option: \"PAUSED\", rename_options: { rename_strategy: \"NO_RENAME\" }, name: $json.new_adset_name, daily_budget: $json.new_daily_budget_minor }) }}","options":{}}},{"id":"9a1b0011-1111-4222-8333-444455550011","name":"Record Duplication Result","type":"n8n-nodes-base.code","typeVersion":2,"position":[2100,-20],"parameters":{"jsCode":"// ---- RECORD DUPLICATION RESULT -------------------------------------------\nconst res = $input.first().json;\n// Must be the CURRENT batch item, not the top-ranked candidate — otherwise every\n// iteration would log rank 1's metadata against a different new_adset_id.\nconst src = $('Loop Scale Candidates').first().json;\nreturn [{ json: {\n  ...src,\n  new_adset_id: res.copied_adset_id || res.id || null,\n  duplicated: Boolean(res.copied_adset_id || res.id),\n  duplicated_at: new Date().toISOString(),\n} }];"}},{"id":"9a1b0012-1111-4222-8333-444455550012","name":"Build Ladder Step","type":"n8n-nodes-base.code","typeVersion":2,"position":[1840,140],"parameters":{"jsCode":"// ---- BUILD LADDER STEP ----------------------------------------------------\n// One row summarising today's rung on the scale ladder.\nconst rows = $input.all().map(i => i.json).filter(r => r.source_adset_id);\nconst h = $('Score Account Health').first().json;\n\nconst added = rows.filter(r => r.duplicated);\nconst addedBudget = added.reduce((s, r) => s + Number(r.new_daily_budget || 0), 0);\nconst newDailyRun = h.daily_run_rate + addedBudget;\nconst newProjected30 = newDailyRun * 30;\n\n// Ladder step naming: how many consecutive scale days we are into the ramp.\nconst rungPct = h.daily_run_rate > 0 ? addedBudget / h.daily_run_rate : 0;\nconst rung = rungPct === 0 ? 'HOLD'\n           : rungPct < 0.08 ? 'RUNG_1_NUDGE'\n           : rungPct < 0.15 ? 'RUNG_2_STEP'\n           : 'RUNG_3_PUSH';\n\nreturn [{ json: {\n  logged_at: new Date().toISOString(),\n  account_id: h.account_id,\n  decision: added.length ? 'SCALE' : 'NO_CANDIDATES',\n  ladder_step: rung,\n  health_score: h.health_score,\n  roas_7d: h.roas_7d,\n  roas_trend_pct: h.roas_trend_pct,\n  learning_share: h.learning_share,\n  pacing_ratio: h.pacing_ratio,\n  daily_run_rate: h.daily_run_rate,\n  budget_added: Number(addedBudget.toFixed(2)),\n  new_daily_run_rate: Number(newDailyRun.toFixed(2)),\n  projected_30d_after: Number(newProjected30.toFixed(2)),\n  adsets_duplicated: added.length,\n  duplicated_names: added.map(r => r.new_adset_name).join(' | '),\n} }];"}},{"id":"9a1b0013-1111-4222-8333-444455550013","name":"Log Ladder Step","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[2100,140],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Scale Ladder"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"9a1b0014-1111-4222-8333-444455550014","name":"Post Scale Summary","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[2360,140],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#ads-scaling","mode":"name"},"text":"=📈 *{{ $json.ladder_step }}* on {{ $json.account_id }} — health {{ $json.health_score }}/100, ROAS {{ $json.roas_7d }} ({{ $json.roas_trend_pct }}% wow), learning share {{ $json.learning_share }}.\nDuplicated {{ $json.adsets_duplicated }} ad set(s), +${{ $json.budget_added }}/day → new run rate ${{ $json.new_daily_run_rate }}/day (30d projection ${{ $json.projected_30d_after }}).\n{{ $json.duplicated_names }}","otherOptions":{}}},{"id":"9a1b0015-1111-4222-8333-444455550015","name":"Hold Ladder Step","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[1060,480],"parameters":{"assignments":{"assignments":[{"id":"h1","name":"logged_at","value":"={{ $now.toISO() }}","type":"string"},{"id":"h2","name":"account_id","value":"={{ $json.account_id }}","type":"string"},{"id":"h3","name":"decision","value":"HOLD","type":"string"},{"id":"h4","name":"ladder_step","value":"HOLD_UNHEALTHY","type":"string"},{"id":"h5","name":"health_score","value":"={{ $json.health_score }}","type":"number"},{"id":"h6","name":"roas_7d","value":"={{ $json.roas_7d }}","type":"number"},{"id":"h7","name":"roas_trend_pct","value":"={{ $json.roas_trend_pct }}","type":"number"},{"id":"h8","name":"learning_share","value":"={{ $json.learning_share }}","type":"number"},{"id":"h9","name":"pacing_ratio","value":"={{ $json.pacing_ratio }}","type":"number"},{"id":"h10","name":"daily_run_rate","value":"={{ $json.daily_run_rate }}","type":"number"},{"id":"h11","name":"budget_added","value":"0","type":"number"},{"id":"h12","name":"blocker","value":"={{ $json.pillars.roas < 70 ? \"roas below target\" : $json.pillars.trend < 50 ? \"roas trending down\" : $json.pillars.learning < 60 ? \"too much budget in learning\" : \"pacing over monthly cap\" }}","type":"string"}]},"options":{}}},{"id":"9a1b0016-1111-4222-8333-444455550016","name":"Log Hold Step","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1320,480],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Scale Ladder"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"9a1b0017-1111-4222-8333-444455550017","name":"Alert Account Health Blocker","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[1580,480],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#ads-alerts","mode":"name"},"text":"=⛔ No scaling on {{ $json.account_id }} today — health {{ $json.health_score }}/100 (need 70).\nBlocker: {{ $json.blocker }}. ROAS 7d {{ $json.roas_7d }} ({{ $json.roas_trend_pct }}% wow), learning share {{ $json.learning_share }}, pacing {{ $json.pacing_ratio }}x cap.","otherOptions":{}}},{"id":"9a1b00a1-1111-4222-8333-4444555500a1","name":"Note Health","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-280,-120],"parameters":{"content":"## 1. ACCOUNT HEALTH\nPull 30 days of account-level daily insights + 7 days of ad set insights, then blend four pillars into a 0-100 health score: ROAS vs target (40%), week-over-week ROAS trend (25%), share of spend still in learning (20%), and spend pacing vs the monthly cap (15%). Scaling is gated at 70.","height":320,"width":460,"color":4}},{"id":"9a1b00a2-1111-4222-8333-4444555500a2","name":"Note Scale","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1040,-320],"parameters":{"content":"## 2. SCALE LADDER\nWinners must be out of learning, >=15 purchases, >=$300 spend, ROAS >= 1.15x target and frequency <= 2.4. Already-cloned ad sets are de-duped via the \"src:<id>\" tag in the scale CBO. Ladder budget = min(20% of daily run rate, monthly-cap headroom), split by score.","height":320,"width":460,"color":5}},{"id":"9a1b00a3-1111-4222-8333-4444555500a3","name":"Note Log","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1040,700],"parameters":{"content":"## 3. LOG + HOLD\nEvery run writes one ladder row to Sheets (RUNG_1_NUDGE / RUNG_2_STEP / RUNG_3_PUSH / HOLD) with the pacing projection after the change. Unhealthy accounts log a HOLD row naming the blocking pillar and ping #ads-alerts instead of spending.","height":300,"width":460,"color":3}}],"connections":{"Daily 09:00 Scale Check":{"main":[[{"node":"Fetch Account Daily Insights","type":"main","index":0},{"node":"Fetch Ad Set Insights","type":"main","index":0}]]},"Fetch Account Daily Insights":{"main":[[{"node":"Merge Account + Ad Sets","type":"main","index":0}]]},"Fetch Ad Set Insights":{"main":[[{"node":"Merge Account + Ad Sets","type":"main","index":1}]]},"Merge Account + Ad Sets":{"main":[[{"node":"Score Account Health","type":"main","index":0}]]},"Score Account Health":{"main":[[{"node":"Account Healthy Enough To Scale?","type":"main","index":0}]]},"Account Healthy Enough To Scale?":{"main":[[{"node":"Rank Scale Candidates","type":"main","index":0}],[{"node":"Hold Ladder Step","type":"main","index":0}]]},"Rank Scale Candidates":{"main":[[{"node":"Keep Only Fundable Candidates","type":"main","index":0}]]},"Keep Only Fundable Candidates":{"main":[[{"node":"Loop Scale Candidates","type":"main","index":0}]]},"Loop Scale Candidates":{"main":[[{"node":"Build Ladder Step","type":"main","index":0}],[{"node":"Duplicate Ad Set Into Scale CBO","type":"main","index":0}]]},"Duplicate Ad Set Into Scale CBO":{"main":[[{"node":"Record Duplication Result","type":"main","index":0}]]},"Record Duplication Result":{"main":[[{"node":"Loop Scale Candidates","type":"main","index":0}]]},"Build Ladder Step":{"main":[[{"node":"Log Ladder Step","type":"main","index":0}]]},"Log Ladder Step":{"main":[[{"node":"Post Scale Summary","type":"main","index":0}]]},"Hold Ladder Step":{"main":[[{"node":"Log Hold Step","type":"main","index":0}]]},"Log Hold Step":{"main":[[{"node":"Alert Account Health Blocker","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}