{
  "name": "Template | Invoice Approval Audit and Exception Queue",
  "nodes": [
    {
      "id": "600d3d36-0a36-4c3c-9bcb-bd9c8ed35d92",
      "name": "Run Fictional Demo",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        0
      ],
      "parameters": {}
    },
    {
      "id": "f9ac4b3e-23e7-40d2-ad71-d288b8eca757",
      "name": "Receive Invoice Batch",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        0,
        300
      ],
      "parameters": {
        "httpMethod": "POST",
        "path": "invoice-approval-audit",
        "responseMode": "lastNode",
        "responseData": "firstEntryJson",
        "options": {}
      }
    },
    {
      "id": "4e1d8568-2bf1-40f7-9502-310f68b2fd71",
      "name": "Fictional Batch",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.5,
      "position": [
        260,
        0
      ],
      "parameters": {
        "mode": "raw",
        "jsonOutput": "{\"batchId\": \"DEMO-2026-09-20\", \"currency\": \"USD\", \"approvalLimit\": 1000, \"highValueThreshold\": 5000, \"expectedInvoices\": 5, \"vendors\": [{\"vendorId\": \"V-100\", \"name\": \"Northstar Office Supply\", \"active\": true, \"bankLast4\": \"4421\"}, {\"vendorId\": \"V-200\", \"name\": \"Harbor Cloud Hosting\", \"active\": true, \"bankLast4\": \"1098\"}], \"purchaseOrders\": [{\"poNumber\": \"PO-101\", \"vendorId\": \"V-100\", \"remainingAmount\": 1200, \"currency\": \"USD\"}, {\"poNumber\": \"PO-202\", \"vendorId\": \"V-200\", \"remainingAmount\": 5500, \"currency\": \"USD\"}], \"priorInvoiceKeys\": [\"V-100|INV-OLD\"], \"invoices\": [{\"vendorId\": \"V-100\", \"invoiceNumber\": \"INV-001\", \"invoiceDate\": \"2026-09-10\", \"dueDate\": \"2026-10-10\", \"poNumber\": \"PO-101\", \"subtotal\": 900, \"tax\": 90, \"total\": 990, \"currency\": \"USD\", \"bankLast4\": \"4421\"}, {\"vendorId\": \"V-200\", \"invoiceNumber\": \"INV-002\", \"invoiceDate\": \"2026-09-11\", \"dueDate\": \"2026-10-11\", \"poNumber\": \"PO-202\", \"subtotal\": 3000, \"tax\": 300, \"total\": 3300, \"currency\": \"USD\", \"bankLast4\": \"1098\"}, {\"vendorId\": \"V-100\", \"invoiceNumber\": \"INV-OLD\", \"invoiceDate\": \"2026-09-12\", \"dueDate\": \"2026-10-12\", \"poNumber\": \"PO-101\", \"subtotal\": 100, \"tax\": 10, \"total\": 110, \"currency\": \"USD\", \"bankLast4\": \"4421\"}, {\"vendorId\": \"V-200\", \"invoiceNumber\": \"INV-003\", \"invoiceDate\": \"2026-09-12\", \"dueDate\": \"2026-10-12\", \"poNumber\": \"PO-202\", \"subtotal\": 100, \"tax\": 10, \"total\": 110, \"currency\": \"USD\", \"bankLast4\": \"9999\"}, {\"vendorId\": \"V-404\", \"invoiceNumber\": \"INV-004\", \"invoiceDate\": \"2026-09-12\", \"dueDate\": \"2026-10-12\", \"poNumber\": \"PO-202\", \"subtotal\": 120, \"tax\": 12, \"total\": 132, \"currency\": \"USD\", \"bankLast4\": \"0000\"}]}",
        "options": {}
      }
    },
    {
      "id": "152d2330-bc9f-4b89-82d8-1c13dffcf1e0",
      "name": "Validate Batch Contract",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        520,
        150
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "const input = $input.first().json;\nconst raw = input.body && typeof input.body === 'object' ? input.body : input;\nconst errors = [];\nconst isList = Array.isArray;\nif (typeof raw.batchId !== 'string' || !/^[A-Za-z0-9_-]{1,80}$/.test(raw.batchId)) errors.push('batchId must be 1-80 letters, numbers, underscores or hyphens');\nif (typeof raw.currency !== 'string' || !/^[A-Z]{3}$/.test(raw.currency)) errors.push('currency must be an ISO-style three-letter code');\nif (!isList(raw.invoices) || raw.invoices.length > 250) errors.push('invoices must be an array of at most 250 entries');\nif (!isList(raw.vendors) || raw.vendors.length > 1000) errors.push('vendors must be an array of at most 1000 entries');\nif (!isList(raw.purchaseOrders) || raw.purchaseOrders.length > 2000) errors.push('purchaseOrders must be an array of at most 2000 entries');\nif (!isList(raw.priorInvoiceKeys) || raw.priorInvoiceKeys.length > 10000) errors.push('priorInvoiceKeys must be an array of at most 10000 keys');\nfor (const key of ['approvalLimit','highValueThreshold']) if (typeof raw[key] !== 'number' || !Number.isFinite(raw[key]) || raw[key] < 0) errors.push(key + ' must be a nonnegative number');\nif (isList(raw.invoices) && raw.expectedInvoices != null && raw.expectedInvoices !== raw.invoices.length) errors.push('expectedInvoices does not match invoices.length');\nconst invoiceCount=isList(raw.invoices)?raw.invoices.length:0;\nreturn [{json:{batch:raw, batchId: String(raw.batchId || ''), errors, valid:errors.length===0 && invoiceCount>0, empty:errors.length===0 && invoiceCount===0}}];"
      }
    },
    {
      "id": "c72e2229-56ac-4e66-b015-3375e166d5ec",
      "name": "Batch Has Invoices?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        780,
        150
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "leftValue": "={{ $json.valid }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "ee295c5e-6b54-4314-81c9-ba68c4f94146",
      "name": "Expand Invoice Rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1040,
        0
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "const batch = $input.first().json.batch;\nreturn batch.invoices.map((invoice,index)=>({json:{invoice,index,batchId:batch.batchId},pairedItem:{item:0}}));"
      }
    },
    {
      "id": "7b4c0d24-2151-4c1a-8b90-e450e6c11ceb",
      "name": "Normalize Invoice Fields",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1300,
        0
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "return $input.all().map(item=>{\n  const src=item.json.invoice || {};\n  const text=(v,max)=>String(v??'').trim().slice(0,max);\n  const money=v=>typeof v==='number' && Number.isFinite(v) ? Math.round(v*100) : null;\n  const inv={vendorId:text(src.vendorId,80).toUpperCase(),invoiceNumber:text(src.invoiceNumber,100).toUpperCase(),poNumber:text(src.poNumber,100).toUpperCase(),invoiceDate:text(src.invoiceDate,30),dueDate:text(src.dueDate,30),currency:text(src.currency,3).toUpperCase(),bankLast4:text(src.bankLast4,4),subtotalCents:money(src.subtotal),taxCents:money(src.tax),totalCents:money(src.total)};\n  inv.key=inv.vendorId+'|'+inv.invoiceNumber;\n  return {json:{index:item.json.index,invoice:inv,source:src,flags:[]}};\n});"
      }
    },
    {
      "id": "e309bf6c-3bf8-45f5-8ba7-659a60827f5f",
      "name": "Match Vendor and PO",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1560,
        0
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "const b=$('Validate Batch Contract').first().json.batch;\nconst vendors=new Map(b.vendors.map(v=>[String(v.vendorId||'').trim().toUpperCase(),v]));\nconst pos=new Map(b.purchaseOrders.map(p=>[String(p.poNumber||'').trim().toUpperCase(),p]));\nreturn $input.all().map(item=>{const row=item.json, i=row.invoice, flags=[...row.flags];\n  const vendor=vendors.get(i.vendorId), po=pos.get(i.poNumber);\n  if(!i.vendorId||!i.invoiceNumber) flags.push('missing_invoice_identity');\n  if(!vendor) flags.push('unknown_vendor'); else if(vendor.active!==true) flags.push('inactive_vendor');\n  if(!i.poNumber) flags.push('missing_po'); else if(!po) flags.push('unknown_po');\n  else {if(String(po.vendorId||'').toUpperCase()!==i.vendorId) flags.push('po_vendor_mismatch');\n    if(String(po.currency||'').toUpperCase()!==i.currency) flags.push('po_currency_mismatch');\n    if(typeof po.remainingAmount!=='number'||!Number.isFinite(po.remainingAmount)||i.totalCents>Math.round(po.remainingAmount*100)) flags.push('po_amount_exceeded');}\n  if(vendor && i.bankLast4 && String(vendor.bankLast4||'')!==i.bankLast4) flags.push('bank_change');\n  return {json:{...row,flags,vendorName:String(vendor?.name||''),poMatched:!!po}};\n});"
      }
    },
    {
      "id": "67bf95a7-8a83-4d8b-ba58-af6609c60d31",
      "name": "Check Amounts and Dates",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1820,
        0
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "const b=$('Validate Batch Contract').first().json.batch;\nconst date=s=>{if(!/^\\d{4}-\\d{2}-\\d{2}$/.test(s))return null;const ms=Date.parse(s+'T00:00:00Z');return Number.isFinite(ms)&&new Date(ms).toISOString().slice(0,10)===s?ms:null;};\nconst balances=new Map(b.purchaseOrders.map(p=>[String(p.poNumber||'').trim().toUpperCase(),Math.round(Number(p.remainingAmount)*100)]));\nconst consumed=new Map();\nreturn $input.all().map(item=>{const row=item.json,i=row.invoice,flags=[...row.flags];\n if(i.currency!==b.currency) flags.push('batch_currency_mismatch');\n if([i.subtotalCents,i.taxCents,i.totalCents].some(n=>n===null||n<0)) flags.push('invalid_amount');\n else if(Math.abs(i.subtotalCents+i.taxCents-i.totalCents)>1) flags.push('amount_mismatch');\n const issued=date(i.invoiceDate),due=date(i.dueDate);\n if(issued===null||due===null||due<issued) flags.push('invalid_dates');\n if(!/^\\d{4}$/.test(i.bankLast4)) flags.push('invalid_bank_last4');\n if(i.poNumber && i.totalCents!==null && i.totalCents>=0 && balances.has(i.poNumber)){\n   const running=(consumed.get(i.poNumber)||0)+i.totalCents;\n   consumed.set(i.poNumber,running);\n   if(!Number.isFinite(balances.get(i.poNumber))||running>balances.get(i.poNumber)) flags.push('po_batch_overrun');\n }\n return {json:{...row,flags}};\n});"
      }
    },
    {
      "id": "f7ebbcd8-44a3-44cd-856d-025bc29fcb67",
      "name": "Check Replays and In-Batch Duplicates",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2080,
        0
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "const b=$('Validate Batch Contract').first().json.batch;\nconst previous=new Set(b.priorInvoiceKeys.map(x=>String(x||'').trim().toUpperCase()));\nconst seen=new Set();\nreturn $input.all().map(item=>{const row=item.json,flags=[...row.flags],key=row.invoice.key;\n if(previous.has(key)) flags.push('previously_processed');\n if(seen.has(key)) flags.push('duplicate_in_batch');\n seen.add(key);\n return {json:{...row,flags}};\n});"
      }
    },
    {
      "id": "906bf1be-8c93-4dc3-85f8-d48c7f36f6d3",
      "name": "Assign Approval Decision",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2340,
        0
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "const b=$('Validate Batch Contract').first().json.batch;\nconst hard=new Set(['missing_invoice_identity','unknown_vendor','inactive_vendor','unknown_po','po_vendor_mismatch','po_currency_mismatch','po_amount_exceeded','po_batch_overrun','invalid_amount','amount_mismatch','invalid_dates','batch_currency_mismatch','invalid_bank_last4','previously_processed','duplicate_in_batch']);\nreturn $input.all().map(item=>{const r=item.json,i=r.invoice,flags=[...new Set(r.flags)];\n if(i.totalCents!==null && i.totalCents>Math.round(b.highValueThreshold*100)) flags.push('high_value');\n const decision=flags.some(f=>hard.has(f))?'hold':flags.length||i.totalCents>Math.round(b.approvalLimit*100)?'review':'ready_for_approval';\n const owner=decision==='hold'?'AP exception queue':decision==='review'?'Finance manager':'AP reviewer';\n return {json:{...r,flags,decision,owner,humanApprovalRequired:true}};\n});"
      }
    },
    {
      "id": "9bd684d1-5f91-43ca-89d2-6eae94160d8f",
      "name": "Build Approval Queue",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2600,
        0
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "const batch=$('Validate Batch Contract').first().json.batch;\nconst rows=$input.all().map(x=>x.json);\nconst queue=rows.map(r=>({index:r.index+1,key:r.invoice.key,vendorName:r.vendorName,vendorId:r.invoice.vendorId,invoiceNumber:r.invoice.invoiceNumber,poNumber:r.invoice.poNumber,total:r.invoice.totalCents===null?null:r.invoice.totalCents/100,currency:r.invoice.currency,dueDate:r.invoice.dueDate,decision:r.decision,owner:r.owner,flags:r.flags,humanApprovalRequired:true}));\nconst counts={ready_for_approval:0,review:0,hold:0};for(const r of queue)counts[r.decision]++;\nconst newKeys=queue.filter(r=>r.decision!=='hold'&&r.key!=='|').map(r=>r.key);\nreturn [{json:{status:'completed',batchId:batch.batchId,createdAt:new Date().toISOString(),counts,queue,newKeysToPersist:[...new Set(newKeys)],instructions:'Human approval required. Persist newKeysToPersist in your ERP or ledger only after accepting the queue. No payment is initiated.',sourceCount:rows.length}}];"
      }
    },
    {
      "id": "b17694df-b865-4c60-8c6d-44a4a030dabf",
      "name": "Report Empty or Invalid Batch",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1040,
        300
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "const x=$input.first().json;\nreturn [{json:{status:x.empty?'empty':'invalid',batchId:x.batchId,createdAt:new Date().toISOString(),errors:x.errors,counts:{ready_for_approval:0,review:0,hold:0},queue:[],newKeysToPersist:[],instructions:'Correct the batch input and run again. No invoice was processed.'}}];"
      }
    },
    {
      "id": "ce321d06-1bc0-4752-b11c-fb3b76384203",
      "name": "Download Approval Report",
      "type": "n8n-nodes-base.convertToFile",
      "typeVersion": 1.1,
      "position": [
        2860,
        150
      ],
      "parameters": {
        "operation": "toJson",
        "mode": "once",
        "options": {
          "fileName": "invoice-approval-report.json",
          "format": true
        }
      }
    },
    {
      "id": "cbca19df-6178-45ff-9051-6d7281cfb138",
      "name": "Sticky Note 1b08072d",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        -400
      ],
      "parameters": {
        "content": "## Invoice approval audit\nInput: structured invoices, vendor master, purchase orders and prior keys. Configure upstream ERP/parser mapping before activating the webhook. The fictional demo is safe to run. Every output requires human approval; no payment is sent.",
        "color": 5,
        "width": 570,
        "height": 260
      }
    },
    {
      "id": "cba30674-bb05-4cf2-aa66-b8cc11d2affe",
      "name": "Sticky Note ce6245a2",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1700,
        -400
      ],
      "parameters": {
        "content": "## Decision rules\nHOLD: invalid identity, unknown/inactive vendor, PO mismatch/overrun, amount/date/currency error, replay. REVIEW: bank change, high value or above approval limit. READY FOR APPROVAL: clean and within limit. Persist accepted keys in your own ledger.",
        "color": 4,
        "width": 680,
        "height": 250
      }
    },
    {
      "id": "3588a751-1133-4660-b043-3edd816e4af2",
      "name": "Return Decision and File",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3120,
        150
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "let report;\ntry { report=$('Build Approval Queue').first().json; }\ncatch { report=$('Report Empty or Invalid Batch').first().json; }\nreturn [{json:report,binary:$input.first().binary}];"
      }
    }
  ],
  "connections": {
    "Run Fictional Demo": {
      "main": [
        [
          {
            "node": "Fictional Batch",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Receive Invoice Batch": {
      "main": [
        [
          {
            "node": "Validate Batch Contract",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fictional Batch": {
      "main": [
        [
          {
            "node": "Validate Batch Contract",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate Batch Contract": {
      "main": [
        [
          {
            "node": "Batch Has Invoices?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Batch Has Invoices?": {
      "main": [
        [
          {
            "node": "Expand Invoice Rows",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Report Empty or Invalid Batch",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Expand Invoice Rows": {
      "main": [
        [
          {
            "node": "Normalize Invoice Fields",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Invoice Fields": {
      "main": [
        [
          {
            "node": "Match Vendor and PO",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Match Vendor and PO": {
      "main": [
        [
          {
            "node": "Check Amounts and Dates",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Amounts and Dates": {
      "main": [
        [
          {
            "node": "Check Replays and In-Batch Duplicates",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Replays and In-Batch Duplicates": {
      "main": [
        [
          {
            "node": "Assign Approval Decision",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Assign Approval Decision": {
      "main": [
        [
          {
            "node": "Build Approval Queue",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Approval Queue": {
      "main": [
        [
          {
            "node": "Download Approval Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Report Empty or Invalid Batch": {
      "main": [
        [
          {
            "node": "Download Approval Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download Approval Report": {
      "main": [
        [
          {
            "node": "Return Decision and File",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "active": false,
  "pinData": {}
}
