{
  "id": "dd2mK6aG4p6XKh09",
  "name": "Vision AI Receipt & Document Verification",
  "tags": [],
  "nodes": [
    {
      "id": "a0b4d2c1-4219-4a11-aa94-caccadeba04a",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1648,
        -16
      ],
      "parameters": {
        "color": 7,
        "width": 560,
        "height": 1268,
        "content": "## \ud83e\uddfe Vision AI Receipt & Document Verification\n\nVerifies uploaded receipts and invoices by extracting structured data with GPT-4o vision, checking it against the expected booking amount/currency, and routing mismatches to a human reviewer.\n\n**Perfect for:** Hotel finance and ops teams reconciling guest receipts against bookings.\n\n---\n\n## How it works\n\n1. **Document Upload Webhook** \u2014 Receives a POST with document_id, guest_id, booking_id, image_url, doc_type, expected_amount, and currency.\n2. **Normalize Input** \u2014 Defaults and normalizes the incoming fields into a consistent record.\n3. **GPT Vision Extract** \u2014 Sends the image to GPT-4o (vision) and extracts vendor, date, total, currency, line items, and a confidence score. Retries twice on failure.\n4. **Parse Extraction** \u2014 Parses the model's JSON reply and merges it with the original booking context.\n5. **Validate Against Booking** \u2014 Flags low confidence, missing total, currency mismatch, or amount outside a 2% tolerance; assigns verdict APPROVED, REVIEW, or REJECTED.\n6. **Auto Approve?** \u2014 Routes APPROVED straight to logging; anything else goes through review flagging first.\n7. **Flag For Review** *(when not auto-approved)* \u2014 Posts the mismatch reasons to Slack #finance-review.\n8. **Build Error Verdict** *(error path)* \u2014 Runs when GPT Vision Extract fails after retries; produces an ERROR verdict so the record still gets logged.\n9. **Record Result** \u2014 Appends the final verdict and extracted data to a Google Sheet.\n10. **Respond** \u2014 Returns {documentId, verdict, reasons} as JSON to the caller.\n11. **Error Trigger** *(workflow-level)* \u2014 Catches any unhandled error in the workflow.\n12. **Alert Engineering** \u2014 Posts the failing node and error message to Slack #automation-alerts.\n\n---\n\n## Setup (~10 minutes)\n\n1. **OpenAI API** \u2014 Add your key in the *GPT Vision Extract* node.\n2. **Slack** \u2014 Connect your workspace in the *Flag For Review* and *Alert Engineering* nodes; confirm the #finance-review and #automation-alerts channels exist.\n3. **Google Sheets** \u2014 Connect your account in the *Record Result* node and set the spreadsheet ID and 'Verifications' sheet name.\n4. **Webhook** \u2014 Point your document-upload flow at the *Document Upload Webhook* path (`receipt-verify`).\n> GPT-4o vision calls cost per-request OpenAI usage (~$0.01-0.03/receipt). Image URLs must be publicly reachable by OpenAI."
      },
      "typeVersion": 1
    },
    {
      "id": "5386614e-92b5-4549-9e9c-42368cfdd36f",
      "name": "Section 1 - Trigger & Intake",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1024,
        112
      ],
      "parameters": {
        "color": 5,
        "width": 696,
        "height": 456,
        "content": "## 1\ufe0f\u20e3 Trigger & Intake\n\nThe **Document Upload Webhook** receives the incoming receipt or invoice submission as a POST request. **Normalize Input** then defaults and reshapes the payload into a consistent record \u2014 document ID, guest and booking IDs, image URL, document type, and the expected amount/currency to validate against."
      },
      "typeVersion": 1
    },
    {
      "id": "2dc958f5-2da3-439c-88ae-c1846b4cfb7e",
      "name": "Section 2 - AI Vision Extraction & Error Handling",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -272,
        48
      ],
      "parameters": {
        "color": 3,
        "width": 600,
        "height": 840,
        "content": "## 2\ufe0f\u20e3 AI Vision Extraction & Error Handling\n\nThe **GPT Vision Extract** node sends the receipt image to GPT-4o and asks for structured JSON \u2014 vendor, date, total, currency, line items, and confidence \u2014 retrying twice before failing. **Parse Extraction** turns that reply into a clean record merged with the original booking context. If extraction fails outright, **Build Error Verdict** produces a fallback ERROR record instead of losing the submission, while the workflow-level **Error Trigger** and **Alert Engineering** catch any other unhandled failure and post it to Slack."
      },
      "typeVersion": 1
    },
    {
      "id": "072fbe59-0e6a-48b8-b6fc-3fe64c1d9f68",
      "name": "Section 3 - Validation & Decision",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        368,
        -48
      ],
      "parameters": {
        "color": 6,
        "width": 840,
        "height": 568,
        "content": "## 3\ufe0f\u20e3 Validation & Decision\n\n**Validate Against Booking** checks the extracted total and currency against the expected booking values, flagging low confidence, missing totals, currency mismatches, or amounts outside a 2% tolerance, and assigns a verdict. **Auto Approve?** branches on that verdict \u2014 anything not cleanly APPROVED is routed to **Flag For Review**, which posts the mismatch reasons to the finance team on Slack."
      },
      "typeVersion": 1
    },
    {
      "id": "9af66b88-5978-4a5f-858a-c6687ef5bde1",
      "name": "Section 4 - Output & Logging",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        368,
        560
      ],
      "parameters": {
        "color": 4,
        "width": 600,
        "height": 360,
        "content": "## 4\ufe0f\u20e3 Output & Logging\n\n**Record Result** appends the final verdict, extracted fields, and reasons to a Google Sheet for audit history, then **Respond** sends the verdict back to whatever system uploaded the document."
      },
      "typeVersion": 1
    },
    {
      "id": "f86e5cf2-4b49-49e6-8b27-8567956fb2e5",
      "name": "Document Upload Webhook",
      "type": "n8n-nodes-base.webhook",
      "position": [
        -976,
        336
      ],
      "parameters": {
        "path": "receipt-verify",
        "options": {},
        "httpMethod": "POST",
        "responseMode": "responseNode"
      },
      "typeVersion": 2
    },
    {
      "id": "f08bcd6d-1f34-4fab-bc72-86baf87da1c2",
      "name": "Normalize Input",
      "type": "n8n-nodes-base.code",
      "position": [
        -736,
        336
      ],
      "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": "5473bb42-83df-4e58-916d-d6d90debb6fd",
      "name": "GPT Vision Extract",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueErrorOutput",
      "maxTries": 2,
      "position": [
        -496,
        336
      ],
      "parameters": {
        "url": "https://api.openai.com/v1/chat/completions",
        "method": "POST",
        "options": {},
        "jsonBody": "={{ ({ model: 'gpt-4o', temperature: 0, max_tokens: 700, response_format: { type: 'json_object' }, messages: [ { role: 'system', content: '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 } } ] } ] }) }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openAiApi"
      },
      "retryOnFail": true,
      "typeVersion": 4.2,
      "waitBetweenTries": 3000
    },
    {
      "id": "7b9a20bb-3a58-4df6-813b-4f360bb52f46",
      "name": "Parse Extraction",
      "type": "n8n-nodes-base.code",
      "position": [
        0,
        336
      ],
      "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": "3a7af534-ab76-4952-838f-b809585f6d28",
      "name": "Validate Against Booking",
      "type": "n8n-nodes-base.code",
      "position": [
        432,
        320
      ],
      "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": "6ed5f500-949f-4971-8bf7-07995510c945",
      "name": "Auto Approve?",
      "type": "n8n-nodes-base.if",
      "position": [
        672,
        320
      ],
      "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": "15b868ca-c2ea-4a72-acff-6fe9bf14a267",
      "name": "Flag For Review",
      "type": "n8n-nodes-base.slack",
      "position": [
        912,
        160
      ],
      "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": "name",
          "value": "finance-review"
        },
        "otherOptions": {}
      },
      "typeVersion": 2.3
    },
    {
      "id": "75c7b678-28df-45f5-bd68-cc6d9531c4e0",
      "name": "Build Error Verdict",
      "type": "n8n-nodes-base.code",
      "position": [
        0,
        512
      ],
      "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": "bb5a2a85-f7cf-4102-9e2f-cd4bb788dc8b",
      "name": "Record Result",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        480,
        768
      ],
      "parameters": {
        "columns": {
          "value": {
            "date": "={{ $json.date }}",
            "total": "={{ $json.total }}",
            "vendor": "={{ $json.vendor }}",
            "reasons": "={{ $json.reasons.join('; ') }}",
            "verdict": "={{ $json.verdict }}",
            "currency": "={{ $json.currency }}",
            "guest_id": "={{ $json.guestId }}",
            "booking_id": "={{ $json.bookingId }}",
            "confidence": "={{ $json.confidence }}",
            "line_items": "={{ JSON.stringify($json.lineItems) }}",
            "document_id": "={{ $json.documentId }}",
            "processed_at": "={{ $json.processedAt }}",
            "expected_amount": "={{ $json.expectedAmount }}"
          },
          "schema": [],
          "mappingMode": "defineBelow",
          "matchingColumns": []
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Verifications"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "REPLACE_SHEET_ID"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "f09741f9-8af2-4179-9db2-ca5c1fec5324",
      "name": "Respond",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        720,
        768
      ],
      "parameters": {
        "options": {},
        "respondWith": "json",
        "responseBody": "={{ ({ documentId: $json.documentId, verdict: $json.verdict, reasons: $json.reasons }) }}"
      },
      "typeVersion": 1.1
    },
    {
      "id": "e3cf9dda-d93a-4577-9d99-655ee127e520",
      "name": "Error Trigger",
      "type": "n8n-nodes-base.errorTrigger",
      "position": [
        -176,
        688
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "fd6aca14-6ebe-4429-a3e7-62b0f84e4dd1",
      "name": "Alert Engineering",
      "type": "n8n-nodes-base.slack",
      "position": [
        80,
        688
      ],
      "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": "name",
          "value": "automation-alerts"
        },
        "otherOptions": {}
      },
      "typeVersion": 2.3
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "executionOrder": "v1"
  },
  "versionId": "3f35832c-fd4b-4381-9cee-8ad969e1cdf3",
  "nodeGroups": [],
  "connections": {
    "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": "GPT Vision Extract",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Extraction": {
      "main": [
        [
          {
            "node": "Validate Against Booking",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "GPT Vision Extract": {
      "main": [
        [
          {
            "node": "Parse Extraction",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Error Verdict",
            "type": "main",
            "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
          }
        ]
      ]
    }
  }
}