{
  "name": "FlowPantry - Turn receipt photos and emails into a clean expense log (Gmail + AI + Google Sheets)",
  "nodes": [
    {
      "id": "2461e519-2cfb-48d6-8e04-8c7a60de1133",
      "name": "Gmail: new receipt email",
      "type": "n8n-nodes-base.gmailTrigger",
      "typeVersion": 1.3,
      "position": [
        0,
        300
      ],
      "parameters": {
        "pollTimes": {
          "item": [
            {
              "mode": "everyMinute"
            }
          ]
        },
        "simple": false,
        "filters": {
          "q": "label:receipts",
          "readStatus": "unread"
        },
        "options": {
          "downloadAttachments": true,
          "dataPropertyAttachmentsPrefixName": "attachment_"
        }
      }
    },
    {
      "id": "e1dab2d0-e871-4f0f-aee9-a41e1cd1de47",
      "name": "Your settings",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        300
      ],
      "parameters": {
        "mode": "manual",
        "assignments": {
          "assignments": [
            {
              "id": "1f2f1a01-2e98-48fd-a928-8bebfea46a5e",
              "name": "BUSINESS_NAME",
              "value": "YOUR BUSINESS",
              "type": "string"
            },
            {
              "id": "2e9d64b1-f92e-42ed-8586-401eb524686a",
              "name": "OWNER_EMAIL",
              "value": "you@yourdomain.com",
              "type": "string"
            },
            {
              "id": "699cab83-0917-4584-8179-26e47ecf7082",
              "name": "FROM_EMAIL",
              "value": "Receipts bot <receipts@yourdomain.com>",
              "type": "string"
            },
            {
              "id": "81ec8a2e-fad5-47a0-9daf-90c360410f58",
              "name": "OPENAI_MODEL",
              "value": "gpt-4o-mini",
              "type": "string"
            },
            {
              "id": "dda3f007-96fc-4180-8653-2a9bbb18ec81",
              "name": "CATEGORIES",
              "value": "Advertising, Car and truck, Fuel, Meals, Travel, Supplies, Materials, Tools and equipment, Office expenses, Software and subscriptions, Repairs and maintenance, Rent or lease, Utilities, Phone and internet, Insurance, Contract labor, Legal and professional, Taxes and licenses, Bank and card fees, Other",
              "type": "string"
            },
            {
              "id": "bfc81eb0-49bf-4b1c-979a-d4398c1f2a57",
              "name": "MIN_CONFIDENCE",
              "value": 0.8,
              "type": "number"
            },
            {
              "id": "58799691-cddf-49a7-a581-7110a44f2fa8",
              "name": "REVIEW_OVER_AMOUNT",
              "value": 500,
              "type": "number"
            },
            {
              "id": "8c1c3b4a-af6a-432a-9dd2-29a7fa41da8d",
              "name": "MIN_IMAGE_KB",
              "value": 15,
              "type": "number"
            }
          ]
        },
        "includeOtherFields": true,
        "include": "all",
        "options": {}
      }
    },
    {
      "id": "8c33439f-1fa9-4d63-a979-b0edeac9f065",
      "name": "Split into receipts",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        300
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// One item per receipt: each PDF/photo attachment, or the email body when there is no usable attachment.\nconst s = $('Your settings').first().json;\nconst minKb = Number(s.MIN_IMAGE_KB || 15);\nconst okImage = /^image\\/(jpeg|jpg|png|webp|gif)$/;\nconst items = $input.all();\nconst out = [];\nfor (let i = 0; i < items.length; i++) {\n  const mail = items[i].json;\n  const base = {\n    message_id: mail.id || '',\n    thread_id: mail.threadId || mail.id || '',\n    email_from: (mail.from && mail.from.text) || '',\n    email_subject: mail.subject || '',\n    email_date: mail.date || '',\n  };\n  let files = 0;\n  for (const key of Object.keys(items[i].binary || {})) {\n    const b = items[i].binary[key];\n    const mime = String(b.mimeType || '').toLowerCase().replace('image/jpg', 'image/jpeg');\n    const isPdf = mime === 'application/pdf';\n    if (!isPdf && !okImage.test(mime)) continue;          // skip .ics, .docx, HEIC, etc.\n    const buf = await this.helpers.getBinaryDataBuffer(i, key);\n    if (!isPdf && buf.length < minKb * 1024) continue;    // skip logos and signature images\n    files++;\n    out.push({\n      json: { ...base, kind: 'file', file_name: b.fileName || `receipt-${files}`, mime_type: mime, size_bytes: buf.length, file_b64: buf.toString('base64') },\n      binary: { data: b },\n      pairedItem: { item: i },\n    });\n  }\n  if (files === 0) {\n    const text = String(mail.text || '').replace(/\\n{3,}/g, '\\n\\n').trim().slice(0, 12000);\n    if (text.length > 40) out.push({ json: { ...base, kind: 'email_body', file_name: '', mime_type: 'text/plain', size_bytes: text.length, body_text: text }, pairedItem: { item: i } });\n  }\n}\nreturn out;"
      }
    },
    {
      "id": "51463197-807c-4617-87a9-256dea70fc6c",
      "name": "Fingerprint receipt",
      "type": "n8n-nodes-base.crypto",
      "typeVersion": 2,
      "position": [
        660,
        300
      ],
      "parameters": {
        "action": "hash",
        "type": "SHA256",
        "value": "={{ $json.kind === 'file' ? $json.file_b64 : $json.body_text }}",
        "dataPropertyName": "file_hash",
        "encoding": "hex"
      }
    },
    {
      "id": "749daa13-a8bf-4651-8c3a-44a7f8487f42",
      "name": "Skip repeat receipts",
      "type": "n8n-nodes-base.removeDuplicates",
      "typeVersion": 2,
      "position": [
        880,
        300
      ],
      "parameters": {
        "operation": "removeItemsSeenInPreviousExecutions",
        "dedupeValue": "={{ $json.file_hash }}",
        "options": {}
      }
    },
    {
      "id": "d8dfacfe-810d-42a6-bb6c-2166a0ece5a1",
      "name": "Has a file?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1100,
        300
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "641c87ed-b3d7-4b91-bb56-a61c146d413e",
              "leftValue": "={{ $json.kind }}",
              "rightValue": "file",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "5827a0f9-b60a-4997-8226-b3892b1d0653",
      "name": "Save original to Drive",
      "type": "n8n-nodes-base.googleDrive",
      "typeVersion": 3,
      "position": [
        1320,
        200
      ],
      "parameters": {
        "resource": "file",
        "operation": "upload",
        "inputDataFieldName": "data",
        "name": "={{ $now.toFormat('yyyy-MM-dd') }}_{{ $json.file_name }}",
        "driveId": {
          "__rl": true,
          "mode": "list",
          "value": "My Drive"
        },
        "folderId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "options": {}
      }
    },
    {
      "id": "6c9b49aa-0398-4085-a3c0-a66e6c9e10a9",
      "name": "Build AI request",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1540,
        300
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "language": "javaScript",
        "jsCode": "// Builds the OpenAI request. Runs once per receipt.\nconst s = $('Your settings').first().json;\nconst src = $('Skip repeat receipts').item.json;\nconst cats = String(s.CATEGORIES || '').split(',').map(c => c.trim()).filter(Boolean);\nconst driveId = src.kind === 'file' ? ($json.id || '') : '';\nconst receiptLink = driveId ? `https://drive.google.com/file/d/${driveId}/view` : `https://mail.google.com/mail/u/0/#all/${src.thread_id}`;\nconst intro = `Email from: ${src.email_from}\\nSubject: ${src.email_subject}\\nReceived: ${src.email_date}\\n`;\nconst content = [{ type: 'text', text: src.kind === 'file' ? intro + `Attached file: ${src.file_name}` : intro + `Email body:\\n${src.body_text}` }];\nif (src.kind === 'file') {\n  const dataUrl = `data:${src.mime_type};base64,${src.file_b64}`;\n  content.push(src.mime_type === 'application/pdf'\n    ? { type: 'file', file: { filename: src.file_name || 'receipt.pdf', file_data: dataUrl } }\n    : { type: 'image_url', image_url: { url: dataUrl, detail: 'high' } });\n}\nconst num = { type: ['number', 'null'] };\nconst schema = {\n  type: 'object', additionalProperties: false,\n  required: ['is_receipt', 'vendor', 'date', 'description', 'subtotal', 'tax', 'tip', 'total', 'currency', 'card_last4', 'category', 'confidence'],\n  properties: {\n    is_receipt: { type: 'boolean' },\n    vendor: { type: 'string' },\n    date: { type: 'string', description: 'Purchase date as YYYY-MM-DD, or empty if not shown' },\n    description: { type: 'string', description: 'What was bought, under 12 words' },\n    subtotal: num, tax: num, tip: num, total: num,\n    currency: { type: 'string', description: '3-letter code, e.g. USD' },\n    card_last4: { type: 'string', description: 'Last 4 digits of the card if printed, else empty. Never more than 4 digits.' },\n    category: { type: 'string', enum: [...cats, 'Uncategorized'] },\n    confidence: { type: 'number', description: '0 to 1: how sure you are the vendor, date and total are read correctly' },\n  },\n};\nconst system = `You read receipts and invoices for ${s.BUSINESS_NAME || 'a small business'}. Extract only what is printed. Do not guess numbers: use null when a value is missing or unreadable. Use the final amount actually charged as total. Pick the closest category from the list, or Uncategorized if none fits. If the document is not a receipt or invoice for a purchase (a logo, an ad, a shipping notice with no amount), set is_receipt to false.`;\nconst { file_b64, body_text, ...meta } = src;\nreturn { json: { ...meta, drive_file_id: driveId, receipt_link: receiptLink, ai_body: {\n  model: s.OPENAI_MODEL || 'gpt-4o-mini', temperature: 0,\n  response_format: { type: 'json_schema', json_schema: { name: 'receipt', strict: true, schema } },\n  messages: [{ role: 'system', content: system }, { role: 'user', content }],\n} } };"
      }
    },
    {
      "id": "d3d59577-1e73-462e-8670-decd8ef8fa5c",
      "name": "Read receipt with AI",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1760,
        300
      ],
      "parameters": {
        "method": "POST",
        "url": "https://api.openai.com/v1/chat/completions",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.ai_body) }}",
        "options": {
          "timeout": 120000
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "id": "a625f700-1fa3-457a-a116-a3a4402e80da",
      "name": "Check and build row",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1980,
        300
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "language": "javaScript",
        "jsCode": "// Checks the AI's answer and builds one ledger row. Anything doubtful goes to \"needs_review\" instead of being trusted.\nconst s = $('Your settings').first().json;\nconst meta = $('Build AI request').item.json;\nconst cats = String(s.CATEGORIES || '').split(',').map(c => c.trim()).filter(Boolean);\nconst minConf = Number(s.MIN_CONFIDENCE || 0.8);\nconst reviewOver = Number(s.REVIEW_OVER_AMOUNT || 500);\nconst reasons = [];\nlet r = {};\ntry { r = JSON.parse($json.choices[0].message.content); } catch (e) { reasons.push('AI reply could not be read'); }\nconst money = v => (typeof v === 'number' && isFinite(v)) ? Math.round(v * 100) / 100 : null;\nconst subtotal = money(r.subtotal), tax = money(r.tax), tip = money(r.tip), total = money(r.total);\nconst vendor = String(r.vendor || '').trim();\nconst date = /^\\d{4}-\\d{2}-\\d{2}$/.test(String(r.date || '')) ? r.date : '';\nconst today = new Date().toISOString().slice(0, 10);\nconst daysAgo = date ? (Date.parse(today) - Date.parse(date)) / 86400000 : null;\nconst last4 = String(r.card_last4 || '').replace(/\\D/g, '').slice(-4);\nif (r.is_receipt === false) reasons.push(\"Doesn't look like a receipt\");\nif (!vendor) reasons.push('No vendor');\nif (total === null || total <= 0) reasons.push('No total found');\nif (!date) reasons.push('No date found');\nelse if (daysAgo < -1) reasons.push('Date is in the future');\nelse if (daysAgo > 400) reasons.push('Date is over a year old');\nif (subtotal !== null && tax !== null && total !== null && Math.abs(subtotal + tax + (tip || 0) - total) > 0.02) reasons.push(\"Subtotal + tax + tip doesn't match total\");\nif (typeof r.confidence !== 'number' || r.confidence < minConf) reasons.push('AI not sure');\nif (!cats.includes(r.category)) reasons.push('Pick a category');\nif (total !== null && total > reviewOver) reasons.push(`Over ${reviewOver}, double-check`);\nconst status = reasons.length ? 'needs_review' : 'auto';\nreturn { json: {\n  'Logged At': new Date().toISOString(),\n  'Receipt Date': date,\n  'Vendor': vendor,\n  'Description': String(r.description || ''),\n  'Category (AI)': r.category || '',\n  'Category (Final)': status === 'auto' ? r.category : '',\n  'Subtotal': subtotal, 'Tax': tax, 'Tip': tip, 'Total': total,\n  'Currency': String(r.currency || 'USD').toUpperCase().slice(0, 3),\n  'Card Last4': last4,\n  'Status': status,\n  'Review Notes': reasons.join('; '),\n  'Receipt Link': meta.receipt_link,\n  'Email From': meta.email_from,\n  'Email Subject': meta.email_subject,\n  'Gmail Message ID': meta.message_id,\n  'File Hash': meta.file_hash,\n} };"
      }
    },
    {
      "id": "1b4bb33d-38aa-417d-917b-ac367d8a11b5",
      "name": "Add to expense sheet",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        2200,
        300
      ],
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {},
          "matchingColumns": [],
          "schema": []
        },
        "options": {}
      }
    },
    {
      "id": "87aa944e-7835-4554-83c0-b0625fd4c8b6",
      "name": "Needs review?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2420,
        300
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "ec450738-2f63-4440-88bd-d3f2a7dabe56",
              "leftValue": "={{ $('Check and build row').item.json.Status }}",
              "rightValue": "needs_review",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "8ae0658f-4d1e-471c-b6e8-ad9d2f60e1ee",
      "name": "Email me: receipt to check",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2640,
        200
      ],
      "parameters": {
        "method": "POST",
        "url": "https://api.resend.com/emails",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ from: $('Your settings').first().json.FROM_EMAIL, to: $('Your settings').first().json.OWNER_EMAIL, subject: 'Check receipt: ' + ($('Check and build row').item.json.Vendor || 'unknown vendor') + ' ' + ($('Check and build row').item.json.Total === null ? '' : $('Check and build row').item.json.Total), text: 'This receipt was logged as needs_review.\\n\\nWhy: ' + $('Check and build row').item.json['Review Notes'] + '\\n\\nVendor: ' + $('Check and build row').item.json.Vendor + '\\nDate: ' + $('Check and build row').item.json['Receipt Date'] + '\\nTotal: ' + $('Check and build row').item.json.Total + ' ' + $('Check and build row').item.json.Currency + '\\nAI category: ' + $('Check and build row').item.json['Category (AI)'] + '\\n\\nReceipt: ' + $('Check and build row').item.json['Receipt Link'] + '\\n\\nFix the row in your expense sheet and set Status to reviewed.' }) }}",
        "options": {}
      }
    }
  ],
  "connections": {
    "Gmail: new receipt email": {
      "main": [
        [
          {
            "node": "Your settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Your settings": {
      "main": [
        [
          {
            "node": "Split into receipts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split into receipts": {
      "main": [
        [
          {
            "node": "Fingerprint receipt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fingerprint receipt": {
      "main": [
        [
          {
            "node": "Skip repeat receipts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Skip repeat receipts": {
      "main": [
        [
          {
            "node": "Has a file?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has a file?": {
      "main": [
        [
          {
            "node": "Save original to Drive",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build AI request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save original to Drive": {
      "main": [
        [
          {
            "node": "Build AI request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build AI request": {
      "main": [
        [
          {
            "node": "Read receipt with AI",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read receipt with AI": {
      "main": [
        [
          {
            "node": "Check and build row",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check and build row": {
      "main": [
        [
          {
            "node": "Add to expense sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Add to expense sheet": {
      "main": [
        [
          {
            "node": "Needs review?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Needs review?": {
      "main": [
        [
          {
            "node": "Email me: receipt to check",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {}
}