{
  "id": "InvoiceFollowUps1",
  "name": "Invoice Follow-ups (synthetic demo)",
  "nodes": [
    {
      "id": "manual-trigger",
      "name": "Manual Trigger",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        0
      ],
      "parameters": {}
    },
    {
      "id": "daily-9am-(disabled)",
      "name": "Daily 9am (disabled)",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        200
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 9
            }
          ]
        }
      },
      "disabled": true
    },
    {
      "id": "run-config",
      "name": "Run Config",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        0
      ],
      "parameters": {
        "jsCode": "// \"Run Config\" Code node. In production, today = new Date(); it's pinned here so the demo is repeatable.\nreturn [{ json: { today: '2026-10-26', sender_name: 'Accounts team', company: 'Demo Co',\n  cooldown_days: 5, pre_due_days: 3, firm_after: 8, final_after: 22,\n  pay_link_base: 'https://pay.demo.example/inv/', owner_email: 'owner@demo.example' } }];\n"
      }
    },
    {
      "id": "read-invoices",
      "name": "Read Invoices",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1,
      "position": [
        440,
        0
      ],
      "parameters": {
        "fileSelector": "/workspace/portfolio/invoice-follow-ups/data/invoices.csv",
        "options": {}
      },
      "executeOnce": true
    },
    {
      "id": "parse-invoices",
      "name": "Parse Invoices",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1,
      "position": [
        660,
        0
      ],
      "parameters": {
        "options": {
          "headerRow": true
        }
      }
    },
    {
      "id": "classify-and-draft",
      "name": "Classify & Draft",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        0
      ],
      "parameters": {
        "jsCode": "// \"Classify & Draft\" Code node: decides the follow-up for every invoice and drafts the email text.\nconst C = $('Run Config').first().json;\nconst day = s => Math.round(new Date(s + 'T00:00:00Z').getTime() / 864e5);\nconst cents = v => Math.round(parseFloat(v || 0) * 100);\nconst money = (c, cur) => `${cur} ${(c / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`;\nconst today = day(C.today);\nconst T = {\n  PRE_DUE_NUDGE: (i, bal, d) => [`Friendly reminder: invoice ${i.invoice_id} is due ${i.due_date}`,\n    `Hi ${i.contact_name},\\n\\nJust a heads-up that invoice ${i.invoice_id} for ${bal} is due on ${i.due_date} (in ${-d} day${-d === 1 ? '' : 's'}). If it's already scheduled, please ignore this.`],\n  GENTLE: (i, bal, d) => [`Invoice ${i.invoice_id} is now due`,\n    `Hi ${i.contact_name},\\n\\nInvoice ${i.invoice_id} for ${bal} was due on ${i.due_date}${d ? ` (${d} day${d === 1 ? '' : 's'} ago)` : ' (today)'}. It may have slipped through, so here's the link again.`],\n  FIRM: (i, bal, d) => [`Overdue: invoice ${i.invoice_id} (${d} days past due)`,\n    `Hi ${i.contact_name},\\n\\nInvoice ${i.invoice_id} is now ${d} days past its due date of ${i.due_date}, with ${bal} outstanding. Could you let me know when we can expect payment, or if anything is holding it up?`],\n  FINAL: (i, bal, d) => [`Final reminder: invoice ${i.invoice_id} is ${d} days overdue`,\n    `Hi ${i.contact_name},\\n\\nThis is a final reminder that invoice ${i.invoice_id} is ${d} days overdue, with ${bal} outstanding. Please pay or reply this week so we can sort it out. If we don't hear back, our account owner will follow up directly.`],\n};\nconst out = [];\nfor (const i of $input.all().map(x => x.json)) {\n  const bal = cents(i.amount) - cents(i.amount_paid);\n  const dpd = today - day(i.due_date);\n  const sinceReminder = i.last_reminder_date ? today - day(i.last_reminder_date) : null;\n  let action, reason;\n  if (i.status === 'paid' || bal <= 0) { action = 'SKIP_PAID'; reason = 'Fully paid'; }\n  else if (i.status === 'disputed') { action = 'HOLD_DISPUTED'; reason = 'In dispute: no automated reminder, flagged for a person'; }\n  else if (dpd < -C.pre_due_days) { action = 'NOT_DUE'; reason = `Due in ${-dpd} days`; }\n  else if (sinceReminder !== null && sinceReminder < C.cooldown_days) { action = 'COOLDOWN'; reason = `Reminded ${sinceReminder} day(s) ago (cooldown ${C.cooldown_days})`; }\n  else if (dpd < 0) { action = 'PRE_DUE_NUDGE'; reason = `Due in ${-dpd} day(s)`; }\n  else if (dpd < C.firm_after) { action = 'GENTLE'; reason = `${dpd} day(s) past due`; }\n  else if (dpd < C.final_after) { action = 'FIRM'; reason = `${dpd} days past due`; }\n  else { action = 'FINAL'; reason = `${dpd} days past due: final notice + owner escalation`; }\n  const row = { invoice_id: i.invoice_id, customer: i.customer, contact_email: i.contact_email, due_date: i.due_date,\n    days_past_due: dpd, balance: (bal / 100).toFixed(2), partial: cents(i.amount_paid) > 0 && bal > 0 ? 'yes' : 'no',\n    action, send_email: T[action] ? 'yes' : 'no', escalate: ['FINAL', 'HOLD_DISPUTED'].includes(action) ? 'yes' : 'no', reason,\n    subject: '', body: '' };\n  if (T[action]) {\n    const [subject, intro] = T[action](i, money(bal, i.currency), dpd);\n    const partialLine = row.partial === 'yes' ? `\\n\\nThanks for the ${money(cents(i.amount_paid), i.currency)} already received; the figure above is the remaining balance.` : '';\n    row.subject = subject;\n    row.body = `${intro}${partialLine}\\n\\nPay online: ${C.pay_link_base}${i.invoice_id}\\n\\nThanks,\\n${C.sender_name}\\n${C.company}`;\n  }\n  out.push({ json: row });\n}\nreturn out;\n"
      }
    },
    {
      "id": "action-log-to-csv",
      "name": "Action Log to CSV",
      "type": "n8n-nodes-base.convertToFile",
      "typeVersion": 1.1,
      "position": [
        1100,
        -200
      ],
      "parameters": {
        "options": {
          "fileName": "action_log.csv"
        }
      }
    },
    {
      "id": "write-action-log",
      "name": "Write Action Log",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1,
      "position": [
        1320,
        -200
      ],
      "parameters": {
        "operation": "write",
        "fileName": "/workspace/portfolio/invoice-follow-ups/output/action_log.csv",
        "options": {}
      }
    },
    {
      "id": "build-outbox",
      "name": "Build Outbox",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        0
      ],
      "parameters": {
        "jsCode": "// \"Build Outbox\" Code node: one .eml-style text file per drafted email. Swap the Write node for a Gmail/Outlook node to really send.\nconst C = $('Run Config').first().json;\nreturn $input.all().map(x => x.json).filter(r => r.send_email === 'yes').map(r => {\n  const cc = r.escalate === 'yes' ? `Cc: ${C.owner_email}\\n` : '';\n  const text = `To: ${r.contact_email}\\n${cc}Subject: ${r.subject}\\nX-Demo: synthetic data, not sent\\n\\n${r.body}\\n`;\n  const fileName = `${r.invoice_id}_${r.action}.eml.txt`;\n  return { json: { invoice_id: r.invoice_id, fileName },\n    binary: { data: { data: Buffer.from(text).toString('base64'), mimeType: 'text/plain', fileName, fileExtension: 'txt' } } };\n});\n"
      }
    },
    {
      "id": "write-email-drafts",
      "name": "Write Email Drafts",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1,
      "position": [
        1320,
        0
      ],
      "parameters": {
        "operation": "write",
        "fileName": "=/workspace/portfolio/invoice-follow-ups/output/outbox/{{ $json.fileName }}",
        "options": {}
      }
    },
    {
      "id": "build-summary",
      "name": "Build Summary",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        200
      ],
      "parameters": {
        "jsCode": "// \"Build Summary\" Code node: daily digest for the account owner.\nconst C = $('Run Config').first().json;\nconst rows = $input.all().map(i => i.json);\nconst by = {}; rows.forEach(r => by[r.action] = (by[r.action] || 0) + 1);\nconst owed = rows.filter(r => !['SKIP_PAID'].includes(r.action)).reduce((s, r) => s + parseFloat(r.balance), 0);\nconst lines = [`# Invoice follow-up run for ${C.today} (SYNTHETIC DEMO DATA)`, '',\n  `Invoices checked: ${rows.length} | Emails drafted: ${rows.filter(r => r.send_email === 'yes').length} | Need a person: ${rows.filter(r => r.escalate === 'yes').length}`,\n  `Open balance across unpaid invoices: USD ${owed.toFixed(2)}`, '', '## Counts by action', ...Object.entries(by).map(([k, v]) => `- ${k}: ${v}`), '',\n  '## Needs a person', ...rows.filter(r => r.escalate === 'yes').map(r => `- ${r.invoice_id} ${r.customer}: ${r.reason} (balance ${r.balance})`), '',\n  '## Emails drafted', ...rows.filter(r => r.send_email === 'yes').map(r => `- ${r.invoice_id} \u2192 ${r.contact_email}: \"${r.subject}\"`)];\nconst text = lines.join('\\n') + '\\n';\nreturn [{ json: { summary: text, counts: by },\n  binary: { data: { data: Buffer.from(text).toString('base64'), mimeType: 'text/markdown', fileName: 'summary.md', fileExtension: 'md' } } }];\n"
      }
    },
    {
      "id": "write-summary",
      "name": "Write Summary",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1,
      "position": [
        1320,
        200
      ],
      "parameters": {
        "operation": "write",
        "fileName": "/workspace/portfolio/invoice-follow-ups/output/summary.md",
        "options": {}
      }
    },
    {
      "id": "going-live",
      "name": "Going live",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1540,
        -60
      ],
      "parameters": {
        "content": "## Going live (not wired in the demo)\n- Replace **Read Invoices** with Stripe / QuickBooks / Xero / Google Sheets\n- Replace **Write Email Drafts** with a Gmail or Outlook node\n- Post **summary.md** to Slack\n- Enable the daily schedule trigger\n- Optional: an LLM node to adjust tone per customer. Left out so the demo needs no key.",
        "width": 400,
        "height": 230
      }
    }
  ],
  "connections": {
    "Manual Trigger": {
      "main": [
        [
          {
            "node": "Run Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Daily 9am (disabled)": {
      "main": [
        [
          {
            "node": "Run Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run Config": {
      "main": [
        [
          {
            "node": "Read Invoices",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Invoices": {
      "main": [
        [
          {
            "node": "Parse Invoices",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Invoices": {
      "main": [
        [
          {
            "node": "Classify & Draft",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Action Log to CSV": {
      "main": [
        [
          {
            "node": "Write Action Log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Outbox": {
      "main": [
        [
          {
            "node": "Write Email Drafts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Summary": {
      "main": [
        [
          {
            "node": "Write Summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Classify & Draft": {
      "main": [
        [
          {
            "node": "Action Log to CSV",
            "type": "main",
            "index": 0
          },
          {
            "node": "Build Outbox",
            "type": "main",
            "index": 0
          },
          {
            "node": "Build Summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {}
}