{
  "nodes": [
    {
      "id": "d78dd4ad-7316-454a-b8c9-7e7d7882d8e3",
      "name": "\ud83d\udccb Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        0,
        0
      ],
      "parameters": {
        "width": 560,
        "height": 648,
        "content": "## Fast-track expense reimbursements with UploadToURL, Mindee OCR, and Expensify\nThe Problem: Employees hate manual expense forms, and finance teams waste hours chasing blurry attachments and missing data.\nThe Solution: A \"snap-and-submit\" pipeline that hosts receipt photos via UploadToURL, extracts data via Mindee OCR, and handles auto-approvals via Slack.\n\n\u2699\ufe0f How it Works\nSubmission: Employee uploads a receipt photo via mobile form or Webhook.\n\nUploadToURL: Instantly hosts the photo and returns a permanent CDN link for the audit trail.\n\nMindee OCR: Automatically extracts merchant, total, date, and tax with high precision.\n\nSmart Approval: Low-value items are auto-approved; high-value items trigger a 1-click Slack approval for managers.\n\nLogging: Final data is synced to Expensify and Google Sheets.\n\n\ud83d\udd10 Credentials & Setup\nNode: Install n8n-nodes-uploadtourl via Community Nodes.\n\nAPIs: UploadToURL, Mindee, Expensify, and Slack.\n\nVariables: Set AUTO_APPROVE_THRESHOLD and EXPENSIFY_POLICY_ID"
      },
      "typeVersion": 1
    },
    {
      "id": "8bc2b0d8-6cb8-4f6b-98be-34b9d43ab3c1",
      "name": "Section 1 \u2014 Upload",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        672,
        688
      ],
      "parameters": {
        "color": 7,
        "width": 776,
        "height": 578,
        "content": "## 1 \u2014 Form intake & upload\n\n**Form Trigger \u2192 Validate \u2192 Has URL? \u2192 Upload to URL (\u00d72) \u2192 Extract CDN URL**\n\nMobile-friendly form with fields for employee name, email, manager email, and expense category. Validates email format and file extension (`jpg`, `jpeg`, `png`, `pdf`, `heic`). UploadToURL hosts via the native community node. Filename is structured as `EXP-{timestamp}-{employeeSlug}.ext` for audit traceability."
      },
      "typeVersion": 1
    },
    {
      "id": "88215ffc-3680-4cac-a05d-3235fbee621a",
      "name": "Section 2 \u2014 OCR & Gate",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1504,
        720
      ],
      "parameters": {
        "color": 7,
        "width": 440,
        "height": 519,
        "content": "## 2 \u2014 Mindee OCR & confidence gate\n\n**Mindee Receipt API \u2192 Parse & Score \u2192 Confidence Gate (IF)**\n\nMindee's `/expense_receipts/v5/predict` endpoint returns structured fields with per-field confidence scores. The parse node computes a composite confidence score, flags missing required fields, and evaluates the auto-approve rule: `confidence \u2265 0.85 AND total \u2264 AUTO_APPROVE_THRESHOLD`. Below that threshold routes to auto-approve; above routes to manager."
      },
      "typeVersion": 1
    },
    {
      "id": "924cd41e-9f8b-4d9e-b572-a4b3db687b0d",
      "name": "Section 3 \u2014 Approval",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2064,
        672
      ],
      "parameters": {
        "color": 7,
        "width": 840,
        "height": 631,
        "content": "## 3 \u2014 Dual-path approval\n\n**Auto-approve branch:** Expensify entry created immediately, status set to `approved`, employee emailed with ETA.\n\n**Manager branch:** Slack posts an interactive message with receipt CDN link, extracted data, and Accept/Reject buttons to `MANAGER_SLACK_USER_ID`. On response the Expensify entry is created with the manager's decision. Both paths converge at the Sheets audit log node."
      },
      "typeVersion": 1
    },
    {
      "id": "949db194-c812-4032-868f-ab68d03d00e9",
      "name": "Section 4 \u2014 Audit & Email",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3056,
        704
      ],
      "parameters": {
        "color": 7,
        "width": 548,
        "height": 483,
        "content": "## 4 \u2014 Audit log & confirmation\n\n**Sheets - Append Audit Row \u2192 Gmail - Employee Confirmation**\n\nEvery expense \u2014 regardless of approval path \u2014 is written to the Google Sheet with full OCR data, confidence score, CDN URL, Expensify ID, and resolution timestamp. The employee receives a plain-text email confirming approval or review status and their reimbursement timeline."
      },
      "typeVersion": 1
    },
    {
      "id": "4ac61781-ff01-43b6-873b-b3ca4f72ad75",
      "name": "Form Trigger - Submit Receipt",
      "type": "n8n-nodes-base.formTrigger",
      "position": [
        688,
        1008
      ],
      "parameters": {
        "options": {},
        "formTitle": "Expense Reimbursement \u2014 Submit Receipt",
        "formFields": {
          "values": [
            {
              "fieldLabel": "Your Full Name",
              "requiredField": true
            },
            {
              "fieldType": "email",
              "fieldLabel": "Your Email",
              "requiredField": true
            },
            {
              "fieldType": "email",
              "fieldLabel": "Manager Email",
              "requiredField": true
            },
            {
              "fieldType": "dropdown",
              "fieldLabel": "Expense Category",
              "fieldOptions": {
                "values": [
                  {
                    "option": "Meals & Entertainment"
                  },
                  {
                    "option": "Travel & Transport"
                  },
                  {
                    "option": "Accommodation"
                  },
                  {
                    "option": "Office Supplies"
                  },
                  {
                    "option": "Software & Subscriptions"
                  },
                  {
                    "option": "Client Gifts"
                  },
                  {
                    "option": "Other"
                  }
                ]
              },
              "requiredField": true
            },
            {
              "fieldLabel": "Receipt Photo or PDF URL",
              "placeholder": "https://... or leave blank and upload below",
              "requiredField": true
            },
            {
              "fieldType": "textarea",
              "fieldLabel": "Business Purpose",
              "placeholder": "e.g. Team lunch with client Acme Corp",
              "requiredField": true
            },
            {
              "fieldLabel": "Project Code",
              "placeholder": "e.g. PROJ-204"
            }
          ]
        },
        "responseMode": "responseNode",
        "formDescription": "Upload your receipt photo or PDF. Expenses under $50 with a clear receipt are approved automatically."
      },
      "typeVersion": 2.2
    },
    {
      "id": "dba8fc24-bf9f-410c-bc78-396fdd5d935e",
      "name": "Validate & Build Expense Record",
      "type": "n8n-nodes-base.code",
      "position": [
        832,
        1040
      ],
      "parameters": {
        "jsCode": "const body = $input.first().json;\n\n// \u2500\u2500 Pull form fields \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst employeeName = String(body['Your Full Name'] || body.employeeName || '').trim();\nconst employeeEmail = String(body['Your Email'] || body.employeeEmail || '').trim().toLowerCase();\nconst managerEmail = String(body['Manager Email'] || body.managerEmail || '').trim().toLowerCase();\nconst category = String(body['Expense Category'] || body.category || 'Other').trim();\nconst fileUrl = String(body['Receipt Photo or PDF URL'] || body.fileUrl || '').trim();\nconst businessPurpose = String(body['Business Purpose'] || body.businessPurpose || '').trim();\nconst projectCode = String(body['Project Code'] || body.projectCode || '').trim();\n\n// \u2500\u2500 Required field guards \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nif (!employeeName) throw new Error('Employee name is required.');\nif (!employeeEmail) throw new Error('Employee email is required.');\nif (!managerEmail) throw new Error('Manager email is required.');\nif (!businessPurpose) throw new Error('Business purpose is required.');\nif (!fileUrl && !body.filename) throw new Error('Provide a receipt file URL or binary upload.');\n\n// \u2500\u2500 Email format validation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst emailRx = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\nif (!emailRx.test(employeeEmail)) throw new Error(`Invalid employee email: ${employeeEmail}`);\nif (!emailRx.test(managerEmail)) throw new Error(`Invalid manager email: ${managerEmail}`);\nif (employeeEmail === managerEmail) throw new Error('Employee and manager email must be different.');\n\n// \u2500\u2500 Filename & extension \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst filename = body.filename ||\n  fileUrl.split('?')[0].split('/').pop() ||\n  'receipt.jpg';\nconst ext = filename.split('.').pop()?.toLowerCase() || 'jpg';\nconst allowedExts = ['jpg', 'jpeg', 'png', 'pdf', 'heic', 'webp'];\nif (!allowedExts.includes(ext)) {\n  throw new Error(`File type .${ext} not supported. Accepted: ${allowedExts.join(', ')}`);\n}\n\n// \u2500\u2500 Generate expense ID \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst expenseId = `EXP-${Date.now()}`;\nconst employeeSlug = employeeName.toLowerCase().replace(/[^a-z0-9]/g, '-').slice(0, 20);\nconst structuredFilename = `${expenseId}-${employeeSlug}.${ext}`;\n\n// \u2500\u2500 Auto-approve threshold from env var \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst autoApproveThreshold = parseFloat($vars.AUTO_APPROVE_THRESHOLD || '50');\n\nreturn [{\n  json: {\n    expenseId,\n    employeeName,\n    employeeEmail,\n    managerEmail,\n    category,\n    businessPurpose,\n    projectCode: projectCode || null,\n    fileUrl: fileUrl || null,\n    filename,\n    structuredFilename,\n    ext,\n    autoApproveThreshold,\n    submittedAt: new Date().toISOString()\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "e79c9f4b-d787-4170-8bde-fa08154ca8ff",
      "name": "Has Remote URL?",
      "type": "n8n-nodes-base.if",
      "position": [
        976,
        1024
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "caseSensitive": false,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-url",
              "operator": {
                "type": "string",
                "operation": "notEmpty"
              },
              "leftValue": "={{ $json.fileUrl }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "fa8f26ea-dfb7-4e88-af4e-d240a08b89a1",
      "name": "Upload to URL - Remote",
      "type": "n8n-nodes-uploadtourl.uploadToUrl",
      "position": [
        1168,
        944
      ],
      "parameters": {
        "operation": "uploadFile"
      },
      "credentials": {
        "uploadToUrlApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "afa9624e-6b5b-42db-9900-4f6ff8efa915",
      "name": "Upload to URL - Binary",
      "type": "n8n-nodes-uploadtourl.uploadToUrl",
      "position": [
        1168,
        1104
      ],
      "parameters": {
        "operation": "uploadFile"
      },
      "credentials": {
        "uploadToUrlApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "0831a344-8c30-4c86-b932-90e19dec8286",
      "name": "Extract CDN URL",
      "type": "n8n-nodes-base.code",
      "position": [
        1344,
        1008
      ],
      "parameters": {
        "jsCode": "const uploadResp = $input.first().json;\nconst meta = $('Validate & Build Expense Record').first().json;\n\nconst cdnUrl =\n  uploadResp.url ||\n  uploadResp.link ||\n  uploadResp.data?.url ||\n  uploadResp.file?.url ||\n  uploadResp.shortUrl;\n\nif (!cdnUrl) {\n  throw new Error('UploadToURL returned no public URL. Raw: ' + JSON.stringify(uploadResp).slice(0, 300));\n}\n\nreturn [{\n  json: {\n    ...meta,\n    cdnUrl: cdnUrl.replace(/^http:\\/\\//, 'https://'),\n    uploadId: uploadResp.id || uploadResp.data?.id || null,\n    fileSizeBytes: uploadResp.size || uploadResp.data?.size || null\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "8ec4dc59-b870-44bb-b6e0-928da07576ba",
      "name": "Mindee - Extract Receipt Data",
      "type": "n8n-nodes-base.httpRequest",
      "notes": "Calls Mindee's expense_receipts/v5/predict endpoint with the CDN URL. Returns structured fields with per-field confidence scores: total amount, tax, merchant name, date, currency, and category.",
      "position": [
        1584,
        1008
      ],
      "parameters": {
        "url": "https://api.mindee.net/v1/products/mindee/expense_receipts/v5/predict",
        "method": "POST",
        "options": {
          "timeout": 30000,
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "contentType": "multipart-form-data",
        "sendHeaders": true,
        "authentication": "genericCredentialType",
        "bodyParameters": {
          "parameters": [
            {
              "name": "document",
              "parameterType": "formBinaryData"
            }
          ]
        },
        "genericAuthType": "httpHeaderAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "Token {{ $credentials.value }}"
            }
          ]
        }
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "3bc45e41-5a93-46e8-ac20-8f74c950f8cb",
      "name": "Parse OCR & Score Confidence",
      "type": "n8n-nodes-base.code",
      "notes": "Extracts Mindee fields with per-field confidence. Composite score weights: total 40%, merchant 30%, date 20%, tax 10%. Auto-approve fires only when score \u2265 0.85 AND total \u2264 threshold AND no missing fields.",
      "position": [
        1760,
        1008
      ],
      "parameters": {
        "jsCode": "const mindeeResp = $input.first().json;\nconst meta = $('Extract CDN URL').first().json;\n\n// \u2500\u2500 Navigate Mindee v5 response structure \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst prediction = mindeeResp.document?.inference?.prediction || {};\n\n// \u2500\u2500 Helper: extract value + confidence from Mindee field \u2500\u2500\u2500\u2500\u2500\u2500\nconst getField = (field) => ({\n  value: field?.value ?? null,\n  confidence: field?.confidence ?? 0\n});\n\n// \u2500\u2500 Extract core fields \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst totalField     = getField(prediction.total_amount);\nconst taxField       = getField(prediction.total_tax);\nconst merchantField  = getField(prediction.supplier_name);\nconst dateField      = getField(prediction.date);\nconst currencyField  = getField(prediction.locale?.currency || { value: 'USD', confidence: 0.5 });\nconst categoryField  = getField(prediction.category);\nconst receiptNumField= getField(prediction.receipt_number);\n\n// \u2500\u2500 Normalise values \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst total    = parseFloat(totalField.value) || null;\nconst tax      = parseFloat(taxField.value) || null;\nconst merchant = String(merchantField.value || 'Unknown Merchant').trim();\nconst date     = dateField.value\n  ? new Date(dateField.value).toISOString().split('T')[0]\n  : new Date().toISOString().split('T')[0];\nconst currency = String(currencyField.value || 'USD').toUpperCase().slice(0, 3);\nconst detectedCategory = categoryField.value || meta.category;\nconst receiptNumber = receiptNumField.value || null;\n\n// \u2500\u2500 Composite confidence score \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Weight by importance: total (40%), merchant (30%), date (20%), tax (10%)\nconst compositeConfidence = (\n  totalField.confidence * 0.40 +\n  merchantField.confidence * 0.30 +\n  dateField.confidence * 0.20 +\n  taxField.confidence * 0.10\n);\nconst confidenceScore = Math.round(compositeConfidence * 100) / 100;\n\n// \u2500\u2500 Flag missing required fields \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst missingFields = [];\nif (total === null) missingFields.push('total');\nif (merchantField.confidence < 0.5) missingFields.push('merchant (low confidence)');\nif (!dateField.value) missingFields.push('date');\n\n// \u2500\u2500 Auto-approve logic \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Auto-approve only if: high confidence AND total within threshold AND no missing fields\nconst meetsAutoApprove =\n  confidenceScore >= 0.85 &&\n  total !== null &&\n  total <= meta.autoApproveThreshold &&\n  missingFields.length === 0;\n\nconst approvalRoute = meetsAutoApprove ? 'auto' : 'manager';\n\n// \u2500\u2500 Format for display \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst totalFormatted = total !== null\n  ? `${currency} ${total.toFixed(2)}`\n  : 'Not detected';\n\nreturn [{\n  json: {\n    ...meta,\n    // Extracted\n    merchant,\n    total,\n    tax,\n    currency,\n    date,\n    detectedCategory,\n    receiptNumber,\n    // Confidence\n    confidenceScore,\n    missingFields,\n    perFieldConfidence: {\n      total: totalField.confidence,\n      merchant: merchantField.confidence,\n      date: dateField.confidence,\n      tax: taxField.confidence\n    },\n    // Routing\n    approvalRoute,\n    meetsAutoApprove,\n    // Display\n    totalFormatted,\n    ocrStatus: missingFields.length === 0 ? 'complete' : 'partial'\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "9ab3672d-fda3-4ee0-9b98-3a11068ee8f7",
      "name": "Confidence Gate \u2014 Auto or Manager?",
      "type": "n8n-nodes-base.if",
      "position": [
        2080,
        1008
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "caseSensitive": false,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-auto",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.approvalRoute }}",
              "rightValue": "auto"
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "5cbb6bda-d4b3-4577-bab1-54e088f705a8",
      "name": "Mark Auto-Approved",
      "type": "n8n-nodes-base.code",
      "notes": "Marks the expense as auto-approved. Reimbursement ETA is 3 days for amounts under $25, 5 days otherwise. No manager interaction required.",
      "position": [
        2256,
        912
      ],
      "parameters": {
        "jsCode": "// Auto-approve path \u2014 mark as approved immediately\nconst data = $input.first().json;\nconst reimbursementDays = data.total <= 25 ? 3 : 5;\n\nreturn [{\n  json: {\n    ...data,\n    approvalStatus: 'approved',\n    approvedBy: 'Auto-approval system',\n    approvedAt: new Date().toISOString(),\n    rejectionReason: null,\n    reimbursementEta: `${reimbursementDays} business days`,\n    statusEmoji: '\u2705'\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "3ceb1d1d-13da-4b67-bb29-ea9495d0579f",
      "name": "Slack - Manager Approval Request",
      "type": "n8n-nodes-base.slack",
      "notes": "Sends a direct Slack message to the manager's user ID with approve/reject buttons. The button values carry expenseId and employeeEmail for the webhook handler to process.",
      "position": [
        2240,
        1088
      ],
      "parameters": {
        "text": "=\ud83d\udcb3 *Expense Approval Request*\n\n\ud83d\udc64 *{{ $json.employeeName }}* (`{{ $json.employeeEmail }}`)\n\ud83c\udfea Merchant: *{{ $json.merchant }}*\n\ud83d\udcb0 Total: *{{ $json.totalFormatted }}*{{ $json.tax ? '  |  Tax: ' + $json.currency + ' ' + $json.tax.toFixed(2) : '' }}\n\ud83d\udcc5 Date: {{ $json.date }}\n\ud83d\uddc2 Category: {{ $json.detectedCategory }}\n\ud83d\udccb Purpose: _{{ $json.businessPurpose }}_\n{{ $json.projectCode ? '\ud83d\udd16 Project: ' + $json.projectCode : '' }}\n\n\ud83d\udd0d OCR Confidence: {{ Math.round($json.confidenceScore * 100) }}%{{ $json.missingFields.length > 0 ? '  \u26a0\ufe0f Missing: ' + $json.missingFields.join(', ') : '' }}\n\n\ud83e\uddfe <{{ $json.cdnUrl }}|View Receipt>\n\nPlease approve or reject:",
        "otherOptions": {},
        "authentication": "oAuth2"
      },
      "typeVersion": 2.2
    },
    {
      "id": "28c39db4-8c0d-41b7-8490-643df3f02cc8",
      "name": "Mark Pending Manager Review",
      "type": "n8n-nodes-base.code",
      "notes": "Sets status to pending_manager. A separate n8n workflow (triggered by Slack's interactivity webhook) handles the button click response and completes the approval loop.",
      "position": [
        2400,
        1088
      ],
      "parameters": {
        "jsCode": "// Manager path \u2014 pending until Slack response received\n// In production: a separate Slack webhook workflow handles the button click\n// and updates the expense status via the Expensify/Sheets nodes\nconst data = $input.first().json;\nconst slackResp = $input.first().json;\n\nreturn [{\n  json: {\n    ...data,\n    approvalStatus: 'pending_manager',\n    approvedBy: null,\n    approvedAt: null,\n    rejectionReason: null,\n    reimbursementEta: 'Awaiting manager approval',\n    statusEmoji: '\u23f3',\n    slackMessageTs: slackResp.ts || null\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "23228806-6525-4c57-b30b-d3a3beb3db4d",
      "name": "Expensify - Create Expense Entry",
      "type": "n8n-nodes-base.httpRequest",
      "notes": "Creates an Expensify expense entry on both approval paths. Amount is sent in cents (\u00d7100). The CDN URL is attached as receipt proof \u2014 no re-upload needed. Fires regardless of auto-approve or manager path.",
      "position": [
        2592,
        944
      ],
      "parameters": {
        "url": "https://integrations.expensify.com/Integration-Server/ExpensifyIntegrations",
        "method": "POST",
        "options": {
          "timeout": 20000,
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "contentType": "form-urlencoded",
        "authentication": "genericCredentialType",
        "bodyParameters": {
          "parameters": [
            {
              "name": "requestJobDescription",
              "value": "={\n  \"type\": \"create\",\n  \"credentials\": {\n    \"partnerUserID\": \"{{ $credentials.username }}\",\n    \"partnerUserSecret\": \"{{ $credentials.password }}\"\n  },\n  \"inputSettings\": {\n    \"type\": \"expenses\",\n    \"policyID\": \"{{ $vars.EXPENSIFY_POLICY_ID }}\",\n    \"employeeEmail\": \"{{ $json.employeeEmail }}\",\n    \"expenses\": [{\n      \"date\": \"{{ $json.date }}\",\n      \"currency\": \"{{ $json.currency }}\",\n      \"amount\": {{ Math.round(($json.total || 0) * 100) }},\n      \"merchant\": \"{{ $json.merchant }}\",\n      \"tag\": \"{{ $json.detectedCategory }}\",\n      \"billable\": false,\n      \"reimbursable\": true,\n      \"comment\": \"{{ $json.businessPurpose }}{{ $json.projectCode ? ' | Project: ' + $json.projectCode : '' }} | ExpenseID: {{ $json.expenseId }}\",\n      \"receipt\": {\n        \"url\": \"{{ $json.cdnUrl }}\",\n        \"filename\": \"{{ $json.structuredFilename }}\"\n      }\n    }]\n  }\n}"
            }
          ]
        },
        "genericAuthType": "httpBasicAuth"
      },
      "typeVersion": 4.2
    },
    {
      "id": "0168f1e5-f9f1-4b15-ba87-7a6895317a10",
      "name": "Merge Approval + Expensify Result",
      "type": "n8n-nodes-base.code",
      "position": [
        2784,
        944
      ],
      "parameters": {
        "jsCode": "const expensifyResp = $input.first().json;\n// Get data from whichever approval branch ran\nconst expData =\n  $('Mark Auto-Approved').first()?.json ||\n  $('Mark Pending Manager Review').first()?.json;\n\nconst expensifyId =\n  expensifyResp.reportID ||\n  expensifyResp.transactionID ||\n  expensifyResp.responseMessage ||\n  null;\n\nreturn [{\n  json: {\n    ...expData,\n    expensifyId: String(expensifyId || ''),\n    resolvedAt: new Date().toISOString()\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "52b837a4-2156-4a96-b1e1-cac1a26151e6",
      "name": "Sheets - Append Audit Row",
      "type": "n8n-nodes-base.googleSheets",
      "notes": "Appends every expense to the audit log regardless of approval path. Includes full OCR data, confidence score, CDN URL, Expensify ID, and timestamps \u2014 complete audit trail for finance.",
      "position": [
        3136,
        976
      ],
      "parameters": {
        "columns": {
          "value": {
            "Tax": "={{ $json.tax }}",
            "Date": "={{ $json.date }}",
            "Total": "={{ $json.total }}",
            "Status": "={{ $json.approvalStatus }}",
            "CDN URL": "={{ $json.cdnUrl }}",
            "Category": "={{ $json.detectedCategory }}",
            "Currency": "={{ $json.currency }}",
            "Employee": "={{ $json.employeeName }}",
            "Merchant": "={{ $json.merchant }}",
            "Expense ID": "={{ $json.expenseId }}",
            "OCR Status": "={{ $json.ocrStatus }}",
            "Approved By": "={{ $json.approvedBy }}",
            "Resolved At": "={{ $json.resolvedAt }}",
            "Expensify ID": "={{ $json.expensifyId }}",
            "Project Code": "={{ $json.projectCode }}",
            "Submitted At": "={{ $json.submittedAt }}",
            "Employee Email": "={{ $json.employeeEmail }}",
            "Missing Fields": "={{ $json.missingFields.join(', ') }}",
            "OCR Confidence": "={{ $json.confidenceScore }}",
            "Business Purpose": "={{ $json.businessPurpose }}"
          },
          "mappingMode": "defineBelow"
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Expense Audit Log"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $vars.GSHEET_SPREADSHEET_ID }}"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "28349e90-3589-453b-81db-b797a1135ec9",
      "name": "Gmail - Employee Confirmation",
      "type": "n8n-nodes-base.gmail",
      "notes": "Sends the employee an HTML confirmation email. Auto-approved expenses show green status and reimbursement ETA. Manager-pending expenses show amber status with next-step instructions.",
      "position": [
        3296,
        992
      ],
      "parameters": {
        "sendTo": "={{ $json.employeeEmail }}",
        "message": "=<!DOCTYPE html>\n<html>\n<body style=\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; padding: 32px; color: #1a1a1a;\">\n  <h2 style=\"color: {{ $json.approvalStatus === 'approved' ? '#16a34a' : '#d97706' }};\">{{ $json.statusEmoji }} Expense {{ $json.approvalStatus === 'approved' ? 'Approved' : 'Received \u2014 Pending Review' }}</h2>\n  <p>Hi {{ $json.employeeName }},</p>\n  <p>{{ $json.approvalStatus === 'approved' ? 'Your expense has been automatically approved and submitted to Expensify.' : 'Your expense has been received and sent to your manager for approval.' }}</p>\n  <table style=\"width:100%;border-collapse:collapse;font-size:14px;margin:16px 0;\">\n    <tr style=\"border-bottom:1px solid #e5e7eb\"><td style=\"padding:8px;color:#6b7280\">Expense ID</td><td style=\"padding:8px;font-weight:600\">{{ $json.expenseId }}</td></tr>\n    <tr style=\"border-bottom:1px solid #e5e7eb\"><td style=\"padding:8px;color:#6b7280\">Merchant</td><td style=\"padding:8px\">{{ $json.merchant }}</td></tr>\n    <tr style=\"border-bottom:1px solid #e5e7eb\"><td style=\"padding:8px;color:#6b7280\">Total</td><td style=\"padding:8px;font-weight:600\">{{ $json.totalFormatted }}</td></tr>\n    <tr style=\"border-bottom:1px solid #e5e7eb\"><td style=\"padding:8px;color:#6b7280\">Date</td><td style=\"padding:8px\">{{ $json.date }}</td></tr>\n    <tr style=\"border-bottom:1px solid #e5e7eb\"><td style=\"padding:8px;color:#6b7280\">Category</td><td style=\"padding:8px\">{{ $json.detectedCategory }}</td></tr>\n    <tr style=\"border-bottom:1px solid #e5e7eb\"><td style=\"padding:8px;color:#6b7280\">Status</td><td style=\"padding:8px\">{{ $json.approvalStatus }}</td></tr>\n    <tr><td style=\"padding:8px;color:#6b7280\">Reimbursement ETA</td><td style=\"padding:8px;font-weight:600;color:#16a34a\">{{ $json.reimbursementEta }}</td></tr>\n  </table>\n  <p><a href=\"{{ $json.cdnUrl }}\" style=\"color:#4F46E5\">View your receipt</a></p>\n  {{ $json.missingFields.length > 0 ? '<p style=\"color:#d97706;font-size:13px;\">\u26a0\ufe0f Note: Some receipt fields could not be read clearly (' + $json.missingFields.join(', ') + '). Your manager may follow up.</p>' : '' }}\n  <p style=\"font-size:13px;color:#9ca3af;margin-top:24px;\">Ref: {{ $json.expenseId }} \u00b7 Expensify: {{ $json.expensifyId }}</p>\n</body>\n</html>",
        "options": {
          "senderName": "={{ 'Expense System \u2014 ' + $json.employeeName.split(' ')[0] }}"
        },
        "subject": "={{ $json.approvalStatus === 'approved' ? '\u2705 Expense approved \u2014 ' + $json.totalFormatted + ' at ' + $json.merchant : '\u23f3 Expense received \u2014 ' + $json.totalFormatted + ' at ' + $json.merchant }}"
      },
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "8f7e0add-6165-483b-bcb1-978c63c76711",
      "name": "Respond to Form",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        3472,
        976
      ],
      "parameters": {
        "options": {
          "responseCode": 201
        },
        "respondWith": "text",
        "responseBody": "={{ $json.approvalStatus === 'approved' \n  ? '\u2705 Expense approved! ' + $json.totalFormatted + ' at ' + $json.merchant + ' \u2014 reimbursement in ' + $json.reimbursementEta + '. Ref: ' + $json.expenseId\n  : '\u23f3 Expense received! ' + $json.totalFormatted + ' at ' + $json.merchant + ' \u2014 sent to your manager for approval. Ref: ' + $json.expenseId }}"
      },
      "typeVersion": 1.1
    }
  ],
  "connections": {
    "Extract CDN URL": {
      "main": [
        [
          {
            "node": "Mindee - Extract Receipt Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has Remote URL?": {
      "main": [
        [
          {
            "node": "Upload to URL - Remote",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Upload to URL - Binary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark Auto-Approved": {
      "main": [
        [
          {
            "node": "Expensify - Create Expense Entry",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload to URL - Binary": {
      "main": [
        [
          {
            "node": "Extract CDN URL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload to URL - Remote": {
      "main": [
        [
          {
            "node": "Extract CDN URL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Sheets - Append Audit Row": {
      "main": [
        [
          {
            "node": "Gmail - Employee Confirmation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark Pending Manager Review": {
      "main": [
        [
          {
            "node": "Expensify - Create Expense Entry",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse OCR & Score Confidence": {
      "main": [
        [
          {
            "node": "Confidence Gate \u2014 Auto or Manager?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Form Trigger - Submit Receipt": {
      "main": [
        [
          {
            "node": "Validate & Build Expense Record",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Gmail - Employee Confirmation": {
      "main": [
        [
          {
            "node": "Respond to Form",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mindee - Extract Receipt Data": {
      "main": [
        [
          {
            "node": "Parse OCR & Score Confidence",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate & Build Expense Record": {
      "main": [
        [
          {
            "node": "Has Remote URL?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Expensify - Create Expense Entry": {
      "main": [
        [
          {
            "node": "Merge Approval + Expensify Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Slack - Manager Approval Request": {
      "main": [
        [
          {
            "node": "Mark Pending Manager Review",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Approval + Expensify Result": {
      "main": [
        [
          {
            "node": "Sheets - Append Audit Row",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Confidence Gate \u2014 Auto or Manager?": {
      "main": [
        [
          {
            "node": "Mark Auto-Approved",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Slack - Manager Approval Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}