This workflow corresponds to n8n.io template #17134 — we link there as the canonical source.
This workflow follows the Airtable → Google Sheets recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"meta": {
"templateCredsSetupCompleted": false
},
"name": "AI Receipt Expense Memory",
"tags": [],
"nodes": [
{
"id": "48a8d1b3-12c5-4022-80d2-2669c80b7657",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
-608,
-368
],
"parameters": {
"color": 7,
"width": 480,
"height": 896,
"content": "## AI Receipt Expense Memory\n\n### How it works\n\nThis workflow watches Gmail for receipt emails, normalizes the email content, downloads receipt attachments, and checks configured expense memory stores to avoid processing duplicates. For new receipts, it uses OpenAI to extract expense details from email text first. If the email body has little/no usable text and a receipt image attachment is available, it falls back to OpenAI image OCR using the same JSON schema. It then optionally sends Slack and Microsoft Teams alerts, writes the expense record to Google Sheets and/or Airtable, and finishes with a final receipt-processing response.\n\n### Setup steps\n\n- Connect Gmail credentials and configure the Gmail trigger to match receipt emails or labels. Leave attachment download enabled for OCR fallback.\n- Edit the setup/configuration code node with receipt settings, duplicate-check options, OCR fallback settings, logging toggles, sheet/base identifiers, alert toggles, and webhook/channel details.\n- Connect OpenAI credentials and review the receipt extraction/OCR prompt and model settings.\n- If Slack alerts are enabled, connect Slack credentials and set the destination channel.\n- If Teams alerts are enabled, provide a valid Microsoft Teams incoming webhook URL.\n- If Google Sheets logging or lookup is enabled, connect Google Sheets credentials and configure the spreadsheet, sheet, and column mapping.\n- If Airtable logging or lookup is enabled, connect Airtable credentials and configure the base, table, and field mapping.\n\n### Customization\n\nAdjust the duplicate matching rules, OCR threshold, extracted receipt fields, alert message formatting, and which memory/log destinations are enabled in the configuration and code nodes."
},
"typeVersion": 1
},
{
"id": "f39f8ae0-28c5-4cc9-bdec-47ca45a342ad",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
-48,
160
],
"parameters": {
"color": 7,
"width": 240,
"height": 320,
"content": "## Gmail receipt intake\n\nStarts the workflow when a receipt-related email arrives in Gmail."
},
"typeVersion": 1
},
{
"id": "7ec28c7a-82cc-47c4-bf0d-e1920db7c567",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"position": [
240,
160
],
"parameters": {
"color": 7,
"width": 448,
"height": 320,
"content": "## Configure and normalize email\n\nLoads user-defined receipt-processing settings and converts the Gmail payload into a consistent structure for downstream checks."
},
"typeVersion": 1
},
{
"id": "c9bb255d-ccc7-4beb-a1ed-a85fb39fa3a1",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"position": [
736,
64
],
"parameters": {
"color": 7,
"width": 720,
"height": 512,
"content": "## Airtable memory lookup\n\nBranches based on whether Airtable memory is enabled, reads prior expense records when enabled, or builds a skip bundle when disabled."
},
"typeVersion": 1
},
{
"id": "b5e4c55a-88d3-4295-a8c7-c048c73dd406",
"name": "Sticky Note4",
"type": "n8n-nodes-base.stickyNote",
"position": [
1520,
48
],
"parameters": {
"color": 7,
"width": 720,
"height": 544,
"content": "## Sheets memory lookup\n\nBranches based on whether Google Sheets memory is enabled, retrieves existing expense rows when enabled, or passes through the prior lookup bundle when disabled."
},
"typeVersion": 1
},
{
"id": "56904c78-5fc8-422b-830c-92f4a08d13df",
"name": "Sticky Note5",
"type": "n8n-nodes-base.stickyNote",
"position": [
2272,
160
],
"parameters": {
"color": 7,
"width": 448,
"height": 320,
"content": "## Duplicate receipt gate\n\nCompares the normalized receipt against lookup rows and only allows new receipts to continue to extraction."
},
"typeVersion": 1
},
{
"id": "c1d03c52-35e1-4c00-97be-bf9bb708baa3",
"name": "Sticky Note6",
"type": "n8n-nodes-base.stickyNote",
"position": [
1600,
624
],
"parameters": {
"color": 7,
"width": 720,
"height": 304,
"content": "## Extract receipt details\n\nUses OpenAI to parse receipt email text. If the body has little usable text and an image attachment is available, OpenAI image OCR extracts the same receipt JSON fields before analysis and alert formatting."
},
"typeVersion": 1
},
{
"id": "44291956-dae1-4085-8f48-500eed1cc124",
"name": "Sticky Note7",
"type": "n8n-nodes-base.stickyNote",
"position": [
2368,
-368
],
"parameters": {
"color": 7,
"width": 720,
"height": 496,
"content": "## Slack receipt alert\n\nConditionally posts the prepared receipt alert to Slack, then records whether the Slack notification was sent or skipped."
},
"typeVersion": 1
},
{
"id": "64dbcde7-468c-446b-a2ea-6441b9446432",
"name": "Sticky Note8",
"type": "n8n-nodes-base.stickyNote",
"position": [
3136,
-32
],
"parameters": {
"color": 7,
"width": 720,
"height": 496,
"content": "## Teams receipt alert\n\nConditionally sends the receipt alert to Microsoft Teams through a webhook and records the sent or skipped status."
},
"typeVersion": 1
},
{
"id": "f28cee7b-ae0a-448b-bf93-d30817060013",
"name": "Sticky Note9",
"type": "n8n-nodes-base.stickyNote",
"position": [
3888,
16
],
"parameters": {
"color": 7,
"width": 240,
"height": 368,
"content": "## Build log record\n\nCombines extracted receipt data and notification status into the expense record that will be written to enabled logs."
},
"typeVersion": 1
},
{
"id": "50c0c7be-39a3-4950-8650-0fc0743fa2ce",
"name": "Sticky Note10",
"type": "n8n-nodes-base.stickyNote",
"position": [
4160,
64
],
"parameters": {
"color": 7,
"width": 240,
"height": 320,
"content": "## Sheets write decision\n\nChecks whether the final expense record should be written to Google Sheets."
},
"typeVersion": 1
},
{
"id": "5a6f4ccd-da94-4d4a-8ddc-f273ad5ecafc",
"name": "Sticky Note11",
"type": "n8n-nodes-base.stickyNote",
"position": [
4448,
-64
],
"parameters": {
"color": 7,
"width": 448,
"height": 512,
"content": "## Write Sheets expense\n\nAppends the expense to Google Sheets when enabled, confirms the write, or records that the Sheets write was skipped."
},
"typeVersion": 1
},
{
"id": "00e757fe-5917-41db-88c4-8ae016092106",
"name": "Sticky Note12",
"type": "n8n-nodes-base.stickyNote",
"position": [
4944,
64
],
"parameters": {
"color": 7,
"width": 240,
"height": 320,
"content": "## Airtable write decision\n\nChecks whether the final expense record should also be written to Airtable."
},
"typeVersion": 1
},
{
"id": "9b75f06e-ebb5-4705-b3ec-3a283a3b27a8",
"name": "Sticky Note13",
"type": "n8n-nodes-base.stickyNote",
"position": [
5216,
-64
],
"parameters": {
"color": 7,
"width": 448,
"height": 512,
"content": "## Write Airtable expense\n\nCreates the expense record in Airtable when enabled, confirms the write, or records that the Airtable write was skipped."
},
"typeVersion": 1
},
{
"id": "a7396889-f0f0-41c9-8c31-1609d5499ee6",
"name": "Sticky Note14",
"type": "n8n-nodes-base.stickyNote",
"position": [
5712,
16
],
"parameters": {
"color": 7,
"width": 240,
"height": 368,
"content": "## Finalize receipt result\n\nProduces the final response summarizing the receipt extraction, alert delivery, duplicate status, and logging outcomes."
},
"typeVersion": 1
},
{
"id": "88888888-8888-8888-8888-000000000001",
"name": "When Receipt Arrives in Gmail",
"type": "n8n-nodes-base.gmailTrigger",
"position": [
0,
320
],
"parameters": {
"filters": {
"q": "label:Receipts newer_than:2d"
},
"options": {
"downloadAttachments": true,
"dataPropertyAttachmentsPrefixName": "attachment_"
},
"pollTimes": {
"item": [
{
"mode": "everyMinute"
}
]
}
},
"credentials": {
"gmailOAuth2": {
"name": "<your credential>"
}
},
"typeVersion": 1.2
},
{
"id": "88888888-8888-8888-8888-000000000002",
"name": "Configure Receipt Settings",
"type": "n8n-nodes-base.code",
"position": [
288,
320
],
"parameters": {
"jsCode": "// ===== EDIT YOUR SETTINGS HERE =====\n// Defaults below match the Gumroad + n8n Creator listings.\n// Change only what you need; leave unused destinations as placeholders.\n\n// Where to log receipts:\n// 'sheets' -> Google Sheets only\n// 'airtable' -> Airtable only\n// 'both' -> Google Sheets and Airtable\nconst EXPENSE_LOG = 'sheets';\n\nconst GOOGLE_SHEET_ID = 'your-google-sheet-id';\nconst SHEETS_TAB_NAME = 'Expenses';\n\nconst AIRTABLE_BASE_ID = 'appXXXXXXXXXXXXXX';\nconst EXPENSES_TABLE_NAME = 'Expenses';\n\n// Where to send alerts for high-value, warranty, or review-needed receipts:\n// 'slack' -> Slack only\n// 'teams' -> Microsoft Teams only\n// 'both' -> Slack and Teams\n// 'none' -> no chat alerts\nconst NOTIFY_CHANNELS = 'slack';\nconst SLACK_CHANNEL = '#receipts';\nconst TEAMS_WEBHOOK_URL = 'https://outlook.office.com/webhook/your-teams-incoming-webhook';\n\nconst HIGH_VALUE_THRESHOLD = 250;\nconst DUPLICATE_WINDOW_DAYS = 45;\nconst WARRANTY_ALERT_ENABLED = true;\nconst ATTACHMENT_OCR_ENABLED = true;\nconst OCR_MIN_TEXT_CHARS = 80;\nconst DEFAULT_SOURCE = 'gmail_receipt';\n\nconst payload = $input.first().json;\nconst expenseLog = String(EXPENSE_LOG || 'sheets').toLowerCase().trim();\nconst notifyChannels = String(NOTIFY_CHANNELS || 'slack').toLowerCase().trim();\n\nreturn [\n {\n json: {\n expenseLog,\n useSheetsLog: expenseLog === 'sheets' || expenseLog === 'both',\n useAirtableLog: expenseLog === 'airtable' || expenseLog === 'both',\n googleSheetId: GOOGLE_SHEET_ID,\n sheetsTabName: SHEETS_TAB_NAME,\n airtableBaseId: AIRTABLE_BASE_ID,\n expensesTableName: EXPENSES_TABLE_NAME,\n notifyChannels,\n useSlack: notifyChannels === 'slack' || notifyChannels === 'both',\n useTeams: notifyChannels === 'teams' || notifyChannels === 'both',\n slackChannel: SLACK_CHANNEL,\n teamsWebhookUrl: TEAMS_WEBHOOK_URL,\n highValueThreshold: HIGH_VALUE_THRESHOLD,\n duplicateWindowDays: DUPLICATE_WINDOW_DAYS,\n warrantyAlertEnabled: WARRANTY_ALERT_ENABLED,\n attachmentOcrEnabled: ATTACHMENT_OCR_ENABLED,\n ocrMinTextChars: OCR_MIN_TEXT_CHARS,\n defaultSource: DEFAULT_SOURCE,\n payload,\n },\n },\n];"
},
"typeVersion": 2
},
{
"id": "88888888-8888-8888-8888-000000000003",
"name": "Normalize Email Structure",
"type": "n8n-nodes-base.code",
"position": [
560,
320
],
"parameters": {
"jsCode": "const inputItem = $input.first();\nconst settings = inputItem.json;\nconst binary = inputItem.binary || {};\nconst raw = settings.payload || {};\n\nconst htmlToText = (value) => String(value || '')\n .replace(/<style[\\s\\S]*?<\\/style>/gi, ' ')\n .replace(/<script[\\s\\S]*?<\\/script>/gi, ' ')\n .replace(/<br\\s*\\/?>/gi, '\\n')\n .replace(/<\\/p>/gi, '\\n')\n .replace(/<[^>]+>/g, ' ')\n .replace(/ /g, ' ')\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\\s+\\n/g, '\\n')\n .replace(/[ \\t]+/g, ' ')\n .trim();\n\nconst headers = raw.headers || raw.payload?.headers || {};\nconst subject = raw.subject || raw.Subject || headers.subject || raw.payload?.subject || '';\nconst fromEmail = raw.from || raw.From || headers.from || raw.payload?.from || '';\nconst messageId = raw.id || raw.messageId || raw.message_id || raw.payload?.id || `receipt-${Date.now()}`;\nconst receivedAt = raw.date || raw.internalDate || raw.receivedAt || new Date().toISOString();\nconst textBody = raw.textPlain || raw.text || raw.snippet || raw.body?.text || '';\nconst htmlBody = raw.textHtml || raw.html || raw.body?.html || '';\nconst rawText = htmlToText(textBody || htmlBody || raw.snippet || JSON.stringify(raw).slice(0, 6000));\n\nconst compact = rawText.toLowerCase().replace(/\\s+/g, ' ').slice(0, 1000);\nconst receiptHash = `${fromEmail}|${subject}|${compact}`.toLowerCase();\nconst attachmentBinaryProperties = Object.keys(binary).filter((name) => name.startsWith('attachment_'));\nconst imageAttachmentProperties = attachmentBinaryProperties.filter((name) =>\n String(binary[name]?.mimeType || '').toLowerCase().startsWith('image/')\n);\nconst pdfAttachmentProperties = attachmentBinaryProperties.filter((name) =>\n String(binary[name]?.mimeType || '').toLowerCase().includes('pdf')\n);\nconst ocrBinaryProperty = imageAttachmentProperties[0] || '';\nconst needsOcrFallback = Boolean(\n settings.attachmentOcrEnabled &&\n rawText.length < (settings.ocrMinTextChars ?? 80) &&\n ocrBinaryProperty\n);\n\nreturn [\n {\n json: {\n ...settings,\n messageId: String(messageId),\n receivedAt,\n source: raw.source || settings.defaultSource || 'gmail_receipt',\n originalSubject: String(subject),\n fromEmail: String(fromEmail),\n rawText: rawText.slice(0, 10000),\n rawTextSnippet: rawText.slice(0, 1000),\n receiptHash,\n attachmentBinaryProperties,\n imageAttachmentProperties,\n pdfAttachmentProperties,\n ocrBinaryProperty,\n needsOcrFallback,\n _mockExistingReceipts: Array.isArray(raw._mockExistingReceipts) ? raw._mockExistingReceipts : [],\n _testScenario: raw._testScenario || raw.scenario || '',\n },\n binary,\n },\n];"
},
"typeVersion": 2
},
{
"id": "88888888-8888-8888-8888-000000000040",
"name": "Check Airtable Log Usage",
"type": "n8n-nodes-base.if",
"position": [
780,
320
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "+1234567890",
"operator": {
"type": "boolean",
"operation": "equals"
},
"leftValue": "={{ $json.useAirtableLog }}",
"rightValue": true
}
]
}
},
"typeVersion": 2.2
},
{
"id": "88888888-8888-8888-8888-000000000004",
"name": "Fetch Airtable Expenses",
"type": "n8n-nodes-base.airtable",
"position": [
1040,
200
],
"parameters": {
"base": {
"__rl": true,
"mode": "id",
"value": "={{ $json.airtableBaseId }}"
},
"limit": 10,
"table": {
"__rl": true,
"mode": "name",
"value": "={{ $json.expensesTableName }}"
},
"options": {},
"operation": "search",
"returnAll": false,
"filterByFormula": "=OR({Message ID} = '{{ $json.messageId }}', {Receipt Hash} = '{{ $json.receiptHash }}')"
},
"credentials": {
"airtableTokenApi": {
"name": "<your credential>"
}
},
"typeVersion": 2.1,
"alwaysOutputData": true
},
{
"id": "88888888-8888-8888-8888-000000000041",
"name": "Compile Airtable Data",
"type": "n8n-nodes-base.code",
"position": [
1300,
200
],
"parameters": {
"jsCode": "const source = $('Normalize Email Structure').first();\nconst receipt = source.json;\nconst rows = $input\n .all()\n .map((item) => item.json)\n .filter((row) => !row._mockEmpty)\n .map((row) => row.fields || row);\nreturn [{ json: { ...receipt, _lookupRows: rows }, binary: source.binary || {} }];"
},
"typeVersion": 2
},
{
"id": "88888888-8888-8888-8888-000000000042",
"name": "Bypass Airtable Data Collection",
"type": "n8n-nodes-base.code",
"position": [
1040,
420
],
"parameters": {
"jsCode": "const source = $('Normalize Email Structure').first();\nconst receipt = source.json;\nreturn [{ json: { ...receipt, _lookupRows: [] }, binary: source.binary || {} }];"
},
"typeVersion": 2
},
{
"id": "88888888-8888-8888-8888-000000000043",
"name": "Check Sheets Log Usage",
"type": "n8n-nodes-base.if",
"position": [
1560,
320
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "+1234567890",
"operator": {
"type": "boolean",
"operation": "equals"
},
"leftValue": "={{ $json.useSheetsLog }}",
"rightValue": true
}
]
}
},
"typeVersion": 2.2
},
{
"id": "88888888-8888-8888-8888-000000000044",
"name": "Fetch Sheets Expenses",
"type": "n8n-nodes-base.googleSheets",
"position": [
1820,
200
],
"parameters": {
"options": {},
"resource": "sheet",
"operation": "read",
"sheetName": {
"__rl": true,
"mode": "name",
"value": "={{ $json.sheetsTabName }}"
},
"documentId": {
"__rl": true,
"mode": "id",
"value": "={{ $json.googleSheetId }}"
}
},
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
},
"typeVersion": 4.5,
"alwaysOutputData": true
},
{
"id": "88888888-8888-8888-8888-000000000045",
"name": "Combine Sheets Data",
"type": "n8n-nodes-base.code",
"position": [
2080,
200
],
"parameters": {
"jsCode": "function fromNode(name) {\n try {\n return $(name).first().json;\n } catch (error) {\n return null;\n }\n}\n\nconst prev =\n fromNode('Compile Airtable Data') ||\n fromNode('Bypass Airtable Data Collection') ||\n $('Normalize Email Structure').first().json;\nconst source = $('Normalize Email Structure').first();\n\nconst sheetRows = $input\n .all()\n .map((item) => item.json)\n .filter((row) => !row._mockEmpty)\n .map((row) => row.fields || row);\n\nreturn [{ json: { ...prev, _lookupRows: [...(prev._lookupRows || []), ...sheetRows] }, binary: source.binary || {} }];"
},
"typeVersion": 2
},
{
"id": "88888888-8888-8888-8888-000000000046",
"name": "Transfer Lookup Data",
"type": "n8n-nodes-base.code",
"position": [
1820,
420
],
"parameters": {
"jsCode": "const inputItem = $input.first();\nreturn [{ json: inputItem.json, binary: inputItem.binary || {} }];"
},
"typeVersion": 2
},
{
"id": "88888888-8888-8888-8888-000000000005",
"name": "Check Receipt Duplication",
"type": "n8n-nodes-base.code",
"position": [
2320,
320
],
"parameters": {
"jsCode": "const inputItem = $input.first();\nconst receipt = inputItem.json;\nconst rows = Array.isArray(receipt._lookupRows) ? receipt._lookupRows : [];\nconst windowDays = receipt.duplicateWindowDays ?? 45;\nconst cutoff = Date.now() - windowDays * 24 * 60 * 60 * 1000;\n\nconst isDuplicate = rows.some((row) => {\n const record = row.fields || row;\n if (record._mockEmpty) return false;\n const existingMessageId = String(record['Message ID'] || record.messageId || '').trim();\n const existingHash = String(record['Receipt Hash'] || record.receiptHash || '').toLowerCase();\n const existingAt = Date.parse(record['Received At'] || record.receivedAt || record.createdTime || '');\n\n if (receipt.messageId && existingMessageId && receipt.messageId === existingMessageId) {\n if (!existingAt || existingAt >= cutoff) return true;\n }\n\n if (receipt.receiptHash && existingHash && receipt.receiptHash === existingHash) {\n if (!existingAt || existingAt >= cutoff) return true;\n }\n\n return false;\n});\n\nreturn [{ json: { ...receipt, isDuplicate }, binary: inputItem.binary || {} }];"
},
"typeVersion": 2
},
{
"id": "88888888-8888-8888-8888-000000000006",
"name": "Determine New Receipt Status",
"type": "n8n-nodes-base.if",
"position": [
2580,
320
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "+1234567890",
"operator": {
"type": "boolean",
"operation": "equals"
},
"leftValue": "={{ $json.isDuplicate }}",
"rightValue": false
}
]
}
},
"typeVersion": 2.2
},
{
"id": "88888888-8888-8888-8888-000000000070",
"name": "Check Attachment OCR Requirement",
"type": "n8n-nodes-base.if",
"position": [
2840,
320
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "+1234567890",
"operator": {
"type": "boolean",
"operation": "equals"
},
"leftValue": "={{ $json.needsOcrFallback }}",
"rightValue": true
}
]
}
},
"typeVersion": 2.2
},
{
"id": "88888888-8888-8888-8888-000000000071",
"name": "AI Receipt Attachment OCR",
"type": "@n8n/n8n-nodes-langchain.openAi",
"position": [
1648,
1008
],
"parameters": {
"text": "=You are extracting expense data from a receipt image attachment because the email body did not contain enough readable text. Return ONLY valid JSON with keys: isReceipt (boolean), extractionStatus (extracted|not_receipt|failed), failureReason (string), merchant, purchaseDate, total, currency, category, taxDeductible, taxCategory, paymentMethod, lastFour, items, warrantyItems, returnDeadline, warrantyEndDate, aiSummary, confidence, needsReview, notes. If the image is not a receipt/invoice, set isReceipt=false and extractionStatus=not_receipt. Use ISO dates when possible. confidence is 0-100. needsReview true when totals conflict, merchant is unclear, or total is missing.\n\nEmail context:\nFrom: {{ $json.fromEmail }}\nSubject: {{ $json.originalSubject }}\nReceived: {{ $json.receivedAt }}",
"modelId": {
"__rl": true,
"mode": "list",
"value": "gpt-4o-mini"
},
"options": {
"detail": "high",
"maxTokens": 900
},
"resource": "image",
"simplify": true,
"inputType": "base64",
"operation": "analyze",
"binaryPropertyName": "={{ $json.ocrBinaryProperty || 'attachment_0' }}"
},
"credentials": {
"openAiApi": {
"name": "<your credential>"
}
},
"typeVersion": 1.8,
"continueOnFail": true
},
{
"id": "88888888-8888-8888-8888-000000000007",
"name": "AI Receipt Data Extraction",
"type": "@n8n/n8n-nodes-langchain.openAi",
"position": [
1648,
752
],
"parameters": {
"modelId": {
"__rl": true,
"mode": "list",
"value": "gpt-4o-mini"
},
"options": {
"temperature": 0.1
},
"messages": {
"values": [
{
"role": "system",
"content": "You extract receipt and invoice data for expense tracking. Return ONLY valid JSON with keys: isReceipt (boolean), extractionStatus (extracted|not_receipt|failed), failureReason (string), merchant, purchaseDate, total, currency, category, taxDeductible, taxCategory, paymentMethod, lastFour, items, warrantyItems, returnDeadline, warrantyEndDate, aiSummary, confidence, needsReview, notes. If the email is not a receipt/invoice/purchase confirmation, set isReceipt=false and extractionStatus=not_receipt. Use ISO dates when possible. confidence is 0-100. needsReview true when totals conflict, merchant is unclear, or total is missing."
},
{
"role": "user",
"content": "=Receipt email:\nFrom: {{ $('Check Receipt Duplication').item.json.fromEmail }}\nSubject: {{ $('Check Receipt Duplication').item.json.originalSubject }}\nReceived: {{ $('Check Receipt Duplication').item.json.receivedAt }}\nBody:\n{{ $('Check Receipt Duplication').item.json.rawText }}"
}
]
},
"resource": "text",
"operation": "message"
},
"credentials": {
"openAiApi": {
"name": "<your credential>"
}
},
"typeVersion": 1.8,
"continueOnFail": true
},
{
"id": "88888888-8888-8888-8888-000000000008",
"name": "Examine Extracted Data",
"type": "n8n-nodes-base.code",
"position": [
1904,
752
],
"parameters": {
"jsCode": "const receiptItem = $('Check Receipt Duplication').first();\nconst receipt = receiptItem.json;\nconst aiItem = $input.first().json;\nconst raw =\n aiItem.message?.content ||\n aiItem.content ||\n aiItem.text ||\n aiItem.output ||\n aiItem.output_text ||\n aiItem.choices?.[0]?.message?.content ||\n '';\n\nlet extraction = {\n isReceipt: true,\n extractionStatus: 'extracted',\n failureReason: '',\n merchant: 'Unknown merchant',\n purchaseDate: '',\n total: 0,\n currency: 'USD',\n category: 'Uncategorized',\n taxDeductible: false,\n taxCategory: '',\n paymentMethod: '',\n lastFour: '',\n items: [],\n warrantyItems: [],\n returnDeadline: '',\n warrantyEndDate: '',\n aiSummary: '',\n confidence: 50,\n needsReview: true,\n notes: '',\n};\n\nlet parsedOk = false;\ntry {\n const jsonMatch = String(raw).match(/\\{[\\s\\S]*\\}/);\n if (jsonMatch) {\n extraction = { ...extraction, ...JSON.parse(jsonMatch[0]) };\n parsedOk = true;\n }\n} catch (error) {\n extraction.aiSummary = String(raw).slice(0, 500);\n extraction.extractionStatus = 'failed';\n extraction.isReceipt = false;\n extraction.failureReason = 'OpenAI returned text that was not valid receipt JSON';\n}\n\nif (!parsedOk && !extraction.failureReason) {\n extraction.extractionStatus = 'failed';\n extraction.failureReason = 'OpenAI did not return a JSON object';\n}\n\nconst toList = (value) => {\n if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean);\n return String(value || '')\n .split(/\\n|, /)\n .map((item) => item.trim())\n .filter(Boolean);\n};\n\nconst total = Number(String(extraction.total || 0).replace(/[^0-9.-]/g, '')) || 0;\nconst confidence = Math.max(0, Math.min(100, Number(extraction.confidence) || 50));\nconst items = toList(extraction.items);\nconst warrantyItems = toList(extraction.warrantyItems);\nconst highValue = total >= (receipt.highValueThreshold ?? 250);\nconst warrantyRelevant = Boolean(\n receipt.warrantyAlertEnabled &&\n (warrantyItems.length || extraction.returnDeadline || extraction.warrantyEndDate)\n);\nconst needsReview = Boolean(\n extraction.needsReview || confidence < 70 || total <= 0 || !extraction.merchant || extraction.merchant === 'Unknown merchant'\n);\nconst isReceipt = extraction.isReceipt !== false && extraction.extractionStatus !== 'not_receipt';\nconst severeMissingFields = Boolean(total <= 0 && (!extraction.merchant || extraction.merchant === 'Unknown merchant'));\nconst pdfOnlyNeedsExtraction = Boolean(\n (receipt.pdfAttachmentProperties || []).length &&\n !receipt.needsOcrFallback &&\n String(receipt.rawText || '').length < (receipt.ocrMinTextChars ?? 80)\n);\nconst extractionFailed = Boolean(\n aiItem.error ||\n extraction.extractionStatus === 'failed' ||\n !parsedOk ||\n (pdfOnlyNeedsExtraction && severeMissingFields)\n);\nconst extractionSucceeded = Boolean(isReceipt && !extractionFailed);\n\nlet route = 'standard';\nif (!isReceipt) route = 'not_receipt';\nelse if (extractionFailed) route = 'extraction_failed';\nelse if (needsReview) route = 'review';\nelse if (highValue) route = 'high_value';\nelse if (warrantyRelevant) route = 'warranty';\n\nconst shouldAlert = receipt.notifyChannels !== 'none' && route !== 'standard' && extractionSucceeded;\n\nreturn [\n {\n json: {\n ...receipt,\n merchant: String(extraction.merchant || 'Unknown merchant'),\n purchaseDate: String(extraction.purchaseDate || ''),\n total,\n currency: String(extraction.currency || 'USD'),\n category: String(extraction.category || 'Uncategorized'),\n taxDeductible: Boolean(extraction.taxDeductible),\n taxCategory: String(extraction.taxCategory || ''),\n paymentMethod: String(extraction.paymentMethod || ''),\n lastFour: String(extraction.lastFour || ''),\n items: items.join(' | '),\n warrantyItems: warrantyItems.join(' | '),\n returnDeadline: String(extraction.returnDeadline || ''),\n warrantyEndDate: String(extraction.warrantyEndDate || ''),\n aiSummary: String(extraction.aiSummary || `${extraction.merchant || 'Receipt'} - ${total}`),\n extractionSource: receipt.needsOcrFallback ? `attachment_ocr:${receipt.ocrBinaryProperty}` : 'email_text',\n isReceipt,\n extractionStatus: extractionFailed ? 'failed' : isReceipt ? 'extracted' : 'not_receipt',\n extractionSucceeded,\n extractionFailureReason:\n aiItem.error?.message ||\n extraction.failureReason ||\n (pdfOnlyNeedsExtraction && severeMissingFields\n ? 'PDF-only receipt attachment could not be extracted from the email body'\n : '') ||\n (!isReceipt ? 'Email or attachment does not appear to be a receipt' : ''),\n confidence,\n needsReview,\n highValue,\n warrantyRelevant,\n shouldAlert,\n route,\n notes: String(extraction.notes || ''),\n },\n binary: receiptItem.binary || {},\n },\n];"
},
"typeVersion": 2
},
{
"id": "88888888-8888-8888-8888-000000000072",
"name": "Check Receipt Extraction Result",
"type": "n8n-nodes-base.if",
"position": [
2160,
752
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "+1234567890",
"operator": {
"type": "boolean",
"operation": "equals"
},
"leftValue": "={{ $json.extractionSucceeded }}",
"rightValue": true
}
]
}
},
"typeVersion": 2.2
},
{
"id": "88888888-8888-8888-8888-000000000073",
"name": "Complete Skipped Receipt Processing",
"type": "n8n-nodes-base.code",
"position": [
2160,
1008
],
"parameters": {
"jsCode": "const item = $input.first().json;\nconst reason = item.isReceipt === false ? 'non_receipt' : 'extraction_failed';\n\nreturn [\n {\n json: {\n status: 'skipped',\n reason,\n message:\n reason === 'non_receipt'\n ? 'Email was processed successfully but does not appear to be a receipt.'\n : 'Receipt extraction failed. Check the email text, attachment quality, or OpenAI node output.',\n messageId: item.messageId,\n receiptHash: item.receiptHash,\n extractionStatus: item.extractionStatus,\n extractionFailureReason: item.extractionFailureReason || '',\n extractionSource: item.extractionSource || '',\n originalSubject: item.originalSubject,\n fromEmail: item.fromEmail,\n testScenario: item._testScenario || '',\n },\n },\n];"
},
"typeVersion": 2
},
{
"id": "88888888-8888-8888-8888-000000000009",
"name": "Format Receipt Alert",
"type": "n8n-nodes-base.code",
"position": [
2160,
752
],
"parameters": {
"jsCode": "const item = $input.first().json;\nconst money = `${item.currency || 'USD'} ${Number(item.total || 0).toFixed(2)}`;\nconst reason =\n item.route === 'review'\n ? 'Needs review'\n : item.route === 'high_value'\n ? 'High-value receipt'\n : item.route === 'warranty'\n ? 'Warranty or return date found'\n : 'Receipt logged';\n\nconst details = [\n `Reason: ${reason}`,\n `Merchant: ${item.merchant}`,\n `Total: ${money}`,\n `Category: ${item.category}`,\n `Purchase date: ${item.purchaseDate || 'Not found'}`,\n `Tax deductible: ${item.taxDeductible ? 'yes' : 'no'}`,\n `Tax category: ${item.taxCategory || 'None'}`,\n `Confidence: ${item.confidence}/100`,\n item.warrantyItems ? `Warranty items: ${item.warrantyItems}` : '',\n item.returnDeadline ? `Return deadline: ${item.returnDeadline}` : '',\n item.warrantyEndDate ? `Warranty end: ${item.warrantyEndDate}` : '',\n `Subject: ${item.originalSubject}`,\n].filter(Boolean);\n\nconst slackMessage = [`*${reason}: ${item.merchant}*`, '', ...details.map((line) => `\u2022 ${line}`)].join('\\n');\nconst teamsMessage = [`**${reason}: ${item.merchant}**`, '', ...details.map((line) => `- ${line}`)].join('\\n');\n\nreturn [\n {\n json: {\n ...item,\n useSlackAlert: Boolean(item.shouldAlert && item.useSlack),\n useTeamsAlert: Boolean(item.shouldAlert && item.useTeams),\n slackMessage,\n teamsMessage,\n },\n },\n];"
},
"typeVersion": 2
},
{
"id": "88888888-8888-8888-8888-000000000050",
"name": "Check Slack Alert Requirement",
"type": "n8n-nodes-base.if",
"position": [
2416,
-112
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "+1234567890",
"operator": {
"type": "boolean",
"operation": "equals"
},
"leftValue": "={{ $json.useSlackAlert }}",
"rightValue": true
}
]
}
},
"typeVersion": 2.2
},
{
"id": "88888888-8888-8888-8888-000000000051",
"name": "Send Slack Receipt Alert",
"type": "n8n-nodes-base.slack",
"position": [
2688,
-240
],
"parameters": {
"text": "={{ $json.slackMessage }}",
"select": "channel",
"resource": "message",
"channelId": {
"__rl": true,
"mode": "name",
"value": "={{ $json.slackChannel }}"
},
"operation": "post",
"otherOptions": {},
"authentication": "oAuth2"
},
"credentials": {
"slackOAuth2Api": {
"name": "<your credential>"
}
},
"typeVersion": 2.2
},
{
"id": "88888888-8888-8888-8888-000000000052",
"name": "Validate Slack Alert Sent",
"type": "n8n-nodes-base.code",
"position": [
2944,
-240
],
"parameters": {
"jsCode": "const item = $input.first().json;\nreturn [{ json: { ...item, alertSent: 'yes' } }];"
},
"typeVersion": 2
},
{
"id": "88888888-8888-8888-8888-000000000053",
"name": "Omit Slack Alert",
"type": "n8n-nodes-base.code",
"position": [
2688,
-32
],
"parameters": {
"jsCode": "const item = $input.first().json;\nreturn [{ json: { ...item, alertSent: item.alertSent || 'no' } }];"
},
"typeVersion": 2
},
{
"id": "88888888-8888-8888-8888-000000000054",
"name": "Check Teams Alert Requirement",
"type": "n8n-nodes-base.if",
"position": [
3180,
220
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "+1234567890",
"operator": {
"type": "boolean",
"operation": "equals"
},
"leftValue": "={{ $json.useTeamsAlert }}",
"rightValue": true
}
]
}
},
"typeVersion": 2.2
},
{
"id": "88888888-8888-8888-8888-000000000055",
"name": "Send Teams Receipt Alert",
"type": "n8n-nodes-base.httpRequest",
"position": [
3440,
100
],
"parameters": {
"url": "={{ $json.teamsWebhookUrl }}",
"method": "POST",
"options": {},
"jsonBody": "={{ JSON.stringify({ text: $json.teamsMessage }) }}",
"sendBody": true,
"specifyBody": "json"
},
"typeVersion": 4.2
},
{
"id": "88888888-8888-8888-8888-000000000056",
"name": "Validate Teams Alert Sent",
"type": "n8n-nodes-base.code",
"position": [
3700,
100
],
"parameters": {
"jsCode": "const item = $input.first().json;\nreturn [{ json: { ...item, alertSent: 'yes' } }];"
},
"typeVersion": 2
},
{
"id": "88888888-8888-8888-8888-000000000057",
"name": "Omit Teams Alert",
"type": "n8n-nodes-base.code",
"position": [
3440,
300
],
"parameters": {
"jsCode": "const item = $input.first().json;\nreturn [{ json: { ...item, alertSent: item.alertSent || 'no' } }];"
},
"typeVersion": 2
},
{
"id": "88888888-8888-8888-8888-000000000010",
"name": "Create Expense Log",
"type": "n8n-nodes-base.code",
"position": [
3940,
220
],
"parameters": {
"jsCode": "const item = $input.first().json;\nreturn [\n {\n json: {\n expenseLog: item.expenseLog,\n useSheetsLog: item.useSheetsLog,\n useAirtableLog: item.useAirtableLog,\n googleSheetId: item.googleSheetId,\n sheetsTabName: item.sheetsTabName,\n airtableBaseId: item.airtableBaseId,\n expensesTableName: item.expensesTableName,\n 'Received At': item.receivedAt,\n Source: item.source,\n 'Message ID': item.messageId,\n 'Receipt Hash': item.receiptHash,\n Merchant: item.merchant,\n 'Purchase Date': item.purchaseDate,\n Total: item.total,\n Currency: item.currency,\n Category: item.category,\n 'Tax Deductible': item.taxDeductible ? 'yes' : 'no',\n 'Tax Category': item.taxCategory,\n 'Payment Method': item.paymentMethod,\n 'Last Four': item.lastFour,\n Items: item.items,\n 'Warranty Items': item.warrantyItems,\n 'Return Deadline': item.returnDeadline,\n 'Warranty End Date': item.warrantyEndDate,\n 'AI Summary': item.aiSummary,\n 'Extraction Source': item.extractionSource,\n 'Is Receipt': item.isReceipt ? 'yes' : 'no',\n 'Extraction Status': item.extractionStatus,\n 'Extraction Failure Reason': item.extractionFailureReason,\n Confidence: item.confidence,\n 'Needs Review': item.needsReview ? 'yes' : 'no',\n 'High Value': item.highValue ? 'yes' : 'no',\n 'Alert Sent': item.alertSent || 'no',\n Status:\n item.route === 'review'\n ? 'Needs Review'\n : item.route === 'high_value'\n ? 'High Value'\n : item.route === 'warranty'\n ? 'Warranty / Return'\n : 'Logged',\n 'Original Subject': item.originalSubject,\n 'From Email': item.fromEmail,\n Notes: item.notes,\n 'Raw Text Snippet': item.rawTextSnippet,\n _testScenario: item._testScenario || '',\n },\n },\n];"
},
"typeVersion": 2
},
{
"id": "88888888-8888-8888-8888-000000000060",
"name": "Check Sheets Log Requirement",
"type": "n8n-nodes-base.if",
"position": [
4200,
220
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "+1234567890",
"operator": {
"type": "boolean",
"operation": "equals"
},
"leftValue": "={{ $json.useSheetsLog }}",
"rightValue": true
}
]
}
},
"typeVersion": 2.2
},
{
"id": "88888888-8888-8888-8888-000000000061",
"name": "Log Expense in Sheets",
"type": "n8n-nodes-base.googleSheets",
"position": [
4496,
96
],
"parameters": {
"columns": {
"value": {
"Items": "={ $json[\"Items\"] }",
"Notes": "={ $json[\"Notes\"] }",
"Total": "={ $json[\"Total\"] }",
"Source": "={ $json[\"Source\"] }",
"Status": "={ $json[\"Status\"] }",
"Category": "={ $json[\"Category\"] }",
"Currency": "={ $json[\"Currency\"] }",
"Merchant": "={ $json[\"Merchant\"] }",
"Last Four": "={ $json[\"Last Four\"] }",
"AI Summary": "={ $json[\"AI Summary\"] }",
"Alert Sent": "={ $json[\"Alert Sent\"] }",
"Confidence": "={ $json[\"Confidence\"] }",
"From Email": "={ $json[\"From Email\"] }",
"High Value": "={ $json[\"High Value\"] }",
"Is Receipt": "={ $json[\"Is Receipt\"] }",
"Message ID": "={ $json[\"Message ID\"] }",
"Received At": "={ $json[\"Received At\"] }",
"Needs Review": "={ $json[\"Needs Review\"] }",
"Receipt Hash": "={ $json[\"Receipt Hash\"] }",
"Tax Category": "={ $json[\"Tax Category\"] }",
"Purchase Date": "={ $json[\"Purchase Date\"] }",
"Payment Method": "={ $json[\"Payment Method\"] }",
"Tax Deductible": "={ $json[\"Tax Deductible\"] }",
"Warranty Items": "={ $json[\"Warranty Items\"] }",
"Return Deadline": "={ $json[\"Return Deadline\"] }",
"Original Subject": "={ $json[\"Original Subject\"] }",
"Raw Text Snippet": "={ $json[\"Raw Text Snippet\"] }",
"Extraction Source": "={ $json[\"Extraction Source\"] }",
"Extraction Status": "={ $json[\"Extraction Status\"] }",
"Warranty End Date": "={ $json[\"Warranty End Date\"] }",
"Extraction Failure Reason": "={ $json[\"Extraction Failure Reason\"] }"
},
"schema": [
{
"id": "Received At",
"type": "string",
"display": true,
"required": false,
"displayName": "Received At",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Source",
"type": "string",
"display": true,
"required": false,
"displayName": "Source",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Message ID",
"type": "string",
"display": true,
"required": false,
"displayName": "Message ID",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Receipt Hash",
"type": "string",
"display": true,
"required": false,
"displayName": "Receipt Hash",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Merchant",
"type": "string",
"display": true,
"required": false,
"displayName": "Merchant",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Purchase Date",
"type": "string",
"display": true,
"required": false,
"displayName": "Purchase Date",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Total",
"type": "string",
"display": true,
"required": false,
"displayName": "Total",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Currency",
"type": "string",
"display": true,
"required": false,
"displayName": "Currency",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Category",
"type": "string",
"display": true,
"required": false,
"displayName": "Category",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Tax Deductible",
"type": "string",
"display": true,
"required": false,
"displayName": "Tax Deductible",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Tax Category",
"type": "string",
"display": true,
"required": false,
"displayName": "Tax Category",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Payment Method",
"type": "string",
"display": true,
"required": false,
"displayName": "Payment Method",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Last Four",
"type": "string",
"display": true,
"required": false,
"displayName": "Last Four",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Items",
"type": "string",
"display": true,
"required": false,
"displayName": "Items",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Warranty Items",
"type": "string",
"display": true,
"required": false,
"displayName": "Warranty Items",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Return Deadline",
"type": "string",
"display": true,
"required": false,
"displayName": "Return Deadline",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Warranty End Date",
"type": "string",
"display": true,
"required": false,
"displayName": "Warranty End Date",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "AI Summary",
"type": "string",
"display": true,
"required": false,
"displayName": "AI Summary",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Extraction Source",
"type": "string",
"display": true,
"required": false,
"displayName": "Extraction Source",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Is Receipt",
"type": "string",
"display": true,
"required": false,
"displayName": "Is Receipt",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Extraction Status",
"type": "string",
"display": true,
"required": false,
"displayName": "Extraction Status",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Extraction Failure Reason",
"type": "string",
"display": true,
"required": false,
"displayName": "Extraction Failure Reason",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Confidence",
"type": "string",
"display": true,
"required": false,
"displayName": "Confidence",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Needs Review",
"type": "string",
"display": true,
"required": false,
"displayName": "Needs Review",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "High Value",
"type": "string",
"display": true,
"required": false,
"displayName": "High Value",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Alert Sent",
"type": "string",
"display": true,
"required": false,
"displayName": "Alert Sent",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Status",
"type": "string",
"display": true,
"required": false,
"displayName": "Status",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Original Subject",
"type": "string",
"display": true,
"required": false,
"displayName": "Original Subject",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "From Email",
"type": "string",
"display": true,
"required": false,
"displayName": "From Email",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Notes",
"type": "string",
"display": true,
"required": false,
"displayName": "Notes",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Raw Text Snippet",
"type": "string",
"display": true,
"required": false,
"displayName": "Raw Text Snippet",
"defaultMatch": false,
"canBeUsedToMatch": true
}
],
"mappingMode": "defineBelow",
"matchingColumns": []
},
"options": {},
"resource": "sheet",
"operation": "append",
"sheetName": {
"__rl": true,
"mode": "name",
"value": "={{ $json.sheetsTabName }}"
},
"documentId": {
"__rl": true,
"mode": "id",
"value": "={{ $json.googleSheetId }}"
}
},
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
},
"typeVersion": 4.5
},
{
"id": "88888888-8888-8888-8888-000000000062",
"name": "Verify Sheets Log Entry",
"type": "n8n-nodes-base.code",
"position": [
4752,
96
],
"parameters": {
"jsCode": "const prior = $('Create Expense Log').first().json;\nreturn [{ json: { ...prior, sheetsLogged: 'yes' } }];"
},
"typeVersion": 2
},
{
"id": "88888888-8888-8888-8888-000000000063",
"name": "Bypass Sheets Logging",
"type": "n8n-nodes-base.code",
"position": [
4496,
304
],
"parameters": {
"jsCode": "const item = $input.first().json;\nreturn [{ json: { ...item, sheetsLogged: 'no' } }];"
},
"typeVersion": 2
},
{
"id": "88888888-8888-8888-8888-000000000064",
"name": "Check Airtable Log Requirement",
"type": "n8n-nodes-base.if",
"position": [
4992,
224
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "+1234567890",
"operator": {
"type": "boolean",
"operation": "equals"
},
"leftValue": "={{ $json.useAirtableLog }}",
"rightValue": true
}
]
}
},
"typeVersion": 2.2
},
{
"id": "88888888-8888-8888-8888-000000000065",
"name": "Log Expense in Airtable",
"type": "n8n-nodes-base.airtable",
"position": [
5264,
96
],
"parameters": {
"base": {
"__rl": true,
"mode": "id",
"value": "={{ $json.airtableBaseId }}"
},
"table": {
"__rl": true,
"mode": "name",
"value": "={{ $json.expensesTableName }}"
},
"columns": {
"value": {
"Items": "={ $json[\"Items\"] }",
"Notes": "={ $json[\"Notes\"] }",
"Total": "={ $json[\"Total\"] }",
"Source": "={ $json[\"Source\"] }",
"Status": "={ $json[\"Status\"] }",
"Category": "={ $json[\"Category\"] }",
"Currency": "={ $json[\"Currency\"] }",
"Merchant": "={ $json[\"Merchant\"] }",
"Last Four": "={ $json[\"Last Four\"] }",
"AI Summary": "={ $json[\"AI Summary\"] }",
"Alert Sent": "={ $json[\"Alert Sent\"] }",
"Confidence": "={ $json[\"Confidence\"] }",
"From Email": "={ $json[\"From Email\"] }",
"High Value": "={ $json[\"High Value\"] }",
"Is Receipt": "={ $json[\"Is Receipt\"] }",
"Message ID": "={ $json[\"Message ID\"] }",
"Received At": "={ $json[\"Received At\"] }",
"Needs Review": "={ $json[\"Needs Review\"] }",
"Receipt Hash": "={ $json[\"Receipt Hash\"] }",
"Tax Category": "={ $json[\"Tax Category\"] }",
"Purchase Date": "={ $json[\"Purchase Date\"] }",
"Payment Method": "={ $json[\"Payment Method\"] }",
"Tax Deductible": "={ $json[\"Tax Deductible\"] }",
"Warranty Items": "={ $json[\"Warranty Items\"] }",
"Return Deadline": "={ $json[\"Return Deadline\"] }",
"Original Subject": "={ $json[\"Original Subject\"] }",
"Raw Text Snippet": "={ $json[\"Raw Text Snippet\"] }",
"Extraction Source": "={ $json[\"Extraction Source\"] }",
"Extraction Status": "={ $json[\"Extraction Status\"] }",
"Warranty End Date": "={ $json[\"Warranty End Date\"] }",
"Extraction Failure Reason": "={ $json[\"Extraction Failure Reason\"] }"
},
"schema": [
{
"id": "Received At",
"type": "string",
"display": true,
"required": false,
"displayName": "Received At",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Source",
"type": "string",
"display": true,
"required": false,
"displayName": "Source",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Message ID",
"type": "string",
"display": true,
"required": false,
"displayName": "Message ID",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Receipt Hash",
"type": "string",
"display": true,
"required": false,
"displayName": "Receipt Hash",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Merchant",
"type": "string",
"display": true,
"required": false,
"displayName":
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
airtableTokenApigmailOAuth2googleSheetsOAuth2ApiopenAiApislackOAuth2Api
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow monitors a Gmail “Receipts” label, downloads attachments, uses OpenAI to extract expense data from receipt emails and images, skips duplicates and non-receipts, fails safely when extraction is unreadable, sends Slack/Teams alerts for high-value, warranty, or…
Source: https://n8n.io/workflows/17134/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
This workflow monitors Gmail for invoice or receipt emails, uses OpenAI to extract an expense amount and category, logs the result to Google Sheets, and sends a Slack alert when an expense exceeds a d
📘 Description
This workflow automatically detects duplicate invoices from Gmail. Incoming PDF attachments are scanned by the easybits AI Extractor, then checked against the Master Finance File in Google Sheets. Dup
Transform your Google Form into an intelligent customer support system that automatically analyzes, prioritizes, and responds to every inquiry with AI-powered personalization.
Support Ticket Triage. Uses gmailTrigger, googleSheets, slack, gmail. Event-driven trigger; 9 nodes.