{ "name": "Hebrew WhatsApp Invoice Reminders", "nodes": [ { "id": "schedule-daily-t2", "name": "When Each Day at 09:00", "type": "n8n-nodes-base.scheduleTrigger", "position": [ 0, 0 ], "parameters": { "rule": { "interval": [ { "field": "days", "triggerAtHour": 9, "triggerAtMinute": 0 } ] } }, "typeVersion": 1.3 }, { "id": "invoice-reminder-settings", "name": "Configure Invoice Sheet and Timezone", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 220, 0 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "function prepareInvoiceReminderSettings({ spreadsheetId, sheetName, timeZone }) {\n for (const [key, value] of Object.entries({ spreadsheetId, sheetName, timeZone })) if (typeof value !== 'string' || !value.trim() || value.includes('YOUR_')) throw new Error('Configure ' + key + '.');\n if (!/^[a-zA-Z0-9_-]+$/.test(spreadsheetId)) throw new Error('Use your Google spreadsheet ID.');\n const now = new Date(), parts = new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(now), get = name => parts.find(part => part.type === name).value;\n const today = get('year') + '-' + get('month') + '-' + get('day');\n return { spreadsheetId: spreadsheetId.trim(), sheetName, range: \"'\" + sheetName.replaceAll(\"'\", \"''\") + \"'\", timeZone, today };\n}\n\nreturn [{json:prepareInvoiceReminderSettings({\"spreadsheetId\":\"YOUR_SPREADSHEET_ID\",\"sheetName\":\"Invoices\",\"timeZone\":\"Asia/Jerusalem\"})}];" } }, { "id": "invoice-reminder-read", "name": "Read Complete Invoices Table", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ 440, 0 ], "parameters": { "method": "GET", "url": "={{ \"https://sheets.googleapis.com/v4/spreadsheets/\" + encodeURIComponent($(\"Configure Invoice Sheet and Timezone\").first().json.spreadsheetId) + \"/values/\" + encodeURIComponent($(\"Configure Invoice Sheet and Timezone\").first().json.range) }}", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleSheetsOAuth2Api", "sendQuery": true, "queryParameters": { "parameters": [ { "name": "majorDimension", "value": "ROWS" }, { "name": "valueRenderOption", "value": "UNFORMATTED_VALUE" }, { "name": "dateTimeRenderOption", "value": "FORMATTED_STRING" } ] }, "options": { "timeout": 30000, "redirect": { "redirect": { "followRedirects": false } }, "response": { "response": { "responseFormat": "json" } } } } }, { "id": "invoice-reminder-plan", "name": "Plan Invoice Reminders", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 660, 0 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "function sheetTable({ values, majorDimension = \"ROWS\" }) {\n if (majorDimension !== \"ROWS\" || !Array.isArray(values) || values.some((row) => !Array.isArray(row)))\n throw Error(\"Google Sheets must return row-oriented cell values.\");\n const isEmpty = (value) => value == null || value === \"\", nonempty = (row) => row.some((value) => !isEmpty(value)), headerIndex = values.findIndex(nonempty);\n if (headerIndex < 0)\n return { columns: [], records: [] };\n const headers = values[headerIndex];\n if (headers.some((name) => typeof name !== \"string\" || !name.trim() || name === \"row_number\") || new Set(headers).size !== headers.length)\n throw Error(\"Use unique nonempty text headers; row_number is reserved.\");\n const columns = [\"row_number\", ...headers], records = [];\n for (let index = headerIndex + 1;index < values.length; index++) {\n const cells = values[index];\n if (!nonempty(cells))\n continue;\n if (cells.slice(headers.length).some((value) => !isEmpty(value)))\n throw Error(\"Every populated column needs a header.\");\n records.push(Object.fromEntries([[\"row_number\", index + 1], ...headers.map((name, column) => [name, cells[column] ?? \"\"])]));\n }\n return { columns, records };\n}\nconst reminderMessages = {\n 7: inv => 'היי ' + inv.name + ', תזכורת ידידותית: חשבונית מספר ' + inv.id + ' על סך ' + inv.amount + ' ₪ ממתינה לתשלום כבר שבוע. נשמח להסדרה בהקדם, תודה!',\n 14: inv => 'שלום ' + inv.name + ', חשבונית מספר ' + inv.id + ' על סך ' + inv.amount + ' ₪ פתוחה כבר 14 יום. נודה לטיפולך. אם התשלום כבר בוצע, אפשר להתעלם מהודעה זו.',\n 30: inv => 'שלום ' + inv.name + ', תזכורת אחרונה: חשבונית מספר ' + inv.id + ' על סך ' + inv.amount + ' ₪ פתוחה מזה 30 יום. נבקש להסדיר את התשלום עוד היום. לשאלות אנחנו זמינים כאן.',\n};\n\nfunction invoiceCalendarDate(value, timeZone) {\n const parts = new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(value);\n const get = name => parts.find(part => part.type === name).value;\n return get('year') + '-' + get('month') + '-' + get('day');\n}\n\nfunction invoiceDateOrdinal(value, timeZone) {\n if (typeof value !== 'string' || !value.trim()) return null;\n const text = value.trim();\n const ordinal = (year, month, day) => {\n const date = new Date(Date.UTC(year, month - 1, day));\n return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day ? date.getTime() / 86400000 : null;\n };\n const iso = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(text);\n if (iso) return ordinal(Number(iso[1]), Number(iso[2]), Number(iso[3]));\n const timestamp = /^(\\d{4})-(\\d{2})-(\\d{2})T(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d{1,9})?)?(?:Z|[+-](?:[01]\\d|2[0-3]):?[0-5]\\d)$/.exec(text);\n if (timestamp && ordinal(Number(timestamp[1]), Number(timestamp[2]), Number(timestamp[3])) !== null) {\n const date = new Date(text);\n if (!Number.isFinite(date.getTime())) return null;\n return invoiceDateOrdinal(invoiceCalendarDate(date, timeZone), timeZone);\n }\n const slash = /^(\\d{1,2})\\/(\\d{1,2})\\/(\\d{4})$/.exec(text);\n if (!slash) return null;\n return ordinal(Number(slash[3]), Number(slash[2]), Number(slash[1])) ?? ordinal(Number(slash[3]), Number(slash[1]), Number(slash[2]));\n}\n\nfunction invoiceIdentity(value) {\n if (typeof value !== 'string' && !(typeof value === 'number' && Number.isFinite(value))) throw new Error('Use nonempty text or numeric InvoiceID values.');\n const id = String(value).trim();\n if (!id) throw new Error('Use a nonempty InvoiceID.');\n return id;\n}\n\nfunction invoiceChatId(value) {\n if (typeof value === 'number' && (!Number.isSafeInteger(value) || value <= 0)) throw new Error('Use a valid customer phone number.');\n if ((typeof value !== 'string' && typeof value !== 'number') || !/^[+\\d\\s().-]+$/.test(String(value))) throw new Error('Use an Israeli customer phone number.');\n let digits = String(value).replace(/\\D/g, '');\n if (digits.startsWith('0')) digits = '972' + digits.slice(1);\n if (!digits.startsWith('972')) digits = '972' + digits;\n if (!/^972[2-9]\\d{7,8}$/.test(digits)) throw new Error('Use an Israeli national number or a number with the 972 country code.');\n return digits + '@c.us';\n}\n\nfunction planInvoiceReminders({ rows, today, timeZone }) {\n const current = invoiceDateOrdinal(today, timeZone);\n if (!Array.isArray(rows) || current === null) throw new Error('Provide invoice rows and the processing calendar date.');\n const reminders = [];\n const idCounts = new Map();\n for (const row of rows) { try { const id = invoiceIdentity(row?.InvoiceID); idCounts.set(id, (idCounts.get(id) ?? 0) + 1); } catch {} }\n let invalidDates = 0;\n for (const row of rows) {\n if (!row || row.Status !== 'open') continue;\n const due = invoiceDateOrdinal(row.DueDate, timeZone);\n if (due === null) { invalidDates++; continue; }\n const daysOverdue = current - due, message = reminderMessages[daysOverdue];\n if (!message) continue;\n const InvoiceID = invoiceIdentity(row.InvoiceID);\n if (idCounts.get(InvoiceID) !== 1) throw new Error('Each selected InvoiceID must identify exactly one sheet row.');\n const name = row.CustomerName ?? '', amount = row.Amount ?? '';\n if (!['string', 'number'].includes(typeof name) || !['string', 'number'].includes(typeof amount)) throw new Error('CustomerName and Amount must be text or numbers.');\n reminders.push({ InvoiceID, tier: 'reminder-' + daysOverdue, daysOverdue, date: today, chatId: invoiceChatId(row.Phone), message: message({ id: InvoiceID, name, amount }) });\n }\n return { reminders, invalidDates, selectedCount: reminders.length };\n}\n\nfunction planTable(input) { const table=sheetTable(input); const expected=[\"InvoiceID\",\"CustomerName\",\"Amount\",\"DueDate\",\"Phone\",\"Status\",\"LastReminder\",\"LastReminderDate\"]; if(table.columns.length && expected.some(name=>!table.columns.includes(name))) throw new Error(\"The required invoice columns are missing.\"); return planInvoiceReminders({rows:table.records,today:input.today,timeZone:input.timeZone}); }\n\nconst settings=$(\"Configure Invoice Sheet and Timezone\").first().json;const input=$input.first().json; const result=planTable({...input,values:input.values??[],majorDimension:input.majorDimension??\"ROWS\",today:settings.today,timeZone:settings.timeZone});return result.reminders.map(reminder=>({json:{reminder,...settings},pairedItem:{item:0}}));" } }, { "id": "invoice-reminder-loop", "name": "One Invoice at a Time", "type": "n8n-nodes-base.splitInBatches", "typeVersion": 3, "position": [ 880, 0 ], "parameters": { "batchSize": 1, "options": {} } }, { "id": "invoice-reminder-delivery", "name": "Configure WAHA Delivery", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1100, 0 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "function prepareWhatsAppInvoiceDelivery({ reminder, endpoint, session }) {\n // The native secure Code VM has no URL global. The host/path are deployment configuration.\n if (typeof endpoint !== 'string' || !/^https:\\/\\/[a-zA-Z0-9.-]+(?::\\d+)?\\/api\\/sendText$/.test(endpoint) || /(?:^|[/.])(?:localhost|example)(?:[/:.]|$)/i.test(endpoint) || endpoint.includes('YOUR_')) throw new Error('Configure your HTTPS WAHA /api/sendText endpoint.');\n if (typeof session !== 'string' || !session.trim()) throw new Error('Configure your WAHA session.');\n if (!reminder || !/^972[2-9]\\d{7,8}@c\\.us$/.test(reminder.chatId) || typeof reminder.message !== 'string' || !reminder.message) throw new Error('Provide a validated invoice reminder.');\n return { endpoint, payload: { session: session.trim(), chatId: reminder.chatId, text: reminder.message } };\n}\n\nreturn $input.all().map((item,index)=>({json:{...item.json,...prepareWhatsAppInvoiceDelivery({reminder:item.json.reminder,...{\"endpoint\":\"https://your-waha-server.example/api/sendText\",\"session\":\"default\"}})},pairedItem:{item:index}}));" } }, { "id": "http-send-reminder-t2", "name": "Post WhatsApp Reminder", "type": "n8n-nodes-base.httpRequest", "position": [ 1320, 0 ], "parameters": { "method": "POST", "url": "={{ $json.endpoint }}", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ $json.payload }}", "options": { "timeout": 30000, "redirect": { "redirect": { "followRedirects": false } }, "response": { "response": { "fullResponse": true, "responseFormat": "json" } } } }, "typeVersion": 4.4 }, { "id": "invoice-reminder-accepted", "name": "Verify WAHA Acceptance", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1540, 0 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "function verifyWhatsAppInvoiceAcceptance({ body, statusCode }) {\n if (statusCode !== 201 || !body || typeof body !== 'object' || Array.isArray(body) || typeof body.id !== 'string' || !body.id.trim() || body.fromMe !== true || body.ack === -1) throw new Error('WAHA did not confirm acceptance of the reminder.');\n return { messageId: body.id };\n}\n\nreturn $input.all().map((item,index)=>({json:{...$(\"Configure WAHA Delivery\").itemMatching(index).json,...verifyWhatsAppInvoiceAcceptance(item.json)},pairedItem:{item:index}}));" } }, { "id": "invoice-reminder-reread", "name": "Find Invoice in Current Sheet", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ 1760, 0 ], "parameters": { "method": "GET", "url": "={{ \"https://sheets.googleapis.com/v4/spreadsheets/\" + encodeURIComponent($(\"Configure Invoice Sheet and Timezone\").first().json.spreadsheetId) + \"/values/\" + encodeURIComponent($(\"Configure Invoice Sheet and Timezone\").first().json.range) }}", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleSheetsOAuth2Api", "sendQuery": true, "queryParameters": { "parameters": [ { "name": "majorDimension", "value": "ROWS" }, { "name": "valueRenderOption", "value": "UNFORMATTED_VALUE" }, { "name": "dateTimeRenderOption", "value": "FORMATTED_STRING" } ] }, "options": { "timeout": 30000, "redirect": { "redirect": { "followRedirects": false } }, "response": { "response": { "responseFormat": "json" } } } } }, { "id": "invoice-reminder-log", "name": "Prepare Two Reminder Log Cells", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1980, 0 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "function sheetTable({ values, majorDimension = \"ROWS\" }) {\n if (majorDimension !== \"ROWS\" || !Array.isArray(values) || values.some((row) => !Array.isArray(row)))\n throw Error(\"Google Sheets must return row-oriented cell values.\");\n const isEmpty = (value) => value == null || value === \"\", nonempty = (row) => row.some((value) => !isEmpty(value)), headerIndex = values.findIndex(nonempty);\n if (headerIndex < 0)\n return { columns: [], records: [] };\n const headers = values[headerIndex];\n if (headers.some((name) => typeof name !== \"string\" || !name.trim() || name === \"row_number\") || new Set(headers).size !== headers.length)\n throw Error(\"Use unique nonempty text headers; row_number is reserved.\");\n const columns = [\"row_number\", ...headers], records = [];\n for (let index = headerIndex + 1;index < values.length; index++) {\n const cells = values[index];\n if (!nonempty(cells))\n continue;\n if (cells.slice(headers.length).some((value) => !isEmpty(value)))\n throw Error(\"Every populated column needs a header.\");\n records.push(Object.fromEntries([[\"row_number\", index + 1], ...headers.map((name, column) => [name, cells[column] ?? \"\"])]));\n }\n return { columns, records };\n}\nfunction invoiceIdentity(value) {\n if (typeof value !== \"string\" && !(typeof value === \"number\" && Number.isFinite(value)))\n throw Error(\"Use nonempty text or numeric InvoiceID values.\");\n const id = String(value).trim();\n if (!id)\n throw Error(\"Use a nonempty InvoiceID.\");\n return id;\n}\n\nfunction buildInvoiceReminderLog({ values, majorDimension, range, reminder }) {\n const { columns, records } = sheetTable({ values, majorDimension });\n const required = ['InvoiceID', 'LastReminder', 'LastReminderDate'];\n if (required.some(name => !columns.includes(name))) throw new Error('The invoice log columns are missing.');\n const matches = records.filter(row => { try { return invoiceIdentity(row.InvoiceID) === reminder.InvoiceID; } catch { return false; } });\n if (matches.length !== 1) throw new Error('The sent invoice no longer identifies exactly one row.');\n const columnName = zeroBased => {\n let index = zeroBased + 1, name = '';\n while (index > 0) { index--; name = String.fromCharCode(65 + index % 26) + name; index = Math.floor(index / 26); }\n return name;\n };\n const data = ['LastReminder', 'LastReminderDate'].map(name => ({ range: range + '!' + columnName(columns.indexOf(name) - 1) + matches[0].row_number, majorDimension: 'ROWS', values: [[name === 'LastReminder' ? reminder.tier : reminder.date]] }));\n return { data };\n}\n\nreturn $input.all().map((item,index)=>{const previous=$(\"Verify WAHA Acceptance\").itemMatching(index).json;return {json:{...previous,...buildInvoiceReminderLog({...item.json,values:item.json.values??[],majorDimension:item.json.majorDimension??\"ROWS\",range:previous.range,reminder:previous.reminder})},pairedItem:{item:index}};});" } }, { "id": "invoice-reminder-write", "name": "Write Reminder Tier and Date", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ 2200, 0 ], "parameters": { "method": "POST", "url": "={{ \"https://sheets.googleapis.com/v4/spreadsheets/\" + encodeURIComponent($json.spreadsheetId) + \"/values:batchUpdate\" }}", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleSheetsOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ {valueInputOption:\"RAW\",includeValuesInResponse:true,responseValueRenderOption:\"UNFORMATTED_VALUE\",responseDateTimeRenderOption:\"FORMATTED_STRING\",data:$json.data} }}", "options": { "timeout": 30000, "redirect": { "redirect": { "followRedirects": false } }, "response": { "response": { "responseFormat": "json" } } } } }, { "id": "invoice-reminder-confirm", "name": "Confirm Both Log Cells", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2420, 0 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "function verifyInvoiceReminderLog({ spreadsheetId, expectedSpreadsheetId, totalUpdatedCells, responses, data, reminder, messageId }) {\n const cell = value => {\n if (typeof value !== 'string') return null;\n const match = /^('(?:[^']|'')+'|[^!]+)!\\$?([A-Z]+)\\$?(\\d+)(?::\\$?([A-Z]+)\\$?(\\d+))?$/.exec(value);\n if (!match || (match[4] && (match[4] !== match[2] || match[5] !== match[3]))) return null;\n const sheet = match[1].startsWith(\"'\") ? match[1].slice(1, -1).replaceAll(\"''\", \"'\") : match[1];\n return JSON.stringify([sheet, match[2], Number(match[3])]);\n };\n const sameCell = (first, second) => cell(first) !== null && cell(first) === cell(second);\n if (spreadsheetId !== expectedSpreadsheetId || totalUpdatedCells !== 2 || !Array.isArray(responses) || responses.length !== 2 || responses.some((response, index) => response.updatedCells !== 1 || !sameCell(response.updatedRange, data[index].range) || !sameCell(response.updatedData?.range, data[index].range) || response.updatedData?.values?.length !== 1 || response.updatedData.values[0]?.length !== 1 || response.updatedData.values[0][0] !== data[index].values[0][0])) throw new Error('Google Sheets did not confirm both reminder log cells.');\n return { receipt: { InvoiceID: reminder.InvoiceID, tier: reminder.tier, date: reminder.date, messageId } };\n}\n\nreturn $input.all().map((item,index)=>{const previous=$(\"Prepare Two Reminder Log Cells\").itemMatching(index).json;return {json:verifyInvoiceReminderLog({...item.json,expectedSpreadsheetId:previous.spreadsheetId,data:previous.data,reminder:previous.reminder,messageId:previous.messageId}),pairedItem:{item:index}};});" } }, { "id": "invoice-reminder-more", "name": "More Reminders to Send?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 2640, 0 ], "parameters": { "conditions": { "options": { "caseSensitive": true, "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "invoice-reminder-has-more", "leftValue": "={{ !$('One Invoice at a Time').context.noItemsLeft }}", "rightValue": true, "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} } }, { "id": "invoice-reminder-wait", "name": "Wait Three Seconds", "type": "n8n-nodes-base.wait", "typeVersion": 1.1, "position": [ 2860, -100 ], "parameters": { "resume": "timeInterval", "amount": 3, "unit": "seconds" } }, { "id": "invoice-reminder-receipts", "name": "Logged Reminder Receipts", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1100, 260 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "return [{json:{receipts:$input.all().map(item=>item.json.receipt),sentCount:$input.all().length}}];" } } ], "active": false, "pinData": {}, "settings": { "executionOrder": "v1", "timezone": "Asia/Jerusalem" }, "connections": { "When Each Day at 09:00": { "main": [ [ { "node": "Configure Invoice Sheet and Timezone", "type": "main", "index": 0 } ] ] }, "Configure Invoice Sheet and Timezone": { "main": [ [ { "node": "Read Complete Invoices Table", "type": "main", "index": 0 } ] ] }, "Read Complete Invoices Table": { "main": [ [ { "node": "Plan Invoice Reminders", "type": "main", "index": 0 } ] ] }, "Plan Invoice Reminders": { "main": [ [ { "node": "One Invoice at a Time", "type": "main", "index": 0 } ] ] }, "One Invoice at a Time": { "main": [ [ { "node": "Logged Reminder Receipts", "type": "main", "index": 0 } ], [ { "node": "Configure WAHA Delivery", "type": "main", "index": 0 } ] ] }, "Configure WAHA Delivery": { "main": [ [ { "node": "Post WhatsApp Reminder", "type": "main", "index": 0 } ] ] }, "Post WhatsApp Reminder": { "main": [ [ { "node": "Verify WAHA Acceptance", "type": "main", "index": 0 } ] ] }, "Verify WAHA Acceptance": { "main": [ [ { "node": "Find Invoice in Current Sheet", "type": "main", "index": 0 } ] ] }, "Find Invoice in Current Sheet": { "main": [ [ { "node": "Prepare Two Reminder Log Cells", "type": "main", "index": 0 } ] ] }, "Prepare Two Reminder Log Cells": { "main": [ [ { "node": "Write Reminder Tier and Date", "type": "main", "index": 0 } ] ] }, "Write Reminder Tier and Date": { "main": [ [ { "node": "Confirm Both Log Cells", "type": "main", "index": 0 } ] ] }, "Confirm Both Log Cells": { "main": [ [ { "node": "More Reminders to Send?", "type": "main", "index": 0 } ] ] }, "More Reminders to Send?": { "main": [ [ { "node": "Wait Three Seconds", "type": "main", "index": 0 } ], [ { "node": "One Invoice at a Time", "type": "main", "index": 0 } ] ] }, "Wait Three Seconds": { "main": [ [ { "node": "One Invoice at a Time", "type": "main", "index": 0 } ] ] } } }