{"id":"P7Tc134xq0XZFjz7","meta":{"instanceId":"1dba5975f3a8a617982ba8166777e7f0e0e67bfe6567fc5ed8a4e7636d11f7cd","templateCredsSetupCompleted":true},"name":"AI Multimodal Expense Tracker_Final_v2.2","tags":[],"nodes":[{"id":"789a3e35-547d-47ff-9fce-4287f1a51ee0","name":"Google Sheets: Get Rows (Dedup lookup)","type":"n8n-nodes-base.googleSheets","maxTries":2,"position":[560,-64],"parameters":{"options":{},"filtersUI":{"values":[{"lookupValue":"={{ $json.update_id }}","lookupColumn":"Update_ID"}]},"sheetName":{"__rl":true,"mode":"id","value":"={{ $('CONFIG - User Settings').item.json.sheet_gid_log }}"},"documentId":{"__rl":true,"mode":"id","value":"={{ $('CONFIG - User Settings').item.json.spreadsheet_id }}"}},"credentials":{"googleSheetsOAuth2Api":{"id":"credential-id","name":"googleSheetsOAuth2Api Credential"}},"executeOnce":false,"retryOnFail":false,"typeVersion":4.7,"alwaysOutputData":true},{"id":"81ffc0d2-926c-4ac4-ba23-04c81652b99e","name":"IF (Is Duplicate?)","type":"n8n-nodes-base.if","position":[736,-64],"parameters":{"options":{},"conditions":{"options":{"version":3,"leftValue":"","caseSensitive":true,"typeValidation":"loose"},"combinator":"and","conditions":[{"id":"2707e1b7-5a59-47e0-9bf5-a24868021e3f","operator":{"type":"string","operation":"notEmpty","singleValue":true},"leftValue":"={{ $json.Message_ID }}","rightValue":"="}]},"looseTypeValidation":true},"typeVersion":2.3},{"id":"e0461045-0fc1-4a8f-a8f2-81189e44917f","name":"Switch (Voice/Photo/Text)","type":"n8n-nodes-base.switch","position":[1104,-288],"parameters":{"rules":{"values":[{"outputKey":"Voice","conditions":{"options":{"version":3,"leftValue":"","caseSensitive":true,"typeValidation":"loose"},"combinator":"and","conditions":[{"id":"8505ca3e-24b9-4ce6-b803-3f844f5e07f5","operator":{"type":"string","operation":"notEmpty","singleValue":true},"leftValue":"={{$node[\"Telegram Trigger\"].json.message.voice}}","rightValue":"true"}]},"renameOutput":true},{"outputKey":"Photo","conditions":{"options":{"version":3,"leftValue":"","caseSensitive":true,"typeValidation":"loose"},"combinator":"and","conditions":[{"id":"dfefc516-d1e3-4932-a4b4-d2f3dd2c84ae","operator":{"type":"string","operation":"notEmpty","singleValue":true},"leftValue":"={{$node[\"Telegram Trigger\"].json.message.photo}}","rightValue":"true"}]},"renameOutput":true},{"outputKey":"Text","conditions":{"options":{"version":3,"leftValue":"","caseSensitive":true,"typeValidation":"loose"},"combinator":"and","conditions":[{"id":"3e137d35-f896-4ecf-bc4e-a481240c7acd","operator":{"type":"string","operation":"notEmpty","singleValue":true},"leftValue":"={{$node[\"Telegram Trigger\"].json.message.text}}","rightValue":"true"}]},"renameOutput":true}]},"options":{},"looseTypeValidation":true},"typeVersion":3.4},{"id":"2881f5cc-61ba-4204-acaa-f0cfb7615a1b","name":"Code (Restore Telegram Payload)","type":"n8n-nodes-base.code","position":[944,-48],"parameters":{"jsCode":"return [{ json: $node[\"Telegram Trigger\"].json }];"},"typeVersion":2},{"id":"6408da1b-ca88-48ad-a351-ea89b565d527","name":"Set (Text Context)","type":"n8n-nodes-base.set","position":[1408,336],"parameters":{"options":{},"assignments":{"assignments":[{"id":"d5fe1c74-66db-4a76-ae1e-f8c8043f8309","name":"raw_input","type":"string","value":"={{$json.message.text}}"},{"id":"45bc27fc-b351-4429-a7cb-45c6138a7f7c","name":"message_id","type":"string","value":"={{$json.message.message_id}}"},{"id":"06f119fc-1fb7-4033-a1f3-68f292f294e4","name":"chat_id","type":"string","value":"={{$json.message.chat.id}}"},{"id":"98509cc8-b1b5-4384-899e-19d7a2ffe14d","name":"source_type","type":"string","value":"text"},{"id":"c25b1e8b-91e8-45ab-b0c5-189c8184223f","name":"now","type":"string","value":"={{ $now.setZone('Asia/Ho_Chi_Minh').toFormat('yyyy-LL-dd HH:mm:ss') }}"},{"id":"ba343e09-509b-4031-958f-b41ab59505fc","name":"update_id","type":"string","value":"={{ $json.update_id }}"}]}},"typeVersion":3.4},{"id":"10079662-d912-4409-91e5-09fa347111ce","name":"Google Gemini Chat (Text → JSON)","type":"@n8n/n8n-nodes-langchain.googleGemini","position":[1648,336],"parameters":{"modelId":{"__rl":true,"mode":"list","value":"models/gemini-2.5-flash","cachedResultName":"models/gemini-2.5-flash"},"options":{},"messages":{"values":[{"content":"=You are an advanced Expense Tracker Assistant.\nYour goal is to extract expense data from the user input into structured JSON.\n\nCURRENT CONTEXT:\n- Date: {{ $json.now }}\n- Default Currency: {{ $('CONFIG - User Settings').item.json.currency_code }}\n\nRULES:\n1. Extract ALL distinct items.\n2. Category MUST be one of: [Food, Transport, Bills, Shopping, Entertainment, Other].\n3. Payment Method: Infer if possible (e.g., \"transfer\" -> Transfer), else default to \"Cash\".\n4. AMOUNT NORMALIZATION:\n   - Convert \"k\" or \"grand\" to 1,000 (e.g., \"50k\" -> 50000).\n   - Convert \"m\", \"mil\", or \"million\" to 1,000,000.\n   - Return pure integers (no decimals).\n5. If currency is missing, use Default Currency from context.\n\nOUTPUT FORMAT (Strict JSON, No Markdown):\n{\n  \"expenses\": [\n    {\n      \"item\": \"string (short description)\",\n      \"amount\": number,\n      \"currency\": \"string\",\n      \"category\": \"string\",\n      \"payment_method\": \"string\",\n      \"date\": \"YYYY-MM-DD HH:mm:ss\"\n    }\n  ],\n  \"summary_text\": \"string (A concise confirmation message in English)\"\n}"},{"content":"={{$json.raw_input}}"}]}},"credentials":{"googlePalmApi":{"id":"credential-id","name":"googlePalmApi Credential"}},"typeVersion":1},{"id":"b0accb8d-9078-4361-bc9b-609e39fc6fd1","name":"Code (Parse Gemini JSON)","type":"n8n-nodes-base.code","position":[2496,-64],"parameters":{"jsCode":"function extractJsonObject(text) {\n  if (!text || typeof text !== 'string') return null;\n\n  // Remove ```json ... ``` fences\n  let cleaned = text\n    .replace(/```json/gi, '```')\n    .replace(/```/g, '')\n    .trim();\n\n  // Try direct parse\n  try {\n    return { obj: JSON.parse(cleaned), cleaned };\n  } catch (e) {}\n\n  // Fallback: extract first {...} block\n  const match = cleaned.match(/\\{[\\s\\S]*\\}/);\n  if (!match) return null;\n\n  try {\n    return { obj: JSON.parse(match[0]), cleaned: match[0] };\n  } catch (e) {\n    return null;\n  }\n}\n\nconst aiText = $json?.content?.parts?.[0]?.text ?? '';\nconst parsed = extractJsonObject(aiText);\n\nif (!parsed) {\n  throw new Error(`Gemini output is not valid JSON. Raw:\\n${aiText}`);\n}\n\nconst data = parsed.obj;\n\n// Basic validation\nif (!Array.isArray(data.expenses)) data.expenses = [];\nif (typeof data.summary_text !== 'string') data.summary_text = '';\n\n// Context: Prioritize using the current item (to be used for Text + Photo + Voice)\nconst updateIdFromTrigger = $node[\"Telegram Trigger\"]?.json?.update_id;\n\nconst ctx = {\n  update_id: $json.update_id ?? updateIdFromTrigger ?? null,\n  raw_input: $json.raw_input ?? null,\n  message_id: $json.message_id ?? null,\n  chat_id: $json.chat_id ?? null,\n  source_type: $json.source_type ?? null,\n  // It supports both the \"now\" field (in case of accidentally setting the wrong key with a space) and the $now field.\n  now: $json.now ?? $json[\"now \"] ?? $now,\n};\n\nreturn [{\n  json: {\n    ...data,\n    _ai_raw_text: aiText,\n    _ai_clean_json_text: parsed.cleaned,\n    ...ctx,\n  }\n}];"},"typeVersion":2},{"id":"c47bab6f-6c9f-412f-a717-262277657d14","name":"Code (Split expenses to items)","type":"n8n-nodes-base.code","position":[2880,-80],"parameters":{"jsCode":"// Get the configuration from the User Settings node.\nconst config = $('CONFIG - User Settings').item.json;\nconst timeZone = config.timezone || 'Asia/Ho_Chi_Minh';\nconst defaultCurrency = config.currency_code || 'USD';\n\nfunction formatDateTimeInTZ(dateInput, tz) {\n  const d = dateInput ? new Date(dateInput) : new Date();\n  if (Number.isNaN(d.getTime())) return '';\n\n  const parts = new Intl.DateTimeFormat('en-GB', {\n    timeZone: tz,\n    year: 'numeric', month: '2-digit', day: '2-digit',\n    hour: '2-digit', minute: '2-digit', second: '2-digit',\n    hour12: false,\n  }).formatToParts(d);\n\n  const map = Object.fromEntries(parts.map(p => [p.type, p.value]));\n  return `${map.year}-${map.month}-${map.day} ${map.hour}:${map.minute}:${map.second}`;\n}\n\nconst expenses = $json.expenses ?? [];\nconst updateId = $node[\"Telegram Trigger\"].json.update_id;\nconst fallbackDate = formatDateTimeInTZ($json.now ?? new Date(), timeZone);\n\nreturn expenses.map((e) => {\n  const dateValue = (typeof e?.date === 'string' && e.date.trim())\n      ? e.date.trim()\n      : fallbackDate;\n\n  return {\n    json: {\n      update_id: updateId,\n      message_id: $json.message_id,\n      chat_id: $json.chat_id,\n      source_type: $json.source_type,\n      raw_input: $json.raw_input,\n\n      item: e.item ?? '',\n      amount: Number(e.amount ?? 0),\n      // Use the default currency from the Config if the AI doesn't return it.\n      currency: e.currency ?? defaultCurrency,\n      category: e.category ?? 'Other',\n      payment_method: e.payment_method ?? 'Cash',\n\n      date: dateValue,\n      summary_text: $json.summary_text,\n    }\n  };\n});"},"typeVersion":2},{"id":"1cd58e0a-904a-4ac6-88dc-e35b6f3cbfc2","name":"Google Sheets → Append row(s)","type":"n8n-nodes-base.googleSheets","position":[3072,-80],"parameters":{"columns":{"value":{"Date":"={{ $json.date }}","Item":"={{ $json.item }}","Amount":"={{ $json.amount }}","Category":"={{ $json.category }}","Currency":"={{ $json.currency }}","Raw_Input":"={{ $json.raw_input }}","Update_ID":"={{ $json.update_id }}","Message_ID":"={{ $json.message_id }}","Payment_Method":"={{ $json.payment_method }}"},"schema":[{"id":"Update_ID","type":"string","display":true,"removed":false,"required":false,"displayName":"Update_ID","defaultMatch":false,"canBeUsedToMatch":true},{"id":"Message_ID","type":"string","display":true,"required":false,"displayName":"Message_ID","defaultMatch":false,"canBeUsedToMatch":true},{"id":"Date","type":"string","display":true,"required":false,"displayName":"Date","defaultMatch":false,"canBeUsedToMatch":true},{"id":"Item","type":"string","display":true,"required":false,"displayName":"Item","defaultMatch":false,"canBeUsedToMatch":true},{"id":"Amount","type":"string","display":true,"required":false,"displayName":"Amount","defaultMatch":false,"canBeUsedToMatch":true},{"id":"Payment_Method","type":"string","display":true,"required":false,"displayName":"Payment_Method","defaultMatch":false,"canBeUsedToMatch":true},{"id":"Category","type":"string","display":true,"required":false,"displayName":"Category","defaultMatch":false,"canBeUsedToMatch":true},{"id":"Currency","type":"string","display":true,"required":false,"displayName":"Currency","defaultMatch":false,"canBeUsedToMatch":true},{"id":"Raw_Input","type":"string","display":true,"required":false,"displayName":"Raw_Input","defaultMatch":false,"canBeUsedToMatch":true}],"mappingMode":"defineBelow","matchingColumns":[],"attemptToConvertTypes":false,"convertFieldsToString":false},"options":{},"operation":"append","sheetName":{"__rl":true,"mode":"id","value":"={{ $('CONFIG - User Settings').item.json.sheet_gid_log }}"},"documentId":{"__rl":true,"mode":"id","value":"={{ $('CONFIG - User Settings').item.json.spreadsheet_id }}"}},"credentials":{"googleSheetsOAuth2Api":{"id":"credential-id","name":"googleSheetsOAuth2Api Credential"}},"retryOnFail":true,"typeVersion":4.7},{"id":"d57bc019-f640-48d0-9628-4e3c9c1f5d57","name":"IF (Has expenses?)","type":"n8n-nodes-base.if","position":[2672,-64],"parameters":{"options":{},"conditions":{"options":{"version":3,"leftValue":"","caseSensitive":true,"typeValidation":"loose"},"combinator":"and","conditions":[{"id":"8d8e2ec2-b28e-47f8-8066-69c3f036fa21","operator":{"type":"string","operation":"notEmpty","singleValue":true},"leftValue":"={{ ($json.expenses || []).length > 0 }}","rightValue":""}]},"looseTypeValidation":true},"typeVersion":2.3},{"id":"ee625a24-d767-4ffe-88ab-0b174e656a3c","name":"Set (Photo Context)","type":"n8n-nodes-base.set","position":[1408,-64],"parameters":{"options":{},"assignments":{"assignments":[{"id":"03df0e04-9fbe-49d6-8018-52f43f252b96","name":"update_id","type":"string","value":"={{$json.update_id}}"},{"id":"12fc48a7-aacf-460a-95d5-93e3e4dd1b4c","name":"message_id","type":"string","value":"={{$json.message.message_id}}"},{"id":"93ec3b26-05aa-45d6-99e6-52e12d3cdfbe","name":"chat_id","type":"string","value":"={{$json.message.chat.id}}"},{"id":"9955eb19-7634-4cf9-8cf8-3a868aaacad2","name":"source_type","type":"string","value":"photo"},{"id":"8a7b2fc9-350c-499e-a5aa-d28cc37701b7","name":"caption","type":"string","value":"={{$json.message.caption || \"[photo]\"}}"},{"id":"5c70c114-304e-446f-8ced-30d935ea46f1","name":"raw_input","type":"string","value":"={{$json.message.caption || \"[photo]\"}}"},{"id":"e7e5b2d3-cbe6-4dec-a0b2-3ee3e5906fcc","name":"now","type":"string","value":"={{ $now.setZone('Asia/Ho_Chi_Minh').toFormat('yyyy-LL-dd HH:mm:ss') }}"}]},"includeOtherFields":true},"typeVersion":3.4},{"id":"4da8ecb9-2306-457f-97d2-de9a6200bd5a","name":"Code (Pick Best Photo)","type":"n8n-nodes-base.code","position":[1616,-64],"parameters":{"jsCode":"const photos = $json?.message?.photo ?? [];\nif (!photos.length) throw new Error('No message.photo found');\n\nconst best = [...photos].sort((a, b) => (a.file_size ?? 0) - (b.file_size ?? 0)).pop();\n\nreturn [{\n  json: {\n    ...$json,\n    file_id: best.file_id,\n    photo_width: best.width,\n    photo_height: best.height,\n    photo_file_size: best.file_size ?? null,\n  }\n}];"},"typeVersion":2},{"id":"9318dbfe-62f6-4136-a880-05a6e8b32f52","name":"Code (Normalize Gemini Image Output)","type":"n8n-nodes-base.code","position":[2224,-64],"parameters":{"jsCode":"// 1) Get text output from Gemini Analyze Image\nconst aiText =\n  $json?.content?.parts?.[0]?.text ??\n  $json?.text ??\n  '';\n\n// 2) Get context from Set (Photo Context)\nconst ctx = $node[\"Set (Photo Context)\"].json;\n\nreturn [{\n  json: {\n    // Standardize to match the format Parse is reading.\n    content: { parts: [{ text: aiText }] },\n\n    // Context for downstream to share\n    update_id: ctx.update_id,\n    message_id: ctx.message_id,\n    chat_id: ctx.chat_id,\n    source_type: ctx.source_type ?? 'photo',\n\n    // raw_input: prioritize raw_input if you have it, fallback caption\n    raw_input: ctx.raw_input ?? ctx.caption ?? '[photo]',\n\n    now: ctx.now ?? $now,\n  }\n}];"},"typeVersion":2},{"id":"ea8bb0d4-9ea0-482c-b2d2-3e035f810993","name":"Telegram → Send Error Message and wait for response","type":"n8n-nodes-base.telegram","position":[2880,128],"webhookId":"958aac8d-9479-49df-afa6-e7df2920d97d","parameters":{"text":"=⚠️ Could not understand expenses in: \"{{$json.raw_input}}\"\nPlease try format: \"Lunch 10k\" or \"Taxi 50k\".","chatId":"={{ $('Telegram Trigger').item.json.message.chat.id }}","additionalFields":{"reply_to_message_id":"={{ $json.message_id }}"}},"credentials":{"telegramApi":{"id":"credential-id","name":"telegramApi Credential"}},"typeVersion":1.2},{"id":"dd199ba1-0778-49f7-9fb6-a932092fd5f4","name":"Telegram → Send Final Message","type":"n8n-nodes-base.telegram","position":[2880,-272],"webhookId":"d3148d9c-68bc-4a83-9239-cbbcfd659b00","parameters":{"text":"={{ $json.summary_text }}","chatId":"={{ $json.chat_id }}","additionalFields":{"reply_to_message_id":"={{ $json.message_id }}"}},"credentials":{"telegramApi":{"id":"credential-id","name":"telegramApi Credential"}},"typeVersion":1.2},{"id":"64d0da46-7a55-460e-914a-f3f89fc53d78","name":"Google Gemini (Analyze Image)","type":"@n8n/n8n-nodes-langchain.googleGemini","position":[2016,-64],"parameters":{"text":"Analyze this receipt image and extract expense items.\nReturn ONLY raw JSON (no markdown, no ```).\n\nCONTEXT:\n- Default Currency: {{ $('CONFIG - User Settings').item.json.currency_code }}\n\nOutput schema:\n{\n  \"expenses\": [\n    {\n      \"item\": \"string\",\n      \"amount\": number,\n      \"currency\": \"string\",\n      \"category\": \"Food|Shopping|Transport|Bills|Entertainment|Other\",\n      \"payment_method\": \"Cash|Card|Transfer\",\n      \"date\": \"YYYY-MM-DD HH:mm:ss\"\n    }\n  ],\n  \"summary_text\": \"string (Concise summary in English, e.g. 'Receipt processed: Item A, Item B')\"\n}","modelId":{"__rl":true,"mode":"list","value":"models/gemini-2.5-flash","cachedResultName":"models/gemini-2.5-flash"},"options":{},"resource":"image","inputType":"binary","operation":"analyze"},"credentials":{"googlePalmApi":{"id":"credential-id","name":"googlePalmApi Credential"}},"typeVersion":1},{"id":"7af32a67-6212-439d-8189-b27b94a8261f","name":"Set (Voice Context)","type":"n8n-nodes-base.set","position":[1408,-464],"parameters":{"options":{},"assignments":{"assignments":[{"id":"c84c376e-40f2-4026-9875-9e36880714eb","name":"update_id","type":"string","value":"={{ $json.update_id }}"},{"id":"55101232-245a-43e9-a903-bdf36cffb71d","name":"message_id","type":"string","value":"={{$json.message.message_id}}"},{"id":"4f342238-d326-498c-9570-fd1f5a5fa40f","name":"chat_id","type":"string","value":"={{$json.message.chat.id}}"},{"id":"72c7c3ab-cd23-49e7-b2e9-52d78317a8d2","name":"source_type","type":"string","value":"voice"},{"id":"229a19c4-9e34-4f76-b1e6-0ba16b0fbc45","name":"file_id","type":"string","value":"={{$json.message.voice.file_id}}"},{"id":"e953e5e2-356a-434c-923a-d157c7f8dea8","name":"raw_input","type":"string","value":"[voice]"},{"id":"15a7e226-39c0-42d3-8e08-d904eb8c6758","name":"now","type":"string","value":"={{ $now.setZone('Asia/Ho_Chi_Minh').toFormat('yyyy-LL-dd HH:mm:ss') }}"}]}},"typeVersion":3.4},{"id":"fc1d557e-30b4-4f28-852a-f19af6ddcb76","name":"Telegram → Get Voice File","type":"n8n-nodes-base.telegram","position":[1600,-464],"webhookId":"bd649c0f-3722-48a1-9f0d-04839711069b","parameters":{"fileId":"={{$json.file_id}}","resource":"file","additionalFields":{"mimeType":"audio/ogg"}},"credentials":{"telegramApi":{"id":"credential-id","name":"telegramApi Credential"}},"typeVersion":1.2},{"id":"022846d3-718f-41ad-b6c2-89d990e95629","name":"Telegram → Get Image File","type":"n8n-nodes-base.telegram","position":[1808,-64],"webhookId":"bdec36f8-31a6-4797-a3ec-ebf7302ce4f1","parameters":{"fileId":"={{$json.file_id}}","resource":"file","additionalFields":{"mimeType":"image/jpeg"}},"credentials":{"telegramApi":{"id":"credential-id","name":"telegramApi Credential"}},"typeVersion":1.2},{"id":"3c664eb1-856f-4b6b-8408-351c5f2653dc","name":"Google Gemini (Analyze Audio)","type":"@n8n/n8n-nodes-langchain.googleGemini","position":[1808,-464],"parameters":{"text":"You are an advanced Expense Tracker Assistant.\nInput is an AUDIO message (voice note).\n\nTASKS:\n1. Transcribe the audio accurately to text (Detect language automatically).\n2. Extract expense data from the transcript.\n\nCONTEXT:\n- Default Currency: {{ $('CONFIG - User Settings').item.json.currency_code }}\n\nOutput schema (Strict JSON, No Markdown):\n{\n  \"expenses\": [\n    {\n      \"item\": \"string\",\n      \"amount\": number,\n      \"currency\": \"string\",\n      \"category\": \"Food|Shopping|Transport|Bills|Entertainment|Other\",\n      \"payment_method\": \"Cash|Card|Transfer\",\n      \"date\": \"YYYY-MM-DD HH:mm:ss\"\n    }\n  ],\n  \"summary_text\": \"string (Concise summary in English)\"\n}","modelId":{"__rl":true,"mode":"list","value":"models/gemini-2.5-flash","cachedResultName":"models/gemini-2.5-flash"},"options":{},"resource":"audio","inputType":"binary","operation":"analyze"},"credentials":{"googlePalmApi":{"id":"credential-id","name":"googlePalmApi Credential"}},"typeVersion":1},{"id":"5a718e98-34de-407f-ab47-e975d3249ebe","name":"Code (Normalize Gemini Audio Output)","type":"n8n-nodes-base.code","position":[2016,-464],"parameters":{"jsCode":"// 1) Get text output from Gemini Analyze Audio\nconst aiText =\n  $json?.content?.parts?.[0]?.text ??\n  $json?.text ??\n  '';\n\n// 2) Get context from Set (Voice Context)\nconst ctx = $node[\"Set (Voice Context)\"].json;\n\nreturn [{\n  json: {\n    // Standardize to match the format Parse is reading.\n    content: { parts: [{ text: aiText }] },\n\n    // Context for downstream to share\n    update_id: ctx.update_id,\n    message_id: ctx.message_id,\n    chat_id: ctx.chat_id,\n    source_type: ctx.source_type ?? 'voice',\n\n    // raw_input: retain the [voice] marker or caption\n    raw_input: ctx.raw_input ?? '[voice]',\n\n    now: ctx.now ?? $now,\n  }\n}];"},"typeVersion":2},{"id":"0f04fa94-5c7b-48ec-9ad3-558a7cb24316","name":"Code (Normalize Gemini Text Output)","type":"n8n-nodes-base.code","position":[2016,336],"parameters":{"jsCode":"// 1) Get text output from Gemini Chat (Text)\nconst aiText =\n  $json?.content?.parts?.[0]?.text ??\n  $json?.text ??\n  '';\n\n// 2) Get context from Set (Text Context)\nconst ctx = $node[\"Set (Text Context)\"].json;\n\nreturn [{\n  json: {\n    // Standardize to match the format Parse is reading.\n    content: { parts: [{ text: aiText }] },\n\n    // Context for downstream to share\n    update_id: ctx.update_id,\n    message_id: ctx.message_id,\n    chat_id: ctx.chat_id,\n    source_type: ctx.source_type ?? 'text',\n    raw_input: ctx.raw_input ?? '[text]',\n\n    now: ctx.now ?? $now,\n  }\n}];"},"typeVersion":2},{"id":"e8c72bf3-485e-4a10-bfb2-2c98a966e9bb","name":"Switch (Command Router)","type":"n8n-nodes-base.switch","position":[416,-272],"parameters":{"rules":{"values":[{"outputKey":"AddBudget","conditions":{"options":{"version":3,"leftValue":"","caseSensitive":true,"typeValidation":"strict"},"combinator":"and","conditions":[{"id":"c48f3c1f-65ec-4d06-9464-7bc8efa70bdf","operator":{"type":"boolean","operation":"equals"},"leftValue":"={{ /^\\/add(?:@\\w+)?\\s+budget\\b/i.test(($json.message?.text || '').trim()) }}","rightValue":true}]},"renameOutput":true},{"outputKey":"Default","conditions":{"options":{"version":3,"leftValue":"","caseSensitive":true,"typeValidation":"strict"},"combinator":"and","conditions":[{"id":"58c02279-ceef-4ab7-bd74-4223c5cf8abb","operator":{"type":"boolean","operation":"equals"},"leftValue":"={{ !/^\\/add(?:@\\w+)?\\s+budget\\b/i.test(($json.message?.text || '').trim()) }}","rightValue":true}]},"renameOutput":true}]},"options":{}},"typeVersion":3.4},{"id":"bed08bc1-2a8d-4e18-90c0-3a9a145169bc","name":"Code (Parse Budget Amount)","type":"n8n-nodes-base.code","position":[752,-928],"parameters":{"jsCode":"function parseAmount(text) {\n  if (!text) return null;\n\n  // Remove command prefix\n  let s = text.trim().replace(/^\\/add(?:@\\w+)?\\s+budget\\b/i, '').trim();\n\n  // Normalize: Remove commas (1,000 -> 1000)\n  s = s.replace(/,/g, '').replace(/\\s+/g, ' ').trim();\n  const lower = s.toLowerCase();\n\n  // Find number\n  const m = lower.match(/(\\d+(?:\\.\\d+)?)/);\n  if (!m) return null;\n\n  const num = Number(m[1]);\n  if (!Number.isFinite(num)) return null;\n\n  let multiplier = 1;\n  // Global suffixes: k = 1000, m = 1,000,000\n  if (/(k|grand)\\b/i.test(lower)) multiplier = 1000;\n  if (/(m|mil|million)\\b/i.test(lower)) multiplier = 1000000;\n  \n  // support Vietnamese currency (Hybrid)\n  if (/(tr|triệu|trieu)\\b/i.test(lower)) multiplier = 1000000; \n\n  return Math.round(num * multiplier);\n}\n\nconst text = $json.message?.text ?? '';\nconst amount = parseAmount(text);\n// Get the configuration to reformat the display for the user\nconst config = $('CONFIG - User Settings').item.json;\nconst locale = config.locale || 'en-US';\nconst symbol = config.currency_symbol || '$';\n\nreturn [{\n  json: {\n    ok: !!(amount && amount > 0),\n    amount: amount || 0,\n    chat_id: $json.message?.chat?.id,\n    message_id: $json.message?.message_id,\n    raw_text: text,\n    formatted_amount: (amount || 0).toLocaleString(locale) + ' ' + symbol\n  }\n}];"},"typeVersion":2},{"id":"bef10b08-afb6-492e-8581-f859ee5084b7","name":"IF (Budget ok?)","type":"n8n-nodes-base.if","position":[944,-928],"parameters":{"options":{},"conditions":{"options":{"version":3,"leftValue":"","caseSensitive":true,"typeValidation":"strict"},"combinator":"and","conditions":[{"id":"4c555183-5715-4b0b-91ab-b2f14498a9e5","operator":{"type":"boolean","operation":"true","singleValue":true},"leftValue":"={{ $json.ok }}","rightValue":""}]}},"typeVersion":2.3},{"id":"d7a70e37-8fab-4c8e-b040-22897d66b88a","name":"Telegram → Budget Error","type":"n8n-nodes-base.telegram","position":[1184,-736],"webhookId":"5a399870-c352-40ef-a7fc-268f89272c4b","parameters":{"text":"=⚠️ Invalid format.\nUsage: /add budget 500k or /add budget 10m.\n(Input: {{$json.raw_text}})","chatId":"={{ $json.chat_id }}","additionalFields":{"reply_to_message_id":"={{ $json.message_id }}"}},"credentials":{"telegramApi":{"id":"credential-id","name":"telegramApi Credential"}},"typeVersion":1.2},{"id":"9551f85a-3dd3-413c-a5b9-155a3065611e","name":"Google Sheets → Append or update row in sheet","type":"n8n-nodes-base.googleSheets","position":[1168,-944],"parameters":{"columns":{"value":{"ts":"={{ new Date().toLocaleString('vi-VN', { timeZone: 'Asia/Ho_Chi_Minh' }) }}","amount":"={{ $json.amount }}","raw_text":"={{ $json.raw_text }}"},"schema":[{"id":"ts","type":"string","display":true,"required":false,"displayName":"ts","defaultMatch":false,"canBeUsedToMatch":true},{"id":"amount","type":"string","display":true,"required":false,"displayName":"amount","defaultMatch":false,"canBeUsedToMatch":true},{"id":"raw_text","type":"string","display":true,"required":false,"displayName":"raw_text","defaultMatch":false,"canBeUsedToMatch":true}],"mappingMode":"defineBelow","matchingColumns":[],"attemptToConvertTypes":false,"convertFieldsToString":false},"options":{},"operation":"append","sheetName":{"__rl":true,"mode":"id","value":"={{ Number($('CONFIG - User Settings').item.json.sheet_gid_budget) }}"},"documentId":{"__rl":true,"mode":"id","value":"={{ $('CONFIG - User Settings').item.json.spreadsheet_id }}"}},"credentials":{"googleSheetsOAuth2Api":{"id":"credential-id","name":"googleSheetsOAuth2Api Credential"}},"typeVersion":4.7},{"id":"af27370d-b54a-4b35-b31d-679a1889ebe8","name":"Telegram → Budget Updated","type":"n8n-nodes-base.telegram","position":[1360,-944],"webhookId":"a71b02d7-a09b-471a-9344-418f6f95d3c3","parameters":{"text":"=✅ Budget updated to: {{$json.formatted_amount}}","chatId":"={{ $('IF (Budget ok?)').item.json.chat_id }}","additionalFields":{"reply_to_message_id":"={{ $('IF (Budget ok?)').item.json.message_id }}"}},"credentials":{"telegramApi":{"id":"credential-id","name":"telegramApi Credential"}},"typeVersion":1.2},{"id":"179743fe-7387-48b4-a2e0-50a5b9479ad4","name":"GS - Get Daily Report Range","type":"n8n-nodes-base.googleSheets","position":[4512,-80],"parameters":{"options":{"dataLocationOnSheet":{"values":{"range":"D1:I2","rangeDefinition":"specifyRangeA1"}}},"sheetName":{"__rl":true,"mode":"id","value":"={{ Number($('CONFIG - User Settings').item.json.sheet_gid_dashboard) }}"},"documentId":{"__rl":true,"mode":"id","value":"={{ $('CONFIG - User Settings').item.json.spreadsheet_id }}"}},"credentials":{"googleSheetsOAuth2Api":{"id":"credential-id","name":"googleSheetsOAuth2Api Credential"}},"notesInFlow":false,"typeVersion":4.7},{"id":"9852ebb9-18c4-4420-9281-048c03995433","name":"Code - Build Daily Report","type":"n8n-nodes-base.code","position":[4704,-80],"parameters":{"jsCode":"const r = $json;\nconst config = $('CONFIG - User Settings').item.json;\nconst locale = config.locale || 'en-US';\nconst symbol = config.currency_symbol || '$';\n\nfunction num(v){ return Number(String(v ?? '0').replace(/[^\\d.-]/g,'')) || 0; }\nfunction fmt(n){ return num(n).toLocaleString(locale) + ' ' + symbol; }\n\nconst note = String(r.note ?? '').trim();\n\nconst text =\n`📊 *Daily Report* (${r.date})\n\n💰 Total budget: *${fmt(r.total_budget)}*\n💸 Total spent: *${fmt(r.total_spent)}*\n🏦 Remaining: *${fmt(r.remaining)}*\n📉 Daily Avg: *${fmt(r.monthly_rate)}*${note ? `\\n\\n📝 Note: ${note}` : ''}`;\n\nreturn [{ json: { text } }];"},"typeVersion":2},{"id":"8a1698e4-c3df-4ca8-be1a-c0c595d70058","name":"TG - Send Daily Report","type":"n8n-nodes-base.telegram","position":[4912,-80],"webhookId":"c8a912e7-4b90-4a63-af25-16dc5d6c510d","parameters":{"text":"={{ $json.text }}","chatId":"={{ $node[\"Telegram Trigger\"].json.message.chat.id }}","additionalFields":{}},"credentials":{"telegramApi":{"id":"credential-id","name":"telegramApi Credential"}},"typeVersion":1.2},{"id":"aeaf6ba9-378d-4065-b020-72d63f68bf34","name":"Code (Schedule Report Token)","type":"n8n-nodes-base.code","position":[3248,-80],"parameters":{"jsCode":"const chatId = $node[\"Telegram Trigger\"].json.message?.chat?.id;\nif (!chatId) throw new Error(\"Missing chat_id from Telegram Trigger\");\n\nconst token = `${Date.now()}_${Math.random().toString(16).slice(2)}`;\n\nreturn [{\n  json: {\n    chat_id: String(chatId),\n    report_token: token,\n  }\n}];"},"typeVersion":2},{"id":"d8055f37-ddb8-4b09-97c9-733fd69d5259","name":"Telegram Trigger","type":"n8n-nodes-base.telegramTrigger","position":[0,-272],"webhookId":"936fab6f-61fd-4989-a7bc-549ed956d58b","parameters":{"updates":["message"],"additionalFields":{}},"credentials":{"telegramApi":{"id":"credential-id","name":"telegramApi Credential"}},"typeVersion":1.2},{"id":"a6ee6c21-fa8e-44eb-b200-d42766bcb7ad","name":"Code - Check Latest Token","type":"n8n-nodes-base.code","position":[3920,-80],"parameters":{"jsCode":"const scheduledToken = $node[\"Wait\"].json.report_token;\n\n// Data table Get row(s): each row is an item => must be retrieved via $input.all()\nconst items = $input.all().map(i => i.json);\n\nif (!items.length) {\n  return [{ json: { shouldSend: false, reason: 'No row found', itemsCount: 0 } }];\n}\n\n// If the Upgrade is correct, there is usually only 1 item.\n// But to be safe, use the newest one based on updatedAt (fallback is based on ID).\nitems.sort((a, b) => {\n  const ta = new Date(a.updatedAt || a.createdAt || 0).getTime();\n  const tb = new Date(b.updatedAt || b.createdAt || 0).getTime();\n  if (ta !== tb) return tb - ta;\n  return (b.id || 0) - (a.id || 0);\n});\n\nconst latest = items[0];\nconst latestToken = latest?.report_token ?? null;\n\nreturn [{\n  json: {\n    shouldSend: String(latestToken ?? '') === String(scheduledToken ?? ''),\n    debug_latestToken: latestToken,\n    debug_scheduledToken: scheduledToken,\n    debug_itemsCount: items.length,\n    debug_latestRowId: latest?.id ?? null,\n  }\n}];"},"typeVersion":2},{"id":"f5ab1340-25b8-4573-9225-8aa67e1e434a","name":"If","type":"n8n-nodes-base.if","position":[4096,-64],"parameters":{"options":{},"conditions":{"options":{"version":3,"leftValue":"","caseSensitive":true,"typeValidation":"strict"},"combinator":"and","conditions":[{"id":"b4555b5d-0140-41df-bc19-01adab9af376","operator":{"type":"boolean","operation":"true","singleValue":true},"leftValue":"={{ $json.shouldSend }}","rightValue":""}]}},"typeVersion":2.3},{"id":"b4027f12-8ec6-4f80-83a5-678229609c19","name":"ReportTokens","type":"n8n-nodes-base.dataTable","position":[3424,-80],"parameters":{"columns":{"value":{"chat_id":"={{ $json.chat_id }}","updated_at":"={{ $now.setZone('Asia/Ho_Chi_Minh').toFormat('yyyy-LL-dd HH:mm:ss') }}","report_token":"={{ $json.report_token }}"},"schema":[{"id":"chat_id","type":"string","display":true,"removed":false,"readOnly":false,"required":false,"displayName":"chat_id","defaultMatch":false},{"id":"report_token","type":"string","display":true,"removed":false,"readOnly":false,"required":false,"displayName":"report_token","defaultMatch":false},{"id":"updated_at","type":"string","display":true,"removed":false,"readOnly":false,"required":false,"displayName":"updated_at","defaultMatch":false}],"mappingMode":"defineBelow","matchingColumns":[],"attemptToConvertTypes":false,"convertFieldsToString":false},"filters":{"conditions":[{"keyName":"chat_id","keyValue":"={{ $json.chat_id }}"}]},"options":{},"operation":"upsert","dataTableId":{"__rl":true,"mode":"list","value":"QvSW24TWoTE0N3cH","cachedResultUrl":"/projects/abUHa4TKmFzPJngM/datatables/QvSW24TWoTE0N3cH","cachedResultName":"ReportTokens"}},"typeVersion":1},{"id":"9e10464d-c414-4091-a991-089403a7ae39","name":"Data table → Get row(s)","type":"n8n-nodes-base.dataTable","position":[3744,-80],"parameters":{"filters":{"conditions":[{"keyName":"chat_id","keyValue":"={{ $node[\"Wait\"].json.chat_id }}"}]},"operation":"get","dataTableId":{"__rl":true,"mode":"list","value":"QvSW24TWoTE0N3cH","cachedResultUrl":"/projects/abUHa4TKmFzPJngM/datatables/QvSW24TWoTE0N3cH","cachedResultName":"ReportTokens"}},"typeVersion":1},{"id":"fa1ae335-6574-45fc-aa5a-a84fdfd1ba2f","name":"Data table → Delete row(s)","type":"n8n-nodes-base.dataTable","position":[4304,-80],"parameters":{"filters":{"conditions":[{"keyName":"chat_id","keyValue":"={{ $node[\"Wait\"].json.chat_id }}"}]},"options":{},"operation":"deleteRows","dataTableId":{"__rl":true,"mode":"list","value":"QvSW24TWoTE0N3cH","cachedResultUrl":"/projects/abUHa4TKmFzPJngM/datatables/QvSW24TWoTE0N3cH","cachedResultName":"ReportTokens"}},"typeVersion":1},{"id":"fa2618f9-6914-41ab-a274-0f90dbebb441","name":"Wait","type":"n8n-nodes-base.wait","position":[3584,-80],"webhookId":"7774be61-977b-4f2c-838d-c71ae61dd976","parameters":{"unit":"minutes","amount":30},"typeVersion":1.1},{"id":"52f3a9a9-5cc4-4c5a-9cf1-fdb42663c306","name":"CONFIG - User Settings","type":"n8n-nodes-base.set","position":[208,-272],"parameters":{"options":{},"assignments":{"assignments":[{"id":"2696366a-a0ac-4911-8f0b-3c1e4cb4efab","name":"spreadsheet_id","type":"string","value":"Input your Spread Sheet ID here"},{"id":"393db088-bfe0-493e-9fdb-61eb49d62fd1","name":"sheet_gid_log","type":"string","value":"gid=0"},{"id":"719092f5-e6b7-416c-9b73-4b7346bdf6df","name":"sheet_gid_dashboard","type":"string","value":"Input your Sheet \"Dashboard\" ID here"},{"id":"56c12832-6f97-47b0-93de-986dcaa1357f","name":"sheet_gid_budget","type":"string","value":"Input your Sheet \"Budget Topups\" ID here"},{"id":"4de77bb6-8952-4df1-a34d-16b4c9393b8a","name":"currency_code","type":"string","value":"USD"},{"id":"13626830-f7c6-44b3-bd31-3b0ac81b56e3","name":"currency_symbol","type":"string","value":"$"},{"id":"8a3aba9b-1e2d-4608-89c0-4713c594e657","name":"locale","type":"string","value":"en-US"},{"id":"39a94c66-e67f-4e3a-8f01-2a5e76040ebe","name":"timezone","type":"string","value":"Input your timezone"}]},"includeOtherFields":true},"typeVersion":3.4},{"id":"6bb87bf8-368b-4e9a-8ef2-77e0e610d5ae","name":"Sticky Note","type":"n8n-nodes-base.stickyNote","position":[-32,-1024],"parameters":{"width":592,"height":576,"content":"# AI Multimodal Expense Tracker\n**Global, Multi-Currency Expense Logger via Telegram (Text, Voice, Photo).**\n\nThis workflow turns Telegram into a frictionless finance assistant using Gemini AI. It supports receipt scanning, voice logging, budget management, and smart daily reporting.\n\n### How it works\n1.  **Router:** Routes incoming messages (Text, Audio, Photo) to the specific Gemini AI agent.\n2.  **AI Extraction:** Gemini analyzes the input to extract structured data (Item, Amount, Category).\n3.  **Normalization:** Javascript logic normalizes numbers (k/m suffixes) and handles global currency formatting based on your Locale config.\n4.  **Logging:** Data is appended to Google Sheets.\n5.  **Smart Report:** A \"debounce\" logic waits for 30 minutes of inactivity before sending a daily summary to avoid spamming.\n\n### Setup steps\n1.  **Prerequisites:** You **MUST** copy the provided Google Sheet Template and create a \"ReportTokens\" Data Table in n8n (See Template Description).\n2.  **Config:** Open the `CONFIG - User Settings` node to set your Sheet ID, Currency (USD/VND), and Locale.\n3.  **Credentials:** Connect Telegram, Google Sheets, and Google Gemini (PaLM) accounts."},"typeVersion":1},{"id":"6d5e677d-c3f8-4e4d-932b-f03ff87e6b22","name":"Sticky Note1","type":"n8n-nodes-base.stickyNote","position":[-32,-368],"parameters":{"color":7,"width":1280,"height":480,"content":"## 1. Input & Routing\nInitializes configuration, handles credentials, and routes inputs to the appropriate processing chain (Command, Voice, Photo, or Text)."},"typeVersion":1},{"id":"4489dbec-b060-4b53-a509-1dd6f053cf02","name":"Sticky Note2","type":"n8n-nodes-base.stickyNote","position":[1280,-544],"parameters":{"color":7,"width":1104,"height":1072,"content":"## 2. AI Extraction & Normalization\nGemini AI analyzes multimodal inputs to extract structured JSON. Code nodes normalize currency formats and parse the AI response."},"typeVersion":1},{"id":"5836aead-4611-4f97-9066-84e1b4531ce1","name":"Sticky Note3","type":"n8n-nodes-base.stickyNote","position":[3216,-176],"parameters":{"color":7,"width":1856,"height":288,"content":"## 5. Smart Debounced Reporting (optional)\nUses n8n Data Table to wait for 30 minutes of inactivity before generating and sending a daily financial summary."},"typeVersion":1},{"id":"7d325593-4132-4125-8f8b-fd167afaecf6","name":"Sticky Note4","type":"n8n-nodes-base.stickyNote","position":[704,-1024],"parameters":{"color":7,"width":784,"height":432,"content":"## 4. Logging & Budget Logic\nSplits multiple items into separate rows, handles \"/add budget\" commands, and writes clean data to Google Sheets."},"typeVersion":1},{"id":"f9de232a-e13f-423d-9907-0f5516b0a9a9","name":"Sticky Note5","type":"n8n-nodes-base.stickyNote","position":[112,-112],"parameters":{"color":3,"width":304,"height":176,"content":"### ⚠️ CRITICAL SETUP REQUIRED\n1. **Google Sheet:** You must use the Template provided in the description.\n2. **Data Table (optional):** You must create a `ReportTokens` table in n8n for the reporting feature to work. \nCheck the Template Description for the setup guide!"},"typeVersion":1},{"id":"2dc28159-a189-4b28-8982-44cd7558d18b","name":"Sticky Note6","type":"n8n-nodes-base.stickyNote","position":[2416,-384],"parameters":{"color":7,"width":784,"height":688,"content":"## 3. Logging & Feedback\nValidates AI output, splits multiple items, logs to Google Sheets, and sends success/error feedback to Telegram."},"typeVersion":1}],"active":false,"pinData":{},"settings":{"timezone":"Asia/Ho_Chi_Minh","callerPolicy":"workflowsFromSameOwner","timeSavedMode":"fixed","availableInMCP":false,"executionOrder":"v1","executionTimeout":-1,"saveExecutionProgress":true},"versionId":"f0b24ec4-2588-4ea3-b4bb-c6747d837dbe","connections":{"If":{"main":[[{"node":"Data table → Delete row(s)","type":"main","index":0}]]},"Wait":{"main":[[{"node":"Data table → Get row(s)","type":"main","index":0}]]},"ReportTokens":{"main":[[{"node":"Wait","type":"main","index":0}]]},"IF (Budget ok?)":{"main":[[{"node":"Google Sheets → Append or update row in sheet","type":"main","index":0}],[{"node":"Telegram → Budget Error","type":"main","index":0}]]},"Telegram Trigger":{"main":[[{"node":"CONFIG - User Settings","type":"main","index":0}]]},"IF (Has expenses?)":{"main":[[{"node":"Code (Split expenses to items)","type":"main","index":0},{"node":"Telegram → Send Final Message","type":"main","index":0}],[{"node":"Telegram → Send Error Message and wait for response","type":"main","index":0}]]},"IF (Is Duplicate?)":{"main":[[],[{"node":"Code (Restore Telegram Payload)","type":"main","index":0}]]},"Set (Text Context)":{"main":[[{"node":"Google Gemini Chat (Text → JSON)","type":"main","index":0}]]},"Set (Photo Context)":{"main":[[{"node":"Code (Pick Best Photo)","type":"main","index":0}]]},"Set (Voice Context)":{"main":[[{"node":"Telegram → Get Voice File","type":"main","index":0}]]},"CONFIG - User Settings":{"main":[[{"node":"Switch (Command Router)","type":"main","index":0}]]},"Code (Pick Best Photo)":{"main":[[{"node":"Telegram → Get Image File","type":"main","index":0}]]},"Switch (Command Router)":{"main":[[{"node":"Code (Parse Budget Amount)","type":"main","index":0}],[{"node":"Google Sheets: Get Rows (Dedup lookup)","type":"main","index":0}]]},"Code (Parse Gemini JSON)":{"main":[[{"node":"IF (Has expenses?)","type":"main","index":0}]]},"Code - Build Daily Report":{"main":[[{"node":"TG - Send Daily Report","type":"main","index":0}]]},"Code - Check Latest Token":{"main":[[{"node":"If","type":"main","index":0}]]},"Data table → Get row(s)":{"main":[[{"node":"Code - Check Latest Token","type":"main","index":0}]]},"Switch (Voice/Photo/Text)":{"main":[[{"node":"Set (Voice Context)","type":"main","index":0}],[{"node":"Set (Photo Context)","type":"main","index":0}],[{"node":"Set (Text Context)","type":"main","index":0}]]},"Code (Parse Budget Amount)":{"main":[[{"node":"IF (Budget ok?)","type":"main","index":0}]]},"GS - Get Daily Report Range":{"main":[[{"node":"Code - Build Daily Report","type":"main","index":0}]]},"Telegram → Get Image File":{"main":[[{"node":"Google Gemini (Analyze Image)","type":"main","index":0}]]},"Telegram → Get Voice File":{"main":[[{"node":"Google Gemini (Analyze Audio)","type":"main","index":0}]]},"Code (Schedule Report Token)":{"main":[[{"node":"ReportTokens","type":"main","index":0}]]},"Data table → Delete row(s)":{"main":[[{"node":"GS - Get Daily Report Range","type":"main","index":0}]]},"Google Gemini (Analyze Audio)":{"main":[[{"node":"Code (Normalize Gemini Audio Output)","type":"main","index":0}]]},"Google Gemini (Analyze Image)":{"main":[[{"node":"Code (Normalize Gemini Image Output)","type":"main","index":0}]]},"Code (Split expenses to items)":{"main":[[{"node":"Google Sheets → Append row(s)","type":"main","index":0}]]},"Code (Restore Telegram Payload)":{"main":[[{"node":"Switch (Voice/Photo/Text)","type":"main","index":0}]]},"Google Sheets → Append row(s)":{"main":[[{"node":"Code (Schedule Report Token)","type":"main","index":0}]]},"Google Gemini Chat (Text → JSON)":{"main":[[{"node":"Code (Normalize Gemini Text Output)","type":"main","index":0}]]},"Code (Normalize Gemini Text Output)":{"main":[[{"node":"Code (Parse Gemini JSON)","type":"main","index":0}]]},"Code (Normalize Gemini Audio Output)":{"main":[[{"node":"Code (Parse Gemini JSON)","type":"main","index":0}]]},"Code (Normalize Gemini Image Output)":{"main":[[{"node":"Code (Parse Gemini JSON)","type":"main","index":0}]]},"Google Sheets: Get Rows (Dedup lookup)":{"main":[[{"node":"IF (Is Duplicate?)","type":"main","index":0}]]},"Google Sheets → Append or update row in sheet":{"main":[[{"node":"Telegram → Budget Updated","type":"main","index":0}]]}}}