Overdue Invoice Chaser
Module 21 · business · intermediate
learner.json
In n8n, copy this file and paste it onto the canvas, or use Import from File.
{
"name": "21 · Overdue Invoice Chaser",
"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": {
"jsCode": "// Decide, per open invoice, whether a reminder goes out today and which one.\n// Catch-up rule: if runs were missed, send only the latest stage that is due (never a burst of old ones).\nconst s = $('Settings (edit me)').first().json;\nconst { invoices, now, dry_run, record_only } = $input.first().json;\nconst ar = $getWorkflowStaticData('global').ar ?? {};\nconst today = DateTime.fromISO(now).setZone(s.timezone).startOf('day');\nconst money = (n, cur) => new Intl.NumberFormat('en-US', { style: 'currency', currency: cur.toUpperCase() }).format(n);\nconst nice = (d) => DateTime.fromISO(d).toFormat('LLLL d, yyyy');\nconst stages = [...s.stages].sort((a, b) => a.day - b.day);\n\nfunction compose(inv, stage, dpd) {\n const amt = money(inv.amount_remaining, inv.currency);\n const first = inv.customer.split(' ')[0];\n const pay = inv.pay_url ? `You can pay online here: ${inv.pay_url}` : s.pay_instructions;\n const sign = `\\n\\nThanks,\\n${s.from_name}\\n${s.business_name}`;\n const T = {\n upcoming: [`Upcoming: invoice ${inv.number} is due ${nice(inv.due_date)}`,\n `Hi ${first},\\n\\nA friendly heads-up that invoice ${inv.number} for ${amt} is due on ${nice(inv.due_date)}.\\n\\n${pay}`],\n due: [`Invoice ${inv.number} is due today`,\n `Hi ${first},\\n\\nJust a reminder that invoice ${inv.number} for ${amt} is due today.\\n\\n${pay}\\n\\nIf you've already paid, thank you, and please ignore this.`],\n overdue_1: [`Invoice ${inv.number} is now past due`,\n `Hi ${first},\\n\\nOur records show invoice ${inv.number} for ${amt} was due on ${nice(inv.due_date)} and is still open. It's easy to miss one, so here it is again.\\n\\n${pay}\\n\\nIf there's a problem with the invoice, just reply and let me know.`],\n overdue_2: [`Second reminder: invoice ${inv.number} (${dpd} days overdue)`,\n `Hi ${first},\\n\\nInvoice ${inv.number} for ${amt} is now ${dpd} days overdue. Could you let me know when we can expect payment?\\n\\n${pay}\\n\\nIf something is holding it up (a PO number, a question about the work), reply and I'll sort it out.`],\n final: [`Final notice: invoice ${inv.number} (${dpd} days overdue)`,\n `Hi ${first},\\n\\nThis is a final reminder that invoice ${inv.number} for ${amt} is ${dpd} days overdue. Please arrange payment within 7 days${s.final_note ? ', ' + s.final_note : ''}.\\n\\n${pay}\\n\\nIf you've already paid or need a payment plan, reply to this email.`],\n };\n if (stage === 'escalate')\n return { to: s.owner_email, cc: '', internal: true,\n subject: `Escalate: ${inv.customer} owes ${amt} (${inv.number}, ${dpd} days overdue)`,\n body: `${inv.customer} <${inv.email}> hasn't paid invoice ${inv.number} for ${amt}, due ${nice(inv.due_date)} (${dpd} days ago).\\nAll customer reminders have been sent, including the final notice.\\n\\nSuggested next step: call them, offer a payment plan, or pause services.\\nPause chasing: POST /webhook/ar/pause {\"invoice_id\":\"${inv.invoice_id}\",\"reason\":\"called\"}` };\n const [subject, body] = T[stage];\n return { to: inv.email, cc: stage === 'final' ? s.owner_email : '', internal: false, subject, body: body + sign };\n}\n\nconst decisions = [], sends = [];\nfor (const inv of invoices) {\n const led = ar[inv.invoice_id] ?? { sent: {} };\n const base = { invoice_id: inv.invoice_id, number: inv.number, customer: inv.customer, amount_remaining: inv.amount_remaining, currency: inv.currency, due_date: inv.due_date };\n const due = inv.due_date ? DateTime.fromISO(inv.due_date, { zone: s.timezone }).startOf('day') : null;\n const dpd = due ? Math.round(today.diff(due, 'days').days) : null;\n const skip = (reason) => decisions.push({ ...base, days_past_due: dpd, action: 'skip', reason });\n const domain = inv.email.split('@')[1] ?? '';\n\n if (inv.status !== 'open') { skip(`status ${inv.status}`); continue; }\n if (inv.amount_remaining < s.min_amount) { skip('balance below minimum'); continue; }\n if (!due) { skip('no due date'); continue; }\n if (s.do_not_chase.some((d) => d === inv.email || d === domain)) { skip('on do-not-chase list'); continue; }\n if (led.paused_until === 'indefinite' || (led.paused_until && led.paused_until >= today.toISODate())) { skip(`paused: ${led.pause_reason ?? 'no reason'}${led.paused_until !== 'indefinite' ? ' until ' + led.paused_until : ''}`); continue; }\n\n const dueStages = stages.filter((st) => st.day <= dpd);\n let latest = dueStages[dueStages.length - 1];\n if (!latest) { decisions.push({ ...base, days_past_due: dpd, action: 'wait', reason: 'not due for a reminder yet' }); continue; }\n // Escalate to the owner only after the customer had the final notice and 7 days to act on it.\n if (latest.key === 'escalate' && !led.sent.escalate) {\n if (!led.sent.final) latest = stages.find((st) => st.key === 'final');\n else if (today.diff(DateTime.fromISO(led.sent.final).setZone(s.timezone).startOf('day'), 'days').days < 7) {\n decisions.push({ ...base, days_past_due: dpd, action: 'wait', reason: 'final notice sent under 7 days ago' }); continue;\n }\n }\n if (led.sent[latest.key]) { decisions.push({ ...base, days_past_due: dpd, action: 'wait', reason: `${latest.key} already sent` }); continue; }\n if (latest.key !== 'escalate' && !inv.email) { skip('no customer email'); continue; }\n const lastCustomer = Object.entries(led.sent).filter(([k]) => k !== 'escalate').map(([, v]) => v).sort().pop();\n if (latest.key !== 'escalate' && lastCustomer && today.diff(DateTime.fromISO(lastCustomer).setZone(s.timezone).startOf('day'), 'days').days < s.min_gap_days) {\n decisions.push({ ...base, days_past_due: dpd, action: 'wait', reason: `last reminder under ${s.min_gap_days} days ago` }); continue;\n }\n const skipped = dueStages.filter((st) => st.day < latest.day && !led.sent[st.key]).map((st) => st.key);\n const msg = compose(inv, latest.key, dpd);\n decisions.push({ ...base, days_past_due: dpd, action: 'send', stage: latest.key, skipped_stages: skipped });\n sends.push({ json: { ...base, days_past_due: dpd, stage: latest.key, skipped_stages: skipped, ...msg, dry_run, record_only } });\n}\n\nif (!sends.length) return [{ json: { no_reminders: true, _plan: decisions } }];\nsends[0].json._plan = decisions; // the full plan rides on the first item for the report\nreturn sends;\n"
},
"id": "m21-plan-reminders",
"name": "Plan Reminders",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1360,
60
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ Boolean($json.invoice_id) && !$json.dry_run && !$json.record_only }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m21-send-now",
"name": "Send Now?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1580,
60
]
},
{
"parameters": {
"sendTo": "={{ $json.to }}",
"subject": "={{ $json.subject }}",
"emailType": "text",
"message": "={{ $json.body }}",
"options": {
"appendAttribution": false,
"senderName": "={{ $('Settings (edit me)').first().json.from_name }}",
"ccList": "={{ $json.cc }}"
}
},
"id": "m21-send-reminder",
"name": "Send Reminder",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2.1,
"position": [
1800,
-20
],
"onError": "continueRegularOutput",
"webhookId": "course-21-gmail-send"
},
{
"parameters": {
"jsCode": "// Save what was sent (a send counts only with a Gmail message id) and build the A/R summary.\nconst s = $('Settings (edit me)').first().json;\nconst src = $('Normalize Invoices').first().json;\nconst plan = $('Plan Reminders').first().json._plan ?? [];\nconst db = $getWorkflowStaticData('global');\ndb.ar ??= {};\n\nconst results = [];\n$input.all().forEach((item, i) => {\n // Gmail's response ({ id, threadId } or { error }) has no invoice_id. Without a credential the node passes\n // the plan item through unchanged, so that counts as not sent.\n const fromGmail = !item.json.invoice_id && !item.json.no_reminders;\n const x = fromGmail ? $('Plan Reminders').itemMatching(i).json : item.json;\n if (!x.invoice_id) return; // the \"no reminders\" marker\n const willSend = !x.dry_run && !x.record_only;\n const ok = x.record_only ? !x.dry_run : fromGmail && Boolean(item.json.id);\n const error = willSend && !ok ? String(item.json.error?.message ?? item.json.error ?? 'no message id returned (check the Gmail credential)').slice(0, 300) : null;\n if (willSend || (x.record_only && !x.dry_run)) {\n const led = (db.ar[x.invoice_id] ??= { sent: {} });\n if (ok) { led.sent[x.stage] = src.now; led.last_error = null; }\n else led.last_error = { stage: x.stage, at: src.now, error }; // not marked sent → retried next run\n }\n results.push({ invoice_id: x.invoice_id, number: x.number, customer: x.customer, stage: x.stage, to: x.to, cc: x.cc || null,\n internal: x.internal, subject: x.subject, body: x.dry_run ? x.body : undefined, skipped_stages: x.skipped_stages, sent: ok && willSend, recorded_only: Boolean(x.record_only), error });\n});\n\n// Aging buckets (by days past due) per currency\nconst buckets = {};\nconst today = DateTime.fromISO(src.now).setZone(s.timezone).startOf('day');\nfor (const inv of src.invoices.filter((v) => v.status === 'open' && v.amount_remaining > 0)) {\n const dpd = inv.due_date ? Math.round(today.diff(DateTime.fromISO(inv.due_date, { zone: s.timezone }).startOf('day'), 'days').days) : 0;\n const b = dpd <= 0 ? 'current' : dpd <= 30 ? '1-30' : dpd <= 60 ? '31-60' : dpd <= 90 ? '61-90' : '90+';\n const cur = (buckets[inv.currency] ??= { current: 0, '1-30': 0, '31-60': 0, '61-90': 0, '90+': 0, total: 0, overdue: 0 });\n cur[b] = Math.round((cur[b] + inv.amount_remaining) * 100) / 100;\n cur.total = Math.round((cur.total + inv.amount_remaining) * 100) / 100;\n if (dpd > 0) cur.overdue = Math.round((cur.overdue + inv.amount_remaining) * 100) / 100;\n}\nconst money = (n, c) => new Intl.NumberFormat('en-US', { style: 'currency', currency: c.toUpperCase() }).format(n);\nconst topOverdue = plan.filter((p) => p.days_past_due > 0 && p.amount_remaining > 0 && !(p.reason ?? '').startsWith('status')).sort((a, b) => b.amount_remaining - a.amount_remaining).slice(0, 5);\nconst summaryText = [\n `A/R summary for ${s.business_name}, ${today.toFormat('ccc LLL d, yyyy')}`, '',\n ...Object.entries(buckets).map(([c, b]) => `${c.toUpperCase()}: ${money(b.total, c)} open, ${money(b.overdue, c)} overdue (1-30: ${money(b['1-30'], c)} · 31-60: ${money(b['31-60'], c)} · 61-90: ${money(b['61-90'], c)} · 90+: ${money(b['90+'], c)})`),\n '', 'Largest overdue:', ...(topOverdue.length ? topOverdue.map((p) => `- ${p.customer}: ${money(p.amount_remaining, p.currency)}, ${p.number}, ${p.days_past_due} days`) : ['- none']),\n '', `Reminders this run: ${results.filter((r) => r.sent).length} sent, ${results.filter((r) => r.recorded_only && !src.dry_run).length} recorded without email, ${results.filter((r) => r.error).length} failed${src.dry_run ? ' (dry run)' : ''}.`,\n ...plan.filter((p) => (p.reason ?? '').startsWith('paused') || p.reason === 'no customer email').map((p) => `- ${p.number} (${p.customer}): ${p.reason}`),\n].join('\\n');\n\nreturn [{\n json: {\n run_at: src.now, source: src.source, source_error: src.source_error, has_more: src.has_more, dry_run: src.dry_run,\n open_invoices: src.invoices.length, reminders: results, plan, aging: buckets,\n summary_due: src.force_summary || today.toFormat('ccc') === s.summary_day,\n summary_subject: `A/R summary: ${Object.entries(buckets).map(([c, b]) => money(b.overdue, c) + ' overdue').join(', ') || 'nothing open'}`,\n summary_text: summaryText, via_api: src.via_api,\n },\n}];\n"
},
"id": "m21-record-and-summarize",
"name": "Record and Summarize",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2020,
60
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.via_api }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m21-called-via-api",
"name": "Called via API?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
2240,
-40
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"id": "m21-respond-run",
"name": "Respond Run",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
2460,
-40
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.summary_due && !$json.dry_run }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m21-summary-day",
"name": "Summary Day?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
2240,
160
]
},
{
"parameters": {
"sendTo": "={{ $('Settings (edit me)').first().json.owner_email }}",
"subject": "={{ $json.summary_subject }}",
"emailType": "text",
"message": "={{ $json.summary_text }}",
"options": {
"appendAttribution": false
}
},
"id": "m21-email-a/r-summary",
"name": "Email A/R Summary",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2.1,
"position": [
2460,
160
],
"onError": "continueRegularOutput",
"webhookId": "course-21-gmail-summary"
},
{
"parameters": {
"jsCode": "// Pause (or resume) chasing one invoice: a promise to pay, a dispute, or \"I called them\".\nconst b = $('Pause API').first().json.body ?? {};\nconst id = String(b.invoice_id ?? '').trim();\nif (!id) return [{ json: { ok: false, error: 'invoice_id is required' } }];\nconst db = $getWorkflowStaticData('global');\ndb.ar ??= {};\nconst led = (db.ar[id] ??= { sent: {} });\nif (b.resume === true) { led.paused_until = null; led.pause_reason = null; }\nelse { led.paused_until = b.until ? String(b.until).slice(0, 10) : 'indefinite'; led.pause_reason = String(b.reason ?? 'paused'); }\nreturn [{ json: { ok: true, invoice_id: id, paused_until: led.paused_until, reason: led.pause_reason, sent: led.sent } }];\n"
},
"id": "m21-pause-invoice",
"name": "Pause Invoice",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
700,
400
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {
"responseCode": "={{ $json.ok ? 200 : 400 }}"
}
},
"id": "m21-respond-pause",
"name": "Respond Pause",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
920,
400
]
},
{
"parameters": {
"jsCode": "// Read-only view of the reminder history for one invoice.\nconst id = String($('Status API').first().json.query?.invoice_id ?? '');\nconst led = ($getWorkflowStaticData('global').ar ?? {})[id];\nreturn [{ json: led ? { found: true, invoice_id: id, ...led } : { found: false, invoice_id: id } }];\n"
},
"id": "m21-invoice-status",
"name": "Invoice Status",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
700,
580
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {
"responseCode": "={{ $json.found ? 200 : 404 }}"
}
},
"id": "m21-respond-status",
"name": "Respond Status",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
920,
580
]
}
],
"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
}
],
[
{
"node": "Pause Invoice",
"type": "main",
"index": 0
}
],
[
{
"node": "Invoice Status",
"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": [
[
{
"node": "Plan Reminders",
"type": "main",
"index": 0
}
]
]
},
"Plan Reminders": {
"main": [
[
{
"node": "Send Now?",
"type": "main",
"index": 0
}
]
]
},
"Send Now?": {
"main": [
[
{
"node": "Send Reminder",
"type": "main",
"index": 0
}
],
[
{
"node": "Record and Summarize",
"type": "main",
"index": 0
}
]
]
},
"Send Reminder": {
"main": [
[
{
"node": "Record and Summarize",
"type": "main",
"index": 0
}
]
]
},
"Record and Summarize": {
"main": [
[
{
"node": "Called via API?",
"type": "main",
"index": 0
},
{
"node": "Summary Day?",
"type": "main",
"index": 0
}
]
]
},
"Called via API?": {
"main": [
[
{
"node": "Respond Run",
"type": "main",
"index": 0
}
]
]
},
"Summary Day?": {
"main": [
[
{
"node": "Email A/R Summary",
"type": "main",
"index": 0
}
]
]
},
"Pause Invoice": {
"main": [
[
{
"node": "Respond Pause",
"type": "main",
"index": 0
}
]
]
},
"Invoice Status": {
"main": [
[
{
"node": "Respond Status",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"pinData": {}
}