{"name":"Google Ads scale workflow","nodes":[{"id":"bb000001-1111-4222-8333-444455556601","name":"Every Morning","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-460,300],"parameters":{"rule":{"interval":[{"field":"days","daysInterval":1}]}}},{"id":"bb000002-1111-4222-8333-444455556602","name":"Fetch Campaign Performance","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[-220,180],"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.status, campaign.advertising_channel_type, campaign.bidding_strategy_type, metrics.cost_micros, metrics.impressions, metrics.clicks, metrics.conversions, metrics.conversions_value, metrics.search_impression_share, metrics.search_budget_lost_impression_share, metrics.search_rank_lost_impression_share FROM campaign WHERE segments.date DURING LAST_14_DAYS AND campaign.status = 'ENABLED' AND metrics.cost_micros > 300000000\" }) }}","options":{}}},{"id":"bb000003-1111-4222-8333-444455556603","name":"Fetch Campaign Budgets","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[-220,420],"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_budget.resource_name, campaign_budget.amount_micros, campaign_budget.delivery_method, campaign_budget.explicitly_shared FROM campaign WHERE campaign.status = 'ENABLED'\" }) }}","options":{}}},{"id":"bb000004-1111-4222-8333-444455556604","name":"Collect Metrics And Budgets","type":"n8n-nodes-base.merge","typeVersion":3,"position":[20,300],"parameters":{"mode":"append","options":{}}},{"id":"bb000005-1111-4222-8333-444455556605","name":"Score Campaigns For Scaling","type":"n8n-nodes-base.code","typeVersion":2,"position":[260,300],"parameters":{"jsCode":"// ---- Scale rules config ---------------------------------------------------\n// Google Ads reports money in micros (1 unit = 1,000,000 micros) and\n// impression-share fields as a 0..1 fraction. \"0.9\" in a lost-IS field means\n// the API capped it (>90%), so treat it as \"definitely constrained\".\nconst CFG = {\n  lookbackDays: 14,\n  minCost: 300,            // don't touch a campaign that hasn't spent enough to judge\n  minConversions: 8,       // need real conversion volume, not 2 lucky sales\n  targetRoas: 3.0,         // our profitable floor after COGS + fees\n  strongRoas: 4.5,         // clearly leaving money on the table\n  budgetLostFloor: 0.10,   // 10%+ IS lost to budget = genuinely budget limited\n  maxIncreasePct: 0.30,    // never jump a budget more than 30% in one run\n  minIncreasePct: 0.10,\n  maxDailyBudget: 1500     // hard ceiling so a bug can't run away with the account\n};\n\nconst MICROS = 1000000;\nconst num = (v) => {\n  const n = typeof v === 'string' ? parseFloat(v) : v;\n  return Number.isFinite(n) ? n : 0;\n};\n\n// GAQL rows come back nested (campaign.id / metrics.cost_micros). n8n may hand\n// them over either already flattened or still nested, so read both shapes.\nconst get = (row, path) => {\n  const flat = row[path];\n  if (flat !== undefined && flat !== null) return flat;\n  let cur = row;\n  for (const part of path.split('.')) {\n    if (cur === null || cur === undefined) return undefined;\n    cur = cur[part];\n  }\n  return cur;\n};\n\nconst rows = $input.all().map((i) => i.json).filter(Boolean);\n\n// ---- 1. build the budget lookup from the campaign_budget pull -------------\n// The two GAQL pulls arrive appended into one flat stream, NOT paired: budget\n// rows and metrics rows are interleaved in arbitrary order. Never zip them by\n// position — key everything by campaign id and join explicitly.\nconst budgetByCampaign = new Map();\nfor (const r of rows) {\n  const campId = String(get(r, 'campaign.id') || get(r, 'campaign_id') || '');\n  const amount = num(get(r, 'campaign_budget.amount_micros'));\n  const resource = get(r, 'campaign_budget.resource_name') || get(r, 'campaign.campaign_budget');\n  if (!campId || !amount) continue;\n  budgetByCampaign.set(campId, {\n    daily_budget: amount / MICROS,\n    budget_resource: resource || null,\n    shared: Boolean(get(r, 'campaign_budget.explicitly_shared')),\n    delivery: String(get(r, 'campaign_budget.delivery_method') || 'STANDARD')\n  });\n}\n\n// ---- 2. collapse metrics rows per campaign (segments can split them) ------\nconst byCampaign = new Map();\nfor (const r of rows) {\n  const campId = String(get(r, 'campaign.id') || get(r, 'campaign_id') || '');\n  if (!campId) continue;\n  const cost = num(get(r, 'metrics.cost_micros'));\n  if (!cost && !num(get(r, 'metrics.impressions'))) continue; // budget-only row\n  const prev = byCampaign.get(campId);\n  const rec = {\n    campaign_id: campId,\n    campaign_name: String(get(r, 'campaign.name') || '(unnamed campaign)'),\n    status: String(get(r, 'campaign.status') || 'UNKNOWN').toUpperCase(),\n    channel: String(get(r, 'campaign.advertising_channel_type') || ''),\n    bidding: String(get(r, 'campaign.bidding_strategy_type') || ''),\n    cost_micros: cost,\n    impressions: num(get(r, 'metrics.impressions')),\n    clicks: num(get(r, 'metrics.clicks')),\n    conversions: num(get(r, 'metrics.conversions')),\n    conversions_value: num(get(r, 'metrics.conversions_value')),\n    // these are rates, not sums -> keep the max across split rows\n    budget_lost_is: num(get(r, 'metrics.search_budget_lost_impression_share')),\n    rank_lost_is: num(get(r, 'metrics.search_rank_lost_impression_share')),\n    search_is: num(get(r, 'metrics.search_impression_share'))\n  };\n  if (!prev) { byCampaign.set(campId, rec); continue; }\n  prev.cost_micros += rec.cost_micros;\n  prev.impressions += rec.impressions;\n  prev.clicks += rec.clicks;\n  prev.conversions += rec.conversions;\n  prev.conversions_value += rec.conversions_value;\n  prev.budget_lost_is = Math.max(prev.budget_lost_is, rec.budget_lost_is);\n  prev.rank_lost_is = Math.max(prev.rank_lost_is, rec.rank_lost_is);\n  prev.search_is = Math.max(prev.search_is, rec.search_is);\n}\n\nconst out = [];\n\nfor (const c of byCampaign.values()) {\n  const cost = c.cost_micros / MICROS;\n  const revenue = c.conversions_value;\n  const roas = cost > 0 ? revenue / cost : 0;\n  const cpa = c.conversions > 0 ? cost / c.conversions : null;\n  const budget = budgetByCampaign.get(c.campaign_id) || {};\n  const dailyBudget = num(budget.daily_budget);\n  const dailySpend = cost / CFG.lookbackDays;\n  // how hard the campaign is pressing against its cap, 0..1+\n  const pacing = dailyBudget > 0 ? dailySpend / dailyBudget : 0;\n\n  // ---- 3. eligibility -----------------------------------------------------\n  const blockers = [];\n  if (c.status !== 'ENABLED') blockers.push('campaign not ENABLED');\n  if (cost < CFG.minCost) blockers.push('only $' + cost.toFixed(0) + ' spent in ' + CFG.lookbackDays + 'd');\n  if (c.conversions < CFG.minConversions) blockers.push(c.conversions.toFixed(1) + ' conversions is too thin to judge');\n  if (!dailyBudget) blockers.push('no daily budget found for this campaign');\n  if (budget.shared) blockers.push('shared budget — raising it moves spend for other campaigns too');\n\n  // ---- 4. is it actually budget limited? ---------------------------------\n  // Two independent signals: Google telling us we lost IS to budget, and the\n  // campaign pacing at/above its cap. Either one alone is weak, together strong.\n  const lostToBudget = c.budget_lost_is;\n  const isBudgetLimited = lostToBudget >= CFG.budgetLostFloor || pacing >= 0.95;\n  const bothSignals = lostToBudget >= CFG.budgetLostFloor && pacing >= 0.95;\n\n  // ---- 5. is it profitable enough to deserve more money? -----------------\n  const isProfitable = roas >= CFG.targetRoas;\n  const headroom = CFG.targetRoas > 0 ? roas / CFG.targetRoas : 0; // 1.0 = exactly at target\n\n  // ---- 6. size the raise -------------------------------------------------\n  // Scale the step by BOTH how profitable it is and how much impression share\n  // we're actually losing — a campaign at 4.5 ROAS losing 40% of IS to budget\n  // gets the full 30%; one barely over target losing 10% gets the floor.\n  const profitFactor = Math.min(1, Math.max(0, (roas - CFG.targetRoas) / (CFG.strongRoas - CFG.targetRoas)));\n  const lossFactor = Math.min(1, lostToBudget / 0.40);\n  const blend = 0.6 * profitFactor + 0.4 * lossFactor;\n  let increasePct = CFG.minIncreasePct + blend * (CFG.maxIncreasePct - CFG.minIncreasePct);\n  if (bothSignals) increasePct = Math.min(CFG.maxIncreasePct, increasePct + 0.05);\n  increasePct = Math.round(increasePct * 100) / 100;\n\n  const rawNewBudget = dailyBudget * (1 + increasePct);\n  const newBudget = Math.min(CFG.maxDailyBudget, Math.round(rawNewBudget * 100) / 100);\n  const cappedByCeiling = rawNewBudget > CFG.maxDailyBudget;\n  const extraDaily = Math.max(0, newBudget - dailyBudget);\n  // what that extra daily spend should return at the campaign's own ROAS,\n  // haircut 20% because incremental impressions convert worse than the head\n  const projectedExtraRevenue = extraDaily * roas * 0.8;\n\n  const eligible = blockers.length === 0 && newBudget > dailyBudget;\n  const shouldScale = eligible && isBudgetLimited && isProfitable;\n\n  let decision;\n  if (shouldScale) decision = 'scale';\n  else if (blockers.length) decision = 'blocked';\n  else if (!isBudgetLimited) decision = 'not_budget_limited';\n  else decision = 'not_profitable';\n\n  const reason = shouldScale\n    ? 'ROAS ' + roas.toFixed(2) + ' vs ' + CFG.targetRoas + ' target, losing ' +\n      (lostToBudget * 100).toFixed(0) + '% IS to budget, pacing at ' + (pacing * 100).toFixed(0) + '% of cap'\n    : (blockers[0] || (isBudgetLimited\n        ? 'budget limited but ROAS ' + roas.toFixed(2) + ' is under the ' + CFG.targetRoas + ' target'\n        : 'only ' + (lostToBudget * 100).toFixed(0) + '% IS lost to budget, pacing ' + (pacing * 100).toFixed(0) + '% — headroom already there'));\n\n  out.push({\n    json: {\n      campaign_id: c.campaign_id,\n      campaign_name: c.campaign_name,\n      channel: c.channel,\n      bidding_strategy: c.bidding,\n      status: c.status,\n      budget_resource: budget.budget_resource || null,\n      cost: Math.round(cost * 100) / 100,\n      revenue: Math.round(revenue * 100) / 100,\n      conversions: Math.round(c.conversions * 10) / 10,\n      roas: Math.round(roas * 100) / 100,\n      cpa: cpa === null ? null : Math.round(cpa * 100) / 100,\n      daily_budget: Math.round(dailyBudget * 100) / 100,\n      daily_spend: Math.round(dailySpend * 100) / 100,\n      pacing_pct: Math.round(pacing * 100),\n      budget_lost_is_pct: Math.round(lostToBudget * 1000) / 10,\n      rank_lost_is_pct: Math.round(c.rank_lost_is * 1000) / 10,\n      search_is_pct: Math.round(c.search_is * 1000) / 10,\n      is_budget_limited: isBudgetLimited,\n      is_profitable: isProfitable,\n      roas_headroom: Math.round(headroom * 100) / 100,\n      increase_pct: increasePct,\n      new_daily_budget: newBudget,\n      new_budget_micros: Math.round(newBudget * MICROS),\n      extra_daily_spend: Math.round(extraDaily * 100) / 100,\n      projected_extra_revenue: Math.round(projectedExtraRevenue * 100) / 100,\n      capped_by_ceiling: cappedByCeiling,\n      eligible,\n      decision,\n      reason,\n      blockers: blockers.join(' + ') || 'none',\n      evaluated_at: new Date().toISOString()\n    }\n  });\n}\n\n// biggest untapped opportunity first\nout.sort((a, b) => b.json.projected_extra_revenue - a.json.projected_extra_revenue);\nreturn out;"}},{"id":"bb000006-1111-4222-8333-444455556606","name":"Drop Campaigns We Cannot Judge","type":"n8n-nodes-base.filter","typeVersion":2,"position":[500,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"f1","leftValue":"={{ $json.blockers }}","rightValue":"none","operator":{"type":"string","operation":"equals"}}]},"options":{}}},{"id":"bb000007-1111-4222-8333-444455556607","name":"Budget Limited?","type":"n8n-nodes-base.if","typeVersion":2,"position":[740,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"b1","leftValue":"={{ $json.is_budget_limited }}","rightValue":"","operator":{"type":"boolean","operation":"true","singleValue":true}}]},"options":{}}},{"id":"bb000008-1111-4222-8333-444455556608","name":"ROAS Above Target?","type":"n8n-nodes-base.if","typeVersion":2,"position":[980,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"r1","leftValue":"={{ $json.roas }}","rightValue":3,"operator":{"type":"number","operation":"gte"}}]},"options":{}}},{"id":"bb000009-1111-4222-8333-444455556609","name":"Explain Why We Held","type":"n8n-nodes-base.code","typeVersion":2,"position":[980,620],"parameters":{"jsCode":"// Everything that did NOT get a raise still belongs in the log — half the value\n// of this workflow is knowing WHY a campaign was left alone.\nconst rows = $input.all().map((i) => i.json);\n\nreturn rows.map((r) => {\n  // A campaign that is budget limited but under target ROAS is not a scale\n  // candidate — it's an efficiency problem. Say so explicitly.\n  let next_action;\n  if (r.decision === 'blocked') next_action = 'no action — ' + r.blockers;\n  else if (r.decision === 'not_profitable' && r.is_budget_limited) {\n    next_action = 'fix efficiency first: capped out at ROAS ' + r.roas +\n      ' — tighten tCPA/tROAS or prune queries before adding budget';\n  } else if (r.decision === 'not_profitable') {\n    next_action = 'watch — ROAS ' + r.roas + ' under target and not budget limited';\n  } else if (r.rank_lost_is_pct > 30) {\n    next_action = 'not budget limited, but losing ' + r.rank_lost_is_pct +\n      '% IS to Ad Rank — raise bids/quality, not budget';\n  } else {\n    next_action = 'healthy and unconstrained — leave it alone';\n  }\n\n  return { json: {\n    campaign_id: r.campaign_id,\n    campaign_name: r.campaign_name,\n    decision: r.decision,\n    roas: r.roas,\n    cost: r.cost,\n    conversions: r.conversions,\n    daily_budget: r.daily_budget,\n    pacing_pct: r.pacing_pct,\n    budget_lost_is_pct: r.budget_lost_is_pct,\n    rank_lost_is_pct: r.rank_lost_is_pct,\n    scaled: false,\n    reason: r.reason,\n    next_action,\n    logged_at: new Date().toISOString()\n  } };\n});"}},{"id":"bb000010-1111-4222-8333-444455556610","name":"Log Held Campaigns","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1220,620],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=1","mode":"list","cachedResultName":"Held"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"bb000011-1111-4222-8333-444455556611","name":"Alert Capped But Unprofitable","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[1220,60],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#ads-alerts","mode":"name"},"text":"=🟠 *{{ $json.campaign_name }}* is budget limited but only {{ $json.roas }} ROAS (target 3.0).\nLosing {{ $json.budget_lost_is_pct }}% impression share to budget on {{ $json.daily_budget }}/day — but adding money here buys unprofitable volume.\nFix efficiency first: {{ $json.reason }}","otherOptions":{}}},{"id":"bb000012-1111-4222-8333-444455556612","name":"Loop Over Scale List","type":"n8n-nodes-base.splitInBatches","typeVersion":3,"position":[1220,300],"parameters":{"batchSize":1,"options":{}}},{"id":"bb000013-1111-4222-8333-444455556613","name":"Raise Budget Via Google Ads API","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[1460,500],"parameters":{"method":"POST","url":"https://googleads.googleapis.com/v18/customers/CUSTOMER_ID/campaignBudgets:mutate","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ operations: [ { update: { resourceName: $json.budget_resource, amountMicros: $json.new_budget_micros }, updateMask: \"amount_micros\" } ], validateOnly: false }) }}","options":{}}},{"id":"bb000014-1111-4222-8333-444455556614","name":"Mark Budget As Raised","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[1700,500],"parameters":{"assignments":{"assignments":[{"id":"s1","name":"campaign_id","value":"={{ $('Loop Over Scale List').item.json.campaign_id }}","type":"string"},{"id":"s2","name":"campaign_name","value":"={{ $('Loop Over Scale List').item.json.campaign_name }}","type":"string"},{"id":"s3","name":"roas","value":"={{ $('Loop Over Scale List').item.json.roas }}","type":"number"},{"id":"s4","name":"cost","value":"={{ $('Loop Over Scale List').item.json.cost }}","type":"number"},{"id":"s5","name":"conversions","value":"={{ $('Loop Over Scale List').item.json.conversions }}","type":"number"},{"id":"s6","name":"budget_lost_is_pct","value":"={{ $('Loop Over Scale List').item.json.budget_lost_is_pct }}","type":"number"},{"id":"s7","name":"pacing_pct","value":"={{ $('Loop Over Scale List').item.json.pacing_pct }}","type":"number"},{"id":"s8","name":"daily_budget","value":"={{ $('Loop Over Scale List').item.json.daily_budget }}","type":"number"},{"id":"s9","name":"new_daily_budget","value":"={{ $('Loop Over Scale List').item.json.new_daily_budget }}","type":"number"},{"id":"s10","name":"increase_pct","value":"={{ $('Loop Over Scale List').item.json.increase_pct }}","type":"number"},{"id":"s11","name":"extra_daily_spend","value":"={{ $('Loop Over Scale List').item.json.extra_daily_spend }}","type":"number"},{"id":"s12","name":"projected_extra_revenue","value":"={{ $('Loop Over Scale List').item.json.projected_extra_revenue }}","type":"number"},{"id":"s13","name":"capped_by_ceiling","value":"={{ $('Loop Over Scale List').item.json.capped_by_ceiling }}","type":"boolean"},{"id":"s14","name":"reason","value":"={{ $('Loop Over Scale List').item.json.reason }}","type":"string"},{"id":"s15","name":"scaled","value":"={{ true }}","type":"boolean"}]},"options":{}}},{"id":"bb000015-1111-4222-8333-444455556615","name":"Log Budget Change","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1940,500],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Budget Change Log"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"bb000016-1111-4222-8333-444455556616","name":"Build Scale Report","type":"n8n-nodes-base.code","typeVersion":2,"position":[1460,300],"parameters":{"jsCode":"const scaled = $input.all().map((i) => i.json).filter((r) => r && r.scaled === true);\n\nconst money = (n) => '$' + Number(n || 0).toLocaleString('en-US', { maximumFractionDigits: 0 });\n\nif (!scaled.length) {\n  return [{ json: {\n    scaled_count: 0,\n    added_daily_budget: 0,\n    projected_extra_revenue: 0,\n    slack_text: '📊 *Google Ads scale sweep* — nothing to raise today. No ENABLED campaign was both budget limited and above the 3.0 ROAS target.'\n  } }];\n}\n\nconst addedDaily = scaled.reduce((s, r) => s + Number(r.extra_daily_spend || 0), 0);\nconst projected = scaled.reduce((s, r) => s + Number(r.projected_extra_revenue || 0), 0);\nconst capped = scaled.filter((r) => r.capped_by_ceiling === true);\n\nconst lines = scaled\n  .sort((a, b) => Number(b.projected_extra_revenue) - Number(a.projected_extra_revenue))\n  .map((r, i) => (i + 1) + '. *' + r.campaign_name + '* — ' +\n    money(r.daily_budget) + '/day → ' + money(r.new_daily_budget) + '/day (+' +\n    Math.round(Number(r.increase_pct) * 100) + '%)\\n     ↳ ROAS ' + r.roas + ' · ' +\n    r.budget_lost_is_pct + '% IS lost to budget · pacing ' + r.pacing_pct + '%' +\n    '\\n     ↳ +' + money(r.extra_daily_spend) + '/day should return ~' +\n    money(r.projected_extra_revenue) + '/day at 0.8x current ROAS');\n\nconst slack_text = [\n  '📈 *Scaled ' + scaled.length + ' budget-limited campaigns*',\n  '+' + money(addedDaily) + '/day of budget · ~' + money(projected) + '/day of projected revenue · ~' +\n    money(projected * 30) + '/month',\n  capped.length ? '⚠️ ' + capped.length + ' hit the $1,500/day safety ceiling — raise it manually if you want more.' : '',\n  '',\n  ...lines,\n  '',\n  '_Re-check in 3 days: a raise that drops ROAS below 3.0 should be rolled back._'\n].filter(Boolean).join('\\n');\n\nreturn [{ json: {\n  scaled_count: scaled.length,\n  added_daily_budget: Math.round(addedDaily),\n  projected_extra_revenue: Math.round(projected),\n  projected_monthly_revenue: Math.round(projected * 30),\n  capped_count: capped.length,\n  slack_text\n} }];"}},{"id":"bb000017-1111-4222-8333-444455556617","name":"Post Scale Summary To Slack","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[1700,300],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#ads-alerts","mode":"name"},"text":"={{ $json.slack_text }}","otherOptions":{}}},{"id":"bb000018-1111-4222-8333-444455556618","name":"Note Pull","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-500,0],"parameters":{"content":"## 1. PULL THE ACCOUNT\nTwo GAQL queries over searchStream: 14 days of campaign metrics (cost_micros, conversions_value, search_budget_lost_impression_share) and the live campaign_budget amount_micros. Both pulls are appended into one stream and joined by campaign id in code — never zipped by position, which would attach one campaign's cap to another's metrics.","height":280,"width":420,"color":4}},{"id":"bb000019-1111-4222-8333-444455556619","name":"Note Rules","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[240,0],"parameters":{"content":"## 2. SCALE RULES\nNeeds $300 spend + 8 conversions to be judged. Budget limited = 10%+ IS lost to budget OR pacing at 95% of cap. Profitable = ROAS >= 3.0. The raise is 10-30%, blended 60/40 from how far ROAS beats target and how much IS we are losing, capped at $1,500/day.","height":280,"width":440,"color":3}},{"id":"bb000020-1111-4222-8333-444455556620","name":"Note Act","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1180,780],"parameters":{"content":"## 3. RAISE, LOG, REPORT\nOne campaign at a time via campaignBudgets:mutate with updateMask amount_micros, each change appended to the Budget Change Log, then one Slack post with total budget added and projected revenue. Campaigns we held get their own sheet with the reason.","height":260,"width":460,"color":5}}],"connections":{"Every Morning":{"main":[[{"node":"Fetch Campaign Performance","type":"main","index":0},{"node":"Fetch Campaign Budgets","type":"main","index":0}]]},"Fetch Campaign Performance":{"main":[[{"node":"Collect Metrics And Budgets","type":"main","index":0}]]},"Fetch Campaign Budgets":{"main":[[{"node":"Collect Metrics And Budgets","type":"main","index":1}]]},"Collect Metrics And Budgets":{"main":[[{"node":"Score Campaigns For Scaling","type":"main","index":0}]]},"Score Campaigns For Scaling":{"main":[[{"node":"Drop Campaigns We Cannot Judge","type":"main","index":0}]]},"Drop Campaigns We Cannot Judge":{"main":[[{"node":"Budget Limited?","type":"main","index":0}]]},"Budget Limited?":{"main":[[{"node":"ROAS Above Target?","type":"main","index":0}],[{"node":"Explain Why We Held","type":"main","index":0}]]},"ROAS Above Target?":{"main":[[{"node":"Loop Over Scale List","type":"main","index":0}],[{"node":"Alert Capped But Unprofitable","type":"main","index":0},{"node":"Explain Why We Held","type":"main","index":0}]]},"Explain Why We Held":{"main":[[{"node":"Log Held Campaigns","type":"main","index":0}]]},"Loop Over Scale List":{"main":[[{"node":"Build Scale Report","type":"main","index":0}],[{"node":"Raise Budget Via Google Ads API","type":"main","index":0}]]},"Raise Budget Via Google Ads API":{"main":[[{"node":"Mark Budget As Raised","type":"main","index":0}]]},"Mark Budget As Raised":{"main":[[{"node":"Log Budget Change","type":"main","index":0}]]},"Log Budget Change":{"main":[[{"node":"Loop Over Scale List","type":"main","index":0}]]},"Build Scale Report":{"main":[[{"node":"Post Scale Summary To Slack","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}