AdsPlatform
Budget · beginner · Dagelijks (ochtend)

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.

budgetpacingalerte-mailslack

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

budget-pacing-alert.js· 289 regels
1/**
2 * Budget Pacing Alert
3 *
4 * Vergelijkt de werkelijke uitgaven van deze maand (account + per campagne) met de
5 * verwachte uitgave op basis van de dag van de maand. Bij een afwijking groter dan
6 * de ingestelde drempel wordt een alert gestuurd via e-mail en/of Slack. Het script
7 * wijzigt niets in het account; het rapporteert alleen.
8 *
9 * Versie: 1.2.0
10 * Frequentie: Dagelijks (bij voorkeur 's ochtends tussen 06:00 en 09:00)
11 * Niveau: Account (enkel account, geen MCC)
12 * Licentie: MIT
13 */
14
15// ===== CONFIGURATIE =====
16
17// Maandbudget voor het volledige account in accountvaluta. Zet op 0 om alleen
18// per campagne te controleren (het campagnetarget wordt dan afgeleid van het dagbudget).
19var ACCOUNT_MONTHLY_BUDGET = 10000;
20
21// 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';
25
26// Alleen campagnes waarvan de naam deze tekst bevat (hoofdletterongevoelig). Leeg = alle campagnes.
27var CAMPAIGN_NAME_CONTAINS = '';
28
29// 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 pacing
32 minSpendForAlert: 50 // negeer campagnes met minder dan dit bedrag uitgegeven (ruis)
33};
34
35// Ontvangers van de e-mail (komma-gescheiden). Leeg = geen e-mail.
36var EMAIL_RECIPIENTS = 'ads@voorbeeld.nl';
37
38// Slack Incoming Webhook URL. Leeg = geen Slack-bericht.
39var SLACK_WEBHOOK_URL = '';
40
41// Stuur ook een samenvatting als er GEEN afwijkingen zijn (handig als dagelijkse check).
42var SEND_SUMMARY_WHEN_OK = false;
43
44// ===== EINDE CONFIGURATIE =====
45
46function 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);
54
55 // 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;
58
59 Logger.log('Pacing-check voor ' + account.getName() + ' (' + account.getCustomerId() + ')');
60 Logger.log('Dag ' + dayOfMonth + ' van ' + daysInMonth + ', verwachte fractie: ' + (expectedFraction * 100).toFixed(1) + '%');
61
62 var sheetBudgets = loadSheetBudgets();
63 var campaigns = fetchCampaignSpend(daysInMonth, sheetBudgets);
64 var alerts = [];
65 var totalSpend = 0;
66
67 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 });
77
78 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_BUDGET
92 };
93 }
94 }
95
96 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 });
101
102 if (alerts.length === 0 && !accountAlert) {
103 Logger.log('Geen pacing-afwijkingen boven de drempel gevonden.');
104 if (!SEND_SUMMARY_WHEN_OK) { return; }
105 }
106
107 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);
111
112 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}
119
120/**
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'";
130
131 var rows = AdsApp.report(query).rows();
132 var result = [];
133 var filter = CAMPAIGN_NAME_CONTAINS ? CAMPAIGN_NAME_CONTAINS.toLowerCase() : '';
134
135 while (rows.hasNext()) {
136 var row = rows.next();
137 var name = row['campaign.name'];
138 if (filter && name.toLowerCase().indexOf(filter) === -1) { continue; }
139
140 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 totaal
148 }
149
150 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: 0
159 });
160 }
161 return result;
162}
163
164/**
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}
192
193function 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}
198
199function formatPct(fraction) {
200 var sign = fraction > 0 ? '+' : '';
201 return sign + (fraction * 100).toFixed(1) + '%';
202}
203
204function formatMoney(value, currency) {
205 return currency + ' ' + Number(value).toFixed(2).replace('.', ',');
206}
207
208function 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>';
212
213 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 }
220
221 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}
228
229function 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}
245
246function 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}
260
261function 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}
270
271function 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: true
279 });
280 Logger.log('Slack-response: ' + response.getResponseCode());
281 } catch (e) {
282 Logger.log('Slack versturen mislukt: ' + e);
283 }
284}
285
286function escapeHtml(text) {
287 return String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
288}
289

Installatie & configuratie

Installatie

  1. Kopieer budget-pacing-alert.js naar een nieuw script in Google Ads (Tools en instellingen → Bulkacties → Scripts).
  2. Vul ACCOUNT_MONTHLY_BUDGET in met het totale maandbudget van het account in accountvaluta. Zet op 0 als je alleen per campagne wilt controleren.
  3. Vul EMAIL_RECIPIENTS in (komma-gescheiden) en/of SLACK_WEBHOOK_URL.
  4. 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.
  5. 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.
  6. 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.