{
  "id": "P7Tc134xq0XZFjz7",
  "meta": {
    "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": {
          "name": "<your 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 \u2192 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": {
          "name": "<your 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 \u2192 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": {
          "name": "<your 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 \u2192 Send Error Message and wait for response",
      "type": "n8n-nodes-base.telegram",
      "position": [
        2880,
        128
      ],
      "parameters": {
        "text": "=\u26a0\ufe0f 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": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "dd199ba1-0778-49f7-9fb6-a932092fd5f4",
      "name": "Telegram \u2192 Send Final Message",
      "type": "n8n-nodes-base.telegram",
      "position": [
        2880,
        -272
      ],
      "parameters": {
        "text": "={{ $json.summary_text }}",
        "chatId": "={{ $json.chat_id }}",
        "additionalFields": {
          "reply_to_message_id": "={{ $json.message_id }}"
        }
      },
      "credentials": {
        "telegramApi": {
          "name": "<your 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": {
          "name": "<your 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 \u2192 Get Voice File",
      "type": "n8n-nodes-base.telegram",
      "position": [
        1600,
        -464
      ],
      "parameters": {
        "fileId": "={{$json.file_id}}",
        "resource": "file",
        "additionalFields": {
          "mimeType": "audio/ogg"
        }
      },
      "credentials": {
        "telegramApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "022846d3-718f-41ad-b6c2-89d990e95629",
      "name": "Telegram \u2192 Get Image File",
      "type": "n8n-nodes-base.telegram",
      "position": [
        1808,
        -64
      ],
      "parameters": {
        "fileId": "={{$json.file_id}}",
        "resource": "file",
        "additionalFields": {
          "mimeType": "image/jpeg"
        }
      },
      "credentials": {
        "telegramApi": {
          "name": "<your 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": {
          "name": "<your 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\u1ec7u|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 \u2192 Budget Error",
      "type": "n8n-nodes-base.telegram",
      "position": [
        1184,
        -736
      ],
      "parameters": {
        "text": "=\u26a0\ufe0f 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": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "9551f85a-3dd3-413c-a5b9-155a3065611e",
      "name": "Google Sheets \u2192 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": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "af27370d-b54a-4b35-b31d-679a1889ebe8",
      "name": "Telegram \u2192 Budget Updated",
      "type": "n8n-nodes-base.telegram",
      "position": [
        1360,
        -944
      ],
      "parameters": {
        "text": "=\u2705 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": {
          "name": "<your 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": {
          "name": "<your 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`\ud83d\udcca *Daily Report* (${r.date})\n\n\ud83d\udcb0 Total budget: *${fmt(r.total_budget)}*\n\ud83d\udcb8 Total spent: *${fmt(r.total_spent)}*\n\ud83c\udfe6 Remaining: *${fmt(r.remaining)}*\n\ud83d\udcc9 Daily Avg: *${fmt(r.monthly_rate)}*${note ? `\\n\\n\ud83d\udcdd 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
      ],
      "parameters": {
        "text": "={{ $json.text }}",
        "chatId": "={{ $node[\"Telegram Trigger\"].json.message.chat.id }}",
        "additionalFields": {}
      },
      "credentials": {
        "telegramApi": {
          "name": "<your 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
      ],
      "parameters": {
        "updates": [
          "message"
        ],
        "additionalFields": {}
      },
      "credentials": {
        "telegramApi": {
          "name": "<your 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 \u2192 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 \u2192 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
      ],
      "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": "### \u26a0\ufe0f 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,
  "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 \u2192 Delete row(s)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait": {
      "main": [
        [
          {
            "node": "Data table \u2192 Get row(s)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ReportTokens": {
      "main": [
        [
          {
            "node": "Wait",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF (Budget ok?)": {
      "main": [
        [
          {
            "node": "Google Sheets \u2192 Append or update row in sheet",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Telegram \u2192 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 \u2192 Send Final Message",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Telegram \u2192 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 \u2192 JSON)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set (Photo Context)": {
      "main": [
        [
          {
            "node": "Code (Pick Best Photo)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set (Voice Context)": {
      "main": [
        [
          {
            "node": "Telegram \u2192 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 \u2192 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 \u2192 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 \u2192 Get Image File": {
      "main": [
        [
          {
            "node": "Google Gemini (Analyze Image)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Telegram \u2192 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 \u2192 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 \u2192 Append row(s)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code (Restore Telegram Payload)": {
      "main": [
        [
          {
            "node": "Switch (Voice/Photo/Text)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Google Sheets \u2192 Append row(s)": {
      "main": [
        [
          {
            "node": "Code (Schedule Report Token)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Google Gemini Chat (Text \u2192 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 \u2192 Append or update row in sheet": {
      "main": [
        [
          {
            "node": "Telegram \u2192 Budget Updated",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}