ads-system.store

Budget pacing monitor

read-only

Compares month-to-date spend against a monthly budget, projects where the month ends, and states the daily figure that would land it on target. Reads every campaign type, and can weight the expected line by the account's own weekday pattern.

requires
Any account. A monthly budget figure you set at the top.
writes
Nothing. Read-only.
schedule
Daily, early morning
change first
MONTHLY_BUDGET, EMAIL, and the alert band at the top.
budget-pacing-monitor.js
// Budget pacing monitor
// Compares month-to-date spend against a monthly budget and projects the month end.
// Reads through GAQL FROM campaign, so Performance Max and Demand Gen are included.
// Optionally weights the expected line by weekday, so a weekday-heavy account does not
// look alarmingly behind every Monday.
// Read-only: it reports, it changes no budget.

var MONTHLY_BUDGET = 0;      // required, in the account currency
var ALERT_ABOVE    = 1.15;   // pace above this triggers an alert
var ALERT_BELOW    = 0.85;   // pace below this triggers an alert
var SKIP_FIRST_DAYS = 4;     // early in the month one day distorts the ratio
var WEEKDAY_WEIGHTS = true;  // false uses a flat expected line
var EMAIL = '';              // leave empty to log only

function main() {
  if (!MONTHLY_BUDGET) {
    Logger.log('Set MONTHLY_BUDGET at the top of the script.');
    return;
  }

  var tz    = AdsApp.currentAccount().getTimeZone();
  var now   = new Date();
  var year  = Number(Utilities.formatDate(now, tz, 'yyyy'));
  var month = Number(Utilities.formatDate(now, tz, 'MM'));
  var dayOfMonth = Number(Utilities.formatDate(now, tz, 'dd'));

  var daysInMonth = new Date(year, month, 0).getDate();
  var daysDone    = dayOfMonth - 1;   // today is still spending, do not count it

  if (daysDone < SKIP_FIRST_DAYS) {
    Logger.log('Day ' + dayOfMonth + '. Too early in the month to pace.');
    return;
  }

  var from = year + '-' + pad(month) + '-01';
  var to   = Utilities.formatDate(new Date(now.getTime() - 86400000), tz, 'yyyy-MM-dd');

  var spend = spendByCampaign(from, to);
  var total = 0;
  for (var name in spend) { total += spend[name]; }

  var doneShare = WEEKDAY_WEIGHTS
    ? weightedShare(year, month, daysDone, daysInMonth, tz)
    : (daysDone / daysInMonth);

  var expected   = MONTHLY_BUDGET * doneShare;
  var pace       = expected > 0 ? (total / expected) : 0;
  var projected  = doneShare > 0 ? (total / doneShare) : 0;
  var remaining  = MONTHLY_BUDGET - total;
  var daysLeft   = daysInMonth - daysDone;
  var dailyToFit = daysLeft > 0 ? (remaining / daysLeft) : 0;

  var lines = [];
  lines.push(AdsApp.currentAccount().getName());
  lines.push(from + ' to ' + to + '  (' + daysDone + ' of ' + daysInMonth + ' days)');
  lines.push('');
  lines.push('Budget      ' + MONTHLY_BUDGET.toFixed(2));
  lines.push('Spent       ' + total.toFixed(2));
  lines.push('Expected    ' + expected.toFixed(2) + (WEEKDAY_WEIGHTS ? '  (weekday weighted)' : ''));
  lines.push('Pace        ' + pace.toFixed(2));
  lines.push('Projected   ' + projected.toFixed(2));
  lines.push('To finish on budget, spend ' + dailyToFit.toFixed(2) + ' per day for the remaining ' + daysLeft + ' days.');
  lines.push('');

  var names = Object.keys(spend).sort(function (a, b) { return spend[b] - spend[a]; });
  lines.push('campaign\tspend\tshare');
  for (var i = 0; i < names.length; i++) {
    var share = total > 0 ? (spend[names[i]] / total * 100) : 0;
    lines.push(names[i] + '\t' + spend[names[i]].toFixed(2) + '\t' + share.toFixed(1) + '%');
  }

  var body = lines.join('\n');
  Logger.log(body);

  var breached = (pace >= ALERT_ABOVE || pace <= ALERT_BELOW);
  if (EMAIL && breached) {
    var word = pace >= ALERT_ABOVE ? 'ahead' : 'behind';
    MailApp.sendEmail(
      EMAIL,
      'Pacing ' + word + ' (' + pace.toFixed(2) + ') - ' + AdsApp.currentAccount().getName(),
      body
    );
  }
}

function spendByCampaign(from, to) {
  var out  = {};
  var rows = AdsApp.search(
    'SELECT campaign.name, metrics.cost_micros ' +
    'FROM campaign ' +
    'WHERE segments.date BETWEEN "' + from + '" AND "' + to + '" ' +
    '  AND campaign.status != "REMOVED"'
  );
  while (rows.hasNext()) {
    var r = rows.next();
    var n = String(r.campaign.name);
    out[n] = (out[n] || 0) + Number(r.metrics.costMicros) / 1000000;
  }
  return out;
}

// Derives the share of a normal month that the elapsed days represent,
// using the account's own weekday spend pattern over the last eight weeks.
function weightedShare(year, month, daysDone, daysInMonth, tz) {
  var weights = weekdayWeights(tz);
  if (!weights) { return daysDone / daysInMonth; }

  var done = 0, all = 0;
  for (var d = 1; d <= daysInMonth; d++) {
    var w = weights[new Date(year, month - 1, d).getDay()];
    all += w;
    if (d <= daysDone) { done += w; }
  }
  return all > 0 ? (done / all) : (daysDone / daysInMonth);
}

function weekdayWeights(tz) {
  var from = Utilities.formatDate(new Date(Date.now() - 57 * 86400000), tz, 'yyyy-MM-dd');
  var to   = Utilities.formatDate(new Date(Date.now() - 86400000), tz, 'yyyy-MM-dd');

  var sums   = [0, 0, 0, 0, 0, 0, 0];
  var counts = [0, 0, 0, 0, 0, 0, 0];

  var rows = AdsApp.search(
    'SELECT segments.date, metrics.cost_micros ' +
    'FROM customer ' +
    'WHERE segments.date BETWEEN "' + from + '" AND "' + to + '"'
  );
  while (rows.hasNext()) {
    var r = rows.next();
    var p = String(r.segments.date).split('-');
    var d = new Date(Number(p[0]), Number(p[1]) - 1, Number(p[2])).getDay();
    sums[d]   += Number(r.metrics.costMicros) / 1000000;
    counts[d] += 1;
  }

  var total = 0;
  for (var i = 0; i < 7; i++) { total += sums[i]; }
  if (total <= 0) { return null; }   // no history, fall back to a flat line

  var w = [];
  for (var j = 0; j < 7; j++) {
    w.push(counts[j] > 0 ? (sums[j] / counts[j]) : 0);
  }
  return w;
}

function pad(n) { return (n < 10 ? '0' : '') + n; }

What it does differently

Two things. It reads through FROM campaign, so Performance Max and Demand Gen spend is included — a pacing script built on AdsApp.campaigns() covers Search and Display only and will understate the account.

And it can weight the expected line by weekday. It pulls the last eight weeks of daily account spend, derives an average for each weekday, and uses that shape to decide what share of the month should already be spent. In an account that does most of its business Monday to Friday, a flat model reports the same false panic every Monday morning.

Set WEEKDAY_WEIGHTS to false if you want the plain version. With no spend history the script falls back to flat automatically.

Reading the output

Pace is spend divided by expected. Projected is what the month ends at if nothing changes. The last line is the arithmetic you would otherwise do by hand: the daily figure that lands the month on budget.

The per-campaign table below is sorted by spend and includes each campaign’s share. That share column is what tells you whether the pacing problem belongs to one campaign or the whole account.

Before you schedule it

MONTHLY_BUDGET is required and the script exits without it. If the real number lives in a spreadsheet somebody else edits, read it from there instead of hard-coding it — the hard-coded version will be wrong within two months and nobody will notice.

SKIP_FIRST_DAYS suppresses the first four days, where a single strong day puts pace at 2.0 and means nothing.

Verify once: the script’s total for the month to date should match the account total in the interface for the same window, to the currency unit. If it does not, something is being excluded.

Notes and limits

  • Campaigns on a shared budget are reported individually. If you use shared budgets, pace the budget rather than the campaign — the campaigns compete for one pool.
  • Daily budget in the interface is an average, not a cap. This script deliberately never compares a single day against it.
  • The alert fires each run while pace is outside the band. If you want it once per crossing, keep the last state in a spreadsheet.

Background: budget pacing in Google Ads scripts.