Client Compliance Reminder Bot (Google Apps Script + Gmail + Twilio
Author : CA. Tanwar Ratan Singh
Author : CA. Tanwar Ratan Singh
This solution automates compliance reminders for a CA practice using Google Sheets + Google Apps Script, delivering reminders via Email (Gmail) and WhatsApp (Twilio). It supports three operational modes:
The script updates Status and Last Reminder On for auditability and includes a test tool to validate WhatsApp.
Sheet Design
Create a Google Sheet with tab name Client Compliance Reminder Bot and headers in row 1:
A Client Name | B Email | C Mobile | D PAN/GSTIN | E Service | F Period | G Due Date | H Channels (Email | WhatsApp | Both) | I Status | J Last Reminder On
Tip: Keep Due Date as a proper date cell (or supported format like dd/mm/yyyy, mm/dd/yyyy, dd-MMM-yyyy).
One-Time Configuration
1) Apps Script binding
2) Twilio (WhatsApp)
3) Store secrets in Script Properties
In Apps Script → Project Settings → Script properties:
Using Script Properties keeps credentials out of source code and is appropriate for committee publication.
Operating Model
Complete Working Script (production-ready)
Paste this entire file into your Apps Script project.
/***************** CLIENT COMPLIANCE REMINDER BOT *****************
Tab name: Client Compliance Reminder Bot
Headers (row 1):
A Client Name | B Email | C Mobile | D PAN/GSTIN | E Service | F Period
G Due Date | H Channels (Email | WhatsApp | Both) | I Status | J Last Reminder On
Time zone: Asia/Kolkata
********************************************************************/
const CONFIG = {
SHEET_NAME: 'Client Compliance Reminder Bot',
TIMEZONE: 'Asia/Kolkata',
ORG_NAME: 'Tanwar Ratan Singh & Associates',
SENDER_NAME: 'CA Ratan Singh Tanwar',
REPLY_TO: 'caratansinghtanwar@gmail.com',
DAYS_BEFORE_DUE_TO_SEND: 5,// scheduled reminder (D-5)
OVERDUE_REPEAT_EVERY_DAYS: 3,// overdue cadence
MAX_OVERDUE_DAYS: 45, // stop sending after this many days overdue
TEST_MODE: false // true = simulate only (no Email/WA); false = send live
};
// Column map (1-based)
const COL = {
CLIENT: 1, // A
EMAIL: 2, // B
MOBILE: 3, // C
SERVICE: 5, // E
PERIOD: 6, // F
DUE: 7, // G
CHANNELS: 8, // H: Email | WhatsApp | Both
STATUS: 9, // I
LAST: 10 // J
};
/* ================= MENU ================= */
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('Compliance Bot')
.addItem('Send Manual Reminders (J blank)', 'sendManualReminders')
.addItem('Run Scheduled Reminders (D-5)', 'runScheduledReminders')
.addItem('Run Overdue Reminders (every 3 days)', 'runOverdueReminders')
.addSeparator()
.addItem('Send Test WhatsApp', 'sendTestWhatsApp')
.addSeparator()
.addItem('Create Daily Triggers', 'createDailyTriggers')
.addItem('Remove All Triggers', 'removeAllTriggers')
.addToUi();
}
/* ================= TRIGGERS ================= */
function createDailyTriggers() {
removeAllTriggers();
ScriptApp.newTrigger('runScheduledReminders').timeBased().atHour(9).everyDays(1).create();
ScriptApp.newTrigger('runOverdueReminders').timeBased().atHour(10).everyDays(1).create();
SpreadsheetApp.getActive().toast('Daily triggers created: 9:00 (D-5), 10:00 (Overdue).');
}
function removeAllTriggers() {
ScriptApp.getProjectTriggers().forEach(t => ScriptApp.deleteTrigger(t));
SpreadsheetApp.getActive().toast('All triggers removed.');
}
/* ================= MANUAL REMINDERS ================= */
function sendManualReminders() {
const sh = getSheet_();
const values = sh.getDataRange().getValues();
let sent = 0, skipped = 0;
for (let r = 2; r <= values.length; r++) {
const row = values[r - 1];
const status = normalizeStatus_(row[COL.STATUS - 1]); // blank → pending
const last = row[COL.LAST - 1];
const channel = String(row[COL.CHANNELS - 1] || 'Email').trim();
const email = String(row[COL.EMAIL - 1] || '').trim();
if (status !== 'pending') { skipped++; continue; }
if (last) { skipped++; continue; }
if (!email && !['WhatsApp','Both'].includes(channel)) { skipped++; continue; }
if (sendForRow_(sh, r, row, 'Manual')) sent++; else skipped++;
}
SpreadsheetApp.getActive().toast(`Manual: Sent ${sent}, Skipped ${skipped}`);
}
/* ================= SCHEDULED (D-5) ================= */
function runScheduledReminders() {
const sh = getSheet_();
const values = sh.getDataRange().getValues();
const today = normalizeDate_(new Date());
let sent = 0;
for (let r = 2; r <= values.length; r++) {
const row = values[r - 1];
const status = normalizeStatus_(row[COL.STATUS - 1]);
if (status !== 'pending') continue;
const due = smartParseDate_(row[COL.DUE - 1]);
if (!due) continue;
const diffDays = daysBetween_(today, due); // due - today
if (diffDays === CONFIG.DAYS_BEFORE_DUE_TO_SEND) {
if (sendForRow_(sh, r, row, `D-${CONFIG.DAYS_BEFORE_DUE_TO_SEND}`)) sent++;
}
}
SpreadsheetApp.getActive().toast(`Scheduled (D-5): Sent ${sent}`);
}
/* ================= OVERDUE (every 3 days) ================= */
function runOverdueReminders() {
const sh = getSheet_();
const values = sh.getDataRange().getValues();
const today = normalizeDate_(new Date());
let sent = 0;
for (let r = 2; r <= values.length; r++) {
const row = values[r - 1];
const status = normalizeStatus_(row[COL.STATUS - 1]);
if (status !== 'pending') continue;
const due = smartParseDate_(row[COL.DUE - 1]);
if (!due) continue;
const overdueDays = daysBetween_(due, today); // today - due
if (overdueDays <= 0 || overdueDays > CONFIG.MAX_OVERDUE_DAYS) continue;
const last = smartParseDate_(row[COL.LAST - 1]);
const sinceLast = last ? daysBetween_(normalizeDate_(last), today) : null;
if (sinceLast === null || sinceLast >= CONFIG.OVERDUE_REPEAT_EVERY_DAYS) {
if (sendForRow_(sh, r, row, `D+${overdueDays}`)) sent++;
}
}
SpreadsheetApp.getActive().toast(`Overdue: Sent ${sent}`);
}
/* ================= SEND ONE ROW (Email / WhatsApp / Both) ================= */
function sendForRow_(sh, r, row, stageLabel) {
try {
const client = String(row[COL.CLIENT - 1] || '').trim();
const email = String(row[COL.EMAIL - 1]|| '').trim();
const mobile = String(row[COL.MOBILE - 1] || '').trim();
const service = String(row[COL.SERVICE - 1] || '').trim();
const period = String(row[COL.PERIOD - 1] || '').trim();
const due = smartParseDate_(row[COL.DUE - 1]);
const channel = String(row[COL.CHANNELS - 1] || 'Email').trim(); // Email | WhatsApp | Both
if (!client) return false;
const dueStr = due ? formatIST_(due, 'EEE, dd MMM yyyy') : '-';
const subject = `[Reminder] ${service || 'Compliance'} for ${period || '-'} – Due ${dueStr}`;
const textBody =
`Dear ${client},
This is a reminder for your ${service || 'compliance'} for ${period || '-'}.
Due Date: ${dueStr}
Kindly share the required documents/confirmations.
If already submitted, please ignore this message.
Regards,
${CONFIG.SENDER_NAME}
${CONFIG.ORG_NAME}`;
// Email channel
if (!CONFIG.TEST_MODE && (channel === 'Email' || channel === 'Both')) {
if (email) {
GmailApp.sendEmail(email, subject, textBody, {
name: CONFIG.SENDER_NAME,
replyTo: CONFIG.REPLY_TO
});
}
}
// WhatsApp channel (Twilio)
if (!CONFIG.TEST_MODE && (channel === 'WhatsApp' || channel === 'Both')) {
const toWA = normalizeWhatsAppNumber_(mobile);
if (!toWA) throw new Error('Invalid/blank mobile for WhatsApp');
sendWhatsApp_(toWA, textBody);
}
// Update audit columns upon success
sh.getRange(r, COL.LAST).setValue(new Date());
sh.getRange(r, COL.STATUS).setValue(`Reminder Sent – ${stageLabel}`);
return true;
} catch (err) {
// Silent skip with log for operational robustness
Logger.log(`Row ${r} send error: ${err}`);
return false;
}
}
/* ================= TEST TOOLING ================= */
function sendTestWhatsApp() {
try {
const sh = SpreadsheetApp.getActiveSheet();
const sel = sh.getActiveRange();
let rawMobile = '';
if (sel && sel.getRow() > 1) {
rawMobile = String(sh.getRange(sel.getRow(), COL.MOBILE).getValue() || '').trim();
}
if (!rawMobile) {
const ui = SpreadsheetApp.getUi();
const res = ui.prompt('Send Test WhatsApp',
'Enter a mobile (India): 9876543210 / 919876543210 / +919876543210',
ui.ButtonSet.OK_CANCEL);
if (res.getSelectedButton() !== ui.Button.OK) { ui.alert('Cancelled'); return; }
rawMobile = (res.getResponseText() || '').trim();
if (!rawMobile) { ui.alert('No number entered'); return; }
}
const toWA = normalizeWhatsAppNumber_(rawMobile);
if (!toWA) throw new Error('Invalid mobile format for WhatsApp');
const nowStr = Utilities.formatDate(new Date(), CONFIG.TIMEZONE, 'EEE, dd MMM yyyy HH:mm');
const body = '[TEST] Compliance Bot – WhatsApp connectivity\nTime: ' + nowStr +
'\nThis is a test from ' + CONFIG.SENDER_NAME + '.';
if (!CONFIG.TEST_MODE) sendWhatsApp_(toWA, body);
SpreadsheetApp.getActive().toast('Test WhatsApp sent to: ' + toWA);
} catch (err) {
Logger.log('sendTestWhatsApp error: ' + err);
SpreadsheetApp.getActive().toast('Test WhatsApp failed. See Logs.');
}
}
/* ================= HELPERS ================= */
function getSheet_() {
const ss = SpreadsheetApp.getActive();
const sh = ss.getSheetByName(CONFIG.SHEET_NAME);
if (!sh) throw new Error('Sheet not found. Check CONFIG.SHEET_NAME.');
return sh;
}
function normalizeStatus_(val) {
const s = String(val || '').trim().toLowerCase();
return (s === '' || s === 'pending') ? 'pending' : 'other';
}
// Robust date parser: Date object, Excel serials, dd/mm/yyyy, mm/dd/yyyy, dd-MMM-yyyy, ISO
function smartParseDate_(val) {
if (!val) return null;
if (val instanceof Date && !isNaN(val)) return val;
if (typeof val === 'number') {
const base = new Date(1899, 11, 30);
const d = new Date(base.getTime() + val * 86400000);
return isNaN(d) ? null : d;
}
const s = String(val).trim();
let d = new Date(s);
if (!isNaN(d)) return d;
let m = s.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$/);
if (m) {
// try dd/mm first
let dd = parseInt(m[1],10), mm = parseInt(m[2],10)-1, yy = parseInt(m[3],10);
d = new Date(yy, mm, dd); if (!isNaN(d)) return d;
// fallback mm/dd
dd = parseInt(m[2],10); mm = parseInt(m[1],10)-1; d = new Date(yy, mm, dd);
if (!isNaN(d)) return d;
}
m = s.match(/^(\d{1,2})[\/\-\s]([A-Za-z]{3,})[\/\-\s](\d{4})$/);
if (m) {
const mon = m[2].substr(0,3).toLowerCase();
const months = {jan:0,feb:1,mar:2,apr:3,may:4,jun:5,jul:6,aug:7,sep:8,oct:9,nov:10,dec:11};
if (months.hasOwnProperty(mon)) {
d = new Date(parseInt(m[3],10), months[mon], parseInt(m[1],10));
if (!isNaN(d)) return d;
}
}
return null;
}
function normalizeDate_(d) {
const s = Utilities.formatDate(d, CONFIG.TIMEZONE, 'yyyy-MM-dd');
return new Date(s + 'T00:00:00');
}
function daysBetween_(a, b) {
return Math.round((normalizeDate_(b) - normalizeDate_(a)) / 86400000);
}
function formatIST_(date, pattern) {
return Utilities.formatDate(date, CONFIG.TIMEZONE, pattern);
}
/* ================= WHATSAPP (Twilio) ================= */
function getTwilioProps_() {
// Read from Script Properties for security
const p = PropertiesService.getScriptProperties();
const SID = p.getProperty('TWILIO_SID');
const AUTH = p.getProperty('TWILIO_AUTH');
const FROM = p.getProperty('TWILIO_FROM'); // e.g., 'whatsapp:+14155238886'
if (!SID || !AUTH || !FROM) throw new Error('Twilio properties missing: set TWILIO_SID, TWILIO_AUTH, TWILIO_FROM.');
return { SID, AUTH, FROM };
}
function normalizeWhatsAppNumber_(raw) {
if (!raw) return '';
const digits = String(raw).replace(/[^\d]/g, '');
if (digits.length === 10) return 'whatsapp:+91' + digits;
if (digits.length === 12 && digits.startsWith('91')) return 'whatsapp:+' + digits;
if (digits.length === 13 && digits.startsWith('+91')) return 'whatsapp:' + digits;
return '';
}
function sendWhatsApp_(toWhatsApp, body) {
const { SID, AUTH, FROM } = getTwilioProps_();
const url = 'https://api.twilio.com/2010-04-01/Accounts/' + SID + '/Messages.json';
const payload = { To: toWhatsApp, From: FROM, Body: body };
const options = {
method: 'post',
muteHttpExceptions: true,
payload,
headers: { Authorization: 'Basic ' + Utilities.base64Encode(SID + ':' + AUTH) }
};
const res = UrlFetchApp.fetch(url, options);
const code = res.getResponseCode();
if (code < 200 || code >= 300) {
// Common cause in sandbox: wrong FROM or recipient didn’t join sandbox
Logger.log('Twilio error ' + code + ': ' + res.getContentText());
throw new Error('Twilio WhatsApp send failed (HTTP ' + code + ').');
}
}
Usage (concise)
Compliance & Security (publication note)