{
  "name": "Lumeor | Expense report review (receipts, rules, Slack)",
  "nodes": [
    {
      "parameters": {
        "content": "## Expense report review (receipts + hard rules + Slack)\n\n**What it does**\n1. Receives a submitted expense report as JSON on a webhook (from your expense tool, a form or a script).\n2. Downloads each receipt and has Claude read all of them in one call (structured outputs).\n3. Checks hard rules in code: missing receipts, amount and date mismatches, trip dates, daily caps, itemized receipts, alcohol, and duplicates within the report and across past reports.\n4. Sends the manager one Slack message: ready to approve, or the flagged lines with reasons.\n5. Logs every line so duplicates are caught across employees.\n\n**Setup**\n1. Credentials: Header Auth (webhook), Google Sheets, Anthropic, Slack. Add auth to **Download receipt** if your receipt URLs are private.\n2. Create a sheet with tabs \"Limits\" (category, daily_cap, receipt_required_over, itemized_required, alcohol_allowed) and \"Expense log\" (headers in the log node). Replace REPLACE_WITH_SHEET_ID.\n3. Replace REPLACE_WITH_FINANCE_CHANNEL in **Post to finance channel**.\n4. Body format is documented at the top of **Split report lines**.\n\nModel: claude-opus-5-5. claude-haiku-5-5 is a cheaper option for simple, high-volume receipts.\n\nTemplate by Lumeor Studio, https://www.lumeor.studio/guides/automate-expense-report-review/",
        "height": 720,
        "width": 520
      },
      "id": "bfd84681-c30c-4278-8e90-e41a9a8f6fc0",
      "name": "Template notes",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -620,
        -260
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "lumeor-expense-review",
        "authentication": "headerAuth",
        "responseMode": "onReceived",
        "options": {}
      },
      "id": "3d804bde-8ef7-4126-81dc-5b8c8ec6e157",
      "name": "Expense report submitted",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        0
      ],
      "webhookId": "cb36bf2a-1bae-42ee-be35-ce129ae0a947"
    },
    {
      "parameters": {
        "operation": "read",
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "REPLACE_WITH_SHEET_ID"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Limits"
        },
        "options": {}
      },
      "id": "000f3de9-5105-4b21-88ae-b4f7b17f5acf",
      "name": "Read limits",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        220,
        0
      ],
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "operation": "read",
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "REPLACE_WITH_SHEET_ID"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Expense log"
        },
        "options": {}
      },
      "id": "30ab0a52-4caa-4da7-a7c1-d8b0d5465b8b",
      "name": "Read expense history",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        440,
        0
      ],
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// One item per expense line. Expected body:\n// { reportId, employeeName, employeeEmail, managerSlackId, tripStart, tripEnd, currency,\n//   lines: [{ lineId, date, merchant, category, amount, receiptUrl }] }\nconst body = $('Expense report submitted').first().json.body || {};\nconst lines = Array.isArray(body.lines) ? body.lines : [];\nif (!body.reportId || !lines.length) throw new Error('Request body needs reportId and at least one line');\nreturn lines.map((l, idx) => ({\n  json: {\n    reportId: String(body.reportId),\n    employeeName: body.employeeName || '',\n    employeeEmail: body.employeeEmail || '',\n    managerSlackId: body.managerSlackId || '',\n    tripStart: body.tripStart || '',\n    tripEnd: body.tripEnd || '',\n    currency: (body.currency || 'USD').toUpperCase(),\n    lineId: String(l.lineId || idx + 1),\n    date: l.date || '',\n    merchant: l.merchant || '',\n    category: String(l.category || 'other').toLowerCase(),\n    amount: Number(l.amount || 0),\n    receiptUrl: l.receiptUrl || '',\n  },\n  pairedItem: { item: 0 },\n}));\n"
      },
      "id": "da0dfe70-91d6-42cf-b985-0206f8842983",
      "name": "Split report lines",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        660,
        0
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.receiptUrl }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file",
              "outputPropertyName": "data"
            }
          },
          "timeout": 30000
        }
      },
      "id": "b6f56299-093b-494d-a6c9-d998b1f705ac",
      "name": "Download receipt",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        880,
        0
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Builds ONE Claude request per report with every downloaded receipt, labeled by line ID.\nconst SUPPORTED = { 'application/pdf': 'document', 'image/jpeg': 'image', 'image/png': 'image', 'image/gif': 'image', 'image/webp': 'image' };\nconst items = $input.all();\nconst content = [];\nconst lineIds = [];\nconst attached = [];\nfor (let i = 0; i < items.length; i++) {\n  const line = $('Split report lines').itemMatching(i).json;\n  lineIds.push(line.lineId);\n  const bin = items[i].binary && items[i].binary.data;\n  if (!bin || !SUPPORTED[bin.mimeType]) continue;\n  const buffer = await this.helpers.getBinaryDataBuffer(i, 'data');\n  const source = { type: 'base64', media_type: bin.mimeType, data: buffer.toString('base64') };\n  content.push({ type: 'text', text: 'Receipt for line_id ' + line.lineId + ':' });\n  content.push(SUPPORTED[bin.mimeType] === 'document' ? { type: 'document', source } : { type: 'image', source });\n  attached.push(line.lineId);\n}\n\nconst schema = {\n  type: 'object',\n  additionalProperties: false,\n  required: ['receipts'],\n  properties: {\n    receipts: {\n      type: 'array',\n      items: {\n        type: 'object',\n        additionalProperties: false,\n        required: ['line_id', 'receipt_type', 'merchant', 'date', 'total', 'tax', 'tip', 'currency', 'contains_alcohol'],\n        properties: {\n          line_id: { type: 'string', enum: [...new Set(lineIds)] },\n          receipt_type: { type: 'string', enum: ['itemized', 'card_slip', 'invoice', 'not_a_receipt', 'unreadable'] },\n          merchant: { type: 'string' },\n          date: { type: 'string', description: 'YYYY-MM-DD or empty string' },\n          total: { type: 'number', description: 'Amount actually charged, including tip' },\n          tax: { type: 'number' },\n          tip: { type: 'number' },\n          currency: { type: 'string', description: 'ISO 4217 code' },\n          contains_alcohol: { type: 'boolean' },\n        },\n      },\n    },\n  },\n};\n\ncontent.push({\n  type: 'text',\n  text: attached.length\n    ? 'Read each receipt above and return one entry per receipt, using the line_id given before it. Copy values as printed. ' +\n      'Use 0 for tax or tip that is not shown. Mark card slips without item detail as card_slip. Do not guess unreadable values.'\n    : 'No receipts were attached. Return an empty receipts array.',\n});\n\nreturn [{\n  json: {\n    attachedLineIds: attached,\n    claudeRequest: {\n      model: 'claude-opus-5-5',\n      max_tokens: 4000,\n      messages: [{ role: 'user', content }],\n      output_config: { format: { type: 'json_schema', schema } },\n    },\n  },\n  pairedItem: { item: 0 },\n}];\n"
      },
      "id": "8bc2b31f-0574-4012-8a4f-4c948108bad0",
      "name": "Build receipt request",
      "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": "a44d887e-3c82-48d9-bc97-ad5254d34b8d",
      "name": "Claude: read receipts",
      "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": "// Hard rules in code. Every flag has a reason code a manager can read.\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 lines = $('Split report lines').all().map((i) => i.json);\nconst limits = {};\nfor (const r of $('Read limits').all().map((i) => i.json)) if (r.category) limits[String(r.category).toLowerCase()] = r;\nconst history = $('Read expense history').all().map((i) => i.json).filter((r) => r.dup_key);\nconst ai = readClaude($input.first().json);\nconst receipts = {};\nif (ai.ok) for (const r of ai.data.receipts) receipts[r.line_id] = r;\n\nconst DEFAULT_RECEIPT_OVER = 75;\nconst report = lines[0];\nconst money = (n) => '$' + Number(n || 0).toFixed(2);\nconst norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 20);\nconst dayDiff = (a, b) => Math.abs(Date.parse(a) - Date.parse(b)) / 86400000;\n\n// Daily totals per category for caps\nconst daily = {};\nfor (const l of lines) {\n  const k = l.category + '|' + l.date;\n  daily[k] = (daily[k] || 0) + l.amount;\n}\n\nconst seen = {};\nconst results = lines.map((l) => {\n  const lim = limits[l.category] || {};\n  const rc = receipts[l.lineId];\n  const flags = [];\n  const receiptOver = lim.receipt_required_over !== undefined && lim.receipt_required_over !== '' ? Number(lim.receipt_required_over) : DEFAULT_RECEIPT_OVER;\n\n  if (!rc) {\n    if (l.amount > receiptOver) flags.push(ai.ok ? 'missing_receipt_over_' + receiptOver : 'receipt_not_verified');\n  } else {\n    if (rc.receipt_type === 'not_a_receipt' || rc.receipt_type === 'unreadable') flags.push('receipt_' + rc.receipt_type);\n    if (rc.total > 0 && Math.abs(rc.total - l.amount) > Math.max(1, l.amount * 0.01)) flags.push('claimed_' + money(l.amount) + '_receipt_' + money(rc.total));\n    if (rc.currency && rc.currency.toUpperCase() !== l.currency) flags.push('currency_' + rc.currency);\n    if (rc.date && l.date && dayDiff(rc.date, l.date) > 1) flags.push('receipt_date_' + rc.date + '_differs');\n    if (String(lim.itemized_required || '').toLowerCase() === 'yes' && rc.receipt_type === 'card_slip') flags.push('itemized_receipt_required');\n    if (String(lim.alcohol_allowed || 'yes').toLowerCase() === 'no' && rc.contains_alcohol) flags.push('alcohol_not_reimbursable');\n  }\n  if (report.tripStart && l.date < report.tripStart) flags.push('before_trip_start');\n  if (report.tripEnd && l.date > report.tripEnd) flags.push('after_trip_end');\n  if (lim.daily_cap !== undefined && lim.daily_cap !== '' && daily[l.category + '|' + l.date] > Number(lim.daily_cap)) flags.push(l.category + '_over_daily_cap_' + money(lim.daily_cap));\n\n  const amount = rc && rc.total > 0 ? rc.total : l.amount;\n  const dupKey = norm(rc && rc.merchant ? rc.merchant : l.merchant) + '|' + (rc && rc.date ? rc.date : l.date) + '|' + Number(amount).toFixed(2);\n  if (seen[dupKey]) flags.push('duplicate_of_line_' + seen[dupKey]);\n  seen[dupKey] = l.lineId;\n  const prior = history.find((h) => h.dup_key === dupKey && String(h.report_id) !== l.reportId);\n  if (prior) flags.push('duplicate_in_report_' + prior.report_id + (prior.employee && prior.employee !== l.employeeName ? '_by_' + prior.employee : ''));\n\n  return { ...l, flags, dupKey, receiptType: rc ? rc.receipt_type : 'none' };\n});\n\nconst flagged = results.filter((r) => r.flags.length);\nconst total = lines.reduce((s, l) => s + l.amount, 0);\nlet text = (flagged.length ? ':mag: *Expense report needs a look*' : ':white_check_mark: *Expense report ready to approve*') +\n  '\\n' + report.employeeName + ' | report ' + report.reportId + ' | ' + lines.length + ' lines | ' + money(total);\nfor (const r of flagged) text += '\\n- Line ' + r.lineId + ' ' + r.merchant + ' ' + money(r.amount) + ' (' + r.category + '): ' + r.flags.join(', ');\nif (!ai.ok) text += '\\n_Receipts could not be read automatically (' + ai.reason + '). Check them by hand._';\n\nconst route = ai.ok && report.managerSlackId ? 'manager' : 'exception';\nreturn results.map((r, idx) => ({\n  json: { ...r, route, reportStatus: flagged.length ? 'flagged' : 'clean', slackText: text },\n  pairedItem: { item: 0 },\n}));\n"
      },
      "id": "6524a466-2034-4e18-bec4-f635e18f7015",
      "name": "Check hard rules",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1540,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "6f7f3a51-ad08-4bba-86db-883c9841c6b7",
              "leftValue": "={{ $json.route }}",
              "rightValue": "manager",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "3f9ddcc5-3b31-49be-917c-9b5df2646267",
      "name": "Send to manager?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1760,
        0
      ]
    },
    {
      "parameters": {
        "authentication": "accessToken",
        "resource": "message",
        "operation": "post",
        "select": "user",
        "user": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $json.managerSlackId }}"
        },
        "messageType": "text",
        "text": "={{ $json.slackText }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "b17cdff5-dc49-40e2-b646-861d3738ed22",
      "name": "Message the manager",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1980,
        -140
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "authentication": "accessToken",
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "#REPLACE_WITH_FINANCE_CHANNEL"
        },
        "messageType": "text",
        "text": "={{ $json.slackText || (':warning: Expense review 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": "f9e9b915-d88f-4d77-a587-a2b76420a87d",
      "name": "Post to finance channel",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1980,
        160
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "REPLACE_WITH_SHEET_ID"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Expense log"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "logged_at": "={{ $now.toISO() }}",
            "report_id": "={{ $('Check hard rules').item.json.reportId }}",
            "employee": "={{ $('Check hard rules').item.json.employeeName }}",
            "line_id": "={{ $('Check hard rules').item.json.lineId }}",
            "date": "={{ $('Check hard rules').item.json.date }}",
            "merchant": "={{ $('Check hard rules').item.json.merchant }}",
            "category": "={{ $('Check hard rules').item.json.category }}",
            "amount": "={{ $('Check hard rules').item.json.amount }}",
            "receipt_type": "={{ $('Check hard rules').item.json.receiptType }}",
            "flags": "={{ $('Check hard rules').item.json.flags.join(', ') }}",
            "dup_key": "={{ $('Check hard rules').item.json.dupKey }}",
            "manager_decision": ""
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "logged_at",
              "displayName": "logged_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "report_id",
              "displayName": "report_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "employee",
              "displayName": "employee",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "line_id",
              "displayName": "line_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "date",
              "displayName": "date",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "merchant",
              "displayName": "merchant",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "category",
              "displayName": "category",
              "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": "receipt_type",
              "displayName": "receipt_type",
              "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": "dup_key",
              "displayName": "dup_key",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "manager_decision",
              "displayName": "manager_decision",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": true
        },
        "options": {
          "cellFormat": "RAW"
        }
      },
      "id": "0c6de334-5d71-4be0-bf3d-9365b1230118",
      "name": "Append to expense log",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        2200,
        0
      ]
    }
  ],
  "connections": {
    "Expense report submitted": {
      "main": [
        [
          {
            "node": "Read limits",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read limits": {
      "main": [
        [
          {
            "node": "Read expense history",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read expense history": {
      "main": [
        [
          {
            "node": "Split report lines",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split report lines": {
      "main": [
        [
          {
            "node": "Download receipt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download receipt": {
      "main": [
        [
          {
            "node": "Build receipt request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build receipt request": {
      "main": [
        [
          {
            "node": "Claude: read receipts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude: read receipts": {
      "main": [
        [
          {
            "node": "Check hard rules",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Check hard rules",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check hard rules": {
      "main": [
        [
          {
            "node": "Send to manager?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send to manager?": {
      "main": [
        [
          {
            "node": "Message the manager",
            "type": "main",
            "index": 0
          },
          {
            "node": "Append to expense log",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Post to finance channel",
            "type": "main",
            "index": 0
          },
          {
            "node": "Append to expense log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
