Skip to content

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": {}
}