{
  "name": "Lumeor | Accounts payable approvals (QuickBooks + Slack)",
  "nodes": [
    {
      "parameters": {
        "content": "## Accounts payable approvals (QuickBooks Online + Slack)\n\n**What it does**\n1. Receives one bill ID per call on a webhook (from the invoice processing template, a QuickBooks webhook relay, or any system that creates bills).\n2. Loads the bill and the vendor's last 12 bills from QuickBooks Online.\n3. Picks the approver from an approval matrix in Google Sheets.\n4. Computes history and flags in code, then Claude writes a short brief.\n5. Sends the approver a Slack message with Approve and Reject buttons and waits.\n6. Escalates to the delegate if nobody answers in 24 hours, and logs every decision with a fingerprint.\n\n**Setup**\n1. Credentials: Header Auth (for the webhook), QuickBooks Online OAuth2, Anthropic, Google Sheets, Slack.\n2. In **Config**, set qboRealmId, apLeadSlackId (fallback approver) and qboAppUrl.\n3. Create a sheet with tabs \"Approval matrix\" (rule_id, min_amount, max_amount, department, gl_account_id, vendor_id, approver_slack_id, approver_name, delegate_slack_id, second_approver_slack_id) and \"Approvals log\" (headers listed in the log node). Replace REPLACE_WITH_SHEET_ID.\n4. Replace REPLACE_WITH_AP_CHANNEL in the two Slack channel nodes.\n5. Change the wait limit in **Ask approver in Slack** if 24 hours is wrong for you.\n\nModel: claude-opus-5-5 at low effort. claude-haiku-5-5 is a cheaper option for high volume.\n\nTemplate by Lumeor Studio, https://www.lumeor.studio/guides/automate-accounts-payable-approvals/",
        "height": 720,
        "width": 520
      },
      "id": "d995d21b-cce6-4131-959d-97fcfd5f85f3",
      "name": "Template notes",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -620,
        -260
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "lumeor-ap-approval",
        "authentication": "headerAuth",
        "responseMode": "onReceived",
        "options": {}
      },
      "id": "6c912b64-0bf7-4ca9-8792-328e4ff8b128",
      "name": "New bill to approve",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        0
      ],
      "webhookId": "5b421684-2aae-4f92-97ad-929aa3520291"
    },
    {
      "parameters": {
        "mode": "manual",
        "assignments": {
          "assignments": [
            {
              "id": "aa53ea6e-e5e7-4669-a6d6-61c419f6ace6",
              "name": "qboBaseUrl",
              "value": "https://quickbooks.api.intuit.com",
              "type": "string"
            },
            {
              "id": "7a28896b-308f-4f9c-bc0f-ea21b8c314c7",
              "name": "qboRealmId",
              "value": "REPLACE_WITH_QBO_COMPANY_ID",
              "type": "string"
            },
            {
              "id": "30ed0251-40da-4396-8259-01c4f0b5ff1c",
              "name": "qboAppUrl",
              "value": "https://app.qbo.intuit.com",
              "type": "string"
            },
            {
              "id": "ebab58cf-c47c-4acb-84ac-e7a997891da1",
              "name": "apLeadSlackId",
              "value": "REPLACE_WITH_AP_LEAD_SLACK_USER_ID",
              "type": "string"
            },
            {
              "id": "cbaf79ce-fd13-41ca-9baf-96abcb45d5e0",
              "name": "priceJumpPct",
              "value": 25,
              "type": "number"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {
          "stripBinary": false
        }
      },
      "id": "13088b8d-99cf-4b28-be39-ef62c3aa26ae",
      "name": "Config",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        0
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $('Config').first().json.qboBaseUrl }}/v3/company/{{ $('Config').first().json.qboRealmId }}/bill/{{ encodeURIComponent($json.body.billId) }}",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "quickBooksOAuth2Api",
        "sendQuery": true,
        "specifyQuery": "keypair",
        "queryParameters": {
          "parameters": [
            {
              "name": "minorversion",
              "value": "75"
            }
          ]
        },
        "sendHeaders": true,
        "specifyHeaders": "keypair",
        "headerParameters": {
          "parameters": [
            {
              "name": "Accept",
              "value": "application/json"
            }
          ]
        },
        "options": {}
      },
      "id": "5a04d48d-2b00-4660-a303-0a5c2ff8bd63",
      "name": "QBO: get bill",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        440,
        0
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $('Config').first().json.qboBaseUrl }}/v3/company/{{ $('Config').first().json.qboRealmId }}/query",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "quickBooksOAuth2Api",
        "sendQuery": true,
        "specifyQuery": "keypair",
        "queryParameters": {
          "parameters": [
            {
              "name": "query",
              "value": "={{ \"select * from Bill where VendorRef = '\" + $json.Bill.VendorRef.value + \"' orderby TxnDate desc maxresults 13\" }}"
            },
            {
              "name": "minorversion",
              "value": "75"
            }
          ]
        },
        "sendHeaders": true,
        "specifyHeaders": "keypair",
        "headerParameters": {
          "parameters": [
            {
              "name": "Accept",
              "value": "application/json"
            }
          ]
        },
        "options": {}
      },
      "id": "a337e754-05d5-45cb-91d5-5d60041c3356",
      "name": "QBO: vendor history",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        660,
        0
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "operation": "read",
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "REPLACE_WITH_SHEET_ID"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Approval matrix"
        },
        "options": {}
      },
      "id": "7cd7263b-b2ca-4a66-9f4a-214327f36cde",
      "name": "Read approval matrix",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        880,
        0
      ],
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Picks the approver from the matrix, computes vendor history in code and asks Claude for a short brief.\n// One bill per execution (the webhook receives one billId).\nconst cfg = $('Config').first().json;\nconst bill = $('QBO: get bill').first().json.Bill;\nconst history = (($('QBO: vendor history').first().json.QueryResponse || {}).Bill || []).filter((b) => b.Id !== bill.Id);\nconst matrix = $input.all().map((i) => i.json).filter((r) => r.approver_slack_id);\n\nconst total = Number(bill.TotalAmt || 0);\nconst lines = (bill.Line || []).filter((l) => l.DetailType === 'AccountBasedExpenseLineDetail' || l.DetailType === 'ItemBasedExpenseLineDetail');\nconst detail = (l) => l.AccountBasedExpenseLineDetail || l.ItemBasedExpenseLineDetail || {};\nconst accounts = [...new Set(lines.map((l) => (detail(l).AccountRef || {}).value).filter(Boolean))];\nconst classes = [...new Set(lines.map((l) => (detail(l).ClassRef || {}).name).filter(Boolean))];\nconst vendorId = (bill.VendorRef || {}).value || '';\nconst vendorName = (bill.VendorRef || {}).name || vendorId;\n\n// 1. Approval matrix: every non-wildcard column must match. Most specific rule wins, then the lowest ceiling.\nconst any = (v) => v === undefined || v === null || String(v).trim() === '' || String(v).trim() === '*';\nconst matches = matrix.filter((r) =>\n  total >= Number(r.min_amount || 0) &&\n  total <= Number(any(r.max_amount) ? Infinity : r.max_amount) &&\n  (any(r.vendor_id) || String(r.vendor_id) === vendorId) &&\n  (any(r.gl_account_id) || accounts.includes(String(r.gl_account_id))) &&\n  (any(r.department) || classes.includes(String(r.department)))\n);\nconst specificity = (r) => ['vendor_id', 'gl_account_id', 'department'].filter((k) => !any(r[k])).length;\nmatches.sort((a, b) => specificity(b) - specificity(a) || Number(a.max_amount || Infinity) - Number(b.max_amount || Infinity));\nconst rule = matches[0] || null;\n\n// 2. Vendor history, computed in code. The model never does the arithmetic.\nconst amounts = history.map((b) => Number(b.TotalAmt || 0));\nconst avg = amounts.length ? amounts.reduce((s, a) => s + a, 0) / amounts.length : 0;\nconst pctVsAvg = avg ? Math.round(((total - avg) / avg) * 100) : null;\nconst daysToDue = bill.DueDate ? Math.round((Date.parse(bill.DueDate) - Date.now()) / 86400000) : null;\n\nconst flags = [];\nif (!rule) flags.push('no_matching_rule');\nif (!history.length) flags.push('first_bill_from_vendor');\nif (pctVsAvg !== null && pctVsAvg > Number(cfg.priceJumpPct)) flags.push('amount_above_usual');\nif (!bill.DocNumber) flags.push('missing_invoice_number');\nif (daysToDue !== null && daysToDue <= 5) flags.push('due_within_5_days');\nif (total >= 1000 && total % 100 === 0) flags.push('round_amount');\nif (rule && !any(rule.second_approver_slack_id)) flags.push('second_approver_required');\n\n// 3. Fingerprint of what is being approved (vendor, amount, coding). FNV-1a, no crypto module needed.\nconst fpSource = [vendorId, total.toFixed(2), ...lines.map((l) => ((detail(l).AccountRef || {}).value || '') + ':' + Number(l.Amount).toFixed(2))].join('|');\nlet h = 0x811c9dc5;\nfor (let i = 0; i < fpSource.length; i++) { h ^= fpSource.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; }\nconst fingerprint = h.toString(16).padStart(8, '0');\n\nconst facts = {\n  vendor: vendorName,\n  invoice_number: bill.DocNumber || '',\n  bill_date: bill.TxnDate,\n  due_date: bill.DueDate || '',\n  total,\n  currency: (bill.CurrencyRef || {}).value || 'USD',\n  lines: lines.map((l) => ({ description: l.Description || '', amount: Number(l.Amount), account: (detail(l).AccountRef || {}).name || '' })),\n  memo: bill.PrivateNote || '',\n  prior_bills_count: amounts.length,\n  prior_bills_average: Math.round(avg * 100) / 100,\n  percent_vs_average: pctVsAvg,\n  last_prior_bill: history[0] ? { date: history[0].TxnDate, total: Number(history[0].TotalAmt) } : null,\n  days_until_due: daysToDue,\n  flags,\n};\n\nconst FLAG_ENUM = ['no_matching_rule', 'first_bill_from_vendor', 'amount_above_usual', 'missing_invoice_number', 'due_within_5_days', 'round_amount', 'second_approver_required'];\nconst schema = {\n  type: 'object',\n  additionalProperties: false,\n  required: ['summary', 'flag_notes', 'question_for_approver'],\n  properties: {\n    summary: { type: 'string', description: 'Two or three plain sentences: what the bill is for and how it compares with history' },\n    flag_notes: {\n      type: 'array',\n      items: {\n        type: 'object', additionalProperties: false, required: ['flag', 'note'],\n        properties: { flag: { type: 'string', enum: FLAG_ENUM }, note: { type: 'string' } },\n      },\n    },\n    question_for_approver: { type: 'string', description: 'One question worth asking before approving, or empty string' },\n  },\n};\n\nconst prompt = 'You write approval briefs for an accounts payable team. Using only the facts below, write a short brief. ' +\n  'Use the numbers exactly as given and do not calculate new ones. Explain each flag in one short note. ' +\n  'Do not recommend approving or rejecting; a named person decides.\\n\\nFacts:\\n' + JSON.stringify(facts, null, 2);\n\nreturn [{\n  json: {\n    billId: bill.Id,\n    vendorName,\n    total,\n    dueDate: bill.DueDate || '',\n    docNumber: bill.DocNumber || '',\n    facts,\n    flags,\n    fingerprint,\n    ruleId: rule ? String(rule.rule_id || '') : 'default',\n    approverSlackId: rule ? String(rule.approver_slack_id) : String(cfg.apLeadSlackId),\n    approverName: rule ? String(rule.approver_name || '') : 'AP lead',\n    delegateSlackId: rule && !any(rule.delegate_slack_id) ? String(rule.delegate_slack_id) : String(cfg.apLeadSlackId),\n    claudeRequest: {\n      model: 'claude-opus-5-5',\n      max_tokens: 2000,\n      messages: [{ role: 'user', content: prompt }],\n      output_config: { effort: 'low', format: { type: 'json_schema', schema } },\n    },\n  },\n  pairedItem: { item: 0 },\n}];\n"
      },
      "id": "7f63ca47-173c-48a6-aac8-685ce940cd4a",
      "name": "Route and build brief",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        0
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "anthropicApi",
        "sendHeaders": true,
        "specifyHeaders": "keypair",
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            }
          ]
        },
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.claudeRequest) }}",
        "options": {
          "timeout": 180000
        }
      },
      "id": "051330bc-4d7e-4f4d-93bc-3ae87aa1bda9",
      "name": "Claude: write brief",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1320,
        0
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Builds the Slack approval message. If the AI brief failed, the facts alone are still sent.\n\n// Reads a Messages API response. Thinking blocks may come first, so find the text block.\nfunction readClaude(resp) {\n  if (!resp || resp.error) {\n    const msg = resp && resp.error ? (resp.error.message || JSON.stringify(resp.error)) : 'empty response';\n    return { ok: false, reason: 'api_error: ' + String(msg).slice(0, 300) };\n  }\n  if (resp.stop_reason === 'refusal') {\n    const cat = resp.stop_details && resp.stop_details.category ? ' (' + resp.stop_details.category + ')' : '';\n    return { ok: false, reason: 'model_refusal' + cat };\n  }\n  if (resp.stop_reason === 'max_tokens') return { ok: false, reason: 'max_tokens: output was cut off, raise max_tokens' };\n  const block = (resp.content || []).find((b) => b.type === 'text');\n  if (!block) return { ok: false, reason: 'no_text_block_in_response' };\n  try {\n    return { ok: true, data: JSON.parse(block.text) };\n  } catch (e) {\n    return { ok: false, reason: 'invalid_json: ' + e.message };\n  }\n}\n\nconst cfg = $('Config').first().json;\nconst ctx = $('Route and build brief').first().json;\nconst r = readClaude($input.first().json);\nconst money = (n) => Number(n).toLocaleString('en-US', { style: 'currency', currency: ctx.facts.currency || 'USD' });\n\nlet brief;\nif (r.ok) {\n  brief = r.data.summary;\n  if (r.data.flag_notes.length) brief += '\\n' + r.data.flag_notes.map((f) => ':triangular_flag_on_post: ' + f.note).join('\\n');\n  if (r.data.question_for_approver) brief += '\\n:grey_question: ' + r.data.question_for_approver;\n} else {\n  brief = '_AI brief unavailable (' + r.reason + ')._ Flags: ' + (ctx.flags.join(', ') || 'none');\n}\n\nconst link = cfg.qboAppUrl + '/app/bill?txnId=' + ctx.billId;\nconst text = '*Bill approval needed: ' + ctx.vendorName + ', ' + money(ctx.total) + '*\\n' +\n  'Invoice ' + (ctx.docNumber || 'n/a') + ' | due ' + (ctx.dueDate || 'n/a') + ' | rule ' + ctx.ruleId + '\\n\\n' +\n  brief + '\\n\\n<' + link + '|Open the bill in QuickBooks>';\n\nreturn [{ json: { ...ctx, briefOk: r.ok, briefReason: r.ok ? '' : r.reason, slackMessage: text }, pairedItem: { item: 0 } }];\n"
      },
      "id": "42a3ebef-429d-4eae-8922-33001d7d85ac",
      "name": "Build approval message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1540,
        0
      ]
    },
    {
      "parameters": {
        "authentication": "accessToken",
        "resource": "message",
        "operation": "sendAndWait",
        "select": "user",
        "user": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $json.approverSlackId }}"
        },
        "message": "={{ $json.slackMessage }}",
        "responseType": "approval",
        "approvalOptions": {
          "values": {
            "approvalType": "double",
            "approveLabel": "Approve",
            "disapproveLabel": "Reject"
          }
        },
        "options": {
          "appendAttribution": false,
          "limitWaitTime": {
            "values": {
              "limitType": "afterTimeInterval",
              "resumeAmount": 24,
              "resumeUnit": "hours"
            }
          }
        }
      },
      "id": "66c4b91f-897e-4136-af11-1c5f67abba5c",
      "name": "Ask approver in Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1760,
        0
      ],
      "webhookId": "4971c014-29e0-4ccf-b3a6-5ddd9f634200"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Turns the Slack response into a decision record. No response before the limit means timed out.\nconst ctx = $('Build approval message').first().json;\nconst data = $input.first().json.data || {};\nconst outcome = data.approved === true ? 'approved' : data.approved === false ? 'rejected' : 'timed_out';\nreturn [{\n  json: {\n    ...ctx,\n    outcome,\n    respondedAt: data.respondedAt || '',\n    route: outcome === 'timed_out' ? 'timed_out' : 'decided',\n    escalationText: ':alarm_clock: Bill from *' + ctx.vendorName + '* for ' + ctx.total + ' has waited past the deadline for <@' + ctx.approverSlackId + '>. ' +\n      '<@' + ctx.delegateSlackId + '>, please review it in QuickBooks (bill ' + ctx.billId + ').',\n  },\n  pairedItem: { item: 0 },\n}];\n"
      },
      "id": "db6769e2-4b6e-42ad-8daf-b4d8ceffcb8c",
      "name": "Record decision",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1980,
        0
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "ee66e7e7-e7cd-4ed3-a438-f82a841ace0b",
                    "leftValue": "={{ $json.route }}",
                    "rightValue": "timed_out",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "timed_out"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "b2626cd4-1ea8-4310-ab3f-3eddcc73505e",
                    "leftValue": "={{ $json.route }}",
                    "rightValue": "decided",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "decided"
            }
          ]
        },
        "options": {}
      },
      "id": "c9606412-d8e3-4206-b70e-df48269fdfc0",
      "name": "Timed out?",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        2200,
        0
      ]
    },
    {
      "parameters": {
        "authentication": "accessToken",
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "#REPLACE_WITH_AP_CHANNEL"
        },
        "messageType": "text",
        "text": "={{ $json.escalationText }}",
        "otherOptions": {
          "includeLinkToWorkflow": true
        }
      },
      "id": "58250c84-67f9-414f-abc0-a2463799cdb8",
      "name": "Escalate to delegate",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        2420,
        -140
      ]
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "REPLACE_WITH_SHEET_ID"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Approvals log"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "logged_at": "={{ $now.toISO() }}",
            "bill_id": "={{ $('Record decision').first().json.billId }}",
            "vendor": "={{ $('Record decision').first().json.vendorName }}",
            "amount": "={{ $('Record decision').first().json.total }}",
            "due_date": "={{ $('Record decision').first().json.dueDate }}",
            "rule_id": "={{ $('Record decision').first().json.ruleId }}",
            "approver_slack_id": "={{ $('Record decision').first().json.approverSlackId }}",
            "outcome": "={{ $('Record decision').first().json.outcome }}",
            "responded_at": "={{ $('Record decision').first().json.respondedAt }}",
            "flags": "={{ $('Record decision').first().json.flags.join(', ') }}",
            "fingerprint": "={{ $('Record decision').first().json.fingerprint }}",
            "execution_id": "={{ $execution.id }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "logged_at",
              "displayName": "logged_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "bill_id",
              "displayName": "bill_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "vendor",
              "displayName": "vendor",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "amount",
              "displayName": "amount",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "due_date",
              "displayName": "due_date",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "rule_id",
              "displayName": "rule_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "approver_slack_id",
              "displayName": "approver_slack_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "outcome",
              "displayName": "outcome",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "responded_at",
              "displayName": "responded_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "flags",
              "displayName": "flags",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "fingerprint",
              "displayName": "fingerprint",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "execution_id",
              "displayName": "execution_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": true
        },
        "options": {
          "cellFormat": "RAW"
        }
      },
      "id": "9660121f-03fe-48c0-afd2-7493fe5489b0",
      "name": "Append to approvals log",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        2640,
        0
      ]
    },
    {
      "parameters": {
        "authentication": "accessToken",
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "#REPLACE_WITH_AP_CHANNEL"
        },
        "messageType": "text",
        "text": "={{ $json.slackText || (':warning: AP approval workflow error: ' + (($json.error && ($json.error.message || $json.error.description)) || JSON.stringify($json.error || $json).slice(0, 500)) + ' (execution ' + $execution.id + ')') }}",
        "otherOptions": {
          "includeLinkToWorkflow": true
        }
      },
      "id": "056605d5-2339-4ee9-a2f2-4abf38b5ae7e",
      "name": "Post AP exception",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1100,
        260
      ]
    }
  ],
  "connections": {
    "New bill to approve": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config": {
      "main": [
        [
          {
            "node": "QBO: get bill",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "QBO: get bill": {
      "main": [
        [
          {
            "node": "QBO: vendor history",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Post AP exception",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "QBO: vendor history": {
      "main": [
        [
          {
            "node": "Read approval matrix",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Post AP exception",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read approval matrix": {
      "main": [
        [
          {
            "node": "Route and build brief",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route and build brief": {
      "main": [
        [
          {
            "node": "Claude: write brief",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude: write brief": {
      "main": [
        [
          {
            "node": "Build approval message",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build approval message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build approval message": {
      "main": [
        [
          {
            "node": "Ask approver in Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ask approver in Slack": {
      "main": [
        [
          {
            "node": "Record decision",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Record decision": {
      "main": [
        [
          {
            "node": "Timed out?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Timed out?": {
      "main": [
        [
          {
            "node": "Escalate to delegate",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Append to approvals log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Escalate to delegate": {
      "main": [
        [
          {
            "node": "Append to approvals log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
