{
  "name": "Daily owner report from Xero (The Gantry blueprint)",
  "nodes": [
    {
      "parameters": {
        "content": "## Daily owner report from Xero\nBlueprint by The Gantry (gantry.work).\n\n1. Fill in **Settings**: who gets the report (comma-separate several), and your Xero organisation name if you have more than one.\n2. Create one Xero credential and select it on all four Xero steps (the payments step uses the same credential through the HTTP Request node).\n3. Connect Gmail on **Email the report**.\n4. Optional: enable **Telegram line** and put your chat ID in Settings.\n5. Test once, then activate. It runs 8am Singapore time, Monday to Friday. Monday's report covers Friday to Sunday.\n\nTo re-send a past day, put that date (YYYY-MM-DD) in reportDate and click Test workflow. Clear it afterwards.",
        "height": 340,
        "width": 460
      },
      "id": "6a59e436-b28a-5cd6-b31b-fa6381e051f5",
      "name": "Blueprint notes",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -80,
        -100
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 8 * * 1-5"
            }
          ]
        }
      },
      "id": "cfd3e041-589d-5988-bfae-7155a990943a",
      "name": "Weekdays 8am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        300
      ]
    },
    {
      "parameters": {
        "mode": "manual",
        "assignments": {
          "assignments": [
            {
              "id": "7552fe06-991d-52c0-beec-e5a0d35348fb",
              "name": "companyName",
              "value": "",
              "type": "string"
            },
            {
              "id": "fb82cbc2-e7e0-5d6a-808c-9ec873cf6a91",
              "name": "reportTo",
              "value": "owner@example.com",
              "type": "string"
            },
            {
              "id": "ba4cdf0e-6130-502e-a6a6-08b91a699980",
              "name": "xeroOrganisation",
              "value": "",
              "type": "string"
            },
            {
              "id": "33823276-0343-59bd-a427-fc80403edc68",
              "name": "baseCurrency",
              "value": "SGD",
              "type": "string"
            },
            {
              "id": "1ae5a440-8d2e-5cd7-aa31-54052b8496a9",
              "name": "topCustomers",
              "value": "5",
              "type": "string"
            },
            {
              "id": "53bf03d6-76f0-5872-816f-79aaf534a0a1",
              "name": "telegramChatId",
              "value": "",
              "type": "string"
            },
            {
              "id": "50c61063-8a82-51fa-b89b-0b812a8b40f7",
              "name": "reportDate",
              "value": "",
              "type": "string"
            },
            {
              "id": "44792908-4490-5cba-aa52-0b6c8c40169c",
              "name": "timezone",
              "value": "Asia/Singapore",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "93d41708-1d35-5837-851e-b7a513b84485",
      "name": "Settings",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        300
      ]
    },
    {
      "parameters": {
        "url": "https://api.xero.com/connections",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "xeroOAuth2Api",
        "options": {}
      },
      "id": "0cf8e21d-a867-580f-a975-75a622ff4f19",
      "name": "Get Xero organisation",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        440,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// Picks the Xero organisation and works out which days this report covers.\n// Tuesday to Friday: yesterday. Monday: Friday, Saturday and Sunday, so nothing falls between reports.\nconst s = $('Settings').first().json;\nconst tz = s.timezone || 'Asia/Singapore';\nconst orgs = $input.all().map(i => i.json).filter(o => o.tenantId && (!o.tenantType || o.tenantType === 'ORGANISATION'));\nif (!orgs.length) throw new Error('No Xero organisation is connected to this Xero credential.');\nconst want = String(s.xeroOrganisation || '').trim().toLowerCase();\nconst org = want ? orgs.find(o => String(o.tenantName || '').trim().toLowerCase() === want) : orgs[0];\nif (!org) throw new Error(`No connected Xero organisation is called \"${s.xeroOrganisation}\". Connected: ${orgs.map(o => o.tenantName).join(', ')}`);\n\nconst todayStr = /^\\d{4}-\\d{2}-\\d{2}$/.test(String(s.reportDate || '')) ? s.reportDate\n  : new Intl.DateTimeFormat('en-CA', { timeZone: tz }).format(new Date());\nconst today = new Date(todayStr + 'T00:00:00Z');\nconst back = today.getUTCDay() === 1 ? 3 : 1;\nconst addDays = (d, n) => { const x = new Date(d); x.setUTCDate(x.getUTCDate() + n); return x; };\nconst from = addDays(today, -back), to = addDays(today, -1);\nconst iso = d => d.toISOString().slice(0, 10);\nconst xdt = d => `DateTime(${d.getUTCFullYear()}, ${String(d.getUTCMonth() + 1).padStart(2, '0')}, ${String(d.getUTCDate()).padStart(2, '0')})`;\nconst label = d => ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d.getUTCDay()] + ' ' + d.getUTCDate() + ' ' +\n  ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][d.getUTCMonth()];\nreturn [{ json: {\n  tenantId: org.tenantId,\n  orgName: s.companyName || org.tenantName,\n  today: todayStr,\n  from: iso(from),\n  to: iso(to),\n  periodLabel: back === 1 ? label(from) : `${label(from)} to ${label(to)}`,\n  whereRaised: `Type==\"ACCREC\" AND Date>=${xdt(from)} AND Date<${xdt(today)}`,\n  wherePayments: `Date>=${xdt(from)} AND Date<${xdt(today)}`,\n}}];"
      },
      "id": "c4b03def-c6ee-5ab3-8ec9-a1ea6e9a29fb",
      "name": "Work out dates",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        660,
        300
      ]
    },
    {
      "parameters": {
        "resource": "invoice",
        "operation": "getAll",
        "organizationId": "={{ $json.tenantId }}",
        "returnAll": true,
        "options": {
          "statuses": [
            "AUTHORISED",
            "PAID"
          ],
          "where": "={{ $json.whereRaised }}"
        }
      },
      "id": "31a9ab25-87b0-5706-b968-cbffa0eea612",
      "name": "Get invoices raised",
      "type": "n8n-nodes-base.xero",
      "typeVersion": 1,
      "position": [
        880,
        300
      ],
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "url": "https://api.xero.com/api.xro/2.0/Payments",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "xeroOAuth2Api",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "where",
              "value": "={{ $('Work out dates').first().json.wherePayments }}"
            }
          ]
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Xero-tenant-id",
              "value": "={{ $('Work out dates').first().json.tenantId }}"
            },
            {
              "name": "Accept",
              "value": "application/json"
            }
          ]
        },
        "options": {
          "pagination": {
            "pagination": {
              "paginationMode": "updateAParameterInEachRequest",
              "parameters": {
                "parameters": [
                  {
                    "type": "qs",
                    "name": "page",
                    "value": "={{ $pageCount + 1 }}"
                  }
                ]
              },
              "paginationCompleteWhen": "other",
              "completeExpression": "={{ ($response.body.Payments || []).length < 100 }}",
              "limitPagesFetched": true,
              "maxRequests": 50
            }
          }
        }
      },
      "id": "e32ddcde-b5e7-56eb-bee4-a615bda3d9c2",
      "name": "Get payments received",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1100,
        300
      ],
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "resource": "invoice",
        "operation": "getAll",
        "organizationId": "={{ $('Work out dates').first().json.tenantId }}",
        "returnAll": true,
        "options": {
          "statuses": [
            "AUTHORISED"
          ],
          "where": "Type==\"ACCREC\""
        }
      },
      "id": "6322a038-5a71-5853-ab75-9cc19f70920d",
      "name": "Get unpaid invoices",
      "type": "n8n-nodes-base.xero",
      "typeVersion": 1,
      "position": [
        1320,
        300
      ],
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "jsCode": "// Turns the three Xero pulls into six numbers and the five biggest overdue customers.\n// Amounts in other currencies are converted with the rate Xero stored on the invoice or payment.\nconst s = $('Settings').first().json;\nconst d = $('Work out dates').first().json;\nconst base = String(s.baseCurrency || 'SGD').toUpperCase();\nconst rows = name => $(name).all().map(i => i.json).filter(j => j && Object.keys(j).length);\nconst toBase = (amount, currency, rate) => {\n  const cur = String(currency || base).toUpperCase();\n  const r = Number(rate);\n  return cur === base || !(r > 0) ? Number(amount) || 0 : (Number(amount) || 0) / r;   // Xero rate = foreign units per 1 base unit\n};\nconst xeroDay = (str, legacy) => {\n  if (str) return String(str).slice(0, 10);\n  const m = String(legacy || '').match(/\\/Date\\((\\d+)/);\n  return m ? new Date(parseInt(m[1], 10)).toISOString().slice(0, 10) : '';\n};\nconst today = new Date(d.today + 'T00:00:00Z');\nconst r2 = n => Math.round(n * 100) / 100;\nconst money = n => base + ' ' + Number(n).toLocaleString('en-SG', { minimumFractionDigits: 2, maximumFractionDigits: 2 });\nconst short = n => base + ' ' + Math.round(n).toLocaleString('en-SG');\n\n// 1. Invoiced in the period (sales invoices dated in the period, approved or already paid)\nconst raised = rows('Get invoices raised').filter(i => i.Type === 'ACCREC' && ['AUTHORISED', 'PAID'].includes(i.Status));\nconst invoiced = raised.reduce((t, i) => t + toBase(i.Total, i.CurrencyCode, i.CurrencyRate), 0);\nconst invoicedExGst = raised.reduce((t, i) => t + toBase(i.SubTotal, i.CurrencyCode, i.CurrencyRate), 0);\n\n// 2. Cash received in the period (customer payments only; supplier payments, refunds and deleted payments are left out)\nconst payments = rows('Get payments received').flatMap(p => p.Payments || [])\n  .filter(p => p.PaymentType === 'ACCRECPAYMENT' && p.Status === 'AUTHORISED')\n  .filter(p => { const day = xeroDay(null, p.Date); return !day || (day >= d.from && day <= d.to); });\nconst received = payments.reduce((t, p) => t + toBase(p.Amount, p.Invoice && p.Invoice.CurrencyCode, p.CurrencyRate), 0);\n\n// 3-6. Overdue by age band, and who owes the most\nconst bands = [['1 to 30 days', 1, 30], ['31 to 60 days', 31, 60], ['61 to 90 days', 61, 90], ['over 90 days', 91, Infinity]];\nconst bandTotals = bands.map(() => 0);\nconst byCustomer = {};\nlet owedTotal = 0, overdueTotal = 0, overdueCount = 0;\nfor (const inv of rows('Get unpaid invoices')) {\n  if (inv.Type && inv.Type !== 'ACCREC') continue;\n  const due = Number(inv.AmountDue) || 0;\n  if (!(due > 0)) continue;\n  const amount = toBase(due, inv.CurrencyCode, inv.CurrencyRate);\n  owedTotal += amount;\n  const dueDay = xeroDay(inv.DueDateString, inv.DueDate);\n  if (!dueDay) continue;\n  const days = Math.round((today - new Date(dueDay + 'T00:00:00Z')) / 86400000);\n  if (days < 1) continue;\n  overdueTotal += amount; overdueCount++;\n  bandTotals[bands.findIndex(([, lo, hi]) => days >= lo && days <= hi)] += amount;\n  const name = (inv.Contact && inv.Contact.Name) || 'Unknown customer';\n  const c = byCustomer[name] = byCustomer[name] || { name, amount: 0, invoices: 0, oldest: 0 };\n  c.amount += amount; c.invoices++; c.oldest = Math.max(c.oldest, days);\n}\nconst top = Object.values(byCustomer).sort((a, b) => b.amount - a.amount).slice(0, Number(s.topCustomers || 5));\n\nconst numbers = [\n  { label: 'Invoiced', value: invoiced, note: `${raised.length} invoice${raised.length === 1 ? '' : 's'}, ${money(invoicedExGst)} before GST` },\n  { label: 'Cash received', value: received, note: `${payments.length} customer payment${payments.length === 1 ? '' : 's'}` },\n  ...bands.map(([label], k) => ({ label: 'Overdue ' + label, value: bandTotals[k], note: '' })),\n];\n\nconst esc = t => String(t).replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));\nconst cell = 'padding:8px 10px;border-bottom:1px solid #e6e4dc;font-size:14px';\nconst numRows = numbers.map((n, k) => `<tr${k === 2 ? ' style=\"border-top:2px solid #141c5c\"' : ''}><td style=\"${cell}\">${esc(n.label)}${n.note ? `<br><span style=\"color:#6b7092;font-size:12px\">${esc(n.note)}</span>` : ''}</td><td style=\"${cell};text-align:right;font-weight:bold\">${money(n.value)}</td></tr>`).join('');\nconst topRows = top.length ? top.map(c => `<tr><td style=\"${cell}\">${esc(c.name)}</td><td style=\"${cell};text-align:right\">${c.invoices}</td><td style=\"${cell};text-align:right\">${c.oldest} days</td><td style=\"${cell};text-align:right;font-weight:bold\">${money(c.amount)}</td></tr>`).join('')\n  : `<tr><td style=\"${cell}\" colspan=\"4\">Nobody is overdue. Good morning indeed.</td></tr>`;\nconst html = `<div style=\"font-family:Arial,sans-serif;color:#141a3a;max-width:620px\">\n<p style=\"font-size:13px;color:#6b7092;margin:0\">${esc(d.orgName)} \u00b7 ${esc(d.periodLabel)}</p>\n<h2 style=\"margin:4px 0 14px;color:#0e1440\">Your numbers this morning</h2>\n<table style=\"border-collapse:collapse;width:100%\">${numRows}\n<tr><td style=\"${cell};font-weight:bold\">Total overdue (${overdueCount} invoices)</td><td style=\"${cell};text-align:right;font-weight:bold;color:#b3261e\">${money(overdueTotal)}</td></tr>\n<tr><td style=\"${cell};color:#6b7092\">Total owed to you, including not yet due</td><td style=\"${cell};text-align:right;color:#6b7092\">${money(owedTotal)}</td></tr></table>\n<h3 style=\"margin:22px 0 6px;color:#0e1440\">Biggest overdue customers</h3>\n<table style=\"border-collapse:collapse;width:100%\"><tr><th style=\"${cell};text-align:left\">Customer</th><th style=\"${cell};text-align:right\">Invoices</th><th style=\"${cell};text-align:right\">Oldest</th><th style=\"${cell};text-align:right\">Overdue</th></tr>${topRows}</table>\n<p style=\"font-size:12px;color:#6b7092;margin-top:18px\">Pulled from Xero on ${esc(d.today)}. Invoiced = sales invoices dated ${esc(d.periodLabel)}, including GST. Cash received = customer payments dated the same days. Other currencies converted at the rate Xero stored.</p></div>`;\nconst line = `${d.orgName}, ${d.periodLabel}: invoiced ${short(invoiced)}, cash in ${short(received)}, overdue ${short(overdueTotal)} (${short(bandTotals[3])} over 90 days)` +\n  (top[0] ? `. Biggest: ${top[0].name} ${short(top[0].amount)}` : '');\nreturn [{ json: {\n  subject: `Daily numbers, ${d.periodLabel}: invoiced ${short(invoiced)}, cash in ${short(received)}, overdue ${short(overdueTotal)}`,\n  html, line,\n  invoiced: r2(invoiced), invoicedCount: raised.length, received: r2(received), paymentsCount: payments.length,\n  overdue1to30: r2(bandTotals[0]), overdue31to60: r2(bandTotals[1]), overdue61to90: r2(bandTotals[2]), overdueOver90: r2(bandTotals[3]),\n  overdueTotal: r2(overdueTotal), owedTotal: r2(owedTotal),\n  topCustomers: top.map(c => ({ name: c.name, amount: r2(c.amount), invoices: c.invoices, oldestDays: c.oldest })),\n}}];"
      },
      "id": "6b6d34c1-03e9-52ed-9f1d-a205f1f17333",
      "name": "Build the report",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1540,
        300
      ]
    },
    {
      "parameters": {
        "sendTo": "={{ $('Settings').first().json.reportTo }}",
        "subject": "={{ $json.subject }}",
        "message": "={{ $json.html }}",
        "emailType": "html",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "ff88621e-0ff0-5122-b5b2-44b9e87d96b2",
      "name": "Email the report",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        1760,
        300
      ]
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "sendMessage",
        "chatId": "={{ $('Settings').first().json.telegramChatId }}",
        "text": "={{ $json.line }}",
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "id": "3e7f5385-4bcb-5756-8d93-7b30350a7b3b",
      "name": "Telegram line",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        1760,
        500
      ],
      "disabled": true
    }
  ],
  "connections": {
    "Weekdays 8am": {
      "main": [
        [
          {
            "node": "Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Settings": {
      "main": [
        [
          {
            "node": "Get Xero organisation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Xero organisation": {
      "main": [
        [
          {
            "node": "Work out dates",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Work out dates": {
      "main": [
        [
          {
            "node": "Get invoices raised",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get invoices raised": {
      "main": [
        [
          {
            "node": "Get payments received",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get payments received": {
      "main": [
        [
          {
            "node": "Get unpaid invoices",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get unpaid invoices": {
      "main": [
        [
          {
            "node": "Build the report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build the report": {
      "main": [
        [
          {
            "node": "Email the report",
            "type": "main",
            "index": 0
          },
          {
            "node": "Telegram line",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "timezone": "Asia/Singapore",
    "saveManualExecutions": true
  },
  "pinData": {},
  "active": false,
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
