Recipes / Recipe 9
Send polite overdue-invoice reminders from a Google Sheet
A daily script that emails clients whose invoices are past due, at most once a week each, and logs when it did. Starts in safe dry-run mode.
You'll need: A sheet with columns: Invoice # | Client | Email | Amount | Due date | Status | Last reminder.
Steps
- Set up an Invoices tab with the columns above. Due date must be a real date (right-aligned), and Status should say Paid once paid.
- Extensions → Apps Script, paste the script, and set
businessName,graceDaysandminDaysBetween. - Run
sendOverdueReminders. BecausedryRunistrue, nothing is sent: open Executions (or View → Logs) to see who would have been emailed. - When the list looks right, set
dryRun: falseand runinstallDailyReminderonce. It then runs every morning around 9.
/**
* Daily overdue-invoice reminders from a sheet.
* Columns (row 1 = headers): Invoice # | Client | Email | Amount | Due date | Status | Last reminder
* Starts in dryRun mode: it only logs who WOULD be emailed until you set dryRun to false.
*/
const REMINDER_CONFIG = {
sheetName: 'Invoices',
headerRows: 1,
cols: { invoice: 1, client: 2, email: 3, amount: 4, due: 5, status: 6, lastReminder: 7 },
paidValues: ['paid', 'void', 'cancelled'], // statuses that mean "don't chase"
graceDays: 3, // wait this many days after the due date before the first reminder
minDaysBetween: 7, // then at most one reminder per week per invoice
dryRun: true, // set to false when the log looks right
businessName: 'Your Business',
currency: '$'
};
const DAY_MS = 24 * 60 * 60 * 1000;
function installDailyReminder() {
ScriptApp.getProjectTriggers()
.filter((t) => t.getHandlerFunction() === 'sendOverdueReminders')
.forEach((t) => ScriptApp.deleteTrigger(t));
ScriptApp.newTrigger('sendOverdueReminders').timeBased().everyDays(1).atHour(9).create();
}
/** Pure function: which rows need a reminder today? Returns [{rowNumber, ...fields}]. */
function findDueReminders(rows, today, cfg) {
const c = cfg.cols;
const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime();
const due = [];
rows.forEach((row, i) => {
const status = String(row[c.status - 1] || '').trim().toLowerCase();
const email = String(row[c.email - 1] || '').trim();
const dueDate = row[c.due - 1];
const last = row[c.lastReminder - 1];
if (cfg.paidValues.indexOf(status) !== -1) return;
if (!email || !(dueDate instanceof Date)) return;
const daysOverdue = Math.floor((startOfToday - dueDate.getTime()) / DAY_MS);
if (daysOverdue < cfg.graceDays) return;
if (last instanceof Date && (startOfToday - last.getTime()) / DAY_MS < cfg.minDaysBetween) return;
due.push({
rowNumber: i + cfg.headerRows + 1,
invoice: row[c.invoice - 1],
client: row[c.client - 1],
email: email,
amount: row[c.amount - 1],
daysOverdue: daysOverdue
});
});
return due;
}
function sendOverdueReminders() {
const cfg = REMINDER_CONFIG;
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(cfg.sheetName);
const lastRow = sheet.getLastRow();
if (lastRow <= cfg.headerRows) return;
const width = Math.max.apply(null, Object.keys(cfg.cols).map((k) => cfg.cols[k]));
const rows = sheet.getRange(cfg.headerRows + 1, 1, lastRow - cfg.headerRows, width).getValues();
const today = new Date();
findDueReminders(rows, today, cfg).forEach((r) => {
const subject = 'Reminder: invoice ' + r.invoice + ' is ' + r.daysOverdue + ' days overdue';
const body = 'Hi ' + r.client + ',\n\n' +
'A friendly reminder that invoice ' + r.invoice + ' for ' + cfg.currency + r.amount +
' was due ' + r.daysOverdue + ' days ago. If you have already paid, thank you, and please ignore this note.\n\n' +
'Thanks,\n' + cfg.businessName;
if (cfg.dryRun) {
Logger.log('[dry run] would email ' + r.email + ': ' + subject);
return;
}
MailApp.sendEmail(r.email, subject, body);
sheet.getRange(r.rowNumber, cfg.cols.lastReminder).setValue(today);
});
}
Good to know
- Edit the email wording in
sendOverdueRemindersto match your tone. Keep it friendly; most late payers simply forgot. - Statuses listed in
paidValuesare never chased. Matching is case-insensitive. - The script writes today's date into “Last reminder” after each email, which is what stops repeat emails.
- This sends reminders only. It doesn't calculate late fees or interest, and it isn't accounting or legal advice.
Do it yourself
Recipe Club: $12/month
Prefer to set things up yourself? Join the Recipe Club and get access to a private GitHub repository of ready-made, tested automations for Sheets, Apps Script, Python and Node. Each one comes with setup notes and sample data.
- A new recipe added every week
- Everything in the free library, plus the longer, more complete versions
- Use them in your own business as much as you like
- Cancel anytime by email (or the Stripe customer portal once available)
Recipe Club opens soon: join the waitlist
You'll need a free GitHub account. After checkout, reply with your GitHub username and we'll send the repo invite, usually within one business day.
Or buy a one-time recipe pack ($19–$29)
- Clean Data Pack $19
Dedupe, cleaning, merging and normalizing scripts and formulas for messy exports, with a sample sheet.
Coming soon - Forms & Inbox Pack $19
Form auto-replies, request routing, a lead log and follow-up nudges, ready to paste into Apps Script.
Coming soon - Money Ops Pack $29
Invoice reminders, a monthly revenue dashboard with QUERY, a receipts log and nightly backups.
Coming soon
Written with AI assistance. Script logic is unit-tested; formulas follow documented Google Sheets behavior. Always try new automations on a copy of your sheet first.