{
  "name": "Track employee expenses from receipt photos on Telegram with GPT-4o-mini vision, policy checks, manager approval and monthly reports",
  "nodes": [
    {
      "id": "ba0c1e2c-f26b-46fc-a4f3-84b3b82e9c25",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1000,
        -320
      ],
      "parameters": {
        "width": 480,
        "height": 880,
        "content": "## Track employee expenses from receipt photos on Telegram with GPT-4o-mini vision, policy checks, manager approval and monthly reports\n\n### How it works\n\nAn employee sends a receipt photo to your Telegram bot. GPT-4o-mini reads merchant, date, total, currency, tax and category from the picture; a Code node cleans the values. Two Merge nodes apply your rules from a Google Sheet: the Policy tab (limit per receipt, approval threshold, allowed categories) and the Expenses tab (same claim already recorded). A Switch auto-approves claims within policy, blocks duplicates, bounces unreadable photos, rejects non-reimbursable categories and sends the rest to the manager on Telegram with Approve / Reject buttons. Every claim lands in the Expenses tab, the employee gets the decision in the chat, approved receipts are forwarded to the finance chat, and on the 1st a monthly report goes out by Gmail.\n\n### Setup steps\n\n- Create a Google Sheet with the tabs Policy and Expenses (columns in the section notes) and paste its ID into the Sheets nodes.\n- Connect Telegram (bot from @BotFather), OpenAI, Google Sheets and Gmail; set the manager and finance chat IDs and the finance email.\n- Fill the Policy tab with your categories and limits, then send the bot a test receipt.\n\n### Customization\n\nChange the categories in the prompt and Code node, the 60-day age limit, or swap Telegram for Slack."
      },
      "typeVersion": 1
    },
    {
      "id": "dee4e9a4-d795-4468-a1d5-03bea1030ebe",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -390,
        -207
      ],
      "parameters": {
        "color": 7,
        "width": 940,
        "height": 567,
        "content": "## 1. Receive receipts on Telegram\n\nThe trigger downloads the largest photo size of every message to the bot. Messages without a picture get a short reminder. The submission fields (who, when, caption, file id) are kept for the sheet and the replies."
      },
      "typeVersion": 1
    },
    {
      "id": "62a727a3-77f6-4789-a265-931524a794c1",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        910,
        -267
      ],
      "parameters": {
        "color": 7,
        "width": 940,
        "height": 627,
        "content": "## 2. Read the receipt with AI\n\nThe OpenAI node sends the image to GPT-4o-mini with a strict JSON instruction. The Code node parses the answer, normalises dates (12.09.2026, 09/12/2026, 12 Sep 2026), amounts (1.250,00 / 1,250.00) and currency symbols, maps the category to your list and builds the duplicate key. If the call fails, the employee is asked to retry and nothing is recorded."
      },
      "typeVersion": 1
    },
    {
      "id": "a71f1460-741c-4acf-a9dd-ef1c8373711c",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1950,
        -237
      ],
      "parameters": {
        "color": 7,
        "width": 2240,
        "height": 597,
        "content": "## 3. Apply the expense policy\n\nPolicy tab columns: Category, LimitPerReceipt, ApprovalAbove, Allowed, Notes (leave ApprovalAbove empty for no approval, 0 to always ask). Expenses tab columns: SubmittedAt, Employee, TelegramUserId, ChatId, Merchant, Date, Amount, Currency, Tax, Category, Description, Status, Decision, DecisionNote, ReceiptFileId, Month.\nThe first Merge attaches the policy row of the receipt's category, the second finds an identical claim already recorded. The Switch routes: auto-approve, duplicate, missing info, not allowed (category or over the limit), or needs approval (above the threshold, unknown category, older than 60 days or low AI confidence)."
      },
      "typeVersion": 1
    },
    {
      "id": "d599f0eb-f35a-4f14-ae02-b6bbfb554b6a",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4290,
        -177
      ],
      "parameters": {
        "color": 7,
        "width": 2240,
        "height": 1337,
        "content": "## 4. Approve, record and reply\n\nClaims that need a decision go to the manager on Telegram with Approve / Reject buttons; the workflow waits up to 3 days. All outcomes meet in one place, are appended to the Expenses tab, and the employee gets the result as a reply in the chat. Approved receipts are forwarded as photos to the finance chat, so the bookkeeper has the evidence next to the sheet."
      },
      "typeVersion": 1
    },
    {
      "id": "c1963a41-2f6e-418e-a6b8-4f6b3396c276",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -390,
        1223
      ],
      "parameters": {
        "color": 7,
        "width": 1460,
        "height": 337,
        "content": "## 5. Monthly expense report\n\nOn the 1st the approved expenses of the previous month are read from the sheet, totalled per employee and per category (per currency) and emailed as an HTML table via Gmail."
      },
      "typeVersion": 1
    },
    {
      "id": "e4b47b31-f569-46f3-a47a-e4527215c0a6",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1000,
        608
      ],
      "parameters": {
        "color": 4,
        "width": 480,
        "height": 400,
        "content": "### 🚀 Want the full expense process?\n\nThis template is the safe starting point. I build extended versions for real businesses: **mileage and per-diem rules**, multi-currency conversion, **Google Drive archive** of every receipt, reimbursement batches with **bank payment files**, QuickBooks or Xero export, Slack or WhatsApp instead of Telegram.\n\nAsync delivery, no calls - contact links in my creator profile."
      },
      "typeVersion": 1
    },
    {
      "id": "3e6229b4-571f-4ea1-aff9-2fba717caa50",
      "name": "When Receipt Sent to Telegram",
      "type": "n8n-nodes-base.telegramTrigger",
      "position": [
        -260,
        0
      ],
      "parameters": {
        "updates": [
          "message"
        ],
        "additionalFields": {
          "download": true,
          "imageSize": "large"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "ff6d5002-e3a9-4de9-accc-5fb07835a146",
      "name": "Check for Receipt Photo",
      "type": "n8n-nodes-base.if",
      "position": [
        0,
        0
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": false,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c-has-photo",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ Boolean($binary && $binary.data) }}"
            }
          ]
        },
        "looseTypeValidation": false
      },
      "typeVersion": 2.2
    },
    {
      "id": "ddd4ff15-1ae4-4082-a0db-3427309425ab",
      "name": "Prepare Submission Data",
      "type": "n8n-nodes-base.set",
      "position": [
        260,
        0
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "d14e5937-6fc7-4532-a0f5-3ed3642bd748",
              "name": "chat_id",
              "type": "string",
              "value": "={{ $json.message.chat.id }}"
            },
            {
              "id": "8910c4eb-f6d1-430e-aef8-038ee6a3ed71",
              "name": "message_id",
              "type": "number",
              "value": "={{ $json.message.message_id }}"
            },
            {
              "id": "d6acfb14-ff57-4063-abc2-b41648fc7875",
              "name": "user_id",
              "type": "string",
              "value": "={{ $json.message.from.id }}"
            },
            {
              "id": "0d09fff4-76aa-4e9b-abb0-a521983559fb",
              "name": "employee",
              "type": "string",
              "value": "={{ [$json.message.from.first_name, $json.message.from.last_name].filter(Boolean).join(' ') || $json.message.from.username || String($json.message.from.id) }}"
            },
            {
              "id": "41feac58-679c-4239-a346-f0053501dbca",
              "name": "username",
              "type": "string",
              "value": "={{ $json.message.from.username || '' }}"
            },
            {
              "id": "9e2b3b7b-4a92-4693-a11a-08ea1de200c5",
              "name": "caption",
              "type": "string",
              "value": "={{ String($json.message.caption ?? '').trim() }}"
            },
            {
              "id": "1081df04-0da0-4d40-a22c-a1c538c5eb15",
              "name": "submitted_at",
              "type": "string",
              "value": "={{ new Date(($json.message.date || Math.floor(Date.now() / 1000)) * 1000).toISOString() }}"
            },
            {
              "id": "ffa034ef-91b9-48b9-a294-de5c93c961b6",
              "name": "month",
              "type": "string",
              "value": "={{ new Date(($json.message.date || Math.floor(Date.now() / 1000)) * 1000).toISOString().slice(0, 7) }}"
            },
            {
              "id": "ef7f916b-1860-4bc6-a46b-5a8a7776e243",
              "name": "file_id",
              "type": "string",
              "value": "={{ ($json.message.photo && $json.message.photo.length) ? $json.message.photo[$json.message.photo.length - 1].file_id : (($json.message.document && $json.message.document.file_id) || '') }}"
            }
          ]
        },
        "includeOtherFields": false
      },
      "typeVersion": 3.4
    },
    {
      "id": "50a53b57-7d49-4d47-a3f0-e06a38cc7b95",
      "name": "Request Receipt Photo",
      "type": "n8n-nodes-base.telegram",
      "position": [
        260,
        200
      ],
      "parameters": {
        "text": "=Please send the receipt as a photo (not as text). Add a short caption if you like, for example \"team lunch with client\" - the amount, date and merchant are read from the picture.",
        "chatId": "={{ $json.message.chat.id }}",
        "additionalFields": {}
      },
      "typeVersion": 1.2
    },
    {
      "id": "7950ff9e-9ef4-4398-a945-e0e86c675f08",
      "name": "AI Analyze Receipt Photo",
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "onError": "continueErrorOutput",
      "position": [
        1040,
        0
      ],
      "parameters": {
        "text": "You read receipts for an expense claim. Look at the image and return ONLY a JSON object with these keys:\n{\"is_receipt\": true or false, \"merchant\": \"shop or restaurant name\", \"date\": \"YYYY-MM-DD of the purchase\", \"total\": number (grand total actually paid, including tax and tip), \"currency\": \"3-letter ISO code, infer from symbol or country\", \"tax\": number (VAT / sales tax shown, 0 if none), \"category\": one of Meals | Travel | Transport | Accommodation | Office | Software | Other, \"payment_method\": \"card | cash | other\", \"items\": \"short description of what was bought, max 100 characters\", \"confidence\": 0-100}\nRules: copy numbers exactly as printed; if a value is not visible use \"\" or 0; never guess a merchant; if the picture is not a receipt set is_receipt to false. No markdown, no explanations.",
        "modelId": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-4o-mini",
          "cachedResultName": "GPT-4O-MINI"
        },
        "options": {
          "detail": "high",
          "maxTokens": 400
        },
        "resource": "image",
        "simplify": true,
        "inputType": "base64",
        "operation": "analyze",
        "binaryPropertyName": "data"
      },
      "typeVersion": 1.8
    },
    {
      "id": "acac3fb6-c9d7-4b29-a5b6-a436779da220",
      "name": "Extract Expense Details",
      "type": "n8n-nodes-base.code",
      "position": [
        1300,
        0
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Turn the model's answer into clean expense fields and combine them with the Telegram submission.\n// Everything the model returns is normalised here (dates in several formats, amounts with , or ., currency symbols),\n// so the policy step downstream works with plain values and never sees raw model text.\nconst CATEGORIES = [\"Meals\",\"Travel\",\"Transport\",\"Accommodation\",\"Office\",\"Software\",\"Other\"];\nconst sub = $('Prepare Submission Data').item.json;\nconst raw = $input.item.json;\nlet text = raw.content ?? raw.text ?? raw.output ?? '';\nif (Array.isArray(text)) text = text.map(p => (p && (p.text || p.content)) || '').join('\\n');\ntext = String(text ?? '');\n\nlet ai = {};\nconst fenced = text.match(/\\{[\\s\\S]*\\}/);\nif (fenced) { try { ai = JSON.parse(fenced[0]); } catch (e) { ai = {}; } }\n\nconst toNumber = (v) => {\n  if (typeof v === 'number') return Number.isFinite(v) ? v : 0;\n  let s = String(v ?? '').replace(/[^\\d.,-]/g, '');\n  if (!s) return 0;\n  if (/^-?\\d{1,3}(\\.\\d{3})+(,\\d{1,2})?$/.test(s)) s = s.replace(/\\./g, '');   // 1.250,00 -> 1250,00\n  else if (/^-?\\d{1,3}(,\\d{3})+(\\.\\d{1,2})?$/.test(s)) s = s.replace(/,/g, ''); // 1,250.00 -> 1250.00\n  s = s.replace(/,(\\d{1,2})$/, '.$1').replace(/,/g, '');\n  const n = Number(s);\n  return Number.isFinite(n) ? Math.round(n * 100) / 100 : 0;\n};\nconst MONTHS = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, sept: 9, oct: 10, nov: 11, dec: 12 };\nconst pad = (n) => String(n).padStart(2, '0');\nconst toIsoDate = (v) => {\n  const s = String(v ?? '').trim();\n  let m;\n  if ((m = s.match(/^(\\d{4})-(\\d{1,2})-(\\d{1,2})/))) return m[1] + '-' + pad(m[2]) + '-' + pad(m[3]);\n  if ((m = s.match(/^(\\d{1,2})[./-](\\d{1,2})[./-](\\d{4})$/))) {                 // dd.mm.yyyy (or mm/dd/yyyy when the first number cannot be a month)\n    let d = +m[1], mo = +m[2]; if (mo > 12 && d <= 12) [d, mo] = [mo, d];\n    return m[3] + '-' + pad(mo) + '-' + pad(d);\n  }\n  if ((m = s.match(/^(\\d{1,2})[./-](\\d{1,2})[./-](\\d{2})$/))) { let d = +m[1], mo = +m[2]; if (mo > 12 && d <= 12) [d, mo] = [mo, d]; return '20' + m[3] + '-' + pad(mo) + '-' + pad(d); }\n  if ((m = s.match(/^(\\d{1,2})\\s+([A-Za-z]{3,})\\.?\\s+(\\d{4})/)) && MONTHS[m[2].slice(0, 4).toLowerCase()] !== undefined) return m[3] + '-' + pad(MONTHS[m[2].slice(0, 4).toLowerCase()] || MONTHS[m[2].slice(0, 3).toLowerCase()]) + '-' + pad(m[1]);\n  if ((m = s.match(/^([A-Za-z]{3,})\\.?\\s+(\\d{1,2}),?\\s+(\\d{4})/)) && MONTHS[m[1].slice(0, 3).toLowerCase()] !== undefined) return m[3] + '-' + pad(MONTHS[m[1].slice(0, 3).toLowerCase()]) + '-' + pad(m[2]);\n  const d = new Date(s);\n  return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10);\n};\nconst SYMBOLS = { '€': 'EUR', '$': 'USD', '£': 'GBP', 'kr': 'NOK', 'zł': 'PLN', '₴': 'UAH', 'chf': 'CHF' };\nconst toCurrency = (v) => {\n  const s = String(v ?? '').trim();\n  if (/^[A-Za-z]{3}$/.test(s)) return s.toUpperCase();\n  for (const [sym, code] of Object.entries(SYMBOLS)) if (s.toLowerCase().includes(sym)) return code;\n  return '';\n};\nconst key = (v) => String(v ?? '').replace(/[^A-Za-z0-9]/g, '').toUpperCase();\n\nconst merchant = String(ai.merchant ?? '').trim().slice(0, 80);\nconst date = toIsoDate(ai.date);\nconst total = toNumber(ai.total);\nconst currency = toCurrency(ai.currency) || 'EUR';\nconst catRaw = String(ai.category ?? '').trim().toLowerCase();\nconst category = CATEGORIES.find(c => c.toLowerCase() === catRaw) || CATEGORIES.find(c => catRaw && c.toLowerCase().startsWith(catRaw.slice(0, 4))) || 'Other';\nconst isReceipt = ai.is_receipt !== false && ai.is_receipt !== 'false';\nconst confidence = Math.max(0, Math.min(100, Math.round(Number(ai.confidence) || 0)));\nconst today = new Date(); const todayUtc = Date.UTC(today.getFullYear(), today.getMonth(), today.getDate());\nconst ageDays = date ? Math.round((todayUtc - Date.parse(date + 'T00:00:00Z')) / 86400000) : null;\n\nconst missing = [];\nif (!isReceipt) missing.push('not a receipt');\nif (!merchant) missing.push('merchant');\nif (!date) missing.push('date');\nif (!(total > 0)) missing.push('total');\n\nreturn { json: {\n  ...sub,\n  merchant, date, total, currency, tax: toNumber(ai.tax), category, category_key: key(category),\n  payment_method: String(ai.payment_method ?? '').trim().toLowerCase().slice(0, 20),\n  description: [sub.caption, String(ai.items ?? '').trim()].filter(Boolean).join(' - ').slice(0, 200),\n  is_receipt: isReceipt, ai_confidence: confidence, age_days: ageDays,\n  missing_fields: missing.join(', '),\n  receipt_key: sub.user_id + '|' + key(merchant) + '|' + date + '|' + total.toFixed(2),\n} };"
      },
      "typeVersion": 2
    },
    {
      "id": "2ef05161-051a-4da8-a415-95ad3beb2cae",
      "name": "Record AI Error State",
      "type": "n8n-nodes-base.set",
      "position": [
        1300,
        200
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "b74af5d4-0336-43f2-a005-92b0ba9e8c01",
              "name": "chat_id",
              "type": "string",
              "value": "={{ $('Prepare Submission Data').item.json.chat_id }}"
            },
            {
              "id": "c90dca47-39f9-454b-ac51-8146ef658e90",
              "name": "message_id",
              "type": "number",
              "value": "={{ $('Prepare Submission Data').item.json.message_id }}"
            },
            {
              "id": "e6de06e1-832f-449f-a5db-df82d5858d4c",
              "name": "error_message",
              "type": "string",
              "value": "={{ typeof $json.error === 'object' && $json.error !== null ? ($json.error.message || JSON.stringify($json.error)) : String($json.error ?? 'unknown error') }}"
            }
          ]
        },
        "includeOtherFields": false
      },
      "typeVersion": 3.4
    },
    {
      "id": "83d6a3b4-68a1-484f-adac-ad70f037e1d1",
      "name": "Notify Retry Attempt Needed",
      "type": "n8n-nodes-base.telegram",
      "position": [
        1560,
        200
      ],
      "parameters": {
        "text": "=Sorry, I could not read that receipt ({{ $json.error_message }}). Please try again with a sharper photo in good light, or send it later - nothing was recorded.",
        "chatId": "={{ $json.chat_id }}",
        "additionalFields": {
          "reply_to_message_id": "={{ $json.message_id }}"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "bb31b666-8c2a-4d98-a752-39a034827c39",
      "name": "Fetch Policy Settings",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        2080,
        200
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Policy"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_SPREADSHEET_ID"
        }
      },
      "executeOnce": true,
      "typeVersion": 4.7,
      "alwaysOutputData": true
    },
    {
      "id": "0711d6da-2d44-4607-a323-4794b99f2d57",
      "name": "Prepare Policy Data",
      "type": "n8n-nodes-base.set",
      "position": [
        2340,
        200
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "10b66fd1-5551-476b-a91c-4fe3009ac856",
              "name": "category_key",
              "type": "string",
              "value": "={{ String($json.Category ?? '').replace(/[^A-Za-z0-9]/g, '').toUpperCase() }}"
            },
            {
              "id": "15ca8b67-d441-46b5-a0b3-2098170fcf98",
              "name": "limit_per_receipt",
              "type": "number",
              "value": "={{ Number(String($json.LimitPerReceipt ?? '').replace(/[^\\d.]/g, '')) || 0 }}"
            },
            {
              "id": "4401b25d-2a36-47bd-a8da-225972ebc57b",
              "name": "approval_above",
              "type": "number",
              "value": "={{ String($json.ApprovalAbove ?? '').trim() === '' ? -1 : (Number(String($json.ApprovalAbove).replace(/[^\\d.]/g, '')) || 0) }}"
            },
            {
              "id": "3de9a2ea-b48b-46c4-af74-44b99b14a1c1",
              "name": "allowed",
              "type": "boolean",
              "value": "={{ !/^(no|n|false|0)$/i.test(String($json.Allowed ?? 'yes').trim()) }}"
            },
            {
              "id": "e64aaabf-7107-41b4-a6ea-c506c3672d7e",
              "name": "policy_note",
              "type": "string",
              "value": "={{ String($json.Notes ?? '').trim() }}"
            },
            {
              "id": "3e6abfa6-771f-4bcf-ae90-9197099f84b6",
              "name": "policy_matched",
              "type": "boolean",
              "value": "={{ Boolean(String($json.Category ?? '').trim()) }}"
            }
          ]
        },
        "includeOtherFields": false
      },
      "typeVersion": 3.4,
      "alwaysOutputData": true
    },
    {
      "id": "c46d5f64-9fb0-4166-add5-daf20cb9aca5",
      "name": "Combine Expense and Policy Data",
      "type": "n8n-nodes-base.merge",
      "position": [
        2600,
        0
      ],
      "parameters": {
        "mode": "combine",
        "options": {
          "multipleMatches": "first"
        },
        "advanced": true,
        "joinMode": "enrichInput1",
        "combineBy": "combineByFields",
        "mergeByFields": {
          "values": [
            {
              "field1": "category_key",
              "field2": "category_key"
            }
          ]
        }
      },
      "typeVersion": 3.2
    },
    {
      "id": "0616ee73-06bf-4826-aa5e-f2868d2b37e9",
      "name": "Fetch Existing Expenses",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        2860,
        200
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Expenses"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_SPREADSHEET_ID"
        }
      },
      "executeOnce": true,
      "typeVersion": 4.7,
      "alwaysOutputData": true
    },
    {
      "id": "5d340f7c-c84a-4bae-a792-3791c8802fe5",
      "name": "Prepare Expense Data",
      "type": "n8n-nodes-base.set",
      "position": [
        3120,
        200
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "8ebd71aa-4a69-48d4-ae27-bc3a77f09350",
              "name": "expense_key",
              "type": "string",
              "value": "={{ String($json.TelegramUserId ?? '') + '|' + String($json.Merchant ?? '').replace(/[^A-Za-z0-9]/g, '').toUpperCase() + '|' + String($json.Date ?? '').trim().slice(0, 10) + '|' + (Number(String($json.Amount ?? '').replace(/[^\\d.-]/g, '')) || 0).toFixed(2) }}"
            },
            {
              "id": "13ac10fd-4a41-4eba-a1d5-b8b7fcd8ceaa",
              "name": "existing_row",
              "type": "number",
              "value": "={{ $json.row_number }}"
            },
            {
              "id": "1c91b498-a5d4-4000-a927-39f25a81bb74",
              "name": "existing_status",
              "type": "string",
              "value": "={{ String($json.Status ?? '').trim() }}"
            },
            {
              "id": "ed89658e-ded3-4477-ad35-5cbd2946a70e",
              "name": "existing_submitted",
              "type": "string",
              "value": "={{ String($json.SubmittedAt ?? '').trim().slice(0, 10) }}"
            }
          ]
        },
        "includeOtherFields": false
      },
      "typeVersion": 3.4,
      "alwaysOutputData": true
    },
    {
      "id": "b48e2f0b-71b0-47a2-a565-70f1e6beab91",
      "name": "Combine to Check Duplicates",
      "type": "n8n-nodes-base.merge",
      "position": [
        3380,
        0
      ],
      "parameters": {
        "mode": "combine",
        "options": {
          "multipleMatches": "first"
        },
        "advanced": true,
        "joinMode": "enrichInput1",
        "combineBy": "combineByFields",
        "mergeByFields": {
          "values": [
            {
              "field1": "receipt_key",
              "field2": "expense_key"
            }
          ]
        }
      },
      "typeVersion": 3.2
    },
    {
      "id": "6ffd69dc-209d-476c-a224-165d6593534c",
      "name": "Prepare Policy Flags",
      "type": "n8n-nodes-base.set",
      "position": [
        3640,
        0
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "c947f79b-0962-42ea-a6af-2206defb9fad",
              "name": "is_duplicate",
              "type": "boolean",
              "value": "={{ Boolean($json.expense_key) }}"
            },
            {
              "id": "0bc097ce-25c0-4ad0-a938-db23af54f622",
              "name": "missing_info",
              "type": "boolean",
              "value": "={{ Boolean($json.missing_fields) }}"
            },
            {
              "id": "a5d8257b-1c17-4ae1-a46f-3a59f8c0c041",
              "name": "policy_matched",
              "type": "boolean",
              "value": "={{ Boolean($json.policy_matched) }}"
            },
            {
              "id": "e63ef8a9-3918-4843-ab4f-b0c456cd2af1",
              "name": "not_allowed",
              "type": "boolean",
              "value": "={{ Boolean($json.policy_matched) && !$json.allowed }}"
            },
            {
              "id": "586eb19f-b5fa-4289-aece-526563b45245",
              "name": "over_limit",
              "type": "boolean",
              "value": "={{ Number($json.limit_per_receipt) > 0 && Number($json.total) > Number($json.limit_per_receipt) }}"
            },
            {
              "id": "a66dab9b-d975-474a-aa45-2b37017dbaea",
              "name": "too_old",
              "type": "boolean",
              "value": "={{ $json.age_days !== null && Number($json.age_days) > 60 }}"
            },
            {
              "id": "0ea80d24-9331-4d45-ac49-af07457d1363",
              "name": "needs_approval",
              "type": "boolean",
              "value": "={{ !Boolean($json.policy_matched) || (Number($json.approval_above) >= 0 && Number($json.total) > Number($json.approval_above)) || ($json.age_days !== null && Number($json.age_days) > 60) || Number($json.ai_confidence) < 70 }}"
            },
            {
              "id": "8867939c-3775-433a-a32c-4fee830cc57f",
              "name": "check_note",
              "type": "string",
              "value": "={{ $json.expense_key ? 'Same receipt already recorded in row ' + $json.existing_row + ' (' + ($json.existing_status || 'no status') + ', ' + $json.existing_submitted + ')' : $json.missing_fields ? 'Could not read: ' + $json.missing_fields : (Boolean($json.policy_matched) && !$json.allowed) ? 'Category ' + $json.category + ' is not reimbursable' + ($json.policy_note ? ' (' + $json.policy_note + ')' : '') : (Number($json.limit_per_receipt) > 0 && Number($json.total) > Number($json.limit_per_receipt)) ? 'Amount ' + $json.total + ' ' + $json.currency + ' is above the ' + $json.category + ' limit of ' + $json.limit_per_receipt + ' per receipt' : !Boolean($json.policy_matched) ? 'No policy row for category ' + $json.category : ($json.age_days !== null && Number($json.age_days) > 60) ? 'Receipt is ' + $json.age_days + ' days old' : Number($json.ai_confidence) < 70 ? 'AI confidence only ' + $json.ai_confidence + '%' : (Number($json.approval_above) >= 0 && Number($json.total) > Number($json.approval_above)) ? 'Amount ' + $json.total + ' ' + $json.currency + ' is above the approval threshold of ' + $json.approval_above : 'Within policy for ' + $json.category + ' (limit per receipt ' + (Number($json.limit_per_receipt) > 0 ? $json.limit_per_receipt : 'none') + ', ' + (Number($json.approval_above) >= 0 ? 'approval above ' + $json.approval_above : 'no approval threshold') + ')' }}"
            }
          ]
        },
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    },
    {
      "id": "8d2a9a3e-1545-463b-a85e-86f6dab2451e",
      "name": "Decide Based on Policy Rules",
      "type": "n8n-nodes-base.switch",
      "position": [
        3900,
        0
      ],
      "parameters": {
        "rules": {
          "values": [
            {
              "outputKey": "Auto-approve",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": false,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "sw-not-dup",
                    "operator": {
                      "type": "boolean",
                      "operation": "false",
                      "singleValue": true
                    },
                    "leftValue": "={{ $json.is_duplicate }}"
                  },
                  {
                    "id": "sw-complete",
                    "operator": {
                      "type": "boolean",
                      "operation": "false",
                      "singleValue": true
                    },
                    "leftValue": "={{ $json.missing_info }}"
                  },
                  {
                    "id": "sw-allowed",
                    "operator": {
                      "type": "boolean",
                      "operation": "false",
                      "singleValue": true
                    },
                    "leftValue": "={{ $json.not_allowed }}"
                  },
                  {
                    "id": "sw-under",
                    "operator": {
                      "type": "boolean",
                      "operation": "false",
                      "singleValue": true
                    },
                    "leftValue": "={{ $json.over_limit }}"
                  },
                  {
                    "id": "sw-no-approval",
                    "operator": {
                      "type": "boolean",
                      "operation": "false",
                      "singleValue": true
                    },
                    "leftValue": "={{ $json.needs_approval }}"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "Duplicate",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": false,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "sw-dup",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    },
                    "leftValue": "={{ $json.is_duplicate }}"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "Missing info",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": false,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "sw-missing",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    },
                    "leftValue": "={{ $json.missing_info }}"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "Not allowed",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": false,
                  "typeValidation": "strict"
                },
                "combinator": "or",
                "conditions": [
                  {
                    "id": "sw-not-allowed",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    },
                    "leftValue": "={{ $json.not_allowed }}"
                  },
                  {
                    "id": "sw-over",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    },
                    "leftValue": "={{ $json.over_limit }}"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "Needs approval",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": false,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "sw-needs",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    },
                    "leftValue": "={{ $json.needs_approval }}"
                  }
                ]
              },
              "renameOutput": true
            }
          ]
        },
        "options": {}
      },
      "typeVersion": 3.2
    },
    {
      "id": "a7b7cbd0-48f1-4c4c-a1f7-3595a808daf7",
      "name": "Mark as Auto-Approved",
      "type": "n8n-nodes-base.set",
      "position": [
        4940,
        0
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "bc537663-b868-4888-acfa-29e149011619",
              "name": "status",
              "type": "string",
              "value": "Approved"
            },
            {
              "id": "f6592a14-f86e-4109-a9be-6c56c5d1160e",
              "name": "decision",
              "type": "string",
              "value": "Auto-approved within policy"
            },
            {
              "id": "be6e8eb1-a8ba-4747-a7e2-63ee9bc69845",
              "name": "decision_note",
              "type": "string",
              "value": "={{ $json.check_note }}"
            }
          ]
        },
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    },
    {
      "id": "f1a412dc-ff21-4086-a09d-5214ecd72bdb",
      "name": "Mark as Duplicate",
      "type": "n8n-nodes-base.set",
      "position": [
        4940,
        200
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "4a54c917-1349-40c6-aded-472cfdda5519",
              "name": "status",
              "type": "string",
              "value": "Duplicate"
            },
            {
              "id": "e9a22f0c-a0bb-43d9-a606-c4cc7d8135d6",
              "name": "decision",
              "type": "string",
              "value": "Duplicate - not recorded again"
            },
            {
              "id": "be6e8eb1-a8ba-4747-a7e2-63ee9bc69845",
              "name": "decision_note",
              "type": "string",
              "value": "={{ $json.check_note }}"
            }
          ]
        },
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    },
    {
      "id": "b1d76a67-18c6-499a-a71a-52fa2c27b4fa",
      "name": "Mark as Missing Info",
      "type": "n8n-nodes-base.set",
      "position": [
        4940,
        400
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "0104af0d-0323-4980-abd6-a92f76952988",
              "name": "status",
              "type": "string",
              "value": "Incomplete"
            },
            {
              "id": "6be23fca-2c44-41e6-a96e-70f039c92372",
              "name": "decision",
              "type": "string",
              "value": "Could not read the receipt"
            },
            {
              "id": "be6e8eb1-a8ba-4747-a7e2-63ee9bc69845",
              "name": "decision_note",
              "type": "string",
              "value": "={{ $json.check_note }}"
            }
          ]
        },
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    },
    {
      "id": "290ae0c4-2f4d-40da-a965-0253137c4f63",
      "name": "Mark as Policy Rejected",
      "type": "n8n-nodes-base.set",
      "position": [
        4940,
        600
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "a2f03556-d12c-4d1c-a8c0-6c70aa0a8dc7",
              "name": "status",
              "type": "string",
              "value": "Rejected"
            },
            {
              "id": "d521d9c9-1d31-4695-aa06-9f22bf94dd76",
              "name": "decision",
              "type": "string",
              "value": "Outside the expense policy"
            },
            {
              "id": "be6e8eb1-a8ba-4747-a7e2-63ee9bc69845",
              "name": "decision_note",
              "type": "string",
              "value": "={{ $json.check_note }}"
            }
          ]
        },
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    },
    {
      "id": "56991521-d1f7-4cb7-aede-767ca3374107",
      "name": "Request Manager Approval",
      "type": "n8n-nodes-base.telegram",
      "position": [
        4420,
        800
      ],
      "parameters": {
        "chatId": "YOUR_MANAGER_CHAT_ID",
        "message": "=🧾 Expense claim from {{ $json.employee }}{{ $json.username ? ' (@' + $json.username + ')' : '' }}\n{{ $json.merchant }} on {{ $json.date }}: {{ $json.total }} {{ $json.currency }} ({{ $json.category }})\n{{ $json.description || 'no description' }}\nWhy you see this: {{ $json.check_note }}",
        "options": {
          "limitWaitTime": {
            "values": {
              "limitType": "afterTimeInterval",
              "resumeUnit": "days",
              "resumeAmount": 3
            }
          }
        },
        "operation": "sendAndWait",
        "responseType": "approval",
        "approvalOptions": {
          "values": {
            "approvalType": "double",
            "approveLabel": "✅ Approve",
            "disapproveLabel": "❌ Reject"
          }
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "e15e8156-dfc5-4575-a383-67c7ff5100e5",
      "name": "Check Manager Approval",
      "type": "n8n-nodes-base.if",
      "position": [
        4680,
        800
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": false,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c-approved",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.data.approved }}"
            }
          ]
        },
        "looseTypeValidation": false
      },
      "typeVersion": 2.2
    },
    {
      "id": "07dd18ab-2cc1-4b1a-a5ed-0f7e40e761bb",
      "name": "Mark as Manager Approved",
      "type": "n8n-nodes-base.set",
      "position": [
        4940,
        800
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "b6a4651b-72d8-4e6d-a83d-6d064c558530",
              "name": "chat_id",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.chat_id }}"
            },
            {
              "id": "12212dbd-9a4a-4d1e-a093-ff32419c2d53",
              "name": "user_id",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.user_id }}"
            },
            {
              "id": "f5b942c5-ce0c-4927-a053-9140dcfa8093",
              "name": "employee",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.employee }}"
            },
            {
              "id": "b38e33d3-7dc5-4bb7-a4d8-a34c851dd108",
              "name": "username",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.username }}"
            },
            {
              "id": "b66e3fd8-0d41-49ff-a4bb-50445e1281d0",
              "name": "caption",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.caption }}"
            },
            {
              "id": "2280132a-a7ae-4c96-a664-7fb59301bb8b",
              "name": "submitted_at",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.submitted_at }}"
            },
            {
              "id": "98c4aab4-cfa7-4b28-adc3-03b4ea55cac2",
              "name": "month",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.month }}"
            },
            {
              "id": "e9127c51-1681-4672-a6e7-8a150f1c0c2b",
              "name": "file_id",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.file_id }}"
            },
            {
              "id": "4707dffd-4ea5-4eb0-a7ff-dd00518f30eb",
              "name": "merchant",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.merchant }}"
            },
            {
              "id": "182bf15b-e0dc-4709-a1f1-a4ccb72c724d",
              "name": "date",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.date }}"
            },
            {
              "id": "e8032da5-dafe-41ed-a4ed-823e774c73f4",
              "name": "currency",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.currency }}"
            },
            {
              "id": "7de2a8d3-444f-4203-a1e2-081b87cfe1dc",
              "name": "category",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.category }}"
            },
            {
              "id": "88e16c6b-22d7-4ed5-a3db-b6a8889abd72",
              "name": "description",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.description }}"
            },
            {
              "id": "b2b68a4a-510d-4011-a364-67d9902c6c19",
              "name": "check_note",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.check_note }}"
            },
            {
              "id": "0e7f1b7e-c893-4cf0-ae01-4113a9272efb",
              "name": "payment_method",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.payment_method }}"
            },
            {
              "id": "fd0d9534-883e-4deb-aa6f-8ea350ff42a1",
              "name": "message_id",
              "type": "number",
              "value": "={{ $('Prepare Policy Flags').item.json.message_id }}"
            },
            {
              "id": "01415ea2-d65e-448f-abf5-5ff169308aca",
              "name": "total",
              "type": "number",
              "value": "={{ $('Prepare Policy Flags').item.json.total }}"
            },
            {
              "id": "ee561b46-4f0e-44a0-a72f-dc2e9815abae",
              "name": "tax",
              "type": "number",
              "value": "={{ $('Prepare Policy Flags').item.json.tax }}"
            },
            {
              "id": "4efc257d-6d8b-4b25-abed-0b782ef869a6",
              "name": "ai_confidence",
              "type": "number",
              "value": "={{ $('Prepare Policy Flags').item.json.ai_confidence }}"
            },
            {
              "id": "bc537663-b868-4888-acfa-29e149011619",
              "name": "status",
              "type": "string",
              "value": "Approved"
            },
            {
              "id": "bf6ff579-8253-4c9c-a410-c52d6bc1f4fa",
              "name": "decision",
              "type": "string",
              "value": "Approved by the manager on Telegram"
            },
            {
              "id": "00a088ee-a60d-4085-aed3-e2bf9e207a03",
              "name": "decision_note",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.check_note + '; approved ' + String($json.data.respondedAt || $now.toISO()).slice(0, 16).replace('T', ' ') }}"
            }
          ]
        },
        "includeOtherFields": false
      },
      "typeVersion": 3.4
    },
    {
      "id": "3a05c43e-5a95-4912-a41e-cbd42461dbfb",
      "name": "Mark as Manager Rejected",
      "type": "n8n-nodes-base.set",
      "position": [
        4940,
        1000
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "b6a4651b-72d8-4e6d-a83d-6d064c558530",
              "name": "chat_id",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.chat_id }}"
            },
            {
              "id": "12212dbd-9a4a-4d1e-a093-ff32419c2d53",
              "name": "user_id",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.user_id }}"
            },
            {
              "id": "f5b942c5-ce0c-4927-a053-9140dcfa8093",
              "name": "employee",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.employee }}"
            },
            {
              "id": "b38e33d3-7dc5-4bb7-a4d8-a34c851dd108",
              "name": "username",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.username }}"
            },
            {
              "id": "b66e3fd8-0d41-49ff-a4bb-50445e1281d0",
              "name": "caption",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.caption }}"
            },
            {
              "id": "2280132a-a7ae-4c96-a664-7fb59301bb8b",
              "name": "submitted_at",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.submitted_at }}"
            },
            {
              "id": "98c4aab4-cfa7-4b28-adc3-03b4ea55cac2",
              "name": "month",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.month }}"
            },
            {
              "id": "e9127c51-1681-4672-a6e7-8a150f1c0c2b",
              "name": "file_id",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.file_id }}"
            },
            {
              "id": "4707dffd-4ea5-4eb0-a7ff-dd00518f30eb",
              "name": "merchant",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.merchant }}"
            },
            {
              "id": "182bf15b-e0dc-4709-a1f1-a4ccb72c724d",
              "name": "date",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.date }}"
            },
            {
              "id": "e8032da5-dafe-41ed-a4ed-823e774c73f4",
              "name": "currency",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.currency }}"
            },
            {
              "id": "7de2a8d3-444f-4203-a1e2-081b87cfe1dc",
              "name": "category",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.category }}"
            },
            {
              "id": "88e16c6b-22d7-4ed5-a3db-b6a8889abd72",
              "name": "description",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.description }}"
            },
            {
              "id": "b2b68a4a-510d-4011-a364-67d9902c6c19",
              "name": "check_note",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.check_note }}"
            },
            {
              "id": "0e7f1b7e-c893-4cf0-ae01-4113a9272efb",
              "name": "payment_method",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.payment_method }}"
            },
            {
              "id": "fd0d9534-883e-4deb-aa6f-8ea350ff42a1",
              "name": "message_id",
              "type": "number",
              "value": "={{ $('Prepare Policy Flags').item.json.message_id }}"
            },
            {
              "id": "01415ea2-d65e-448f-abf5-5ff169308aca",
              "name": "total",
              "type": "number",
              "value": "={{ $('Prepare Policy Flags').item.json.total }}"
            },
            {
              "id": "ee561b46-4f0e-44a0-a72f-dc2e9815abae",
              "name": "tax",
              "type": "number",
              "value": "={{ $('Prepare Policy Flags').item.json.tax }}"
            },
            {
              "id": "4efc257d-6d8b-4b25-abed-0b782ef869a6",
              "name": "ai_confidence",
              "type": "number",
              "value": "={{ $('Prepare Policy Flags').item.json.ai_confidence }}"
            },
            {
              "id": "a2f03556-d12c-4d1c-a8c0-6c70aa0a8dc7",
              "name": "status",
              "type": "string",
              "value": "Rejected"
            },
            {
              "id": "f33e984b-49ce-4078-a31d-063c33fecdb9",
              "name": "decision",
              "type": "string",
              "value": "Rejected by the manager on Telegram"
            },
            {
              "id": "d0d8195c-6563-42e3-a046-45e931d766b2",
              "name": "decision_note",
              "type": "string",
              "value": "={{ $('Prepare Policy Flags').item.json.check_note + '; rejected ' + String($json.data.respondedAt || $now.toISO()).slice(0, 16).replace('T', ' ') }}"
            }
          ]
        },
        "includeOtherFields": false
      },
      "typeVersion": 3.4
    },
    {
      "id": "fa8ba556-3415-47ea-a2f8-f2ca4abca127",
      "name": "Compile Decision Outcomes",
      "type": "n8n-nodes-base.noOp",
      "position": [
        5200,
        400
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "60dda36c-0f29-46a3-ac74-4d7f5da54a19",
      "name": "Add Entry to Expenses",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        5460,
        400
      ],
      "parameters": {
        "columns": {
          "value": {
            "Tax": "={{ $json.tax }}",
            "Date": "={{ $json.date }}",
            "Month": "={{ $json.month }}",
            "Amount": "={{ $json.total }}",
            "ChatId": "={{ $json.chat_id }}",
            "Status": "={{ $json.status }}",
            "Category": "={{ $json.category }}",
            "Currency": "={{ $json.currency }}",
            "Decision": "={{ $json.decision }}",
            "Employee": "={{ $json.employee }}",
            "Merchant": "={{ $json.merchant }}",
            "Description": "={{ $json.description }}",
            "SubmittedAt": "={{ $json.submitted_at }}",
            "DecisionNote": "={{ $json.decision_note }}",
            "ReceiptFileId": "={{ $json.file_id }}",
            "TelegramUserId": "={{ $json.user_id }}"
          },
          "schema": [
            {
              "id": "SubmittedAt",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "SubmittedAt",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Employee",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Employee",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "TelegramUserId",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "TelegramUserId",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "ChatId",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "ChatId",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Merchant",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Merchant",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Date",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Date",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Amount",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Amount",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Currency",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Currency",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Tax",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Tax",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Category",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Category",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Description",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Description",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Status",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Status",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Decision",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Decision",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "DecisionNote",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "DecisionNote",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "ReceiptFileId",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "ReceiptFileId",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Month",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Month",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": []
        },
        "options": {
          "cellFormat": "RAW"
        },
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Expenses"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_SPREADSHEET_ID"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "e8678e05-fe23-458f-ab26-2fb722885899",
      "name": "Inform Employee of Outcome",
      "type": "n8n-nodes-base.telegram",
      "position": [
        5720,
        400
      ],
      "parameters": {
        "text": "={{ $json.Status === 'Approved' ? '✅' : $json.Status === 'Rejected' ? '❌' : $json.Status === 'Duplicate' ? '🔁' : '❓' }} {{ $json.Status }}: {{ $json.Merchant || 'receipt' }}{{ $json.Date ? ' (' + $json.Date + ')' : '' }} - {{ $json.Amount }} {{ $json.Currency }}, {{ $json.Category }}\n{{ $json.Decision }}. {{ $json.DecisionNote }}\n{{ $json.Status === 'Approved' ? 'It is in this month\\'s expense report.' : $json.Status === 'Incomplete' ? 'Please send a clearer photo, or reply with the merchant, date and total.' : $json.Status === 'Duplicate' ? 'Nothing was recorded twice.' : 'Talk to your manager if you think this is wrong.' }}",
        "chatId": "={{ $json.ChatId }}",
        "additionalFields": {}
      },
      "typeVersion": 1.2
    },
    {
      "id": "8036cef2-63a5-409c-a622-e3a2e53ab803",
      "name": "Verify Approval Status",
      "type": "n8n-nodes-base.if",
      "position": [
        5980,
        400
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": false,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c-status-approved",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.Status }}",
              "rightValue": "Approved"
            }
          ]
        },
        "looseTypeValidation": true
      },
      "typeVersion": 2.2
    },
    {
      "id": "a779badb-8446-4339-af3a-72e4abd01b95",
      "name": "Send Receipt to Finance",
      "type": "n8n-nodes-base.telegram",
      "position": [
        6240,
        400
      ],
      "parameters": {
        "file": "={{ $json.ReceiptFileId }}",
        "chatId": "YOUR_FINANCE_CHAT_ID",
        "operation": "sendPhoto",
        "binaryData": false,
        "additionalFields": {
          "caption": "={{ $json.Employee }}: {{ $json.Merchant }} {{ $json.Date }} - {{ $json.Amount }} {{ $json.Currency }} ({{ $json.Category }}). {{ $json.Decision }}"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "a4541da0-29a0-4f3f-a2fb-d2641f3653e7",
      "name": "Monthly Trigger at 8AM",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -260,
        1400
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "months",
              "triggerAtHour": 8,
              "monthsInterval": 1,
              "triggerAtDayOfMonth": 1
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "6b75b7b1-5231-4f0c-af74-03afae0645ed",
      "name": "Gather Expenses for Report",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        0,
        1400
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Expenses"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_SPREADSHEET_ID"
        }
      },
      "typeVersion": 4.7,
      "alwaysOutputData": true
    },
    {
      "id": "01ca4fd5-920d-4470-af84-f5e8bdff2765",
      "name": "Filter Approved Expenses",
      "type": "n8n-nodes-base.filter",
      "position": [
        260,
        1400
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": false,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c-m-merchant",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              },
              "leftValue": "={{ $json.Merchant }}"
            },
            {
              "id": "c-m-status",
              "operator": {
                "type": "string",
                "operation": "regex"
              },
              "leftValue": "={{ $json.Status }}",
              "rightValue": "^\\s*approved\\s*$"
            }
          ]
        },
        "looseTypeValidation": true
      },
      "typeVersion": 2.2,
      "alwaysOutputData": true
    },
    {
      "id": "e2128862-af9e-4cce-a810-ab9492875483",
      "name": "Create Monthly Expense Report",
      "type": "n8n-nodes-base.code",
      "position": [
        520,
        1400
      ],
      "parameters": {
        "jsCode": "// Monthly expense report: approved expenses of the previous calendar month, totals per employee and per category,\n// one HTML email. Set MONTH to \"2026-08\" to rebuild an older month by hand.\nconst MONTH = ''; // '' = previous month\nconst MAX_ROWS = 200;\n\nconst now = new Date();\nconst prev = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));\nconst month = MONTH || prev.toISOString().slice(0, 7);\nconst toNumber = (v) => {\n  if (typeof v === 'number') return v;\n  let s = String(v ?? '').replace(/[^\\d.,-]/g, '');\n  if (/^-?\\d{1,3}(\\.\\d{3})+(,\\d{1,2})?$/.test(s)) s = s.replace(/\\./g, '');   // 1.250,00 -> 1250,00\n  else if (/^-?\\d{1,3}(,\\d{3})+(\\.\\d{1,2})?$/.test(s)) s = s.replace(/,/g, ''); // 1,250.00 -> 1250.00\n  const n = Number(s.replace(/,(\\d{1,2})$/, '.$1').replace(/,/g, ''));\n  return Number.isFinite(n) ? n : 0;\n};\nconst money = (n) => (Math.round(n * 100) / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });\nconst esc = (s) => String(s ?? '').replace(/[&<>\"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;' }[c]));\nconst monthOf = (r) => {\n  const m = String(r.Month ?? '').trim();\n  if (/^\\d{4}-\\d{2}$/.test(m)) return m;\n  const d = String(r.Date ?? r.SubmittedAt ?? '').trim();\n  if (/^\\d{4}-\\d{2}/.test(d)) return d.slice(0, 7);\n  if (typeof r.Date === 'number') return new Date(Date.UTC(1899, 11, 30) + Math.round(r.Date) * 86400000).toISOString().slice(0, 7); // Sheets serial date\n  return '';\n};\n\nconst rows = [];\nfor (const { json: r } of $input.all()) {\n  if (!r || !String(r.Merchant ?? '').trim()) continue;\n  if (!/^\\s*approved\\s*$/i.test(String(r.Status ?? ''))) continue;\n  if (monthOf(r) !== month) continue;\n  const amount = Math.round(toNumber(r.Amount) * 100) / 100;\n  if (amount <= 0) continue;\n  rows.push({ employee: String(r.Employee ?? '').trim() || 'unknown', merchant: String(r.Merchant).trim(), date: String(r.Date ?? '').trim().slice(0, 10), amount,\n    currency: String(r.Currency ?? '').trim().toUpperCase() || '?', category: String(r.Category ?? '').trim() || 'Other', description: String(r.Description ?? '').trim() });\n}\nrows.sort((a, b) => a.employee.localeCompare(b.employee) || a.date.localeCompare(b.date));\n\nconst sumBy = (keyFn) => { const t = {}; for (const r of rows) { const k = keyFn(r); t[k] = t[k] || { count: 0, sums: {} }; t[k].count++; t[k].sums[r.currency] = (t[k].sums[r.currency] || 0) + r.amount; } return t; };\nconst fmtSums = (sums) => Object.keys(sums).sort().map(c => money(sums[c]) + ' ' + c).join(', ') || '-';\nconst byEmployee = sumBy(r => r.employee), byCategory = sumBy(r => r.category);\nconst totals = {}; for (const r of rows) totals[r.currency] = (totals[r.currency] || 0) + r.amount;\n\nif (!rows.length) {\n  return [{ json: { month, subject: 'Expense report ' + month + ': no approved expenses', html: '<p>No approved expenses were recorded for ' + month + '.</p>', expense_count: 0, totals: {} } }];\n}\nconst table = (title, obj) => '<h3>' + title + '</h3><table border=\"1\" cellpadding=\"6\" cellspacing=\"0\" style=\"border-collapse:collapse\"><tr><th>' + title.split(' ')[1] + '</th><th>Receipts</th><th>Total</th></tr>' +\n  Object.keys(obj).sort().map(k => '<tr><td>' + esc(k) + '</td><td style=\"text-align:right\">' + obj[k].count + '</td><td>' + esc(fmtSums(obj[k].sums)) + '</td></tr>').join('') + '</table>';\nconst detail = rows.slice(0, MAX_ROWS).map(r => '<tr><td>' + esc(r.employee) + '</td><td>' + esc(r.date) + '</td><td>' + esc(r.merchant) + '</td><td>' + esc(r.category) + '</td><td style=\"text-align:right\">' + money(r.amount) + ' ' + esc(r.currency) + '</td><td>' + esc(r.description) + '</td></tr>').join('');\nconst html = '<h2>Expense report ' + month + '</h2><p><b>' + rows.length + '</b> approved receipt(s), total <b>' + esc(fmtSums(totals)) + '</b></p>' +\n  table('By employee', byEmployee) + table('By category', byCategory) +\n  '<h3>All receipts' + (rows.length > MAX_ROWS ? ' (first ' + MAX_ROWS + ')' : '') + '</h3><table border=\"1\" cellpadding=\"6\" cellspacing=\"0\" style=\"border-collapse:collapse\"><tr><th>Employee</th><th>Date</th><th>Merchant</th><th>Category</th><th>Amount</th><th>Description</th></tr>' + detail + '</table>' +\n  '<p style=\"color:#666\">Generated by the n8n expense workflow from the Expenses tab. Receipt photos are in the finance Telegram chat.</p>';\n\nreturn [{ json: { month, subject: 'Expense report ' + month + ': ' + rows.length + ' receipt(s), ' + fmtSums(totals), html, expense_count: rows.length, totals, by_employee: byEmployee } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "51adaaaf-ffc6-4553-ae02-b5bbd62541f3",
      "name": "Send Monthly Report via Email",
      "type": "n8n-nodes-base.gmail",
      "position": [
        780,
        1400
      ],
      "parameters": {
        "sendTo": "user@example.com",
        "message": "={{ $json.html }}",
        "options": {},
        "subject": "={{ $json.subject }}",
        "emailType": "html"
      },
      "typeVersion": 2.2
    }
  ],
  "connections": {
    "Mark as Duplicate": {
      "main": [
        [
          {
            "node": "Compile Decision Outcomes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Policy Data": {
      "main": [
        [
          {
            "node": "Combine Expense and Policy Data",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Mark as Missing Info": {
      "main": [
        [
          {
            "node": "Compile Decision Outcomes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Expense Data": {
      "main": [
        [
          {
            "node": "Combine to Check Duplicates",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Prepare Policy Flags": {
      "main": [
        [
          {
            "node": "Decide Based on Policy Rules",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Add Entry to Expenses": {
      "main": [
        [
          {
            "node": "Inform Employee of Outcome",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Policy Settings": {
      "main": [
        [
          {
            "node": "Prepare Policy Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark as Auto-Approved": {
      "main": [
        [
          {
            "node": "Compile Decision Outcomes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Record AI Error State": {
      "main": [
        [
          {
            "node": "Notify Retry Attempt Needed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Manager Approval": {
      "main": [
        [
          {
            "node": "Mark as Manager Approved",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Mark as Manager Rejected",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Monthly Trigger at 8AM": {
      "main": [
        [
          {
            "node": "Gather Expenses for Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify Approval Status": {
      "main": [
        [
          {
            "node": "Send Receipt to Finance",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check for Receipt Photo": {
      "main": [
        [
          {
            "node": "Prepare Submission Data",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Request Receipt Photo",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Expense Details": {
      "main": [
        [
          {
            "node": "Combine Expense and Policy Data",
            "type": "main",
            "index": 0
          },
          {
            "node": "Fetch Policy Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Existing Expenses": {
      "main": [
        [
          {
            "node": "Prepare Expense Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark as Policy Rejected": {
      "main": [
        [
          {
            "node": "Compile Decision Outcomes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Submission Data": {
      "main": [
        [
          {
            "node": "AI Analyze Receipt Photo",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI Analyze Receipt Photo": {
      "main": [
        [
          {
            "node": "Extract Expense Details",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Record AI Error State",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter Approved Expenses": {
      "main": [
        [
          {
            "node": "Create Monthly Expense Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark as Manager Approved": {
      "main": [
        [
          {
            "node": "Compile Decision Outcomes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark as Manager Rejected": {
      "main": [
        [
          {
            "node": "Compile Decision Outcomes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Request Manager Approval": {
      "main": [
        [
          {
            "node": "Check Manager Approval",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compile Decision Outcomes": {
      "main": [
        [
          {
            "node": "Add Entry to Expenses",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Gather Expenses for Report": {
      "main": [
        [
          {
            "node": "Filter Approved Expenses",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Inform Employee of Outcome": {
      "main": [
        [
          {
            "node": "Verify Approval Status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Combine to Check Duplicates": {
      "main": [
        [
          {
            "node": "Prepare Policy Flags",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Decide Based on Policy Rules": {
      "main": [
        [
          {
            "node": "Mark as Auto-Approved",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Mark as Duplicate",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Mark as Missing Info",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Mark as Policy Rejected",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Request Manager Approval",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Monthly Expense Report": {
      "main": [
        [
          {
            "node": "Send Monthly Report via Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When Receipt Sent to Telegram": {
      "main": [
        [
          {
            "node": "Check for Receipt Photo",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Combine Expense and Policy Data": {
      "main": [
        [
          {
            "node": "Combine to Check Duplicates",
            "type": "main",
            "index": 0
          },
          {
            "node": "Fetch Existing Expenses",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "active": false
}
