{
  "id": "EegiaHUeHSdOLYuc",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Vision AI Receipt & Document Verification",
  "tags": [],
  "nodes": [
    {
      "id": "915c1efe-6a38-45d5-bfb3-2e477329adf2",
      "name": "Document Upload Webhook",
      "type": "n8n-nodes-base.webhook",
      "position": [
        -1440,
        128
      ],
      "parameters": {
        "path": "receipt-verify",
        "options": {},
        "httpMethod": "POST",
        "responseMode": "responseNode"
      },
      "typeVersion": 2
    },
    {
      "id": "7423276b-e20c-44eb-b20c-39cfaa2600dc",
      "name": "Normalize Input",
      "type": "n8n-nodes-base.code",
      "position": [
        -1232,
        128
      ],
      "parameters": {
        "jsCode": "const b = ($input.first().json.body) || {};\nreturn [{\n  json: {\n    documentId: b.document_id || ('doc_' + Date.now()),\n    guestId: b.guest_id || 'unknown',\n    bookingId: b.booking_id || '',\n    imageUrl: b.image_url || b.imageUrl || '',\n    docType: b.doc_type || 'receipt',\n    expectedAmount: (b.expected_amount != null) ? Number(b.expected_amount) : null,\n    expectedCurrency: String(b.currency || 'EUR').toUpperCase(),\n    receivedAt: new Date().toISOString()\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "3a855dc5-d14b-4fa6-b442-f0d863925abb",
      "name": "Parse Extraction",
      "type": "n8n-nodes-base.code",
      "position": [
        -752,
        64
      ],
      "parameters": {
        "jsCode": "const res = $input.first().json;\nlet content = '{}';\ntry { content = res.choices[0].message.content; } catch (e) {}\nlet parsed = {};\ntry { parsed = JSON.parse(content); } catch (e) { parsed = { confidence: 0 }; }\nconst prev = $node['Normalize Input'].json;\nreturn [{\n  json: {\n    documentId: prev.documentId,\n    guestId: prev.guestId,\n    bookingId: prev.bookingId,\n    expectedAmount: prev.expectedAmount,\n    expectedCurrency: prev.expectedCurrency,\n    vendor: parsed.vendor ?? null,\n    date: parsed.document_date ?? null,\n    total: (parsed.total != null) ? Number(parsed.total) : null,\n    currency: parsed.currency ? String(parsed.currency).toUpperCase() : null,\n    lineItems: parsed.line_items ?? [],\n    confidence: (parsed.confidence != null) ? Number(parsed.confidence) : 0\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "2c2be2b9-d0be-4428-885b-e7d4c2435b68",
      "name": "Validate Against Booking",
      "type": "n8n-nodes-base.code",
      "position": [
        -512,
        64
      ],
      "parameters": {
        "jsCode": "const d = $input.first().json;\nconst reasons = [];\nconst tolerance = 0.02;\nif (d.confidence < 0.6) reasons.push('Low extraction confidence');\nif (d.total == null) reasons.push('Total not found');\nif (d.currency && d.expectedCurrency && d.currency !== d.expectedCurrency) {\n  reasons.push('Currency mismatch: got ' + d.currency + ' expected ' + d.expectedCurrency);\n}\nif (d.total != null && d.expectedAmount != null) {\n  const diff = Math.abs(d.total - d.expectedAmount);\n  const allowed = Math.abs(d.expectedAmount) * tolerance;\n  if (diff > allowed) reasons.push('Amount mismatch: got ' + d.total + ' expected ' + d.expectedAmount);\n}\nlet verdict = 'APPROVED';\nif (reasons.length > 0) {\n  verdict = (d.confidence < 0.6 || d.total == null) ? 'REJECTED' : 'REVIEW';\n}\nreturn [{\n  json: Object.assign({}, d, {\n    verdict: verdict,\n    reasons: reasons.length ? reasons : ['All checks passed'],\n    processedAt: new Date().toISOString()\n  })\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "4e447abe-f3e9-428a-bc86-b7c81a0c5cf2",
      "name": "Auto Approve?",
      "type": "n8n-nodes-base.if",
      "position": [
        -272,
        64
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-approved",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.verdict }}",
              "rightValue": "APPROVED"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "f6b47b44-f290-4dc2-a2c0-c9353ba152c9",
      "name": "Flag For Review",
      "type": "n8n-nodes-base.slack",
      "position": [
        -48,
        -16
      ],
      "parameters": {
        "text": "=:mag: *Document needs review* ({{ $json.verdict }})\nDocument: {{ $json.documentId }}  Booking: {{ $json.bookingId }}\nVendor: {{ $json.vendor }}  Total: {{ $json.total }} {{ $json.currency }}\nExpected: {{ $json.expectedAmount }} {{ $json.expectedCurrency }}\nReasons: {{ $json.reasons.join('; ') }}",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": "C0AK5VBUE1Z",
          "cachedResultName": "pricing-alerts"
        },
        "otherOptions": {},
        "authentication": "oAuth2"
      },
      "credentials": {
        "slackOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "8cac6fc5-8c39-40ca-8fb7-02d3901a1d46",
      "name": "Build Error Verdict",
      "type": "n8n-nodes-base.code",
      "position": [
        -736,
        320
      ],
      "parameters": {
        "jsCode": "const prev = $node['Normalize Input'].json;\nreturn [{\n  json: {\n    documentId: prev.documentId,\n    guestId: prev.guestId,\n    bookingId: prev.bookingId,\n    expectedAmount: prev.expectedAmount,\n    expectedCurrency: prev.expectedCurrency,\n    vendor: null,\n    date: null,\n    total: null,\n    currency: null,\n    lineItems: [],\n    confidence: 0,\n    verdict: 'ERROR',\n    reasons: ['Vision extraction failed after retries'],\n    processedAt: new Date().toISOString()\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "20255ea7-22ca-42a7-b303-76c541acd279",
      "name": "Record Result",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        208,
        128
      ],
      "parameters": {
        "columns": {
          "value": {
            "VELORA APPAREL CO.": "={{ $json.documentId }}"
          },
          "schema": [
            {
              "id": "VELORA APPAREL CO.",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "VELORA APPAREL CO.",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "VELORA APPAREL CO."
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1EV68pHdgAY4B5vTAk2djVSGEfXWhaXpSSy2GNjdCjYU/edit#gid=0",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "1EV68pHdgAY4B5vTAk2djVSGEfXWhaXpSSy2GNjdCjYU",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1EV68pHdgAY4B5vTAk2djVSGEfXWhaXpSSy2GNjdCjYU/edit?usp=drivesdk",
          "cachedResultName": "Cashflow"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "b707f596-45ba-402f-a3fb-a00d8d0c7d32",
      "name": "Respond",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        448,
        128
      ],
      "parameters": {
        "options": {},
        "respondWith": "json",
        "responseBody": "={{ ({ documentId: $json.documentId, verdict: $json.verdict, reasons: $json.reasons }) }}"
      },
      "typeVersion": 1.1
    },
    {
      "id": "685f0daf-3760-45d5-bb3d-78ecbc5e9b84",
      "name": "Error Trigger",
      "type": "n8n-nodes-base.errorTrigger",
      "position": [
        -992,
        608
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "26943552-536a-4169-9297-5395920b550e",
      "name": "Alert Engineering",
      "type": "n8n-nodes-base.slack",
      "position": [
        -752,
        608
      ],
      "parameters": {
        "text": "=:rotating_light: *Receipt verification workflow failed*\nWorkflow: {{ $json.workflow.name }}\nNode: {{ $json.execution.lastNodeExecuted }}\nError: {{ $json.execution.error.message }}",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": "C0AKM4U1K25",
          "cachedResultName": "hr-team"
        },
        "otherOptions": {},
        "authentication": "oAuth2"
      },
      "credentials": {
        "slackOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "abc87bd5-80c4-4c54-9092-933dda799457",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -2224,
        -240
      ],
      "parameters": {
        "color": 7,
        "width": 560,
        "height": 1100,
        "content": "## \ud83e\uddfe Vision AI Receipt & Document Verification\n\nReceives a receipt or invoice image, reads it with GPT 4o Vision, validates the extracted total and currency against the expected booking values, then auto approves clean documents and flags anything suspicious for human review. Every result is logged to Google Sheets.\n\n**Perfect for:** hospitality finance and operations teams that need to verify guest receipts, deposits and supplier invoices at scale.\n\n---\n\n## How it works\n\n1. **Document Upload Webhook** \u2014 Receives the document image URL plus the expected amount and currency.\n2. **Normalize Input** \u2014 Cleans the payload into a consistent shape.\n3. **GPT Vision Extract** \u2014 Calls the OpenAI vision API to read the document into structured JSON. Retries on failure.\n4. **Parse Extraction** \u2014 Safely parses the model output into typed fields.\n5. **Validate Against Booking** \u2014 Checks total, currency and confidence against the expected values and assigns a verdict.\n6. **Auto Approve?** \u2014 Branches on whether the verdict is APPROVED.\n7. **Flag For Review** \u2014 Posts non approved documents to the finance review channel *(review path)*.\n8. **Build Error Verdict** \u2014 If the vision call fails, builds an ERROR verdict so nothing is silently dropped *(error path)*.\n9. **Record Result** \u2014 Appends the full result to Google Sheets.\n10. **Respond** \u2014 Returns the verdict to the caller.\n11. **Error Trigger \u2192 Alert Engineering** \u2014 Catches any unhandled failure and alerts the team.\n\n---\n\n## Setup (~10 minutes)\n\n1. **OpenAI** \u2014 Connect your key in the *GPT Vision Extract* node (uses the OpenAI credential).\n2. **Slack** \u2014 Connect your account in *Flag For Review* and *Alert Engineering*.\n3. **Google Sheets** \u2014 Connect in *Record Result* and set your sheet id and a tab named Verifications.\n> Send a POST to *Document Upload Webhook* with image_url, expected_amount, currency, guest_id and booking_id. The image URL must be publicly reachable by the OpenAI API."
      },
      "typeVersion": 1
    },
    {
      "id": "657608e8-b12b-4eef-ae4d-c8cccc9d7733",
      "name": "Section Intake",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1536,
        -176
      ],
      "parameters": {
        "color": 5,
        "width": 464,
        "height": 480,
        "content": "## 1\ufe0f\u20e3 Intake\n\nThe **Document Upload Webhook** receives the document image URL together with the expected amount and currency for the booking. **Normalize Input** flattens the request body into a predictable structure and assigns a document id so every downstream node works from the same fields."
      },
      "typeVersion": 1
    },
    {
      "id": "6854ae3e-9d15-4c1b-ac8c-9aae0a4e32df",
      "name": "Section Vision",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1056,
        -192
      ],
      "parameters": {
        "color": 3,
        "width": 496,
        "height": 684,
        "content": "## 2\ufe0f\u20e3 Vision Extraction\n\n**GPT Vision Extract** sends the image to the OpenAI vision model and forces a strict JSON response, retrying once on a transient failure. **Parse Extraction** safely parses that response into typed fields. If extraction fails entirely, the error output routes to **Build Error Verdict** so the document is still logged rather than lost."
      },
      "typeVersion": 1
    },
    {
      "id": "e660d8cd-3b01-4bcd-b28b-7d1c80900961",
      "name": "Section Validate",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -544,
        -192
      ],
      "parameters": {
        "color": 6,
        "width": 640,
        "height": 684,
        "content": "## 3\ufe0f\u20e3 Validation & Routing\n\n**Validate Against Booking** compares the extracted total and currency against the expected values within a 2 percent tolerance and gates on extraction confidence, producing an APPROVED, REVIEW, REJECTED or ERROR verdict. **Auto Approve?** sends clean documents straight to logging and routes everything else to **Flag For Review** in Slack."
      },
      "typeVersion": 1
    },
    {
      "id": "3a6e51b1-64da-4ee6-a343-e4a492d63675",
      "name": "Section Logging",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        128,
        -192
      ],
      "parameters": {
        "color": 4,
        "width": 540,
        "height": 728,
        "content": "## 4\ufe0f\u20e3 Logging & Error Handling\n\n**Record Result** appends the vendor, totals, verdict, reasons and confidence to Google Sheets for a full audit trail, and **Respond** returns the verdict to the caller. The **Error Trigger** fires on any unhandled failure and **Alert Engineering** posts the failing node and message to the alerts channel."
      },
      "typeVersion": 1
    },
    {
      "id": "38960c74-2c2f-40f1-adf1-ca8c449b631a",
      "name": "AI Agent",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        -1024,
        64
      ],
      "parameters": {
        "text": "= 'You are a precise document extraction engine for hotel receipts and invoices. Return ONLY JSON with keys: vendor (string), document_date (YYYY-MM-DD string), total (number), currency (3 letter code), line_items (array of objects with description and amount), confidence (number between 0 and 1). If a field is unreadable use null.' }, { role: 'user', content: [ { type: 'text', text: 'Extract the structured data from this document.' }, { type: 'image_url', image_url: { url: $json.imageUrl } } ] } ] }) }}",
        "options": {},
        "promptType": "define"
      },
      "typeVersion": 3.1
    },
    {
      "id": "432df3b2-6d26-4fbc-9380-9e32c587903d",
      "name": "OpenAI Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "position": [
        -1008,
        272
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-5-mini"
        },
        "options": {},
        "builtInTools": {}
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.3
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "executionOrder": "v1"
  },
  "versionId": "74bf75f1-64c6-4006-b62f-77a116985f37",
  "nodeGroups": [],
  "connections": {
    "AI Agent": {
      "main": [
        [
          {
            "node": "Parse Extraction",
            "type": "main",
            "index": 0
          },
          {
            "node": "Build Error Verdict",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Auto Approve?": {
      "main": [
        [
          {
            "node": "Record Result",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Flag For Review",
            "type": "main",
            "index": 0
          },
          {
            "node": "Record Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Error Trigger": {
      "main": [
        [
          {
            "node": "Alert Engineering",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Record Result": {
      "main": [
        [
          {
            "node": "Respond",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Input": {
      "main": [
        [
          {
            "node": "AI Agent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Extraction": {
      "main": [
        [
          {
            "node": "Validate Against Booking",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI Chat Model": {
      "ai_languageModel": [
        [
          {
            "node": "AI Agent",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Build Error Verdict": {
      "main": [
        [
          {
            "node": "Record Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Document Upload Webhook": {
      "main": [
        [
          {
            "node": "Normalize Input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate Against Booking": {
      "main": [
        [
          {
            "node": "Auto Approve?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}