{
  "name": "Lumeor | Receipt capture and matching (Gmail to QuickBooks)",
  "nodes": [
    {
      "parameters": {
        "content": "## Receipt capture and matching (Gmail to QuickBooks Online)\n\n**What it does**\n1. Watches a receipts inbox for photos and PDFs.\n2. Claude reads merchant, date, totals, tip, card digits and items (structured outputs).\n3. Code validates the result and pulls QuickBooks Online purchases within a few days of the receipt date.\n4. Scores each purchase on amount (with tip tolerance), date, merchant name and card digits.\n5. Clear matches go to a \"Matched receipts\" sheet with the purchase ID and its current account. Everything else goes to the bookkeeper in Slack.\n\n**Setup**\n1. Credentials: Gmail OAuth2, Anthropic, QuickBooks Online OAuth2, Google Sheets, Slack.\n2. Replace REPLACE_WITH_RECEIPTS_INBOX_ADDRESS in **Receipts inbox**.\n3. In **Config**, set qboRealmId, matchWindowDays and tipTolerancePct.\n4. Create a \"Matched receipts\" tab with the headers in the sheet node. Replace REPLACE_WITH_SHEET_ID and REPLACE_WITH_SLACK_CHANNEL.\n\nModel: claude-opus-5-5. claude-haiku-5-5 is a cheaper option for high volumes of clear receipts.\n\nTemplate by Lumeor Studio, https://www.lumeor.studio/guides/automate-receipt-capture-and-categorization/",
        "height": 720,
        "width": 520
      },
      "id": "356fbdc6-4b07-4b0b-9503-0e8a0756a105",
      "name": "Template notes",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -620,
        -260
      ]
    },
    {
      "parameters": {
        "pollTimes": {
          "item": [
            {
              "mode": "everyMinute"
            }
          ]
        },
        "simple": false,
        "filters": {
          "q": "to:REPLACE_WITH_RECEIPTS_INBOX_ADDRESS has:attachment",
          "readStatus": "unread"
        },
        "options": {
          "downloadAttachments": true,
          "dataPropertyAttachmentsPrefixName": "attachment_"
        }
      },
      "id": "b51a3b71-d864-490c-bcb2-525dfd9b8a31",
      "name": "Receipts inbox",
      "type": "n8n-nodes-base.gmailTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {
        "mode": "manual",
        "assignments": {
          "assignments": [
            {
              "id": "85a68225-9dff-4b88-beb0-3e074b865335",
              "name": "qboBaseUrl",
              "value": "https://quickbooks.api.intuit.com",
              "type": "string"
            },
            {
              "id": "643fb5fc-48c7-4feb-9295-f901562b10f7",
              "name": "qboRealmId",
              "value": "REPLACE_WITH_QBO_COMPANY_ID",
              "type": "string"
            },
            {
              "id": "0b9fb044-d1e7-4ec2-a5a5-145546c553c8",
              "name": "matchWindowDays",
              "value": 4,
              "type": "number"
            },
            {
              "id": "df9b81f0-292d-4390-8036-b89266b5250c",
              "name": "tipTolerancePct",
              "value": 25,
              "type": "number"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {
          "stripBinary": false
        }
      },
      "id": "812ba4d4-b3f6-42cf-9962-2c8f4bb28d6c",
      "name": "Config",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        0
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// One Claude request per receipt email, using the first PDF or image attachment.\nconst SUPPORTED = { 'application/pdf': 'document', 'image/jpeg': 'image', 'image/png': 'image', 'image/gif': 'image', 'image/webp': 'image' };\nconst schema = {\n  type: 'object',\n  additionalProperties: false,\n  required: ['receipt_type', 'merchant', 'date', 'subtotal', 'tax', 'tip', 'total', 'currency', 'card_last4', 'items', 'category_hint'],\n  properties: {\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' },\n    subtotal: { type: 'number' },\n    tax: { type: 'number' },\n    tip: { type: 'number', description: '0 if none' },\n    total: { type: 'number', description: 'Amount charged including tip if written' },\n    currency: { type: 'string', description: 'ISO 4217 code' },\n    card_last4: { type: 'string', description: 'Last four card digits if printed, else empty string' },\n    items: {\n      type: 'array',\n      items: { type: 'object', additionalProperties: false, required: ['description', 'amount'], properties: { description: { type: 'string' }, amount: { type: 'number' } } },\n    },\n    category_hint: { type: 'string', description: 'Two or three words describing what was bought, such as client meal or office supplies' },\n  },\n};\nconst out = [];\nconst items = $input.all();\nfor (let i = 0; i < items.length; i++) {\n  const bin = items[i].binary || {};\n  const key = Object.keys(bin).find((k) => SUPPORTED[bin[k].mimeType]);\n  if (!key) continue;\n  const buffer = await this.helpers.getBinaryDataBuffer(i, key);\n  const source = { type: 'base64', media_type: bin[key].mimeType, data: buffer.toString('base64') };\n  const email = items[i].json;\n  out.push({\n    json: {\n      messageId: email.id,\n      sender: ((email.from && email.from.value && email.from.value[0]) || {}).address || '',\n      fileName: bin[key].fileName || key,\n      claudeRequest: {\n        model: 'claude-opus-5-5',\n        max_tokens: 3000,\n        messages: [{ role: 'user', content: [\n          SUPPORTED[bin[key].mimeType] === 'document' ? { type: 'document', source } : { type: 'image', source },\n          { type: 'text', text: 'Read this receipt. Copy values as printed, use YYYY-MM-DD dates and plain numbers. Use 0 or an empty string for anything not shown. Do not guess.' },\n        ] }],\n        output_config: { format: { type: 'json_schema', schema } },\n      },\n    },\n    pairedItem: { item: i },\n  });\n}\nreturn out;\n"
      },
      "id": "4ccac0e4-4d31-4782-92f3-1a996e8470a4",
      "name": "Build extraction request",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        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": "782089ee-6bc1-4eb0-b3f6-d040c2276ac2",
      "name": "Claude: read receipt",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        660,
        0
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Validates the extraction and builds the QuickBooks query for card and bank purchases near the receipt date.\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 shift = (d, days) => new Date(Date.parse(d) + days * 86400000).toISOString().slice(0, 10);\nconst out = [];\nconst items = $input.all();\nfor (let i = 0; i < items.length; i++) {\n  const { claudeRequest, ...src } = $('Build extraction request').itemMatching(i).json;\n  const r = readClaude(items[i].json);\n  const reasons = [];\n  let rc = null;\n  if (!r.ok) reasons.push(r.reason);\n  else {\n    rc = r.data;\n    if (rc.receipt_type === 'not_a_receipt' || rc.receipt_type === 'unreadable') reasons.push('receipt_' + rc.receipt_type);\n    if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(rc.date) || isNaN(Date.parse(rc.date))) reasons.push('no_readable_date');\n    if (!(rc.total > 0)) reasons.push('no_total');\n  }\n  const ok = !reasons.length;\n  const w = Number(cfg.matchWindowDays);\n  out.push({\n    json: {\n      ...src,\n      receipt: rc,\n      reasons,\n      purchaseQuery: ok\n        ? \"select * from Purchase where TxnDate >= '\" + shift(rc.date, -w) + \"' and TxnDate <= '\" + shift(rc.date, w) + \"' maxresults 1000\"\n        : \"select * from Purchase where Id = '0'\",\n    },\n    pairedItem: { item: i },\n  });\n}\nreturn out;\n"
      },
      "id": "b29ab502-9e50-46d1-8cb7-fc4dc4469d80",
      "name": "Parse receipt",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        0
      ]
    },
    {
      "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": "={{ $json.purchaseQuery }}"
            },
            {
              "name": "minorversion",
              "value": "75"
            }
          ]
        },
        "sendHeaders": true,
        "specifyHeaders": "keypair",
        "headerParameters": {
          "parameters": [
            {
              "name": "Accept",
              "value": "application/json"
            }
          ]
        },
        "options": {}
      },
      "id": "287efeac-bcf9-493f-b52e-82123372520a",
      "name": "QBO: purchases near date",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1100,
        0
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Scores each purchase on amount, date, merchant and card digits. Only a clear winner is matched.\nconst cfg = $('Config').first().json;\nconst tipPct = Number(cfg.tipTolerancePct) / 100;\nconst window = Number(cfg.matchWindowDays);\nconst tokens = (s) => new Set(String(s || '').toLowerCase().replace(/[^a-z0-9 ]/g, ' ').split(/\\s+/).filter((t) => t.length > 2));\nconst similarity = (a, b) => {\n  const A = tokens(a), B = tokens(b);\n  if (!A.size || !B.size) return 0;\n  let hit = 0;\n  for (const t of A) if (B.has(t) || [...B].some((x) => x.startsWith(t) || t.startsWith(x))) hit++;\n  return hit / Math.min(A.size, B.size);\n};\nconst out = [];\nconst items = $input.all();\nfor (let i = 0; i < items.length; i++) {\n  const p = $('Parse receipt').itemMatching(i).json;\n  const rc = p.receipt;\n  const reasons = [...p.reasons];\n  let best = null, second = null;\n  if (!reasons.length) {\n    const purchases = (items[i].json.QueryResponse && items[i].json.QueryResponse.Purchase) || [];\n    const scored = purchases.map((pu) => {\n      const amt = Number(pu.TotalAmt);\n      const amountScore = Math.abs(amt - rc.total) <= 0.01 ? 1 : (amt >= rc.total && amt <= rc.total * (1 + tipPct)) ? 0.7 : 0;\n      const days = Math.abs(Date.parse(pu.TxnDate) - Date.parse(rc.date)) / 86400000;\n      const dateScore = Math.max(0, 1 - days / (window + 1));\n      const text = [(pu.EntityRef || {}).name, pu.PrivateNote, ...(pu.Line || []).map((l) => l.Description)].join(' ');\n      const merchantScore = similarity(rc.merchant, text);\n      const card = ((pu.AccountRef || {}).name || '');\n      const cardBonus = rc.card_last4 && card.includes(rc.card_last4) ? 0.1 : 0;\n      const score = amountScore === 0 ? 0 : Math.min(1, 0.5 * amountScore + 0.2 * dateScore + 0.3 * merchantScore + cardBonus);\n      const line = (pu.Line || [])[0] || {};\n      const acct = ((line.AccountBasedExpenseLineDetail || {}).AccountRef || {}).name || '';\n      return { id: pu.Id, date: pu.TxnDate, amount: amt, payee: (pu.EntityRef || {}).name || '', card, account: acct, score: Math.round(score * 100) / 100 };\n    }).sort((a, b) => b.score - a.score);\n    best = scored[0] || null;\n    second = scored[1] || null;\n    if (!best || best.score < 0.75) reasons.push('no_confident_match' + (best ? ' (best ' + best.score + ', purchase ' + best.id + ')' : ''));\n    else if (second && second.score > best.score - 0.15) reasons.push('two_close_matches: ' + best.id + ' and ' + second.id);\n  }\n  const route = reasons.length ? 'review' : 'matched';\n  const desc = rc ? rc.merchant + ' | ' + rc.date + ' | ' + rc.total + ' ' + rc.currency + ' | ' + rc.category_hint : p.fileName;\n  out.push({\n    json: {\n      ...p,\n      route,\n      reasons,\n      match: route === 'matched' ? best : null,\n      slackText: ':receipt: *Receipt needs the bookkeeper*\\n' + desc + '\\nFrom: ' + p.sender + '\\nReasons: ' + reasons.join(', '),\n    },\n    pairedItem: { item: i },\n  });\n}\nreturn out;\n"
      },
      "id": "c5b6a10e-316e-4c19-a245-4ecc85865404",
      "name": "Match to transaction",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1320,
        0
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "8b6d70a4-0512-4f23-8e23-a0d028e03ab4",
                    "leftValue": "={{ $json.route }}",
                    "rightValue": "matched",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "matched"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "788290f6-09fe-4c64-aaa7-31c30ca773cd",
                    "leftValue": "={{ $json.route }}",
                    "rightValue": "review",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "review"
            }
          ]
        },
        "options": {}
      },
      "id": "0af394ec-5805-419f-8bf8-df65a2fd5c98",
      "name": "Matched?",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        1540,
        0
      ]
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "REPLACE_WITH_SHEET_ID"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Matched receipts"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "logged_at": "={{ $now.toISO() }}",
            "sender": "={{ $json.sender }}",
            "merchant": "={{ $json.receipt.merchant }}",
            "receipt_date": "={{ $json.receipt.date }}",
            "total": "={{ $json.receipt.total }}",
            "tip": "={{ $json.receipt.tip }}",
            "category_hint": "={{ $json.receipt.category_hint }}",
            "qbo_purchase_id": "={{ $json.match.id }}",
            "qbo_payee": "={{ $json.match.payee }}",
            "qbo_account": "={{ $json.match.account }}",
            "match_score": "={{ $json.match.score }}",
            "gmail_message_id": "={{ $json.messageId }}",
            "attached_in_qbo": "no"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "logged_at",
              "displayName": "logged_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "sender",
              "displayName": "sender",
              "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": "receipt_date",
              "displayName": "receipt_date",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "total",
              "displayName": "total",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "tip",
              "displayName": "tip",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "category_hint",
              "displayName": "category_hint",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "qbo_purchase_id",
              "displayName": "qbo_purchase_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "qbo_payee",
              "displayName": "qbo_payee",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "qbo_account",
              "displayName": "qbo_account",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "match_score",
              "displayName": "match_score",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "gmail_message_id",
              "displayName": "gmail_message_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "attached_in_qbo",
              "displayName": "attached_in_qbo",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": true
        },
        "options": {
          "cellFormat": "RAW"
        }
      },
      "id": "af7417c0-533c-457f-b601-7bddc7192934",
      "name": "Append to matched receipts",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        1760,
        -120
      ]
    },
    {
      "parameters": {
        "authentication": "accessToken",
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "#REPLACE_WITH_SLACK_CHANNEL"
        },
        "messageType": "text",
        "text": "={{ $json.slackText || (':warning: Receipt 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": "2e215642-035e-47b2-a2e2-f53bb94f6602",
      "name": "Send to bookkeeper",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1760,
        140
      ]
    }
  ],
  "connections": {
    "Receipts inbox": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config": {
      "main": [
        [
          {
            "node": "Build extraction request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build extraction request": {
      "main": [
        [
          {
            "node": "Claude: read receipt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude: read receipt": {
      "main": [
        [
          {
            "node": "Parse receipt",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Send to bookkeeper",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse receipt": {
      "main": [
        [
          {
            "node": "QBO: purchases near date",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "QBO: purchases near date": {
      "main": [
        [
          {
            "node": "Match to transaction",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Send to bookkeeper",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Match to transaction": {
      "main": [
        [
          {
            "node": "Matched?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Matched?": {
      "main": [
        [
          {
            "node": "Append to matched receipts",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Send to bookkeeper",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
