{
  "name": "Invoice chaser — 7/14/30-day reminder ladder (Little Red Bot)",
  "nodes": [
    {
      "parameters": {
        "content": "## Invoice chaser — start here\n\nFree template from Little Red Bot (aiautomations.sg). Chases overdue invoices on a 7/14/30-day ladder, then logs what it sent.\n\n**Setup, in order:**\n\n1. Create a Google Sheet with one tab named `Invoices` and these exact headers in row 1: `InvoiceNo`, `ClientName`, `Email`, `AmountSGD`, `DueDate`, `Status`, `LastReminded`.\n2. Enter dates as YYYY-MM-DD (e.g. 2026-06-30). Leave `LastReminded` blank — the workflow fills it in.\n3. Open both Google Sheets nodes and the Gmail node, and connect your own credentials (n8n walks you through the OAuth screens).\n4. Paste your spreadsheet ID (the long string in the sheet's URL) into both Google Sheets nodes.\n5. Open \"Pick invoices due a reminder\" and edit the BUSINESS block at the top: company name, contact person, phone, PayNow/bank details.\n6. Test safely: set one row's DueDate about 10 days back with YOUR OWN email address, then press \"Execute workflow\" and check your inbox.\n7. Happy with it? Toggle the workflow to Active. It runs daily at 09:00, Asia/Singapore time (set in workflow settings).\n\nFull walkthrough: aiautomations.sg/articles/automate-invoice-chasing",
        "height": 620,
        "width": 460
      },
      "id": "8d527a24-6f99-4913-a3b0-94fbf37ec7a5",
      "name": "Note — start here",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -780,
        -40
      ]
    },
    {
      "parameters": {
        "content": "## How the ladder works\n\n- 7+ days overdue → step 1: friendly nudge\n- 14+ days overdue → step 2: firmer follow-up\n- 30+ days overdue → step 3: final notice (still no legal threats — that conversation is a phone call, not an email)\n\nRules baked in:\n\n- `Status` = Paid is never emailed. Mark invoices Paid promptly — this is the workflow's off switch.\n- No repeat email within 6 days of `LastReminded`.\n- Rows with a missing/unreadable email or due date are skipped silently.\n\nChange the thresholds in the LADDER constant. The three email templates live in buildEmail() — edit them until they sound like you, not like a robot.",
        "height": 420,
        "width": 440,
        "color": 7
      },
      "id": "b3db884b-3e96-4595-aaf2-1f94adc149e6",
      "name": "Note — ladder logic",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        300,
        420
      ]
    },
    {
      "parameters": {
        "content": "## About the email node\n\n- Sends plain-text email from your own Gmail address, so replies land back in your inbox — where you want a \"sorry, paying today!\" to arrive.\n- Not on Gmail? Swap this node for n8n's \"Send Email\" (SMTP) node — the three fields map straight across (To / Subject / Text).\n- n8n's promotional footer is switched off here (Options → Append n8n Attribution). A payment chaser is no place for someone else's ad.",
        "height": 300,
        "width": 400,
        "color": 7
      },
      "id": "6464bbeb-b86a-489c-9453-a00e05c34493",
      "name": "Note — email node",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        780,
        420
      ]
    },
    {
      "parameters": {
        "content": "## PDPA note (read this one)\n\nClient emails are personal data under the PDPA. Using them to chase invoices for work you delivered is fine — collecting payment is exactly the purpose the address was given to you for.\n\nWhat NOT to do with this sheet:\n\n- Don't add these addresses to a marketing or newsletter list. That is a different purpose and needs its own consent.\n- Don't share the sheet more widely than needed — restrict access to whoever handles billing.\n- Delete or archive rows you no longer need once invoices are settled.",
        "height": 340,
        "width": 440,
        "color": 3
      },
      "id": "a1b08670-d154-424a-8551-91b8d4627219",
      "name": "Note — PDPA",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -180,
        420
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "days",
              "daysInterval": 1,
              "triggerAtHour": 9,
              "triggerAtMinute": 0
            }
          ]
        }
      },
      "id": "e5d65179-e7c3-475f-b66d-ce5df384dbe5",
      "name": "Every day at 9am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -220,
        160
      ]
    },
    {
      "parameters": {
        "resource": "sheet",
        "operation": "read",
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "PASTE_YOUR_SPREADSHEET_ID_HERE"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Invoices"
        },
        "options": {}
      },
      "id": "29045de1-1f0c-440b-b867-2013d96a5d55",
      "name": "Read invoices sheet",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        20,
        160
      ],
      "credentials": {
        "googleSheetsOAuth2Api": {
          "id": "REPLACE_WITH_YOUR_CREDENTIAL",
          "name": "Google Sheets account (connect your own)"
        }
      }
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// ============================================================\n// INVOICE CHASER — reminder ladder logic\n// Reads every row from the sheet and keeps only the invoices\n// that should get a reminder email today.\n//\n// The ladder:   7+ days overdue -> step 1 (friendly nudge)\n//              14+ days overdue -> step 2 (firmer follow-up)\n//              30+ days overdue -> step 3 (final notice)\n// An invoice is skipped if it was reminded in the last 6 days,\n// so nobody gets two emails in the same week.\n// ============================================================\n\n// ---- Edit this block to fit your business ------------------\nconst BUSINESS = {\n  name: 'Your Company Pte Ltd',\n  contactPerson: 'Your Name',\n  contactPhone: '+65 9123 4567',\n  paymentInstructions:\n    'PayNow to UEN 201234567A, or FAST transfer to DBS 123-456789-0 (Your Company Pte Ltd)',\n};\n\nconst LADDER = [\n  { step: 1, minDaysOverdue: 7 },\n  { step: 2, minDaysOverdue: 14 },\n  { step: 3, minDaysOverdue: 30 },\n];\nconst MIN_DAYS_BETWEEN_REMINDERS = 6;\n// ------------------------------------------------------------\n\nconst ZONE = 'Asia/Singapore';\nconst today = DateTime.now().setZone(ZONE).startOf('day');\n\nfunction parseDate(value) {\n  if (value === null || value === undefined || value === '') return null;\n  const s = String(value).trim();\n  // Parse in SG time explicitly — otherwise a UTC server counts days wrongly.\n  let d = DateTime.fromISO(s, { zone: ZONE }); // 2026-06-30\n  if (!d.isValid) d = DateTime.fromFormat(s, 'd/M/yyyy', { zone: ZONE }); // 30/6/2026\n  if (!d.isValid) d = DateTime.fromFormat(s, 'd MMM yyyy', { zone: ZONE }); // 30 Jun 2026\n  return d.isValid ? d.startOf('day') : null;\n}\n\nfunction sgd(value) {\n  const n = Number(String(value).replace(/[^0-9.\\-]/g, ''));\n  if (!Number.isFinite(n)) return 'S$' + value;\n  return (\n    'S$' +\n    n.toLocaleString('en-SG', { minimumFractionDigits: 2, maximumFractionDigits: 2 })\n  );\n}\n\nfunction buildEmail(step, inv, daysOverdue) {\n  const name = inv.ClientName || 'there';\n  const amount = sgd(inv.AmountSGD);\n  const due = parseDate(inv.DueDate).toFormat('d LLL yyyy');\n  const no = inv.InvoiceNo;\n\n  if (step === 1) {\n    return {\n      subject: `Friendly reminder: invoice ${no} (${amount}) is past due`,\n      body: `Dear ${name},\n\nHope all is well. This is a friendly reminder that invoice ${no} for ${amount}, due on ${due}, is now ${daysOverdue} days past due.\n\nIt may simply have slipped through — happy to resend the invoice or answer any questions about it.\n\nPayment details: ${BUSINESS.paymentInstructions}\n\nIf payment is already on the way, please ignore this note.\n\nThank you,\n${BUSINESS.contactPerson}\n${BUSINESS.name}`,\n    };\n  }\n\n  if (step === 2) {\n    return {\n      subject: `Second reminder: invoice ${no} (${amount}) — ${daysOverdue} days overdue`,\n      body: `Dear ${name},\n\nFollowing up on our earlier reminder: invoice ${no} for ${amount} was due on ${due} and remains unpaid after ${daysOverdue} days.\n\nCould you let me know when we can expect payment? If there is an issue with the invoice, or you would like to discuss a payment arrangement, call me on ${BUSINESS.contactPhone} and we will sort it out.\n\nPayment details: ${BUSINESS.paymentInstructions}\n\nRegards,\n${BUSINESS.contactPerson}\n${BUSINESS.name}`,\n    };\n  }\n\n  return {\n    subject: `Final reminder: invoice ${no} (${amount}) — payment required`,\n    body: `Dear ${name},\n\nDespite two earlier reminders, invoice ${no} for ${amount}, due on ${due}, is still outstanding after ${daysOverdue} days.\n\nPlease arrange payment within 7 days of this email. If we do not receive payment or hear from you by then, we will place further work on hold and review our next steps under our payment terms.\n\nIf payment was made in the last few days, please reply with the transaction reference so we can update our records — transfers do occasionally cross with reminders.\n\nPayment details: ${BUSINESS.paymentInstructions}\n\nRegards,\n${BUSINESS.contactPerson}\n${BUSINESS.name}`,\n  };\n}\n\nconst out = [];\nfor (const item of $input.all()) {\n  const inv = item.json;\n\n  // Never chase a paid invoice.\n  const status = String(inv.Status ?? '').trim().toLowerCase();\n  if (status === 'paid') continue;\n\n  // No email or no due date -> nothing sensible to send. Fix the sheet.\n  if (!inv.Email || !inv.DueDate) continue;\n\n  const dueDate = parseDate(inv.DueDate);\n  if (!dueDate) continue; // unparseable date — use YYYY-MM-DD in the sheet\n\n  const daysOverdue = Math.floor(today.diff(dueDate, 'days').days);\n  if (daysOverdue < LADDER[0].minDaysOverdue) continue; // not overdue enough yet\n\n  // Breathing room: no repeat reminder within 6 days.\n  const lastReminded = parseDate(inv.LastReminded);\n  if (\n    lastReminded &&\n    today.diff(lastReminded, 'days').days < MIN_DAYS_BETWEEN_REMINDERS\n  )\n    continue;\n\n  // Highest rung of the ladder this invoice has reached.\n  const rung = [...LADDER].reverse().find((l) => daysOverdue >= l.minDaysOverdue);\n  const email = buildEmail(rung.step, inv, daysOverdue);\n\n  out.push({\n    json: {\n      ...inv,\n      daysOverdue,\n      reminderStep: rung.step,\n      emailSubject: email.subject,\n      emailBody: email.body,\n      todayISO: today.toISODate(),\n    },\n  });\n}\n\nreturn out;"
      },
      "id": "530489ec-9a89-4298-9e2b-f9a36c885137",
      "name": "Pick invoices due a reminder",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        260,
        160
      ]
    },
    {
      "parameters": {
        "sendTo": "={{ $json.Email }}",
        "subject": "={{ $json.emailSubject }}",
        "emailType": "text",
        "message": "={{ $json.emailBody }}",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "0880a694-2536-49cf-9a80-0c8ab3fc55bd",
      "name": "Send reminder email",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        500,
        160
      ],
      "credentials": {
        "gmailOAuth2": {
          "id": "REPLACE_WITH_YOUR_CREDENTIAL",
          "name": "Gmail account (connect your own)"
        }
      }
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "PASTE_YOUR_SPREADSHEET_ID_HERE"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Invoices"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "InvoiceNo": "={{ $json.InvoiceNo }}",
            "LastReminded": "={{ $json.todayISO }}"
          },
          "matchingColumns": [
            "InvoiceNo"
          ],
          "schema": []
        },
        "options": {}
      },
      "id": "7878f02f-b7ef-4338-bcdd-a48d7584fc42",
      "name": "Update LastReminded",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        740,
        160
      ],
      "credentials": {
        "googleSheetsOAuth2Api": {
          "id": "REPLACE_WITH_YOUR_CREDENTIAL",
          "name": "Google Sheets account (connect your own)"
        }
      }
    }
  ],
  "connections": {
    "Every day at 9am": {
      "main": [
        [
          {
            "node": "Read invoices sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read invoices sheet": {
      "main": [
        [
          {
            "node": "Pick invoices due a reminder",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick invoices due a reminder": {
      "main": [
        [
          {
            "node": "Send reminder email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send reminder email": {
      "main": [
        [
          {
            "node": "Update LastReminded",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "timezone": "Asia/Singapore"
  }
}
