Budget Pacing Alert
Vergelijkt de uitgaven van deze maand met de verwachte pacing op basis van de dag van de maand en stuurt een e-mail- of Slack-alert bij afwijkingen.
Wat dit script doet
- Haalt de uitgaven van deze maand per ingeschakelde campagne op via GAQL (THIS_MONTH).
- Bepaalt het maandtarget per campagne uit een Sheet of uit dagbudget x aantal dagen in de maand.
- Berekent de verwachte uitgave tot en met gisteren en de afwijking in procenten.
- Projecteert de uitgave aan het einde van de maand op basis van het huidige tempo.
- Controleert ook het accounttotaal tegen ACCOUNT_MONTHLY_BUDGET.
- Stuurt een HTML-e-mail met gekleurde tabel en een Slack-bericht bij overschrijding van de drempel.
Broncode
1/**2 * Budget Pacing Alert3 *4 * Vergelijkt de werkelijke uitgaven van deze maand (account + per campagne) met de5 * verwachte uitgave op basis van de dag van de maand. Bij een afwijking groter dan6 * de ingestelde drempel wordt een alert gestuurd via e-mail en/of Slack. Het script7 * wijzigt niets in het account; het rapporteert alleen.8 *9 * Versie: 1.2.010 * Frequentie: Dagelijks (bij voorkeur 's ochtends tussen 06:00 en 09:00)11 * Niveau: Account (enkel account, geen MCC)12 * Licentie: MIT13 */1415// ===== CONFIGURATIE =====1617// Maandbudget voor het volledige account in accountvaluta. Zet op 0 om alleen18// per campagne te controleren (het campagnetarget wordt dan afgeleid van het dagbudget).19var ACCOUNT_MONTHLY_BUDGET = 10000;2021// Optioneel: Google Sheet met per-campagne maandbudgetten. Tabblad met kolommen:22// A = Campagnenaam (exact), B = Maandbudget. Laat leeg ('') om dagbudget x aantal dagen te gebruiken.23var SPREADSHEET_URL = '';24var BUDGET_SHEET_NAME = 'Budgetten';2526// Alleen campagnes waarvan de naam deze tekst bevat (hoofdletterongevoelig). Leeg = alle campagnes.27var CAMPAIGN_NAME_CONTAINS = '';2829// Drempel als fractie: 0.15 = alert bij meer dan 15% boven of onder de verwachte uitgave.30var THRESHOLDS = {31 deviation: 0.15, // afwijking t.o.v. verwachte pacing32 minSpendForAlert: 50 // negeer campagnes met minder dan dit bedrag uitgegeven (ruis)33};3435// Ontvangers van de e-mail (komma-gescheiden). Leeg = geen e-mail.36var EMAIL_RECIPIENTS = 'ads@voorbeeld.nl';3738// Slack Incoming Webhook URL. Leeg = geen Slack-bericht.39var SLACK_WEBHOOK_URL = '';4041// Stuur ook een samenvatting als er GEEN afwijkingen zijn (handig als dagelijkse check).42var SEND_SUMMARY_WHEN_OK = false;4344// ===== EINDE CONFIGURATIE =====4546function main() {47 try {48 var account = AdsApp.currentAccount();49 var timezone = account.getTimeZone();50 var currency = account.getCurrencyCode();51 var now = new Date();52 var dayOfMonth = Number(Utilities.formatDate(now, timezone, 'd'));53 var daysInMonth = getDaysInMonth(now, timezone);5455 // Verwachte fractie van het maandbudget die tot en met gisteren uitgegeven zou moeten zijn.56 var completedDays = Math.max(dayOfMonth - 1, 0);57 var expectedFraction = completedDays / daysInMonth;5859 Logger.log('Pacing-check voor ' + account.getName() + ' (' + account.getCustomerId() + ')');60 Logger.log('Dag ' + dayOfMonth + ' van ' + daysInMonth + ', verwachte fractie: ' + (expectedFraction * 100).toFixed(1) + '%');6162 var sheetBudgets = loadSheetBudgets();63 var campaigns = fetchCampaignSpend(daysInMonth, sheetBudgets);64 var alerts = [];65 var totalSpend = 0;6667 campaigns.forEach(function (c) {68 totalSpend += c.spend;69 if (c.monthlyTarget <= 0) { return; }70 c.expected = c.monthlyTarget * expectedFraction;71 c.deviation = c.expected > 0 ? (c.spend - c.expected) / c.expected : 0;72 c.projected = completedDays > 0 ? (c.spend / completedDays) * daysInMonth : 0;73 if (c.spend >= THRESHOLDS.minSpendForAlert && Math.abs(c.deviation) > THRESHOLDS.deviation) {74 alerts.push(c);75 }76 });7778 var accountAlert = null;79 if (ACCOUNT_MONTHLY_BUDGET > 0) {80 var expectedAccount = ACCOUNT_MONTHLY_BUDGET * expectedFraction;81 var accountDeviation = expectedAccount > 0 ? (totalSpend - expectedAccount) / expectedAccount : 0;82 var projectedAccount = completedDays > 0 ? (totalSpend / completedDays) * daysInMonth : 0;83 Logger.log('Account: uitgegeven ' + totalSpend.toFixed(2) + ', verwacht ' + expectedAccount.toFixed(2) +84 ', afwijking ' + (accountDeviation * 100).toFixed(1) + '%, projectie ' + projectedAccount.toFixed(2));85 if (Math.abs(accountDeviation) > THRESHOLDS.deviation && completedDays > 0) {86 accountAlert = {87 spend: totalSpend,88 expected: expectedAccount,89 deviation: accountDeviation,90 projected: projectedAccount,91 budget: ACCOUNT_MONTHLY_BUDGET92 };93 }94 }9596 alerts.sort(function (a, b) { return Math.abs(b.deviation) - Math.abs(a.deviation); });97 alerts.forEach(function (c) {98 Logger.log('ALERT ' + c.name + ': uitgegeven ' + c.spend.toFixed(2) + ' vs verwacht ' + c.expected.toFixed(2) +99 ' (' + formatPct(c.deviation) + ')');100 });101102 if (alerts.length === 0 && !accountAlert) {103 Logger.log('Geen pacing-afwijkingen boven de drempel gevonden.');104 if (!SEND_SUMMARY_WHEN_OK) { return; }105 }106107 var subject = '[Google Ads] Budget pacing ' + account.getName() + ' - ' +108 (alerts.length + (accountAlert ? 1 : 0)) + ' afwijking(en)';109 var html = buildHtmlReport(account, currency, dayOfMonth, daysInMonth, accountAlert, alerts, campaigns);110 var slackText = buildSlackText(account, currency, dayOfMonth, daysInMonth, accountAlert, alerts);111112 sendEmail(subject, html);113 sendSlack(slackText);114 } catch (e) {115 Logger.log('FOUT in budget-pacing-alert: ' + e + (e.stack ? '\n' + e.stack : ''));116 throw e;117 }118}119120/**121 * Haalt uitgaven van deze maand per ingeschakelde campagne op via GAQL.122 */123function fetchCampaignSpend(daysInMonth, sheetBudgets) {124 var query =125 'SELECT campaign.id, campaign.name, campaign_budget.amount_micros, campaign_budget.period, ' +126 'metrics.cost_micros ' +127 'FROM campaign ' +128 'WHERE segments.date DURING THIS_MONTH ' +129 "AND campaign.status = 'ENABLED'";130131 var rows = AdsApp.report(query).rows();132 var result = [];133 var filter = CAMPAIGN_NAME_CONTAINS ? CAMPAIGN_NAME_CONTAINS.toLowerCase() : '';134135 while (rows.hasNext()) {136 var row = rows.next();137 var name = row['campaign.name'];138 if (filter && name.toLowerCase().indexOf(filter) === -1) { continue; }139140 var dailyBudget = Number(row['campaign_budget.amount_micros']) / 1000000;141 var monthlyTarget;142 if (sheetBudgets && sheetBudgets[name] !== undefined) {143 monthlyTarget = sheetBudgets[name];144 } else if (row['campaign_budget.period'] === 'DAILY' || !row['campaign_budget.period']) {145 monthlyTarget = dailyBudget * daysInMonth;146 } else {147 monthlyTarget = dailyBudget; // niet-dagelijkse periode: bedrag is al het totaal148 }149150 result.push({151 id: row['campaign.id'],152 name: name,153 spend: Number(row['metrics.cost_micros']) / 1000000,154 dailyBudget: dailyBudget,155 monthlyTarget: monthlyTarget,156 expected: 0,157 deviation: 0,158 projected: 0159 });160 }161 return result;162}163164/**165 * Leest optionele maandbudgetten per campagne uit de Google Sheet.166 */167function loadSheetBudgets() {168 if (!SPREADSHEET_URL) { return null; }169 try {170 var ss = SpreadsheetApp.openByUrl(SPREADSHEET_URL);171 var sheet = ss.getSheetByName(BUDGET_SHEET_NAME);172 if (!sheet) {173 Logger.log('Tabblad "' + BUDGET_SHEET_NAME + '" niet gevonden; dagbudget x dagen wordt gebruikt.');174 return null;175 }176 var values = sheet.getDataRange().getValues();177 var budgets = {};178 for (var i = 1; i < values.length; i++) {179 var name = String(values[i][0] || '').trim();180 var amount = Number(values[i][1]);181 if (name && !isNaN(amount) && amount > 0) {182 budgets[name] = amount;183 }184 }185 Logger.log(Object.keys(budgets).length + ' campagnebudgetten geladen uit Sheet.');186 return budgets;187 } catch (e) {188 Logger.log('Kon Sheet niet lezen (' + e + '); dagbudget x dagen wordt gebruikt.');189 return null;190 }191}192193function getDaysInMonth(date, timezone) {194 var year = Number(Utilities.formatDate(date, timezone, 'yyyy'));195 var month = Number(Utilities.formatDate(date, timezone, 'M'));196 return new Date(year, month, 0).getDate();197}198199function formatPct(fraction) {200 var sign = fraction > 0 ? '+' : '';201 return sign + (fraction * 100).toFixed(1) + '%';202}203204function formatMoney(value, currency) {205 return currency + ' ' + Number(value).toFixed(2).replace('.', ',');206}207208function buildHtmlReport(account, currency, dayOfMonth, daysInMonth, accountAlert, alerts, campaigns) {209 var html = '<div style="font-family:Arial,sans-serif;font-size:13px;color:#222">';210 html += '<h2>Budget pacing - ' + escapeHtml(account.getName()) + '</h2>';211 html += '<p>Dag ' + dayOfMonth + ' van ' + daysInMonth + '. Verwachte uitgave is berekend tot en met gisteren.</p>';212213 if (accountAlert) {214 var color = accountAlert.deviation > 0 ? '#c62828' : '#ef6c00';215 html += '<p style="color:' + color + ';font-weight:bold">Account: ' + formatMoney(accountAlert.spend, currency) +216 ' uitgegeven, verwacht ' + formatMoney(accountAlert.expected, currency) + ' (' + formatPct(accountAlert.deviation) +217 '). Projectie einde maand: ' + formatMoney(accountAlert.projected, currency) + ' van ' +218 formatMoney(accountAlert.budget, currency) + '.</p>';219 }220221 html += '<h3>Campagnes met afwijking (' + alerts.length + ')</h3>';222 html += buildTable(alerts, currency);223 html += '<h3>Alle campagnes</h3>';224 html += buildTable(campaigns.filter(function (c) { return c.monthlyTarget > 0; }), currency);225 html += '<p style="color:#777">Gegenereerd door budget-pacing-alert.js</p></div>';226 return html;227}228229function buildTable(list, currency) {230 if (!list.length) { return '<p>Geen.</p>'; }231 var html = '<table cellpadding="6" style="border-collapse:collapse;border:1px solid #ddd">' +232 '<tr style="background:#f5f5f5"><th align="left">Campagne</th><th>Uitgegeven</th><th>Verwacht</th>' +233 '<th>Afwijking</th><th>Maandtarget</th><th>Projectie</th></tr>';234 list.forEach(function (c) {235 var bg = Math.abs(c.deviation) > THRESHOLDS.deviation ? (c.deviation > 0 ? '#ffebee' : '#fff3e0') : '#ffffff';236 html += '<tr style="background:' + bg + '"><td>' + escapeHtml(c.name) + '</td>' +237 '<td align="right">' + formatMoney(c.spend, currency) + '</td>' +238 '<td align="right">' + formatMoney(c.expected, currency) + '</td>' +239 '<td align="right">' + formatPct(c.deviation) + '</td>' +240 '<td align="right">' + formatMoney(c.monthlyTarget, currency) + '</td>' +241 '<td align="right">' + formatMoney(c.projected, currency) + '</td></tr>';242 });243 return html + '</table>';244}245246function buildSlackText(account, currency, dayOfMonth, daysInMonth, accountAlert, alerts) {247 var lines = ['*Budget pacing ' + account.getName() + '* (dag ' + dayOfMonth + '/' + daysInMonth + ')'];248 if (accountAlert) {249 lines.push(':warning: Account: ' + formatMoney(accountAlert.spend, currency) + ' vs verwacht ' +250 formatMoney(accountAlert.expected, currency) + ' (' + formatPct(accountAlert.deviation) + ')');251 }252 alerts.slice(0, 15).forEach(function (c) {253 lines.push((c.deviation > 0 ? ':red_circle: ' : ':large_orange_circle: ') + c.name + ': ' +254 formatMoney(c.spend, currency) + ' vs ' + formatMoney(c.expected, currency) + ' (' + formatPct(c.deviation) + ')');255 });256 if (alerts.length > 15) { lines.push('... en ' + (alerts.length - 15) + ' meer.'); }257 if (!accountAlert && alerts.length === 0) { lines.push(':white_check_mark: Geen afwijkingen boven de drempel.'); }258 return lines.join('\n');259}260261function sendEmail(subject, htmlBody) {262 if (!EMAIL_RECIPIENTS) { return; }263 try {264 MailApp.sendEmail({ to: EMAIL_RECIPIENTS, subject: subject, htmlBody: htmlBody });265 Logger.log('E-mail verstuurd naar ' + EMAIL_RECIPIENTS);266 } catch (e) {267 Logger.log('E-mail versturen mislukt: ' + e);268 }269}270271function sendSlack(text) {272 if (!SLACK_WEBHOOK_URL) { return; }273 try {274 var response = UrlFetchApp.fetch(SLACK_WEBHOOK_URL, {275 method: 'post',276 contentType: 'application/json',277 payload: JSON.stringify({ text: text }),278 muteHttpExceptions: true279 });280 Logger.log('Slack-response: ' + response.getResponseCode());281 } catch (e) {282 Logger.log('Slack versturen mislukt: ' + e);283 }284}285286function escapeHtml(text) {287 return String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');288}289Installatie & configuratie
Installatie
- Kopieer budget-pacing-alert.js naar een nieuw script in Google Ads (Tools en instellingen → Bulkacties → Scripts).
- Vul ACCOUNT_MONTHLY_BUDGET in met het totale maandbudget van het account in accountvaluta. Zet op 0 als je alleen per campagne wilt controleren.
- Vul EMAIL_RECIPIENTS in (komma-gescheiden) en/of SLACK_WEBHOOK_URL.
- Stel THRESHOLDS.deviation in; 0.15 betekent een alert bij meer dan 15% boven of onder de verwachte uitgave. Met minSpendForAlert filter je kleine campagnes weg.
- Optioneel: maak een Sheet met tabblad Budgetten (kolom A campagnenaam, kolom B maandbudget) en vul SPREADSHEET_URL in. Zonder Sheet gebruikt het script dagbudget x dagen in de maand.
- Gebruik CAMPAIGN_NAME_CONTAINS om het script te beperken tot een deel van de campagnes.
Testen
Klik op Preview. In de logs zie je per campagne de uitgave, de verwachte uitgave en de afwijking. Het script wijzigt nooit iets; e-mails worden in preview wel echt verstuurd, dus test eerst met je eigen adres.
Output
Een e-mail met twee tabellen (campagnes met afwijking en alle campagnes) waarin de kolommen Uitgegeven, Verwacht, Afwijking, Maandtarget en Projectie staan. Rood betekent te snel, oranje te langzaam. Op Slack komt een compacte lijst van de afwijkende campagnes. Zet SEND_SUMMARY_WHEN_OK op true als je ook een dagelijkse bevestiging wilt dat alles op koers ligt.
Plan het script dagelijks tussen 06:00 en 09:00 zodat de cijfers van gisteren compleet zijn.