{
  "name": "AI Accounts Payable Autopilot — Invoice Capture, Fraud Checks, 3-Way Match & Approvals",
  "nodes": [
    {
      "parameters": {
        "content": "# 🧾 AI Accounts Payable Autopilot\n**Problem:** finance teams re-key invoices from email by hand, miss early-payment discounts, pay duplicates, and are a prime target for bank-detail-change (BEC) fraud.\n**Solution:** an AP clerk that never sleeps: it reads every invoice with Claude vision, checks it against the vendor master and purchase orders, stops fraud before money moves, gets the right person's approval in Slack, and posts the bill to QuickBooks.\n\n**What it does**\n• Captures PDFs and photos from the AP inbox; idempotent via SHA-256 file fingerprint\n• Claude reads the document natively (no OCR step), constrained to a strict JSON schema\n• Recomputes line maths, totals and dates itself; never trusts one extraction\n• Fraud controls: bank-account change, sender-domain spoofing, blocked vendors, AI-spotted pressure tactics\n• 3-way match (PO × goods received × invoice) with price/qty tolerances, auto GL coding\n• Routing: auto-approve clean matches, amount-based approver in Slack (Approve / Reject buttons), AP review, fraud hold, duplicate reply\n• Posts the bill to QuickBooks Online, schedules payment on the discount deadline, confirms to the vendor, archives to Drive\n• Morning digest: cash out next 7 days, touchless rate, discounts captured\n\n**Stack:** Gmail · Claude (Anthropic API) · Postgres (pg_trgm) · Slack human-in-the-loop · QuickBooks Online · Google Drive",
        "height": 820,
        "width": 860,
        "color": 1
      },
      "id": "1be06704-33d6-5eee-8f01-29a7d9101847",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -980,
        -50
      ]
    },
    {
      "parameters": {
        "content": "## 🛠️ Setup\n1. **Postgres** → select on all Postgres nodes → ▶ **Run Once: Create Tables** (vendor master, POs, GL map, approval matrix + demo data). Load your real vendors/POs into the same tables.\n2. **Gmail OAuth2** on the trigger + Gmail nodes. Create a label such as `AP/Processed` and put its **label ID** in the config.\n3. **Anthropic** credential on *Claude: Extract Invoice* (HTTP node, uses the Anthropic API directly for native PDF input + structured outputs).\n4. **Google Drive** credential + an archive folder ID.\n5. **QuickBooks Online OAuth2** credential; set `qboRealmId` (company ID). Use the sandbox base URL while testing. Map `gl_accounts.qbo_account_id` and `vendors.qbo_vendor_id` to your QBO ids.\n6. **Slack** credential; approvers' Slack user IDs live in `approval_matrix`.\n7. Edit **AP Config** (limits, tolerances, channel). Activate.\n\n**Try it:** email a PDF invoice for PO-1001 from Acme (500 × Steel mounting brackets @ 2.40 + 40 × Hex bolts @ 18.50) → it should auto-approve. Change the bank account digits → fraud hold.",
        "height": 700,
        "width": 860,
        "color": 3
      },
      "id": "1b50f98a-4da4-5280-9335-81f48a23b642",
      "name": "Setup Guide",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -980,
        820
      ]
    },
    {
      "parameters": {
        "content": "## 📥 Two triggers → one config → one router",
        "height": 1860,
        "width": 740,
        "color": 7
      },
      "id": "8561513f-7364-5cda-b782-f0e155f2579b",
      "name": "Frame: Entry",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -70,
        50
      ]
    },
    {
      "parameters": {
        "content": "## ① Capture → fingerprint → Claude vision extraction → validate → match → decide\nFiles already seen are skipped before any AI spend. Claude reads PDFs/photos natively with a strict JSON schema; the workflow then re-checks every number itself and runs fraud + 3-way-match controls.",
        "height": 520,
        "width": 3100,
        "color": 5
      },
      "id": "c6e22979-2d08-510b-a12e-abc80dd33f7d",
      "name": "Frame: Capture",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        730,
        10
      ]
    },
    {
      "parameters": {
        "content": "## ② Route → approve → post → pay\nClean matches post straight to QuickBooks. Everything else goes to the right human: the amount-band approver (Slack buttons, 48h timeout), the AP team, or a fraud hold that never replies to the sender.",
        "height": 1000,
        "width": 2580,
        "color": 6
      },
      "id": "ca152a68-4736-5517-92cf-20c3dc66c0df",
      "name": "Frame: Decisions",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        730,
        550
      ]
    },
    {
      "parameters": {
        "content": "## ③ Morning AP digest (weekdays 8 AM)",
        "height": 260,
        "width": 760,
        "color": 4
      },
      "id": "d8433fd8-ac30-55fd-85d1-d7fc9cb42144",
      "name": "Frame: Digest",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        730,
        1630
      ]
    },
    {
      "parameters": {
        "content": "## 🧱 Run once",
        "height": 260,
        "width": 520,
        "color": 7
      },
      "id": "fb448dda-23c9-5463-949f-315f769ec4a2",
      "name": "Frame: Setup",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -70,
        1990
      ]
    },
    {
      "parameters": {
        "pollTimes": {
          "item": [
            {
              "mode": "everyX",
              "value": 5,
              "unit": "minutes"
            }
          ]
        },
        "simple": false,
        "filters": {
          "q": "has:attachment -label:ap-processed newer_than:3d"
        },
        "options": {
          "downloadAttachments": true
        }
      },
      "id": "700b3145-ea00-5a58-8a03-6b11d2fefe09",
      "name": "Gmail: AP Inbox",
      "type": "n8n-nodes-base.gmailTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        200
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 8 * * 1-5"
            }
          ]
        }
      },
      "id": "0f88e111-3832-5012-84f6-46d135a7561c",
      "name": "Weekdays 8 AM",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        1760
      ]
    },
    {
      "parameters": {
        "mode": "manual",
        "assignments": {
          "assignments": [
            {
              "id": "8ecc67cd-232b-57df-838f-21e10f5f0688",
              "name": "companyName",
              "value": "Harbor & Pine Co.",
              "type": "string"
            },
            {
              "id": "0e4e2011-5b13-56e2-a116-e2d72cb35075",
              "name": "apSlackChannelId",
              "value": "C0123456789",
              "type": "string"
            },
            {
              "id": "584a46a1-49d1-5970-95ca-49228c2f5a23",
              "name": "autoApproveLimit",
              "value": 2500,
              "type": "number"
            },
            {
              "id": "e104921f-c474-5fb6-8fa0-b40e7f9a247e",
              "name": "priceTolerancePct",
              "value": 2,
              "type": "number"
            },
            {
              "id": "c21bc8d4-1043-5398-bbf4-dcbae9c9e72a",
              "name": "qtyTolerance",
              "value": 0,
              "type": "number"
            },
            {
              "id": "5d899296-626e-5333-a6f9-bb9071bd979d",
              "name": "baseCurrency",
              "value": "USD",
              "type": "string"
            },
            {
              "id": "287da946-4712-5f9f-9dcb-655553c72313",
              "name": "claudeModel",
              "value": "claude-opus-5",
              "type": "string"
            },
            {
              "id": "72f7a5af-848f-5209-a313-7723261aa407",
              "name": "qboBaseUrl",
              "value": "https://sandbox-quickbooks.api.intuit.com",
              "type": "string"
            },
            {
              "id": "f6a2448d-79ef-50ab-b729-c416fcf5b7c2",
              "name": "qboRealmId",
              "value": "1234567890",
              "type": "string"
            },
            {
              "id": "df65b45b-7140-5fdb-aaba-08d91cf90c3f",
              "name": "processedLabelId",
              "value": "Label_0000000000000000001",
              "type": "string"
            },
            {
              "id": "e42f017f-a08e-5c8e-9c7a-79fc100a1350",
              "name": "driveFolderId",
              "value": "REPLACE_WITH_DRIVE_FOLDER_ID",
              "type": "string"
            },
            {
              "id": "9590504e-1095-5088-a3dc-1c1b456c54a9",
              "name": "event",
              "value": "={{ $('Gmail: AP Inbox').isExecuted ? 'invoice' : 'digest' }}",
              "type": "string"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "id": "2a8a76d5-76dc-5cae-9827-d69d3d577a43",
      "name": "AP Config",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        260,
        919
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "b7f85ca9-6c53-51b9-83b8-5803cade434b",
                    "leftValue": "={{ $json.event }}",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "rightValue": "invoice"
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "New Email"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "fe35042d-37fc-5ba1-83d5-8cb6ed099e72",
                    "leftValue": "={{ $json.event }}",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "rightValue": "digest"
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Morning Digest"
            }
          ]
        },
        "options": {}
      },
      "id": "48c181ac-e6cd-5c1c-9aea-20048888bd4a",
      "name": "Route by Trigger",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        520,
        919
      ]
    },
    {
      "parameters": {
        "jsCode": "// One item per real attachment (PDF or image). Inline signature logos are skipped.\nconst out = [];\nfor (const item of $input.all()) {\n  const m = item.json;\n  const fromEmail = String(m.from?.value?.[0]?.address || '').toLowerCase();\n  for (const [key, bin] of Object.entries(item.binary || {})) {\n    const mime = String(bin.mimeType || '').toLowerCase();\n    const name = String(bin.fileName || key);\n    const isPdf = mime === 'application/pdf' || /\\.pdf$/i.test(name);\n    const isImage = /^image\\/(png|jpe?g|webp|gif)$/.test(mime);\n    if (!isPdf && !isImage) continue;\n    if (isImage && /^(image\\d+|logo|signature|icon)[^/]*\\.(png|jpe?g|gif|webp)$/i.test(name)) continue;\n    out.push({\n      json: {\n        messageId: m.id,\n        threadId: m.threadId,\n        subject: m.subject || '',\n        receivedAt: m.date,\n        senderEmail: fromEmail,\n        senderDomain: fromEmail.split('@')[1] || '',\n        fileName: name,\n        mimeType: isPdf ? 'application/pdf' : mime,\n      },\n      binary: { data: bin },\n    });\n  }\n}\nreturn out;"
      },
      "id": "80f44457-32cc-5f65-8f8d-486452fa3695",
      "name": "Split Attachments",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        780,
        200
      ]
    },
    {
      "parameters": {
        "action": "hash",
        "type": "SHA256",
        "binaryData": true,
        "binaryPropertyName": "data",
        "dataPropertyName": "fileHash",
        "encoding": "hex"
      },
      "id": "de1add1e-d1b5-5b84-9e92-7f775e377f02",
      "name": "Fingerprint File (SHA-256)",
      "type": "n8n-nodes-base.crypto",
      "typeVersion": 1,
      "position": [
        1040,
        200
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT $1::text AS file_hash,\n       EXISTS (SELECT 1 FROM invoices WHERE file_hash = $1) AS duplicate_file;",
        "options": {
          "queryReplacement": "={{ [ $json.fileHash ] }}"
        }
      },
      "id": "0c028687-414f-58e9-b4f7-3573431c30b2",
      "name": "Already Processed?",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        1300,
        360
      ]
    },
    {
      "parameters": {
        "mode": "combine",
        "combineBy": "combineByPosition",
        "options": {}
      },
      "id": "a01290c9-2340-5db6-9fa9-f75b24677338",
      "name": "Rejoin File + Check",
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3,
      "position": [
        1560,
        200
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "1b72a9f5-3e81-510e-b1b1-968db5c723e2",
              "leftValue": "={{ $json.duplicate_file }}",
              "operator": {
                "type": "boolean",
                "operation": "false",
                "singleValue": true
              },
              "rightValue": ""
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "57dbe758-142a-5a1e-8683-ff8965b85c09",
      "name": "New Files Only",
      "type": "n8n-nodes-base.filter",
      "typeVersion": 2.2,
      "position": [
        1820,
        200
      ]
    },
    {
      "parameters": {
        "jsCode": "// Build a Claude Messages API request per file: PDF/image block + strict JSON-schema output\nconst SCHEMA = {\n  type: 'object',\n  additionalProperties: false,\n  properties: {\n    is_invoice: { type: 'boolean', description: 'True only for a bill requesting payment (not a quote, statement, receipt or marketing PDF)' },\n    document_type: { type: 'string', enum: ['invoice', 'credit_note', 'statement', 'receipt', 'quote', 'other'] },\n    vendor_name: { type: 'string' },\n    vendor_tax_id: { type: 'string', description: 'EIN / VAT / GST number, empty string if absent' },\n    vendor_email: { type: 'string' },\n    invoice_number: { type: 'string' },\n    po_number: { type: 'string', description: 'Purchase order reference, empty string if absent' },\n    invoice_date: { type: 'string', description: 'YYYY-MM-DD, empty string if absent' },\n    due_date: { type: 'string', description: 'YYYY-MM-DD, empty string if absent' },\n    currency: { type: 'string', description: 'ISO 4217 code, e.g. USD' },\n    payment_terms: { type: 'string', description: 'As printed, e.g. Net 30 or 2/10 Net 30; empty if absent' },\n    line_items: {\n      type: 'array',\n      items: {\n        type: 'object',\n        additionalProperties: false,\n        properties: {\n          description: { type: 'string' },\n          sku: { type: 'string', description: 'Item code, empty string if absent' },\n          quantity: { type: 'number' },\n          unit_price: { type: 'number' },\n          amount: { type: 'number' },\n        },\n        required: ['description', 'sku', 'quantity', 'unit_price', 'amount'],\n      },\n    },\n    subtotal: { type: 'number' },\n    tax: { type: 'number', description: '0 if none' },\n    shipping: { type: 'number', description: '0 if none' },\n    discount: { type: 'number', description: 'Positive number, 0 if none' },\n    total: { type: 'number' },\n    bank_account_last4: { type: 'string', description: 'Last 4 digits of the bank account or IBAN printed for payment, empty if absent. Never output more than 4 digits.' },\n    suspicious_signals: { type: 'array', items: { type: 'string' }, description: 'e.g. says bank details changed, urgent pressure to pay, mismatched branding, edited-looking numbers' },\n    confidence: { type: 'number', description: '0 to 1, how confident you are in the extraction overall' },\n    notes: { type: 'string' },\n  },\n  required: ['is_invoice', 'document_type', 'vendor_name', 'vendor_tax_id', 'vendor_email', 'invoice_number', 'po_number',\n    'invoice_date', 'due_date', 'currency', 'payment_terms', 'line_items', 'subtotal', 'tax', 'shipping', 'discount',\n    'total', 'bank_account_last4', 'suspicious_signals', 'confidence', 'notes'],\n};\n\nconst SYSTEM = 'You are an accounts-payable document specialist. Extract exactly what is printed on the document. '\n  + 'Never invent values: use empty strings or 0 when a field is absent. Amounts are plain numbers without currency symbols. '\n  + 'Flag anything that looks like payment fraud in suspicious_signals, especially notices that bank details have changed.';\n\nconst MAX_BYTES = 30 * 1024 * 1024;\nconst out = [];\nconst items = $input.all();\nfor (let i = 0; i < items.length; i++) {\n  const meta = items[i].json;\n  const buf = await this.helpers.getBinaryDataBuffer(i, 'data');\n  if (buf.length > MAX_BYTES) continue;  // ponytail: oversized files skip AI; split them or use the Files API if this happens often\n  const source = { type: 'base64', media_type: meta.mimeType, data: buf.toString('base64') };\n  const fileBlock = meta.mimeType === 'application/pdf' ? { type: 'document', source } : { type: 'image', source };\n  out.push({\n    json: {\n      ...meta,\n      claudeRequest: {\n        model: $('AP Config').first().json.claudeModel,\n        max_tokens: 16000,\n        fallbacks: 'default',\n        system: SYSTEM,\n        output_config: { format: { type: 'json_schema', schema: SCHEMA } },\n        messages: [{\n          role: 'user',\n          content: [\n            fileBlock,\n            { type: 'text', text: `Extract this document. It was emailed by ${meta.senderEmail} with subject \"${meta.subject}\" as file ${meta.fileName}.` },\n          ],\n        }],\n      },\n    },\n    binary: items[i].binary,\n  });\n}\nreturn out;"
      },
      "id": "b6bf0516-b46c-5808-9734-03bed72aa75c",
      "name": "Prepare Claude Request",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2080,
        200
      ]
    },
    {
      "parameters": {
        "resource": "file",
        "operation": "upload",
        "name": "={{ $now.toFormat('yyyy-MM-dd') }}_{{ $json.fileHash.slice(0, 8) }}_{{ $json.fileName }}",
        "driveId": {
          "__rl": true,
          "mode": "list",
          "value": "My Drive"
        },
        "folderId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('AP Config').first().json.driveFolderId }}"
        },
        "inputDataFieldName": "data",
        "options": {}
      },
      "id": "50bc50c9-acc6-532e-a2cf-c9f7fbd17b79",
      "name": "Archive to Google Drive",
      "type": "n8n-nodes-base.googleDrive",
      "typeVersion": 3,
      "position": [
        2340,
        200
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "anthropicApi",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            },
            {
              "name": "anthropic-beta",
              "value": "server-side-fallback-2026-07-01"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($('Prepare Claude Request').item.json.claudeRequest) }}",
        "options": {
          "timeout": 300000
        }
      },
      "id": "28bc4508-634e-5269-8e22-f44c495bfe5a",
      "name": "Claude: Extract Invoice",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2600,
        200
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('AP Config').first().json.apSlackChannelId }}"
        },
        "text": "=:warning: Could not extract *{{ $('Prepare Claude Request').item.json.fileName }}* from {{ $('Prepare Claude Request').item.json.senderEmail }}: {{ $json.error?.message || 'unknown error' }}. <https://drive.google.com/file/d/{{ $('Archive to Google Drive').item.json.id }}/view|Open the file> and key it manually.",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "a24c02ba-00aa-5466-ba3a-c91ad5f0bb64",
      "name": "Slack: Extraction Failed",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        2860,
        400
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Parse Claude's JSON, then verify the maths and dates ourselves: never trust a single extraction\nconst meta = $('Prepare Claude Request').item.json;\nconst drive = $('Archive to Google Drive').item.json;\nconst res = $json;\nconst issues = [];\n\nlet inv = {};\nif (res.stop_reason === 'refusal') {\n  issues.push({ code: 'model_refusal', severity: 'high', message: 'The model declined to read this document' });\n} else {\n  const text = (res.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('');\n  try { inv = JSON.parse(text); } catch (e) {\n    issues.push({ code: 'unparseable', severity: 'high', message: `Extraction was not valid JSON (stop_reason ${res.stop_reason})` });\n  }\n}\n\nconst r2 = (x) => Math.round((Number(x) || 0) * 100) / 100;\nconst lines = (inv.line_items || []).map((l) => ({\n  description: String(l.description || '').trim(), sku: String(l.sku || '').trim(),\n  quantity: Number(l.quantity) || 0, unit_price: Number(l.unit_price) || 0, amount: r2(l.amount),\n}));\nfor (const [i, l] of lines.entries()) {\n  if (Math.abs(r2(l.quantity * l.unit_price) - l.amount) > 0.02 + l.amount * 0.001) {\n    issues.push({ code: 'line_math', severity: 'medium', message: `Line ${i + 1}: ${l.quantity} x ${l.unit_price} is not ${l.amount}` });\n  }\n}\nconst subtotal = r2(inv.subtotal);\nconst lineSum = r2(lines.reduce((s, l) => s + l.amount, 0));\nif (lines.length && Math.abs(lineSum - subtotal) > 0.05) {\n  issues.push({ code: 'subtotal_mismatch', severity: 'medium', message: `Lines add up to ${lineSum}, subtotal says ${subtotal}` });\n}\nconst total = r2(inv.total);\nconst expected = r2(subtotal + r2(inv.tax) + r2(inv.shipping) - r2(inv.discount));\nif (Math.abs(expected - total) > 0.05) {\n  issues.push({ code: 'total_mismatch', severity: 'medium', message: `Subtotal + tax + shipping - discount = ${expected}, total says ${total}` });\n}\n\nconst today = DateTime.now().startOf('day');\nconst invDate = inv.invoice_date ? DateTime.fromISO(inv.invoice_date) : null;\nlet dueDate = inv.due_date ? DateTime.fromISO(inv.due_date) : null;\nif (invDate && !invDate.isValid) issues.push({ code: 'bad_date', severity: 'medium', message: `Invoice date \"${inv.invoice_date}\" is not a date` });\nif (invDate?.isValid && invDate > today.plus({ days: 1 })) issues.push({ code: 'future_date', severity: 'medium', message: 'Invoice is dated in the future' });\nconst net = String(inv.payment_terms || '').match(/net\\s*(\\d{1,3})/i);\nif (!dueDate && invDate?.isValid && net) dueDate = invDate.plus({ days: Number(net[1]) });\nif (dueDate?.isValid && invDate?.isValid && dueDate < invDate) issues.push({ code: 'due_before_invoice', severity: 'medium', message: 'Due date is before the invoice date' });\n\n// Early-payment discount terms such as \"2/10 Net 30\"\nlet discountDeadline = null;\nlet discountAmount = 0;\nconst disc = String(inv.payment_terms || '').match(/(\\d+(?:\\.\\d+)?)\\s*%?\\s*\\/\\s*(\\d{1,3})\\s*,?\\s*net/i);\nif (disc && invDate?.isValid) {\n  discountDeadline = invDate.plus({ days: Number(disc[2]) });\n  discountAmount = r2(total * Number(disc[1]) / 100);\n}\nif (!inv.invoice_number && inv.is_invoice) issues.push({ code: 'no_invoice_number', severity: 'medium', message: 'No invoice number found' });\n\nreturn {\n  json: {\n    ...meta,\n    claudeRequest: undefined,\n    driveFileId: drive.id || null,\n    driveLink: drive.id ? `https://drive.google.com/file/d/${drive.id}/view` : null,\n    isInvoice: inv.is_invoice === true,\n    documentType: inv.document_type || 'other',\n    vendorName: String(inv.vendor_name || '').trim(),\n    vendorTaxId: String(inv.vendor_tax_id || '').trim(),\n    invoiceNumber: String(inv.invoice_number || '').trim(),\n    poNumber: String(inv.po_number || '').trim(),\n    invoiceDate: invDate?.isValid ? invDate.toISODate() : '',\n    dueDate: dueDate?.isValid ? dueDate.toISODate() : '',\n    currency: String(inv.currency || 'USD').toUpperCase(),\n    paymentTerms: inv.payment_terms || '',\n    lines, subtotal, total,\n    tax: r2(inv.tax), shipping: r2(inv.shipping), discount: r2(inv.discount),\n    bankLast4: String(inv.bank_account_last4 || '').replace(/\\D/g, '').slice(-4),\n    suspiciousSignals: inv.suspicious_signals || [],\n    confidence: Number(inv.confidence ?? 0),\n    discountDeadline: discountDeadline?.isValid ? discountDeadline.toISODate() : '',\n    discountAmount,\n    issues,\n  },\n};"
      },
      "id": "2c4970b9-b474-577d-9143-59f622b4d2ef",
      "name": "Parse & Validate Invoice",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2860,
        200
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "-- Vendor master (tax id > sender domain > fuzzy name), PO + goods receipts, duplicate history, GL map\nWITH v AS (\n  SELECT id, name, tax_id, email_domain, bank_last4, payment_terms_days, default_gl_account, qbo_vendor_id, status,\n         round(similarity(lower(name), lower($1))::numeric, 2) AS name_score\n  FROM vendors\n  WHERE ($2 <> '' AND tax_id = $2) OR email_domain = $3 OR lower(name) % lower($1)\n  ORDER BY ($2 <> '' AND tax_id = $2) DESC, (email_domain = $3) DESC, similarity(lower(name), lower($1)) DESC\n  LIMIT 1\n), po AS (\n  SELECT p.id, p.po_number, p.vendor_id, p.status, p.total_amount,\n         coalesce(json_agg(json_build_object(\n           'sku', l.sku, 'description', l.description, 'qty_ordered', l.qty_ordered,\n           'qty_received', l.qty_received, 'unit_price', l.unit_price, 'gl_account', l.gl_account\n         )) FILTER (WHERE l.id IS NOT NULL), '[]') AS lines\n  FROM purchase_orders p\n  LEFT JOIN po_lines l ON l.po_id = p.id\n  WHERE $4 <> '' AND upper(p.po_number) = upper($4)\n  GROUP BY p.id\n)\nSELECT\n  (SELECT row_to_json(v) FROM v) AS vendor,\n  (SELECT row_to_json(po) FROM po) AS po,\n  (SELECT count(*) FROM invoices i\n    WHERE i.vendor_id = (SELECT id FROM v) AND upper(i.invoice_number) = upper($5))::int AS duplicate_number,\n  (SELECT count(*) FROM invoices i\n    WHERE i.vendor_id = (SELECT id FROM v) AND i.total = $6\n      AND i.invoice_date BETWEEN nullif($7, '')::date - 7 AND nullif($7, '')::date + 7)::int AS near_duplicates,\n  (SELECT count(*) FROM invoices i WHERE i.vendor_id = (SELECT id FROM v))::int AS prior_invoices,\n  (SELECT json_object_agg(code, qbo_account_id) FROM gl_accounts) AS gl_map;",
        "options": {
          "queryReplacement": "={{ [ $json.vendorName, $json.vendorTaxId, $json.senderDomain, $json.poNumber, $json.invoiceNumber, $json.total, $json.invoiceDate ] }}"
        }
      },
      "id": "71772565-77c2-5c18-84a2-063d7f0a5cac",
      "name": "Match Vendor, PO & History",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        3120,
        200
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Fraud checks + 3-way match (PO x goods receipt x invoice) + GL coding + routing decision\nconst cfg = $('AP Config').first().json;\nconst inv = $('Parse & Validate Invoice').item.json;\nconst m = $json;\nconst vendor = m.vendor;\nconst po = m.po;\nconst glMap = m.gl_map || {};\nconst flags = [...inv.issues];\nconst add = (code, severity, message) => flags.push({ code, severity, message });\nconst r2 = (x) => Math.round((Number(x) || 0) * 100) / 100;\n\n// Vendor + fraud controls\nif (!vendor) add('new_vendor', 'high', `No vendor master match for \"${inv.vendorName}\"`);\nelse {\n  if (vendor.status === 'blocked') add('vendor_blocked', 'critical', `${vendor.name} is blocked in the vendor master`);\n  if (inv.bankLast4 && vendor.bank_last4 && inv.bankLast4 !== vendor.bank_last4) {\n    add('bank_details_changed', 'critical', `Bank account on invoice ends ${inv.bankLast4}, vendor master says ${vendor.bank_last4}`);\n  }\n  if (vendor.email_domain && inv.senderDomain && inv.senderDomain !== vendor.email_domain) {\n    add('sender_domain_mismatch', 'high', `Sent from ${inv.senderDomain}, vendor domain is ${vendor.email_domain}`);\n  }\n  if (!vendor.qbo_vendor_id) add('vendor_not_in_accounting', 'high', 'Vendor has no QuickBooks vendor id');\n}\nfor (const s of inv.suspiciousSignals) add('ai_suspicious', 'high', `AI noticed: ${s}`);\nif (m.duplicate_number > 0) add('duplicate_invoice', 'critical', `Invoice ${inv.invoiceNumber} from this vendor is already on file`);\nelse if (m.near_duplicates > 0) add('possible_duplicate', 'high', 'Same vendor, same amount within 7 days');\nif (inv.confidence < 0.8) add('low_confidence', 'medium', `Extraction confidence ${inv.confidence}`);\n\n// 3-way match\nconst words = (s) => new Set(String(s).toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2));\nconst overlap = (a, b) => { const A = words(a); const B = words(b); const hit = [...A].filter((w) => B.has(w)).length; return hit / Math.max(1, Math.min(A.size, B.size)); };\nconst used = new Set();\nlet matchedAll = Boolean(po) && inv.lines.length > 0;\nconst lines = inv.lines.map((l, i) => {\n  let gl = vendor?.default_gl_account || '6999';\n  if (po) {\n    let idx = po.lines.findIndex((p, j) => !used.has(j) && p.sku && l.sku && p.sku.toUpperCase() === l.sku.toUpperCase());\n    if (idx < 0) {\n      let best = -1; let score = 0;\n      po.lines.forEach((p, j) => { const s = overlap(p.description, l.description); if (!used.has(j) && s > score) { score = s; best = j; } });\n      if (score >= 0.5) idx = best;\n    }\n    if (idx < 0) { matchedAll = false; add('unmatched_line', 'high', `Line ${i + 1} \"${l.description}\" is not on ${po.po_number}`); }\n    else {\n      used.add(idx);\n      const p = po.lines[idx];\n      gl = p.gl_account || gl;\n      const variance = ((l.unit_price - Number(p.unit_price)) / Number(p.unit_price)) * 100;\n      if (Math.abs(variance) > cfg.priceTolerancePct) { matchedAll = false; add('price_variance', 'high', `Line ${i + 1}: price ${l.unit_price} vs PO ${p.unit_price} (${variance.toFixed(1)}%)`); }\n      if (l.quantity > Number(p.qty_received) + cfg.qtyTolerance) { matchedAll = false; add('qty_exceeds_received', 'high', `Line ${i + 1}: billed ${l.quantity}, received ${p.qty_received}`); }\n    }\n  }\n  return { ...l, gl };\n});\nif (inv.poNumber && !po) add('po_not_found', 'high', `PO ${inv.poNumber} does not exist`);\nif (po && vendor && po.vendor_id !== vendor.id) add('po_vendor_mismatch', 'critical', `${po.po_number} belongs to another vendor`);\n\n// Decision (strictest rule wins)\nconst has = (sev) => flags.some((f) => f.severity === sev);\nconst severityWeight = { critical: 60, high: 25, medium: 10 };\nconst riskScore = Math.min(100, flags.reduce((s, f) => s + (severityWeight[f.severity] || 0), 0));\nlet decision; let autoReason = '';\nif (!inv.isInvoice) decision = 'ignore';\nelse if (flags.some((f) => f.severity === 'critical' && f.code !== 'duplicate_invoice')) decision = 'fraud_hold';\nelse if (flags.some((f) => f.code === 'duplicate_invoice')) decision = 'reject_duplicate';\nelse if (has('high') || has('medium')) decision = 'needs_review';\nelse if (po && matchedAll) { decision = 'auto_approve'; autoReason = `clean 3-way match on ${po.po_number}`; }\nelse if (!po && inv.total <= cfg.autoApproveLimit && m.prior_invoices > 0) { decision = 'auto_approve'; autoReason = `known vendor, under ${cfg.autoApproveLimit} ${inv.currency}`; }\nelse decision = 'needs_approval';\n\n// Pay on the discount deadline when there is one, otherwise on the due date\nconst today = DateTime.now().toISODate();\nconst scheduledPayDate = inv.discountDeadline && inv.discountDeadline >= today ? inv.discountDeadline : (inv.dueDate || today);\n\nconst acct = (code) => glMap[code] || glMap['6999'];\nconst billLines = lines.map((l) => ({ DetailType: 'AccountBasedExpenseLineDetail', Amount: l.amount,\n  Description: `${l.sku ? l.sku + ' ' : ''}${l.description}`.slice(0, 4000), AccountBasedExpenseLineDetail: { AccountRef: { value: acct(l.gl) } } }));\nif (inv.shipping) billLines.push({ DetailType: 'AccountBasedExpenseLineDetail', Amount: inv.shipping, Description: 'Shipping', AccountBasedExpenseLineDetail: { AccountRef: { value: acct('6100') } } });\nif (inv.tax) billLines.push({ DetailType: 'AccountBasedExpenseLineDetail', Amount: inv.tax, Description: 'Tax', AccountBasedExpenseLineDetail: { AccountRef: { value: acct('6990') } } });\nif (inv.discount) billLines.push({ DetailType: 'AccountBasedExpenseLineDetail', Amount: -inv.discount, Description: 'Discount', AccountBasedExpenseLineDetail: { AccountRef: { value: acct('6999') } } });\n\nreturn {\n  json: {\n    ...inv,\n    issues: undefined,\n    vendorId: vendor?.id ?? null,\n    vendorName: vendor?.name || inv.vendorName,\n    lines, flags, riskScore, decision, autoReason, scheduledPayDate,\n    qboBill: {\n      VendorRef: { value: String(vendor?.qbo_vendor_id || '') },\n      TxnDate: inv.invoiceDate || today,\n      ...(inv.dueDate ? { DueDate: inv.dueDate } : {}),\n      DocNumber: inv.invoiceNumber.slice(0, 21),\n      PrivateNote: `Captured by n8n AP autopilot. Risk ${riskScore}/100. ${inv.driveLink || ''}`.slice(0, 4000),\n      Line: billLines,\n    },\n  },\n};"
      },
      "id": "0bd312df-6ee2-564c-afef-f47b07e8e177",
      "name": "Decision Engine",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3380,
        200
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "-- One JSON document in, typed columns out; the file hash makes re-deliveries idempotent\nWITH d AS (SELECT $1::jsonb AS j),\nins AS (\n  INSERT INTO invoices (file_hash, gmail_message_id, gmail_thread_id, sender_email, file_name, drive_file_id,\n                        vendor_id, vendor_name, invoice_number, po_number, invoice_date, due_date, currency,\n                        subtotal, tax, total, payment_terms, discount_deadline, discount_amount,\n                        scheduled_pay_date, lines, flags, risk_score, decision, status)\n  SELECT j->>'fileHash', j->>'messageId', j->>'threadId', j->>'senderEmail', j->>'fileName', j->>'driveFileId',\n         (j->>'vendorId')::bigint, j->>'vendorName', j->>'invoiceNumber', nullif(j->>'poNumber', ''),\n         nullif(j->>'invoiceDate', '')::date, nullif(j->>'dueDate', '')::date, j->>'currency',\n         (j->>'subtotal')::numeric, (j->>'tax')::numeric, (j->>'total')::numeric, j->>'paymentTerms',\n         nullif(j->>'discountDeadline', '')::date, (j->>'discountAmount')::numeric,\n         nullif(j->>'scheduledPayDate', '')::date, j->'lines', j->'flags', (j->>'riskScore')::int,\n         j->>'decision',\n         CASE j->>'decision' WHEN 'reject_duplicate' THEN 'rejected' WHEN 'fraud_hold' THEN 'on_hold'\n                             WHEN 'ignore' THEN 'ignored' ELSE 'pending' END\n  FROM d\n  ON CONFLICT (file_hash) DO NOTHING\n  RETURNING id, total\n)\nSELECT ins.id AS invoice_id, a.approver_name, a.approver_email, a.slack_user_id AS approver_slack_id\nFROM ins\nLEFT JOIN LATERAL (\n  SELECT approver_name, approver_email, slack_user_id\n  FROM approval_matrix\n  WHERE ins.total >= min_amount AND (max_amount IS NULL OR ins.total < max_amount)\n  ORDER BY min_amount DESC\n  LIMIT 1\n) a ON true;",
        "options": {
          "queryReplacement": "={{ [ JSON.stringify($json) ] }}"
        }
      },
      "id": "09654441-f74b-50bb-8bec-0294f24dc386",
      "name": "Record Invoice",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        3640,
        200
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "1b722657-5bec-5aa9-8f8b-89d74897cf3b",
                    "leftValue": "={{ $('Decision Engine').item.json.decision }}",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "rightValue": "auto_approve"
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Auto-Approve"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "9c08976f-e280-5d09-9601-e77024816b15",
                    "leftValue": "={{ $('Decision Engine').item.json.decision }}",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "rightValue": "needs_approval"
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Needs Approval"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "49dfa8b6-7f7e-52e3-b59a-e316da846af9",
                    "leftValue": "={{ $('Decision Engine').item.json.decision }}",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "rightValue": "needs_review"
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "AP Review"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "89b62370-ac33-5826-becb-428a21fa5e65",
                    "leftValue": "={{ $('Decision Engine').item.json.decision }}",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "rightValue": "fraud_hold"
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Fraud Hold"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "e1b292ca-c19a-551f-8501-3888a7d78a8f",
                    "leftValue": "={{ $('Decision Engine').item.json.decision }}",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "rightValue": "reject_duplicate"
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Duplicate"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "6eb678e8-0619-5b8b-9219-b01ced7d7097",
                    "leftValue": "={{ $('Decision Engine').item.json.decision }}",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "rightValue": "ignore"
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Not an Invoice"
            }
          ]
        },
        "options": {}
      },
      "id": "0f68be30-9615-5f73-af63-88bbef3838d9",
      "name": "Route Decision",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        780,
        919
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE invoices SET status = 'approved', approved_by = $2, approved_at = now(), updated_at = now()\nWHERE id = $1\nRETURNING id;",
        "options": {
          "queryReplacement": "={{ [ $('Record Invoice').item.json.invoice_id, 'auto: ' + $('Decision Engine').item.json.autoReason ] }}"
        }
      },
      "id": "3e9745f0-64fd-5ef6-bc9c-5e8179dfe83f",
      "name": "Approve: Auto",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        1118,
        720
      ]
    },
    {
      "parameters": {
        "operation": "sendAndWait",
        "select": "user",
        "user": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Record Invoice').item.json.approver_slack_id }}"
        },
        "message": "=*Invoice approval needed*: {{ $('Decision Engine').item.json.vendorName }} #{{ $('Decision Engine').item.json.invoiceNumber }}\n*Amount:* {{ $('Decision Engine').item.json.currency }} {{ $('Decision Engine').item.json.total }}   *Due:* {{ $('Decision Engine').item.json.dueDate || 'n/a' }}   *Pay on:* {{ $('Decision Engine').item.json.scheduledPayDate }}{{ $('Decision Engine').item.json.discountAmount ? '   *Early-pay discount:* ' + $('Decision Engine').item.json.discountAmount + ' if paid by ' + $('Decision Engine').item.json.discountDeadline : '' }}\n*PO:* {{ $('Decision Engine').item.json.poNumber || 'none (non-PO spend)' }}   *Risk:* {{ $('Decision Engine').item.json.riskScore }}/100\n*Lines:*\n{{ $('Decision Engine').item.json.lines.map(l => '• ' + l.description + ': ' + l.quantity + ' x ' + l.unit_price + ' = ' + l.amount + ' (GL ' + l.gl + ')').join('\\n') }}\n<{{ $('Decision Engine').item.json.driveLink }}|View the invoice>\nYou are the approver for this amount band.",
        "approvalOptions": {
          "values": {
            "approvalType": "double",
            "approveLabel": "✅ Approve",
            "disapproveLabel": "❌ Reject"
          }
        },
        "options": {
          "limitWaitTime": {
            "values": {
              "limitType": "afterTimeInterval",
              "resumeAmount": 48,
              "resumeUnit": "hours"
            }
          }
        }
      },
      "id": "a15f03dd-f82c-56e3-bde9-ecdd0de2dbb2",
      "name": "Slack: Request Approval",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1118,
        900
      ],
      "webhookId": "9935095d-8c7f-56b7-b931-ba319cabbdbb"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "fa6460d6-df07-5025-81d8-e64eaa832a3b",
              "leftValue": "={{ $json.data?.approved }}",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "rightValue": ""
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "c7273b81-b71f-5c09-bf3d-9060aa34e6a2",
      "name": "Approved?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1430,
        900
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE invoices SET status = 'approved', approved_by = $2, approved_at = now(), updated_at = now()\nWHERE id = $1\nRETURNING id;",
        "options": {
          "queryReplacement": "={{ [ $('Record Invoice').item.json.invoice_id, $('Record Invoice').item.json.approver_name || 'approver' ] }}"
        }
      },
      "id": "4cd46033-b1c4-5efb-917b-a4522b9f2dba",
      "name": "Approve: Human",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        1716,
        840
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE invoices SET status = 'declined', approved_by = $2, updated_at = now()\nWHERE id = $1\nRETURNING id, vendor_name, invoice_number, total, currency;",
        "options": {
          "queryReplacement": "={{ [ $('Record Invoice').item.json.invoice_id, ($('Record Invoice').item.json.approver_name || 'approver') + ($json.data?.approved === false ? ' (rejected)' : ' (no response in 48h)') ] }}"
        }
      },
      "id": "9822ac22-4825-5362-bf32-f86ad9346ffa",
      "name": "Record Decline",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        1716,
        1019
      ]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('AP Config').first().json.apSlackChannelId }}"
        },
        "text": "=:no_entry: {{ $json.vendor_name }} #{{ $json.invoice_number }} ({{ $json.currency }} {{ $json.total }}) was rejected or the approval window expired. Please follow up with the approver before anything is paid.",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "3a3fdbf3-ead9-5083-80fd-5db4c88c0c06",
      "name": "Slack: Declined",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        2002,
        1019
      ]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('AP Config').first().json.apSlackChannelId }}"
        },
        "text": "=:mag: *Invoice needs AP review*: {{ $('Decision Engine').item.json.vendorName || $('Decision Engine').item.json.senderEmail }} #{{ $('Decision Engine').item.json.invoiceNumber || 'n/a' }}, {{ $('Decision Engine').item.json.currency }} {{ $('Decision Engine').item.json.total }}\n{{ $('Decision Engine').item.json.flags.map(f => '• [' + f.severity + '] ' + f.message).join('\\n') }}\n<{{ $('Decision Engine').item.json.driveLink }}|View document>  risk {{ $('Decision Engine').item.json.riskScore }}/100  invoice id {{ $('Record Invoice').item.json.invoice_id }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "2b14f4fa-e1f9-5d72-981b-4941742895e2",
      "name": "Slack: AP Review Needed",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1118,
        1100
      ]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('AP Config').first().json.apSlackChannelId }}"
        },
        "text": "=:rotating_light: <!here> *Possible invoice fraud, payment held*: {{ $('Decision Engine').item.json.vendorName || $('Decision Engine').item.json.senderEmail }} #{{ $('Decision Engine').item.json.invoiceNumber || 'n/a' }} for {{ $('Decision Engine').item.json.currency }} {{ $('Decision Engine').item.json.total }}\n{{ $('Decision Engine').item.json.flags.map(f => '• [' + f.severity + '] ' + f.message).join('\\n') }}\n*Do not reply to the sender or pay.* Verify by phoning the vendor on the number in the vendor master, never the one on the invoice.\n<{{ $('Decision Engine').item.json.driveLink }}|View document>  invoice id {{ $('Record Invoice').item.json.invoice_id }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "2e6472f8-17b5-5e84-bfb0-94e0f261d8cc",
      "name": "Slack: Fraud Alert",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1118,
        1260
      ]
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "reply",
        "messageId": "={{ $('Decision Engine').item.json.messageId }}",
        "emailType": "html",
        "message": "=<p>Hello,</p><p>Thanks for your email. Invoice <b>{{ $('Decision Engine').item.json.invoiceNumber }}</b> is already on file with us, so no further action is needed on your side.</p><p>Accounts Payable<br>{{ $('AP Config').first().json.companyName }}</p>",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "f0dde5fd-0914-5de7-8261-088964c016d0",
      "name": "Gmail: Duplicate Notice",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        1118,
        1420
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('AP Config').first().json.qboBaseUrl }}/v3/company/{{ $('AP Config').first().json.qboRealmId }}/bill?minorversion=75",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "quickBooksOAuth2Api",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Accept",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($('Decision Engine').item.json.qboBill) }}",
        "options": {
          "timeout": 30000
        }
      },
      "id": "90846c7d-4a19-5185-9db6-d70dd26b8316",
      "name": "Post Bill to QuickBooks",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2028,
        760
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('AP Config').first().json.apSlackChannelId }}"
        },
        "text": "=:warning: QuickBooks rejected the bill for {{ $('Decision Engine').item.json.vendorName }} #{{ $('Decision Engine').item.json.invoiceNumber }}: {{ $json.error?.message || 'unknown error' }}. Invoice {{ $('Record Invoice').item.json.invoice_id }} is approved but NOT posted.",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "2deca1eb-ba96-55ca-8f26-70b479e7f31a",
      "name": "Slack: QuickBooks Sync Failed",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        2314,
        919
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE invoices SET status = 'scheduled', qbo_bill_id = $2, updated_at = now()\nWHERE id = $1\nRETURNING id, vendor_name, invoice_number, total, currency,\n          scheduled_pay_date::text AS scheduled_pay_date, due_date::text AS due_date,   -- text: avoid JS Date timezone shifts\n          discount_amount, discount_deadline::text AS discount_deadline, approved_by, gmail_message_id;",
        "options": {
          "queryReplacement": "={{ [ $('Record Invoice').item.json.invoice_id, $json.Bill?.Id ?? null ] }}"
        }
      },
      "id": "7f12ac41-0c85-5127-b914-017eb1893bec",
      "name": "Schedule Payment",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        2314,
        760
      ]
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "reply",
        "messageId": "={{ $json.gmail_message_id }}",
        "emailType": "html",
        "message": "=<p>Hello,</p><p>Thanks for sending invoice <b>{{ $json.invoice_number }}</b> for {{ $json.currency }} {{ Number($json.total).toFixed(2) }}. It has been approved and is scheduled for payment on <b>{{ $json.scheduled_pay_date }}</b>.</p><p>Accounts Payable<br>{{ $('AP Config').first().json.companyName }}</p>",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "ad59696a-ced9-5174-94d9-4abf67e4b63c",
      "name": "Gmail: Confirm to Vendor",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        2600,
        760
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "addLabels",
        "messageId": "={{ $('Schedule Payment').item.json.gmail_message_id }}",
        "labelIds": "={{ [ $('AP Config').first().json.processedLabelId ] }}"
      },
      "id": "c6e00e79-a45f-514d-8cc3-04676ef0f989",
      "name": "Gmail: Label Processed",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        2860,
        760
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('AP Config').first().json.apSlackChannelId }}"
        },
        "text": "=:white_check_mark: *Bill posted to QuickBooks*: {{ $('Schedule Payment').item.json.vendor_name }} #{{ $('Schedule Payment').item.json.invoice_number }}, {{ $('Schedule Payment').item.json.currency }} {{ $('Schedule Payment').item.json.total }}\nPay on {{ $('Schedule Payment').item.json.scheduled_pay_date }}{{ Number($('Schedule Payment').item.json.discount_amount) > 0 ? ' (captures ' + $('Schedule Payment').item.json.currency + ' ' + $('Schedule Payment').item.json.discount_amount + ' early-pay discount)' : '' }}, approved by {{ $('Schedule Payment').item.json.approved_by }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "6efdb96c-5ef6-5118-958e-debf81cc1a3c",
      "name": "Slack: AP Log",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        3120,
        760
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT\n  (SELECT count(*) FROM invoices WHERE status = 'pending')::int AS pending,\n  (SELECT count(*) FROM invoices WHERE status = 'on_hold')::int AS fraud_holds,\n  (SELECT count(*) FROM invoices WHERE created_at > now() - interval '1 day')::int AS processed_24h,\n  (SELECT count(*) FROM invoices WHERE status = 'scheduled' AND scheduled_pay_date <= current_date + 7)::int AS due_7d_count,\n  (SELECT coalesce(sum(total), 0) FROM invoices WHERE status = 'scheduled' AND scheduled_pay_date <= current_date + 7) AS due_7d_amount,\n  (SELECT coalesce(sum(discount_amount), 0) FROM invoices\n    WHERE status = 'scheduled' AND discount_deadline BETWEEN current_date AND current_date + 3) AS discounts_expiring,\n  (SELECT coalesce(sum(discount_amount), 0) FROM invoices\n    WHERE status IN ('scheduled', 'paid') AND discount_deadline IS NOT NULL\n      AND created_at >= date_trunc('month', now())) AS discounts_captured_mtd,\n  (SELECT round(100.0 * count(*) FILTER (WHERE decision = 'auto_approve') / nullif(count(*), 0))\n     FROM invoices WHERE created_at > now() - interval '30 days' AND decision <> 'ignore') AS touchless_rate,\n  (SELECT coalesce(json_agg(t), '[]') FROM (\n     SELECT vendor_name, invoice_number, total, currency, created_at::date AS received\n     FROM invoices WHERE status = 'pending' ORDER BY created_at LIMIT 5) t) AS oldest_pending;",
        "options": {}
      },
      "id": "037c8ee9-6ae4-5339-9475-50a01c9e2bd6",
      "name": "AP Snapshot",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        780,
        1760
      ]
    },
    {
      "parameters": {
        "jsCode": "// Morning AP digest for Slack\nconst s = $input.first().json;\nconst cfg = $('AP Config').first().json;\nconst money = (x) => Number(x || 0).toLocaleString('en-US', { style: 'currency', currency: cfg.baseCurrency });\nconst oldest = (s.oldest_pending || []).map((p) => `• ${p.vendor_name} #${p.invoice_number}: ${money(p.total)} (since ${p.received})`).join('\\n');\nconst text = [\n  `:sunrise: *AP morning digest* for ${cfg.companyName}`,\n  `*Last 24h:* ${s.processed_24h} documents processed · *Touchless rate (30d):* ${s.touchless_rate ?? 0}%`,\n  `*Waiting on people:* ${s.pending} invoices pending review/approval${s.fraud_holds ? ` · :rotating_light: ${s.fraud_holds} on fraud hold` : ''}`,\n  `*Cash out next 7 days:* ${money(s.due_7d_amount)} across ${s.due_7d_count} scheduled payments`,\n  `*Early-pay discounts:* ${money(s.discounts_captured_mtd)} captured this month${Number(s.discounts_expiring) ? ` · ${money(s.discounts_expiring)} expiring within 3 days` : ''}`,\n  oldest ? `*Oldest pending:*\\n${oldest}` : '*Oldest pending:* none, inbox zero :tada:',\n].join('\\n');\nreturn [{ json: { text } }];"
      },
      "id": "2417272d-6443-54bc-961e-2d102205b5d6",
      "name": "Format Digest",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1040,
        1760
      ]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('AP Config').first().json.apSlackChannelId }}"
        },
        "text": "={{ $json.text }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "ee501fad-c4af-5c27-a72f-715059bc81e8",
      "name": "Slack: Morning Digest",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1300,
        1760
      ]
    },
    {
      "parameters": {},
      "id": "808da3a8-334f-5630-a71f-936164bf06ca",
      "name": "Run Once: Create Tables",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        2120
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "-- AP autopilot schema + demo vendor master. Safe to re-run.\nCREATE EXTENSION IF NOT EXISTS pg_trgm;\n\nCREATE TABLE IF NOT EXISTS gl_accounts (\n  code           text PRIMARY KEY,\n  name           text NOT NULL,\n  qbo_account_id text NOT NULL             -- QuickBooks Online Account.Id\n);\n\nCREATE TABLE IF NOT EXISTS vendors (\n  id                 bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  name               text NOT NULL,\n  tax_id             text NOT NULL DEFAULT '',\n  email_domain       text,\n  bank_last4         text,                 -- only the last 4 digits are ever stored\n  payment_terms_days int NOT NULL DEFAULT 30,\n  default_gl_account text REFERENCES gl_accounts(code),\n  qbo_vendor_id      text,\n  status             text NOT NULL DEFAULT 'active'   -- active | blocked\n);\nCREATE INDEX IF NOT EXISTS vendors_name_trgm ON vendors USING gin (lower(name) gin_trgm_ops);\n\nCREATE TABLE IF NOT EXISTS purchase_orders (\n  id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  po_number    text UNIQUE NOT NULL,\n  vendor_id    bigint REFERENCES vendors(id),\n  status       text NOT NULL DEFAULT 'open',\n  total_amount numeric(12,2)\n);\n\nCREATE TABLE IF NOT EXISTS po_lines (\n  id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  po_id        bigint NOT NULL REFERENCES purchase_orders(id) ON DELETE CASCADE,\n  sku          text,\n  description  text NOT NULL,\n  qty_ordered  numeric(12,2) NOT NULL,\n  qty_received numeric(12,2) NOT NULL DEFAULT 0,  -- goods receipt, the third leg of the match\n  unit_price   numeric(12,4) NOT NULL,\n  gl_account   text REFERENCES gl_accounts(code)\n);\n\nCREATE TABLE IF NOT EXISTS approval_matrix (\n  id             bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  min_amount     numeric(12,2) NOT NULL,\n  max_amount     numeric(12,2),            -- NULL = no upper limit\n  approver_name  text NOT NULL,\n  approver_email text,\n  slack_user_id  text NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS invoices (\n  id                 bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  file_hash          text UNIQUE NOT NULL,\n  gmail_message_id   text,\n  gmail_thread_id    text,\n  sender_email       text,\n  file_name          text,\n  drive_file_id      text,\n  vendor_id          bigint REFERENCES vendors(id),\n  vendor_name        text,\n  invoice_number     text,\n  po_number          text,\n  invoice_date       date,\n  due_date           date,\n  currency           text,\n  subtotal           numeric(12,2),\n  tax                numeric(12,2),\n  total              numeric(12,2),\n  payment_terms      text,\n  discount_deadline  date,\n  discount_amount    numeric(12,2),\n  scheduled_pay_date date,\n  lines              jsonb,\n  flags              jsonb,\n  risk_score         int,\n  decision           text,\n  status             text NOT NULL DEFAULT 'pending',\n  approved_by        text,\n  approved_at        timestamptz,\n  qbo_bill_id        text,\n  created_at         timestamptz NOT NULL DEFAULT now(),\n  updated_at         timestamptz NOT NULL DEFAULT now()\n);\nCREATE INDEX IF NOT EXISTS invoices_vendor_number_idx ON invoices (vendor_id, upper(invoice_number));\nCREATE INDEX IF NOT EXISTS invoices_status_idx ON invoices (status, scheduled_pay_date);\n\n-- Demo data -------------------------------------------------------------\nINSERT INTO gl_accounts (code, name, qbo_account_id) VALUES\n  ('5000', 'Cost of Goods Sold', '80'),\n  ('6100', 'Freight & Shipping', '81'),\n  ('6400', 'Software & Subscriptions', '82'),\n  ('6990', 'Taxes & Fees', '84'),\n  ('6999', 'Uncategorized Expense', '83')\nON CONFLICT (code) DO NOTHING;\n\nINSERT INTO vendors (name, tax_id, email_domain, bank_last4, payment_terms_days, default_gl_account, qbo_vendor_id)\nSELECT * FROM (VALUES\n  ('Acme Industrial Supply', '12-3456789', 'acmeindustrial.com', '4821', 30, '5000', '56'),\n  ('Brightwave Logistics',   '98-7654321', 'brightwave.io',      '1177', 15, '6100', '57'),\n  ('Cloudline Software',     '',           'cloudline.app',      '9034', 30, '6400', '58')\n) AS v(name, tax_id, email_domain, bank_last4, payment_terms_days, default_gl_account, qbo_vendor_id)\nWHERE NOT EXISTS (SELECT 1 FROM vendors);\n\nINSERT INTO purchase_orders (po_number, vendor_id, total_amount)\nSELECT 'PO-1001', id, 1940.00 FROM vendors WHERE name = 'Acme Industrial Supply'\nON CONFLICT (po_number) DO NOTHING;\n\nINSERT INTO po_lines (po_id, sku, description, qty_ordered, qty_received, unit_price, gl_account)\nSELECT p.id, v.sku, v.description, v.qty, v.received, v.price, '5000'\nFROM purchase_orders p,\n     (VALUES ('AC-100', 'Steel mounting brackets', 500, 500, 2.40),\n             ('AC-220', 'Hex bolts M8, box of 200', 40, 40, 18.50)) AS v(sku, description, qty, received, price)\nWHERE p.po_number = 'PO-1001' AND NOT EXISTS (SELECT 1 FROM po_lines);\n\nINSERT INTO approval_matrix (min_amount, max_amount, approver_name, approver_email, slack_user_id)\nSELECT * FROM (VALUES\n  (0::numeric,     5000::numeric,  'Jordan Lee (AP Manager)',    'jordan@example.com', 'U01AAAAAAA'),\n  (5000::numeric,  25000::numeric, 'Sam Rivera (Finance Director)', 'sam@example.com', 'U01BBBBBBB'),\n  (25000::numeric, NULL::numeric,  'Taylor Brooks (CFO)',        'taylor@example.com', 'U01CCCCCCC')\n) AS v(min_amount, max_amount, approver_name, approver_email, slack_user_id)\nWHERE NOT EXISTS (SELECT 1 FROM approval_matrix);\n",
        "options": {}
      },
      "id": "6be7b40d-ac6b-5b4b-8adc-8712e2d11488",
      "name": "Create Schema & Demo Data",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        260,
        2120
      ]
    }
  ],
  "pinData": {},
  "connections": {
    "Gmail: AP Inbox": {
      "main": [
        [
          {
            "node": "AP Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Weekdays 8 AM": {
      "main": [
        [
          {
            "node": "AP Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AP Config": {
      "main": [
        [
          {
            "node": "Route by Trigger",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by Trigger": {
      "main": [
        [
          {
            "node": "Split Attachments",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "AP Snapshot",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split Attachments": {
      "main": [
        [
          {
            "node": "Fingerprint File (SHA-256)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fingerprint File (SHA-256)": {
      "main": [
        [
          {
            "node": "Rejoin File + Check",
            "type": "main",
            "index": 0
          },
          {
            "node": "Already Processed?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Already Processed?": {
      "main": [
        [
          {
            "node": "Rejoin File + Check",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Rejoin File + Check": {
      "main": [
        [
          {
            "node": "New Files Only",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "New Files Only": {
      "main": [
        [
          {
            "node": "Prepare Claude Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Claude Request": {
      "main": [
        [
          {
            "node": "Archive to Google Drive",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Archive to Google Drive": {
      "main": [
        [
          {
            "node": "Claude: Extract Invoice",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude: Extract Invoice": {
      "main": [
        [
          {
            "node": "Parse & Validate Invoice",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Slack: Extraction Failed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse & Validate Invoice": {
      "main": [
        [
          {
            "node": "Match Vendor, PO & History",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Match Vendor, PO & History": {
      "main": [
        [
          {
            "node": "Decision Engine",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Decision Engine": {
      "main": [
        [
          {
            "node": "Record Invoice",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Record Invoice": {
      "main": [
        [
          {
            "node": "Route Decision",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route Decision": {
      "main": [
        [
          {
            "node": "Approve: Auto",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Slack: Request Approval",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Slack: AP Review Needed",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Slack: Fraud Alert",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Gmail: Duplicate Notice",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Slack: Request Approval": {
      "main": [
        [
          {
            "node": "Approved?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Approved?": {
      "main": [
        [
          {
            "node": "Approve: Human",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Record Decline",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Record Decline": {
      "main": [
        [
          {
            "node": "Slack: Declined",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Approve: Auto": {
      "main": [
        [
          {
            "node": "Post Bill to QuickBooks",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Approve: Human": {
      "main": [
        [
          {
            "node": "Post Bill to QuickBooks",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Post Bill to QuickBooks": {
      "main": [
        [
          {
            "node": "Schedule Payment",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Slack: QuickBooks Sync Failed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Schedule Payment": {
      "main": [
        [
          {
            "node": "Gmail: Confirm to Vendor",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Gmail: Confirm to Vendor": {
      "main": [
        [
          {
            "node": "Gmail: Label Processed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Gmail: Label Processed": {
      "main": [
        [
          {
            "node": "Slack: AP Log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AP Snapshot": {
      "main": [
        [
          {
            "node": "Format Digest",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Digest": {
      "main": [
        [
          {
            "node": "Slack: Morning Digest",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run Once: Create Tables": {
      "main": [
        [
          {
            "node": "Create Schema & Demo Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "saveManualExecutions": true,
    "saveDataErrorExecution": "all",
    "saveDataSuccessExecution": "all"
  },
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}