{
  "id": "5930e993-b971-57",
  "name": "Issue gapless sequential invoice numbers and PDF invoices from Google Sheets with Gmail",
  "nodes": [
    {
      "parameters": {
        "content": "## Issue gapless sequential invoice numbers and PDF invoices from Google Sheets with Gmail\n\n### How it works\n\nMark a row in your invoice sheet as **Ready** and it becomes a numbered PDF invoice in your client's inbox. The number is the point: in many countries invoice numbers must run in an unbroken sequence, and this workflow can't skip one or use one twice.\n\n1. **Every 15 minutes the invoice sheet is read**: rows marked Ready with no number yet are due to be issued.\n2. **The existing numbers are audited first.** A Code node checks that each series (for example `INV-2026-`) runs 1, 2, 3… with no gap, no duplicate and no hand-typed number in the wrong format. If anything is off, nothing is issued and you get an email that names the missing or repeated numbers.\n3. **New numbers continue from the ledger itself**, the Invoice No column. There's no separate counter to drift out of step, and a new year starts a new series automatically.\n4. **Rows with missing details are skipped without using a number.** A missing client, email or price marks the row *Needs details* with the reason.\n5. **The number is written to the sheet before the PDF exists.** If a later step fails, the number is already recorded, so a retry can never hand the same number to a second invoice.\n6. **The PDF is made from your own Google Docs template.** It's copied, its placeholders are filled in, it's exported as a PDF, emailed to the client and the row is marked Sent with a link to the document.\n\nAmounts are computed in integer cents: quantity × unit price, then tax at the row's rate.\n\n### Setup steps\n\nTakes ≈15 minutes.\n\n1. Create an invoice sheet with the columns `Invoice No, Status, Client, Client email, Description, Quantity, Unit price, Tax rate %, Currency, Due in days, Issued at, Total, PDF link`. Leave Invoice No empty; the workflow fills it.\n2. Select that sheet in **Read the Invoice Sheet**, **Reserve the Number in the Sheet**, **Flag the Incomplete Row** and **Mark the Invoice as Sent**.\n3. Create a Google Doc to use as your invoice template, with these placeholders where the values should go: `{{number}}`, `{{client}}`, `{{description}}`, `{{quantity}}`, `{{unit_price}}`, `{{subtotal}}`, `{{tax_rate}}`, `{{tax}}`, `{{total}}`, `{{currency}}`, `{{issued}}`, `{{due}}`. Select it in **Copy the Invoice Template**.\n4. Connect Google Drive, Google Docs, Google Sheets and Gmail credentials, and put your own address in **Email You That Numbering Stopped**.\n5. Open **Assign the Next Invoice Numbers** and set your prefix, whether numbers restart each year, and the zero padding.\n6. Add a test row with your own email as the client, set its Status to `Ready`, run the workflow and check the PDF. Delete a number in the middle of the column and run again to watch the audit stop the run.\n\n### Customization\n\n- **Different number formats:** `PREFIX`, `YEARLY_SERIES` and `DIGITS` at the top of the numbering node cover `INV-0001`, `2026-0001` and similar schemes.\n- **Multi-line invoices:** add a Lines sheet keyed by a draft id and build the line table in the numbering node; the number logic doesn't change.\n- **Approval before sending:** insert a Gmail *send and wait* step after the PDF is made so you can check each invoice before it goes out.\n- **Credit notes:** duplicate the workflow with a `CN-` prefix so credit notes get their own gapless series.\n\n*Once a number is written, it's burned. That's what makes the sequence trustworthy.*",
        "height": 2040,
        "width": 520
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -1180,
        -260
      ],
      "id": "ece40956-5749-556f-be31-832171308580",
      "name": "Sticky Note"
    },
    {
      "parameters": {
        "content": "## Collect invoices marked Ready\n\nEvery 15 minutes the whole invoice sheet is read, because the audit in the next step needs every number issued so far, not just the new rows.",
        "height": 340,
        "width": 520,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -580,
        -260
      ],
      "id": "ed0fbcc4-8de2-5d92-8467-1e2d9c8e021d",
      "name": "Sticky Note1"
    },
    {
      "parameters": {
        "content": "## Audit the sequence, then number in code\n\nExisting numbers must run with no gap, no repeat and no odd format, or nothing is issued. New numbers continue from the highest in the ledger. Incomplete rows are skipped without using one.",
        "height": 560,
        "width": 1240,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -4,
        -260
      ],
      "id": "3d17cb50-f54f-54ba-a946-1edc937d3d73",
      "name": "Sticky Note2"
    },
    {
      "parameters": {
        "content": "## Burn the number, then build and send the PDF\n\nThe number is written to the sheet before the invoice exists, so a failure later can never reuse it. Your Google Docs template is copied, filled in, exported as PDF and emailed.",
        "height": 360,
        "width": 1480,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1292,
        -260
      ],
      "id": "93ca59f9-b85d-5444-b2d9-9f17103b0a44",
      "name": "Sticky Note3"
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 15
            }
          ]
        }
      },
      "id": "f9184df6-1590-5595-9aae-67e755662bec",
      "name": "Every 15 Minutes",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -498,
        -104.9
      ]
    },
    {
      "parameters": {
        "operation": "read",
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "options": {}
      },
      "id": "490d5996-ef69-5349-be26-c9de5ed023db",
      "name": "Read the Invoice Sheet",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        -258,
        -104.9
      ],
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "jsCode": "// Numbers come from the ledger itself (the Invoice No column), not from a\n// separate counter that can drift. Before anything is issued the existing\n// numbers are audited: within each series they must run 1, 2, 3... with no\n// gap and no repeat. If they don't, nothing is issued and you are alerted.\n\n// ---- Your numbering format ----\nconst PREFIX = 'INV-';          // e.g. 'INV-' -> INV-2026-0001\nconst YEARLY_SERIES = true;     // restart at 1 every calendar year\nconst DIGITS = 4;               // zero padding\nconst READY = 'ready';          // Status value that means \"issue this invoice\"\n// -------------------------------\n\nconst rows = $input.all().map(i => i.json).filter(r => r && Object.keys(r).length);\nconst year = new Date().getFullYear();\nconst seriesOf = y => (YEARLY_SERIES ? PREFIX + y + '-' : PREFIX);\nconst esc = s => s.replace(/[.*+?^\\x24{}()|[\\]\\\\]/g, '\\\\$&');\nconst pattern = new RegExp('^(' + esc(PREFIX) + (YEARLY_SERIES ? '(\\\\d{4})-' : '') + ')(\\\\d+)$');\n\nconst problems = [];\nconst bySeries = {};\nfor (const r of rows) {\n  const no = String(r['Invoice No'] ?? '').trim();\n  if (!no) continue;\n  const m = no.match(pattern);\n  if (!m) { problems.push('Row ' + r.row_number + ' has an invoice number in an unexpected format: ' + no); continue; }\n  (bySeries[m[1]] ??= []).push({ n: Number(m[m.length - 1]), row: r.row_number, no });\n}\nfor (const [series, list] of Object.entries(bySeries)) {\n  const seen = new Map();\n  for (const x of list) {\n    if (seen.has(x.n)) problems.push(x.no + ' is used twice (rows ' + seen.get(x.n) + ' and ' + x.row + ')');\n    else seen.set(x.n, x.row);\n  }\n  const max = Math.max(...seen.keys());\n  const missing = [];\n  for (let i = 1; i <= max; i++) if (!seen.has(i)) missing.push(series + String(i).padStart(DIGITS, '0'));\n  if (missing.length) problems.push('Series ' + series + ' has gaps: ' + missing.slice(0, 10).join(', ') + (missing.length > 10 ? ' and ' + (missing.length - 10) + ' more' : ''));\n}\n\nif (problems.length) {\n  return [{ json: { intact: false, problems } }];\n}\n\n// Money is computed in integer cents.\nconst cents = v => {\n  const n = Number(String(v ?? '').replace(/[^0-9.\\-]/g, ''));\n  return Number.isFinite(n) ? Math.round(n * 100) : NaN;\n};\n\nconst series = seriesOf(year);\nlet next = Math.max(0, ...(bySeries[series] ?? []).map(x => x.n)) + 1;\nconst today = new Date();\nconst iso = d => d.toISOString().slice(0, 10);\n\nconst out = [];\nfor (const r of rows) {\n  if (String(r['Invoice No'] ?? '').trim()) continue;\n  if (String(r.Status ?? '').trim().toLowerCase() !== READY) continue;\n  const qty = Number(r.Quantity ?? 1) || 1;\n  const unit = cents(r['Unit price']);\n  const rate = Number(String(r['Tax rate %'] ?? 0).replace('%', '')) || 0;\n  const errs = [];\n  if (!String(r.Client ?? '').trim()) errs.push('Client is empty');\n  if (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(String(r['Client email'] ?? '').trim())) errs.push('Client email is missing or invalid');\n  if (!Number.isFinite(unit) || unit <= 0) errs.push('Unit price is missing or not a number');\n  if (errs.length) { out.push({ json: { intact: true, skip: true, row_number: r.row_number, reason: errs.join('; ') } }); continue; }\n\n  const subtotal = Math.round(qty * unit);\n  const tax = Math.round(subtotal * rate / 100);\n  const due = new Date(today); due.setDate(due.getDate() + (Number(r['Due in days']) || 14));\n  const number = series + String(next++).padStart(DIGITS, '0');\n  const money = c => (c / 100).toFixed(2);\n  out.push({ json: {\n    intact: true, skip: false, row_number: r.row_number,\n    number, client: String(r.Client).trim(), email: String(r['Client email']).trim(),\n    description: String(r.Description ?? '').trim(), quantity: qty,\n    unitPrice: money(unit), subtotal: money(subtotal), taxRate: rate, tax: money(tax),\n    total: money(subtotal + tax), currency: String(r.Currency ?? '').trim().toUpperCase() || 'USD',\n    issued: iso(today), due: iso(due),\n  } });\n}\nreturn out;"
      },
      "id": "4e13c4fc-747f-51ed-bba6-a494a2691331",
      "name": "Assign the Next Invoice Numbers",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        78,
        -104.9
      ]
    },
    {
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-intact",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.intact }}",
              "rightValue": ""
            }
          ]
        }
      },
      "id": "0eb5478d-7e0e-504f-af1f-dd17747396f2",
      "name": "Is the Numbering Intact?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        318,
        -104.9
      ]
    },
    {
      "parameters": {
        "sendTo": "you@example.com",
        "subject": "Invoicing stopped: your invoice number sequence needs attention",
        "emailType": "text",
        "message": "=No invoices were issued, because the numbers already in your sheet don't form a clean sequence:\n\n- {{ $json.problems.join('\\n- ') }}\n\nFix the Invoice No column (restore the missing number, or record a voided invoice under it) and the next run will continue.",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "d785e481-f3c2-5078-b38d-f290c127a8eb",
      "name": "Email You That Numbering Stopped",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        558,
        95.1
      ]
    },
    {
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-complete",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ !$json.skip }}",
              "rightValue": ""
            }
          ]
        }
      },
      "id": "56ebeb1b-e8bf-5024-a4b4-eb38648ce225",
      "name": "Is the Row Complete?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        798,
        -104.9
      ]
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "row_number": "={{ $json.row_number }}",
            "Status": "=Needs details: {{ $json.reason }}"
          },
          "matchingColumns": [
            "row_number"
          ],
          "schema": []
        },
        "options": {}
      },
      "id": "7c454ce9-531b-532c-b556-16bf220a85ba",
      "name": "Flag the Incomplete Row",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        1038,
        95.1
      ]
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "row_number": "={{ $json.row_number }}",
            "Invoice No": "={{ $json.number }}",
            "Status": "Numbered",
            "Issued at": "={{ $json.issued }}",
            "Total": "={{ $json.total }}"
          },
          "matchingColumns": [
            "row_number"
          ],
          "schema": []
        },
        "options": {}
      },
      "id": "2cfca5dd-7053-52e5-83fa-05435e711df5",
      "name": "Reserve the Number in the Sheet",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        1374,
        -104.9
      ]
    },
    {
      "parameters": {
        "operation": "copy",
        "fileId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "name": "={{ $('Assign the Next Invoice Numbers').item.json.number }} {{ $('Assign the Next Invoice Numbers').item.json.client }}",
        "sameFolder": true,
        "options": {}
      },
      "id": "ce35d838-ca94-5d51-8592-e4063d8fbf13",
      "name": "Copy the Invoice Template",
      "type": "n8n-nodes-base.googleDrive",
      "typeVersion": 3,
      "position": [
        1614,
        -104.9
      ]
    },
    {
      "parameters": {
        "operation": "update",
        "documentURL": "={{ $json.id }}",
        "actionsUi": {
          "actionFields": [
            {
              "object": "text",
              "action": "replaceAll",
              "text": "{{number}}",
              "matchCase": true,
              "replaceText": "={{ $('Assign the Next Invoice Numbers').item.json.number }}"
            },
            {
              "object": "text",
              "action": "replaceAll",
              "text": "{{client}}",
              "matchCase": true,
              "replaceText": "={{ $('Assign the Next Invoice Numbers').item.json.client }}"
            },
            {
              "object": "text",
              "action": "replaceAll",
              "text": "{{description}}",
              "matchCase": true,
              "replaceText": "={{ $('Assign the Next Invoice Numbers').item.json.description }}"
            },
            {
              "object": "text",
              "action": "replaceAll",
              "text": "{{quantity}}",
              "matchCase": true,
              "replaceText": "={{ $('Assign the Next Invoice Numbers').item.json.quantity }}"
            },
            {
              "object": "text",
              "action": "replaceAll",
              "text": "{{unit_price}}",
              "matchCase": true,
              "replaceText": "={{ $('Assign the Next Invoice Numbers').item.json.unitPrice }}"
            },
            {
              "object": "text",
              "action": "replaceAll",
              "text": "{{subtotal}}",
              "matchCase": true,
              "replaceText": "={{ $('Assign the Next Invoice Numbers').item.json.subtotal }}"
            },
            {
              "object": "text",
              "action": "replaceAll",
              "text": "{{tax_rate}}",
              "matchCase": true,
              "replaceText": "={{ $('Assign the Next Invoice Numbers').item.json.taxRate }}"
            },
            {
              "object": "text",
              "action": "replaceAll",
              "text": "{{tax}}",
              "matchCase": true,
              "replaceText": "={{ $('Assign the Next Invoice Numbers').item.json.tax }}"
            },
            {
              "object": "text",
              "action": "replaceAll",
              "text": "{{total}}",
              "matchCase": true,
              "replaceText": "={{ $('Assign the Next Invoice Numbers').item.json.total }}"
            },
            {
              "object": "text",
              "action": "replaceAll",
              "text": "{{currency}}",
              "matchCase": true,
              "replaceText": "={{ $('Assign the Next Invoice Numbers').item.json.currency }}"
            },
            {
              "object": "text",
              "action": "replaceAll",
              "text": "{{issued}}",
              "matchCase": true,
              "replaceText": "={{ $('Assign the Next Invoice Numbers').item.json.issued }}"
            },
            {
              "object": "text",
              "action": "replaceAll",
              "text": "{{due}}",
              "matchCase": true,
              "replaceText": "={{ $('Assign the Next Invoice Numbers').item.json.due }}"
            }
          ]
        }
      },
      "id": "c40993e2-d344-51c3-b977-35d0e48c51dc",
      "name": "Fill In the Invoice",
      "type": "n8n-nodes-base.googleDocs",
      "typeVersion": 2,
      "position": [
        1854,
        -104.9
      ]
    },
    {
      "parameters": {
        "operation": "download",
        "fileId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Copy the Invoice Template').item.json.id }}"
        },
        "options": {
          "fileName": "={{ $('Assign the Next Invoice Numbers').item.json.number }}.pdf",
          "googleFileConversion": {
            "conversion": {
              "docsToFormat": "application/pdf"
            }
          }
        }
      },
      "id": "83ff53d7-f15f-5886-a044-d9aa92881bee",
      "name": "Export the Invoice as PDF",
      "type": "n8n-nodes-base.googleDrive",
      "typeVersion": 3,
      "position": [
        2094,
        -104.9
      ]
    },
    {
      "parameters": {
        "sendTo": "={{ $('Assign the Next Invoice Numbers').item.json.email }}",
        "subject": "=Invoice {{ $('Assign the Next Invoice Numbers').item.json.number }}: {{ $('Assign the Next Invoice Numbers').item.json.total }} {{ $('Assign the Next Invoice Numbers').item.json.currency }}",
        "emailType": "text",
        "message": "=Hello {{ $('Assign the Next Invoice Numbers').item.json.client }},\n\nPlease find invoice {{ $('Assign the Next Invoice Numbers').item.json.number }} attached, for {{ $('Assign the Next Invoice Numbers').item.json.total }} {{ $('Assign the Next Invoice Numbers').item.json.currency }}, due on {{ $('Assign the Next Invoice Numbers').item.json.due }}.\n\nThank you for your business.",
        "options": {
          "appendAttribution": false,
          "attachmentsUi": {
            "attachmentsBinary": [
              {
                "property": "data"
              }
            ]
          }
        }
      },
      "id": "5bb63012-82fb-5415-b679-ee79ed79813b",
      "name": "Email the Invoice to the Client",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        2334,
        -104.9
      ]
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "row_number": "={{ $('Assign the Next Invoice Numbers').item.json.row_number }}",
            "Status": "Sent",
            "PDF link": "=https://docs.google.com/document/d/{{ $('Copy the Invoice Template').item.json.id }}"
          },
          "matchingColumns": [
            "row_number"
          ],
          "schema": []
        },
        "options": {}
      },
      "id": "2a23d5b5-15ac-5461-9bcf-9e574a91aadf",
      "name": "Mark the Invoice as Sent",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        2574,
        -104.9
      ]
    }
  ],
  "connections": {
    "Every 15 Minutes": {
      "main": [
        [
          {
            "node": "Read the Invoice Sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read the Invoice Sheet": {
      "main": [
        [
          {
            "node": "Assign the Next Invoice Numbers",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Assign the Next Invoice Numbers": {
      "main": [
        [
          {
            "node": "Is the Numbering Intact?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is the Numbering Intact?": {
      "main": [
        [
          {
            "node": "Is the Row Complete?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Email You That Numbering Stopped",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is the Row Complete?": {
      "main": [
        [
          {
            "node": "Reserve the Number in the Sheet",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Flag the Incomplete Row",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reserve the Number in the Sheet": {
      "main": [
        [
          {
            "node": "Copy the Invoice Template",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Copy the Invoice Template": {
      "main": [
        [
          {
            "node": "Fill In the Invoice",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fill In the Invoice": {
      "main": [
        [
          {
            "node": "Export the Invoice as PDF",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Export the Invoice as PDF": {
      "main": [
        [
          {
            "node": "Email the Invoice to the Client",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Email the Invoice to the Client": {
      "main": [
        [
          {
            "node": "Mark the Invoice as Sent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {}
}
