Overdue Invoice Chaser
Module 21 · business · intermediate
starter.json
In n8n, copy this file and paste it onto the canvas, or use Import from File.
{
"name": "21 · Overdue Invoice Chaser (starter)",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 9 * * 1-5"
}
]
}
},
"id": "m21-weekdays-9-am",
"name": "Weekdays 9 AM",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
0,
0
]
},
{
"parameters": {
"httpMethod": "POST",
"path": "ar/run",
"responseMode": "responseNode",
"options": {}
},
"id": "m21-chase-run-api",
"name": "Chase Run API",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
180
],
"webhookId": "course-21-run"
},
{
"parameters": {
"httpMethod": "POST",
"path": "ar/pause",
"responseMode": "responseNode",
"options": {}
},
"id": "m21-pause-api",
"name": "Pause API",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
400
],
"webhookId": "course-21-pause"
},
{
"parameters": {
"httpMethod": "GET",
"path": "ar/status",
"responseMode": "responseNode",
"options": {}
},
"id": "m21-status-api",
"name": "Status API",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
580
],
"webhookId": "course-21-status"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "s1",
"name": "business_name",
"value": "Northwind Studio",
"type": "string"
},
{
"id": "s2",
"name": "from_name",
"value": "Ava Chen",
"type": "string"
},
{
"id": "s3",
"name": "owner_email",
"value": "owner@northwind.example",
"type": "string"
},
{
"id": "s4",
"name": "currency",
"value": "usd",
"type": "string"
},
{
"id": "s5",
"name": "timezone",
"value": "America/New_York",
"type": "string"
},
{
"id": "s6",
"name": "stages",
"value": "={{ [ { key: 'upcoming', day: -3 }, { key: 'due', day: 0 }, { key: 'overdue_1', day: 3 }, { key: 'overdue_2', day: 10 }, { key: 'final', day: 21 }, { key: 'escalate', day: 30 } ] }}",
"type": "array"
},
{
"id": "s7",
"name": "min_gap_days",
"value": 3,
"type": "number"
},
{
"id": "s8",
"name": "min_amount",
"value": 5,
"type": "number"
},
{
"id": "s9",
"name": "do_not_chase",
"value": "={{ ['bigretail.example', 'friend@example.com'] }}",
"type": "array"
},
{
"id": "s10",
"name": "pay_instructions",
"value": "Bank transfer details are on the invoice; reply to this email if you need them again.",
"type": "string"
},
{
"id": "s11",
"name": "final_note",
"value": "after which we may pause ongoing work",
"type": "string"
},
{
"id": "s12",
"name": "summary_day",
"value": "Mon",
"type": "string"
}
]
},
"includeOtherFields": true,
"options": {}
},
"id": "m21-settings-edit-me",
"name": "Settings (edit me)",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
240,
280
]
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r1",
"leftValue": "={{ $('Weekdays 9 AM').isExecuted || $('Chase Run API').isExecuted }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "run"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r2",
"leftValue": "={{ $('Pause API').isExecuted }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "pause"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r3",
"leftValue": "={{ $('Status API').isExecuted }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "status"
}
]
},
"options": {}
},
"id": "m21-route",
"name": "Route",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [
460,
280
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $('Chase Run API').isExecuted && $('Chase Run API').first().json.body.source !== 'stripe' }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m21-invoices-from-api",
"name": "Invoices from API?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
700,
60
]
},
{
"parameters": {
"url": "https://api.stripe.com/v1/invoices",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "stripeApi",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "status",
"value": "open"
},
{
"name": "collection_method",
"value": "send_invoice"
},
{
"name": "limit",
"value": "100"
}
]
},
"options": {}
},
"id": "m21-get-stripe-invoices",
"name": "Get Stripe Invoices",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
920,
160
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "// One clean invoice list, whatever the source: simple JSON from the API, raw Stripe invoices\n// from the API (for testing the mapping), or the Stripe REST response (GET /v1/invoices).\nconst viaApi = $('Chase Run API').isExecuted;\nconst body = viaApi ? $('Chase Run API').first().json.body ?? {} : {};\nconst fromStripeHttp = $('Get Stripe Invoices').isExecuted;\nconst http = fromStripeHttp ? $('Get Stripe Invoices').first().json : null;\n\nconst fromStripe = (inv) => ({\n invoice_id: inv.id,\n number: inv.number ?? inv.id,\n customer: inv.customer_name ?? inv.customer_email ?? 'customer',\n email: (inv.customer_email ?? '').toLowerCase(),\n amount_remaining: (inv.amount_remaining ?? inv.amount_due ?? 0) / 100, // Stripe amounts are in cents\n currency: (inv.currency ?? 'usd').toLowerCase(),\n due_date: inv.due_date ? new Date(inv.due_date * 1000).toISOString().slice(0, 10) : null,\n status: inv.status,\n pay_url: inv.hosted_invoice_url ?? '',\n source: 'stripe',\n});\nconst fromSimple = (inv) => ({\n invoice_id: String(inv.id ?? inv.invoice_id ?? ''),\n number: String(inv.number ?? inv.id ?? ''),\n customer: inv.customer ?? inv.email ?? 'customer',\n email: String(inv.email ?? '').trim().toLowerCase(),\n amount_remaining: Number(inv.amount_remaining ?? inv.amount ?? 0),\n currency: String(inv.currency ?? $('Settings (edit me)').first().json.currency).toLowerCase(),\n due_date: inv.due_date ?? null,\n status: inv.status ?? 'open',\n pay_url: inv.pay_url ?? '',\n source: 'api',\n});\n\nlet invoices = [], source_error = null, has_more = false;\nif (fromStripeHttp) {\n if (Array.isArray(http.data)) { invoices = http.data.map(fromStripe); has_more = Boolean(http.has_more); }\n else source_error = String(http.error?.message ?? http.error ?? 'Stripe returned no invoice list (check the Stripe credential)').slice(0, 300);\n} else {\n invoices = [...(body.invoices ?? []).map(fromSimple), ...(body.stripe_invoices ?? []).map(fromStripe)];\n}\n\nreturn [{\n json: {\n invoices: invoices.filter((i) => i.invoice_id),\n source: fromStripeHttp ? 'stripe' : 'api',\n source_error, has_more,\n now: body.now || new Date().toISOString(), // tests inject the clock\n dry_run: body.dry_run === true,\n record_only: body.record_only === true, // mark reminders as sent without emailing (first-time import, or already chased by hand)\n force_summary: body.summary === true,\n via_api: viaApi,\n },\n}];\n"
},
"id": "m21-normalize-invoices",
"name": "Normalize Invoices",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1140,
60
]
},
{
"parameters": {
"content": "## Your task\nInvoices are loaded and normalized (try a dry run with `invoices` in the body).\n1. **Plan Reminders** (Code): days past due in your timezone → latest due stage only (catch-up), skip paid/small/no date/do-not-chase/paused, 3-day gap, final notice before escalation; compose each email.\n2. **Send Now?** → **Gmail** (On Error: Continue).\n3. **Record and Summarize**: mark a stage sent only with a Gmail id; aging buckets + summary text.\n4. Respond (API) and email the A/R summary on Mondays.\n5. Pause and status endpoints.\n\nTest: `./test/test.sh`",
"height": 260,
"width": 420
},
"id": "starter-task-note",
"name": "Your Task",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
0,
-340
]
}
],
"connections": {
"Weekdays 9 AM": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Chase Run API": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Pause API": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Status API": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Settings (edit me)": {
"main": [
[
{
"node": "Route",
"type": "main",
"index": 0
}
]
]
},
"Route": {
"main": [
[
{
"node": "Invoices from API?",
"type": "main",
"index": 0
}
],
[],
[]
]
},
"Invoices from API?": {
"main": [
[
{
"node": "Normalize Invoices",
"type": "main",
"index": 0
}
],
[
{
"node": "Get Stripe Invoices",
"type": "main",
"index": 0
}
]
]
},
"Get Stripe Invoices": {
"main": [
[
{
"node": "Normalize Invoices",
"type": "main",
"index": 0
}
]
]
},
"Normalize Invoices": {
"main": [
[]
]
}
},
"settings": {
"executionOrder": "v1"
},
"pinData": {}
}