{
  "name": "Hours to Invoices and Chasers (any business)",
  "nodes": [
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "0f44f12f-ed7a-43eb-9251-c401d9a50de4",
              "name": "businessName",
              "value": "YOUR BUSINESS NAME",
              "type": "string"
            },
            {
              "id": "4fe135e1-4cf9-4c5e-aade-e50bb73cdb6d",
              "name": "myName",
              "value": "YOUR NAME",
              "type": "string"
            },
            {
              "id": "cc72c1c0-0a60-4320-9ea3-e355b66f1dff",
              "name": "invoicePrefix",
              "value": "INV",
              "type": "string"
            },
            {
              "id": "dcf5a149-dd1f-4430-94fe-7bf57da5c8a5",
              "name": "currency",
              "value": "USD",
              "type": "string"
            },
            {
              "id": "04b6ea81-aa69-476b-87e4-b4d7bd4762e6",
              "name": "chaseEveryDays",
              "value": 7,
              "type": "number"
            },
            {
              "id": "aa32b8bb-901d-4024-bcac-f174e776973a",
              "name": "paymentNote",
              "value": "Payment details: [ADD HOW CLIENTS PAY YOU]",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "name": "Config",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        300
      ],
      "id": "6e9b2cf9-364e-4a0d-9d21-8796fdc3a777",
      "executeOnce": true
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 8 1 * *"
            }
          ]
        }
      },
      "name": "1st of the Month, 8am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        300
      ],
      "id": "34e06b4d-7fd9-4802-ad90-4ac822e9d43e"
    },
    {
      "parameters": {
        "operation": "read",
        "documentId": {
          "__rl": true,
          "value": "",
          "mode": "list"
        },
        "sheetName": {
          "__rl": true,
          "value": "",
          "mode": "list"
        },
        "options": {}
      },
      "name": "Read Hours",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        440,
        200
      ],
      "id": "984ee939-7e14-48f7-855e-cd156ed095aa",
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "operation": "read",
        "documentId": {
          "__rl": true,
          "value": "",
          "mode": "list"
        },
        "sheetName": {
          "__rl": true,
          "value": "",
          "mode": "list"
        },
        "options": {}
      },
      "name": "Read Clients",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        440,
        400
      ],
      "id": "41ec930b-0f16-45d7-b3c9-e21f8fc4dcd7",
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "operation": "read",
        "documentId": {
          "__rl": true,
          "value": "",
          "mode": "list"
        },
        "sheetName": {
          "__rl": true,
          "value": "",
          "mode": "list"
        },
        "options": {}
      },
      "name": "Read Invoices",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        660,
        300
      ],
      "id": "a253bfa8-9f3a-4c09-b81c-5733337b3e05",
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "jsCode": "// Plain arithmetic, no AI: last month's unbilled hours, grouped by client.\nconst cfg = $('Config').first().json;\nconst hours = $('Read Hours').all().map(i => i.json).filter(h => h.Client && Number(h.Hours) > 0 && !h.Invoiced);\nconst clients = Object.fromEntries($('Read Clients').all().map(i => i.json).filter(c => c.Client).map(c => [c.Client.trim(), c]));\nconst existing = $('Read Invoices').all().map(i => i.json).filter(i => i['Invoice #']).length;\nconst now = $now;\nconst start = now.minus({ months: 1 }).startOf('month'), end = now.startOf('month');\nconst last = hours.filter(h => { const d = DateTime.fromISO(String(h.Date)); return d.isValid && d >= start && d < end; });\nconst byClient = {};\nfor (const h of last) (byClient[h.Client.trim()] ??= []).push(h);\nconst money = n => `${cfg.currency} ${n.toFixed(2)}`;\nreturn Object.entries(byClient).map(([client, rows], k) => {\n  const c = clients[client] ?? {};\n  const rate = h => Number(h.Rate || c.Rate || 0);\n  const total = rows.reduce((s, h) => s + Number(h.Hours) * rate(h), 0);\n  const number = `${cfg.invoicePrefix}-${now.toFormat('yyyyMM')}-${String(existing + k + 1).padStart(3, '0')}`;\n  const due = now.plus({ days: Number(c['Terms Days'] || 30) }).toISODate();\n  const lines = rows.map(h => `<tr><td>${h.Date}</td><td>${h.Description ?? ''}</td><td style=\"text-align:right\">${h.Hours}</td><td style=\"text-align:right\">${money(rate(h))}</td><td style=\"text-align:right\">${money(Number(h.Hours) * rate(h))}</td></tr>`).join('');\n  return { json: { client, email: c['Billing Email'] ?? '', number, total: total.toFixed(2), due, rowNumbers: rows.map(h => h.row_number),\n    missing: !c['Billing Email'] || rows.some(h => !rate(h)),\n    subject: `${(!c['Billing Email'] || rows.some(h => !rate(h))) ? '[CHECK: missing billing email or rate] ' : ''}Invoice ${number} from ${cfg.businessName}: ${start.toFormat('LLLL yyyy')}`,\n    html: `<p>Hi ${client},</p><p>Here is invoice <b>${number}</b> for ${start.toFormat('LLLL yyyy')}. Total <b>${money(total)}</b>, due ${due}.</p>\n<table cellpadding=\"6\" style=\"border-collapse:collapse;font-family:Arial;font-size:13px\"><tr style=\"background:#1B2A4A;color:#fff\"><th>Date</th><th>Work</th><th>Hours</th><th>Rate</th><th>Amount</th></tr>${lines}\n<tr><td colspan=\"4\" style=\"text-align:right\"><b>Total</b></td><td style=\"text-align:right\"><b>${money(total)}</b></td></tr></table>\n<p>${cfg.paymentNote}</p><p>Thank you,<br>${cfg.myName}</p>` } };\n});"
      },
      "name": "Build the Invoices",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        300
      ],
      "id": "e1168bcc-5b4a-49e8-a613-7d11524c46fb"
    },
    {
      "parameters": {
        "resource": "draft",
        "operation": "create",
        "subject": "={{ $json.subject }}",
        "emailType": "html",
        "message": "={{ $json.html }}",
        "options": {
          "sendTo": "={{ $json.email }}"
        }
      },
      "name": "Draft the Invoice Email",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        1100,
        200
      ],
      "id": "18918079-81aa-4d26-9d0d-e0cf10c8ddb3"
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "value": "",
          "mode": "list"
        },
        "sheetName": {
          "__rl": true,
          "value": "",
          "mode": "list"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "Invoice #": "={{ $('Build the Invoices').item.json.number }}",
            "Client": "={{ $('Build the Invoices').item.json.client }}",
            "Date": "={{ $now.toISODate() }}",
            "Amount": "={{ $('Build the Invoices').item.json.total }}",
            "Due": "={{ $('Build the Invoices').item.json.due }}",
            "Status": "Drafted",
            "Last Chased": ""
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "Invoice #",
              "displayName": "Invoice #",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Client",
              "displayName": "Client",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Date",
              "displayName": "Date",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Amount",
              "displayName": "Amount",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Due",
              "displayName": "Due",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Status",
              "displayName": "Status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Last Chased",
              "displayName": "Last Chased",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        },
        "options": {}
      },
      "name": "Log the Invoice",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        1320,
        200
      ],
      "id": "473c17a1-676d-4b5e-b93d-d42c1df3a12f"
    },
    {
      "parameters": {
        "jsCode": "return $('Build the Invoices').all().flatMap(i => i.json.rowNumbers.map(r => ({ json: { row_number: r, Invoiced: i.json.number } })));"
      },
      "name": "One Row per Billed Hour",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1540,
        200
      ],
      "id": "2b1aa35f-223b-4a83-bd27-9bae114adb3c",
      "executeOnce": true
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": {
          "__rl": true,
          "value": "",
          "mode": "list"
        },
        "sheetName": {
          "__rl": true,
          "value": "",
          "mode": "list"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "row_number": "={{ $json.row_number }}",
            "Invoiced": "={{ $json.Invoiced }}"
          },
          "matchingColumns": [
            "row_number"
          ],
          "schema": [
            {
              "id": "row_number",
              "displayName": "row_number",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Invoiced",
              "displayName": "Invoiced",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        },
        "options": {}
      },
      "name": "Mark Hours as Billed",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        1760,
        200
      ],
      "id": "179d8ace-768b-4d95-b96c-b30b1b1643ae"
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 9 * * 1-5"
            }
          ]
        }
      },
      "name": "Weekdays 9am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        700
      ],
      "id": "fc9ad0ff-ff30-40d4-87a4-8c17e4c2dbec"
    },
    {
      "parameters": {
        "operation": "read",
        "documentId": {
          "__rl": true,
          "value": "",
          "mode": "list"
        },
        "sheetName": {
          "__rl": true,
          "value": "",
          "mode": "list"
        },
        "options": {}
      },
      "name": "Read Invoices for Chasing",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        440,
        700
      ],
      "id": "ce699e0b-e3b4-4209-97fe-9703f6ba22f8",
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "operation": "read",
        "documentId": {
          "__rl": true,
          "value": "",
          "mode": "list"
        },
        "sheetName": {
          "__rl": true,
          "value": "",
          "mode": "list"
        },
        "options": {}
      },
      "name": "Read Clients for Chasing",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        660,
        700
      ],
      "id": "7307acef-a51d-42b7-87d1-1ac6117266ca",
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "jsCode": "// Overdue = status Sent, due date passed, not chased in the last few days. Paid stops everything.\nconst cfg = $('Chaser Config').first().json;\nconst clients = Object.fromEntries($('Read Clients for Chasing').all().map(i => i.json).filter(c => c.Client).map(c => [c.Client.trim(), c]));\nconst today = $now.startOf('day');\nreturn $('Read Invoices for Chasing').all().map(i => i.json)\n  .filter(inv => String(inv.Status).toLowerCase() === 'sent' && inv.Due && DateTime.fromISO(String(inv.Due)) < today)\n  .filter(inv => !inv['Last Chased'] || today.diff(DateTime.fromISO(String(inv['Last Chased'])), 'days').days >= cfg.chaseEveryDays)\n  .map(inv => {\n    const days = Math.floor(today.diff(DateTime.fromISO(String(inv.Due)), 'days').days);\n    const c = clients[String(inv.Client).trim()] ?? {};\n    const tone = days <= 14 ? 'A quick reminder that' : 'Following up again:';\n    return { json: { row_number: inv.row_number, email: c['Billing Email'] ?? '', number: inv['Invoice #'],\n      subject: `Reminder: invoice ${inv['Invoice #']}`,\n      html: `<p>Hi ${inv.Client},</p><p>${tone} invoice <b>${inv['Invoice #']}</b> for ${cfg.currency} ${inv.Amount} was due on ${inv.Due}. If it's already on its way, thank you, and please ignore this.</p><p>${cfg.paymentNote}</p><p>Thank you,<br>${cfg.myName}</p>` } };\n  });"
      },
      "name": "Who's Overdue",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        700
      ],
      "id": "d8f7a30d-1d73-4fa9-bcfc-095a063f1e54"
    },
    {
      "parameters": {
        "resource": "draft",
        "operation": "create",
        "subject": "={{ $json.subject }}",
        "emailType": "html",
        "message": "={{ $json.html }}",
        "options": {
          "sendTo": "={{ $json.email }}"
        }
      },
      "name": "Draft the Reminder",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        1100,
        700
      ],
      "id": "d462365e-2038-4d5b-91a7-c7ce79c6286f"
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": {
          "__rl": true,
          "value": "",
          "mode": "list"
        },
        "sheetName": {
          "__rl": true,
          "value": "",
          "mode": "list"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "row_number": "={{ $(\"Who's Overdue\").item.json.row_number }}",
            "Last Chased": "={{ $now.toISODate() }}"
          },
          "matchingColumns": [
            "row_number"
          ],
          "schema": [
            {
              "id": "row_number",
              "displayName": "row_number",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Last Chased",
              "displayName": "Last Chased",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        },
        "options": {}
      },
      "name": "Note the Chase",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        1320,
        700
      ],
      "id": "cb1a2596-d094-4dc2-9d59-b45395f2b0c7"
    },
    {
      "parameters": {
        "content": "## Hours to invoices, and polite chasers\n**Top row, the 1st of every month at 8am:** takes last month's unbilled hours, groups them by client, numbers each invoice, logs it, and **drafts** the invoice email in Gmail. Then marks those hours as billed so they are never billed twice.\n\n**Bottom row, weekdays at 9am:** finds invoices marked **Sent** that are past due and haven't been chased in 7 days, and **drafts** a polite reminder. Mark an invoice **Paid** and it stops.\n\nEverything is a draft. Nothing sends. There is no AI anywhere in this: the money is plain arithmetic.\n\n**Set up (20 min):** one Google Sheet, three tabs, headers in row 1:\n- **Hours:** Date, Client, Hours, Rate, Description, Invoiced\n- **Clients:** Client, Billing Email, Rate, Terms Days\n- **Invoices:** Invoice #, Client, Date, Amount, Due, Status, Last Chased\nPoint each sheet node at the right tab. Connect Google Sheets and Gmail. Fill in **Config** and **Chaser Config** (same fields; one per row).\n\n**Your part:** log hours as you work. When you send an invoice draft, set its Status to Sent. When it's paid, Paid.",
        "height": 560,
        "width": 460,
        "color": 5
      },
      "name": "Read Me First",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -60,
        -320
      ],
      "id": "5f428a9e-36b6-41e2-9012-7acc90dd6dad"
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "0f44f12f-ed7a-43eb-9251-c401d9a50de4",
              "name": "businessName",
              "value": "YOUR BUSINESS NAME",
              "type": "string"
            },
            {
              "id": "4fe135e1-4cf9-4c5e-aade-e50bb73cdb6d",
              "name": "myName",
              "value": "YOUR NAME",
              "type": "string"
            },
            {
              "id": "cc72c1c0-0a60-4320-9ea3-e355b66f1dff",
              "name": "invoicePrefix",
              "value": "INV",
              "type": "string"
            },
            {
              "id": "dcf5a149-dd1f-4430-94fe-7bf57da5c8a5",
              "name": "currency",
              "value": "USD",
              "type": "string"
            },
            {
              "id": "04b6ea81-aa69-476b-87e4-b4d7bd4762e6",
              "name": "chaseEveryDays",
              "value": 7,
              "type": "number"
            },
            {
              "id": "aa32b8bb-901d-4024-bcac-f174e776973a",
              "name": "paymentNote",
              "value": "Payment details: [ADD HOW CLIENTS PAY YOU]",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "name": "Chaser Config",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        700
      ],
      "id": "64d12e71-e9c1-492b-82d1-97370034b55d",
      "executeOnce": true
    }
  ],
  "connections": {
    "1st of the Month, 8am": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config": {
      "main": [
        [
          {
            "node": "Read Hours",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Hours": {
      "main": [
        [
          {
            "node": "Read Clients",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Clients": {
      "main": [
        [
          {
            "node": "Read Invoices",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Invoices": {
      "main": [
        [
          {
            "node": "Build the Invoices",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build the Invoices": {
      "main": [
        [
          {
            "node": "Draft the Invoice Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Draft the Invoice Email": {
      "main": [
        [
          {
            "node": "Log the Invoice",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log the Invoice": {
      "main": [
        [
          {
            "node": "One Row per Billed Hour",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "One Row per Billed Hour": {
      "main": [
        [
          {
            "node": "Mark Hours as Billed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Weekdays 9am": {
      "main": [
        [
          {
            "node": "Chaser Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Invoices for Chasing": {
      "main": [
        [
          {
            "node": "Read Clients for Chasing",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Clients for Chasing": {
      "main": [
        [
          {
            "node": "Who's Overdue",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Who's Overdue": {
      "main": [
        [
          {
            "node": "Draft the Reminder",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Draft the Reminder": {
      "main": [
        [
          {
            "node": "Note the Chase",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Chaser Config": {
      "main": [
        [
          {
            "node": "Read Invoices for Chasing",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  }
}