{
  "name": "Read Supplier Bills with AI and Draft Them in Xero (ABN and GST checked)",
  "nodes": [
    {
      "parameters": {
        "content": "# Supplier bills into Xero, checked first\n\nBills arrive by email, an AI model reads the PDF, the numbers are checked against Australian tax rules, and only clean bills become Xero drafts. Anything that fails a check goes to a review queue in Slack instead of quietly entering wrong data.\n\n**Sequence**\n1. Gmail label is watched for new bills\n2. The PDF is turned into text\n3. A model extracts supplier, ABN, dates, amounts\n4. **Validate** runs a real ABN checksum and a GST cross-check\n5. Clean bills become draft bills in Xero\n6. Failed checks go to Slack with the reason\n\n**Nothing is ever approved automatically.** Every bill lands in Xero as a draft for a human to approve. That is deliberate.\n\n**You need**\n- Gmail\n- An AI credential (OpenAI, Anthropic, Google or Ollama)\n- Xero\n- Slack\n\nBuilt by Better Automations (Melbourne, Australia).\nhttps://www.betterautomations.com.au/services/n8n-workflow-automation",
        "height": 700,
        "width": 460,
        "color": 4
      },
      "id": "b2c3d4e5-0002-4000-8000-000000000001",
      "name": "Sticky Note - Overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [-660, -180]
    },
    {
      "parameters": {
        "content": "## Start here\n\nIn Gmail, make a label (the default here is `bills`) and a filter that applies it to invoices from your suppliers. The trigger watches that label rather than your whole inbox, so nothing else gets read.\n\nThen open **Configuration** and set your Xero account code and the Slack channel for the review queue.",
        "height": 320,
        "width": 340,
        "color": 7
      },
      "id": "b2c3d4e5-0002-4000-8000-000000000002",
      "name": "Sticky Note - Setup",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [-160, -520]
    },
    {
      "parameters": {
        "content": "## The part that matters\n\n**Validate ABN and GST** is why this is safe to run.\n\nIt does a genuine ABN checksum, the modulus 89 algorithm the ATO publishes, so a transposed or invented ABN fails rather than being trusted because the model was confident.\n\nIt then cross-checks GST at 10%, confirms the subtotal and GST add up to the total, and rejects dates that are not real. A bill has to pass every check to reach Xero.",
        "height": 400,
        "width": 360,
        "color": 7
      },
      "id": "b2c3d4e5-0002-4000-8000-000000000003",
      "name": "Sticky Note - Validation",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [720, -540]
    },
    {
      "parameters": {
        "content": "### Failed checks are not failures\n\nA bill landing in the review queue is the workflow working. Handwritten invoices, scanned faxes and suppliers who round GST oddly will all end up here.\n\nThe Slack message names the exact check that failed, so the fix is usually ten seconds of typing rather than opening the PDF and starting again.",
        "height": 300,
        "width": 340,
        "color": 5
      },
      "id": "b2c3d4e5-0002-4000-8000-000000000004",
      "name": "Sticky Note - Review Queue",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [1620, 200]
    },
    {
      "parameters": {
        "pollTimes": { "item": [{ "mode": "everyMinute" }] },
        "simple": false,
        "filters": { "labelIds": ["bills"], "readStatus": "unread" },
        "options": { "downloadAttachments": true }
      },
      "id": "b2c3d4e5-0002-4000-8000-000000000010",
      "name": "New Bill in Gmail",
      "type": "n8n-nodes-base.gmailTrigger",
      "typeVersion": 1.2,
      "position": [-160, 0]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            { "id": "cfg-acct", "name": "xero_account_code", "value": "400", "type": "string" },
            { "id": "cfg-chan", "name": "review_channel", "value": "#bills-review", "type": "string" },
            { "id": "cfg-gst", "name": "gst_rate", "value": 0.1, "type": "number" },
            { "id": "cfg-tol", "name": "rounding_tolerance", "value": 0.02, "type": "number" },
            { "id": "cfg-cur", "name": "expected_currency", "value": "AUD", "type": "string" }
          ]
        },
        "options": {}
      },
      "id": "b2c3d4e5-0002-4000-8000-000000000011",
      "name": "Configuration",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [60, 0]
    },
    {
      "parameters": {
        "operation": "pdf",
        "binaryPropertyName": "attachment_0",
        "options": {}
      },
      "id": "b2c3d4e5-0002-4000-8000-000000000012",
      "name": "PDF to Text",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1,
      "position": [280, 0]
    },
    {
      "parameters": {
        "text": "={{ $json.text }}",
        "attributes": {
          "attributes": [
            { "name": "supplier_name", "type": "string", "description": "The business issuing the invoice, not the recipient.", "required": true },
            { "name": "supplier_abn", "type": "string", "description": "The supplier's ABN, digits only, no spaces. Empty string if not shown.", "required": true },
            { "name": "invoice_number", "type": "string", "description": "The supplier's own invoice or bill number.", "required": true },
            { "name": "invoice_date", "type": "string", "description": "Date of issue as YYYY-MM-DD. Australian invoices are usually DD/MM/YYYY, so read them that way.", "required": true },
            { "name": "due_date", "type": "string", "description": "Payment due date as YYYY-MM-DD. Empty string if not shown.", "required": true },
            { "name": "subtotal_ex_gst", "type": "number", "description": "Amount before GST, as a number with no currency symbol.", "required": true },
            { "name": "gst_amount", "type": "number", "description": "GST charged, as a number. Zero if the supplier is not registered for GST.", "required": true },
            { "name": "total_inc_gst", "type": "number", "description": "Total payable including GST, as a number.", "required": true },
            { "name": "currency", "type": "string", "description": "Three-letter currency code, for example AUD.", "required": true },
            { "name": "description", "type": "string", "description": "One short line describing what was supplied.", "required": true }
          ]
        },
        "options": {
          "systemPromptTemplate": "You read Australian supplier invoices and return the requested fields exactly as printed.\n\nRules:\n- Never calculate a missing figure. If a number is not printed on the invoice, return 0 and let the validation step catch it.\n- Australian invoices write dates as DD/MM/YYYY. 03/04/2026 is 3 April, not 4 March.\n- Strip currency symbols, thousands separators and the letters GST from numbers.\n- The ABN belongs to the supplier issuing the invoice. If two ABNs appear, take the one next to the supplier's own name and address."
        }
      },
      "id": "b2c3d4e5-0002-4000-8000-000000000013",
      "name": "Extract Bill Fields",
      "type": "@n8n/n8n-nodes-langchain.informationExtractor",
      "typeVersion": 1,
      "position": [500, 0]
    },
    {
      "parameters": { "options": {} },
      "id": "b2c3d4e5-0002-4000-8000-000000000014",
      "name": "Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "typeVersion": 1.2,
      "position": [500, 220]
    },
    {
      "parameters": {
        "jsCode": "// Everything the model returned is treated as unverified until it passes these checks.\n// A confident model and a correct invoice are different things.\nconst cfg = $('Configuration').first().json;\n\n// The ATO's published ABN check: subtract 1 from the first digit, apply the\n// weightings, and a valid ABN sums to a multiple of 89.\nfunction abnIsValid(abn) {\n  const digits = String(abn ?? '').replace(/\\D/g, '');\n  if (digits.length !== 11) return false;\n  const weights = [10, 1, 3, 5, 7, 9, 11, 13, 15, 17, 19];\n  const nums = digits.split('').map(Number);\n  nums[0] -= 1;\n  const sum = nums.reduce((acc, n, i) => acc + n * weights[i], 0);\n  return sum % 89 === 0;\n}\n\nconst isRealDate = (s) => /^\\d{4}-\\d{2}-\\d{2}$/.test(s ?? '') && !Number.isNaN(Date.parse(s));\nconst round2 = (n) => Math.round(n * 100) / 100;\n\nreturn $input.all().map((item) => {\n  const b = item.json.output ?? item.json;\n  const failures = [];\n  const tol = cfg.rounding_tolerance;\n\n  if (!b.supplier_name) failures.push('no supplier name found');\n  if (!b.invoice_number) failures.push('no invoice number found');\n\n  if (!b.supplier_abn) failures.push('no ABN printed on the invoice');\n  else if (!abnIsValid(b.supplier_abn)) failures.push(`ABN ${b.supplier_abn} fails the checksum`);\n\n  if (!isRealDate(b.invoice_date)) failures.push(`invoice date \"${b.invoice_date}\" is not a real date`);\n  if (b.due_date && !isRealDate(b.due_date)) failures.push(`due date \"${b.due_date}\" is not a real date`);\n\n  const sub = Number(b.subtotal_ex_gst) || 0;\n  const gst = Number(b.gst_amount) || 0;\n  const tot = Number(b.total_inc_gst) || 0;\n\n  if (tot <= 0) failures.push('total is zero or missing');\n  if (Math.abs(round2(sub + gst) - round2(tot)) > tol) {\n    failures.push(`subtotal ${sub} plus GST ${gst} does not equal total ${tot}`);\n  }\n  // GST is either 10% of the subtotal, or zero for a supplier not registered.\n  if (gst > 0 && Math.abs(round2(sub * cfg.gst_rate) - round2(gst)) > tol) {\n    failures.push(`GST ${gst} is not ${cfg.gst_rate * 100}% of ${sub}`);\n  }\n\n  if (b.currency && b.currency !== cfg.expected_currency) {\n    failures.push(`currency is ${b.currency}, expected ${cfg.expected_currency}`);\n  }\n\n  return {\n    json: {\n      ...b,\n      subtotal_ex_gst: round2(sub),\n      gst_amount: round2(gst),\n      total_inc_gst: round2(tot),\n      abn_valid: abnIsValid(b.supplier_abn),\n      is_valid: failures.length === 0,\n      failures,\n      source_email_subject: $('New Bill in Gmail').first().json.subject ?? '',\n    },\n  };\n});"
      },
      "id": "b2c3d4e5-0002-4000-8000-000000000015",
      "name": "Validate ABN and GST",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [900, 0]
    },
    {
      "parameters": {
        "conditions": {
          "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 },
          "conditions": [
            { "id": "chk-valid", "leftValue": "={{ $json.is_valid }}", "rightValue": true, "operator": { "type": "boolean", "operation": "true", "singleValue": true } }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "b2c3d4e5-0002-4000-8000-000000000016",
      "name": "Passed Every Check?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [1180, 0]
    },
    {
      "parameters": {
        "resource": "invoice",
        "operation": "create",
        "organizationId": "={{ $json.xero_tenant_id }}",
        "type": "ACCPAY",
        "contactId": "={{ $json.supplier_name }}",
        "lineItemsUi": {
          "lineItemsValues": [
            {
              "description": "={{ $json.description }}",
              "quantity": 1,
              "unitAmount": "={{ $json.subtotal_ex_gst }}",
              "accountCode": "={{ $('Configuration').first().json.xero_account_code }}"
            }
          ]
        },
        "additionalFields": {
          "date": "={{ $json.invoice_date }}",
          "dueDate": "={{ $json.due_date }}",
          "invoiceNumber": "={{ $json.invoice_number }}",
          "status": "DRAFT",
          "currencyCode": "={{ $json.currency }}"
        }
      },
      "id": "b2c3d4e5-0002-4000-8000-000000000017",
      "name": "Create Draft Bill in Xero",
      "type": "n8n-nodes-base.xero",
      "typeVersion": 1,
      "position": [1460, -140],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": { "__rl": true, "value": "={{ $('Configuration').first().json.review_channel }}", "mode": "name" },
        "text": "=:white_check_mark: *Draft bill created in Xero*\n\n*{{ $json.supplier_name }}* · invoice {{ $json.invoice_number }}\nABN {{ $json.supplier_abn }} (checksum passed)\nSubtotal ${{ $json.subtotal_ex_gst }} + GST ${{ $json.gst_amount }} = *${{ $json.total_inc_gst }}*\nDue {{ $json.due_date || 'not stated' }}\n\nSitting in Xero as a draft, waiting for approval.",
        "otherOptions": { "includeLinkToWorkflow": false }
      },
      "id": "b2c3d4e5-0002-4000-8000-000000000018",
      "name": "Confirm in Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [1740, -140],
      "webhookId": "b2c3d4e5-0002-4000-8000-00000000f001"
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": { "__rl": true, "value": "={{ $('Configuration').first().json.review_channel }}", "mode": "name" },
        "text": "=:mag: *Bill needs a human*\n\n*{{ $json.supplier_name || 'unknown supplier' }}* · invoice {{ $json.invoice_number || 'no number' }}\nEmail subject: {{ $json.source_email_subject }}\n\n*What failed:*\n{{ $json.failures.map(f => '• ' + f).join('\\n') }}\n\n*What was read:*\nABN {{ $json.supplier_abn || 'none' }} · subtotal ${{ $json.subtotal_ex_gst }} · GST ${{ $json.gst_amount }} · total ${{ $json.total_inc_gst }}\n\nNothing has been written to Xero.",
        "otherOptions": { "includeLinkToWorkflow": false }
      },
      "id": "b2c3d4e5-0002-4000-8000-000000000019",
      "name": "Send to Review Queue",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [1460, 160],
      "webhookId": "b2c3d4e5-0002-4000-8000-00000000f002"
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": { "__rl": true, "value": "={{ $('Configuration').first().json.review_channel }}", "mode": "name" },
        "text": "=:warning: *Xero rejected a bill that passed validation*\n\n{{ $json.supplier_name }} · invoice {{ $json.invoice_number }}\nError: {{ $json.error ?? 'see the execution log' }}\n\nUsually this means the supplier does not exist as a contact in Xero yet, or the account code in Configuration is wrong.",
        "otherOptions": { "includeLinkToWorkflow": true }
      },
      "id": "b2c3d4e5-0002-4000-8000-000000000020",
      "name": "Xero Rejected It",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [1740, 20],
      "webhookId": "b2c3d4e5-0002-4000-8000-00000000f003"
    }
  ],
  "connections": {
    "New Bill in Gmail": { "main": [[{ "node": "Configuration", "type": "main", "index": 0 }]] },
    "Configuration": { "main": [[{ "node": "PDF to Text", "type": "main", "index": 0 }]] },
    "PDF to Text": { "main": [[{ "node": "Extract Bill Fields", "type": "main", "index": 0 }]] },
    "Chat Model": { "ai_languageModel": [[{ "node": "Extract Bill Fields", "type": "ai_languageModel", "index": 0 }]] },
    "Extract Bill Fields": { "main": [[{ "node": "Validate ABN and GST", "type": "main", "index": 0 }]] },
    "Validate ABN and GST": { "main": [[{ "node": "Passed Every Check?", "type": "main", "index": 0 }]] },
    "Passed Every Check?": {
      "main": [
        [{ "node": "Create Draft Bill in Xero", "type": "main", "index": 0 }],
        [{ "node": "Send to Review Queue", "type": "main", "index": 0 }]
      ]
    },
    "Create Draft Bill in Xero": {
      "main": [
        [{ "node": "Confirm in Slack", "type": "main", "index": 0 }],
        [{ "node": "Xero Rejected It", "type": "main", "index": 0 }]
      ]
    }
  },
  "settings": { "executionOrder": "v1" },
  "pinData": {},
  "meta": {},
  "tags": []
}
